Compare commits

...

6 Commits

16 changed files with 234 additions and 108 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,13 @@ 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

@ -126,6 +126,23 @@ 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 p {
margin-block-start: 0.5em;
margin-block-end: 0.5em;
}
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.characters', 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,87 @@
from typing import Optional
import uuid
from flask import Blueprint, render_template, url_for, g, flash
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 characters(name):
return render_template('characters.jinja')
@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.characters', 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.characters', 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.characters', name=name))
else:
# POST submitted invalid data
return render_template('characters.edit.jinja', character=character, form=form)
# 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))

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,24 @@
{% extends "lexicon.jinja" %}
{% 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">
{% for character in characters %}
<li>
<h3>{{ character.name }}</h3>
<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()] %}