Add settings page and basic settings #20
@ -3,7 +3,9 @@ Index query interface
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from amanuensis.db import DbContext, ArticleIndex, IndexType
|
||||
from amanuensis.errors import ArgumentError, BackendArgumentTypeError
|
||||
@ -72,3 +74,52 @@ def create(
|
||||
db.session.add(new_index)
|
||||
db.session.commit()
|
||||
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()
|
||||
|
@ -473,6 +473,7 @@ class ArticleIndex(ModelBase):
|
||||
"""
|
||||
|
||||
__tablename__ = "article_index"
|
||||
__table_args__ = (UniqueConstraint("lexicon_id", "index_type", "pattern"),)
|
||||
|
||||
##############
|
||||
# Index info #
|
||||
|
@ -46,11 +46,8 @@ div#sidebar {
|
||||
img#logo {
|
||||
max-width: 200px;
|
||||
}
|
||||
table {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
div#sidebar table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
div.citeblock table td:first-child + td a {
|
||||
@ -118,9 +115,6 @@ input.fullwidth {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
input.smallnumber {
|
||||
width: 4em;
|
||||
}
|
||||
form#session-settings p {
|
||||
line-height: 1.8em;
|
||||
}
|
||||
@ -207,6 +201,20 @@ ul.unordered-tabs li a[href]:hover {
|
||||
background-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) {
|
||||
div#wrapper {
|
||||
padding: 5px;
|
||||
|
@ -75,6 +75,7 @@ def get_app(
|
||||
"userq": userq,
|
||||
"memq": memq,
|
||||
"charq": charq,
|
||||
"indq": indq,
|
||||
"current_lexicon": current_lexicon,
|
||||
"current_membership": current_membership
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
from typing import Sequence
|
||||
|
||||
from flask import Blueprint, render_template, url_for, g, flash, redirect
|
||||
|
||||
from amanuensis.backend import *
|
||||
@ -10,7 +12,7 @@ from amanuensis.server.helpers import (
|
||||
current_lexicon,
|
||||
)
|
||||
|
||||
from .forms import PlayerSettingsForm, SetupSettingsForm
|
||||
from .forms import PlayerSettingsForm, SetupSettingsForm, IndexSchemaForm
|
||||
|
||||
|
||||
bp = Blueprint("settings", __name__, url_prefix="/settings", template_folder=".")
|
||||
@ -118,13 +120,61 @@ def setup(lexicon_name):
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/progress/")
|
||||
@bp.get("/index/")
|
||||
@lexicon_param
|
||||
@editor_required
|
||||
def progress(lexicon_name):
|
||||
return render_template(
|
||||
"settings.jinja", lexicon_name=lexicon_name, page_name=progress.__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(
|
||||
"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,
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/publish/")
|
||||
|
@ -1,15 +1,20 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import (
|
||||
BooleanField,
|
||||
FieldList,
|
||||
FormField,
|
||||
IntegerField,
|
||||
PasswordField,
|
||||
SelectField,
|
||||
StringField,
|
||||
SubmitField,
|
||||
TextAreaField,
|
||||
)
|
||||
from wtforms.validators import Optional, DataRequired
|
||||
from wtforms.validators import Optional, DataRequired, ValidationError
|
||||
from wtforms.widgets.html5 import NumberInput
|
||||
|
||||
from amanuensis.db import ArticleIndex, IndexType
|
||||
|
||||
|
||||
class PlayerSettingsForm(FlaskForm):
|
||||
"""/lexicon/<name>/settings/player/"""
|
||||
@ -43,3 +48,50 @@ class SetupSettingsForm(FlaskForm):
|
||||
validators=[Optional()],
|
||||
)
|
||||
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")
|
||||
|
@ -21,9 +21,9 @@
|
||||
{% block main %}
|
||||
{% if current_membership.is_editor %}
|
||||
<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("progress", "Game Progress") }}</li>
|
||||
<li>{{ settings_page_link("index", "Article Indices") }}</li>
|
||||
<li>{{ settings_page_link("publish", "Turn Publishing") }}</li>
|
||||
<li>{{ settings_page_link("article", "Article Requirements") }}</li>
|
||||
</ul>
|
||||
@ -31,6 +31,7 @@
|
||||
|
||||
{% if page_name == "player" %}
|
||||
<h3>Player Settings</h3>
|
||||
<p>These settings are specific to you as a player in this lexicon.</p>
|
||||
<form action="" method="post" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
<p>
|
||||
@ -83,8 +84,48 @@
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if page_name == "progress" %}
|
||||
<h3>Game Progress</h3>
|
||||
{% if page_name == "index" %}
|
||||
<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 %}
|
||||
|
||||
{% if page_name == "publish" %}
|
||||
|
@ -15,7 +15,6 @@ def test_character_view(db: DbContext, app: Flask, make: ObjectFactory):
|
||||
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
|
||||
@ -63,7 +62,6 @@ def test_character_view(db: DbContext, app: Flask, make: ObjectFactory):
|
||||
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
|
||||
|
91
tests/test_index.py
Normal file
91
tests/test_index.py
Normal 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"
|
Loading…
Reference in New Issue
Block a user