Add index settings page

Unlike the player and setup settings, the form here has a variable
number of inputs, so we use a blank row to allow expanding the index
set and allow deleting by clearing out the index type
This commit is contained in:
Tim Van Baak 2021-09-06 22:01:08 -07:00
parent 03c0b4ce70
commit e353ac9b93
9 changed files with 313 additions and 20 deletions

View File

@ -3,7 +3,9 @@ Index query interface
""" """
import re import re
from typing import Optional from typing import Optional, Sequence
from sqlalchemy import select
from amanuensis.db import DbContext, ArticleIndex, IndexType from amanuensis.db import DbContext, ArticleIndex, IndexType
from amanuensis.errors import ArgumentError, BackendArgumentTypeError from amanuensis.errors import ArgumentError, BackendArgumentTypeError
@ -72,3 +74,52 @@ def create(
db.session.add(new_index) db.session.add(new_index)
db.session.commit() db.session.commit()
return new_index return new_index
def get_for_lexicon(db: DbContext, lexicon_id: int) -> Sequence[ArticleIndex]:
"""Returns all index rules for a lexicon."""
return db(
select(ArticleIndex).where(ArticleIndex.lexicon_id == lexicon_id)
).scalars()
def update(db: DbContext, lexicon_id: int, indices: Sequence[ArticleIndex]) -> None:
"""
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
the input logical and display orders and capacity.
"""
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:
match = new_index
break
if match:
extant_index.logical_order = new_index.logical_order
extant_index.display_order = new_index.display_order
extant_index.capacity = new_index.capacity
else:
db.session.delete(extant_index)
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:
match = extant_index
break
if not match:
new_index.lexicon_id = lexicon_id
db.session.add(new_index)
db.session.commit()

View File

@ -473,6 +473,7 @@ class ArticleIndex(ModelBase):
""" """
__tablename__ = "article_index" __tablename__ = "article_index"
__table_args__ = (UniqueConstraint("lexicon_id", "index_type", "pattern"),)
############## ##############
# Index info # # Index info #

View File

@ -46,11 +46,8 @@ div#sidebar {
img#logo { img#logo {
max-width: 200px; max-width: 200px;
} }
table {
table-layout: fixed;
width: 100%;
}
div#sidebar table { div#sidebar table {
width: 100%;
border-collapse: collapse; border-collapse: collapse;
} }
div.citeblock table td:first-child + td a { div.citeblock table td:first-child + td a {
@ -118,9 +115,6 @@ input.fullwidth {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
input.smallnumber {
width: 4em;
}
form#session-settings p { form#session-settings p {
line-height: 1.8em; line-height: 1.8em;
} }
@ -207,6 +201,20 @@ ul.unordered-tabs li a[href]:hover {
background-color: var(--button-hover); background-color: var(--button-hover);
border-color: var(--button-hover); border-color: var(--button-hover);
} }
#index-definition-help {
margin-block-start: 1em;
margin-block-end: 1em;
}
#index-definition-table td:nth-child(2) {
width: 100%;
}
#index-definition-table td:nth-child(2) *:only-child {
box-sizing: border-box;
width: 100%;
}
#index-definition-table td input[type=number] {
width: 4em;
}
@media only screen and (max-width: 816px) { @media only screen and (max-width: 816px) {
div#wrapper { div#wrapper {
padding: 5px; padding: 5px;

View File

@ -75,6 +75,7 @@ def get_app(
"userq": userq, "userq": userq,
"memq": memq, "memq": memq,
"charq": charq, "charq": charq,
"indq": indq,
"current_lexicon": current_lexicon, "current_lexicon": current_lexicon,
"current_membership": current_membership "current_membership": current_membership
} }

View File

@ -1,3 +1,5 @@
from typing import Sequence
from flask import Blueprint, render_template, url_for, g, flash, redirect from flask import Blueprint, render_template, url_for, g, flash, redirect
from amanuensis.backend import * from amanuensis.backend import *
@ -10,7 +12,7 @@ from amanuensis.server.helpers import (
current_lexicon, current_lexicon,
) )
from .forms import PlayerSettingsForm, SetupSettingsForm from .forms import PlayerSettingsForm, SetupSettingsForm, IndexSchemaForm
bp = Blueprint("settings", __name__, url_prefix="/settings", template_folder=".") bp = Blueprint("settings", __name__, url_prefix="/settings", template_folder=".")
@ -118,12 +120,60 @@ def setup(lexicon_name):
) )
@bp.get("/progress/") @bp.get("/index/")
@lexicon_param @lexicon_param
@editor_required @editor_required
def progress(lexicon_name): def index(lexicon_name):
# Get the current indices
indices: Sequence[ArticleIndex] = indq.get_for_lexicon(g.db, current_lexicon.id)
index_data = [
{
"index_type": str(index.index_type),
"pattern": index.pattern,
"logical_order": index.logical_order,
"display_order": index.display_order,
"capacity": index.capacity,
}
for index in indices
]
# Add a blank index to allow for adding rules
index_data.append(
{
"index_type": "",
"pattern": None,
"logical_order": None,
"display_order": None,
"capacity": None,
}
)
form = IndexSchemaForm(indices=index_data)
return render_template( return render_template(
"settings.jinja", lexicon_name=lexicon_name, page_name=progress.__name__ "settings.jinja", lexicon_name=lexicon_name, page_name=index.__name__, form=form
)
@bp.post("/index/")
@lexicon_param
@editor_required
def index_post(lexicon_name):
# Initialize the form
form = IndexSchemaForm()
if form.validate():
# Valid data, strip out all indexes with the blank type
indices = [
index_def.to_model()
for index_def in form.indices.entries
if index_def.index_type.data
]
indq.update(g.db, current_lexicon.id, indices)
return redirect(url_for("lexicon.settings.index", lexicon_name=lexicon_name))
else:
# Invalid data
return render_template(
"settings.jinja",
lexicon_name=lexicon_name,
page_name=index.__name__,
form=form,
) )

View File

@ -1,15 +1,20 @@
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import ( from wtforms import (
BooleanField, BooleanField,
FieldList,
FormField,
IntegerField, IntegerField,
PasswordField, PasswordField,
SelectField,
StringField, StringField,
SubmitField, SubmitField,
TextAreaField, TextAreaField,
) )
from wtforms.validators import Optional, DataRequired from wtforms.validators import Optional, DataRequired, ValidationError
from wtforms.widgets.html5 import NumberInput from wtforms.widgets.html5 import NumberInput
from amanuensis.db import ArticleIndex, IndexType
class PlayerSettingsForm(FlaskForm): class PlayerSettingsForm(FlaskForm):
"""/lexicon/<name>/settings/player/""" """/lexicon/<name>/settings/player/"""
@ -43,3 +48,50 @@ class SetupSettingsForm(FlaskForm):
validators=[Optional()], validators=[Optional()],
) )
submit = SubmitField("Submit") submit = SubmitField("Submit")
def parse_index_type(type_str):
if not type_str:
return None
return getattr(IndexType, type_str)
class IndexDefinitionForm(FlaskForm):
"""/lexicon/<name>/settings/index/"""
class Meta:
# Disable CSRF on the individual index definitions, since the schema
# form will have one
csrf = False
TYPE_CHOICES = [("", "")] + [(str(t), str(t).lower()) for t in IndexType]
index_type = SelectField(choices=TYPE_CHOICES, coerce=parse_index_type)
pattern = StringField()
logical_order = IntegerField(
widget=NumberInput(min=-99, max=99), validators=[Optional()]
)
display_order = IntegerField(
widget=NumberInput(min=-99, max=99), validators=[Optional()]
)
capacity = IntegerField(widget=NumberInput(min=0, max=99), validators=[Optional()])
def validate_pattern(form, field):
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")

View File

@ -21,9 +21,9 @@
{% block main %} {% block main %}
{% if current_membership.is_editor %} {% if current_membership.is_editor %}
<ul class="unordered-tabs"> <ul class="unordered-tabs">
<li>{{ settings_page_link("player", "Player") }}</li> <li>{{ settings_page_link("player", "Player Settings") }}</li>
<li>{{ settings_page_link("setup", "Game Setup") }}</li> <li>{{ settings_page_link("setup", "Game Setup") }}</li>
<li>{{ settings_page_link("progress", "Game Progress") }}</li> <li>{{ settings_page_link("index", "Article Indices") }}</li>
<li>{{ settings_page_link("publish", "Turn Publishing") }}</li> <li>{{ settings_page_link("publish", "Turn Publishing") }}</li>
<li>{{ settings_page_link("article", "Article Requirements") }}</li> <li>{{ settings_page_link("article", "Article Requirements") }}</li>
</ul> </ul>
@ -31,6 +31,7 @@
{% if page_name == "player" %} {% if page_name == "player" %}
<h3>Player Settings</h3> <h3>Player Settings</h3>
<p>These settings are specific to you as a player in this lexicon.</p>
<form action="" method="post" novalidate> <form action="" method="post" novalidate>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<p> <p>
@ -83,8 +84,48 @@
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if page_name == "progress" %} {% if page_name == "index" %}
<h3>Game Progress</h3> <h3>Article Indexes</h3>
<details id="index-definition-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>
<p>On the contents page, indices and the articles under them are displayed sorted instead by <em>display order</em> and then alphabetically by pattern.</p>
<p>The <em>capacity</em> of an index is the number of articles that may exist under that index. If an index is at capacity, no new articles may be written or created via phantom citation in that index.</p>
<p>To add an index, fill in the type and pattern in the blank row and save your changes. To remove an index, set the type to blank. Note: If you change the type or pattern of an index, all index assignments will be reset. Avoid changing index definitions during gameplay.</p>
</details>
<form action="" method="post" novalidate>
{{ form.hidden_tag() }}
<table id="index-definition-table">
<tr>
<th>Type</th>
<th>Pattern</th>
<th>Disp Or</th>
<th>Log Or</th>
<th>Cap</th>
</tr>
{% for index_form in form.indices %}
<tr>
<td>{{ index_form.index_type() }}</td>
<td>{{ index_form.pattern() }}</td>
<td>{{ index_form.logical_order() }}</td>
<td>{{ index_form.display_order() }}</td>
<td>{{ index_form.capacity() }}</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 %} {% endif %}
{% if page_name == "publish" %} {% if page_name == "publish" %}

View File

@ -15,7 +15,6 @@ def test_character_view(db: DbContext, app: Flask, make: ObjectFactory):
username: str = f"user_{os.urandom(8).hex()}" username: str = f"user_{os.urandom(8).hex()}"
charname: str = f"char_{os.urandom(8).hex()}" charname: str = f"char_{os.urandom(8).hex()}"
char_sig: str = f"signature_{os.urandom(8).hex()}" char_sig: str = f"signature_{os.urandom(8).hex()}"
# ub: bytes = username.encode("utf8")
with app.test_client() as client: with app.test_client() as client:
# Create the user and log in # Create the user and log in
@ -63,7 +62,6 @@ def test_character_view(db: DbContext, app: Flask, make: ObjectFactory):
created_redirect, created_redirect,
data={"name": charname, "signature": char_sig, "csrf_token": csrf_token}, data={"name": charname, "signature": char_sig, "csrf_token": csrf_token},
) )
print(response.data.decode("utf8"))
assert 300 <= response.status_code <= 399 assert 300 <= response.status_code <= 399
# The character is updated # The character is updated

91
tests/test_index.py Normal file
View File

@ -0,0 +1,91 @@
from amanuensis.db.models import IndexType
import os
from urllib.parse import urlsplit
from bs4 import BeautifulSoup
from flask import Flask, url_for
from amanuensis.backend import memq, charq, indq
from amanuensis.db import DbContext
from tests.conftest import ObjectFactory
def test_index_view(db: DbContext, app: Flask, make: ObjectFactory):
"""Test the lexicon index page"""
with app.test_client() as client:
# Create the user and log in
user = make.user()
assert user
user_client = make.client(user.id)
assert client
user_client.login(client)
# Create a lexicon and join as the editor
lexicon = make.lexicon()
assert lexicon
mem = memq.create(db, user.id, lexicon.id, is_editor=True)
assert mem
# The index settings page exists
index_settings = url_for("lexicon.settings.index", lexicon_name=lexicon.name)
response = client.get(index_settings)
assert response.status_code == 200
# Add some indices
i1 = indq.create(db, lexicon.id, IndexType.CHAR, "ABCDE", 0, 0, 0)
assert i1
p1 = i1.pattern
assert p1
i2 = indq.create(db, lexicon.id, IndexType.RANGE, "F-M", 0, 0, 0)
assert i2
p2 = i2.pattern
assert p2
i3 = indq.create(db, lexicon.id, IndexType.CHAR, "NOPQ", 0, 0, 0)
assert i3
p3 = i3.pattern
assert p3
db.session.commit()
# The index settings page shows the indices
response = client.get(index_settings)
assert response.status_code == 200
# for i in indq.get_for_lexicon(db, lexicon.id):
assert p1.encode("utf8") in response.data
assert p2.encode("utf8") in response.data
assert p3.encode("utf8") in response.data
# Indices can be modified
soup = BeautifulSoup(response.data, features="html.parser")
csrf_token = soup.find(id="csrf_token")["value"]
assert csrf_token
response = client.post(
index_settings,
data={
"csrf_token": csrf_token,
"indices-0-index_type": "CHAR",
"indices-0-pattern": "ABCDEF",
"indices-0-logical_order": 0,
"indices-0-display_order": 0,
"indices-0-capacity": "",
"indices-1-index_type": "PREFIX",
"indices-1-pattern": "F-M",
"indices-1-logical_order": 1,
"indices-1-display_order": -1,
"indices-1-capacity": "",
"indices-2-index_type": "",
"indices-2-pattern": "NOPQ",
"indices-2-logical_order": 0,
"indices-2-display_order": 0,
"indices-2-capacity": "",
},
)
assert 300 <= response.status_code <= 399
updated_indices = list(indq.get_for_lexicon(db, lexicon.id))
assert len(updated_indices) == 2
assert updated_indices[0].index_type == IndexType.CHAR
assert updated_indices[0].pattern == "ABCDEF"
assert updated_indices[1].index_type == IndexType.PREFIX
assert updated_indices[1].pattern == "F-M"