Compare commits

..

20 Commits

Author SHA1 Message Date
Tim Van Baak 10a0c59d5e Cleanup and removal of obsolete code 2021-06-26 13:48:18 -07:00
Tim Van Baak 6c8f341a4e Add home page test 2021-06-26 10:13:46 -07:00
Tim Van Baak 4284de1cd0 Misc touchups 2021-06-23 22:08:33 -07:00
Tim Van Baak 0e5547b883 Update flask_wtf version 2021-06-23 22:06:46 -07:00
Tim Van Baak 4603e9da28 Add bs4 and auth workflow tests 2021-06-23 22:02:55 -07:00
Tim Van Baak a6e2c8e6db Fix Python 3.8 incompatibility 2021-06-23 20:24:16 -07:00
Tim Van Baak 9f6f5e92f5 Add unit tests for new backend functions 2021-06-17 02:06:48 -07:00
Tim Van Baak ba346c29bc Move backend tests 2021-06-16 20:17:35 -07:00
Tim Van Baak b789bad6c0 Switch tests to run on a tempfile db 2021-06-16 20:17:24 -07:00
Tim Van Baak 3cfc01a9c8 Get home screen working 2021-06-16 20:16:23 -07:00
Tim Van Baak 562d7d8a4b Add some basic lexicon cli commands 2021-06-16 20:13:07 -07:00
Tim Van Baak 398b5705f1 Simplify some backend names 2021-06-16 20:10:09 -07:00
Tim Van Baak 0e35f15a3a Add prettier date formatter 2021-06-16 00:58:09 -07:00
Tim Van Baak a07b342da3 Remove obsolete model code 2021-06-16 00:39:52 -07:00
Tim Van Baak e4e699fa1b Reintegrate auth routes 2021-06-16 00:37:49 -07:00
Tim Van Baak 6b5463b702 Implement user passwd command 2021-06-16 00:34:20 -07:00
Tim Van Baak 3c7fc4b5f8 Show admin status in user list 2021-06-15 23:33:25 -07:00
Tim Van Baak 34685a741e Add user create and promotion commands 2021-06-15 23:26:58 -07:00
Tim Van Baak 651ab1d72f Refactor db to lazy-load at the top level 2021-06-15 23:02:51 -07:00
Tim Van Baak c4f133434d Refactor path-to-uri calculation into DbContext 2021-06-15 22:58:54 -07:00
45 changed files with 766 additions and 890 deletions

View File

@ -1,99 +0,0 @@
# Standard library imports
import argparse
import logging
import os
import sys
# Module imports
from amanuensis.cli import describe_commands, get_commands
from amanuensis.config import (
RootConfigDirectoryContext,
ENV_CONFIG_DIR,
ENV_LOG_FILE)
from amanuensis.errors import AmanuensisError
from amanuensis.log import init_logging
from amanuensis.models import ModelFactory
def process_doc(docstring):
return '\n'.join([
line.strip()
for line in (docstring or "").strip().splitlines()
])
def get_parser(valid_commands):
# Set up the top-level parser.
parser = argparse.ArgumentParser(
description=describe_commands(),
formatter_class=argparse.RawDescriptionHelpFormatter)
# The config directory.
parser.add_argument("--config-dir",
dest="config_dir",
default=os.environ.get(ENV_CONFIG_DIR, "./config"),
help="The config directory for Amanuensis")
# Logging settings.
parser.add_argument("--verbose", "-v",
action="store_true",
dest="verbose",
help="Enable verbose console logging")
parser.add_argument("--log-file",
dest="log_file",
default=os.environ.get(ENV_LOG_FILE),
help="Enable verbose file logging")
parser.set_defaults(func=lambda args: parser.print_help())
subp = parser.add_subparsers(
metavar="COMMAND",
dest="command",
help="The command to execute")
# Set up command subparsers.
# command_ functions perform setup or execution depending on
# whether their argument is an ArgumentParser.
for name, func in valid_commands.items():
# Create the subparser, set the docstring as the description.
cmd = subp.add_parser(name,
description=process_doc(func.__doc__),
formatter_class=argparse.RawDescriptionHelpFormatter,
aliases=func.__dict__.get("aliases", []))
# Delegate subparser setup to the command.
func(cmd)
# Store function for later execution.
cmd.set_defaults(func=func)
return parser
def main(argv):
# Enumerate valid commands from the CLI module.
commands = get_commands()
# Parse args
args = get_parser(commands).parse_args(argv)
# First things first, initialize logging
init_logging(args.verbose, args.log_file)
logger = logging.getLogger('amanuensis')
# The init command initializes a config directory at --config-dir.
# All other commands assume that the config dir already exists.
if args.command and args.command != "init":
args.root = RootConfigDirectoryContext(args.config_dir)
args.model_factory = ModelFactory(args.root)
# If verbose logging, dump args namespace
if args.verbose:
logger.debug('amanuensis')
for key, val in vars(args).items():
logger.debug(f' {key}: {val}')
# Execute command.
try:
args.func(args)
except AmanuensisError as e:
logger.error('Unexpected internal {}: {}'.format(
type(e).__name__, str(e)))
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View File

@ -3,11 +3,11 @@ Lexicon query interface
""" """
import re import re
from typing import Sequence from typing import Sequence, Optional
from sqlalchemy import select, func from sqlalchemy import select, func
from amanuensis.db import DbContext, Lexicon from amanuensis.db import DbContext, Lexicon, Membership
from amanuensis.errors import ArgumentError from amanuensis.errors import ArgumentError
@ -17,7 +17,7 @@ RE_ALPHANUM_DASH_UNDER = re.compile(r"^[A-Za-z0-9-_]*$")
def create( def create(
db: DbContext, db: DbContext,
name: str, name: str,
title: str, title: Optional[str],
prompt: str, prompt: str,
) -> Lexicon: ) -> Lexicon:
""" """
@ -55,6 +55,23 @@ def create(
return new_lexicon return new_lexicon
def get_all_lexicons(db: DbContext) -> Sequence[Lexicon]: def from_name(db: DbContext, name: str) -> Lexicon:
"""Get a lexicon by its name."""
return db(select(Lexicon).where(Lexicon.name == name)).scalar_one()
def get_all(db: DbContext) -> Sequence[Lexicon]:
"""Get all lexicons.""" """Get all lexicons."""
return db(select(Lexicon)).scalars() return db(select(Lexicon)).scalars()
def get_joined(db: DbContext, user_id: int) -> Sequence[Lexicon]:
"""Get all lexicons that a player is in."""
return db(
select(Lexicon).join(Lexicon.memberships).where(Membership.user_id == user_id)
).scalars()
def get_public(db: DbContext) -> Sequence[Lexicon]:
"""Get all publicly visible lexicons."""
return db(select(Lexicon).where(Lexicon.public == True)).scalars()

View File

@ -2,10 +2,12 @@
User query interface User query interface
""" """
import datetime
import re import re
from typing import Sequence from typing import Optional, Sequence
from sqlalchemy import select, func from sqlalchemy import select, func, update
from werkzeug.security import generate_password_hash, check_password_hash
from amanuensis.db import DbContext, User from amanuensis.db import DbContext, User
from amanuensis.errors import ArgumentError from amanuensis.errors import ArgumentError
@ -19,7 +21,7 @@ def create(
db: DbContext, db: DbContext,
username: str, username: str,
password: str, password: str,
display_name: str, display_name: Optional[str],
email: str, email: str,
is_site_admin: bool, is_site_admin: bool,
) -> User: ) -> User:
@ -59,7 +61,7 @@ def create(
new_user = User( new_user = User(
username=username, username=username,
password=password, password=generate_password_hash(password),
display_name=display_name, display_name=display_name,
email=email, email=email,
is_site_admin=is_site_admin, is_site_admin=is_site_admin,
@ -69,6 +71,49 @@ def create(
return new_user return new_user
def get_all_users(db: DbContext) -> Sequence[User]: def from_id(db: DbContext, user_id: int) -> Optional[User]:
"""
Get a user by the user's id.
Returns None if no user was found.
"""
user: User = db(select(User).where(User.id == user_id)).scalar_one_or_none()
return user
def from_username(db: DbContext, username: str) -> Optional[User]:
"""
Get a user by the user's username.
Returns None if no user was found.
"""
user: User = db(select(User).where(User.username == username)).scalar_one_or_none()
return user
def get_all(db: DbContext) -> Sequence[User]:
"""Get all users.""" """Get all users."""
return db(select(User)).scalars() return db(select(User)).scalars()
def password_set(db: DbContext, username: str, new_password: str) -> None:
"""Set a user's password."""
password_hash = generate_password_hash(new_password)
db(update(User).where(User.username == username).values(password=password_hash))
db.session.commit()
def password_check(db: DbContext, username: str, password: str) -> bool:
"""Check if a password is correct."""
user_password_hash: str = db(
select(User.password).where(User.username == username)
).scalar_one()
return check_password_hash(user_password_hash, password)
def update_logged_in(db: DbContext, username: str) -> None:
"""Bump the value of the last_login column for a user."""
db(
update(User)
.where(User.username == username)
.values(last_login=datetime.datetime.now(datetime.timezone.utc))
)
db.session.commit()

View File

@ -1,10 +1,13 @@
from argparse import ArgumentParser from argparse import ArgumentParser, Namespace
import logging import logging
import logging.config import logging.config
import os
from typing import Callable
import amanuensis.cli.admin import amanuensis.cli.admin
import amanuensis.cli.lexicon import amanuensis.cli.lexicon
import amanuensis.cli.user import amanuensis.cli.user
from amanuensis.db import DbContext
LOGGING_CONFIG = { LOGGING_CONFIG = {
@ -63,7 +66,7 @@ def add_subcommand(subparsers, module) -> None:
sc_name, help=sc_help, description=obj.__doc__ sc_name, help=sc_help, description=obj.__doc__
) )
subcommand.set_defaults(func=obj) subcommand.set_defaults(func=obj)
for args, kwargs in obj.__dict__.get("add_argument", []): for args, kwargs in reversed(obj.__dict__.get("add_argument", [])):
subcommand.add_argument(*args, **kwargs) subcommand.add_argument(*args, **kwargs)
@ -76,6 +79,18 @@ def init_logger(args):
logging.config.dictConfig(LOGGING_CONFIG) logging.config.dictConfig(LOGGING_CONFIG)
def get_db_factory(args: Namespace) -> Callable[[], DbContext]:
"""Factory function for lazy-loading the database in subcommands."""
def get_db() -> DbContext:
"""Lazy loader for the database connection."""
if not os.path.exists(args.db_path):
args.parser.error(f"No database found at {args.db_path}")
return DbContext(path=args.db_path, echo=args.verbose)
return get_db
def main(): def main():
"""CLI entry point""" """CLI entry point"""
# Set up the top-level parser # Set up the top-level parser
@ -83,8 +98,12 @@ def main():
parser.set_defaults( parser.set_defaults(
parser=parser, parser=parser,
func=lambda args: parser.print_usage(), func=lambda args: parser.print_usage(),
get_db=None,
) )
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output") parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
parser.add_argument(
"--db", dest="db_path", default="db.sqlite", help="Path to Amanuensis database"
)
# Add commands from cli submodules # Add commands from cli submodules
subparsers = parser.add_subparsers(metavar="COMMAND") subparsers = parser.add_subparsers(metavar="COMMAND")
@ -92,7 +111,10 @@ def main():
add_subcommand(subparsers, amanuensis.cli.lexicon) add_subcommand(subparsers, amanuensis.cli.lexicon)
add_subcommand(subparsers, amanuensis.cli.user) add_subcommand(subparsers, amanuensis.cli.user)
# Parse args and execute the desired action # Parse args and perform top-level arg processing
args = parser.parse_args() args = parser.parse_args()
init_logger(args) init_logger(args)
args.get_db = get_db_factory(args)
# Execute the desired action
args.func(args) args.func(args)

View File

@ -14,24 +14,17 @@ COMMAND_HELP = "Interact with Amanuensis."
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@add_argument( @add_argument("--drop", "-d", action="store_true", help="Overwrite existing database")
"path", metavar="DB_PATH", help="Path to where the database should be created"
)
@add_argument("--force", "-f", action="store_true", help="Overwrite existing database")
@add_argument("--verbose", "-v", action="store_true", help="Enable db echo")
def command_init_db(args) -> int: def command_init_db(args) -> int:
""" """
Initialize the Amanuensis database. Initialize the Amanuensis database.
""" """
# Check if force is required if args.drop:
if not args.force and os.path.exists(args.path): open(args.db_path, mode="w").close()
args.parser.error(f"{args.path} already exists and --force was not specified")
# Initialize the database # Initialize the database
db_uri = f"sqlite:///{os.path.abspath(args.path)}" LOG.info(f"Creating database at {args.db_path}")
LOG.info(f"Creating database at {db_uri}") args.get_db().create_all()
db = DbContext(db_uri, debug=args.verbose)
db.create_all()
LOG.info("Done") LOG.info("Done")
return 0 return 0

View File

@ -1,5 +1,12 @@
import logging import logging
from sqlalchemy import update
import amanuensis.backend.lexicon as lexiq
import amanuensis.backend.membership as memq
import amanuensis.backend.user as userq
from amanuensis.db import DbContext, Lexicon
from .helpers import add_argument from .helpers import add_argument
@ -9,22 +16,56 @@ COMMAND_HELP = "Interact with lexicons."
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@add_argument("lexicon")
@add_argument("user")
@add_argument("--editor", action="store_true")
def command_add(args) -> int:
"""
Add a user to a lexicon.
"""
db: DbContext = args.get_db()
lexicon = lexiq.from_name(db, args.lexicon)
user = userq.from_username(db, args.user)
assert user is not None
memq.create(db, user.id, lexicon.id, args.editor)
LOG.info(f"Added {args.user} to lexicon {args.lexicon}")
return 0
@add_argument("name")
def command_create(args): def command_create(args):
""" """
Create a lexicon. Create a lexicon.
""" """
raise NotImplementedError() db: DbContext = args.get_db()
lexiq.create(db, args.name, None, f"Prompt for Lexicon {args.name}")
LOG.info(f"Created lexicon {args.name}")
return 0
def command_delete(args): @add_argument("name")
@add_argument("--public", dest="public", action="store_const", const=True)
@add_argument("--no-public", dest="public", action="store_const", const=False)
@add_argument("--join", dest="join", action="store_const", const=True)
@add_argument("--no-join", dest="join", action="store_const", const=False)
def command_edit(args):
""" """
Delete a lexicon. Update a lexicon's configuration.
""" """
raise NotImplementedError() db: DbContext = args.get_db()
values = {}
if args.public == True:
values["public"] = True
elif args.public == False:
values["public"] = False
def command_list(args): if args.join == True:
""" values["joinable"] = True
List all lexicons and their statuses. elif args.join == False:
""" values["joinable"] = False
raise NotImplementedError()
result = db(update(Lexicon).where(Lexicon.name == args.name).values(**values))
LOG.info(f"Updated {result.rowcount} lexicons")
db.session.commit()
return 0 if result.rowcount == 1 else -1

View File

@ -1,4 +1,8 @@
import logging import logging
from typing import Optional
import amanuensis.backend.user as userq
from amanuensis.db import DbContext, User
from .helpers import add_argument from .helpers import add_argument
@ -9,11 +13,50 @@ COMMAND_HELP = "Interact with users."
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
def command_create(args): @add_argument("username")
""" @add_argument("--password", default="password")
Create a user. @add_argument("--email", default="")
""" def command_create(args) -> int:
raise NotImplementedError() """Create a user."""
db: DbContext = args.get_db()
userq.create(db, args.username, "password", args.username, args.email, False)
userq.password_set(db, args.username, args.password)
LOG.info(f"Created user {args.username}")
return 0
@add_argument("username")
def command_promote(args) -> int:
"""Make a user a site admin."""
db: DbContext = args.get_db()
user: Optional[User] = userq.from_username(db, args.username)
if user is None:
args.parser.error("User not found")
return -1
if user.is_site_admin:
LOG.info(f"{user.username} is already a site admin.")
else:
user.is_site_admin = True
LOG.info(f"Promoting {user.username} to site admin.")
db.session.commit()
return 0
@add_argument("username")
def command_demote(args):
"""Revoke a user's site admin status."""
db: DbContext = args.get_db()
user: Optional[User] = userq.from_username(db, args.username)
if user is None:
args.parser.error("User not found")
return -1
if not user.is_site_admin:
LOG.info(f"{user.username} is not a site admin.")
else:
user.is_site_admin = False
LOG.info(f"Revoking site admin status for {user.username}.")
db.session.commit()
return 0
def command_delete(args): def command_delete(args):
@ -30,8 +73,13 @@ def command_list(args):
raise NotImplementedError() raise NotImplementedError()
def command_passwd(args): @add_argument("username")
@add_argument("password")
def command_passwd(args) -> int:
""" """
Set a user's password. Set a user's password.
""" """
raise NotImplementedError() db: DbContext = args.get_db()
userq.password_set(db, args.username, args.password)
LOG.info(f"Updated password for {args.username}")
return 0

View File

@ -1,6 +1,8 @@
""" """
Database connection setup Database connection setup
""" """
import os
from sqlalchemy import create_engine, MetaData, event from sqlalchemy import create_engine, MetaData, event
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import scoped_session, sessionmaker from sqlalchemy.orm import scoped_session, sessionmaker
@ -27,20 +29,41 @@ ModelBase = declarative_base(metadata=metadata)
class DbContext: class DbContext:
def __init__(self, db_uri, debug=False): """Class encapsulating connections to the database."""
# Create an engine and enable foreign key constraints in sqlite
self.engine = create_engine(db_uri, echo=debug) def __init__(self, path=None, uri=None, echo=False):
"""
Create a database context.
Exactly one of `path` and `uri` should be specified.
"""
if path and uri:
raise ValueError("Only one of path and uri may be specified")
self.db_uri = uri if uri else f"sqlite:///{os.path.abspath(path)}"
# Create an engine and enable foreign key constraints in sqlite
self.engine = create_engine(self.db_uri, echo=echo)
@event.listens_for(self.engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record): def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor() cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON") cursor.execute("PRAGMA foreign_keys=ON")
cursor.close() cursor.close()
event.listens_for(self.engine, "connect")(set_sqlite_pragma)
# Create a thread-safe session factory # Create a thread-safe session factory
self.session = scoped_session( sm = sessionmaker(bind=self.engine)
sessionmaker(bind=self.engine), scopefunc=get_ident
) def add_lifecycle_hook(sm, from_state, to_state):
def object_lifecycle_hook(_, obj):
print(f"object moved from {from_state} to {to_state}: {obj}")
event.listens_for(sm, f"{from_state}_to_{to_state}")(object_lifecycle_hook)
if echo:
add_lifecycle_hook(sm, "persistent", "detached")
self.session = scoped_session(sm, scopefunc=get_ident)
def __call__(self, *args, **kwargs): def __call__(self, *args, **kwargs):
"""Provides shortcut access to session.execute.""" """Provides shortcut access to session.execute."""

View File

@ -100,6 +100,25 @@ class User(ModelBase):
articles = relationship("Article", back_populates="user") articles = relationship("Article", back_populates="user")
posts = relationship("Post", back_populates="user") posts = relationship("Post", back_populates="user")
#########################
# Flask-Login interface #
#########################
@property
def is_authenticated(self: "User") -> bool:
return True
@property
def is_active(self: "User") -> bool:
return True
@property
def is_anonymous(self: "User") -> bool:
return False
def get_id(self: "User") -> str:
return str(self.id)
class Lexicon(ModelBase): class Lexicon(ModelBase):
""" """

View File

@ -1,104 +0,0 @@
"""
Submodule of functions for creating and managing lexicons within the
general Amanuensis context.
"""
import json
import logging
import os
import re
import time
from typing import Iterable
import uuid
from amanuensis.config import RootConfigDirectoryContext, AttrOrderedDict
from amanuensis.errors import ArgumentError
from amanuensis.models import ModelFactory, UserModel, LexiconModel
from amanuensis.resources import get_stream
logger = logging.getLogger(__name__)
def valid_name(name: str) -> bool:
"""
Validates that a lexicon name consists only of alpahnumerics, dashes,
underscores, and spaces
"""
return re.match(r'^[A-Za-z0-9-_ ]+$', name) is not None
def create_lexicon(
root: RootConfigDirectoryContext,
name: str,
editor: UserModel) -> LexiconModel:
"""
Creates a lexicon with the given name and sets the given user as its editor
"""
# Verify arguments
if not name:
raise ArgumentError(f'Empty lexicon name: "{name}"')
if not valid_name(name):
raise ArgumentError(f'Invalid lexicon name: "{name}"')
with root.lexicon.read_index() as extant_lexicons:
if name in extant_lexicons.keys():
raise ArgumentError(f'Lexicon name already taken: "{name}"')
if editor is None:
raise ArgumentError('Editor must not be None')
# Create the lexicon directory and initialize it with a blank lexicon
lid: str = uuid.uuid4().hex
lex_dir = os.path.join(root.lexicon.path, lid)
os.mkdir(lex_dir)
with get_stream("lexicon.json") as s:
path: str = os.path.join(lex_dir, 'config.json')
with open(path, 'wb') as f:
f.write(s.read())
# Create subdirectories
os.mkdir(os.path.join(lex_dir, 'draft'))
os.mkdir(os.path.join(lex_dir, 'src'))
os.mkdir(os.path.join(lex_dir, 'article'))
# Update the index with the new lexicon
with root.lexicon.edit_index() as index:
index[name] = lid
# Fill out the new lexicon
with root.lexicon[lid].edit_config() as cfg:
cfg.lid = lid
cfg.name = name
cfg.editor = editor.uid
cfg.time.created = int(time.time())
with root.lexicon[lid].edit('info', create=True):
pass # Create an empry config file
# Load the lexicon and add the editor and default character
model_factory: ModelFactory = ModelFactory(root)
lexicon = model_factory.lexicon(lid)
with lexicon.ctx.edit_config() as cfg:
cfg.join.joined.append(editor.uid)
with get_stream('character.json') as template:
character = json.load(template, object_pairs_hook=AttrOrderedDict)
character.cid = 'default'
character.name = 'Ersatz Scrivener'
character.player = None
cfg.character.new(character.cid, character)
# Log the creation
message = f'Created {lexicon.title}, ed. {editor.cfg.displayname} ({lid})'
lexicon.log(message)
logger.info(message)
return lexicon
def load_all_lexicons(
root: RootConfigDirectoryContext) -> Iterable[LexiconModel]:
"""
Iterably loads every lexicon in the config store
"""
model_factory: ModelFactory = ModelFactory(root)
with root.lexicon.read_index() as index:
for lid in index.values():
lexicon: LexiconModel = model_factory.lexicon(lid)
yield lexicon

View File

@ -1,11 +0,0 @@
from .factory import ModelFactory
from .lexicon import LexiconModel
from .user import UserModelBase, UserModel, AnonymousUserModel
__all__ = [member.__name__ for member in [
ModelFactory,
LexiconModel,
UserModelBase,
UserModel,
AnonymousUserModel,
]]

View File

@ -1,57 +0,0 @@
from typing import Optional
from amanuensis.config import is_guid, RootConfigDirectoryContext
from amanuensis.errors import ArgumentError
from .user import UserModel
from .lexicon import LexiconModel
class ModelFactory():
def __init__(self, root: RootConfigDirectoryContext):
self.root: RootConfigDirectoryContext = root
def try_user(self, identifier: str) -> Optional[UserModel]:
user: Optional[UserModel] = None
try:
user = self.user(identifier)
except Exception:
pass
finally:
return user
def user(self, identifier: str) -> UserModel:
"""Get the user model for the given id or username"""
# Ensure we have something to work with
if identifier is None:
raise ArgumentError('identifer must not be None')
# Ensure we have a user guid
if not is_guid(identifier):
with self.root.user.read_index() as index:
uid = index.get(identifier, None)
if uid is None:
raise KeyError(f'Unknown username: {identifier})')
if not is_guid(uid):
raise ValueError(f'Invalid index entry: {uid}')
else:
uid = identifier
user = UserModel(self.root, uid)
return user
def lexicon(self, identifier: str) -> LexiconModel:
"""Get the lexicon model for the given id or name"""
# Ensure we have something to work with
if identifier is None:
raise ArgumentError('identifier must not be None')
# Ensure we have a lexicon guid
if not is_guid(identifier):
with self.root.lexicon.read_index() as index:
lid = index.get(identifier, None)
if lid is None:
raise KeyError(f'Unknown lexicon: {identifier}')
if not is_guid(lid):
raise ValueError(f'Invalid index entry: {lid}')
else:
lid = identifier
lexicon = LexiconModel(self.root, lid)
return lexicon

View File

@ -1,64 +0,0 @@
import time
from typing import cast
from amanuensis.config import (
RootConfigDirectoryContext,
LexiconConfigDirectoryContext,
ReadOnlyOrderedDict)
class LexiconModel():
PREGAME = "unstarted"
ONGOING = "ongoing"
COMPLETE = "completed"
"""Represents a lexicon in the Amanuensis config store"""
def __init__(self, root: RootConfigDirectoryContext, lid: str):
self._lid: str = lid
# Creating the config context implicitly checks for existence
self._ctx: LexiconConfigDirectoryContext = (
cast(LexiconConfigDirectoryContext, root.lexicon[lid]))
with self._ctx.read_config() as config:
self._cfg: ReadOnlyOrderedDict = cast(ReadOnlyOrderedDict, config)
def __str__(self) -> str:
return f'<Lexicon {self.cfg.name}>'
def __repr__(self) -> str:
return f'<LexiconModel({self.lid})>'
# Properties
@property
def lid(self) -> str:
"""Lexicon guid"""
return self._lid
@property
def ctx(self) -> LexiconConfigDirectoryContext:
"""Lexicon config directory context"""
return self._ctx
@property
def cfg(self) -> ReadOnlyOrderedDict:
"""Cached lexicon config"""
return self._cfg
# Utilities
@property
def title(self) -> str:
return self.cfg.get('title') or f'Lexicon {self.cfg.name}'
def log(self, message: str) -> None:
now = int(time.time())
with self.ctx.edit_config() as cfg:
cfg.log.append([now, message])
@property
def status(self) -> str:
if self.cfg.turn.current is None:
return LexiconModel.PREGAME
if self.cfg.turn.current > self.cfg.turn.max:
return LexiconModel.COMPLETE
return LexiconModel.ONGOING

View File

@ -1,83 +0,0 @@
from typing import cast
from werkzeug.security import generate_password_hash, check_password_hash
from amanuensis.config import (
RootConfigDirectoryContext,
UserConfigDirectoryContext,
ReadOnlyOrderedDict)
class UserModelBase():
"""Common base class for auth and anon user models"""
# Properties
@property
def uid(self) -> str:
"""User guid"""
return getattr(self, '_uid', None)
@property
def ctx(self) -> UserConfigDirectoryContext:
"""User config directory context"""
return getattr(self, '_ctx', None)
@property
def cfg(self) -> ReadOnlyOrderedDict:
"""Cached user config"""
return getattr(self, '_cfg', None)
# Flask-Login interfaces
@property
def is_authenticated(self) -> bool:
return self.uid is not None
@property
def is_active(self) -> bool:
return self.uid is not None
@property
def is_anonymous(self) -> bool:
return self.uid is None
def get_id(self) -> str:
return self.uid
class UserModel(UserModelBase):
"""Represents a user in the Amanuensis config store"""
def __init__(self, root: RootConfigDirectoryContext, uid: str):
self._uid: str = uid
# Creating the config context implicitly checks for existence
self._ctx: UserConfigDirectoryContext = (
cast(UserConfigDirectoryContext, root.user[uid]))
with self._ctx.read_config() as config:
self._cfg: ReadOnlyOrderedDict = cast(ReadOnlyOrderedDict, config)
def __str__(self) -> str:
return f'<{self.cfg.username}>'
def __repr__(self) -> str:
return f'<UserModel({self.uid})>'
# Utility methods
def set_password(self, password: str) -> None:
pw_hash = generate_password_hash(password)
with self.ctx.edit_config() as cfg:
cfg['password'] = pw_hash
def check_password(self, password) -> bool:
with self.ctx.read_config() as cfg:
return check_password_hash(cfg.password, password)
class AnonymousUserModel(UserModelBase):
"""Represents an anonymous user"""
def __str__(self) -> str:
return '<Anonymous>'
def __repr__(self) -> str:
return '<AnonymousUserModel>'

View File

@ -1,13 +0,0 @@
{
"version": "0",
"aid": null,
"lexicon": null,
"character": null,
"title": null,
"turn": null,
"status": {
"ready": false,
"approved": false
},
"contents": null
}

View File

@ -1,7 +0,0 @@
{
"version": "0",
"cid": null,
"name": null,
"player": null,
"signature": null
}

View File

@ -1,15 +0,0 @@
{
"version": "0",
"secret_key": null,
"address": "127.0.0.1",
"port": "5000",
"lexicon_data": "./lexicon",
"static_root": "../resources",
"email": {
"server": null,
"port": null,
"tls": null,
"username": null,
"password": null
}
}

View File

@ -1,107 +0,0 @@
{
"version": "0",
"lid": null,
"name": null,
"title": null,
"editor": null,
"prompt": null,
"time": {
"created": null,
"completed": null
},
"turn": {
"current": null,
"max": 8,
"assignment": {
}
},
"join": {
"public": false,
"open": false,
"password": null,
"max_players": 4,
"chars_per_player": 1,
"joined": []
},
"publish": {
"notify": {
"editor_on_ready": true,
"player_on_reject": true,
"player_on_accept": false
},
"deadlines": null,
"asap": false,
"quorum": null,
"block_on_ready": true
},
"article": {
"index": {
"list": [
{
"type": "char",
"pri": 0,
"pattern": "ABC"
},
{
"type": "char",
"pri": 0,
"pattern": "DEF"
},
{
"type": "char",
"pri": 0,
"pattern": "GHI"
},
{
"type": "char",
"pri": 0,
"pattern": "JKL"
},
{
"type": "char",
"pri": 0,
"pattern": "MNO"
},
{
"type": "char",
"pri": 0,
"pattern": "PQRS"
},
{
"type": "char",
"pri": 0,
"pattern": "TUV"
},
{
"type": "char",
"pri": 0,
"pattern": "WXYZ"
}
],
"capacity": null
},
"citation": {
"allow_self": false,
"min_extant": null,
"max_extant": null,
"min_phantom": null,
"max_phantom": null,
"min_total": null,
"max_total": null,
"min_chars": null,
"max_chars": null
},
"word_limit": {
"soft": null,
"hard": null
},
"addendum": {
"allowed": false,
"max": null
}
},
"character": {
},
"log": [
]
}

View File

@ -1,13 +0,0 @@
{
"version": "0",
"uid": null,
"username": null,
"displayname": null,
"email": null,
"password": null,
"created": null,
"last_login": null,
"last_activity": null,
"new_password_required": true,
"is_admin": false
}

View File

@ -1,11 +1,15 @@
from datetime import datetime, timezone
import json import json
import os import os
from flask import Flask, g from flask import Flask, g
import amanuensis.backend.lexicon
import amanuensis.backend.user
from amanuensis.config import AmanuensisConfig, CommandLineConfig from amanuensis.config import AmanuensisConfig, CommandLineConfig
from amanuensis.db import DbContext from amanuensis.db import DbContext
import amanuensis.server.home import amanuensis.server.auth as auth
import amanuensis.server.home as home
def get_app( def get_app(
@ -30,7 +34,7 @@ def get_app(
# Create the database context, if one wasn't already given # Create the database context, if one wasn't already given
if db is None: if db is None:
db = DbContext(app.config["DATABASE_URI"]) db = DbContext(uri=app.config["DATABASE_URI"])
# Make the database connection available to requests via g # Make the database connection available to requests via g
def db_setup(): def db_setup():
@ -47,11 +51,26 @@ def get_app(
# Configure jinja options # Configure jinja options
app.jinja_options.update(trim_blocks=True, lstrip_blocks=True) app.jinja_options.update(trim_blocks=True, lstrip_blocks=True)
def date_format(dt: datetime, formatstr="%Y-%m-%d %H:%M:%S%z") -> str:
if dt is None:
return "never"
# Cast db time to UTC, then convert to local timezone
adjusted = dt.replace(tzinfo=timezone.utc).astimezone()
return adjusted.strftime(formatstr)
app.template_filter("date")(date_format)
def include_backend():
return {"db": db, "lexiq": amanuensis.backend.lexicon, "userq": amanuensis.backend.user}
app.context_processor(include_backend)
# Set up Flask-Login # Set up Flask-Login
# TODO auth.get_login_manager().init_app(app)
# Register blueprints # Register blueprints
app.register_blueprint(amanuensis.server.home.bp) app.register_blueprint(auth.bp)
app.register_blueprint(home.bp)
def test(): def test():
return "Hello, world!" return "Hello, world!"

View File

@ -1,76 +1,79 @@
import logging import logging
import time from typing import Optional
from flask import ( from flask import (
Blueprint, Blueprint,
render_template,
redirect,
url_for,
flash, flash,
current_app) g,
redirect,
render_template,
url_for,
)
from flask_login import ( from flask_login import (
AnonymousUserMixin,
login_user, login_user,
logout_user, logout_user,
login_required, login_required,
LoginManager) LoginManager,
)
from amanuensis.config import RootConfigDirectoryContext import amanuensis.backend.user as userq
from amanuensis.models import ModelFactory, AnonymousUserModel from amanuensis.db import User
from .forms import LoginForm from .forms import LoginForm
logger = logging.getLogger(__name__)
LOG = logging.getLogger(__name__)
bp = Blueprint("auth", __name__, url_prefix="/auth", template_folder=".")
def get_login_manager(root: RootConfigDirectoryContext) -> LoginManager: def get_login_manager() -> LoginManager:
""" """Login manager factory"""
Creates a login manager
"""
login_manager = LoginManager() login_manager = LoginManager()
login_manager.login_view = 'auth.login' login_manager.login_view = "auth.login"
login_manager.anonymous_user = AnonymousUserModel login_manager.anonymous_user = AnonymousUserMixin
@login_manager.user_loader def load_user(user_id_str: str) -> Optional[User]:
def load_user(uid): try:
return current_app.config['model_factory'].user(str(uid)) user_id = int(user_id_str)
except:
return None
return userq.from_id(g.db, user_id)
login_manager.user_loader(load_user)
return login_manager return login_manager
bp_auth = Blueprint('auth', __name__, @bp.route("/login/", methods=["GET", "POST"])
url_prefix='/auth',
template_folder='.')
@bp_auth.route('/login/', methods=['GET', 'POST'])
def login(): def login():
model_factory: ModelFactory = current_app.config['model_factory']
form = LoginForm() form = LoginForm()
if not form.validate_on_submit(): if not form.validate_on_submit():
# Either the request was GET and we should render the form, # Either the request was GET and we should render the form,
# or the request was POST and validation failed. # or the request was POST and validation failed.
return render_template('auth.login.jinja', form=form) return render_template("auth.login.jinja", form=form)
# POST with valid data # POST with valid data
username = form.username.data username: str = form.username.data
user = model_factory.try_user(username) password: str = form.password.data
if not user or not user.check_password(form.password.data): user: User = userq.from_username(g.db, username)
if not user or not userq.password_check(g.db, username, password):
# Bad creds # Bad creds
flash("Login not recognized") flash("Login not recognized")
return redirect(url_for('auth.login')) return redirect(url_for("auth.login"))
# Login credentials were correct # Login credentials were correct
remember_me = form.remember.data remember_me: bool = form.remember.data
login_user(user, remember=remember_me) login_user(user, remember=remember_me)
with user.ctx.edit_config() as cfg: userq.update_logged_in(g.db, username)
cfg.last_login = int(time.time()) LOG.info("Logged in user {0.username} ({0.id})".format(user))
logger.info('Logged in user "{0.username}" ({0.uid})'.format(user.cfg)) return redirect(url_for("home.home"))
return redirect(url_for('home.home'))
@bp_auth.route("/logout/", methods=['GET']) @bp.get("/logout/")
@login_required @login_required
def logout(): def logout():
logout_user() logout_user()
return redirect(url_for('home.home')) return redirect(url_for("home.home"))

View File

@ -5,11 +5,8 @@ from wtforms.validators import DataRequired
class LoginForm(FlaskForm): class LoginForm(FlaskForm):
"""/auth/login/""" """/auth/login/"""
username = StringField(
'Username', username = StringField("Username", validators=[DataRequired()])
validators=[DataRequired()]) password = PasswordField("Password", validators=[DataRequired()])
password = PasswordField( remember = BooleanField("Stay logged in")
'Password', submit = SubmitField("Log in")
validators=[DataRequired()])
remember = BooleanField('Stay logged in')
submit = SubmitField('Log in')

View File

@ -21,13 +21,6 @@ def register_custom_filters(app):
val = getattr(user.cfg, attr) val = getattr(user.cfg, attr)
return val return val
@app.template_filter("asdate")
def timestamp_to_readable(ts, formatstr="%Y-%m-%d %H:%M:%S"):
if ts is None:
return "null"
dt = datetime.fromtimestamp(ts)
return dt.strftime(formatstr)
@app.template_filter("articlelink") @app.template_filter("articlelink")
def article_link(title): def article_link(title):
return url_for( return url_for(

View File

@ -1,43 +1,23 @@
from flask import Blueprint, render_template, g from flask import Blueprint, render_template, g
# from flask import Blueprint, render_template, redirect, url_for, current_app
# from flask_login import login_required, current_user
import amanuensis.backend.user as userq import amanuensis.backend.user as userq
import amanuensis.backend.lexicon as lexiq import amanuensis.backend.lexicon as lexiq
# from amanuensis.config import RootConfigDirectoryContext
# from amanuensis.lexicon import create_lexicon, load_all_lexicons
# from amanuensis.models import UserModel, ModelFactory
# from amanuensis.server.helpers import admin_required
# from .forms import LexiconCreateForm # from .forms import LexiconCreateForm
bp = Blueprint("home", __name__, url_prefix="/home", template_folder=".") bp = Blueprint("home", __name__, url_prefix="/home", template_folder=".")
# @bp.get("/") @bp.get("/")
# def home(): def home():
# Show lexicons that are visible to the current user return render_template("home.root.jinja")
# return "TODO"
# user_lexicons = []
# public_lexicons = []
# for lexicon in load_all_lexicons(root):
# if user.uid in lexicon.cfg.join.joined:
# user_lexicons.append(lexicon)
# elif lexicon.cfg.join.public:
# public_lexicons.append(lexicon)
# return render_template(
# 'home.root.jinja',
# user_lexicons=user_lexicons,
# public_lexicons=public_lexicons)
@bp.get("/admin/") @bp.get("/admin/")
# @login_required # @login_required
# @admin_required # @admin_required
def admin(): def admin():
return render_template("home.admin.jinja", db=g.db, userq=userq, lexiq=lexiq) return render_template("home.admin.jinja", userq=userq, lexiq=lexiq)
# @bp_home.route("/admin/create/", methods=['GET', 'POST']) # @bp_home.route("/admin/create/", methods=['GET', 'POST'])

View File

@ -2,16 +2,16 @@ from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, TextAreaField from wtforms import StringField, SubmitField, TextAreaField
from wtforms.validators import DataRequired from wtforms.validators import DataRequired
from amanuensis.server.forms import User, Lexicon # from amanuensis.server.forms import User, Lexicon
class LexiconCreateForm(FlaskForm): # class LexiconCreateForm(FlaskForm):
"""/admin/create/""" # """/admin/create/"""
lexiconName = StringField( # lexiconName = StringField(
'Lexicon name', # 'Lexicon name',
validators=[DataRequired(), Lexicon(should_exist=False)]) # validators=[DataRequired(), Lexicon(should_exist=False)])
editorName = StringField( # editorName = StringField(
'Username of editor', # 'Username of editor',
validators=[DataRequired(), User(should_exist=True)]) # validators=[DataRequired(), User(should_exist=True)])
promptText = TextAreaField('Prompt') # promptText = TextAreaField('Prompt')
submit = SubmitField('Create') # submit = SubmitField('Create')

View File

@ -4,17 +4,17 @@
{% block header %}<h2>Amanuensis - Admin Dashboard</h2>{% endblock %} {% block header %}<h2>Amanuensis - Admin Dashboard</h2>{% endblock %}
{# TODO #} {# TODO #}
{% block sb_home %}<a href="#{#{ url_for('home.home') }#}">Home</a>{% endblock %} {% block sb_home %}<a href="{{ url_for('home.home') }}">Home</a>{% endblock %}
{% block sb_create %}<a href="#{#{ url_for('home.admin_create') }#}">Create a lexicon</a>{% endblock %} {% block sb_create %}<a href="#{#{ url_for('home.admin_create') }#}">Create a lexicon</a>{% endblock %}
{% set template_sidebar_rows = [self.sb_home(), self.sb_create()] %} {% set template_sidebar_rows = [self.sb_home(), self.sb_create()] %}
{% block main %} {% block main %}
<p>Users:</p> <p>Users:</p>
{% for user in userq.get_all_users(db) %} {% for user in userq.get_all(db) %}
{{ macros.dashboard_user_item(user) }} {{ macros.dashboard_user_item(user) }}
{% endfor %} {% endfor %}
<p>Lexicons:</p> <p>Lexicons:</p>
{% for lexicon in lexiq.get_all_lexicons(db) %} {% for lexicon in lexiq.get_all(db) %}
{{ macros.dashboard_lexicon_item(lexicon) }} {{ macros.dashboard_lexicon_item(lexicon) }}
{% endfor %} {% endfor %}
{% endblock %} {% endblock %}

View File

@ -11,10 +11,16 @@
<span style="color:#ff0000">{{ message }}</span><br> <span style="color:#ff0000">{{ message }}</span><br>
{% endfor %} {% endfor %}
{% if current_user.is_authenticated %}
{% set joined = lexiq.get_joined(db, current_user.id)|list %}
{% else %}
{% set joined = [] %}
{% endif %}
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<h2>Your games</h2> <h2>Your games</h2>
{% if user_lexicons %} {% if joined %}
{% for lexicon in user_lexicons %} {% for lexicon in joined %}
{{ macros.dashboard_lexicon_item(lexicon) }} {{ macros.dashboard_lexicon_item(lexicon) }}
{% endfor %} {% endfor %}
{% else %} {% else %}
@ -22,9 +28,10 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% set public = lexiq.get_public(db)|reject("in", joined)|list %}
<h2>Public games</h2> <h2>Public games</h2>
{% if public_lexicons %} {% if public %}
{% for lexicon in public_lexicons %} {% for lexicon in public %}
{{ macros.dashboard_lexicon_item(lexicon) }} {{ macros.dashboard_lexicon_item(lexicon) }}
{% endfor %} {% endfor %}
{% else %} {% else %}
@ -34,7 +41,7 @@
{% endblock %} {% endblock %}
{% set template_content_blocks = [self.main()] %} {% set template_content_blocks = [self.main()] %}
{% if current_user.cfg.is_admin %} {% if current_user.is_site_admin %}
{% block admin_dash %} {% block admin_dash %}
<a href="{{ url_for('home.admin') }}" style="display:block; text-align:center;">Admin dashboard</a> <a href="{{ url_for('home.admin') }}" style="display:block; text-align:center;">Admin dashboard</a>
{% endblock %} {% endblock %}

View File

@ -3,45 +3,45 @@
<div class="dashboard-lexicon-item dashboard-lexicon-{{ status }}"> <div class="dashboard-lexicon-item dashboard-lexicon-{{ status }}">
<p> <p>
<span class="dashboard-lexicon-item-title"> <span class="dashboard-lexicon-item-title">
<a href="#{#{ url_for('lexicon.contents', name=lexicon.cfg.name) }#}"> <a href="#{#{ url_for('lexicon.contents', name=lexicon.cfg.name) }#}">{{ lexicon.full_title }}</a>
{{ lexicon.full_title }}</a>
</span> </span>
[{{ lexicon.status.capitalize() }}] [{{ status.capitalize() }}]
</p> </p>
<p><i>{{ lexicon.prompt }}</i></p> <p><i>{{ lexicon.prompt }}</i></p>
{# {% if current_user.is_authenticated %} #} {% if current_user.is_authenticated %}
<p> <p>
{# TODO #} {#-
{# {% Show detailed player information if the current user is a member of the lexicon or if the current user is a site admin. The filter sequence must be converted to a list because it returns a generator, which is truthy.
if current_user.uid in lexicon.cfg.join.joined -#}
or current_user.cfg.is_admin {%-
%} #} if lexicon.memberships|map(attribute="user_id")|select("equalto", current_user.id)|list
Editor: {#{ lexicon.cfg.editor|user_attr('username') }#} / or current_user.is_site_admin
Players: -%}
{# {% for uid in lexicon.cfg.join.joined %} #} Editor: {{
{# {{ uid|user_attr('username') }}{% if not loop.last %}, {% endif %} #} lexicon.memberships|selectattr("is_editor")|map(attribute="user")|map(attribute="username")|join(", ")
{# {% endfor %} #} }} / Players: {{
{# ({{ lexicon.cfg.join.joined|count }}/{{ lexicon.cfg.join.max_players }}) #} lexicon.memberships|map(attribute="user")|map(attribute="username")|join(", ")
{# {% else %} #} }} ({{ lexicon.memberships|count }}
{# Players: {{ lexicon.cfg.join.joined|count }}/{{ lexicon.cfg.join.max_players }} #} {%- if lexicon.player_limit is not none -%}
{# {% if lexicon.cfg.join.public and lexicon.cfg.join.open %} #} /{{ lexicon.player_limit }}
{# / <a href="{{ url_for('lexicon.join', name=lexicon.cfg.name) }}"> #} {%- endif -%})
{# Join game #} {%- else -%}
{# </a> #} Players: {{ lexicon.memberships|count }}{% if lexicon.player_limit is not none %} / {{ lexicon.player_limit }}{% endif -%}
{# {% endif %} #} {%-
{# {% endif %} #} if lexicon.public and lexicon.joinable
%} / <a href="#{#{ url_for('lexicon.join', name=lexicon.cfg.name) }#}">Join game</a>
{%- endif -%}
{%- endif -%}
</p> </p>
{# {% endif %} #} {% endif %}
</div> </div>
{% endmacro %} {% endmacro %}
{% macro dashboard_user_item(user) %} {% macro dashboard_user_item(user) %}
<div class="dashboard-lexicon-item"> <div class="dashboard-lexicon-item">
<p> <p>
<b>{{ user.username }}</b> <b>{{ user.username }}</b> {% if user.username != user.display_name %} / {{ user.display_name }}{% endif %} (id #{{user.id}}){% if user.is_site_admin %} <b>[ADMIN]</b>{% endif %}
{% if user.username != user.display_name %} / {{ user.display_name }}{% endif %}
(id #{{user.id}})
</p> </p>
<p>Last activity: {{ user.last_activity }} &mdash; Last login: {{ user.last_login }}</p> <p>Last activity: {{ user.last_activity|date }} &mdash; Last login: {{ user.last_login|date }}</p>
</div> </div>
{% endmacro %} {% endmacro %}

View File

@ -11,13 +11,12 @@
<div id="wrapper"> <div id="wrapper">
<div id="header"> <div id="header">
<div id="login-status" {% block login_status_attr %}{% endblock %}> <div id="login-status" {% block login_status_attr %}{% endblock %}>
{# TODO #} {% if current_user.is_authenticated %}
{# {% if current_user.is_authenticated %} <b>{{ current_user.username -}}</b>
<b>{{ current_user.cfg.username -}}</b>
(<a href="{{ url_for('auth.logout') }}">Logout</a>) (<a href="{{ url_for('auth.logout') }}">Logout</a>)
{% else %} #} {% else %}
<a href="#{#{ url_for('auth.login') }#}">Login</a> <a href="{{ url_for('auth.login') }}">Login</a>
{# {% endif %} #} {% endif %}
</div> </div>
{% block header %}{% endblock %} {% block header %}{% endblock %}
</div> </div>

View File

@ -1,4 +1,4 @@
[mypy] [mypy]
ignore_missing_imports = true ignore_missing_imports = true
exclude = "|amanuensis/lexicon/.*|amanuensis/models/.*|amanuensis/resources/.*|amanuensis/server/.*|amanuensis/user/.*|amanuensis/__main__.py|" exclude = "|amanuensis/lexicon/.*|amanuensis/server/.*|amanuensis/server/lexicon/.*|amanuensis/server/session/.*|"
; mypy stable doesn't support pyproject.toml yet ; mypy stable doesn't support pyproject.toml yet

121
poetry.lock generated
View File

@ -28,6 +28,21 @@ docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"]
tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"]
[[package]]
name = "beautifulsoup4"
version = "4.9.3"
description = "Screen-scraping library"
category = "dev"
optional = false
python-versions = "*"
[package.dependencies]
soupsieve = {version = ">1.2", markers = "python_version >= \"3.0\""}
[package.extras]
html5lib = ["html5lib"]
lxml = ["lxml"]
[[package]] [[package]]
name = "black" name = "black"
version = "21.6b0" version = "21.6b0"
@ -50,6 +65,17 @@ d = ["aiohttp (>=3.6.0)", "aiohttp-cors (>=0.4.0)"]
python2 = ["typed-ast (>=1.4.2)"] python2 = ["typed-ast (>=1.4.2)"]
uvloop = ["uvloop (>=0.15.2)"] uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "bs4"
version = "0.0.1"
description = "Dummy package for Beautiful Soup"
category = "dev"
optional = false
python-versions = "*"
[package.dependencies]
beautifulsoup4 = "*"
[[package]] [[package]]
name = "click" name = "click"
version = "8.0.1" version = "8.0.1"
@ -100,17 +126,20 @@ Flask = "*"
[[package]] [[package]]
name = "flask-wtf" name = "flask-wtf"
version = "0.14.3" version = "0.15.1"
description = "Simple integration of Flask and WTForms." description = "Simple integration of Flask and WTForms."
category = "main" category = "main"
optional = false optional = false
python-versions = "*" python-versions = ">= 3.6"
[package.dependencies] [package.dependencies]
Flask = "*" Flask = "*"
itsdangerous = "*" itsdangerous = "*"
WTForms = "*" WTForms = "*"
[package.extras]
email = ["email-validator"]
[[package]] [[package]]
name = "greenlet" name = "greenlet"
version = "1.1.0" version = "1.1.0"
@ -260,9 +289,17 @@ category = "dev"
optional = false optional = false
python-versions = "*" python-versions = "*"
[[package]]
name = "soupsieve"
version = "2.2.1"
description = "A modern CSS selector implementation for Beautiful Soup."
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "1.4.18" version = "1.4.19"
description = "Database Abstraction Library" description = "Database Abstraction Library"
category = "main" category = "main"
optional = false optional = false
@ -353,7 +390,7 @@ locale = ["Babel (>=1.3)"]
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.8" python-versions = "^3.8"
content-hash = "493d96d9f3aa7056057b41877a76b5d4c4bcbd7f0a3c2864e4221024547ded87" content-hash = "97e970853a3db968f05e70b83348d52d1a5aaed12a844b30cc15d039827233d4"
[metadata.files] [metadata.files]
appdirs = [ appdirs = [
@ -368,10 +405,18 @@ attrs = [
{file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"},
{file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"},
] ]
beautifulsoup4 = [
{file = "beautifulsoup4-4.9.3-py2-none-any.whl", hash = "sha256:4c98143716ef1cb40bf7f39a8e3eec8f8b009509e74904ba3a7b315431577e35"},
{file = "beautifulsoup4-4.9.3-py3-none-any.whl", hash = "sha256:fff47e031e34ec82bf17e00da8f592fe7de69aeea38be00523c04623c04fb666"},
{file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"},
]
black = [ black = [
{file = "black-21.6b0-py3-none-any.whl", hash = "sha256:dfb8c5a069012b2ab1e972e7b908f5fb42b6bbabcba0a788b86dc05067c7d9c7"}, {file = "black-21.6b0-py3-none-any.whl", hash = "sha256:dfb8c5a069012b2ab1e972e7b908f5fb42b6bbabcba0a788b86dc05067c7d9c7"},
{file = "black-21.6b0.tar.gz", hash = "sha256:dc132348a88d103016726fe360cb9ede02cecf99b76e3660ce6c596be132ce04"}, {file = "black-21.6b0.tar.gz", hash = "sha256:dc132348a88d103016726fe360cb9ede02cecf99b76e3660ce6c596be132ce04"},
] ]
bs4 = [
{file = "bs4-0.0.1.tar.gz", hash = "sha256:36ecea1fd7cc5c0c6e4a1ff075df26d50da647b75376626cc186e2212886dd3a"},
]
click = [ click = [
{file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"}, {file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"},
{file = "click-8.0.1.tar.gz", hash = "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a"}, {file = "click-8.0.1.tar.gz", hash = "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a"},
@ -389,8 +434,8 @@ flask-login = [
{file = "Flask_Login-0.5.0-py2.py3-none-any.whl", hash = "sha256:7451b5001e17837ba58945aead261ba425fdf7b4f0448777e597ddab39f4fba0"}, {file = "Flask_Login-0.5.0-py2.py3-none-any.whl", hash = "sha256:7451b5001e17837ba58945aead261ba425fdf7b4f0448777e597ddab39f4fba0"},
] ]
flask-wtf = [ flask-wtf = [
{file = "Flask-WTF-0.14.3.tar.gz", hash = "sha256:d417e3a0008b5ba583da1763e4db0f55a1269d9dd91dcc3eb3c026d3c5dbd720"}, {file = "Flask-WTF-0.15.1.tar.gz", hash = "sha256:ff177185f891302dc253437fe63081e7a46a4e99aca61dfe086fb23e54fff2dc"},
{file = "Flask_WTF-0.14.3-py2.py3-none-any.whl", hash = "sha256:57b3faf6fe5d6168bda0c36b0df1d05770f8e205e18332d0376ddb954d17aef2"}, {file = "Flask_WTF-0.15.1-py2.py3-none-any.whl", hash = "sha256:6ff7af73458f182180906a37a783e290bdc8a3817fe4ad17227563137ca285bf"},
] ]
greenlet = [ greenlet = [
{file = "greenlet-1.1.0-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:60848099b76467ef09b62b0f4512e7e6f0a2c977357a036de602b653667f5f4c"}, {file = "greenlet-1.1.0-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:60848099b76467ef09b62b0f4512e7e6f0a2c977357a036de602b653667f5f4c"},
@ -586,37 +631,41 @@ regex = [
{file = "regex-2021.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:97f29f57d5b84e73fbaf99ab3e26134e6687348e95ef6b48cfd2c06807005a07"}, {file = "regex-2021.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:97f29f57d5b84e73fbaf99ab3e26134e6687348e95ef6b48cfd2c06807005a07"},
{file = "regex-2021.4.4.tar.gz", hash = "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb"}, {file = "regex-2021.4.4.tar.gz", hash = "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb"},
] ]
soupsieve = [
{file = "soupsieve-2.2.1-py3-none-any.whl", hash = "sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b"},
{file = "soupsieve-2.2.1.tar.gz", hash = "sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc"},
]
sqlalchemy = [ sqlalchemy = [
{file = "SQLAlchemy-1.4.18-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:d76abceeb6f7c564fdbc304b1ce17ec59664ca7ed0fe6dbc6fc6a960c91370e3"}, {file = "SQLAlchemy-1.4.19-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:ddbce8fe4d0190db21db602e38aaf4c158c540b49f1ef7475323ec682a9fbf2d"},
{file = "SQLAlchemy-1.4.18-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4cdc91bb3ee5b10e24ec59303131b791f3f82caa4dd8b36064d1918b0f4d0de4"}, {file = "SQLAlchemy-1.4.19-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:942ca49b7ec7449d2473a6587825c55ad99534ddfc4eee249dd42be3cc1aa8c9"},
{file = "SQLAlchemy-1.4.18-cp27-cp27m-win32.whl", hash = "sha256:3690fc0fc671419debdae9b33df1434ac9253155fd76d0f66a01f7b459d56ee6"}, {file = "SQLAlchemy-1.4.19-cp27-cp27m-win32.whl", hash = "sha256:9c0945c79cbe507b49524e31a4bb8700060bbccb60bb553df6432e176baff3d5"},
{file = "SQLAlchemy-1.4.18-cp27-cp27m-win_amd64.whl", hash = "sha256:5b827d3d1d982b38d2bab551edf9893c4734b5db9b852b28d3bc809ea7e179f6"}, {file = "SQLAlchemy-1.4.19-cp27-cp27m-win_amd64.whl", hash = "sha256:6fd1b745ade2020a1a7bf1e22536d8afe86287882c81ca5d860bdf231d5854e9"},
{file = "SQLAlchemy-1.4.18-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:495cce8174c670f1d885e2259d710b0120888db2169ea14fc32d1f72e7950642"}, {file = "SQLAlchemy-1.4.19-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:0fb3f73e5009f5a4c9b24469939d3d57cc3ad8099a09c0cfefc47fe45ab7ffbe"},
{file = "SQLAlchemy-1.4.18-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:60cfe1fb59a34569816907cb25bb256c9490824679c46777377bcc01f6813a81"}, {file = "SQLAlchemy-1.4.19-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:64eab458619ef759f16f0f82242813d3289e829f8557fbc7c212ca4eadf96472"},
{file = "SQLAlchemy-1.4.18-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3357948fa439eb5c7241a8856738605d7ab9d9f276ca5c5cc3220455a5f8e6c"}, {file = "SQLAlchemy-1.4.19-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:311051c06f905774427b4a92dcb3924d6ee563dea3a88176da02fdfc572d0d1d"},
{file = "SQLAlchemy-1.4.18-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:93394d68f02ecbf8c0a4355b6452793000ce0ee7aef79d2c85b491da25a88af7"}, {file = "SQLAlchemy-1.4.19-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a34a7fd3353ee61a1dca72fc0c3e38d4e56bdc2c343e712f60a8c70acd4ef5bf"},
{file = "SQLAlchemy-1.4.18-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56958dd833145f1aa75f8987dfe0cf6f149e93aa31967b7004d4eb9cb579fefc"}, {file = "SQLAlchemy-1.4.19-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ace9ab2af9d7d7b0e2ff2178809941c56ab8921e38128278192a73a8a1c08a2"},
{file = "SQLAlchemy-1.4.18-cp36-cp36m-win32.whl", hash = "sha256:664c6cc84a5d2bad2a4a3984d146b6201b850ba0a7125b2fcd29ca06cddac4b1"}, {file = "SQLAlchemy-1.4.19-cp36-cp36m-win32.whl", hash = "sha256:96d3d4a7ead376d738775a1fa9786dc17a31975ec664cea284e53735c79a5686"},
{file = "SQLAlchemy-1.4.18-cp36-cp36m-win_amd64.whl", hash = "sha256:77549e5ae996de50ad9f69f863c91daf04842b14233e133335b900b152bffb07"}, {file = "SQLAlchemy-1.4.19-cp36-cp36m-win_amd64.whl", hash = "sha256:20f4bf1459548a74aade997cb045015e4d72f0fde1789b09b3bb380be28f6511"},
{file = "SQLAlchemy-1.4.18-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:e2aa39fdf5bff1c325a8648ac1957a0320c66763a3fa5f0f4a02457b2afcf372"}, {file = "SQLAlchemy-1.4.19-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:8cba69545246d16c6d2a12ce45865947cbdd814bacddf2e532fdd4512e70728c"},
{file = "SQLAlchemy-1.4.18-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffb18eb56546aa66640fef831e5d0fe1a8dfbf11cdf5b00803826a01dbbbf3b1"}, {file = "SQLAlchemy-1.4.19-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57ba8a96b6d058c7dcf44de8ac0955b7a787f7177a0221dd4b8016e0191268f5"},
{file = "SQLAlchemy-1.4.18-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cc474d0c40cef94d9b68980155d686d5ad43a9ca0834a8729052d3585f289d57"}, {file = "SQLAlchemy-1.4.19-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8f1e7f4de05c15d6b46af12f3cf0c2552f2940d201a49926703249a62402d851"},
{file = "SQLAlchemy-1.4.18-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d4b2c23d20acf631456e645227cef014e7f84a111118d530cfa1d6053fd05a9"}, {file = "SQLAlchemy-1.4.19-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c92d9ebf4b38c22c0c9e4f203a80e101910a50dc555b4578816932015b97d7f"},
{file = "SQLAlchemy-1.4.18-cp37-cp37m-win32.whl", hash = "sha256:45bbb935b305e381bcb542bf4d952232282ba76881e3458105e4733ba0976060"}, {file = "SQLAlchemy-1.4.19-cp37-cp37m-win32.whl", hash = "sha256:c6efc7477551ba9ce632d5c3b448b7de0277c86005eec190a1068fcc7115fd0e"},
{file = "SQLAlchemy-1.4.18-cp37-cp37m-win_amd64.whl", hash = "sha256:3a6afb7a55374329601c8fcad277f0a47793386255764431c8f6a231a6947ee9"}, {file = "SQLAlchemy-1.4.19-cp37-cp37m-win_amd64.whl", hash = "sha256:e2761b925fda550debfd5a8bc3cef9debc9a23c6a280429c4ec3a07c35c6b4b3"},
{file = "SQLAlchemy-1.4.18-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:9a62b06ad450386a2e671d0bcc5cd430690b77a5cd41c54ede4e4bf46d7a4978"}, {file = "SQLAlchemy-1.4.19-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:58d4f79d119010fdced6e7fd7e4b9f2230dbf55a8235d7c58b1c8207ef74791b"},
{file = "SQLAlchemy-1.4.18-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70674f2ff315a74061da7af1225770578d23f4f6f74dd2e1964493abd8d804bc"}, {file = "SQLAlchemy-1.4.19-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cefd44faca7c57534503261f6fab49bd47eb9c2945ee0bab09faaa8cb047c24f"},
{file = "SQLAlchemy-1.4.18-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4f375c52fed5f2ecd06be18756f121b3167a1fdc4543d877961fba04b1713214"}, {file = "SQLAlchemy-1.4.19-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9133635edcec1e7fbfc16eba5dc2b5b3b11818d25b7a57cfcbfa8d3b3e9594fd"},
{file = "SQLAlchemy-1.4.18-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eba098a4962e1ab0d446c814ae67e30da82c446b382cf718306cc90d4e2ad85f"}, {file = "SQLAlchemy-1.4.19-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3cf5f543d048a7c8da500133068c5c90c97a2c4bf0c027928a85028a519f33d"},
{file = "SQLAlchemy-1.4.18-cp38-cp38-win32.whl", hash = "sha256:ee3428f6100ff2b07e7ecec6357d865a4d604c801760094883587ecdbf8a3533"}, {file = "SQLAlchemy-1.4.19-cp38-cp38-win32.whl", hash = "sha256:d04160462f874eaa4d88721a0d5ecca8ebf433616801efe779f252ef87b0e216"},
{file = "SQLAlchemy-1.4.18-cp38-cp38-win_amd64.whl", hash = "sha256:5c62fff70348e3f8e4392540d31f3b8c251dc8eb830173692e5d61896d4309d6"}, {file = "SQLAlchemy-1.4.19-cp38-cp38-win_amd64.whl", hash = "sha256:45b0f773e195d8d51e2fd67cb5b5fb32f5a1f5e7f0752016207091bed108909a"},
{file = "SQLAlchemy-1.4.18-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:8924d552decf1a50d57dca4984ebd0778a55ca2cb1c0ef16df8c1fed405ff290"}, {file = "SQLAlchemy-1.4.19-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:93ba458b3c279581288a10a55df2aa6ac3509882228fcbad9d9d88069f899337"},
{file = "SQLAlchemy-1.4.18-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:284b6df04bc30e886998e0fdbd700ef9ffb83bcb484ffc54d4084959240dce91"}, {file = "SQLAlchemy-1.4.19-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6317701c06a829b066c794545512bb70b1a10a74574cfa5658a0aaf49f31aa93"},
{file = "SQLAlchemy-1.4.18-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:146af9e67d0f821b28779d602372e65d019db01532d8f7101e91202d447c14ec"}, {file = "SQLAlchemy-1.4.19-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:95a9fd0a11f89a80d8815418eccba034f3fec8ea1f04c41b6b8decc5c95852e9"},
{file = "SQLAlchemy-1.4.18-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2129d33b54da4d4771868a3639a07f461adc5887dbd9e0a80dbf560272245525"}, {file = "SQLAlchemy-1.4.19-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9014fd1d8aebcb4eb6bc69a382dd149200e1d5924412b1d08b4443f6c1ce526f"},
{file = "SQLAlchemy-1.4.18-cp39-cp39-win32.whl", hash = "sha256:0653d444d52f2b9a0cba1ea5cd0fc64e616ee3838ee86c1863781b2a8670fc0c"}, {file = "SQLAlchemy-1.4.19-cp39-cp39-win32.whl", hash = "sha256:fa05a77662c23226c9ec031638fd90ae767009e05cd092b948740f09d10645f0"},
{file = "SQLAlchemy-1.4.18-cp39-cp39-win_amd64.whl", hash = "sha256:c824d14b52000597dfcced0a4e480fd8664b09fed606e746a2c67fe5fbe8dfd9"}, {file = "SQLAlchemy-1.4.19-cp39-cp39-win_amd64.whl", hash = "sha256:d7b21a4b62921cf6dca97e8f9dea1fbe2432aebbb09895a2bd4f527105af41a4"},
{file = "SQLAlchemy-1.4.18.tar.gz", hash = "sha256:d25210f5f1a6b7b6b357d8fa199fc1d5be828c67cc1af517600c02e5b2727e4c"}, {file = "SQLAlchemy-1.4.19.tar.gz", hash = "sha256:89a5a13dcf33b7e47c7a9404a297c836965a247c7f076a0fe0910cae2bee5ce2"},
] ]
toml = [ toml = [
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},

View File

@ -8,24 +8,25 @@ authors = ["Tim Van Baak <tim.vanbaak@gmail.com>"]
python = "^3.8" python = "^3.8"
Flask = "^2.0.1" Flask = "^2.0.1"
Flask-Login = "^0.5.0" Flask-Login = "^0.5.0"
Flask-WTF = "^0.14.3" Flask-WTF = "^0.15.1"
SQLAlchemy = "^1.4.12" SQLAlchemy = "^1.4.12"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pytest = "^5.2" pytest = "^5.2"
black = "^21.5b2" black = "^21.5b2"
mypy = "^0.812" mypy = "^0.812"
bs4 = "^0.0.1"
[tool.poetry.scripts] [tool.poetry.scripts]
amanuensis-cli = "amanuensis.cli:main" amanuensis-cli = "amanuensis.cli:main"
amanuensis-server = "amanuensis.server:run" amanuensis-server = "amanuensis.server:run"
[tool.black] [tool.black]
extend-exclude = "^/amanuensis/lexicon/.*|^/amanuensis/models/.*|^/amanuensis/resources/.*|^/amanuensis/server/.*|^/amanuensis/user/.*|^/amanuensis/__main__.py" extend-exclude = "^/amanuensis/lexicon/.*|^/amanuensis/server/[^/]*py|^/amanuensis/server/lexicon/.*|^/amanuensis/server/session/.*|"
[tool.mypy] [tool.mypy]
ignore_missing_imports = true ignore_missing_imports = true
exclude = "amanuensis/cli/.*|amanuensis/config/.*|amanuensis/lexicon/.*|amanuensis/log/.*|amanuensis/models/.*|amanuensis/resources/.*|amanuensis/server/.*|amanuensis/user/.*|amanuensis/__main__.py" exclude = "|amanuensis/lexicon/.*|amanuensis/server/.*|amanuensis/server/lexicon/.*|amanuensis/server/session/.*|"
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "--show-capture=log" addopts = "--show-capture=log"

View File

@ -1,5 +1,5 @@
[pytest] [pytest]
addopts = --show-capture=log addopts = --show-capture=stdout
; pytest should be able to read the pyproject.toml file, but for some reason it ; pytest should be able to read the pyproject.toml file, but for some reason it
; doesn't seem to be working here. This file is a temporary fix until that gets ; doesn't seem to be working here. This file is a temporary fix until that gets
; resolved. ; resolved.

View File

@ -1,10 +1,9 @@
from amanuensis.db.models import Lexicon
import datetime import datetime
import pytest import pytest
from amanuensis.db import DbContext
import amanuensis.backend.lexicon as lexiq import amanuensis.backend.lexicon as lexiq
from amanuensis.db import DbContext, Lexicon, User
from amanuensis.errors import ArgumentError from amanuensis.errors import ArgumentError
@ -51,3 +50,50 @@ def test_create_lexicon(db: DbContext):
# No duplicate lexicon names # No duplicate lexicon names
with pytest.raises(ArgumentError): with pytest.raises(ArgumentError):
lexiq.create(**defaults) lexiq.create(**defaults)
def test_lexicon_from(db: DbContext, make):
"""Test lexiq.from_*."""
lexicon1: Lexicon = make.lexicon()
lexicon2: Lexicon = make.lexicon()
assert lexiq.from_name(db, lexicon1.name) == lexicon1
assert lexiq.from_name(db, lexicon2.name) == lexicon2
def test_get_lexicon(db: DbContext, make):
"""Test the various scoped get functions."""
user: User = make.user()
public_joined: Lexicon = make.lexicon()
public_joined.public = True
make.membership(user_id=user.id, lexicon_id=public_joined.id)
private_joined: Lexicon = make.lexicon()
private_joined.public = False
make.membership(user_id=user.id, lexicon_id=private_joined.id)
public_open: Lexicon = make.lexicon()
public_open.public = True
db.session.commit()
private_open: Lexicon = make.lexicon()
private_open.public = False
db.session.commit()
get_all = list(lexiq.get_all(db))
assert public_joined in get_all
assert private_joined in get_all
assert public_open in get_all
assert private_open in get_all
get_joined = list(lexiq.get_joined(db, user.id))
assert public_joined in get_joined
assert private_joined in get_joined
assert public_open not in get_joined
assert private_open not in get_joined
get_public = list(lexiq.get_public(db))
assert public_joined in get_public
assert private_joined not in get_public
assert public_open in get_public
assert private_open not in get_public

View File

@ -1,8 +1,9 @@
from amanuensis.db.models import User import os
import pytest import pytest
from amanuensis.db import DbContext
import amanuensis.backend.user as userq import amanuensis.backend.user as userq
from amanuensis.db import DbContext, User
from amanuensis.errors import ArgumentError from amanuensis.errors import ArgumentError
@ -50,3 +51,26 @@ def test_create_user(db: DbContext):
user2_kw: dict = {**defaults, "username": "user2", "display_name": None} user2_kw: dict = {**defaults, "username": "user2", "display_name": None}
user2: User = userq.create(**user2_kw) user2: User = userq.create(**user2_kw)
assert user2.display_name is not None assert user2.display_name is not None
def test_user_from(db: DbContext, make):
"""Test userq.from_*."""
user1: User = make.user()
user2: User = make.user()
assert userq.from_id(db, user1.id) == user1
assert userq.from_username(db, user1.username) == user1
assert userq.from_id(db, user2.id) == user2
assert userq.from_username(db, user2.username) == user2
def test_user_password(db: DbContext, make):
"""Test user password functions."""
pw1 = os.urandom(8).hex()
pw2 = os.urandom(8).hex()
user: User = make.user(password=pw1)
assert userq.password_check(db, user.username, pw1)
assert not userq.password_check(db, user.username, pw2)
userq.password_set(db, user.username, pw2)
assert not userq.password_check(db, user.username, pw1)
assert userq.password_check(db, user.username, pw2)

View File

@ -1,31 +1,88 @@
""" """
pytest test fixtures pytest test fixtures
""" """
import os
import pytest import pytest
import tempfile
from typing import Optional
from bs4 import BeautifulSoup
from flask.testing import FlaskClient
from sqlalchemy.orm.session import close_all_sessions
from amanuensis.db import DbContext
import amanuensis.backend.character as charq import amanuensis.backend.character as charq
import amanuensis.backend.lexicon as lexiq import amanuensis.backend.lexicon as lexiq
import amanuensis.backend.membership as memq import amanuensis.backend.membership as memq
import amanuensis.backend.user as userq import amanuensis.backend.user as userq
from amanuensis.config import AmanuensisConfig from amanuensis.config import AmanuensisConfig
from amanuensis.db import DbContext, User, Lexicon, Membership, Character
from amanuensis.server import get_app from amanuensis.server import get_app
@pytest.fixture @pytest.fixture
def db() -> DbContext: def db(request) -> DbContext:
"""Provides an initialized database in memory.""" """Provides a fully-initialized ephemeral database."""
db = DbContext("sqlite:///:memory:", debug=False) db_fd, db_path = tempfile.mkstemp()
db = DbContext(path=db_path, echo=False)
db.create_all() db.create_all()
def db_teardown():
close_all_sessions()
os.close(db_fd)
os.unlink(db_path)
request.addfinalizer(db_teardown)
return db return db
@pytest.fixture class UserClient:
def make_user(db: DbContext): """Class encapsulating user web operations."""
"""Provides a factory function for creating users, with valid default values."""
def user_factory(state={"nonce": 1}, **kwargs): def __init__(self, db: DbContext, user_id: int):
default_kwargs = { self.db = db
self.user_id = user_id
def login(self, client: FlaskClient):
"""Log the user in."""
user: Optional[User] = userq.from_id(self.db, self.user_id)
assert user is not None
# Set the user's password so we know what it is later
password = os.urandom(8).hex()
userq.password_set(self.db, user.username, password)
# Log in
response = client.get("/auth/login/")
assert response.status_code == 200
soup = BeautifulSoup(response.data, features="html.parser")
csrf_token = soup.find(id="csrf_token")
assert csrf_token is not None
response = client.post(
"/auth/login/",
data={
"username": user.username,
"password": password,
"csrf_token": csrf_token["value"],
},
)
assert 300 <= response.status_code <= 399
def logout(self, client: FlaskClient):
"""Log the user out."""
response = client.get("/auth/logout/")
assert 300 <= response.status_code <= 399
class ObjectFactory:
"""Factory class."""
def __init__(self, db):
self.db = db
def user(self, state={"nonce": 1}, **kwargs) -> User:
"""Factory function for creating users, with valid default values."""
default_kwargs: dict = {
"username": f'test_user_{state["nonce"]}', "username": f'test_user_{state["nonce"]}',
"password": "password", "password": "password",
"display_name": None, "display_name": None,
@ -33,87 +90,54 @@ def make_user(db: DbContext):
"is_site_admin": False, "is_site_admin": False,
} }
state["nonce"] += 1 state["nonce"] += 1
updated_kwargs = {**default_kwargs, **kwargs} updated_kwargs: dict = {**default_kwargs, **kwargs}
return userq.create(db, **updated_kwargs) return userq.create(self.db, **updated_kwargs)
return user_factory def lexicon(self, state={"nonce": 1}, **kwargs) -> Lexicon:
"""Factory function for creating lexicons, with valid default values."""
default_kwargs: dict = {
@pytest.fixture
def make_lexicon(db: DbContext):
"""Provides a factory function for creating lexicons, with valid default values."""
def lexicon_factory(state={"nonce": 1}, **kwargs):
default_kwargs = {
"name": f'Test_{state["nonce"]}', "name": f'Test_{state["nonce"]}',
"title": None, "title": None,
"prompt": f'Test Lexicon game {state["nonce"]}', "prompt": f'Test Lexicon game {state["nonce"]}',
} }
state["nonce"] += 1 state["nonce"] += 1
updated_kwargs = {**default_kwargs, **kwargs} updated_kwargs: dict = {**default_kwargs, **kwargs}
lex = lexiq.create(db, **updated_kwargs) lex = lexiq.create(self.db, **updated_kwargs)
lex.joinable = True lex.joinable = True
db.session.commit() self.db.session.commit()
return lex return lex
return lexicon_factory def membership(self, **kwargs) -> Membership:
"""Factory function for creating memberships, with valid default values."""
default_kwargs: dict = {
@pytest.fixture
def make_membership(db: DbContext):
"""Provides a factory function for creating memberships, with valid default values."""
def membership_factory(**kwargs):
default_kwargs = {
"is_editor": False, "is_editor": False,
} }
updated_kwargs = {**default_kwargs, **kwargs} updated_kwargs: dict = {**default_kwargs, **kwargs}
return memq.create(db, **updated_kwargs) return memq.create(self.db, **updated_kwargs)
return membership_factory def character(self, state={"nonce": 1}, **kwargs) -> Character:
"""Factory function for creating characters, with valid default values."""
default_kwargs: dict = {
@pytest.fixture
def make_character(db: DbContext):
"""Provides a factory function for creating characters, with valid default values."""
def character_factory(state={"nonce": 1}, **kwargs):
default_kwargs = {
"name": f'Character {state["nonce"]}', "name": f'Character {state["nonce"]}',
"signature": None, "signature": None,
} }
state["nonce"] += 1 state["nonce"] += 1
updated_kwargs = {**default_kwargs, **kwargs} updated_kwargs: dict = {**default_kwargs, **kwargs}
return charq.create(db, **updated_kwargs) return charq.create(self.db, **updated_kwargs)
return character_factory def client(self, user_id: int) -> UserClient:
"""Factory function for user test clients."""
return UserClient(self.db, user_id)
class TestFactory:
def __init__(self, db, **factories):
self.db = db
self.factories = factories
def __getattr__(self, name):
return self.factories[name]
@pytest.fixture @pytest.fixture
def make( def make(db: DbContext) -> ObjectFactory:
db: DbContext, make_user, make_lexicon, make_membership, make_character """Fixture that provides a factory class."""
) -> TestFactory: return ObjectFactory(db)
"""Fixture that groups all factory fixtures together."""
return TestFactory(
db,
user=make_user,
lexicon=make_lexicon,
membership=make_membership,
character=make_character,
)
@pytest.fixture @pytest.fixture
def lexicon_with_editor(make): def lexicon_with_editor(make: ObjectFactory):
"""Shortcut setup for a lexicon game with an editor.""" """Shortcut setup for a lexicon game with an editor."""
editor = make.user() editor = make.user()
assert editor assert editor
@ -128,12 +152,10 @@ def lexicon_with_editor(make):
class TestConfig(AmanuensisConfig): class TestConfig(AmanuensisConfig):
TESTING = True TESTING = True
SECRET_KEY = "secret key" SECRET_KEY = os.urandom(32).hex()
DATABASE_URI = "sqlite:///:memory:"
@pytest.fixture @pytest.fixture
def app(db): def app(db: DbContext):
"""Provides an application running on top of the test database.""" """Provides an application running on top of the test database."""
server_app = get_app(TestConfig, db) return get_app(TestConfig(), db)
return server_app

56
tests/test_auth.py Normal file
View File

@ -0,0 +1,56 @@
import os
from urllib.parse import urlsplit
from bs4 import BeautifulSoup
from flask import Flask
from amanuensis.db import User
def test_auth_circuit(app: Flask, make):
"""Test the user login/logout path."""
username: str = f"user_{os.urandom(8).hex()}"
ub: bytes = username.encode("utf8")
assert make.user(username=username, password=username)
with app.test_client() as client:
# User should not be logged in
response = client.get("/home/")
assert response.status_code == 200
assert ub not in response.data
# The login page exists
response = client.get("/auth/login/")
assert response.status_code == 200
assert ub not in response.data
assert b"Username" in response.data
assert b"Username" in response.data
assert b"csrf_token" in response.data
# Get the csrf token for logging in
soup = BeautifulSoup(response.data, features="html.parser")
csrf_token = soup.find(id="csrf_token")["value"]
assert csrf_token
# Log the user in
response = client.post(
"/auth/login/",
data={"username": username, "password": username, "csrf_token": csrf_token},
)
assert 300 <= response.status_code <= 399
assert urlsplit(response.location).path == "/home/"
# Confirm that the user is logged in
response = client.get("/home/")
assert response.status_code == 200
assert ub in response.data
# Log the user out
response = client.get("/auth/logout/")
assert 300 <= response.status_code <= 399
assert urlsplit(response.location).path == "/home/"
# Confirm the user is logged out
response = client.get("/home/")
assert response.status_code == 200
assert ub not in response.data

45
tests/test_home.py Normal file
View File

@ -0,0 +1,45 @@
import os
from urllib.parse import urlsplit
from flask import Flask
from amanuensis.db import DbContext, User, Lexicon
from .conftest import ObjectFactory, UserClient
def test_game_visibility(db: DbContext, app: Flask, make: ObjectFactory):
"""Test lexicon visibility settings."""
user: User = make.user()
auth: UserClient = make.client(user.id)
public_joined: Lexicon = make.lexicon()
public_joined.public = True
make.membership(user_id=auth.user_id, lexicon_id=public_joined.id)
public_joined_title = public_joined.full_title
private_joined: Lexicon = make.lexicon()
private_joined.public = False
make.membership(user_id=auth.user_id, lexicon_id=private_joined.id)
private_joined_title = private_joined.full_title
public_open: Lexicon = make.lexicon()
public_open.public = True
db.session.commit()
public_open_title = public_open.full_title
private_open: Lexicon = make.lexicon()
private_open.public = False
db.session.commit()
private_open_title = private_open.full_title
with app.test_client() as client:
auth.login(client)
# Check that lexicons appear if they should
response = client.get("/home/")
assert response.status_code == 200
assert public_joined_title.encode("utf8") in response.data
assert private_joined_title.encode("utf8") in response.data
assert public_open_title.encode("utf8") in response.data
assert private_open_title.encode("utf8") not in response.data