Browse Source

Make plugins work with alembic

djsilcock 8 years ago
parent
commit
8f3faece65

+ 26 - 0
flaskbb/cli/plugins.py

@@ -128,3 +128,29 @@ def list_plugins():
             click.secho("    - {} (version {})".format(
                 plugin.name, plugin.version), bold=True
             )
+
+
+@plugins.command("migrations")
+@click.argument("plugin_identifier")
+def migrate_plugin(plugin_identifier):
+    """Installs a new plugin."""
+    validate_plugin(plugin_identifier)
+    plugin = get_plugin_from_all(plugin_identifier)
+    click.secho("[+] Updating plugin migrations{}...".format(plugin.name), fg="cyan")
+    try:
+        plugin.migrate()
+    except Exception as e:
+        click.secho("[-] Couldn't generate migrations for plugin because of following "
+                    "exception: \n{}".format(e), fg="red")
+
+@plugins.command("upgrade")
+@click.argument("plugin_identifier")
+def upgrade_plugin(plugin_identifier):
+    """Uninstalls a plugin from FlaskBB."""
+    validate_plugin(plugin_identifier)
+    plugin = get_plugin_from_all(plugin_identifier)
+    click.secho("[+] Upgrading plugin {}...".format(plugin.name), fg="cyan")
+    try:
+        plugin.upgrade_database()
+    except AttributeError:
+        pass

+ 53 - 1
flaskbb/plugins/__init__.py

@@ -10,8 +10,35 @@
 """
 from flask import current_app
 from flask_plugins import Plugin
-
+from flask_migrate import upgrade,downgrade,migrate
+from flaskbb.extensions import db,migrate as mig
 from flaskbb.management.models import SettingsGroup
+from flask import g
+import contextlib
+import os,copy
+
+@contextlib.contextmanager
+def plugin_name_migrate(name):
+    g.plugin_name=name
+    yield
+    del g.plugin_name
+
+def db_for_plugin(plugin_name,db):
+    newdb=copy.copy(db)
+    def Table(*args,**kwargs):
+        newtable=db.Table(*args,**kwargs)
+        newtable._plugin=plugin_name
+        return newtable
+    newdb.Table=Table
+    return newdb
+
+@mig.configure
+def config_migrate(config):
+    plugins = current_app.extensions['plugin_manager'].all_plugins.values()
+    migration_dirs = [p.get_migration_version_dir() for p in plugins]
+    if config.get_main_option('version_table')=='plugins':
+        config.set_main_option('version_locations', ' '.join(migration_dirs))
+    return config
 
 
 class FlaskBBPlugin(Plugin):
@@ -21,6 +48,31 @@ class FlaskBBPlugin(Plugin):
     #: anything.
     settings_key = None
 
+    def resource_filename(self, *names):
+        return os.path.join(self.path, *names)
+
+    def get_migration_version_dir(self):
+        return self.resource_filename('migration_versions')
+
+    def upgrade_database(self, target='head'):
+        plugindir = current_app.extensions['plugin_manager'].plugin_folder
+        upgrade(directory=os.path.join(plugindir, 'migrations'), revision=self.settings_key + '@' + target)
+
+    def downgrade_database(self, target='base'):
+        plugindir = current_app.extensions['plugin_manager'].plugin_folder
+        downgrade(directory=os.path.join(plugindir, 'migrations'), revision=self.settings_key + '@' + target)
+
+    def migrate(self):
+        with plugin_name_migrate(self.__module__):
+            plugindir = current_app.extensions['plugin_manager'].plugin_folder
+            try:
+                migrate(directory=os.path.join(plugindir, 'migrations'),
+                        head=self.settings_key)
+            except Exception as e:  #presumably this is the initial migration?
+                migrate(directory=os.path.join(plugindir, 'migrations'),
+                        version_path=self.resource_filename('migration_versions'),
+                        branch_label=self.settings_key)
+
     @property
     def installable(self):
         """Is ``True`` if the Plugin can be installed."""

+ 9 - 0
flaskbb/plugins/example_model_plugin/CHANGELOG.md

@@ -0,0 +1,9 @@
+Changelog
+=========
+
+Here you can see the full list of changes between each release.
+
+Version 0.1.0
+-------------
+
+* Initial commit

+ 29 - 0
flaskbb/plugins/example_model_plugin/LICENSE

@@ -0,0 +1,29 @@
+BSD License
+
+Copyright (c) 2017, Your Name <Your email address (eq. you@example.com)>
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice, this
+  list of conditions and the following disclaimer in the documentation and/or
+  other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+  contributors may be used to endorse or promote products derived from this
+  software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
+INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
+OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
+OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
+OF THE POSSIBILITY OF SUCH DAMAGE.

+ 65 - 0
flaskbb/plugins/example_model_plugin/__init__.py

@@ -0,0 +1,65 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.plugins.plugin_name
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+    A Portal Plugin for FlaskBB.
+
+    :copyright: (c) 2014 by the FlaskBB Team.
+    :license: BSD, see LICENSE for more details.
+"""
+from flask_plugins import connect_event
+
+from flaskbb.plugins import FlaskBBPlugin,db_for_plugin
+from flaskbb.utils.populate import (create_settings_from_fixture,
+                                    delete_settings_from_fixture)
+from flaskbb.extensions import db
+from flaskbb.forum.models import Forum
+
+from .views import plugin_bp, inject_navigation_link
+
+__version__ = "0.1"
+__plugin__ = "HelloWorldPlugin"
+
+
+fixture = (
+    ('plugin_plugin_name', {
+        'name': "Plugin Name Settings",
+        "description": "Configure the Plugin Name Plugin",
+        "settings": (
+            ('plugin_name_display_in_navigation', {
+                'value':       True,
+                'value_type':  "boolean",
+                'name':        "Show Link in Navigation",
+                'description': "If enabled, it will show the link in the navigation"
+            }),
+        ),
+    }),
+)
+
+db=db_for_plugin(__name__,db)
+
+class MyModel(db.Model):
+    __tablename__='my_model'
+    field1=db.Column(db.String,primary_key=True)
+
+moderators = db.Table(
+    'test_table',
+    db.Column('user_id', db.Integer(), db.ForeignKey('users.id'),
+              nullable=False),
+    db.Column('forum_id', db.Integer(),
+              db.ForeignKey('forums.id', use_alter=True, name="fk_forum_id"),
+              nullable=False))
+
+class HelloWorldPlugin(FlaskBBPlugin):
+    settings_key = 'plugin_plugin_name'
+
+    def setup(self):
+        self.register_blueprint(plugin_bp, url_prefix="/plugin-name")
+        connect_event("before-first-navigation-element", inject_navigation_link)
+
+    def install(self):
+        create_settings_from_fixture(fixture)
+
+    def uninstall(self):
+        delete_settings_from_fixture(fixture)

+ 9 - 0
flaskbb/plugins/example_model_plugin/info.json

@@ -0,0 +1,9 @@
+{
+    "identifier": "test_model_plugin",
+    "name": "Plugin Name",
+    "author": "Your Name",
+    "website": "http://flaskbb.org",
+    "license": "BSD License",
+    "description": "A description of the plugin",
+    "version": "0.1.0"
+}

+ 7 - 0
flaskbb/plugins/example_model_plugin/templates/navigation_link.html

@@ -0,0 +1,7 @@
+{% if flaskbb_config["PLUGIN_NAME_DISPLAY_IN_NAVIGATION"] %}
+<li {% if 'plugin_name.index' == request.endpoint %}class="active"{% endif %}>
+    <a href="{{ url_for('plugin_name.index') }}">
+        <i class="fa fa-home"></i> Plugin Name
+    </a>
+</li>
+{% endif %}

+ 25 - 0
flaskbb/plugins/example_model_plugin/templates/plugin_name.html

@@ -0,0 +1,25 @@
+{% extends theme("layout.html") %}
+
+{% block css %}
+{{  super() }}
+<!-- put some plugin css style here -->
+{% endblock %}
+
+{% block content %}
+<div class="row">
+    <div class="col-md-12 col-sm-12 col-xs-12">
+        <div class="panel panel-default panel-widget">
+            <div class="panel-heading panel-widget-heading">
+                <h3 class="panel-title">Plugin Information</h3>
+            </div>
+            <div class="panel-body panel-widget-body">
+                <p>Plugin Name: {{ plugin_obj.name }}, {{ plugin_obj.version }}</p>
+                <p>Plugin Description: {{ plugin_obj.description }}</p>
+                <p>Plugin Website: {{ plugin_obj.website }}</p>
+                <p>Plugin License: {{ plugin_obj.license }}</p>
+                <p>Plugin Author: {{ plugin_obj.author }}</p>
+            </div>
+        </div>
+    </div>
+</div>
+{% endblock %}

+ 27 - 0
flaskbb/plugins/example_model_plugin/views.py

@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.plugins.plugin_name.views
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+    This module contains the plugin_name view.
+
+    :copyright: (c) 2016 by Your Name.
+    :license: BSD License, see LICENSE for more details.
+"""
+from flask import Blueprint
+from flask_plugins import get_plugin_from_all
+
+from flaskbb.utils.helpers import render_template
+
+# You can modify this to your liking
+plugin_bp = Blueprint("plugin_name", __name__, template_folder="templates")
+
+
+def inject_navigation_link():
+    return render_template("navigation_link.html")
+
+
+@plugin_bp.route("/")
+def index():
+    plugin_obj = get_plugin_from_all("plugin_name")
+    return render_template("plugin_name.html", plugin_obj=plugin_obj)

+ 1 - 0
flaskbb/plugins/migrations/README

@@ -0,0 +1 @@
+Generic single-database configuration.

+ 45 - 0
flaskbb/plugins/migrations/alembic.ini

@@ -0,0 +1,45 @@
+# A generic, single database configuration.
+
+[alembic]
+# template used to generate migration files
+# file_template = %%(rev)s_%%(slug)s
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+version_table = plugins
+
+# Logging configuration
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S

+ 112 - 0
flaskbb/plugins/migrations/env.py

@@ -0,0 +1,112 @@
+from __future__ import with_statement
+from alembic import context
+from sqlalchemy import engine_from_config, pool, ext as sa_ext
+from logging.config import fileConfig
+from flaskbb.plugins import config_migrate
+import logging
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+fileConfig(config.config_file_name)
+logger = logging.getLogger('alembic.env')
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+from flask import current_app, g
+
+config.set_main_option('sqlalchemy.url',
+                       current_app.config.get('SQLALCHEMY_DATABASE_URI'))
+db = current_app.extensions['migrate'].db
+target_metadata = db.metadata
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+moduletables = {k.__table__.name: k.__module__ for k in db.Model._decl_class_registry.values() if
+                hasattr(k, '__table__')}
+
+def include_object(obj, name, type_, reflected, compare_to):
+  try:
+    modname = moduletables.get(name) or getattr(obj,'_plugin',None)
+    if hasattr(g, 'plugin_tables'):
+        return name in g.plugin_tables
+    elif hasattr(g, 'plugin_name'):
+        if name == 'alembic_version': return False
+        if not modname:
+            return False
+        if modname and modname.startswith(g.plugin_name):
+            return True
+        return False
+    else:
+        return False
+  except Exception as e:
+    print e
+
+def run_migrations_offline():
+    """Run migrations in 'offline' mode.
+
+    This configures the context with just a URL
+    and not an Engine, though an Engine is acceptable
+    here as well.  By skipping the Engine creation
+    we don't even need a DBAPI to be available.
+
+    Calls to context.execute() here emit the given string to the
+    script output.
+
+    """
+    url = config.get_main_option("sqlalchemy.url")
+    context.configure(url=url, include_object=include_object)
+
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def run_migrations_online():
+    """Run migrations in 'online' mode.
+
+    In this scenario we need to create an Engine
+    and associate a connection with the context.
+
+    """
+
+    # this callback is used to prevent an auto-migration from being generated
+    # when there are no changes to the schema
+    # reference: http://alembic.readthedocs.org/en/latest/cookbook.html
+    def process_revision_directives(context, revision, directives):
+        if getattr(config.cmd_opts, 'autogenerate', False):
+            script = directives[0]
+            if script.upgrade_ops.is_empty():
+                directives[:] = []
+                logger.info('No changes in schema detected.')
+
+    engine = engine_from_config(config.get_section(config.config_ini_section),
+                                prefix='sqlalchemy.',
+                                poolclass=pool.NullPool)
+
+    connection = engine.connect()
+    context.configure(connection=connection,
+                      target_metadata=target_metadata,
+                      process_revision_directives=process_revision_directives,
+                      include_object=include_object,
+                      version_table='plugins',
+                      **current_app.extensions['migrate'].configure_args)
+
+    try:
+        with context.begin_transaction():
+            context.run_migrations()
+    finally:
+        connection.close()
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()

BIN
flaskbb/plugins/migrations/env.pyc


+ 24 - 0
flaskbb/plugins/migrations/script.py.mako

@@ -0,0 +1,24 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade():
+    ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+    ${downgrades if downgrades else "pass"}