Implement index assignments

The index assignment UI uses the same table-form pattern as the index
UI. There is currently no control over deleting indices when an index
assignment exists on that index.
This commit is contained in:
Tim Van Baak 2021-09-20 14:50:37 -07:00
parent a91be8bc87
commit 0d022af335
10 changed files with 351 additions and 9 deletions

View File

@ -1,9 +1,10 @@
import amanuensis.backend.article as artiq
import amanuensis.backend.character as charq
import amanuensis.backend.index as indq
import amanuensis.backend.indexrule as irq
import amanuensis.backend.lexicon as lexiq
import amanuensis.backend.membership as memq
import amanuensis.backend.post as postq
import amanuensis.backend.user as userq
__all__ = ["artiq", "charq", "indq", "lexiq", "memq", "postq", "userq"]
__all__ = ["artiq", "charq", "indq", "irq", "lexiq", "memq", "postq", "userq"]

View File

@ -0,0 +1,100 @@
"""
Index rule query interface
"""
from typing import Sequence
from sqlalchemy import select
from amanuensis.db import *
from amanuensis.errors import ArgumentError, BackendArgumentTypeError
def create(
db: DbContext,
lexicon_id: int,
character_id: int,
index_id: int,
turn: int,
) -> ArticleIndexRule:
"""Create an index assignment."""
# Verify argument types are correct
if not isinstance(lexicon_id, int):
raise BackendArgumentTypeError(int, lexicon_id=lexicon_id)
if character_id is not None and not isinstance(character_id, int):
raise BackendArgumentTypeError(int, character_id=character_id)
if not isinstance(index_id, int):
raise BackendArgumentTypeError(int, index_id=index_id)
if not isinstance(turn, int):
raise BackendArgumentTypeError(int, turn=turn)
# Verify the character belongs to the lexicon
character: Character = db(
select(Character).where(Character.id == character_id)
).scalar_one_or_none()
if not character:
raise ArgumentError("Character does not exist")
if character.lexicon_id != lexicon_id:
raise ArgumentError("Character belongs to the wrong lexicon")
# Verify the index belongs to the lexicon
index: ArticleIndex = db(
select(ArticleIndex).where(ArticleIndex.id == index_id)
).scalar_one_or_none()
if not index:
raise ArgumentError("Index does not exist")
if index.lexicon_id != lexicon_id:
raise ArgumentError("Index belongs to the wrong lexicon")
new_assignment: ArticleIndexRule = ArticleIndexRule(
lexicon_id=lexicon_id,
character_id=character_id,
index_id=index_id,
turn=turn,
)
db.session.add(new_assignment)
db.session.commit()
return new_assignment
def get_for_lexicon(db: DbContext, lexicon_id: int) -> Sequence[ArticleIndex]:
"""Returns all index rules for a lexicon."""
return db(
select(ArticleIndexRule)
.join(ArticleIndexRule.index)
.join(ArticleIndexRule.character)
.where(ArticleIndexRule.lexicon_id == lexicon_id)
.order_by(ArticleIndexRule.turn, ArticleIndex.pattern, Character.name)
).scalars()
def update(db: DbContext, lexicon_id: int, rules: Sequence[ArticleIndexRule]) -> None:
"""
Update the index assignments for a lexicon. An index assignment is a tuple
of turn, index, and character. Unlike indices themselves, assignments have
no other attributes that can be updated, so they are simply created or
deleted based on their presence or absence in the desired rule list.
"""
print(rules)
extant_rules: Sequence[ArticleIndexRule] = list(get_for_lexicon(db, lexicon_id))
for extant_rule in extant_rules:
if not any(
[
extant_rule.character_id == new_rule.character_id
and extant_rule.index_id == new_rule.index_id
and extant_rule.turn == new_rule.turn
for new_rule in rules
]
):
db.session.delete(extant_rule)
for new_rule in rules:
if not any(
[
extant_rule.character_id == new_rule.character_id
and extant_rule.index_id == new_rule.index_id
and extant_rule.turn == new_rule.turn
for extant_rule in extant_rules
]
):
db.session.add(new_rule)
db.session.commit()

View File

@ -1,4 +1,3 @@
import enum
import logging
from amanuensis.backend import *
@ -40,3 +39,26 @@ def command_create(args) -> int:
)
LOG.info(f"Created {index.index_type}:{index.pattern} in {lexicon.full_title}")
return 0
@add_argument("--lexicon", required=True, help="The lexicon's name")
@add_argument("--character", help="The character's public id")
@add_argument("--index", required=True, help="The index pattern")
@add_argument("--turn", required=True, type=int)
def command_assign(args) -> int:
"""
Create a turn assignment 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")
char = charq.try_from_public_id(db, args.character)
assert char
indices = indq.get_for_lexicon(db, lexicon.id)
index = [i for i in indices if i.pattern == args.index]
if not index:
raise ValueError("Index not found")
assignment = irq.create(db, lexicon.id, char.id, index[0].id, args.turn)
LOG.info("Created")
return 0

View File

@ -518,6 +518,7 @@ class ArticleIndexRule(ModelBase):
"""
__tablename__ = "article_index_rule"
__table_args__ = (UniqueConstraint("character_id", "index_id", "turn"),)
###################
# Index rule info #

View File

@ -201,7 +201,7 @@ ul.unordered-tabs li a[href]:hover {
background-color: var(--button-hover);
border-color: var(--button-hover);
}
#index-definition-help {
details.setting-help {
margin-block-start: 1em;
margin-block-end: 1em;
}

View File

@ -12,7 +12,12 @@ from amanuensis.server.helpers import (
current_lexicon,
)
from .forms import PlayerSettingsForm, SetupSettingsForm, IndexSchemaForm
from .forms import (
PlayerSettingsForm,
SetupSettingsForm,
IndexSchemaForm,
IndexAssignmentsForm,
)
bp = Blueprint("settings", __name__, url_prefix="/settings", template_folder=".")
@ -167,7 +172,7 @@ def index_post(lexicon_name):
pattern=index_def.pattern.data,
logical_order=index_def.logical_order.data,
display_order=index_def.display_order.data,
capacity=index_def.capacity.data
capacity=index_def.capacity.data,
)
for index_def in form.indices.entries
if index_def.index_type.data
@ -184,6 +189,89 @@ def index_post(lexicon_name):
)
@bp.get("/assign/")
@lexicon_param
@editor_required
def assign(lexicon_name):
# Get the current assignments
rules: Sequence[ArticleIndexRule] = list(
irq.get_for_lexicon(g.db, current_lexicon.id)
)
rule_data = [
{
"turn": rule.turn,
"index": rule.index.name,
"character": str(rule.character.public_id),
}
for rule in rules
]
# Add a blank rule to allow for adding rules
rule_data.append(
{
"turn": 0,
"index": "",
"character": "",
}
)
form = IndexAssignmentsForm(rules=rule_data)
form.populate(current_lexicon)
return render_template(
"settings.jinja",
lexicon_name=lexicon_name,
page_name=assign.__name__,
form=form,
)
@bp.post("/assign/")
@lexicon_param
@editor_required
def assign_post(lexicon_name):
# Initialize the form
form = IndexAssignmentsForm()
form.populate(current_lexicon)
if form.validate():
# Valid data
indices = list(current_lexicon.indices)
characters = list(current_lexicon.characters)
rules = []
for rule_def in form.rules.entries:
# Strip out all assignments with no character
if not rule_def.character.data:
continue
# Look up the necessary ids from the public representations
character = [
c for c in characters if c.public_id == rule_def.character.data
]
if not character:
return redirect(
url_for("lexicon.settings.assign", lexicon_name=lexicon_name)
)
index = [i for i in indices if i.name == rule_def.index.data]
if not index:
return redirect(
url_for("lexicon.settings.assign", lexicon_name=lexicon_name)
)
rules.append(
ArticleIndexRule(
lexicon_id=current_lexicon.id,
character_id=character[0].id,
index_id=index[0].id,
turn=rule_def.turn.data,
)
)
irq.update(g.db, current_lexicon.id, rules)
return redirect(url_for("lexicon.settings.assign", lexicon_name=lexicon_name))
else:
# Invalid data
return render_template(
"settings.jinja",
lexicon_name=lexicon_name,
page_name=assign.__name__,
form=form,
)
@bp.get("/publish/")
@lexicon_param
@editor_required

View File

@ -1,3 +1,5 @@
import uuid
from flask_wtf import FlaskForm
from wtforms import (
BooleanField,
@ -13,7 +15,7 @@ from wtforms import (
from wtforms.validators import Optional, DataRequired, ValidationError
from wtforms.widgets.html5 import NumberInput
from amanuensis.db import ArticleIndex, IndexType
from amanuensis.db import IndexType, Lexicon
class PlayerSettingsForm(FlaskForm):
@ -86,3 +88,41 @@ class IndexSchemaForm(FlaskForm):
indices = FieldList(FormField(IndexDefinitionForm))
submit = SubmitField("Submit")
def parse_uuid(uuid_str):
if not uuid_str:
return None
return uuid.UUID(uuid_str)
class AssignmentDefinitionForm(FlaskForm):
"""/lexicon/<name>/settings/assign/"""
class Meta:
# Disable CSRF on the individual assignment definitions, since the
# schema form will have one
csrf = False
turn = IntegerField(widget=NumberInput(min=0, max=99))
index = SelectField()
character = SelectField(coerce=parse_uuid)
class IndexAssignmentsForm(FlaskForm):
"""/lexicon/<name>/settings/assign/"""
rules = FieldList(FormField(AssignmentDefinitionForm))
submit = SubmitField("Submit")
def populate(self, lexicon: Lexicon):
"""Populate the select fields with indices and characters"""
index_choices = []
for i in lexicon.indices:
index_choices.append((i.name, i.pattern))
char_choices = [("", "")]
for c in lexicon.characters:
char_choices.append((str(c.public_id), c.name))
for rule in self.rules:
rule.index.choices = index_choices
rule.character.choices = char_choices

View File

@ -24,6 +24,7 @@
<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("assign", "Index Assignments") }}</li>
<li>{{ settings_page_link("publish", "Turn Publishing") }}</li>
<li>{{ settings_page_link("article", "Article Requirements") }}</li>
</ul>
@ -86,7 +87,7 @@
{% if page_name == "index" %}
<h3>Article Indexes</h3>
<details id="index-definition-help">
<details class="setting-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>
@ -128,6 +129,42 @@
{% endfor %}
{% endif %}
{% if page_name == "assign" %}
<h3>Index Assignments</h3>
<details class="setting-help">
<summary>Index assignment help</summary>
<p>An index assignment is a rule that requires a player to write an article under certain indices for a particular turn. If more than one rule applies to a player, any index satisfying one of those rules is permitted. If no rule applies to a player, any index is permitted.</p>
</details>
<form action="" method="post" novalidate>
{{ form.hidden_tag() }}
<table id="index-definition-table2">
<tr>
<th>Turn</th>
<th>Index</th>
<th>Character</th>
</tr>
{% for rule_form in form.rules %}
<tr>
<td>{{ rule_form.turn() }}</td>
<td>{{ rule_form.index() }}</td>
<td>{{ rule_form.character() }}</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 %}

View File

@ -0,0 +1,40 @@
import pytest
from amanuensis.backend import irq
from amanuensis.db import *
from amanuensis.errors import ArgumentError
from tests.conftest import ObjectFactory
def test_create_assign(db: DbContext, make: ObjectFactory):
"""Test new index assignment creation"""
lexicon: Lexicon = make.lexicon()
user: User = make.user()
mem: Membership = make.membership(lexicon_id=lexicon.id, user_id=user.id)
char: Character = make.character(lexicon_id=lexicon.id, user_id=user.id)
ind1: ArticleIndex = make.index(lexicon_id=lexicon.id)
defaults: dict = {
"db": db,
"lexicon_id": lexicon.id,
"character_id": char.id,
"index_id": ind1.id,
"turn": 1,
}
kwargs: dict
# Index assignments must key to objects in the same lexicon
lexicon2: Lexicon = make.lexicon()
mem2: Membership = make.membership(lexicon_id=lexicon2.id, user_id=user.id)
char2: Character = make.character(lexicon_id=lexicon2.id, user_id=user.id)
ind2: ArticleIndex = make.index(lexicon_id=lexicon2.id)
with pytest.raises(ArgumentError):
kwargs = {**defaults, "index_id": ind2.id}
irq.create(**kwargs)
with pytest.raises(ArgumentError):
kwargs = {**defaults, "character_id": char2.id, "index_id": ind2.id}
irq.create(**kwargs)
with pytest.raises(ArgumentError):
kwargs = {**defaults, "character_id": char2.id}
irq.create(**kwargs)

View File

@ -10,9 +10,9 @@ from bs4 import BeautifulSoup
from flask.testing import FlaskClient
from sqlalchemy.orm.session import close_all_sessions
from amanuensis.backend import charq, lexiq, memq, userq
from amanuensis.backend import *
from amanuensis.config import AmanuensisConfig
from amanuensis.db import DbContext, User, Lexicon, Membership, Character
from amanuensis.db import *
from amanuensis.server import get_app
@ -122,6 +122,19 @@ class ObjectFactory:
updated_kwargs: dict = {**default_kwargs, **kwargs}
return charq.create(self.db, **updated_kwargs)
def index(self, state={"nonce": ord("A")}, **kwargs) -> ArticleIndex:
"""Factory function for creating indices, with valid defaut values."""
default_kwargs: dict = {
"index_type": IndexType.CHAR,
"pattern": chr(state["nonce"]),
"logical_order": 0,
"display_order": 0,
"capacity": None,
}
state["nonce"] += 1
updated_kwargs = {**default_kwargs, **kwargs}
return indq.create(self.db, **updated_kwargs)
def client(self, user_id: int) -> UserClient:
"""Factory function for user test clients."""
return UserClient(self.db, user_id)