diff --git a/app/__init__.py b/app/__init__.py index 1a749ed4..1ed0fd7c 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,3 +1,4 @@ +from apifairy import APIFairy from config import Config from docker import DockerClient from flask import Flask @@ -5,6 +6,7 @@ from flask_apscheduler import APScheduler from flask_assets import Environment from flask_login import LoginManager from flask_mail import Mail +from flask_marshmallow import Marshmallow from flask_migrate import Migrate from flask_paranoid import Paranoid from flask_socketio import SocketIO @@ -12,6 +14,7 @@ from flask_sqlalchemy import SQLAlchemy from flask_hashids import Hashids +apifairy = APIFairy() assets = Environment() db = SQLAlchemy() docker_client = DockerClient() @@ -19,6 +22,7 @@ hashids = Hashids() login = LoginManager() login.login_view = 'auth.login' login.login_message = 'Please log in to access this page.' +ma = Marshmallow() mail = Mail() migrate = Migrate() paranoid = Paranoid() @@ -38,10 +42,12 @@ def create_app(config: Config = Config) -> Flask: registry=app.config['NOPAQUE_DOCKER_REGISTRY'] ) + apifairy.init_app(app) assets.init_app(app) db.init_app(app) hashids.init_app(app) login.init_app(app) + ma.init_app(app) mail.init_app(app) migrate.init_app(app, db) paranoid.init_app(app) @@ -51,6 +57,9 @@ def create_app(config: Config = Config) -> Flask: from .admin import bp as admin_blueprint app.register_blueprint(admin_blueprint, url_prefix='/admin') + from .api import bp as api_blueprint + app.register_blueprint(api_blueprint, url_prefix='/api') + from .auth import bp as auth_blueprint app.register_blueprint(auth_blueprint, url_prefix='/auth') diff --git a/app/api/__init__.py b/app/api/__init__.py index e7674c87..39e40db1 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -1,25 +1,14 @@ from flask import Blueprint -from flask_restx import Api -from .tokens import ns as tokens_ns bp = Blueprint('api', __name__) -authorizations = { - 'basicAuth': { - 'type': 'basic' - }, - 'apiKey': { - 'type': 'apiKey', - 'in': 'header', - 'name': 'Authorization' - } -} -api = Api( - bp, - authorizations=authorizations, - description='An API to interact with nopaque', - title='nopaque API', - version='1.0' -) -api.add_namespace(tokens_ns) + +from .tokens import bp as tokens_blueprint +bp.register_blueprint(tokens_blueprint, url_prefix='/tokens') + +from .users import bp as users_blueprint +bp.register_blueprint(users_blueprint, url_prefix='/users') + +from .jobs import bp as jobs_blueprint +bp.register_blueprint(jobs_blueprint, url_prefix='/jobs') diff --git a/app/api/auth.py b/app/api/auth.py index 4c6a3dd9..afda3a30 100644 --- a/app/api/auth.py +++ b/app/api/auth.py @@ -1,34 +1,49 @@ -from app.models import User +from flask import current_app from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth -from sqlalchemy import or_ -from werkzeug.http import HTTP_STATUS_CODES +from werkzeug.exceptions import Forbidden, Unauthorized +from app.models import User + basic_auth = HTTPBasicAuth() token_auth = HTTPTokenAuth() - +auth_error_responses = { + Unauthorized.code: Unauthorized.description, + Forbidden.code: Forbidden.description +} @basic_auth.verify_password def verify_password(email_or_username, password): - user = User.query.filter( - or_( - User.username == email_or_username, - User.email == email_or_username.lower() - ) - ).first() - if user and user.verify_password(password): + user = User.query.filter((User.email == email_or_username.lower()) | (User.username == email_or_username)).first() + if user is not None and user.verify_password(password): return user @basic_auth.error_handler def basic_auth_error(status): - return {'error': HTTP_STATUS_CODES.get(status, 'Unknown error')}, status + error = (Forbidden if status == 403 else Unauthorized)() + return { + 'code': error.code, + 'message': error.name, + 'description': error.description, + }, error.code, {'WWW-Authenticate': 'Form'} @token_auth.verify_token def verify_token(token): - return User.check_token(token) if token else None + return User.verify_access_token(token) if token else None @token_auth.error_handler def token_auth_error(status): - return {'error': HTTP_STATUS_CODES.get(status, 'Unknown error')}, status + error = (Forbidden if status == 403 else Unauthorized)() + return { + 'code': error.code, + 'message': error.name, + 'description': error.description, + }, error.code + + +@basic_auth.get_user_roles +@token_auth.get_user_roles +def get_user_roles(user): + return [user.role.name] diff --git a/app/api/jobs.py b/app/api/jobs.py new file mode 100644 index 00000000..e730f2e6 --- /dev/null +++ b/app/api/jobs.py @@ -0,0 +1,102 @@ + +from apifairy import authenticate, response +from apifairy.decorators import body, other_responses +from flask import abort, Blueprint +from werkzeug.exceptions import InternalServerError +from app import db, hashids +from app.models import Job, JobInput, JobStatus, TesseractOCRModel +from .schemas import EmptySchema, JobSchema, SpaCyNLPPipelineJobSchema, TesseractOCRPipelineJobSchema, TesseractOCRModelSchema +from .auth import auth_error_responses, token_auth + + +bp = Blueprint('jobs', __name__) +job_schema = JobSchema() +jobs_schema = JobSchema(many=True) +spacy_nlp_pipeline_job_schema = SpaCyNLPPipelineJobSchema() +tesseract_ocr_pipeline_job_schema = TesseractOCRPipelineJobSchema() +tesseract_ocr_model_schema = TesseractOCRModelSchema() +tesseract_ocr_models_schema = TesseractOCRModelSchema(many=True) + + +@bp.route('', methods=['GET']) +@authenticate(token_auth, role='Administrator') +@response(jobs_schema) +@other_responses(auth_error_responses) +def get_jobs(): + """Get all jobs""" + return Job.query.all() + + +@bp.route('/tesseract-ocr-pipeline', methods=['POST']) +@authenticate(token_auth) +@body(tesseract_ocr_pipeline_job_schema, location='form') +@response(job_schema) +@other_responses({**auth_error_responses, InternalServerError.code: InternalServerError.description}) +def create_tesseract_ocr_pipeline_job(args): + """Create a new Tesseract OCR Pipeline job""" + current_user = token_auth.current_user() + try: + job = Job.create( + title=args['title'], + description=args['description'], + service='tesseract-ocr-pipeline', + service_args={ + 'model': hashids.decode(args['model_id']), + 'binarization': args['binarization'] + }, + service_version=args['service_version'], + user=current_user + ) + except OSError: + abort(500) + try: + JobInput.create(args['pdf'], job=job) + except OSError: + abort(500) + job.status = JobStatus.SUBMITTED + db.session.commit() + return job, 201 + + +@bp.route('/tesseract-ocr-pipeline/models', methods=['GET']) +@authenticate(token_auth) +@response(tesseract_ocr_models_schema) +@other_responses(auth_error_responses) +def get_tesseract_ocr_models(): + """Get all Tesseract OCR Models""" + return TesseractOCRModel.query.all() + + +@bp.route('/', methods=['DELETE']) +@authenticate(token_auth) +@response(EmptySchema, status_code=204) +@other_responses(auth_error_responses) +def delete_job(job_id): + """Delete a job by id""" + current_user = token_auth.current_user() + job = Job.query.get(job_id) + if job is None: + abort(404) + if not (job.user == current_user or current_user.is_administrator()): + abort(403) + try: + job.delete() + except OSError as e: + abort(500) + db.session.commit() + return {}, 204 + + +@bp.route('/', methods=['GET']) +@authenticate(token_auth) +@response(job_schema) +@other_responses(auth_error_responses) +def get_job(job_id): + """Get a job by id""" + current_user = token_auth.current_user() + job = Job.query.get(job_id) + if job is None: + abort(404) + if not (job.user == current_user or current_user.is_administrator()): + abort(403) + return job diff --git a/app/api/schemas.py b/app/api/schemas.py new file mode 100644 index 00000000..394b1ebb --- /dev/null +++ b/app/api/schemas.py @@ -0,0 +1,165 @@ +from apifairy.fields import FileField +from marshmallow import validate, validates, ValidationError +from marshmallow.decorators import post_dump +from app import ma +from app.auth import USERNAME_REGEX +from app.models import Job, JobStatus, TesseractOCRModel, Token, User, UserSettingJobStatusMailNotificationLevel +from app.services import SERVICES + + + +class EmptySchema(ma.Schema): + pass + + +class TokenSchema(ma.SQLAlchemySchema): + class Meta: + model = Token + ordered = True + + access_token = ma.String(required=True) + refresh_token = ma.String() + + +class TesseractOCRModelSchema(ma.SQLAlchemySchema): + class Meta: + model = TesseractOCRModel + ordered = True + + hashid = ma.String(data_key='id', dump_only=True) + user_hashid = ma.String(data_key='user_id', dump_only=True) + title = ma.auto_field( + required=True, + validate=validate.Length(min=1, max=64) + ) + description = ma.auto_field( + required=True, + validate=validate.Length(min=1, max=255) + ) + version = ma.String( + required=True, + validate=validate.Length(min=1, max=16) + ) + compatible_service_versions = ma.List( + ma.String(required=True, validate=validate.Length(min=1, max=16)), + required=True, + validate=validate.Length(min=1, max=255) + ) + publisher = ma.String( + required=True, + validate=validate.Length(min=1, max=128) + ) + publisher_url = ma.String( + validate=[validate.URL(), validate.Length(min=1, max=512)] + ) + publishing_url = ma.String( + required=True, + validate=[validate.URL(), validate.Length(min=1, max=512)] + ) + publishing_year = ma.Int( + required=True + ) + shared = ma.Boolean(required=True) + + +class JobSchema(ma.SQLAlchemySchema): + class Meta: + model = Job + ordered = True + + hashid = ma.String(data_key='id', dump_only=True) + user_hashid = ma.String(data_key='user_id', dump_only=True) + title = ma.auto_field( + required=True, + validate=validate.Length(min=1, max=32) + ) + description = ma.auto_field( + required=True, + validate=validate.Length(min=1, max=255) + ) + creation_date = ma.auto_field(dump_only=True) + end_date = ma.auto_field(dump_only=True) + service = ma.String( + dump_only=True, + validate=validate.OneOf(SERVICES.keys()) + ) + service_args = ma.Dict(dump_only=True) + service_version = ma.String(dump_only=True) + status = ma.String( + dump_only=True, + validate=validate.OneOf(list(JobStatus.__members__.keys())) + ) + + @post_dump(pass_original=True) + def post_dump(self, serialized_job, job, **kwargs): + serialized_job['status'] = job.status.name + return serialized_job + + +class TesseractOCRPipelineJobSchema(JobSchema): + binarization = ma.Boolean(load_only=True, missing=False) + model_id = ma.String(required=True, load_only=True) + service_version = ma.auto_field( + required=True, + validate=[validate.Length(min=1, max=16), validate.OneOf(list(SERVICES['tesseract-ocr-pipeline']['versions'].keys()))] + ) + pdf = FileField() + + @validates('pdf') + def validate_pdf(self, value): + if value.mimetype != 'application/pdf': + raise ValidationError('PDF files only!') + + +class SpaCyNLPPipelineJobSchema(JobSchema): + binarization = ma.Boolean(load_only=True, missing=False) + model_id = ma.String(required=True, load_only=True) + service_version = ma.auto_field( + required=True, + validate=[validate.Length(min=1, max=16), validate.OneOf(list(SERVICES['tesseract-ocr-pipeline']['versions'].keys()))] + ) + txt = FileField(required=True) + + @validates('txt') + def validate_txt(self, value): + if value.mimetype != 'text/plain': + raise ValidationError('Plain text files only!') + + +class UserSchema(ma.SQLAlchemySchema): + class Meta: + model = User + ordered = True + + hashid = ma.String(data_key='id', dump_only=True) + username = ma.auto_field( + validate=[ + validate.Length(min=1, max=64), + validate.Regexp(USERNAME_REGEX, error='Usernames must have only letters, numbers, dots or underscores') + ] + ) + email = ma.auto_field(validate=validate.Email()) + member_since = ma.auto_field(dump_only=True) + last_seen = ma.auto_field(dump_only=True) + password = ma.String(load_only=True) + last_seen = ma.auto_field(dump_only=True) + setting_dark_mode = ma.auto_field() + setting_job_status_mail_notification_level = ma.String( + validate=validate.OneOf(list(UserSettingJobStatusMailNotificationLevel.__members__.keys())) + ) + + @validates('email') + def validate_email(self, email): + if User.query.filter(User.email == email).first(): + raise ValidationError('Email already registered') + + @validates('username') + def validate_username(self, username): + if User.query.filter(User.username == username).first(): + raise ValidationError('Username already in use') + + @post_dump(pass_original=True) + def post_dump(self, serialized_user, user, **kwargs): + serialized_user['setting_job_status_mail_notification_level'] = \ + user.setting_job_status_mail_notification_level.name + return serialized_user diff --git a/app/api/tokens.py b/app/api/tokens.py index e1b55527..0aaa3415 100644 --- a/app/api/tokens.py +++ b/app/api/tokens.py @@ -1,27 +1,58 @@ +from apifairy import authenticate, body, response, other_responses +from flask import Blueprint, request, abort from app import db -from flask_restx import Namespace, Resource -from .auth import basic_auth, token_auth +from app.models import Token, User +from .auth import basic_auth +from .schemas import EmptySchema, TokenSchema -ns = Namespace('tokens', description='Token operations') +bp = Blueprint('tokens', __name__) +token_schema = TokenSchema() -@ns.route('') -class API_Tokens(Resource): - '''Get or revoke a user authentication token''' +@bp.route('', methods=['DELETE']) +@response(EmptySchema, status_code=204, description='Token revoked') +@other_responses({401: 'Invalid access token'}) +def delete_token(): + """Revoke an access token""" + access_token = request.headers['Authorization'].split()[1] + token = Token.query.filter(Token.access_token == access_token).first() + if token is None: # pragma: no cover + abort(401) + token.expire() + db.session.commit() + return {} - @ns.doc(security='basicAuth') - @basic_auth.login_required - def post(self): - '''Get a user token''' - token = basic_auth.current_user().get_token() - db.session.commit() - return {'token': 'Bearer ' + token} - @ns.doc(security='apiKey') - @token_auth.login_required - def delete(self): - '''Revoke a user token''' - token_auth.current_user().revoke_token() - db.session.commit() - return '', 204 +@bp.route('', methods=['POST']) +@authenticate(basic_auth) +@response(token_schema) +@other_responses({401: 'Invalid username or password'}) +def create_token(): + """Create new access and refresh tokens""" + user = basic_auth.current_user() + token = user.generate_auth_token() + db.session.add(token) + Token.clean() # keep token table clean of old tokens + db.session.commit() + return token, 200 + + +@bp.route('', methods=['PUT']) +@body(token_schema) +@response(token_schema, description='Newly issued access and refresh tokens') +@other_responses({401: 'Invalid access or refresh token'}) +def refresh_token(args): + """Refresh an access token""" + access_token = args.get('access_token') + refresh_token = args.get('refresh_token') + if access_token is None or refresh_token is None: + abort(401) + token = User.verify_refresh_token(refresh_token, access_token) + if token is None: + abort(401) + token.expire() + new_token = token.user.generate_auth_token() + db.session.add_all([token, new_token]) + db.session.commit() + return new_token, 200 diff --git a/app/api/users.py b/app/api/users.py new file mode 100644 index 00000000..fc180df0 --- /dev/null +++ b/app/api/users.py @@ -0,0 +1,99 @@ + +from apifairy import authenticate, body, response +from apifairy.decorators import other_responses +from flask import abort, Blueprint, current_app +from werkzeug.exceptions import InternalServerError +from app import db +from app.email import create_message, send +from app.models import User +from .schemas import EmptySchema, UserSchema +from .auth import auth_error_responses, token_auth + + +bp = Blueprint('users', __name__) +user_schema = UserSchema() +users_schema = UserSchema(many=True) + + +@bp.route('', methods=['GET']) +@authenticate(token_auth, role='Administrator') +@response(users_schema) +@other_responses(auth_error_responses) +def get_users(): + """Get all users""" + return User.query.all() + + +@bp.route('', methods=['POST']) +@body(user_schema) +@response(user_schema, 201) +@other_responses({InternalServerError.code: InternalServerError.description}) +def create_user(args): + """Create a new user""" + try: + user = User.create( + email=args['email'].lower(), + password=args['password'], + username=args['username'] + ) + except OSError: + abort(500) + msg = create_message( + user.email, + 'Confirm Your Account', + 'auth/email/confirm', + token=user.generate_confirm_token(), + user=user + ) + send(msg) + db.session.commit() + return user, 201 + + +@bp.route('/', methods=['DELETE']) +@authenticate(token_auth) +@response(EmptySchema, status_code=204) +@other_responses(auth_error_responses) +def delete_user(user_id): + """Delete a user by id""" + current_user = token_auth.current_user() + user = User.query.get(user_id) + if user is None: + abort(404) + if not (user == current_user or current_user.is_administrator()): + abort(403) + user.delete() + db.session.commit() + return {}, 204 + + +@bp.route('/', methods=['GET']) +@authenticate(token_auth) +@response(user_schema) +@other_responses(auth_error_responses) +@other_responses({404: 'User not found'}) +def get_user(user_id): + """Retrieve a user by id""" + current_user = token_auth.current_user() + user = User.query.get(user_id) + if user is None: + abort(404) + if not (user == current_user or current_user.is_administrator()): + abort(403) + return user + + +@bp.route('/', methods=['GET']) +@authenticate(token_auth) +@response(user_schema) +@other_responses(auth_error_responses) +@other_responses({404: 'User not found'}) +def get_user_by_username(username): + """Retrieve a user by username""" + current_user = token_auth.current_user() + user = User.query.filter(User.username == username).first() + if user is None: + abort(404) + if not (user == current_user or current_user.is_administrator()): + abort(403) + return user diff --git a/app/models.py b/app/models.py index 9ae31b1c..8efc4bd2 100644 --- a/app/models.py +++ b/app/models.py @@ -11,6 +11,7 @@ import json import jwt import os import requests +import secrets import shutil import xml.etree.ElementTree as ET import yaml @@ -209,6 +210,30 @@ class Role(HashidMixin, db.Model): db.session.commit() +class Token(db.Model): + __tablename__ = 'tokens' + # Primary key + id = db.Column(db.Integer, primary_key=True) + # Foreign keys + user_id = db.Column(db.Integer, db.ForeignKey('users.id')) + # Fields + access_token = db.Column(db.String(64), index=True) + access_expiration = db.Column(db.DateTime) + refresh_token = db.Column(db.String(64), index=True) + refresh_expiration = db.Column(db.DateTime) + # Backrefs: user: User + + def expire(self): + self.access_expiration = datetime.utcnow() + self.refresh_expiration = datetime.utcnow() + + @staticmethod + def clean(): + """Remove any tokens that have been expired for more than a day.""" + yesterday = datetime.utcnow() - timedelta(days=1) + Token.query.filter(Token.refresh_expiration < yesterday).delete() + + class User(HashidMixin, UserMixin, db.Model): __tablename__ = 'users' # Primary key @@ -253,6 +278,12 @@ class User(HashidMixin, UserMixin, db.Model): cascade='all, delete-orphan', lazy='dynamic' ) + tokens = db.relationship( + 'Token', + backref='user', + cascade='all, delete-orphan', + lazy='dynamic' + ) def __init__(self, **kwargs): super().__init__(**kwargs) @@ -337,6 +368,27 @@ class User(HashidMixin, UserMixin, db.Model): db.session.add(user) return True + @staticmethod + def verify_access_token(access_token, refresh_token=None): + token = Token.query.filter(Token.access_token == access_token).first() + if token is not None: + if token.access_expiration > datetime.utcnow(): + token.user.ping() + db.session.commit() + if token.user.role.name != 'System user': + return token.user + + @staticmethod + def verify_refresh_token(refresh_token, access_token): + token = Token.query.filter((Token.refresh_token == refresh_token) & (Token.access_token == access_token)).first() + if token is not None: + if token.refresh_expiration > datetime.utcnow(): + return token + # someone tried to refresh with an expired token + # revoke all tokens from this user as a precaution + token.user.revoke_auth_tokens() + db.session.commit() + def can(self, permission): return self.role.has_permission(permission) @@ -364,6 +416,15 @@ class User(HashidMixin, UserMixin, db.Model): shutil.rmtree(self.path, ignore_errors=True) db.session.delete(self) + def generate_auth_token(self): + return Token( + access_token=secrets.token_urlsafe(), + access_expiration=datetime.utcnow() + timedelta(minutes=15), + refresh_token=secrets.token_urlsafe(), + refresh_expiration=datetime.utcnow() + timedelta(days=7), + user=self + ) + def generate_confirm_token(self, expiration=3600): now = datetime.utcnow() payload = { @@ -400,6 +461,10 @@ class User(HashidMixin, UserMixin, db.Model): def ping(self): self.last_seen = datetime.utcnow() + def revoke_auth_tokens(self): + for token in self.tokens: + db.session.delete(token) + def verify_password(self, password): if self.role.name == 'System user': return False diff --git a/app/templates/_sidenav.html.j2 b/app/templates/_sidenav.html.j2 index b302e9ca..553eb9db 100644 --- a/app/templates/_sidenav.html.j2 +++ b/app/templates/_sidenav.html.j2 @@ -31,6 +31,9 @@ {% if current_user.can(Permission.ADMINISTRATE) %}
  • admin_panel_settingsAdministration
  • {% endif %} + {% if current_user.can(Permission.USE_API) %} +
  • apiAPI
  • + {% endif %} {% if current_user.can(Permission.CONTRIBUTE) %}
  • new_labelContribute
  • {% endif %} diff --git a/config.py b/config.py index ec27529f..4eba99d2 100644 --- a/config.py +++ b/config.py @@ -11,6 +11,13 @@ load_dotenv(os.path.join(basedir, '.env')) class Config: + ''' APIFairy ''' + APIFAIRY_TITLE = 'nopaque' + APIFAIRY_VERSION = '0.0.1' + APIFAIRY_UI = 'swagger_ui' + APIFAIRY_APISPEC_PATH = '/api/apispec.json' + APIFAIRY_UI_PATH = '/api' + ''' # Flask # ''' PREFERRED_URL_SCHEME = os.environ.get('PREFERRED_URL_SCHEME', 'http') SECRET_KEY = os.environ.get('SECRET_KEY', 'hard to guess string') @@ -58,6 +65,9 @@ class Config: NOPAQUE_SOCKETIO_MESSAGE_QUEUE_URI = \ os.environ.get('NOPAQUE_SOCKETIO_MESSAGE_QUEUE_URI') + NOPAQUE_JOB_EXPIRATION_ENABLED = os.environ.get('NOPAQUE_JOB_EXPIRATION_ENABLED', 'true').lower() == 'true' + NOPAQUE_JOB_EXPIRATION_TIME = int(os.environ.get('NOPAQUE_JOB_EXPIRATION_TIME', '120')) + NOPAQUE_DOCKER_REGISTRY = 'gitlab.ub.uni-bielefeld.de:4567' NOPAQUE_DOCKER_IMAGE_PREFIX = f'{NOPAQUE_DOCKER_REGISTRY}/sfb1288inf/' NOPAQUE_DOCKER_REGISTRY_USERNAME = \ diff --git a/migrations/versions/116b4ab3ef9c_.py b/migrations/versions/116b4ab3ef9c_.py new file mode 100644 index 00000000..f8a29e57 --- /dev/null +++ b/migrations/versions/116b4ab3ef9c_.py @@ -0,0 +1,41 @@ +"""Add API authentication token table + +Revision ID: 116b4ab3ef9c +Revises: f9070ff1fa4a +Create Date: 2022-09-02 11:12:01.995451 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '116b4ab3ef9c' +down_revision = 'f9070ff1fa4a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('tokens', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('access_token', sa.String(length=64), nullable=True), + sa.Column('access_expiration', sa.DateTime(), nullable=True), + sa.Column('refresh_token', sa.String(length=64), nullable=True), + sa.Column('refresh_expiration', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_tokens_access_token'), 'tokens', ['access_token'], unique=False) + op.create_index(op.f('ix_tokens_refresh_token'), 'tokens', ['refresh_token'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_tokens_refresh_token'), table_name='tokens') + op.drop_index(op.f('ix_tokens_access_token'), table_name='tokens') + op.drop_table('tokens') + # ### end Alembic commands ###