Implement index assignments #22

Merged
Jaculabilis merged 4 commits from tvb/turn-assignment into develop 2021-09-22 15:25:20 +00:00
13 changed files with 375 additions and 36 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

@ -87,19 +87,18 @@ def update(db: DbContext, lexicon_id: int, indices: Sequence[ArticleIndex]) -> N
"""
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
matched to a an extant index is created. Matched indices are updated with
the input logical and display orders and capacity.
Note that this scheme does not allow for an existing index to have its type
or pattern updated: such an operation will always result in the deletion of
the old index and the creation of a new index.
"""
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:
if extant_index.name == new_index.name:
match = new_index
break
if match:
@ -111,14 +110,9 @@ def update(db: DbContext, lexicon_id: int, indices: Sequence[ArticleIndex]) -> N
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:
if extant_index.name == new_index.name:
match = extant_index
break
if not match:
new_index.lexicon_id = lexicon_id
db.session.add(new_index)
db.session.commit()

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 *
@ -8,7 +7,7 @@ from .helpers import add_argument
COMMAND_NAME = "index"
COMMAND_HELP = "Interact with indexes."
COMMAND_HELP = "Interact with indices."
LOG = logging.getLogger(__name__)
@ -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

@ -248,7 +248,7 @@ class Lexicon(ModelBase):
memberships = relationship("Membership", back_populates="lexicon")
characters = relationship("Character", back_populates="lexicon")
articles = relationship("Article", back_populates="lexicon")
indexes = relationship("ArticleIndex", back_populates="lexicon")
indices = relationship("ArticleIndex", back_populates="lexicon")
index_rules = relationship("ArticleIndexRule", back_populates="lexicon")
content_rules = relationship("ArticleContentRule", back_populates="lexicon")
posts = relationship("Post", back_populates="lexicon")
@ -502,9 +502,13 @@ class ArticleIndex(ModelBase):
# Foreign key relationships #
#############################
lexicon = relationship("Lexicon", back_populates="indexes")
lexicon = relationship("Lexicon", back_populates="indices")
index_rules = relationship("ArticleIndexRule", back_populates="index")
@property
def name(self):
return f"{self.index_type}:{self.pattern}"
class ArticleIndexRule(ModelBase):
"""
@ -514,6 +518,7 @@ class ArticleIndexRule(ModelBase):
"""
__tablename__ = "article_index_rule"
__table_args__ = (UniqueConstraint("character_id", "index_id", "turn"),)
###################
# Index rule info #

View File

@ -392,7 +392,7 @@ def sort_by_index_spec(articles, index_specs, key=None):
indexed = OrderedDict()
for index in index_list_order:
indexed[index.pattern] = []
# Sort articles into indexes
# Sort articles into indices
for article in articles_titlesorted:
for index in index_eval_order:
if index_match(index, key(article)):

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=".")
@ -159,9 +164,16 @@ def index_post(lexicon_name):
# Initialize the form
form = IndexSchemaForm()
if form.validate():
# Valid data, strip out all indexes with the blank type
# Valid data, strip out all indices with the blank type
indices = [
index_def.to_model()
ArticleIndex(
lexicon_id=current_lexicon.id,
index_type=index_def.index_type.data,
pattern=index_def.pattern.data,
logical_order=index_def.logical_order.data,
display_order=index_def.display_order.data,
capacity=index_def.capacity.data,
)
for index_def in form.indices.entries
if index_def.index_type.data
]
@ -177,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):
@ -80,18 +82,47 @@ class IndexDefinitionForm(FlaskForm):
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")
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

@ -4,5 +4,6 @@ pkgs.mkShell {
buildInputs = [
pkgs.python3
pkgs.poetry
pkgs.sqlite
];
}

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)