Skip to content

Commit e375471

Browse files
authored
🗃️ New templates to products map table (#5358)
1 parent 226c183 commit e375471

File tree

12 files changed

+479
-132
lines changed

12 files changed

+479
-132
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""products_to_templates map table
2+
3+
Revision ID: f3a5484fe05d
4+
Revises: f20f4c9fca71
5+
Create Date: 2024-02-21 19:33:48.169810+00:00
6+
7+
"""
8+
from typing import Final
9+
10+
import sqlalchemy as sa
11+
from alembic import op
12+
13+
# revision identifiers, used by Alembic.
14+
revision = "f3a5484fe05d"
15+
down_revision = "f20f4c9fca71"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
# auto-update modified
21+
# TRIGGERS ------------------------
22+
_TABLE_NAME: Final[str] = "products_to_templates"
23+
_TRIGGER_NAME: Final[str] = "trigger_auto_update" # NOTE: scoped on table
24+
_PROCEDURE_NAME: Final[
25+
str
26+
] = f"{_TABLE_NAME}_auto_update_modified()" # NOTE: scoped on database
27+
28+
modified_timestamp_trigger = sa.DDL(
29+
f"""
30+
DROP TRIGGER IF EXISTS {_TRIGGER_NAME} on {_TABLE_NAME};
31+
CREATE TRIGGER {_TRIGGER_NAME}
32+
BEFORE INSERT OR UPDATE ON {_TABLE_NAME}
33+
FOR EACH ROW EXECUTE PROCEDURE {_PROCEDURE_NAME};
34+
"""
35+
)
36+
37+
# PROCEDURES ------------------------
38+
update_modified_timestamp_procedure = sa.DDL(
39+
f"""
40+
CREATE OR REPLACE FUNCTION {_PROCEDURE_NAME}
41+
RETURNS TRIGGER AS $$
42+
BEGIN
43+
NEW.modified := current_timestamp;
44+
RETURN NEW;
45+
END;
46+
$$ LANGUAGE plpgsql;
47+
"""
48+
)
49+
50+
51+
def upgrade():
52+
# ### commands auto generated by Alembic - please adjust! ###
53+
op.create_table(
54+
"products_to_templates",
55+
sa.Column("product_name", sa.String(), nullable=False),
56+
sa.Column("template_name", sa.String(), nullable=True),
57+
sa.Column(
58+
"created", sa.DateTime(), server_default=sa.text("now()"), nullable=False
59+
),
60+
sa.Column(
61+
"modified", sa.DateTime(), server_default=sa.text("now()"), nullable=False
62+
),
63+
sa.ForeignKeyConstraint(
64+
["product_name"],
65+
["products.name"],
66+
name="fk_products_to_templates_product_name",
67+
onupdate="CASCADE",
68+
ondelete="CASCADE",
69+
),
70+
sa.ForeignKeyConstraint(
71+
["template_name"],
72+
["jinja2_templates.name"],
73+
name="fk_products_to_templates_template_name",
74+
onupdate="CASCADE",
75+
ondelete="CASCADE",
76+
),
77+
sa.UniqueConstraint("product_name", "template_name"),
78+
)
79+
# ### end Alembic commands ###
80+
81+
# custom
82+
op.execute(update_modified_timestamp_procedure)
83+
op.execute(modified_timestamp_trigger)
84+
85+
86+
def downgrade():
87+
# custom
88+
op.execute(f"DROP TRIGGER IF EXISTS {_TRIGGER_NAME} on {_TABLE_NAME};")
89+
op.execute(f"DROP FUNCTION {_PROCEDURE_NAME};")
90+
91+
# ### commands auto generated by Alembic - please adjust! ###
92+
op.drop_table("products_to_templates")
93+
# ### end Alembic commands ###

packages/postgres-database/src/simcore_postgres_database/models/_common.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def register_modified_datetime_auto_update_trigger(table: sa.Table) -> None:
3434
update of the 'modified' timestamp column when a row is modified.
3535
3636
NOTE: Add a *hard-coded* version in the alembic migration code!!!
37-
see [this example](https://github.com/ITISFoundation/osparc-simcore/blob/78bc54e5815e8be5a8ed6a08a7bbe5591bbd2bd9/packages/postgres-database/src/simcore_postgres_database/migration/versions/e0a2557dec27_add_services_limitations.py)
37+
SEE https://github.com/ITISFoundation/osparc-simcore/blob/78bc54e5815e8be5a8ed6a08a7bbe5591bbd2bd9/packages/postgres-database/src/simcore_postgres_database/migration/versions/e0a2557dec27_add_services_limitations.py
3838
3939
4040
Arguments:
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import sqlalchemy as sa
2+
3+
from ._common import (
4+
column_created_datetime,
5+
column_modified_datetime,
6+
register_modified_datetime_auto_update_trigger,
7+
)
8+
from .base import metadata
9+
from .jinja2_templates import jinja2_templates
10+
11+
products_to_templates = sa.Table(
12+
"products_to_templates",
13+
metadata,
14+
sa.Column(
15+
"product_name",
16+
sa.String,
17+
sa.ForeignKey(
18+
"products.name",
19+
onupdate="CASCADE",
20+
ondelete="CASCADE",
21+
name="fk_products_to_templates_product_name",
22+
),
23+
nullable=False,
24+
),
25+
sa.Column(
26+
"template_name",
27+
sa.String,
28+
sa.ForeignKey(
29+
jinja2_templates.c.name,
30+
name="fk_products_to_templates_template_name",
31+
ondelete="CASCADE",
32+
onupdate="CASCADE",
33+
),
34+
nullable=True,
35+
doc="Custom jinja2 template",
36+
),
37+
# TIME STAMPS ----
38+
column_created_datetime(timezone=False),
39+
column_modified_datetime(timezone=False),
40+
sa.UniqueConstraint("product_name", "template_name"),
41+
)
42+
43+
register_modified_datetime_auto_update_trigger(products_to_templates)

packages/postgres-database/src/simcore_postgres_database/utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def build_url(
1414
user: str = "",
1515
password: str = "",
1616
host: str = "127.0.0.1",
17-
port: int = 5432,
17+
port: int | str = 5432,
1818
**_kwargs,
1919
) -> URL:
2020
"""
@@ -25,7 +25,7 @@ def build_url(
2525
user=user,
2626
password=password,
2727
host=host,
28-
port=port,
28+
port=int(port),
2929
path=f"/{database}",
3030
)
3131
# _kwargs allows expand on larger dicts without raising exceptions

packages/postgres-database/tests/products/conftest.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
import pytest
1010
from aiopg.sa.exc import ResourceClosedError
11+
from faker import Faker
12+
from pytest_simcore.helpers.rawdata_fakers import random_product
1113
from simcore_postgres_database.webserver_models import products
1214
from sqlalchemy.dialects.postgresql import insert as pg_insert
1315

@@ -22,19 +24,25 @@ def products_regex() -> dict:
2224

2325

2426
@pytest.fixture
25-
def make_products_table(
26-
products_regex: dict,
27-
) -> Callable:
27+
def products_names(products_regex: dict) -> list[str]:
28+
return list(products_regex)
29+
30+
31+
@pytest.fixture
32+
def make_products_table(products_regex: dict, faker: Faker) -> Callable:
2833
async def _make(conn) -> None:
2934
for n, (name, regex) in enumerate(products_regex.items()):
35+
3036
result = await conn.execute(
3137
pg_insert(products)
3238
.values(
33-
name=name,
34-
display_name=f"Product {name.capitalize()}",
35-
short_name=name[:3].lower(),
36-
host_regex=regex,
37-
priority=n,
39+
**random_product(
40+
name=name,
41+
display_name=f"Product {name.capitalize()}",
42+
short_name=name[:3].lower(),
43+
host_regex=regex,
44+
priority=n,
45+
)
3846
)
3947
.on_conflict_do_update(
4048
index_elements=[products.c.name],
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# pylint: disable=no-name-in-module
2+
# pylint: disable=no-value-for-parameter
3+
# pylint: disable=redefined-outer-name
4+
# pylint: disable=unused-argument
5+
6+
7+
import shutil
8+
from collections.abc import Callable
9+
from pathlib import Path
10+
11+
import pytest
12+
import sqlalchemy as sa
13+
from aiopg.sa.connection import SAConnection
14+
from faker import Faker
15+
from simcore_postgres_database.models.jinja2_templates import jinja2_templates
16+
from simcore_postgres_database.models.products import products
17+
from simcore_postgres_database.models.products_to_templates import products_to_templates
18+
from sqlalchemy.dialects.postgresql import insert as pg_insert
19+
20+
21+
@pytest.fixture
22+
def templates_names(faker: Faker) -> list[str]:
23+
return [faker.file_name(extension="html") for _ in range(3)]
24+
25+
26+
@pytest.fixture
27+
def templates_dir(
28+
tmp_path: Path, products_names: list[str], templates_names: list[str]
29+
) -> Path:
30+
templates_path = tmp_path / "templates"
31+
32+
# common keeps default templates
33+
(templates_path / "common").mkdir(parents=True)
34+
for template_name in templates_names:
35+
(templates_path / "common" / template_name).write_text(
36+
"Fake template for 'common'"
37+
)
38+
39+
# only odd products have the first template
40+
for product_name in products_names[1::2]:
41+
(templates_path / product_name).mkdir(parents=True)
42+
(templates_path / product_name / templates_names[0]).write_text(
43+
f"Fake template for {product_name=}"
44+
)
45+
46+
return templates_path
47+
48+
49+
@pytest.fixture
50+
async def product_templates_in_db(
51+
connection: SAConnection,
52+
make_products_table: Callable,
53+
products_names: list[str],
54+
templates_names: list[str],
55+
):
56+
await make_products_table(connection)
57+
58+
# one version of all tempaltes
59+
for template_name in templates_names:
60+
await connection.execute(
61+
jinja2_templates.insert().values(
62+
name=template_name, content="fake template in database"
63+
)
64+
)
65+
66+
# only even products have templates
67+
for product_name in products_names[0::2]:
68+
await connection.execute(
69+
products_to_templates.insert().values(
70+
template_name=template_name, product_name=product_name
71+
)
72+
)
73+
74+
75+
async def test_export_and_import_table(
76+
connection: SAConnection,
77+
product_templates_in_db: None,
78+
):
79+
exported_values = []
80+
excluded_names = {"created", "modified", "group_id"}
81+
async for row in connection.execute(
82+
sa.select(*(c for c in products.c if c.name not in excluded_names))
83+
):
84+
assert row
85+
exported_values.append(dict(row))
86+
87+
# now just upsert them
88+
for values in exported_values:
89+
values["display_name"] += "-changed"
90+
await connection.execute(
91+
pg_insert(products)
92+
.values(**values)
93+
.on_conflict_do_update(index_elements=[products.c.name], set_=values)
94+
)
95+
96+
97+
async def test_create_templates_products_folder(
98+
connection: SAConnection,
99+
templates_dir: Path,
100+
products_names: list[str],
101+
tmp_path: Path,
102+
templates_names: list[str],
103+
product_templates_in_db: None,
104+
):
105+
download_path = tmp_path / "downloaded" / "templates"
106+
assert templates_dir != download_path
107+
108+
for product_name in products_names:
109+
product_folder = download_path / product_name
110+
product_folder.mkdir(parents=True, exist_ok=True)
111+
112+
# takes common as defaults
113+
for p in (templates_dir / "common").iterdir():
114+
if p.is_file():
115+
shutil.copy(p, product_folder / p.name, follow_symlinks=False)
116+
117+
# overrides with customs in-place
118+
if (templates_dir / product_name).exists():
119+
for p in (templates_dir / product_name).iterdir():
120+
if p.is_file():
121+
shutil.copy(p, product_folder / p.name, follow_symlinks=False)
122+
123+
# overrides if with files in database
124+
async for row in connection.execute(
125+
sa.select(
126+
products_to_templates.c.product_name,
127+
jinja2_templates.c.name,
128+
jinja2_templates.c.content,
129+
)
130+
.select_from(products_to_templates.join(jinja2_templates))
131+
.where(products_to_templates.c.product_name == product_name)
132+
):
133+
assert row
134+
135+
template_path = product_folder / row.name
136+
template_path.write_text(row.content)
137+
138+
assert sorted(
139+
product_folder / template_name for template_name in templates_names
140+
) == sorted(product_folder.rglob("*.*"))

0 commit comments

Comments
 (0)