diff --git a/Makefile b/Makefile index 178dc9ad..59ce9ed5 100644 --- a/Makefile +++ b/Makefile @@ -77,7 +77,7 @@ redis: .PHONY: worker worker: - celery -A keeper.celery.celery_app worker -E -l DEBUG + FLASK_APP=keeper LTD_KEEPER_PROFILE=development LTD_KEEPER_DEV_DB_URL="postgresql+psycopg2://user:password@localhost:3308/db" celery -A keeper.celery.celery_app worker -E -l DEBUG .PHONY: flower flower: diff --git a/keeper/api/errorhandlers.py b/keeper/api/errorhandlers.py index 35233178..19d5d5b2 100644 --- a/keeper/api/errorhandlers.py +++ b/keeper/api/errorhandlers.py @@ -30,7 +30,7 @@ def bad_request(e: Exception) -> Response: """Handler for ValidationError exceptions.""" logger = structlog.get_logger() - logger.error(status=400, message=e.args[0]) + logger.error("bad request", status=400, message=e.args[0]) response = jsonify( {"status": 400, "error": "bad request", "message": e.args[0]} @@ -43,7 +43,7 @@ def bad_request(e: Exception) -> Response: def not_found(e: Exception) -> Response: """App-wide handler for HTTP 404 errors.""" logger = structlog.get_logger() - logger.error(status=400) + logger.error("not found", status=400) response = jsonify( { @@ -60,7 +60,7 @@ def not_found(e: Exception) -> Response: def method_not_supported(e: Exception) -> Response: """Handler for HTTP 405 exceptions.""" logger = structlog.get_logger() - logger.error(status=405) + logger.error("method not support", status=405) response = jsonify( { @@ -77,7 +77,7 @@ def method_not_supported(e: Exception) -> Response: def internal_server_error(e: Exception) -> Response: """App-wide handler for HTTP 500 errors.""" logger = structlog.get_logger() - logger.error(status=500, message=e.args[0]) + logger.error("internal server error", status=500, message=e.args[0]) response = jsonify( {"status": 500, "error": "internal server error", "message": e.args[0]} diff --git a/keeper/api/products.py b/keeper/api/products.py index b0972618..45932ccb 100644 --- a/keeper/api/products.py +++ b/keeper/api/products.py @@ -10,7 +10,7 @@ from keeper.api import api from keeper.auth import permission_required, token_auth from keeper.logutils import log_route -from keeper.models import Edition, Permission, Product, db +from keeper.models import Edition, Organization, Permission, Product, db from keeper.taskrunner import ( append_task_to_chain, insert_task_url_in_response, @@ -216,8 +216,11 @@ def new_product() -> Tuple[str, int, Dict[str, str]]: """ product = Product() try: + # Get default organization (v1 API adapter for organizations) + org = Organization.query.order_by(Organization.id).first_or_404() request_json = request.json product.import_data(request_json) + product.organization = org db.session.add(product) db.session.flush() # Because Edition._validate_slug does not autoflush diff --git a/keeper/config.py b/keeper/config.py index 5cdd24c0..a0e4a2ce 100644 --- a/keeper/config.py +++ b/keeper/config.py @@ -10,6 +10,8 @@ import structlog +from keeper.models import EditionKind + if TYPE_CHECKING: from flask import Flask @@ -42,6 +44,7 @@ class Config(abc.ABC): CELERY_RESULT_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379") CELERY_BROKER_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379") LTD_EVENTS_URL: Optional[str] = os.getenv("LTD_EVENTS_URL", None) + DEFAULT_EDITION_KIND: EditionKind = EditionKind.draft # Suppresses a warning until Flask-SQLAlchemy 3 # See http://stackoverflow.com/a/33790196 diff --git a/keeper/models.py b/keeper/models.py index ec336f59..56283fd4 100644 --- a/keeper/models.py +++ b/keeper/models.py @@ -6,10 +6,11 @@ from __future__ import annotations +import enum import urllib.parse import uuid from datetime import datetime -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Type, Union from flask import current_app, url_for from flask_migrate import Migrate @@ -18,11 +19,11 @@ from structlog import get_logger from werkzeug.security import check_password_hash, generate_password_hash -from . import route53, s3 -from .editiontracking import EditionTrackingModes -from .exceptions import ValidationError -from .taskrunner import append_task_to_chain, mock_registry -from .utils import ( +from keeper import route53, s3 +from keeper.editiontracking import EditionTrackingModes +from keeper.exceptions import ValidationError +from keeper.taskrunner import append_task_to_chain, mock_registry +from keeper.utils import ( JSONEncodedVARCHAR, MutableList, format_utc_datetime, @@ -38,7 +39,11 @@ "edition_tracking_modes", "Permission", "User", + "Organization", + "DashboardTemplate", + "Tag", "Product", + "product_tags", "Build", "Edition", ] @@ -63,6 +68,36 @@ """Tracking modes for editions.""" +class IntEnum(db.TypeDecorator): # type: ignore + """A custom column type that persists enums as their value, rather than + the name. + + Notes + ----- + This code is based on + https://michaelcho.me/article/using-python-enums-in-sqlalchemy-models + """ + + impl = db.Integer + + def __init__( + self, enumtype: Type[enum.IntEnum], *args: Any, **kwargs: Any + ) -> None: + super().__init__(*args, **kwargs) + self._enumtype = enumtype + + def process_bind_param( + self, value: Union[int, enum.IntEnum], dialect: Any + ) -> int: + if isinstance(value, enum.IntEnum): + return value.value + else: + return value + + def process_result_value(self, value: int, dialect: Any) -> enum.IntEnum: + return self._enumtype(value) + + class Permission: """User permission definitions. @@ -188,6 +223,197 @@ def has_permission(self, permissions: int) -> bool: return (self.permissions & permissions) == permissions +class DashboardTemplate(db.Model): # type: ignore + """DB model for an edition dashboard template.""" + + __tablename__ = "dashboardtemplates" + + id = db.Column(db.Integer, primary_key=True) + """Primary key for this dashboard template.""" + + organization_id = db.Column(db.Integer, db.ForeignKey("organizations.id")) + """ID of the organization associated with this template.""" + + comment = db.Column(db.UnicodeText(), nullable=True) + """A note about this dashboard template.""" + + bucket_prefix = db.Column(db.Unicode(255), nullable=False, unique=True) + """S3 bucket prefix where all assets related to this template are + persisted. + """ + + created_by_id = db.Column(db.Integer, db.ForeignKey("users.id")) + """ID of user who created this template.""" + + date_created = db.Column(db.DateTime, default=datetime.now, nullable=False) + """DateTime when this template was created.""" + + deleted_by_id = db.Column( + db.Integer, db.ForeignKey("users.id"), nullable=True + ) + """ID of user who deleted this template.""" + + date_deleted = db.Column(db.DateTime, default=None, nullable=True) + """DateTime when this template was deleted (or null if the template has + not been deleted. + """ + + created_by = db.relationship( + "User", + primaryjoin="DashboardTemplate.created_by_id == User.id", + ) + """User who created this template.""" + + deleted_by = db.relationship( + "User", primaryjoin="DashboardTemplate.deleted_by_id == User.id" + ) + """User who deleted this template.""" + + organization = db.relationship( + "Organization", + back_populates="dashboard_templates", + foreign_keys=[organization_id], + ) + + +class OrganizationLayoutMode(enum.IntEnum): + """Layout mode (enum) for organizations.""" + + subdomain = 1 + """Layout based on a subdomain for each project.""" + + path = 2 + """Layout based on a path prefix for each project.""" + + +class Organization(db.Model): # type: ignore + """DB model for an organization resource. + + Organizations own products (`Product`). + """ + + __tablename__ = "organizations" + + id = db.Column(db.Integer, primary_key=True) + """Primary key for this organization.""" + + default_dashboard_template_id = db.Column(db.Integer, nullable=True) + """ID of the organization's default dashboard template + (`DashboardTemplate`), if one is set. + """ + + slug = db.Column(db.Unicode(255), nullable=False, unique=True) + """URL-safe identifier for this organization (unique).""" + + title = db.Column(db.Unicode(255), nullable=False) + """Presentational title for this organization.""" + + layout = db.Column( + IntEnum(OrganizationLayoutMode), + nullable=False, + default=OrganizationLayoutMode.subdomain, + ) + """Layout mode. + + See also + -------- + OrganizationLayoutMode + """ + + fastly_support = db.Column(db.Boolean, nullable=False, default=True) + """Flag Fastly CDN support.""" + + root_domain = db.Column(db.Unicode(255), nullable=False) + """Root domain name serving docs (e.g., lsst.io).""" + + root_path_prefix = db.Column(db.Unicode(255), nullable=False, default="/") + """Root path prefix for serving products.""" + + fastly_domain = db.Column(db.Unicode(255), nullable=True) + """Fastly CDN domain name.""" + + fastly_encrypted_api_key = db.Column(db.String(255), nullable=True) + """Fastly API key for this organization. + + The key is persisted as a fernet token. + """ + + fastly_service_id = db.Column(db.Unicode(255), nullable=True) + """Fastly service ID.""" + + bucket_name = db.Column(db.Unicode(255), nullable=True) + """Name of the S3 bucket hosting builds.""" + + products = db.relationship( + "Product", back_populates="organization", lazy="dynamic" + ) + """Relationship to `Product` objects owned by this organization.""" + + tags = db.relationship("Tag", backref="organization", lazy="dynamic") + """One-to-many relationship to all `Tag` objects related to this + organization. + """ + + dashboard_templates = db.relationship( + DashboardTemplate, + primaryjoin=id == DashboardTemplate.organization_id, + back_populates="organization", + ) + + +product_tags = db.Table( + "producttags", + db.Column( + "tag_id", db.Integer, db.ForeignKey("tags.id"), primary_key=True + ), + db.Column( + "product_id", + db.Integer, + db.ForeignKey("products.id"), + primary_key=True, + ), +) +"""A table that associates the `Product` and `Tag` models.""" + + +class Tag(db.Model): # type: ignore + """DB model for tags in an `Organization`.""" + + __tablename__ = "tags" + + __table_args__ = ( + db.UniqueConstraint("slug", "organization_id"), + db.UniqueConstraint("title", "organization_id"), + ) + + id = db.Column(db.Integer, primary_key=True) + """Primary key for this tag.""" + + organization_id = db.Column( + db.Integer, db.ForeignKey("organizations.id"), index=True + ) + """ID of the organization that this tag belongs to.""" + + slug = db.Column( + db.Unicode(255), + nullable=False, + ) + """URL-safe identifier for this tag.""" + + title = db.Column( + db.Unicode(255), + nullable=False, + ) + """Presentational title or label for this tag.""" + + comment = db.Column(db.UnicodeText(), nullable=True) + """A note about this tag.""" + + products = db.relationship( + "Product", secondary=product_tags, back_populates="tags" + ) + + class Product(db.Model): # type: ignore """DB model for software products. @@ -200,6 +426,11 @@ class Product(db.Model): # type: ignore id = db.Column(db.Integer, primary_key=True) """Primary key for this product.""" + organization_id = db.Column( + db.Integer, db.ForeignKey("organizations.id"), nullable=False + ) + """Foreign key of the organization that owns this product.""" + slug = db.Column(db.Unicode(255), nullable=False, unique=True) """URL/path-safe identifier for this product (unique).""" @@ -224,6 +455,12 @@ class Product(db.Model): # type: ignore Editions and Builds have independent surrogate keys. """ + organization = db.relationship( + "Organization", + back_populates="products", + ) + """Relationship to the parent organization.""" + builds = db.relationship("Build", backref="product", lazy="dynamic") """One-to-many relationship to all `Build` objects related to this Product. """ @@ -233,6 +470,11 @@ class Product(db.Model): # type: ignore Product. """ + tags = db.relationship( + "Tag", secondary=product_tags, back_populates="products" + ) + """Tags associated with this product.""" + @classmethod def from_url(cls, product_url: str) -> "Product": """Get a Product given its API URL. @@ -374,9 +616,7 @@ class Build(db.Model): # type: ignore This slug is also used as a pseudo-POSIX directory prefix in the S3 bucket. """ - date_created = db.Column( - db.DateTime, default=datetime.now(), nullable=False - ) + date_created = db.Column(db.DateTime, default=datetime.now, nullable=False) """DateTime when this build was created. """ @@ -395,12 +635,31 @@ class Build(db.Model): # type: ignore repository products may have multiple git refs. This field is encoded as JSON (`JSONEndedVARCHAR`). + + TODO: deprecate this field after deprecation of the v1 API to use git_ref + (singular) exclusively. + """ + + git_ref = db.Column(db.Unicode(255), nullable=True) + """The git ref that this build corresponds to. + + A git ref is typically a branch or tag name. + + This column replaces `git_refs`. """ github_requester = db.Column(db.Unicode(255), nullable=True) """github handle of person requesting the build (optional). """ + uploaded_by_id = db.Column( + db.Integer, db.ForeignKey("users.id"), nullable=True + ) + """Foreign key of the user that uploaded this build. + + This key is nullable during the transition. + """ + uploaded = db.Column(db.Boolean, default=False) """Flag to indicate the doc has been uploaded to S3. """ @@ -412,6 +671,12 @@ class Build(db.Model): # type: ignore # Relationships # product - from Product class + uploaded_by = db.relationship( + "User", primaryjoin="Build.uploaded_by_id == User.id" + ) + """User who uploaded this build. + """ + @classmethod def from_url(cls, build_url: str) -> "Build": """Get a Build given its API URL. @@ -551,6 +816,30 @@ def deprecate_build(self) -> None: self.date_ended = datetime.now() +class EditionKind(enum.IntEnum): + """Classification of the edition. + + This classification is primarily used by edition dashboards. + """ + + main = 1 + """The main (default) edition.""" + + release = 2 + """A release.""" + + draft = 3 + """A draft edition (not a release).""" + + major = 4 + """An edition that tracks a major version (for the latest minor or + patch version). + """ + + minor = 5 + """An edition that tracks a minor version (for the latest patch.)""" + + class Edition(db.Model): # type: ignore """DB model for Editions. Editions are fixed-location publications of the docs. Editions are updated by new builds; though not all builds are used @@ -599,14 +888,10 @@ class Edition(db.Model): # type: ignore title = db.Column(db.Unicode(256), nullable=False) """Human-readable title for edition.""" - date_created = db.Column( - db.DateTime, default=datetime.now(), nullable=False - ) + date_created = db.Column(db.DateTime, default=datetime.now, nullable=False) """DateTime when this edition was initially created.""" - date_rebuilt = db.Column( - db.DateTime, default=datetime.now(), nullable=False - ) + date_rebuilt = db.Column(db.DateTime, default=datetime.now, nullable=False) """DateTime when the Edition was last rebuild. """ @@ -621,6 +906,16 @@ class Edition(db.Model): # type: ignore pending_rebuild = db.Column(db.Boolean, default=False, nullable=False) """Flag indicating if a rebuild is pending work by the rebuild task.""" + kind = db.Column( + IntEnum(EditionKind), default=EditionKind.draft, nullable=False + ) + """The edition's kind. + + See also + -------- + EditionKind + """ + # Relationships build = db.relationship("Build", uselist=False) """One-to-one relationship with the `Build` resource.""" diff --git a/migrations/versions/8c431c5e70a8_v2_tables.py b/migrations/versions/8c431c5e70a8_v2_tables.py new file mode 100644 index 00000000..24ac926e --- /dev/null +++ b/migrations/versions/8c431c5e70a8_v2_tables.py @@ -0,0 +1,171 @@ +"""v2 tables + +Revision ID: 8c431c5e70a8 +Revises: 4ace74bc8168 +Create Date: 2021-07-05 21:59:13.575188 + +""" + +# revision identifiers, used by Alembic. +revision = "8c431c5e70a8" +down_revision = "4ace74bc8168" + +import sqlalchemy as sa +from alembic import op + + +def upgrade(): + op.create_table( + "organizations", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column( + "default_dashboard_template_id", sa.Integer(), nullable=True + ), + sa.Column("slug", sa.Unicode(length=255), nullable=False), + sa.Column("title", sa.Unicode(length=255), nullable=False), + sa.Column("layout", sa.Integer(), nullable=False), + sa.Column("fastly_support", sa.Boolean(), nullable=False), + sa.Column("root_domain", sa.Unicode(length=255), nullable=False), + sa.Column("root_path_prefix", sa.Unicode(length=255), nullable=False), + sa.Column("fastly_domain", sa.Unicode(length=255), nullable=True), + sa.Column( + "fastly_encrypted_api_key", sa.String(length=255), nullable=True + ), + sa.Column("fastly_service_id", sa.Unicode(length=255), nullable=True), + sa.Column("bucket_name", sa.Unicode(length=255), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("slug"), + ) + # Create a default organization to associate with any existing + # products + op.execute( + "" + "INSERT INTO organizations (\n" + " slug,\n" + " title,\n" + " layout,\n" + " fastly_support,\n" + " root_domain,\n" + " root_path_prefix\n" + ")\n" + "VALUES\n" + " (\n" + " 'default',\n" + " 'Default',\n" + " 1,\n" + " false,\n" + " 'example.com',\n" + " '/'\n" + ");\n" + ) + op.create_table( + "dashboardtemplates", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("organization_id", sa.Integer(), nullable=True), + sa.Column("comment", sa.UnicodeText(), nullable=True), + sa.Column("bucket_prefix", sa.Unicode(length=255), nullable=False), + sa.Column("created_by_id", sa.Integer(), nullable=True), + sa.Column("date_created", sa.DateTime(), nullable=False), + sa.Column("deleted_by_id", sa.Integer(), nullable=True), + sa.Column("date_deleted", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["created_by_id"], + ["users.id"], + ), + sa.ForeignKeyConstraint( + ["deleted_by_id"], + ["users.id"], + ), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organizations.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("bucket_prefix"), + ) + op.create_table( + "tags", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("organization_id", sa.Integer(), nullable=True), + sa.Column("slug", sa.Unicode(length=255), nullable=False), + sa.Column("title", sa.Unicode(length=255), nullable=False), + sa.Column("comment", sa.UnicodeText(), nullable=True), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organizations.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("slug", "organization_id"), + sa.UniqueConstraint("title", "organization_id"), + ) + with op.batch_alter_table("tags", schema=None) as batch_op: + batch_op.create_index( + batch_op.f("ix_tags_organization_id"), + ["organization_id"], + unique=False, + ) + + op.create_table( + "producttags", + sa.Column("tag_id", sa.Integer(), nullable=False), + sa.Column("product_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["product_id"], + ["products.id"], + ), + sa.ForeignKeyConstraint( + ["tag_id"], + ["tags.id"], + ), + sa.PrimaryKeyConstraint("tag_id", "product_id"), + ) + with op.batch_alter_table("builds", schema=None) as batch_op: + batch_op.add_column( + sa.Column("git_ref", sa.Unicode(length=255), nullable=True) + ) + batch_op.add_column( + sa.Column("uploaded_by_id", sa.Integer(), nullable=True) + ) + batch_op.create_foreign_key(None, "users", ["uploaded_by_id"], ["id"]) + + with op.batch_alter_table("editions", schema=None) as batch_op: + batch_op.add_column(sa.Column("kind", sa.Integer(), nullable=True)) + # Insert defaults (main for main edition; draft for all others) + op.execute("UPDATE editions SET kind = 3 WHERE editions.slug != 'main'") + op.execute("UPDATE editions SET kind = 1 WHERE editions.slug = 'main'") + # Make editions.kind non-nullable + op.alter_column("editions", "kind", nullable=False) + + with op.batch_alter_table("products", schema=None) as batch_op: + batch_op.add_column( + sa.Column("organization_id", sa.Integer(), nullable=True) + ) + batch_op.create_foreign_key( + None, "organizations", ["organization_id"], ["id"] + ) + # Insert default organization in any existing products + op.execute("UPDATE products SET organization_id = 1") + # Make products.organization_id non-nullable + op.alter_column("products", "organization_id", nullable=False) + + +def downgrade(): + with op.batch_alter_table("products", schema=None) as batch_op: + batch_op.drop_constraint(None, type_="foreignkey") + batch_op.drop_column("organization_id") + + with op.batch_alter_table("editions", schema=None) as batch_op: + batch_op.drop_column("kind") + + with op.batch_alter_table("builds", schema=None) as batch_op: + batch_op.drop_constraint(None, type_="foreignkey") + batch_op.drop_column("uploaded_by_id") + batch_op.drop_column("git_ref") + + op.drop_table("producttags") + with op.batch_alter_table("tags", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_tags_organization_id")) + + op.drop_table("tags") + op.drop_table("dashboardtemplates") + op.drop_table("organizations") diff --git a/tests/test_builds.py b/tests/test_builds.py index 32b1961a..cc6669b1 100644 --- a/tests/test_builds.py +++ b/tests/test_builds.py @@ -21,6 +21,19 @@ def test_builds(client: TestClient, mocker: Mock) -> None: mock_registry.patch_all(mocker) + # Create default organization + from keeper.models import Organization, db + + org = Organization( + slug="test", + title="Test", + root_domain="lsst.io", + fastly_domain="global.ssl.fastly.net", + bucket_name="bucket-name", + ) + db.session.add(org) + db.session.commit() + # ======================================================================== # Add product /products/pipelines mocker.resetall() diff --git a/tests/test_builds_v2.py b/tests/test_builds_v2.py index 7f46ad54..d03a696f 100644 --- a/tests/test_builds_v2.py +++ b/tests/test_builds_v2.py @@ -40,6 +40,19 @@ def test_builds_v2(client: TestClient, mocker: Mock) -> None: "keeper.api.post_products_builds.open_s3_session" ) + # Create default organization + from keeper.models import Organization, db + + org = Organization( + slug="test", + title="Test", + root_domain="lsst.io", + fastly_domain="global.ssl.fastly.net", + bucket_name="bucket-name", + ) + db.session.add(org) + db.session.commit() + # ======================================================================== # Add product /products/pipelines mocker.resetall() diff --git a/tests/test_editions.py b/tests/test_editions.py index aa709c7b..7213e3f8 100644 --- a/tests/test_editions.py +++ b/tests/test_editions.py @@ -22,6 +22,19 @@ def test_editions(client: TestClient, mocker: Mock) -> None: """Exercise different /edition/ API scenarios.""" mock_registry.patch_all(mocker) + # Create default organization + from keeper.models import Organization, db + + org = Organization( + slug="test", + title="Test", + root_domain="lsst.io", + fastly_domain="global.ssl.fastly.net", + bucket_name="bucket-name", + ) + db.session.add(org) + db.session.commit() + # ======================================================================== # Add product /products/ldm-151 mocker.resetall() diff --git a/tests/test_editions_autoincrement.py b/tests/test_editions_autoincrement.py index 1ddc790e..80d32139 100644 --- a/tests/test_editions_autoincrement.py +++ b/tests/test_editions_autoincrement.py @@ -18,6 +18,19 @@ def test_editions_autoincrement(client: TestClient, mocker: Mock) -> None: """Test creating editions with autoincrement=True.""" mock_registry.patch_all(mocker) + # Create default organization + from keeper.models import Organization, db + + org = Organization( + slug="test", + title="Test", + root_domain="lsst.io", + fastly_domain="global.ssl.fastly.net", + bucket_name="bucket-name", + ) + db.session.add(org) + db.session.commit() + # ======================================================================== # Add product /products/testr-000 mocker.resetall() diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 00000000..7756a0cc --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,43 @@ +"""Test DB models.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from keeper.models import Organization, Product, Tag, db + +if TYPE_CHECKING: + from flask import Flask + + +def test_tags(empty_app: Flask) -> None: + org = Organization( + slug="test-org", + title="Test Org", + fastly_support=True, + root_domain="example.org", + fastly_domain="fastly.example.org", + bucket_name="example", + ) + productA = Product( + slug="productA", + doc_repo="src.example.org/productA", + title="productA", + root_domain="example.org", + root_fastly_domain="fastly.example.org", + bucket_name="example", + surrogate_key="123", + organization=org, + ) + tagA = Tag( + organization=org, + slug="a", + title="a", + comment="This tag is for testing.", + ) + productA.tags.append(tagA) + + db.session.add(org) + db.session.add(productA) + db.session.add(tagA) + db.session.commit() diff --git a/tests/test_patch_edition_mode.py b/tests/test_patch_edition_mode.py index 3e1dd316..59a6bd2a 100644 --- a/tests/test_patch_edition_mode.py +++ b/tests/test_patch_edition_mode.py @@ -29,6 +29,19 @@ def test_pach_lsst_doc_edition(client: TestClient, mocker: Mock) -> None: # Mock all celergy-based tasks. mock_registry.patch_all(mocker) + # Create default organization + from keeper.models import Organization, db + + org = Organization( + slug="test", + title="Test", + root_domain="lsst.io", + fastly_domain="global.ssl.fastly.net", + bucket_name="bucket-name", + ) + db.session.add(org) + db.session.commit() + # ======================================================================== # Add product /products/ldm-151 mocker.resetall() diff --git a/tests/test_products.py b/tests/test_products.py index 28ffa90f..be07fe16 100644 --- a/tests/test_products.py +++ b/tests/test_products.py @@ -21,6 +21,19 @@ def test_products(client: TestClient, mocker: Mock) -> None: """Test various API operations against Product resources.""" mock_registry.patch_all(mocker) + # Create default organization + from keeper.models import Organization, db + + org = Organization( + slug="test", + title="Test", + root_domain="lsst.io", + fastly_domain="global.ssl.fastly.net", + bucket_name="bucket-name", + ) + db.session.add(org) + db.session.commit() + # ======================================================================== # Add product /products/ldm-151 mocker.resetall() @@ -203,6 +216,19 @@ def test_post_product_auth_anon(anon_client: TestClient) -> None: def test_post_product_auth_product_client(product_client: TestClient) -> None: + # Create default organization + from keeper.models import Organization, db + + org = Organization( + slug="test", + title="Test", + root_domain="lsst.io", + fastly_domain="global.ssl.fastly.net", + bucket_name="bucket-name", + ) + db.session.add(org) + db.session.commit() + with pytest.raises(ValidationError): product_client.post("/products/", {"foo": "bar"}) diff --git a/tests/test_track_eups_daily_tag.py b/tests/test_track_eups_daily_tag.py index f5507c5b..ad419da0 100644 --- a/tests/test_track_eups_daily_tag.py +++ b/tests/test_track_eups_daily_tag.py @@ -17,6 +17,19 @@ def test_eups_daily_release_edition(client: TestClient, mocker: Mock) -> None: # The celery tasks need to be mocked, but are not checked. mock_registry.patch_all(mocker) + # Create default organization + from keeper.models import Organization, db + + org = Organization( + slug="test", + title="Test", + root_domain="lsst.io", + fastly_domain="global.ssl.fastly.net", + bucket_name="bucket-name", + ) + db.session.add(org) + db.session.commit() + # ======================================================================== # Add product /products/pipelines p1_data = { diff --git a/tests/test_track_eups_major_tag.py b/tests/test_track_eups_major_tag.py index c3fc3ff9..a979a997 100644 --- a/tests/test_track_eups_major_tag.py +++ b/tests/test_track_eups_major_tag.py @@ -16,6 +16,19 @@ def test_eups_major_release_edition(client: TestClient, mocker: Mock) -> None: """Test an edition that tracks the most recent EUPS major release.""" mock_registry.patch_all(mocker) + # Create default organization + from keeper.models import Organization, db + + org = Organization( + slug="test", + title="Test", + root_domain="lsst.io", + fastly_domain="global.ssl.fastly.net", + bucket_name="bucket-name", + ) + db.session.add(org) + db.session.commit() + # ======================================================================== # Add product /products/pipelines p1_data = { diff --git a/tests/test_track_eups_weekly_tag.py b/tests/test_track_eups_weekly_tag.py index b835a7ea..50ae7805 100644 --- a/tests/test_track_eups_weekly_tag.py +++ b/tests/test_track_eups_weekly_tag.py @@ -18,6 +18,19 @@ def test_eups_weekly_release_edition(client: TestClient, mocker: Mock) -> None: # These mocks are needed but not checked mock_registry.patch_all(mocker) + # Create default organization + from keeper.models import Organization, db + + org = Organization( + slug="test", + title="Test", + root_domain="lsst.io", + fastly_domain="global.ssl.fastly.net", + bucket_name="bucket-name", + ) + db.session.add(org) + db.session.commit() + # ======================================================================== # Add product /products/pipelines p1_data = { diff --git a/tests/test_track_lsst_doc.py b/tests/test_track_lsst_doc.py index a44f54cb..52953573 100644 --- a/tests/test_track_lsst_doc.py +++ b/tests/test_track_lsst_doc.py @@ -28,6 +28,19 @@ def test_lsst_doc_edition(client: TestClient, mocker: Mock) -> None: """ mock_registry.patch_all(mocker) + # Create default organization + from keeper.models import Organization, db + + org = Organization( + slug="test", + title="Test", + root_domain="lsst.io", + fastly_domain="global.ssl.fastly.net", + bucket_name="bucket-name", + ) + db.session.add(org) + db.session.commit() + # ======================================================================== # Add product /products/ldm-151 mocker.resetall() diff --git a/tox.ini b/tox.ini index a2d7956a..8cdfa8f4 100644 --- a/tox.ini +++ b/tox.ini @@ -25,7 +25,7 @@ healthcheck_start_period = 1 [docker:test-postgres] image = postgres:11 ports = - 3308:3308/tcp + 3309:3309/tcp # Environment variables are passed to the container. They are only # available to that container, and not to the testenv, other # containers, or as replacements in other parts of tox.ini @@ -33,7 +33,7 @@ environment = POSTGRES_PASSWORD=password POSTGRES_USER=user POSTGRES_DB=keepertest - PGPORT=3308 + PGPORT=3309 # The healthcheck ensures that tox-docker won't run tests until the # container is up and the command finishes with exit code 0 (success) healthcheck_cmd = PGPASSWORD=$POSTGRES_PASSWORD psql \ @@ -65,7 +65,7 @@ description = Run pytest with Postgres DB. docker = test-postgres setenv = - LTD_KEEPER_TEST_DB_URL=postgresql+psycopg2://user:password@localhost:3308/keepertest + LTD_KEEPER_TEST_DB_URL=postgresql+psycopg2://user:password@localhost:3309/keepertest [testenv:mysql] description = Run pytest with MySQL DB.