From bbbf3e3c2e22e28971056e528f3e883750a4193d Mon Sep 17 00:00:00 2001 From: mabw-rte <41002227+mabw-rte@users.noreply.github.com> Date: Fri, 2 Feb 2024 15:24:23 +0100 Subject: [PATCH] feature(tag-db): add tag and study_tag tables to the db (migration) (#1923) closed ANT-1107 --- ...70366b10ea_add_tag_and_study_tag_tables.py | 58 +++++++ antarest/study/css4_colors.py | 141 ++++++++++++++++++ antarest/study/model.py | 61 +++++++- scripts/rollback.sh | 2 +- tests/study/model.py | 66 -------- tests/study/test_model.py | 122 +++++++++++++++ 6 files changed, 375 insertions(+), 75 deletions(-) create mode 100644 alembic/versions/3c70366b10ea_add_tag_and_study_tag_tables.py create mode 100644 antarest/study/css4_colors.py delete mode 100644 tests/study/model.py create mode 100644 tests/study/test_model.py diff --git a/alembic/versions/3c70366b10ea_add_tag_and_study_tag_tables.py b/alembic/versions/3c70366b10ea_add_tag_and_study_tag_tables.py new file mode 100644 index 0000000000..036f2c86b0 --- /dev/null +++ b/alembic/versions/3c70366b10ea_add_tag_and_study_tag_tables.py @@ -0,0 +1,58 @@ +"""Add tag and study_tag tables + +Revision ID: 3c70366b10ea +Revises: 1f5db5dfad80 +Create Date: 2024-02-02 13:06:47.627554 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "3c70366b10ea" +down_revision = "1f5db5dfad80" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "tag", + sa.Column("label", sa.String(length=40), nullable=False), + sa.Column("color", sa.String(length=20), nullable=True), + sa.PrimaryKeyConstraint("label"), + ) + with op.batch_alter_table("tag", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_tag_color"), ["color"], unique=False) + batch_op.create_index(batch_op.f("ix_tag_label"), ["label"], unique=False) + + op.create_table( + "study_tag", + sa.Column("study_id", sa.String(length=36), nullable=False), + sa.Column("tag_label", sa.String(length=40), nullable=False), + sa.ForeignKeyConstraint(["study_id"], ["study.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["tag_label"], ["tag.label"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("study_id", "tag_label"), + ) + with op.batch_alter_table("study_tag", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_study_tag_study_id"), ["study_id"], unique=False) + batch_op.create_index(batch_op.f("ix_study_tag_tag_label"), ["tag_label"], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("study_tag", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_study_tag_tag_label")) + batch_op.drop_index(batch_op.f("ix_study_tag_study_id")) + + op.drop_table("study_tag") + with op.batch_alter_table("tag", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_tag_label")) + batch_op.drop_index(batch_op.f("ix_tag_color")) + + op.drop_table("tag") + # ### end Alembic commands ### diff --git a/antarest/study/css4_colors.py b/antarest/study/css4_colors.py new file mode 100644 index 0000000000..9d87c4a53e --- /dev/null +++ b/antarest/study/css4_colors.py @@ -0,0 +1,141 @@ +""" +CSS Color Module Level 4 + +[Color keywords](https://www.w3.org/TR/css-color-4/#color-keywords) +""" + +COLOR_NAMES = ( + "AliceBlue", + "AntiqueWhite", + "Aquamarine", + "Aqua", + "Beige", + "Bisque", + "Black", + "BlanchedAlmond", + "BlueViolet", + "Blue", + "Brown", + "BurlyWood", + "CadetBlue", + "Chartreuse", + "Chocolate", + "Coral", + "CornflowerBlue", + "Cornsilk", + "Crimson", + "DarkBlue", + "DarkGoldenrod", + "DarkGray", + "DarkGreen", + "DarkKhaki", + "DarkOliveGreen", + "DarkOrange", + "DarkOrchid", + "DarkSalmon", + "DarkSeaGreen", + "DarkSlateBlue", + "DarkSlateGray", + "DarkTurquoise", + "DarkViolet", + "DeepPink", + "DeepSkyBlue", + "DimGray", + "DodgerBlue", + "FireBrick", + "ForestGreen", + "Gainsboro", + "Goldenrod", + "Gold", + "Gray", + "GreenYellow", + "Green", + "Honeydew", + "HotPink", + "IndianRed", + "Indigo", + "Ivory", + "Khaki", + "LavenderBlush", + "Lavender", + "LawnGreen", + "LemonChiffon", + "LightBlue", + "LightCoral", + "LightCyan", + "LightGoldenrod", + "LightGray", + "LightGreen", + "LightPink", + "LightSalmon", + "LightSeaGreen", + "LightSkyBlue", + "LightSlateGray", + "LightSteelBlue", + "LightYellow", + "LimeGreen", + "Lime", + "Linen", + "Magenta", + "Maroon", + "MediumAquamarine", + "MediumBlue", + "MediumOrchid", + "MediumPurple", + "MediumSeaGreen", + "MediumSlateBlue", + "MediumSpringGreen", + "MediumTurquoise", + "MediumVioletRed", + "MidnightBlue", + "MintCream", + "MistyRose", + "Moccasin", + "NavajoWhite", + "Navy", + "OldLace", + "OliveDrab", + "Olive", + "OrangeRed", + "Orange", + "Orchid", + "PaleGoldenrod", + "PaleGreen", + "PaleTurquoise", + "PaleVioletRed", + "PapayaWhip", + "PeachPuff", + "Peru", + "Pink", + "Plum", + "PowderBlue", + "Purple", + "Red", + "RosyBrown", + "RoyalBlue", + "SaddleBrown", + "Salmon", + "SandyBrown", + "SeaGreen", + "Seashell", + "Sienna", + "Silver", + "SkyBlue", + "SlateBlue", + "SlateGray", + "Snow", + "SpringGreen", + "SteelBlue", + "Tan", + "Teal", + "Thistle", + "Tomato", + "Turquoise", + "Violet", + "Wheat", + "WhiteSmoke", + "White", + "YellowGreen", + "Yellow", +) +"""List of CSS4 color names used to style tags.""" diff --git a/antarest/study/model.py b/antarest/study/model.py index 3fc2fb3228..f8218fd31d 100644 --- a/antarest/study/model.py +++ b/antarest/study/model.py @@ -1,18 +1,30 @@ import dataclasses import enum +import secrets import typing as t import uuid from datetime import datetime, timedelta from pathlib import Path from pydantic import BaseModel -from sqlalchemy import Boolean, Column, DateTime, Enum, ForeignKey, Integer, String, Table # type: ignore +from sqlalchemy import ( # type: ignore + Boolean, + Column, + DateTime, + Enum, + ForeignKey, + Integer, + PrimaryKeyConstraint, + String, + Table, +) from sqlalchemy.orm import relationship # type: ignore from antarest.core.exceptions import ShouldNotHappenException from antarest.core.model import PublicMode from antarest.core.persistence import Base from antarest.login.model import Group, GroupDTO, Identity +from antarest.study.css4_colors import COLOR_NAMES if t.TYPE_CHECKING: # avoid circular import @@ -20,13 +32,6 @@ DEFAULT_WORKSPACE_NAME = "default" -groups_metadata = Table( - "group_metadata", - Base.metadata, - Column("group_id", String(36), ForeignKey("groups.id")), - Column("study_id", String(36), ForeignKey("study.id")), -) - STUDY_REFERENCE_TEMPLATES: t.Dict[str, str] = { "600": "empty_study_613.zip", "610": "empty_study_613.zip", @@ -45,6 +50,44 @@ NEW_DEFAULT_STUDY_VERSION: str = "860" +groups_metadata = Table( + "group_metadata", + Base.metadata, + Column("group_id", String(36), ForeignKey("groups.id")), + Column("study_id", String(36), ForeignKey("study.id")), +) + + +class StudyTag(Base): # type:ignore + """ + A table to manage the many-to-many relationship between `Study` and `Tag` + """ + + __tablename__ = "study_tag" + __table_args__ = (PrimaryKeyConstraint("study_id", "tag_label"),) + + study_id: str = Column(String(36), ForeignKey("study.id", ondelete="CASCADE"), index=True, nullable=False) + tag_label: str = Column(String(40), ForeignKey("tag.label", ondelete="CASCADE"), index=True, nullable=False) + + def __str__(self) -> str: + return f"[StudyTag] study_id={self.study_id}, tag={self.tag}" + + +class Tag(Base): # type:ignore + """ + A table to store all tags + """ + + __tablename__ = "tag" + + label = Column(String(40), primary_key=True, index=True) + color: str = Column(String(20), index=True, default=lambda: secrets.choice(COLOR_NAMES)) + + studies: t.List["Study"] = relationship("Study", secondary=StudyTag.__table__, back_populates="tags") + + def __str__(self) -> str: + return f"[Tag] label={self.label}, css-color-code={self.color}" + class StudyContentStatus(enum.Enum): VALID = "VALID" @@ -106,6 +149,8 @@ class Study(Base): # type: ignore public_mode = Column(Enum(PublicMode), default=PublicMode.NONE) owner_id = Column(Integer, ForeignKey(Identity.id), nullable=True, index=True) archived = Column(Boolean(), default=False, index=True) + + tags: t.List[Tag] = relationship(Tag, secondary=StudyTag.__table__, back_populates="studies") owner = relationship(Identity, uselist=False) groups = relationship(Group, secondary=lambda: groups_metadata, cascade="") additional_data = relationship( diff --git a/scripts/rollback.sh b/scripts/rollback.sh index 7a8d32a169..bf92685dc4 100755 --- a/scripts/rollback.sh +++ b/scripts/rollback.sh @@ -12,5 +12,5 @@ CUR_DIR=$(cd "$(dirname "$0")" && pwd) BASE_DIR=$(dirname "$CUR_DIR") cd "$BASE_DIR" -alembic downgrade 782a481f3414 +alembic downgrade 1f5db5dfad80 cd - diff --git a/tests/study/model.py b/tests/study/model.py deleted file mode 100644 index 1dcea2964a..0000000000 --- a/tests/study/model.py +++ /dev/null @@ -1,66 +0,0 @@ -""" -Test the database model. -""" -import uuid - -from sqlalchemy import inspect # type: ignore -from sqlalchemy.engine import Engine # type: ignore -from sqlalchemy.orm import Session # type: ignore - -from antarest.study.model import Study - - -# noinspection SpellCheckingInspection -class TestStudy: - """ - Test the study model. - """ - - def test_study(self, db_session: Session) -> None: - """ - Basic test of the `study` table. - """ - study_id = uuid.uuid4() - - with db_session: - db_session.add(Study(id=str(study_id), name="Study 1")) - db_session.commit() - - with db_session: - study = db_session.query(Study).first() - assert study.id == str(study_id) - assert study.name == "Study 1" - - def test_index_on_study(self, db_engine: Engine) -> None: - inspector = inspect(db_engine) - indexes = inspector.get_indexes("study") - index_names = {index["name"] for index in indexes} - assert index_names == { - "ix_study_archived", - "ix_study_created_at", - "ix_study_folder", - "ix_study_name", - "ix_study_owner_id", - "ix_study_parent_id", - "ix_study_type", - "ix_study_updated_at", - "ix_study_version", - } - - def test_index_on_rawstudy(self, db_engine: Engine) -> None: - inspector = inspect(db_engine) - indexes = inspector.get_indexes("rawstudy") - index_names = {index["name"] for index in indexes} - assert index_names == {"ix_rawstudy_workspace", "ix_rawstudy_missing"} - - def test_index_on_variantstudy(self, db_engine: Engine) -> None: - inspector = inspect(db_engine) - indexes = inspector.get_indexes("variantstudy") - index_names = {index["name"] for index in indexes} - assert not index_names - - def test_index_on_study_additional_data(self, db_engine: Engine) -> None: - inspector = inspect(db_engine) - indexes = inspector.get_indexes("study_additional_data") - index_names = {index["name"] for index in indexes} - assert index_names == {"ix_study_additional_data_patch"} diff --git a/tests/study/test_model.py b/tests/study/test_model.py new file mode 100644 index 0000000000..6683366b48 --- /dev/null +++ b/tests/study/test_model.py @@ -0,0 +1,122 @@ +""" +Test the database model. +""" +import uuid + +from sqlalchemy import inspect # type: ignore +from sqlalchemy.engine import Engine # type: ignore +from sqlalchemy.orm import Session, joinedload # type: ignore + +from antarest.study.model import Study, StudyTag, Tag + + +class TestStudy: + """ + Test the study model. + """ + + def test_study(self, db_session: Session) -> None: + """ + Basic test of the `study` table. + """ + study_id = uuid.uuid4() + + with db_session: + db_session.add(Study(id=str(study_id), name="Study 1")) + db_session.commit() + + with db_session: + study = db_session.query(Study).first() + assert study.id == str(study_id) + assert study.name == "Study 1" + + def test_index_on_study(self, db_engine: Engine) -> None: + inspector = inspect(db_engine) + indexes = inspector.get_indexes("study") + index_names = {index["name"] for index in indexes} + assert index_names == { + "ix_study_archived", + "ix_study_created_at", + "ix_study_folder", + "ix_study_name", + "ix_study_owner_id", + "ix_study_parent_id", + "ix_study_type", + "ix_study_updated_at", + "ix_study_version", + } + + def test_index_on_rawstudy(self, db_engine: Engine) -> None: + inspector = inspect(db_engine) + indexes = inspector.get_indexes("rawstudy") + index_names = {index["name"] for index in indexes} + assert index_names == {"ix_rawstudy_workspace", "ix_rawstudy_missing"} + + def test_index_on_variantstudy(self, db_engine: Engine) -> None: + inspector = inspect(db_engine) + indexes = inspector.get_indexes("variantstudy") + index_names = {index["name"] for index in indexes} + assert not index_names + + def test_index_on_study_additional_data(self, db_engine: Engine) -> None: + inspector = inspect(db_engine) + indexes = inspector.get_indexes("study_additional_data") + index_names = {index["name"] for index in indexes} + assert index_names == {"ix_study_additional_data_patch"} + + def test_study_tag_relationship(self, db_session: Session) -> None: + study_id_1 = str(uuid.uuid4()) + study_id_2 = str(uuid.uuid4()) + study_id_3 = str(uuid.uuid4()) + + with db_session: + tag1 = Tag(label="test-tag-1") + tag2 = Tag(label="test-tag-2") + db_session.add(Study(id=study_id_1, name="TestStudyTags1", tags=[tag1, tag2])) + db_session.add(Study(id=study_id_2, name="TestStudyTags2", tags=[tag1])) + db_session.add(Study(id=study_id_3, name="TestStudyTags3")) + db_session.commit() + + # verify that when the Study is initialized with tags so would the tables `tag` and `study_tag` be updated + study_tag_pairs = db_session.query(StudyTag).all() + tags = db_session.query(Tag).all() + studies = db_session.query(Study).all() + assert len(study_tag_pairs) == 3 + assert set(e.tag_label for e in study_tag_pairs) == {"test-tag-1", "test-tag-2"} + assert set(e.study_id for e in study_tag_pairs) == {study_id_1, study_id_2} + assert len(tags) == 2 + assert set(tag.label for tag in tags) == {"test-tag-1", "test-tag-2"} + assert len(studies) == 3 + assert set(study.id for study in studies) == {study_id_1, study_id_2, study_id_3} + + # verify that ondelete works for studies + db_session.query(Study).filter(Study.id == study_id_2).delete() + db_session.commit() + study_tag_pairs = db_session.query(StudyTag).all() + tags = db_session.query(Tag).all() + studies = db_session.query(Study).all() + assert len(study_tag_pairs) == 2 + assert set(e.tag_label for e in study_tag_pairs) == {"test-tag-1", "test-tag-2"} + assert set(e.study_id for e in study_tag_pairs) == {study_id_1} + assert len(tags) == 2 + assert set(tag.label for tag in tags) == {"test-tag-1", "test-tag-2"} + assert len(studies) == 2 + assert set(study.id for study in studies) == {study_id_1, study_id_3} + + # verify ondelete works for tags + db_session.query(Tag).filter(Tag.label == "test-tag-2").delete() + db_session.commit() + study_tag_pairs = db_session.query(StudyTag).all() + tags = db_session.query(Tag).all() + studies = db_session.query(Study).all() + assert len(study_tag_pairs) == 1 + assert set(e.tag_label for e in study_tag_pairs) == {"test-tag-1"} + assert set(e.study_id for e in study_tag_pairs) == {study_id_1} + assert len(tags) == 1 + assert set(tag.label for tag in tags) == {"test-tag-1"} + assert len(studies) == 2 + assert set(study.id for study in studies) == {study_id_1, study_id_3} + studies = db_session.query(Study).filter(Study.id == study_id_1).options(joinedload(Study.tags)).all() + assert len(studies) == 1 + assert set(study.id for study in studies) == {study_id_1} + assert set(tag.label for tag in studies[0].tags) == {"test-tag-1"}