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/index.py b/amanuensis/backend/index.py index 7a8af92..eca5f2e 100644 --- a/amanuensis/backend/index.py +++ b/amanuensis/backend/index.py @@ -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() 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 3b09df0..55f1f5e 100644 --- a/amanuensis/cli/index.py +++ b/amanuensis/cli/index.py @@ -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 diff --git a/amanuensis/db/models.py b/amanuensis/db/models.py index fa99e04..e951309 100644 --- a/amanuensis/db/models.py +++ b/amanuensis/db/models.py @@ -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 # diff --git a/amanuensis/lexicon/gameloop.py b/amanuensis/lexicon/gameloop.py index d606d57..e4d91db 100644 --- a/amanuensis/lexicon/gameloop.py +++ b/amanuensis/lexicon/gameloop.py @@ -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)): 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 26b4558..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=".") @@ -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 diff --git a/amanuensis/server/lexicon/settings/forms.py b/amanuensis/server/lexicon/settings/forms.py index 7b9209c..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): @@ -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//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//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/shell.nix b/shell.nix index 679ebf4..86dbc07 100644 --- a/shell.nix +++ b/shell.nix @@ -4,5 +4,6 @@ pkgs.mkShell { buildInputs = [ pkgs.python3 pkgs.poetry + pkgs.sqlite ]; } 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)