From d5b6a4ea27f8c905e613363aab365066ad6d9cda Mon Sep 17 00:00:00 2001 From: Jules Laplace Date: Fri, 5 Mar 2021 18:08:17 +0100 Subject: auth stuff. generate secret and create user from the cli --- animism-align/README.md | 2 +- .../cli/app/controllers/crud_controller.py | 6 + .../cli/app/controllers/upload_controller.py | 5 + .../cli/app/controllers/user_controller.py | 38 ++++++ animism-align/cli/app/server/web.py | 4 + animism-align/cli/app/settings/app_cfg.py | 14 ++ animism-align/cli/app/sql/models/user.py | 2 +- .../versions/202103051807_make_username_unique.py | 41 ++++++ animism-align/cli/app/utils/auth_utils.py | 31 +++++ animism-align/cli/commands/admin/createuser.py | 28 ++++ .../cli/commands/admin/migrate_to_mysql.py | 148 +++++++++++++++++++++ animism-align/cli/commands/admin/secret.py | 7 + animism-align/cli/commands/site/export.py | 107 +++++++++++---- .../cli/commands/site/migrate_to_mysql.py | 148 --------------------- .../frontend/app/views/viewer/nav/viewer.router.js | 5 + .../frontend/app/views/viewer/viewer.actions.js | 1 - animism-align/frontend/site/app.js | 4 +- animism-align/frontend/site/site/site.actions.js | 8 ++ 18 files changed, 421 insertions(+), 178 deletions(-) create mode 100644 animism-align/cli/app/controllers/user_controller.py create mode 100644 animism-align/cli/app/sql/versions/202103051807_make_username_unique.py create mode 100644 animism-align/cli/app/utils/auth_utils.py create mode 100644 animism-align/cli/commands/admin/createuser.py create mode 100644 animism-align/cli/commands/admin/migrate_to_mysql.py create mode 100644 animism-align/cli/commands/admin/secret.py delete mode 100644 animism-align/cli/commands/site/migrate_to_mysql.py (limited to 'animism-align') diff --git a/animism-align/README.md b/animism-align/README.md index ae3fd39..ac00d94 100644 --- a/animism-align/README.md +++ b/animism-align/README.md @@ -59,7 +59,7 @@ Generate a new migration if you've modified the database: For production, export a static version of the episode. This will make an exported folder in `data_store/exports/` which you can zip and upload somewhere. ``` -./cli.py site export -o animism +./cli.py site export -o animism --sync ./cli.py viewer run ``` diff --git a/animism-align/cli/app/controllers/crud_controller.py b/animism-align/cli/app/controllers/crud_controller.py index 595825d..4fcb77d 100644 --- a/animism-align/cli/app/controllers/crud_controller.py +++ b/animism-align/cli/app/controllers/crud_controller.py @@ -1,6 +1,7 @@ from flask import request, jsonify from flask_classful import FlaskView, route from werkzeug.datastructures import MultiDict +from flask_jwt import jwt_required from app.sql.common import db, Session from app.server.helpers import parse_search_args, parse_sort_args @@ -28,6 +29,7 @@ class CrudView(FlaskView): def on_destroy(self, session, item): pass + @jwt_required() def index(self): """ List all {model}s @@ -53,6 +55,7 @@ class CrudView(FlaskView): session.close() return jsonify(res) + @jwt_required() def get(self, id: int): """ Fetch a single {model}. @@ -72,6 +75,7 @@ class CrudView(FlaskView): session.close() return jsonify(result) + @jwt_required() def post(self): """ Create a new {model}. @@ -99,6 +103,7 @@ class CrudView(FlaskView): session.close() return jsonify(res) + @jwt_required() def put(self, id: int): """ Update a {model}. @@ -133,6 +138,7 @@ class CrudView(FlaskView): session.close() return jsonify(res) + @jwt_required() def delete(self, id: int): """ Delete a {model}. diff --git a/animism-align/cli/app/controllers/upload_controller.py b/animism-align/cli/app/controllers/upload_controller.py index 3b6e661..f363b0d 100644 --- a/animism-align/cli/app/controllers/upload_controller.py +++ b/animism-align/cli/app/controllers/upload_controller.py @@ -1,5 +1,6 @@ from flask import request, jsonify from flask_classful import FlaskView, route +from flask_jwt import jwt_required from werkzeug.datastructures import MultiDict from werkzeug.utils import secure_filename import os @@ -13,6 +14,7 @@ from app.utils.file_utils import sha256_stream, sha256_tree, VALID_IMAGE_EXTS from app.server.decorators import APIError class UploadView(FlaskView): + @jwt_required() def index(self): """ List all uploaded files. @@ -28,6 +30,7 @@ class UploadView(FlaskView): session.close() return jsonify(response) + @jwt_required() def get(self, id): """ Fetch a single upload. @@ -41,6 +44,7 @@ class UploadView(FlaskView): session.close() return jsonify(response) + @jwt_required() def post(self): """ Upload a new file. @@ -119,6 +123,7 @@ class UploadView(FlaskView): session.close() return jsonify(response) + @jwt_required() def delete(self, id): """ Delete an uploaded file. diff --git a/animism-align/cli/app/controllers/user_controller.py b/animism-align/cli/app/controllers/user_controller.py new file mode 100644 index 0000000..8d14b98 --- /dev/null +++ b/animism-align/cli/app/controllers/user_controller.py @@ -0,0 +1,38 @@ +from flask import request, jsonify, redirect +from flask_classful import route +from werkzeug.datastructures import MultiDict + +from app.sql.common import db, Session +from app.sql.models.user import User, UserForm +from app.controllers.crud_controller import CrudView + +from flask_jwt import current_identity + +class UserView(CrudView): + model = User + form = UserForm + + def on_create(self, session, form, item): + if not current_identity.is_admin: + raise ValueError("Unauthorized") + if 'password' in form: + item.password = encrypt_password(form['password']) + if 'settings' in form: + item.settings = form['settings'] + + def on_update(self, session, form, item): + if not current_identity.is_admin: + if item.id != current_identity.id: + raise ValueError("Unauthorized") + if current_identity.is_admin != item.is_admin: + raise ValueError("Unauthorized") + if 'password' in form: + item.password = encrypt_password(form['password']) + if 'settings' in form: + item.settings = form['settings'] + + def on_destroy(self, session, form, item): + if not current_identity.is_admin: + raise ValueError("Unauthorized") + if item.id == current_identity.id: + raise ValueError("Unauthorized") diff --git a/animism-align/cli/app/server/web.py b/animism-align/cli/app/server/web.py index 890bd35..58754a2 100644 --- a/animism-align/cli/app/server/web.py +++ b/animism-align/cli/app/server/web.py @@ -14,6 +14,7 @@ logging.getLogger().addHandler(logging.StreamHandler()) from flask import Flask, Blueprint, send_from_directory, request from app.sql.common import db, connection_url +from app.utils.auth_utils import setup_jwt from app.settings import app_cfg from app.controllers.annotation_controller import AnnotationView @@ -33,9 +34,12 @@ def create_app(script_info=None): app.config['SQLALCHEMY_DATABASE_URI'] = connection_url app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SERVER_NAME'] = app_cfg.SERVER_NAME + app.config['SECRET_KEY'] = app_cfg.TOKEN_SECRET + app.config['JWT_AUTH_URL_RULE'] = '/api/v1/auth/login' app.url_map.strict_slashes = False db.init_app(app) + setup_jwt(app) AnnotationView.register(app, route_prefix='/api/v1/') ParagraphView.register(app, route_prefix='/api/v1/') diff --git a/animism-align/cli/app/settings/app_cfg.py b/animism-align/cli/app/settings/app_cfg.py index dfd0154..ceb9e43 100644 --- a/animism-align/cli/app/settings/app_cfg.py +++ b/animism-align/cli/app/settings/app_cfg.py @@ -33,6 +33,7 @@ load_dotenv(dotenv_path=fp_env) CLICK_GROUPS = { 'peaks': 'commands/peaks', 'site': 'commands/site', + 'admin': 'commands/admin', 'db': '', 'flask': '', 'viewer': '', @@ -83,6 +84,19 @@ try: except Exception as e: pass +# ----------------------------------------------------------------------------- +# Le crypto +# ----------------------------------------------------------------------------- + +TOKEN_SECRET = os.getenv("TOKEN_SECRET") or None +if TOKEN_SECRET is None: + import random + raise ValueError("Please set a token secret, e.g. " + hex(random.getrandbits(64 * 8)).replace('0x','')) + +TOKEN_SECRET_BYTES = bytearray() +TOKEN_SECRET_BYTES.extend(map(ord, TOKEN_SECRET)) + + # ----------------------------------------------------------------------------- # Exports # ----------------------------------------------------------------------------- diff --git a/animism-align/cli/app/sql/models/user.py b/animism-align/cli/app/sql/models/user.py index bbc6eef..85549da 100644 --- a/animism-align/cli/app/sql/models/user.py +++ b/animism-align/cli/app/sql/models/user.py @@ -12,7 +12,7 @@ class User(Base): """Table for storing the user list""" __tablename__ = 'user' id = Column(Integer, primary_key=True) - username = Column(String(256, convert_unicode=True), nullable=False) + username = Column(String(256, convert_unicode=True), nullable=False, unique=True) password = Column(String(256, convert_unicode=True), nullable=False) is_admin = Column(Boolean, default=False) settings = Column(JSON, default={}, nullable=True) diff --git a/animism-align/cli/app/sql/versions/202103051807_make_username_unique.py b/animism-align/cli/app/sql/versions/202103051807_make_username_unique.py new file mode 100644 index 0000000..e8f4985 --- /dev/null +++ b/animism-align/cli/app/sql/versions/202103051807_make_username_unique.py @@ -0,0 +1,41 @@ +"""make username unique + +Revision ID: 135ba3ff136a +Revises: 5de5fdfbe69a +Create Date: 2021-03-05 18:07:36.955364 + +""" +from alembic import op +import sqlalchemy as sa +import sqlalchemy_utc +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '135ba3ff136a' +down_revision = '5de5fdfbe69a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('episode', 'episode_number', + existing_type=mysql.INTEGER(), + nullable=True) + op.alter_column('episode', 'release_date', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=256), + nullable=True) + op.create_unique_constraint(None, 'user', ['username']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'user', type_='unique') + op.alter_column('episode', 'release_date', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=256), + nullable=False) + op.alter_column('episode', 'episode_number', + existing_type=mysql.INTEGER(), + nullable=False) + # ### end Alembic commands ### diff --git a/animism-align/cli/app/utils/auth_utils.py b/animism-align/cli/app/utils/auth_utils.py new file mode 100644 index 0000000..71974e3 --- /dev/null +++ b/animism-align/cli/app/utils/auth_utils.py @@ -0,0 +1,31 @@ +from flask_jwt import JWT + +import hmac +import hashlib +from app.settings import app_cfg + +from app.sql.common import db, Session, User + +def encrypt_password(cleartext): + clearbytes = bytearray() + clearbytes.extend(map(ord, cleartext)) + return hmac.new(app_cfg.TOKEN_SECRET_BYTES, clearbytes, hashlib.sha256).hexdigest() + +def authenticate(username, password): + session = Session() + password = encrypt_password(password) + user = session.query(User).filter(User.username == username).first() + session.close() + if user and hmac.compare_digest(user.password.encode('utf-8'), password.encode('utf-8')): + return user + return None + +def identity(payload): + session = Session() + user_id = payload['identity'] + user = session.query(User).get(user_id) + session.close() + return user + +def setup_jwt(app): + return JWT(app, authenticate, identity) diff --git a/animism-align/cli/commands/admin/createuser.py b/animism-align/cli/commands/admin/createuser.py new file mode 100644 index 0000000..ef90f1e --- /dev/null +++ b/animism-align/cli/commands/admin/createuser.py @@ -0,0 +1,28 @@ +import click + +@click.command('createuser') +@click.pass_context +def cli(ctx): + from getpass import getpass + from app.utils.auth_utils import encrypt_password + from app.sql.common import db, Session, User + + username = input("Username: ") + session = Session() + user_exists = session.query(User).filter(User.username == username).first() + if user_exists: + session.close() + raise ValueError("User already exists") + + password = encrypt_password(getpass()) + is_admin = input("Is admin? (y/n): ") == 'y' + + user = User( + username=username, + password=password, + is_admin=is_admin, + settings={} + ) + session.add(user) + session.commit() + session.close() diff --git a/animism-align/cli/commands/admin/migrate_to_mysql.py b/animism-align/cli/commands/admin/migrate_to_mysql.py new file mode 100644 index 0000000..e69dd13 --- /dev/null +++ b/animism-align/cli/commands/admin/migrate_to_mysql.py @@ -0,0 +1,148 @@ +import click +import os +import glob +import time + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.ext.declarative import declarative_base + +from flask_sqlalchemy import SQLAlchemy + +from app.settings import app_cfg + +@click.command('migrate') +@click.pass_context +def cli(ctx): + """ + - Create connections to both databases + - For each table, for each row, insert from one to the other + """ + mysql_session, mysql_base = make_mysql_base() + sqlite_session, sqlite_base = make_sqlite3_base() + mysql_classes = make_classes(mysql_base) + sqlite_classes = make_classes(sqlite_base) + + for mysql_class, sqlite_class in zip(mysql_classes, sqlite_classes): + sqlite_objs = sqlite_session.query(sqlite_class).order_by(sqlite_class.id).all() + for sqlite_obj in sqlite_objs: + mysql_obj = mysql_class() + for column in sqlite_class.__table__.columns: + table_name, column_name = str(column).split(".") + # print(f"{table_name} => {column_name}") + # if column_name != 'id': + setattr(mysql_obj, column_name, getattr(sqlite_obj, column_name)) + mysql_session.add(mysql_obj) + mysql_session.commit() + +def make_mysql_base(): + """Make a Mysql connection""" + connection_url = "mysql+pymysql://{}:{}@{}/{}?charset=utf8mb4".format( + os.getenv("DB_USER"), + os.getenv("DB_PASS"), + os.getenv("DB_HOST"), + os.getenv("DB_NAME") + ) + return make_base(connection_url) + +def make_sqlite3_base(): + """Make a SQLite3 connection""" + connection_url = "sqlite:///{}".format(os.path.join(app_cfg.DIR_DATABASE, 'animism.sqlite3')) + return make_base(connection_url) + +def make_base(connection_url): + """Make a connection base from a connection URL""" + engine = create_engine(connection_url, encoding="utf-8", pool_recycle=3600) + Session = sessionmaker(bind=engine) + Base = declarative_base() + Base.metadata.bind = engine + db = SQLAlchemy() + return Session(), Base + +def make_classes(Base): + """Make classes from a base""" + + from sqlalchemy import create_engine, Table, Column, Text, String, Integer, \ + Boolean, Float, DateTime, JSON, ForeignKey + from sqlalchemy_utc import UtcDateTime, utcnow + + # from app.sql.common import db, Base, Session + + class Episode(Base): + """Table for storing episodes and their metadata""" + __tablename__ = 'episode' + id = Column(Integer, primary_key=True) + episode_number = Column(Integer) + title = Column(String(256, convert_unicode=True), nullable=False) + release_date = Column(String(256, convert_unicode=True)) + is_live = Column(Boolean, default=False) + settings = Column(JSON, default={}, nullable=True) + + class Annotation(Base): + """Table for storing references to annotations""" + __tablename__ = 'annotation' + id = Column(Integer, primary_key=True) + type = Column(String(16, convert_unicode=True), nullable=False) + paragraph_id = Column(Integer, nullable=True) + start_ts = Column(Float, nullable=False) + end_ts = Column(Float, nullable=True) + text = Column(Text(convert_unicode=True), nullable=True) + settings = Column(JSON, default={}, nullable=True) + + class Media(Base): + """Table for storing references to media""" + __tablename__ = 'media' + id = Column(Integer, primary_key=True) + type = Column(String(16, convert_unicode=True), nullable=False) + tag = Column(String(64, convert_unicode=True), nullable=True) + url = Column(String(256, convert_unicode=True), nullable=True) + title = Column(String(256, convert_unicode=True), nullable=True) + author = Column(String(256, convert_unicode=True), nullable=True) + pre_title = Column(String(256, convert_unicode=True), nullable=True) + post_title = Column(String(256, convert_unicode=True), nullable=True) + translated_title = Column(String(256, convert_unicode=True), nullable=True) + date = Column(String(256, convert_unicode=True), nullable=True) + source = Column(String(256, convert_unicode=True), nullable=True) + medium = Column(String(64, convert_unicode=True), nullable=True) + description = Column(Text(convert_unicode=True), nullable=True) + start_ts = Column(Float, nullable=True) + settings = Column(JSON, default={}, nullable=True) + + class Paragraph(Base): + """Table for storing paragraphs, which contain annotations""" + __tablename__ = 'paragraph' + id = Column(Integer, primary_key=True) + type = Column(String(16, convert_unicode=True), nullable=False) + start_ts = Column(Float, nullable=False) + end_ts = Column(Float, nullable=True) + settings = Column(JSON, default={}, nullable=True) + + class Upload(Base): + """Table for storing references to various media""" + __tablename__ = 'upload' + id = Column(Integer, primary_key=True) + sha256 = Column(String(256), nullable=False) + fn = Column(String(256), nullable=False) + ext = Column(String(4, convert_unicode=True), nullable=False) + tag = Column(String(64, convert_unicode=True), nullable=True) + username = Column(String(16, convert_unicode=True), nullable=False) + created_at = Column(UtcDateTime(), default=utcnow()) + + class User(Base): + """Table for storing the user list""" + __tablename__ = 'user' + id = Column(Integer, primary_key=True) + username = Column(String(256, convert_unicode=True), nullable=False) + password = Column(String(256, convert_unicode=True), nullable=False) + is_admin = Column(Boolean, default=False) + settings = Column(JSON, default={}, nullable=True) + + class Venue(Base): + """Table for storing the venue list""" + __tablename__ = 'venue' + id = Column(Integer, primary_key=True) + title = Column(String(256, convert_unicode=True), nullable=False) + date = Column(String(256, convert_unicode=True), nullable=False) + settings = Column(JSON, default={}, nullable=True) + + return [ Episode, Paragraph, Annotation, Media, Upload, User, Venue ] diff --git a/animism-align/cli/commands/admin/secret.py b/animism-align/cli/commands/admin/secret.py new file mode 100644 index 0000000..c8a212d --- /dev/null +++ b/animism-align/cli/commands/admin/secret.py @@ -0,0 +1,7 @@ +import click + +@click.command('secret') +@click.pass_context +def cli(ctx): + import random + print(hex(random.getrandbits(64 * 8)).replace('0x','')) diff --git a/animism-align/cli/commands/site/export.py b/animism-align/cli/commands/site/export.py index f3f5d3c..d706a55 100644 --- a/animism-align/cli/commands/site/export.py +++ b/animism-align/cli/commands/site/export.py @@ -7,13 +7,23 @@ from functools import reduce from shutil import copyfile import os +MEDIA_ANNOTATION_TYPES = [ + 'image', 'carousel', 'grid', 'gallery', + 'video', + 'vitrine', +] + @click.command('info') # @click.option('-g', '--graph', 'opt_graph_path', required=True, # help='Graph name') @click.option('-o', '--output', 'opt_output_dir', required=False, default="animism", help='Output directory') +@click.option('-s', '--sync/--no-sync', 'opt_sync', required=False, default=True, + help='Whether to sync over FTP') +@click.option('-j', '--js/--no-js', 'opt_js', required=False, default=False, + help='Whether to rebuild the Javascript bundle') @click.pass_context -def cli(ctx, opt_output_dir): +def cli(ctx, opt_output_dir, opt_sync, opt_js): """Export a graph""" # ------------------------------------------------ @@ -28,9 +38,10 @@ def cli(ctx, opt_output_dir): page_title = "Animism: Episode 1" page_name = "episode1" - page_desc = "A Report on Migrating Souls in Museums and Moving Pictures. Animism on e-flux.com is the ninth iteration of the exhibition, which will be released in four episodes starting November 2020." - page_image = "https://animism.e-flux.com/episode1/media/8ca4adc754093cc8578a3033de8f96af96180fbce838c480636ffd5d128d1937.jpg" - site_url = "https://animism.e-flux.com/" + page_name + page_desc = "A Report on Migrating Souls in Museums and Moving Pictures. Animism on e-flux.com is the ninth iteration of the exhibition, presented digitally here." + base_href = "https://animism.e-flux.com" + page_image = base_href + "/episode1/media/8ca4adc754093cc8578a3033de8f96af96180fbce838c480636ffd5d128d1937.jpg" + site_url = base_href + "/" + page_name page_url = "/" + page_name media_url = "/" + page_name + "/media" @@ -58,14 +69,7 @@ def cli(ctx, opt_output_dir): # ------------------------------------------------ # build the json for the e-flux search - search_json = [{ - "text": transcript_to_text(db), - "url": site_url, - "type": "text", - "previewtitle": 'Animism, episode 1', - "previewtext": page_desc, - "previewimage": page_image, - }] + search_json = build_search_json(db, base_href, site_url, page_desc, page_image) # ------------------------------------------------ # build the index.html @@ -105,26 +109,79 @@ def cli(ctx, opt_output_dir): copy_tree(join(app_cfg.DIR_STATIC, 'img'), join(site_fp_static, 'img')) copyfile(join(app_cfg.DIR_STATIC, 'favicon.ico'), join(site_fp_root, 'favicon.ico')) + print("Site export complete!") + print(f"Site exported to: {site_fp_out}") + return + # ------------------------------------------------ # build javascript - print("Building javascript...") - print(f'NODE_ENV=production node ./node_modules/webpack-cli/bin/cli.js --config ./webpack.config.site.js -o {site_fp_out}/bundle.js') - os.chdir(app_cfg.DIR_PROJECT_ROOT) - os.system(f'NODE_ENV=production node ./node_modules/webpack-cli/bin/cli.js --config ./webpack.config.site.js -o {site_fp_out}/bundle.js') - - print("Deploying site...") - os.system(""" - lftp -c "set ssl:verify-certificate false; set ftp:list-options -a; - open ftp://animism:Agkp8#48@93.114.86.205; - lcd ./data_store/exports/animism; - cd /; - mirror --reverse --use-cache --verbose --no-umask --ignore-time --parallel=2" - """) + if opt_js: + print("Building javascript...") + print(f'NODE_ENV=production node ./node_modules/webpack-cli/bin/cli.js --config ./webpack.config.site.js -o {site_fp_out}/bundle.js') + os.chdir(app_cfg.DIR_PROJECT_ROOT) + os.system(f'NODE_ENV=production node ./node_modules/webpack-cli/bin/cli.js --config ./webpack.config.site.js -o {site_fp_out}/bundle.js') + else: + print("Skipping JS build.") + + if opt_sync: + print("Deploying site...") + os.system(""" + lftp -c "set ssl:verify-certificate false; set ftp:list-options -a; + open ftp://animism:Agkp8#48@93.114.86.205; + lcd ./data_store/exports/animism; + cd /; + mirror --reverse --use-cache --verbose --no-umask --ignore-time --parallel=2" + """) + else: + print("Skipping sync.") print("Site export complete!") print(f"Site exported to: {site_fp_out}") +###################################################################### +# Search JSON +###################################################################### + +def build_search_json(db, base_href, site_url, page_desc, page_image): + search_items = [] + search_items.append({ + "text": transcript_to_text(db), + "url": site_url, + "type": "text", + "previewtitle": 'Animism, episode 1', + "previewtext": page_desc, + "previewimage": page_image, + }) + seen_media = {} + for annotation in IterateTable(db['annotation']): + if annotation['type'] not in MEDIA_ANNOTATION_TYPES: + continue + if 'media_id' not in annotation['settings']: + continue + media_id = annotation['settings']['media_id'] + if media_id in seen_media: + continue + seen_media[media_id] = True + if media_id not in db['media']['lookup']: + continue + media = db['media']['lookup'][media_id] + if 'hide_in_bibliography' in media['settings'] and media['settings']['hide_in_bibliography']: + continue + if 'thumbnail' not in media['settings']: + continue + start_ts = annotation['start_ts'] + search_items.append({ + "text": media['settings']['bibliography'], + "url": site_url + "/#ts=" + str(start_ts), + "type": annotation['type'], + "previewtitle": media['title'], + "previewtext": page_desc, + "previewimage": base_href + media['settings']['thumbnail']['url'], + }) + + return search_items + ###################################################################### # Database Functions ###################################################################### diff --git a/animism-align/cli/commands/site/migrate_to_mysql.py b/animism-align/cli/commands/site/migrate_to_mysql.py deleted file mode 100644 index e69dd13..0000000 --- a/animism-align/cli/commands/site/migrate_to_mysql.py +++ /dev/null @@ -1,148 +0,0 @@ -import click -import os -import glob -import time - -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker -from sqlalchemy.ext.declarative import declarative_base - -from flask_sqlalchemy import SQLAlchemy - -from app.settings import app_cfg - -@click.command('migrate') -@click.pass_context -def cli(ctx): - """ - - Create connections to both databases - - For each table, for each row, insert from one to the other - """ - mysql_session, mysql_base = make_mysql_base() - sqlite_session, sqlite_base = make_sqlite3_base() - mysql_classes = make_classes(mysql_base) - sqlite_classes = make_classes(sqlite_base) - - for mysql_class, sqlite_class in zip(mysql_classes, sqlite_classes): - sqlite_objs = sqlite_session.query(sqlite_class).order_by(sqlite_class.id).all() - for sqlite_obj in sqlite_objs: - mysql_obj = mysql_class() - for column in sqlite_class.__table__.columns: - table_name, column_name = str(column).split(".") - # print(f"{table_name} => {column_name}") - # if column_name != 'id': - setattr(mysql_obj, column_name, getattr(sqlite_obj, column_name)) - mysql_session.add(mysql_obj) - mysql_session.commit() - -def make_mysql_base(): - """Make a Mysql connection""" - connection_url = "mysql+pymysql://{}:{}@{}/{}?charset=utf8mb4".format( - os.getenv("DB_USER"), - os.getenv("DB_PASS"), - os.getenv("DB_HOST"), - os.getenv("DB_NAME") - ) - return make_base(connection_url) - -def make_sqlite3_base(): - """Make a SQLite3 connection""" - connection_url = "sqlite:///{}".format(os.path.join(app_cfg.DIR_DATABASE, 'animism.sqlite3')) - return make_base(connection_url) - -def make_base(connection_url): - """Make a connection base from a connection URL""" - engine = create_engine(connection_url, encoding="utf-8", pool_recycle=3600) - Session = sessionmaker(bind=engine) - Base = declarative_base() - Base.metadata.bind = engine - db = SQLAlchemy() - return Session(), Base - -def make_classes(Base): - """Make classes from a base""" - - from sqlalchemy import create_engine, Table, Column, Text, String, Integer, \ - Boolean, Float, DateTime, JSON, ForeignKey - from sqlalchemy_utc import UtcDateTime, utcnow - - # from app.sql.common import db, Base, Session - - class Episode(Base): - """Table for storing episodes and their metadata""" - __tablename__ = 'episode' - id = Column(Integer, primary_key=True) - episode_number = Column(Integer) - title = Column(String(256, convert_unicode=True), nullable=False) - release_date = Column(String(256, convert_unicode=True)) - is_live = Column(Boolean, default=False) - settings = Column(JSON, default={}, nullable=True) - - class Annotation(Base): - """Table for storing references to annotations""" - __tablename__ = 'annotation' - id = Column(Integer, primary_key=True) - type = Column(String(16, convert_unicode=True), nullable=False) - paragraph_id = Column(Integer, nullable=True) - start_ts = Column(Float, nullable=False) - end_ts = Column(Float, nullable=True) - text = Column(Text(convert_unicode=True), nullable=True) - settings = Column(JSON, default={}, nullable=True) - - class Media(Base): - """Table for storing references to media""" - __tablename__ = 'media' - id = Column(Integer, primary_key=True) - type = Column(String(16, convert_unicode=True), nullable=False) - tag = Column(String(64, convert_unicode=True), nullable=True) - url = Column(String(256, convert_unicode=True), nullable=True) - title = Column(String(256, convert_unicode=True), nullable=True) - author = Column(String(256, convert_unicode=True), nullable=True) - pre_title = Column(String(256, convert_unicode=True), nullable=True) - post_title = Column(String(256, convert_unicode=True), nullable=True) - translated_title = Column(String(256, convert_unicode=True), nullable=True) - date = Column(String(256, convert_unicode=True), nullable=True) - source = Column(String(256, convert_unicode=True), nullable=True) - medium = Column(String(64, convert_unicode=True), nullable=True) - description = Column(Text(convert_unicode=True), nullable=True) - start_ts = Column(Float, nullable=True) - settings = Column(JSON, default={}, nullable=True) - - class Paragraph(Base): - """Table for storing paragraphs, which contain annotations""" - __tablename__ = 'paragraph' - id = Column(Integer, primary_key=True) - type = Column(String(16, convert_unicode=True), nullable=False) - start_ts = Column(Float, nullable=False) - end_ts = Column(Float, nullable=True) - settings = Column(JSON, default={}, nullable=True) - - class Upload(Base): - """Table for storing references to various media""" - __tablename__ = 'upload' - id = Column(Integer, primary_key=True) - sha256 = Column(String(256), nullable=False) - fn = Column(String(256), nullable=False) - ext = Column(String(4, convert_unicode=True), nullable=False) - tag = Column(String(64, convert_unicode=True), nullable=True) - username = Column(String(16, convert_unicode=True), nullable=False) - created_at = Column(UtcDateTime(), default=utcnow()) - - class User(Base): - """Table for storing the user list""" - __tablename__ = 'user' - id = Column(Integer, primary_key=True) - username = Column(String(256, convert_unicode=True), nullable=False) - password = Column(String(256, convert_unicode=True), nullable=False) - is_admin = Column(Boolean, default=False) - settings = Column(JSON, default={}, nullable=True) - - class Venue(Base): - """Table for storing the venue list""" - __tablename__ = 'venue' - id = Column(Integer, primary_key=True) - title = Column(String(256, convert_unicode=True), nullable=False) - date = Column(String(256, convert_unicode=True), nullable=False) - settings = Column(JSON, default={}, nullable=True) - - return [ Episode, Paragraph, Annotation, Media, Upload, User, Venue ] diff --git a/animism-align/frontend/app/views/viewer/nav/viewer.router.js b/animism-align/frontend/app/views/viewer/nav/viewer.router.js index 6c61223..27ff7a1 100644 --- a/animism-align/frontend/app/views/viewer/nav/viewer.router.js +++ b/animism-align/frontend/app/views/viewer/nav/viewer.router.js @@ -22,6 +22,11 @@ class ViewerRouter extends Component { } return } + if (component.match('timestamp_')) { + const timestampParts = component.split('_') + actions.viewer.seekToTimestamp(timestampToSeconds(timestampParts[1])) + return + } switch (this.props.match.params.component) { case 'transcript': actions.viewer.showComponent('transcript') diff --git a/animism-align/frontend/app/views/viewer/viewer.actions.js b/animism-align/frontend/app/views/viewer/viewer.actions.js index 39aa9f8..ef98533 100644 --- a/animism-align/frontend/app/views/viewer/viewer.actions.js +++ b/animism-align/frontend/app/views/viewer/viewer.actions.js @@ -113,7 +113,6 @@ export const loadSections = () => dispatch => { } // build timeline of "cue" instructions, which tell elements to change - // TODO: modify this to append these instructions to a list based on media_id, so we can grab it for the gallery if (CUE_UTILITY_ANNOTATION_TYPES.has(annotation.type)) { if (annotation.type === 'gallery_advance') { annotation.settings.frame_index = parseInt(annotation.settings.frame_index) diff --git a/animism-align/frontend/site/app.js b/animism-align/frontend/site/app.js index 8509954..ac232ce 100644 --- a/animism-align/frontend/site/app.js +++ b/animism-align/frontend/site/app.js @@ -1,6 +1,6 @@ import React, { Component } from 'react' -import { ConnectedRouter } from 'connected-react-router' -import { Route } from 'react-router' +// import { ConnectedRouter } from 'connected-react-router' +// import { Route } from 'react-router' // import ViewerContainer from './viewer/viewer.container' import Viewer from 'app/views/viewer/viewer.container' diff --git a/animism-align/frontend/site/site/site.actions.js b/animism-align/frontend/site/site/site.actions.js index ff25484..4767619 100644 --- a/animism-align/frontend/site/site/site.actions.js +++ b/animism-align/frontend/site/site/site.actions.js @@ -15,6 +15,14 @@ export const loadProject = () => dispatch => { loadFonts(), ]).then(() => { actions.viewer.loadSections() + if (window.location.hash.length) { + const hash = window.location.hash.substr(1) + const hashParts = hash.split('=') + if (hashParts.length !== 2) return + if (hashParts[0] === 'ts') { + actions.viewer.seekToTimestamp(parseFloat(hashParts[1])) + } + } }).catch(err => { console.error(err) }) -- cgit v1.2.3-70-g09d2