From 9e1995be684e6a6f8bae70a5313e270ae042501e Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Mon, 24 Feb 2025 20:50:44 +0000 Subject: [PATCH] Create Queries table; smarten Alembic Don't have db.create_all() run when Alembic runs because then it fails to detect new tables. --- app/classes.py | 71 ++++++++++--------- app/dbmanager.py | 10 ++- app/dbtables.py | 33 +++++++-- app/models.py | 18 +++-- app/musicmuster.py | 16 +++-- .../335cae31045f_create_queries_table.py | 55 ++++++++++++++ 6 files changed, 153 insertions(+), 50 deletions(-) create mode 100644 migrations/versions/335cae31045f_create_queries_table.py diff --git a/app/classes.py b/app/classes.py index a4fdbfc..6d2839d 100644 --- a/app/classes.py +++ b/app/classes.py @@ -23,27 +23,7 @@ from PyQt6.QtWidgets import ( # App imports -class Col(Enum): - START_GAP = 0 - TITLE = auto() - ARTIST = auto() - INTRO = auto() - DURATION = auto() - START_TIME = auto() - END_TIME = auto() - LAST_PLAYED = auto() - BITRATE = auto() - NOTE = auto() - - -class QueryCol(Enum): - TITLE = 0 - ARTIST = auto() - DURATION = auto() - LAST_PLAYED = auto() - BITRATE = auto() - - +# Define singleton first as it's needed below def singleton(cls): """ Make a class a Singleton class (see @@ -66,6 +46,33 @@ def singleton(cls): return wrapper_singleton +class ApplicationError(Exception): + """ + Custom exception + """ + + pass + + +class AudioMetadata(NamedTuple): + start_gap: int = 0 + silence_at: int = 0 + fade_at: int = 0 + + +class Col(Enum): + START_GAP = 0 + TITLE = auto() + ARTIST = auto() + INTRO = auto() + DURATION = auto() + START_TIME = auto() + END_TIME = auto() + LAST_PLAYED = auto() + BITRATE = auto() + NOTE = auto() + + class FileErrors(NamedTuple): path: str error: str @@ -82,20 +89,6 @@ class Filter: duration_unit: str = "minutes" -class ApplicationError(Exception): - """ - Custom exception - """ - - pass - - -class AudioMetadata(NamedTuple): - start_gap: int = 0 - silence_at: int = 0 - fade_at: int = 0 - - @singleton @dataclass class MusicMusterSignals(QObject): @@ -142,6 +135,14 @@ class PlaylistStyle(QProxyStyle): super().drawPrimitive(element, option, painter, widget) +class QueryCol(Enum): + TITLE = 0 + ARTIST = auto() + DURATION = auto() + LAST_PLAYED = auto() + BITRATE = auto() + + class Tags(NamedTuple): artist: str = "" title: str = "" diff --git a/app/dbmanager.py b/app/dbmanager.py index 9f8c2ca..a099382 100644 --- a/app/dbmanager.py +++ b/app/dbmanager.py @@ -1,4 +1,5 @@ # Standard library imports +import sys # PyQt imports @@ -8,6 +9,12 @@ from alchemical import Alchemical # type:ignore # App imports +def is_alembic_command(): + # Define keywords that indicate Alembic is being invoked. + alembic_keywords = {'alembic', 'revision', 'upgrade', 'downgrade', 'history', 'current'} + return any(arg in alembic_keywords for arg in sys.argv) + + class DatabaseManager: """ Singleton class to ensure we only ever have one db object @@ -18,7 +25,8 @@ class DatabaseManager: def __init__(self, database_url: str, **kwargs: dict) -> None: if DatabaseManager.__instance is None: self.db = Alchemical(database_url, **kwargs) - self.db.create_all() + if not is_alembic_command(): + self.db.create_all() DatabaseManager.__instance = self else: raise Exception("Attempted to create a second DatabaseManager instance") diff --git a/app/dbtables.py b/app/dbtables.py index b0bb075..d6e8502 100644 --- a/app/dbtables.py +++ b/app/dbtables.py @@ -1,6 +1,7 @@ # Standard library imports from typing import Optional import datetime as dt +import json # PyQt imports @@ -18,8 +19,30 @@ from sqlalchemy.orm import ( mapped_column, relationship, ) +from sqlalchemy.types import TypeDecorator, TEXT # App imports +from classes import Filter + + +class JSONEncodedDict(TypeDecorator): + """ + Custom JSON Type for MariaDB (since native JSON type is just LONGTEXT) + """ + + impl = TEXT + + def process_bind_param(self, value, dialect): + """Convert Python dictionary to JSON string before saving.""" + if value is None: + return None + return json.dumps(value) + + def process_result_value(self, value, dialect): + """Convert JSON string back to Python dictionary after retrieval.""" + if value is None: + return None + return json.loads(value) # Database classes @@ -104,7 +127,9 @@ class PlaylistRowsTable(Model): ) playlist: Mapped[PlaylistsTable] = relationship(back_populates="rows") - track_id: Mapped[Optional[int]] = mapped_column(ForeignKey("tracks.id", ondelete="CASCADE")) + track_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("tracks.id", ondelete="CASCADE") + ) track: Mapped["TracksTable"] = relationship( "TracksTable", back_populates="playlistrows", @@ -126,15 +151,13 @@ class QueriesTable(Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(128), nullable=False) - sql: Mapped[str] = mapped_column( - String(2048), index=False, default="", nullable=False - ) + filter: Mapped[Filter] = mapped_column(JSONEncodedDict, nullable=True) favourite: Mapped[bool] = mapped_column( Boolean, nullable=False, index=False, default=False ) def __repr__(self) -> str: - return f"" + return f"" class SettingsTable(Model): diff --git a/app/models.py b/app/models.py index 535bf53..e094f0c 100644 --- a/app/models.py +++ b/app/models.py @@ -253,10 +253,7 @@ class Playlists(dbtables.PlaylistsTable): return session.scalars( select(cls) - .where( - cls.is_template.is_(True), - cls.favourite.is_(True) - ) + .where(cls.is_template.is_(True), cls.favourite.is_(True)) .order_by(cls.name) ).all() @@ -608,6 +605,19 @@ class PlaylistRows(dbtables.PlaylistRowsTable): session.connection().execute(stmt, sqla_map) +class Queries(dbtables.QueriesTable): + def __init__( + self, session: Session, name: str, filter: dbtables.Filter, favourite: bool = False + ) -> None: + """Create new query""" + + self.name = name + self.filter = filter + self.favourite = favourite + session.add(self) + session.commit() + + class Settings(dbtables.SettingsTable): def __init__(self, session: Session, name: str) -> None: self.name = name diff --git a/app/musicmuster.py b/app/musicmuster.py index 173da9c..72ee1a4 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -751,10 +751,11 @@ class Window(QMainWindow): with db.Session() as session: submenu_items: list[dict[str, str | tuple[Session, int]]] = [ - {"text": "Show all", - "handler": "create_playlist_from_template", - "args": (session, 0) - } + { + "text": "Show all", + "handler": "create_playlist_from_template", + "args": (session, 0), + } ] templates = Playlists.get_favourite_templates(session) for template in templates: @@ -2199,7 +2200,12 @@ class Window(QMainWindow): self.playlist_section.tabPlaylist.setTabIcon( idx, QIcon(Config.PLAYLIST_ICON_CURRENT) ) - elif self.playlist_section.tabPlaylist.widget(idx).model().sourceModel().is_template: + elif ( + self.playlist_section.tabPlaylist.widget(idx) + .model() + .sourceModel() + .is_template + ): self.playlist_section.tabPlaylist.setTabIcon( idx, QIcon(Config.PLAYLIST_ICON_TEMPLATE) ) diff --git a/migrations/versions/335cae31045f_create_queries_table.py b/migrations/versions/335cae31045f_create_queries_table.py new file mode 100644 index 0000000..0b7ff92 --- /dev/null +++ b/migrations/versions/335cae31045f_create_queries_table.py @@ -0,0 +1,55 @@ +"""create queries table + +Revision ID: 335cae31045f +Revises: f14dd379850f +Create Date: 2025-02-24 20:43:41.534922 + +""" +from alembic import op +import sqlalchemy as sa +import dbtables + + +# revision identifiers, used by Alembic. +revision = '335cae31045f' +down_revision = 'f14dd379850f' +branch_labels = None +depends_on = None + + +def upgrade(engine_name: str) -> None: + globals()["upgrade_%s" % engine_name]() + + +def downgrade(engine_name: str) -> None: + globals()["downgrade_%s" % engine_name]() + + + + + +def upgrade_() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('queries', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('filter', dbtables.JSONEncodedDict(), nullable=True), + sa.Column('favourite', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('playdates', schema=None) as batch_op: + batch_op.drop_constraint('fk_playdates_track_id_tracks', type_='foreignkey') + batch_op.create_foreign_key(None, 'tracks', ['track_id'], ['id'], ondelete='CASCADE') + + # ### end Alembic commands ### + + +def downgrade_() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('playdates', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key('fk_playdates_track_id_tracks', 'tracks', ['track_id'], ['id']) + + op.drop_table('queries') + # ### end Alembic commands ### +