diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e0845914..17b5e05b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,8 +4,8 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: '3.10' - name: Install requirements @@ -19,16 +19,16 @@ jobs: needs: lint strategy: matrix: - ckan-version: ["2.10", 2.9] + ckan-version: ["2.11", "2.10", 2.9] fail-fast: false name: CKAN ${{ matrix.ckan-version }} runs-on: ubuntu-latest container: - image: openknowledge/ckan-dev:${{ matrix.ckan-version }} + image: ckan/ckan-dev:${{ matrix.ckan-version }} services: solr: - image: ckan/ckan-solr:${{ matrix.ckan-version }} + image: ckan/ckan-solr:${{ matrix.ckan-version }}-solr9 postgres: image: ckan/ckan-postgres-dev:${{ matrix.ckan-version }} env: @@ -46,7 +46,7 @@ jobs: CKAN_REDIS_URL: redis://redis:6379/1 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Install requirements run: | pip install -r requirements.txt @@ -57,10 +57,7 @@ jobs: - name: Setup extension (CKAN >= 2.9) run: | ckan -c test.ini db init - ckan -c test.ini pages initdb + ckan -c test.ini db upgrade -p pages - name: Run tests - run: pytest --ckan-ini=test.ini --cov=ckanext.pages --cov-report=xml --cov-append --disable-warnings ckanext/pages/tests - - name: Upload coverage report to codecov - uses: codecov/codecov-action@v1 - with: - file: ./coverage.xml + run: pytest --ckan-ini=test.ini --cov=ckanext.pages --cov-report=term-missing --cov-append --disable-warnings ckanext/pages/tests + diff --git a/README.md b/README.md index b2979223..ec41f680 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ You need to initialize database from command line with the following commands: ON CKAN >= 2.9: ``` -(pyenv) $ ckan --config=/etc/ckan/default/ckan.ini pages initdb +(pyenv) $ ckan --config=/etc/ckan/default/ckan.ini db upgrade -p pages ``` diff --git a/ckanext/pages/cli.py b/ckanext/pages/cli.py deleted file mode 100644 index 5c837aa8..00000000 --- a/ckanext/pages/cli.py +++ /dev/null @@ -1,24 +0,0 @@ -import click -import ckanext.pages.db as db - - -def get_commands(): - return [pages] - - -@click.group() -def pages(): - pass - - -@pages.command() -def initdb(): - """Adds simple pages to ckan - - Usage: - - pages initdb - - Creates the necessary tables in the database - """ - db.init_db() - click.secho(u"DB tables created", fg=u"green") diff --git a/ckanext/pages/db.py b/ckanext/pages/db.py index abd40338..b8e83bd6 100644 --- a/ckanext/pages/db.py +++ b/ckanext/pages/db.py @@ -4,6 +4,7 @@ from six import text_type import sqlalchemy as sa +from sqlalchemy import Column, types from sqlalchemy.orm import class_mapper try: @@ -17,6 +18,15 @@ from ckan import model from ckan.model.domain_object import DomainObject +try: + from ckan.plugins.toolkit import BaseModel +except ImportError: + # CKAN <= 2.9 + from ckan.model.meta import metadata + from sqlalchemy.ext.declarative import declarative_base + + BaseModel = declarative_base(metadata=metadata) + pages_table = None @@ -24,15 +34,24 @@ def make_uuid(): return text_type(uuid.uuid4()) -def init_db(): - if pages_table is None: - define_tables() +class Page(DomainObject, BaseModel): - if not pages_table.exists(): - pages_table.create() + __tablename__ = "ckanext_pages" - -class Page(DomainObject): + id = Column(types.UnicodeText, primary_key=True, default=make_uuid) + title = Column(types.UnicodeText, default=u'') + name = Column(types.UnicodeText, default=u'') + content = Column(types.UnicodeText, default=u'') + lang = Column(types.UnicodeText, default=u'') + order = Column(types.UnicodeText, default=u'') + private = Column(types.Boolean, default=True) + group_id = Column(types.UnicodeText, default=None) + user_id = Column(types.UnicodeText, default=u'') + publish_date = Column(types.DateTime) + page_type = Column(types.UnicodeText) + created = Column(types.DateTime, default=datetime.datetime.utcnow) + modified = Column(types.DateTime, default=datetime.datetime.utcnow) + extras = Column(types.UnicodeText, default=u'{}') @classmethod def get(cls, **kw): @@ -57,33 +76,6 @@ def pages(cls, **kw): return query.all() -def define_tables(): - types = sa.types - global pages_table - pages_table = sa.Table('ckanext_pages', model.meta.metadata, - sa.Column('id', types.UnicodeText, primary_key=True, default=make_uuid), - sa.Column('title', types.UnicodeText, default=u''), - sa.Column('name', types.UnicodeText, default=u''), - sa.Column('content', types.UnicodeText, default=u''), - sa.Column('lang', types.UnicodeText, default=u''), - sa.Column('order', types.UnicodeText, default=u''), - sa.Column('private', types.Boolean, default=True), - sa.Column('group_id', types.UnicodeText, default=None), - sa.Column('user_id', types.UnicodeText, default=u''), - sa.Column('publish_date', types.DateTime), - sa.Column('page_type', types.UnicodeText), - sa.Column('created', types.DateTime, default=datetime.datetime.utcnow), - sa.Column('modified', types.DateTime, default=datetime.datetime.utcnow), - sa.Column('extras', types.UnicodeText, default=u'{}'), - extend_existing=True - ) - - model.meta.mapper( - Page, - pages_table, - ) - - def table_dictize(obj, context, **kw): '''Get any model object and represent it as a dict''' result_dict = {} diff --git a/ckanext/pages/migration/pages/README b/ckanext/pages/migration/pages/README new file mode 100644 index 00000000..98e4f9c4 --- /dev/null +++ b/ckanext/pages/migration/pages/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/ckanext/pages/migration/pages/alembic.ini b/ckanext/pages/migration/pages/alembic.ini new file mode 100644 index 00000000..9b63b6e1 --- /dev/null +++ b/ckanext/pages/migration/pages/alembic.ini @@ -0,0 +1,74 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = %(here)s + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +#truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; this defaults +# to /home/adria/dev/pyenvs/ckan-211/ckanext-pages/ckanext/pages/migration/pages/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat /home/adria/dev/pyenvs/ckan-211/ckanext-pages/ckanext/pages/migration/pages/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +# 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 diff --git a/ckanext/pages/migration/pages/env.py b/ckanext/pages/migration/pages/env.py new file mode 100644 index 00000000..bb2fcd41 --- /dev/null +++ b/ckanext/pages/migration/pages/env.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- + +from __future__ import with_statement +from alembic import context +from sqlalchemy import engine_from_config, pool +from logging.config import fileConfig +from ckan.model.meta import metadata + +import os + +# 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) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = 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. + +name = os.path.basename(os.path.dirname(__file__)) + + +def include_object(object, object_name, type_, reflected, compare_to): + if type_ == "table": + return object_name.startswith(name) + return True + + +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(u"sqlalchemy.url") + context.configure( + url=url, target_metadata=target_metadata, literal_binds=True, + version_table=u'{}_alembic_version'.format(name), + 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. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix=u'sqlalchemy.', + poolclass=pool.NullPool) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + version_table=u'{}_alembic_version'.format(name), + include_object=include_object, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/ckanext/pages/migration/pages/script.py.mako b/ckanext/pages/migration/pages/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/ckanext/pages/migration/pages/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"} diff --git a/ckanext/pages/migration/pages/versions/a756dbd73ead_add_ckanext_pages_table.py b/ckanext/pages/migration/pages/versions/a756dbd73ead_add_ckanext_pages_table.py new file mode 100644 index 00000000..19369559 --- /dev/null +++ b/ckanext/pages/migration/pages/versions/a756dbd73ead_add_ckanext_pages_table.py @@ -0,0 +1,44 @@ +"""Add ckanext-pages table + +Revision ID: a756dbd73ead +Revises: +Create Date: 2024-07-11 16:22:41.698582 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "a756dbd73ead" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + engine = op.get_bind() + inspector = sa.inspect(engine) + tables = inspector.get_table_names() + if "ckanext_pages" not in tables: + op.create_table( + "ckanext_pages", + sa.Column("id", sa.UnicodeText, primary_key=True), + sa.Column("title", sa.UnicodeText, default=""), + sa.Column("name", sa.UnicodeText, default=""), + sa.Column("content", sa.UnicodeText, default=""), + sa.Column("lang", sa.UnicodeText, default=""), + sa.Column("order", sa.UnicodeText, default=""), + sa.Column("private", sa.Boolean, default=True), + sa.Column("group_id", sa.UnicodeText, default=None), + sa.Column("user_id", sa.UnicodeText, default=""), + sa.Column("publish_date", sa.DateTime), + sa.Column("page_type", sa.UnicodeText), + sa.Column("created", sa.DateTime), + sa.Column("modified", sa.DateTime), + sa.Column("extras", sa.UnicodeText, default="{}"), + ) + + +def downgrade(): + op.drop_table("ckanext_pages") diff --git a/ckanext/pages/plugin.py b/ckanext/pages/plugin.py index fbd1be0b..a5ae54f1 100644 --- a/ckanext/pages/plugin.py +++ b/ckanext/pages/plugin.py @@ -9,10 +9,9 @@ import ckan.plugins as p from ckan.lib.helpers import build_nav_main as core_build_nav_main -from ckanext.pages import actions, db +from ckanext.pages import actions from ckanext.pages import auth from ckanext.pages import blueprint -from ckanext.pages import cli from ckan.lib.plugins import DefaultTranslation @@ -97,14 +96,10 @@ class PagesPlugin(PagesPluginBase): p.implements(p.IAuthFunctions, inherit=True) p.implements(p.IConfigurable, inherit=True) p.implements(p.IBlueprint) - p.implements(p.IClick) def get_blueprint(self): return [blueprint.pages] - def get_commands(self): - return cli.get_commands() - def update_config(self, config): self.organization_pages = tk.asbool(config.get('ckanext.pages.organization', False)) self.group_pages = tk.asbool(config.get('ckanext.pages.group', False)) @@ -172,9 +167,6 @@ def get_auth_functions(self): 'ckanext_group_pages_list': auth.group_pages_list, } - def configure(self, config): - db.init_db() - class TextBoxView(p.SingletonPlugin): diff --git a/ckanext/pages/tests/fixtures.py b/ckanext/pages/tests/fixtures.py index 85209225..6cb0252f 100644 --- a/ckanext/pages/tests/fixtures.py +++ b/ckanext/pages/tests/fixtures.py @@ -6,9 +6,9 @@ @pytest.fixture -def pages_setup(): - if db.pages_table is None: - db.init_db() +def clean_db(reset_db, migrate_db_for): + reset_db() + migrate_db_for("pages") @pytest.fixture diff --git a/ckanext/pages/tests/test_action.py b/ckanext/pages/tests/test_action.py index fa928e27..99edf43a 100644 --- a/ckanext/pages/tests/test_action.py +++ b/ckanext/pages/tests/test_action.py @@ -3,7 +3,8 @@ from ckan.tests import factories, helpers -@pytest.mark.usefixtures("clean_db", "pages_setup") +@pytest.mark.usefixtures("with_plugins", "clean_db") +@pytest.mark.ckan_config("ckan.plugins", "pages") class TestPagesActions: def test_pages_create_action(self, app): user = factories.User() diff --git a/ckanext/pages/tests/test_logic.py b/ckanext/pages/tests/test_logic.py index 1c291c46..f0124f21 100644 --- a/ckanext/pages/tests/test_logic.py +++ b/ckanext/pages/tests/test_logic.py @@ -14,7 +14,8 @@ ckan_29_or_higher = toolkit.check_ckan_version(u'2.9') -@pytest.mark.usefixtures("clean_db", "pages_setup") +@pytest.mark.usefixtures("with_plugins", "clean_db") +@pytest.mark.ckan_config("ckan.plugins", "pages") class TestPages(): def test_create_page(self, app): @@ -158,7 +159,7 @@ def test_unicode(self, app): extra_environ=env, ) - assert u'

Çöñtéñt

' in response.get_data(as_text=True) + assert u'

Çöñtéñt

' in response.get_data(as_text=True) assert u'Tïtlé - CKAN' in response.get_data(as_text=True) assert u'Tïtlé' in response.get_data(as_text=True) assert u'

Tïtlé

' in response.get_data(as_text=True)