From b7111d8a3b629970e90c833fdc7a3ff3e3ac1e0b Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Sun, 31 Jul 2022 21:11:34 +0100 Subject: [PATCH] SQLA2: WIP --- app/helpers.py | 50 ++--- app/models.py | 14 +- app/playlists.py | 206 ++++++++++++------ ipython_commands.txt | 9 + ...063011ed67_schema_changes_for_row_notes.py | 54 +++++ play.py | 94 ++++++++ 6 files changed, 327 insertions(+), 100 deletions(-) create mode 100644 ipython_commands.txt create mode 100644 migrations/versions/3b063011ed67_schema_changes_for_row_notes.py create mode 100755 play.py diff --git a/app/helpers.py b/app/helpers.py index 7d4a37d..b4af652 100644 --- a/app/helpers.py +++ b/app/helpers.py @@ -137,31 +137,31 @@ # return min(trim_ms, len(audio_segment)) # # -# def ms_to_mmss(ms: int, decimals: int = 0, negative: bool = False) -> str: -# """Convert milliseconds to mm:ss""" -# -# minutes: int -# remainder: int -# seconds: float -# -# if not ms: -# return "-" -# sign = "" -# if ms < 0: -# if negative: -# sign = "-" -# else: -# ms = 0 -# -# minutes, remainder = divmod(ms, 60 * 1000) -# seconds = remainder / 1000 -# -# # if seconds >= 59.5, it will be represented as 60, which looks odd. -# # So, fake it under those circumstances -# if seconds >= 59.5: -# seconds = 59.0 -# -# return f"{sign}{minutes:.0f}:{seconds:02.{decimals}f}" +def ms_to_mmss(ms: int, decimals: int = 0, negative: bool = False) -> str: + """Convert milliseconds to mm:ss""" + + minutes: int + remainder: int + seconds: float + + if not ms: + return "-" + sign = "" + if ms < 0: + if negative: + sign = "-" + else: + ms = 0 + + minutes, remainder = divmod(ms, 60 * 1000) + seconds = remainder / 1000 + + # if seconds >= 59.5, it will be represented as 60, which looks odd. + # So, fake it under those circumstances + if seconds >= 59.5: + seconds = 59.0 + + return f"{sign}{minutes:.0f}:{seconds:02.{decimals}f}" # # # def open_in_audacity(path: str) -> Optional[bool]: diff --git a/app/models.py b/app/models.py index 7e627f6..1d50990 100644 --- a/app/models.py +++ b/app/models.py @@ -275,8 +275,12 @@ class Playlists(Base): name: str = Column(String(32), nullable=False, unique=True) last_used = Column(DateTime, default=None, nullable=True) loaded: bool = Column(Boolean, default=True, nullable=False) - rows = relationship("PlaylistRows", back_populates="playlist", - cascade="all, delete-orphan") + rows = relationship( + "PlaylistRows", + back_populates="playlist", + cascade="all, delete-orphan", + order_by="PlaylistRows.row_number" + ) def __repr__(self) -> str: return f"" @@ -317,10 +321,6 @@ class Playlists(Base): # ).all() # # @classmethod -# def get_by_id(cls, session: Session, playlist_id: int) -> "Playlists": -# return (session.query(cls).filter(cls.id == playlist_id)).one() -# -# @classmethod # def get_closed(cls, session: Session) -> List["Playlists"]: # """Returns a list of all closed playlists ordered by last use""" # @@ -529,7 +529,7 @@ class Tracks(Base): silence_at = Column(Integer, index=False) path = Column(String(2048), index=False, nullable=False) mtime = Column(Float, index=True) - lastplayed = Column(DateTime, index=True, default=None) + # lastplayed = Column(DateTime, index=True, default=None) playlistrows = relationship("PlaylistRows", back_populates="track") playlists = association_proxy("playlistrows", "playlist") playdates = relationship("Playdates", back_populates="track") diff --git a/app/playlists.py b/app/playlists.py index 9f1a495..975e091 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -4,7 +4,7 @@ from collections import namedtuple # from typing import Dict, List, Optional, Set, Tuple, Union # # from PyQt5 import QtCore -# from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt # from PyQt5.Qt import QFont # from PyQt5.QtGui import QColor, QDropEvent # from PyQt5 import QtWidgets @@ -21,7 +21,7 @@ from PyQt5.QtWidgets import ( QTableWidgetItem, ) # -# import helpers +import helpers # import os # import re # import subprocess @@ -34,8 +34,8 @@ from config import Config from models import ( # Notes, # Playdates, - # Playlists, - # PlaylistTracks, + Playlists, + PlaylistRows, Settings, # Tracks, # NoteColours @@ -55,16 +55,18 @@ from dbconfig import Session # Columns Column = namedtuple("Column", ['idx', 'heading']) columns = {} -columns["userdata"] = Column(idx=0, heading=Config.COLUMN_NAME_AUTOPLAY), -columns["mss"] = Column(idx=1, heading=Config.COLUMN_NAME_LEADING_SILENCE), -columns["title"] = Column(idx=2, heading=Config.COLUMN_NAME_TITLE), -columns["artist"] = Column(idx=3, heading=Config.COLUMN_NAME_ARTIST), -columns["duration"] = Column(idx=4, heading=Config.COLUMN_NAME_LENGTH), -columns["start_time"] = Column(idx=5, heading=Config.COLUMN_NAME_START_TIME), -columns["end_time"] = Column(idx=6, heading=Config.COLUMN_NAME_END_TIME), -columns["last_played"] = Column(idx=7, heading=Config.COLUMN_NAME_LAST_PLAYED), -columns["row_notes"] = Column(idx=8, heading=Config.COLUMN_NAME_NOTES), -# +columns["userdata"] = Column(idx=0, heading=Config.COLUMN_NAME_AUTOPLAY) +columns["start_gap"] = Column( + idx=1, heading=Config.COLUMN_NAME_LEADING_SILENCE) +columns["title"] = Column(idx=2, heading=Config.COLUMN_NAME_TITLE) +columns["artist"] = Column(idx=3, heading=Config.COLUMN_NAME_ARTIST) +columns["duration"] = Column(idx=4, heading=Config.COLUMN_NAME_LENGTH) +columns["start_time"] = Column(idx=5, heading=Config.COLUMN_NAME_START_TIME) +columns["end_time"] = Column(idx=6, heading=Config.COLUMN_NAME_END_TIME) +columns["lastplayed"] = Column(idx=7, heading=Config.COLUMN_NAME_LAST_PLAYED) +columns["row_notes"] = Column(idx=8, heading=Config.COLUMN_NAME_NOTES) + + # class NoSelectDelegate(QStyledItemDelegate): # """https://stackoverflow.com/questions/72790705/dont-select-text-in-qtablewidget-cell-when-editing/72792962#72792962""" # @@ -85,10 +87,10 @@ class PlaylistTab(QTableWidget): # cellEditingStarted = QtCore.pyqtSignal(int, int) # cellEditingEnded = QtCore.pyqtSignal() - # # Qt.UserRoles - # ROW_METADATA = Qt.UserRole - # CONTENT_OBJECT = Qt.UserRole + 1 - # ROW_DURATION = Qt.UserRole + 2 + # Qt.UserRoles + ROW_METADATA = Qt.UserRole + # CONTENT_OBJECT = Qt.UserRole + 1 + ROW_DURATION = Qt.UserRole + 2 def __init__(self, musicmuster: QMainWindow, session: Session, playlist_id: int, *args, **kwargs): @@ -119,8 +121,8 @@ class PlaylistTab(QTableWidget): self._set_column_widths(session) # Set column headings sorted by idx self.setHorizontalHeaderLabels( - [a[0].heading for a in list(sorted(columns.values(), - key=lambda item: item[0][0]))] + [a.heading for a in list(sorted(columns.values(), + key=lambda item: item.idx))] ) # self.setDragEnabled(True) @@ -153,8 +155,8 @@ class PlaylistTab(QTableWidget): # self.doubleClicked.connect(self._edit_cell) self.horizontalHeader().sectionResized.connect(self._column_resize) # -# # Now load our tracks and notes -# self.populate(session, self.playlist_id) + # Now load our tracks and notes + self.populate(session, self.playlist_id) def _column_resize(self, idx, old, new): """ @@ -165,7 +167,7 @@ class PlaylistTab(QTableWidget): with Session() as session: for column_name, data in columns.items(): - idx = data[0].idx + idx = data.idx width = self.columnWidth(idx) attribute_name = f"playlist_{column_name}_col_width" record = Settings.get_int_settings(session, attribute_name) @@ -350,6 +352,87 @@ class PlaylistTab(QTableWidget): # return self.item(row, self.COL_TITLE).text() # else: # return None + + def insert_row(self, session: Session, row_data: PlaylistRows, + repaint: bool = True) -> None: + """ + Insert a row into playlist tab. + + If playlist has a row selected, add new row above. Otherwise, + add to end of playlist. + + Note: we ignore the row number in the PlaylistRows record. That is + used only to order the query that generates the records. + """ + + if self.selectionModel().hasSelection(): + row = self.currentRow() + else: + row = self.rowCount() + self.insertRow(row) + + # Add row metadata to userdata column + item: QTableWidgetItem = QTableWidgetItem() + item.setData(self.ROW_METADATA, 0) + self.setItem(row, columns['userdata'].idx, item) + + # Prepare notes, start and end items for later + notes_item = QTableWidgetItem(row_data.note) + start_item = QTableWidgetItem() + end_item = QTableWidgetItem() + + if row_data.track_id: + # Add track details to items + start_gap = row_data.track.start_gap + start_gap_item = QTableWidgetItem(str(start_gap)) + if start_gap and start_gap >= 500: + start_gap_item.setBackground(QColor(Config.COLOUR_LONG_START)) + + title_item = QTableWidgetItem(row_data.track.title) + + artist_item = QTableWidgetItem(row_data.track.artist) + + duration_item = QTableWidgetItem( + helpers.ms_to_mmss(row_data.track.duration)) + self._set_row_duration(row, row_data.track.duration) + + last_playtime = Playdates.last_played(session, row_data.track.id) + last_played_str = get_relative_date(last_playtime) + last_played_item = QTableWidgetItem(last_played_str) + self.setItem(row, columns['lastplayed'], last_played_item) + + # Mark track if file is unreadable + if not self._file_is_readable(row_data.track.path): + self._set_unreadable_row(row) + + else: + # This is a note row so make empty items (row background + # won't be coloured without items present) + start_gap_item = QTableWidgetItem() + title_item = QTableWidgetItem() + artist_item = QTableWidgetItem() + duration_item = QTableWidgetItem() + last_played_item = QTableWidgetItem() + + # Add items to table + self.setItem(row, columns['start_gap'].idx, start_gap_item) + self.setItem(row, columns['title'].idx, title_item) + self.setItem(row, columns['artist'].idx, artist_item) + self.setItem(row, columns['duration'].idx, duration_item) + self.setItem(row, columns['start_time'].idx, start_item) + self.setItem(row, columns['end_time'].idx, end_item) + self.setItem(row, columns['row_notes'].idx, notes_item) + + if not row_data.track_id: + # Span note across table + self.setSpan(row, 0, len(columns), 1) + + # Scroll to new row + self.scrollToItem(title_item, QAbstractItemView.PositionAtCenter) + + if repaint: + self.save_playlist(session) + self.update_display(session, clear_selection=False) # # def insert_track(self, session: Session, track: Tracks, # repaint: bool = True) -> None: @@ -525,50 +608,37 @@ class PlaylistTab(QTableWidget): # # self._clear_current_track_row() # self.current_track_start_time = None -# -# def populate(self, session: Session, playlist_id: int) -> None: -# """ -# Populate from the associated playlist ID -# -# We don't mandate that an item will be on its specified row, only -# that it will be above larger-numbered row items, and below -# lower-numbered ones. -# """ -# -# data: List[Union[Tuple[List[int], Tracks], Tuple[List[int], Notes]]] \ -# = [] -# item: Union[Notes, Tracks] -# note: Notes -# row: int -# track: Tracks -# -# playlist = Playlists.get_by_id(session, playlist_id) -# -# for row, track in playlist.tracks.items(): -# data.append(([row], track)) -# for note in playlist.notes: -# data.append(([note.row], note)) -# -# # Clear playlist -# self.setRowCount(0) -# -# # Now add data in row order -# for i in sorted(data, key=lambda x: x[0]): -# item = i[1] -# if isinstance(item, Tracks): -# self.insert_track(session, item, repaint=False) -# elif isinstance(item, Notes): -# self._insert_note(session, item, repaint=False) -# -# # Scroll to top -# scroll_to: QTableWidgetItem = self.item(0, 0) -# self.scrollToItem(scroll_to, QAbstractItemView.PositionAtTop) -# -# # We possibly don't need to save the playlist here, but row -# # numbers may have changed during population, and it's cheap to do -# self.save_playlist(session) -# self.update_display(session) -# + + def populate(self, session: Session, playlist_id: int) -> None: + """ + Populate from the associated playlist ID + """ + + # data: List[Union[Tuple[List[int], Tracks], Tuple[List[int], Notes]]] \ + # = [] + # item: Union[Notes, Tracks] + # note: Notes + # row: int + # track: Tracks + + playlist = session.get(Playlists, playlist_id) + + # Clear playlist + self.setRowCount(0) + + # Add the rows + for row in playlist.rows: + self.insert_row(session, row, repaint=False) + + # Scroll to top + scroll_to: QTableWidgetItem = self.item(0, 0) + self.scrollToItem(scroll_to, QAbstractItemView.PositionAtTop) + + # We possibly don't need to save the playlist here, but row + # numbers may have changed during population, and it's cheap to do + # KAE self.save_playlist(session) + self.update_display(session) + # def save_playlist(self, session) -> None: # """ # Save playlist to database. @@ -1614,7 +1684,7 @@ class PlaylistTab(QTableWidget): """Column widths from settings""" for column_name, data in columns.items(): - idx = data[0].idx + idx = data.idx attr_name = f"playlist_{column_name}_col_width" record: Settings = Settings.get_int_settings(session, attr_name) if record and record.f_int is not None: diff --git a/ipython_commands.txt b/ipython_commands.txt new file mode 100644 index 0000000..2cc21ea --- /dev/null +++ b/ipython_commands.txt @@ -0,0 +1,9 @@ +from sqlalchemy.orm import (sessionmaker, scoped_session) +s = sessionmaker(bind=engine) +from dbconfig import engine +s = sessionmaker(bind=engine) +s +playlist in s +s = scoped_session(sessionmaker(bind=engine)) +playlist_id = 3 +playlist = Playlists.get_by_id(session, playlist_id) diff --git a/migrations/versions/3b063011ed67_schema_changes_for_row_notes.py b/migrations/versions/3b063011ed67_schema_changes_for_row_notes.py new file mode 100644 index 0000000..587f7fa --- /dev/null +++ b/migrations/versions/3b063011ed67_schema_changes_for_row_notes.py @@ -0,0 +1,54 @@ +"""schema changes for row notes + +Revision ID: 3b063011ed67 +Revises: 51f61433256f +Create Date: 2022-07-06 19:48:23.960471 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '3b063011ed67' +down_revision = '51f61433256f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('notes') + op.add_column('playlist_rows', sa.Column('note', sa.String(length=2048), nullable=True)) + op.alter_column('playlist_rows', 'track_id', + existing_type=mysql.INTEGER(display_width=11), + nullable=True) + op.drop_index('uniquerow', table_name='playlist_rows') + op.drop_column('playlist_rows', 'text') + op.alter_column('playlist_rows', 'row', new_column_name='row_number', + existing_type=mysql.INTEGER(display_width=11), + nullable=False) + op.create_index('uniquerow', 'playlist_rows', ['row_number', 'playlist_id'], unique=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('playlist_rows', 'row_number', new_column_name='row', + existing_type=mysql.INTEGER(display_width=11), + nullable=False) + op.add_column('playlist_rows', sa.Column('text', mysql.VARCHAR(length=2048), nullable=True)) + op.drop_index('uniquerow', table_name='playlist_rows') + op.create_index('uniquerow', 'playlist_rows', ['row', 'playlist_id'], unique=False) + op.drop_column('playlist_rows', 'note') + op.create_table('notes', + sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False), + sa.Column('playlist_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True), + sa.Column('row', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False), + sa.Column('note', mysql.VARCHAR(length=256), nullable=True), + sa.ForeignKeyConstraint(['playlist_id'], ['playlists.id'], name='notes_ibfk_1'), + sa.PrimaryKeyConstraint('id'), + mysql_default_charset='utf8mb4', + mysql_engine='InnoDB' + ) + # ### end Alembic commands ### diff --git a/play.py b/play.py new file mode 100755 index 0000000..3b7f381 --- /dev/null +++ b/play.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python + +from sqlalchemy import create_engine +from sqlalchemy import text +from sqlalchemy import Table, Column, Integer, String +from sqlalchemy import ForeignKey +from sqlalchemy import select +from sqlalchemy import insert +from sqlalchemy.orm import Session +from sqlalchemy.orm import declarative_base +from sqlalchemy.orm import relationship + + +Base = declarative_base() + +engine = create_engine("sqlite+pysqlite:///:memory:", echo=True, future=True) + + +class User(Base): + __tablename__ = 'user_account' + id = Column(Integer, primary_key=True) + name = Column(String(30)) + fullname = Column(String) + addresses = relationship("Address", back_populates="user") + + def __repr__(self): + return ( + f"User(id={self.id!r}, name={self.name!r}, " + f"fullname={self.fullname!r})" + ) + + +class Address(Base): + __tablename__ = 'address' + id = Column(Integer, primary_key=True) + email_address = Column(String, nullable=False) + user_id = Column(Integer, ForeignKey('user_account.id')) + user = relationship("User", back_populates="addresses") + + def __repr__(self): + return f"Address(id={self.id!r}, email_address={self.email_address!r})" + + +Base.metadata.create_all(engine) + +squidward = User(name="squidward", fullname="Squidward Tentacles") +krabs = User(name="ehkrabs", fullname="Eugene H. Krabs") + +session = Session(engine) + +session.add(squidward) +session.add(krabs) + +session.commit() + +u1 = User(name='pkrabs', fullname='Pearl Krabs') +a1 = Address(email_address="pearl.krabs@gmail.com") +u1.addresses.append(a1) +a2 = Address(email_address="pearl@aol.com", user=u1) + +session.add(u1) +session.add(a1) +session.add(a2) + +session.commit() + + +# with engine.connect() as conn: +# conn.execute(text("CREATE TABLE some_table (x int, y int)")) +# conn.execute( +# text("INSERT INTO some_table (x, y) VALUES (:x, :y)"), +# [{"x": 1, "y": 1}, {"x": 2, "y": 4}] +# ) +# conn.commit() +# +# with engine.begin() as conn: +# conn.execute( +# text("INSERT INTO some_table (x, y) VALUES (:x, :y)"), +# [{"x": 6, "y": 8}, {"x": 9, "y": 10}] +# ) +# +# # with engine.connect() as conn: +# # result = conn.execute(text("SELECT x, y FROM some_table")) +# # for row in result: +# # print(f"x: {row.x} y: {row.y}") +# +# +# stmt = text( +# "SELECT x, y FROM some_table WHERE y > :y ORDER BY x, y").bindparams(y=6) +# +# with Session(engine) as session: +# result = session.execute(stmt) +# for row in result: +# print(f"x: {row.x} y: {row.y}")