diff --git a/amanuensis/backend/character.py b/amanuensis/backend/character.py index 01f5804..49e51a1 100644 --- a/amanuensis/backend/character.py +++ b/amanuensis/backend/character.py @@ -2,7 +2,8 @@ Character query interface """ -from typing import Optional +from typing import Optional, Sequence +from uuid import UUID from sqlalchemy import select, func @@ -68,3 +69,15 @@ def create( db.session.add(new_character) db.session.commit() 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() diff --git a/amanuensis/backend/membership.py b/amanuensis/backend/membership.py index dff50e2..2dd08b8 100644 --- a/amanuensis/backend/membership.py +++ b/amanuensis/backend/membership.py @@ -2,6 +2,8 @@ Membership query interface """ +from typing import Sequence + from sqlalchemy import select, func from amanuensis.db import DbContext, Membership @@ -66,6 +68,11 @@ def create( 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: """Get a membership by the user and lexicon ids, or None if no such membership was found.""" return db( diff --git a/amanuensis/cli/__init__.py b/amanuensis/cli/__init__.py index eb9e111..f1d0807 100644 --- a/amanuensis/cli/__init__.py +++ b/amanuensis/cli/__init__.py @@ -5,6 +5,7 @@ import os from typing import Callable import amanuensis.cli.admin +import amanuensis.cli.character import amanuensis.cli.lexicon import amanuensis.cli.user from amanuensis.db import DbContext @@ -50,7 +51,7 @@ def add_subcommand(subparsers, module) -> None: command_parser: ArgumentParser = subparsers.add_parser( 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 subcommands = command_parser.add_subparsers(metavar="SUBCOMMAND") @@ -97,7 +98,7 @@ def main(): parser = ArgumentParser() parser.set_defaults( parser=parser, - func=lambda args: parser.print_usage(), + func=lambda args: parser.print_help(), get_db=None, ) parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output") @@ -108,6 +109,7 @@ def main(): # Add commands from cli submodules subparsers = parser.add_subparsers(metavar="COMMAND") add_subcommand(subparsers, amanuensis.cli.admin) + add_subcommand(subparsers, amanuensis.cli.character) add_subcommand(subparsers, amanuensis.cli.lexicon) add_subcommand(subparsers, amanuensis.cli.user) diff --git a/amanuensis/cli/character.py b/amanuensis/cli/character.py new file mode 100644 index 0000000..f49fe61 --- /dev/null +++ b/amanuensis/cli/character.py @@ -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 diff --git a/amanuensis/cli/lexicon.py b/amanuensis/cli/lexicon.py index 0c0e3c1..c3ea481 100644 --- a/amanuensis/cli/lexicon.py +++ b/amanuensis/cli/lexicon.py @@ -49,6 +49,7 @@ def command_create(args): @add_argument("--no-public", dest="public", action="store_const", const=False) @add_argument("--join", dest="join", action="store_const", const=True) @add_argument("--no-join", dest="join", action="store_const", const=False) +@add_argument("--char-limit", type=int, default=None) def command_edit(args): """ Update a lexicon's configuration. @@ -66,6 +67,9 @@ def command_edit(args): elif args.join == 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)) LOG.info(f"Updated {result.rowcount} lexicons") db.session.commit() diff --git a/amanuensis/db/models.py b/amanuensis/db/models.py index 5f7a600..2e63c4c 100644 --- a/amanuensis/db/models.py +++ b/amanuensis/db/models.py @@ -30,6 +30,7 @@ class Uuid(TypeDecorator): """ impl = CHAR(32) + cache_ok = True def process_bind_param(self, value, dialect): if value is None: diff --git a/amanuensis/resources/page.css b/amanuensis/resources/page.css index b22db7b..536b854 100644 --- a/amanuensis/resources/page.css +++ b/amanuensis/resources/page.css @@ -95,9 +95,6 @@ div.contentblock { border-radius: 5px; word-break: break-word; } -div.contentblock h3 { - margin: 0.3em 0; -} a.phantom { color: #cc2200; } @@ -126,6 +123,34 @@ div.dashboard-lexicon-item { padding: 0 10px; 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 { border-left-color: blue; } diff --git a/amanuensis/server/__init__.py b/amanuensis/server/__init__.py index 5e845f8..8f5c842 100644 --- a/amanuensis/server/__init__.py +++ b/amanuensis/server/__init__.py @@ -4,7 +4,7 @@ import os 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.db import DbContext from amanuensis.parser import filesafe_title @@ -68,7 +68,7 @@ def get_app( # Configure jinja options 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.template_filter("date")(date_format) diff --git a/amanuensis/server/helpers.py b/amanuensis/server/helpers.py index c68c190..434dcb3 100644 --- a/amanuensis/server/helpers.py +++ b/amanuensis/server/helpers.py @@ -88,7 +88,7 @@ def editor_required(route): user: User = current_user lexicon: Lexicon = g.lexicon 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") return redirect(url_for('lexicon.contents', name=lexicon.name)) return route(*args, **kwargs) diff --git a/amanuensis/server/lexicon.jinja b/amanuensis/server/lexicon.jinja index 9c976eb..70862b4 100644 --- a/amanuensis/server/lexicon.jinja +++ b/amanuensis/server/lexicon.jinja @@ -7,8 +7,10 @@ {% endblock %} {% block sb_logo %}{% endblock %} -{% block sb_home %}Home -{% endblock %} +{% block sb_characters %}Characters{% endblock %} {% block sb_contents %}", 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) + ) diff --git a/amanuensis/server/lexicon/characters/characters.edit.jinja b/amanuensis/server/lexicon/characters/characters.edit.jinja new file mode 100644 index 0000000..b9693d7 --- /dev/null +++ b/amanuensis/server/lexicon/characters/characters.edit.jinja @@ -0,0 +1,24 @@ +{% extends "lexicon.jinja" %} +{% block title %}Edit {{ character.name }} | {{ lexicon_title }}{% endblock %} + +{% block main %} +
+ {{ form.hidden_tag() }} +

+ {{ form.name.label }}
{{ form.name(size=32) }} +

+ {% for error in form.name.errors %} + {{ error }}
+ {% endfor %}

+

+ {{ form.signature.label }}
{{ form.signature(class_='fullwidth') }} +

+

{{ form.submit() }}

+
+ +{% for message in get_flashed_messages() %} +{{ message }}
+{% endfor %} + +{% endblock %} +{% set template_content_blocks = [self.main()] %} \ No newline at end of file diff --git a/amanuensis/server/lexicon/characters/characters.jinja b/amanuensis/server/lexicon/characters/characters.jinja new file mode 100644 index 0000000..2d0a31c --- /dev/null +++ b/amanuensis/server/lexicon/characters/characters.jinja @@ -0,0 +1,34 @@ +{% extends "lexicon.jinja" %} +{% set current_page = "characters" %} +{% block title %}Character | {{ lexicon_title }}{% endblock %} + +{% block main %} +

Characters

+{% set players = memq.get_players_in_lexicon(db, g.lexicon.id)|list %} +{% set characters = charq.get_in_lexicon(db, g.lexicon.id)|list %} +

This lexicon has {{ players|count }} player{% if players|count > 1 %}s{% endif %} and {{ characters|count }} character{% if characters|count > 1 %}s{% endif %}.

+{% for message in get_flashed_messages() %} +{{ message }}
+{% endfor %} +
+{% endblock %} +{% set template_content_blocks = [self.main()] %} \ No newline at end of file diff --git a/amanuensis/server/lexicon/characters/forms.py b/amanuensis/server/lexicon/characters/forms.py new file mode 100644 index 0000000..a06edd8 --- /dev/null +++ b/amanuensis/server/lexicon/characters/forms.py @@ -0,0 +1,11 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, SubmitField, TextAreaField +from wtforms.validators import DataRequired + + +class CharacterCreateForm(FlaskForm): + """/lexicon//characters/edit/""" + + name = StringField("Character name", validators=[DataRequired()]) + signature = TextAreaField("Signature") + submit = SubmitField("Submit") diff --git a/amanuensis/server/page.jinja b/amanuensis/server/page.jinja index 4cf7e36..5b45466 100644 --- a/amanuensis/server/page.jinja +++ b/amanuensis/server/page.jinja @@ -13,8 +13,13 @@
{% if current_user.is_authenticated %} {{ current_user.username -}} - (Logout) + ‧ + Home + ‧ + Logout {% else %} + Home + ‧ Login {% endif %}
diff --git a/amanuensis/server/session/__init__.py b/amanuensis/server/session/__init__.py index 743754d..2f1fdff 100644 --- a/amanuensis/server/session/__init__.py +++ b/amanuensis/server/session/__init__.py @@ -68,77 +68,6 @@ def session(name): 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']) @lexicon_param @editor_required diff --git a/amanuensis/server/session/session.character.jinja b/amanuensis/server/session/session.character.jinja deleted file mode 100644 index 81b3036..0000000 --- a/amanuensis/server/session/session.character.jinja +++ /dev/null @@ -1,26 +0,0 @@ -{% extends "lexicon.jinja" %} -{% block title %}Character | {{ lexicon_title }}{% endblock %} - -{% block main %} - -

Character

-
- {{ form.hidden_tag() }} -

- {{ form.characterName.label }}
{{ form.characterName(size=32) }} -

- {% for error in form.characterName.errors %} - {{ error }}
- {% endfor %}

-

- {{ form.defaultSignature.label }}
{{ form.defaultSignature(class_='fullwidth') }} -

-

{{ form.submit() }}

-
- -{% for message in get_flashed_messages() %} -{{ message }}
-{% endfor %} - -{% endblock %} -{% set template_content_blocks = [self.main()] %} \ No newline at end of file diff --git a/tests/test_character.py b/tests/test_character.py new file mode 100644 index 0000000..ccd5b51 --- /dev/null +++ b/tests/test_character.py @@ -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