diff --git a/amanuensis/backend/__init__.py b/amanuensis/backend/__init__.py index e0c9365..e35cc56 100644 --- a/amanuensis/backend/__init__.py +++ b/amanuensis/backend/__init__.py @@ -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"] diff --git a/amanuensis/backend/indexrule.py b/amanuensis/backend/indexrule.py new file mode 100644 index 0000000..e860a8d --- /dev/null +++ b/amanuensis/backend/indexrule.py @@ -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() diff --git a/amanuensis/cli/index.py b/amanuensis/cli/index.py index 52cd3c1..55f1f5e 100644 --- a/amanuensis/cli/index.py +++ b/amanuensis/cli/index.py @@ -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 diff --git a/amanuensis/db/models.py b/amanuensis/db/models.py index 7251591..e951309 100644 --- a/amanuensis/db/models.py +++ b/amanuensis/db/models.py @@ -518,6 +518,7 @@ class ArticleIndexRule(ModelBase): """ __tablename__ = "article_index_rule" + __table_args__ = (UniqueConstraint("character_id", "index_id", "turn"),) ################### # Index rule info # diff --git a/amanuensis/resources/page.css b/amanuensis/resources/page.css index 976f2f8..bffd77b 100644 --- a/amanuensis/resources/page.css +++ b/amanuensis/resources/page.css @@ -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; } diff --git a/amanuensis/server/lexicon/settings/__init__.py b/amanuensis/server/lexicon/settings/__init__.py index 2a0b258..12295be 100644 --- a/amanuensis/server/lexicon/settings/__init__.py +++ b/amanuensis/server/lexicon/settings/__init__.py @@ -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 diff --git a/amanuensis/server/lexicon/settings/forms.py b/amanuensis/server/lexicon/settings/forms.py index 74faa47..f7dfab1 100644 --- a/amanuensis/server/lexicon/settings/forms.py +++ b/amanuensis/server/lexicon/settings/forms.py @@ -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//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//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 diff --git a/amanuensis/server/lexicon/settings/settings.jinja b/amanuensis/server/lexicon/settings/settings.jinja index d17d712..d143b98 100644 --- a/amanuensis/server/lexicon/settings/settings.jinja +++ b/amanuensis/server/lexicon/settings/settings.jinja @@ -24,6 +24,7 @@
  • {{ settings_page_link("player", "Player Settings") }}
  • {{ settings_page_link("setup", "Game Setup") }}
  • {{ settings_page_link("index", "Article Indices") }}
  • +
  • {{ settings_page_link("assign", "Index Assignments") }}
  • {{ settings_page_link("publish", "Turn Publishing") }}
  • {{ settings_page_link("article", "Article Requirements") }}
  • @@ -86,7 +87,7 @@ {% if page_name == "index" %}

    Article Indexes

    -
    +
    Index definition help

    An index is a rule that matches the title of a lexicon article based on its index type and pattern. A char 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 range 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 prefix index matches any title that begins with the pattern. An etc index always matches a title.

    When a title is to be sorted under an index, indices are checked in order, sorted first by descending order of logical priority, and then by alphabetical order of index pattern. The title is sorted under the first index that matches it.

    @@ -128,6 +129,42 @@ {% endfor %} {% endif %} +{% if page_name == "assign" %} +

    Index Assignments

    +
    + Index assignment help +

    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.

    +
    +
    + {{ form.hidden_tag() }} + + + + + + + {% for rule_form in form.rules %} + + + + + + {% for field in index_form %} + {% for error in field.errors %} + + + + {% endfor %} + {% endfor %} + {% endfor %} +
    TurnIndexCharacter
    {{ rule_form.turn() }}{{ rule_form.index() }}{{ rule_form.character() }}
    {{ error }}
    +

    {{ form.submit() }}

    +
    + {% for message in get_flashed_messages() %} + {{ message }}
    + {% endfor %} +{% endif %} + {% if page_name == "publish" %}

    Turn Publishing

    {% endif %} diff --git a/tests/backend/test_rule.py b/tests/backend/test_rule.py new file mode 100644 index 0000000..0f420b1 --- /dev/null +++ b/tests/backend/test_rule.py @@ -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) diff --git a/tests/conftest.py b/tests/conftest.py index 203da40..1f41648 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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)