diff --git a/amanuensis/database.py b/amanuensis/database.py index 6c1f7b7..98afcaa 100644 --- a/amanuensis/database.py +++ b/amanuensis/database.py @@ -1,10 +1,38 @@ -from sqlalchemy import create_engine, MetaData, event +from sqlalchemy import create_engine, MetaData, event, TypeDecorator, CHAR from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import scoped_session from sqlalchemy.orm import sessionmaker +import sqlite3 +import uuid -engine = create_engine('sqlite:///:memory:') +# Register GUID as a known type with sqlite +sqlite3.register_converter('GUID', lambda h: uuid.UUID(hex=h)) +sqlite3.register_adapter(uuid.UUID, lambda u: u.hex) + +class Uuid(TypeDecorator): + """ + A uuid backed by a char(32) field in sqlite. + """ + impl = CHAR(32) + + def process_bind_param(self, value, dialect): + if value is None: + return value + elif not isinstance(value, uuid.UUID): + return f'{uuid.UUID(value).int:32x}' + else: + return f'{value.int:32x}' + + def process_result_value(self, value, dialect): + if value is None: + return value + elif not isinstance(value, uuid.UUID): + return uuid.UUID(value) + else: + return value + +engine = create_engine('sqlite:///:memory:', connect_args={'detect_types': sqlite3.PARSE_DECLTYPES}) # Enable foreign key constraints @event.listens_for(engine, "connect") diff --git a/amanuensis/models.py b/amanuensis/models.py index 26b0084..31dc80f 100644 --- a/amanuensis/models.py +++ b/amanuensis/models.py @@ -1,16 +1,20 @@ +import enum from sqlalchemy import ( Boolean, Column, DateTime, + Enum, ForeignKey, Integer, String, Table, + Text, text, ) -from sqlalchemy.orm import relationship +from sqlalchemy.orm import relationship, backref +from uuid import uuid4 -from .database import ModelBase +from .database import ModelBase, Uuid class User(ModelBase): @@ -46,16 +50,20 @@ class User(ModelBase): created = Column(DateTime, nullable=False, server_default=text('CURRENT_TIMESTAMP')) # The timestamp the user last logged in + # This is NULL if the user has never logged in last_login = Column(DateTime, nullable=True) # The timestamp the user last performed an action + # This is NULL if the user has never performed an action last_activity = Column(DateTime, nullable=True) ############################# # Foreign key relationships # ############################# - memberships = relationship('Membership', back_populates='player') + memberships = relationship('Membership', back_populates='user') + characters = relationship('Character', back_populates='user') + articles = relationship('Article', back_populates='user') class Lexicon(ModelBase): @@ -74,7 +82,8 @@ class Lexicon(ModelBase): # The lexicon's human-readable identifier name = Column(String, nullable=False, unique=True) - # Optional title override, instead of "Lexicon " + # Optional title override + # If this is NULL, the title is rendered as "Lexicon " title = Column(String, nullable=True) # The initial prompt describing the game's setting @@ -91,9 +100,11 @@ class Lexicon(ModelBase): last_updated = Column(DateTime, nullable=False, server_default=text('CURRENT_TIMESTAMP')) # The timestamp the first turn was started + # This is NULL until the game starts started = Column(DateTime, nullable=True) # The timestamp when the last turn was published + # This is NULL until the game is completed completed = Column(DateTime, nullable=True) ############## @@ -101,6 +112,7 @@ class Lexicon(ModelBase): ############## # The current turn number + # This is NULL until the game strts current_turn = Column(Integer, nullable=True) # The number of turns in the game @@ -117,12 +129,15 @@ class Lexicon(ModelBase): public = Column(Boolean, nullable=False, default=False) # Optional password required to join + # If this is NULL, no password is required to join join_password = Column(String, nullable=True) # Maximum number of players who can join + # If this is NULL, there is no limit to player joins player_limit = Column(Integer, nullable=True) # Maximum number of characters per player + # If this is NULL, there is no limit to creating characters character_limit = Column(Integer, nullable=True, default=1) #################### @@ -130,12 +145,14 @@ class Lexicon(ModelBase): #################### # Recurrence for turn publish attempts, as crontab spec + # If this is NULL, turns will not publish on a recurrence publish_recur = Column(String, nullable=True) # Whether to attempt publish when an article is approved publish_asap = Column(Boolean, nullable=False, default=True) # Allow an incomplete turn to be published with this many articles + # If this is NULL, the publish quorum is the number of characters publish_quorum = Column(Integer, nullable=True) ##################### @@ -146,9 +163,11 @@ class Lexicon(ModelBase): allow_addendum = Column(Boolean, nullable=False, default=False) # Maximum number of addenda per player per turn + # If this is NULL, there is no limit addendum_turn_limit = Column(Integer, nullable=True) # Maximum number of addenda per title + # If this is NULL, there is no limit addendum_title_limit = Column(Integer, nullable=True) ################# @@ -166,6 +185,10 @@ 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') + index_rules = relationship('ArticleIndexRule', back_populates='lexicon') class Membership(ModelBase): @@ -219,5 +242,215 @@ class Membership(ModelBase): ############################# user = relationship('User', back_populates='memberships') - lexicon = relationship('Lexicon', back_populates='memberships') + + +class Character(ModelBase): + """ + Represents a character played by a uaser in a Lexicon game. + """ + __tablename__ = 'character' + + ################## + # Character info # + ################## + + # Primary character id + id = Column(Integer, primary_key=True) + + # Public-facing character id + public_id = Column(Uuid, nullable=False, unique=True, default=uuid4) + + # The lexicon to which this character belongs + lexicon_id = Column(Integer, ForeignKey('lexicon.id'), nullable=False) + + # The user to whom this character belongs + user_id = Column(Integer, ForeignKey('user.id'), nullable=False) + + # The character's name + name = Column(String, nullable=False) + + # The character's signature + signature = Column(String, nullable=False) + + ############################# + # Foreign key relationships # + ############################# + + user = relationship('User', back_populates='characters') + lexicon = relationship('Lexicon', back_populates='characters') + articles = relationship('Article', back_populates='character') + index_rules = relationship('ArticleIndexRule', back_populates='character') + + +class ArticleState(enum.Enum): + """ + The step of the editorial process an article is in. + """ + DRAFT = 0 + SUBMITTED = 1 + APPROVED = 2 + + +class Article(ModelBase): + """ + Represents a single article in a lexicon. + """ + __tablename__ = 'article' + + ################ + # Article info # + ################ + + # Primary article id + id = Column(Integer, primary_key=True) + + # Public-facing article id + public_id = Column(Uuid, nullable=False, unique=True, default=uuid4) + + # The lexicon to which this article belongs + lexicon_id = Column(Integer, ForeignKey('lexicon.id'), nullable=False) + + # The character who is the author of this article + # If this is NULL, the article is written by Ersatz Scrivener + character_id = Column(Integer, ForeignKey('character.id'), nullable=True) + + # The user who owns this article + user_id = Column(Integer, ForeignKey('user.id'), nullable=False) + + # The article to which this is an addendum + addendum_to = Column(Integer, ForeignKey('article.id'), nullable=True) + + ################# + # Article state # + ################# + + # The turn in which the article was published + # This is NULL until the article is published + turn = Column(Integer, nullable=True) + + # The stage of review the article is in + state = Column(Enum(ArticleState), nullable=False, default=ArticleState.DRAFT) + + # The number of times the article has been submitted + submit_nonce = Column(Integer, nullable=False, default=0) + + #################### + # History tracking # + #################### + + # Timestamp the content of the article was last updated + last_updated = Column(DateTime, nullable=False, server_default=text('CURRENT_TIMESTAMP')) + + # Timestamp the article was last submitted + # This is NULL until the article is submitted + submitted = Column(DateTime, nullable=True) + + # Timestamp the article was last approved + # This is NULL until the article is approved + approved = Column(DateTime, nullable=True) + + ################### + # Article content # + ################### + + # The article's title + title = Column(String, nullable=False, default="") + + # The article's text + body = Column(Text, nullable=False) + + ############################# + # Foreign key relationships # + ############################# + + lexicon = relationship('Lexicon', back_populates='articles') + character = relationship('Character', back_populates='articles') + user = relationship('User', back_populates='articles') + addenda = relationship('Article', backref=backref('parent', remote_side=[id])) + + +class IndexType(enum.Enum): + """ + The title-matching behavior of an article index. + """ + CHAR = 0 + RANGE = 1 + PREFIX = 2 + ETC = 3 + + +class ArticleIndex(ModelBase): + """ + Represents an index definition. + """ + __tablename__ = 'article_index' + + ############## + # Index info # + ############## + + # Primary index id + id = Column(Integer, primary_key=True) + + # The lexicon this index is in + lexicon_id = Column(Integer, ForeignKey('lexicon.id'), nullable=False) + + # The index type + index_type = Column(Enum(IndexType), nullable=False) + + # The index pattern + pattern = Column(String, nullable=False) + + # The order in which the index is processed + logical_order = Column(Integer, nullable=False, default=0) + + # The order in which the index is displayed + display_order = Column(Integer, nullable=False, default=0) + + # The maximum number of articles allowed in this index + # If this is NULL, there is no limit on this index + capacity = Column(Integer, nullable=True) + + ############################# + # Foreign key relationships # + ############################# + + lexicon = relationship('Lexicon', back_populates='indexes') + index_rules = relationship('ArticleIndexRule', back_populates='index') + + +class ArticleIndexRule(ModelBase): + """ + Represents a restriction of which index a character may write in for a turn. + A character with multiple index rules may write in any index that satisfies + a rule. A character with no index rules may write in any index. + """ + __tablename__ = 'article_index_rule' + + ################### + # Index rule info # + ################### + + # Primary index rule id + id = Column(Integer, primary_key=True) + + # The lexicon of this index rule + lexicon_id = Column(Integer, ForeignKey('lexicon.id'), nullable=False) + + # The character to whom this rule applies + character_id = Column(Integer, ForeignKey('character.id'), nullable=False) + + # The index to which the character is restricted + index = Column(Integer, ForeignKey('index.id'), nullable=False) + + # The turn in which this rule applies + turn = Column(Integer, nullable=False) + + ############################# + # Foreign key relationships # + ############################# + + lexicon = relationship('Lexicon', back_populates='index_rules') + index = relationship('ArticleIndex', back_populates='index_rules') + character = relationship('Character', back_populates='index_rules')