diff --git a/alembic.ini b/alembic.ini index e19c527..892de8d 100644 --- a/alembic.ini +++ b/alembic.ini @@ -41,9 +41,7 @@ prepend_sys_path = . sqlalchemy.url = SET # sqlalchemy.url = mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod -# sqlalchemy.url = mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_dev -# sqlalchemy.url = mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_v2 -# sqlalchemy.url = mysql+mysqldb://musicmusterv3:musicmusterv3@localhost/musicmuster_dev_v3 +# sqlalchemy.url = mysql+mysqldb://dev_musicmuster:dev_musicmuster@localhost/dev_musicmuster [post_write_hooks] # post_write_hooks defines scripts or Python functions that are run diff --git a/app/helpers.py b/app/helpers.py index 36e2a37..69c7b97 100644 --- a/app/helpers.py +++ b/app/helpers.py @@ -325,6 +325,12 @@ def set_track_metadata(session, track): session.commit() +def show_OK(title: str, msg: str) -> None: + """Display a message to user""" + + QMessageBox.information(None, title, msg, buttons=QMessageBox.Ok) + + def show_warning(title: str, msg: str) -> None: """Display a warning to user""" diff --git a/app/models.py b/app/models.py index e2553e7..6986377 100644 --- a/app/models.py +++ b/app/models.py @@ -202,7 +202,8 @@ class Playlists(Base): id: int = Column(Integer, primary_key=True, autoincrement=True) 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) + loaded = Column(Boolean, default=True, nullable=False) + is_template = Column(Boolean, default=False, nullable=False) rows = relationship( "PlaylistRows", back_populates="playlist", @@ -211,7 +212,10 @@ class Playlists(Base): ) def __repr__(self) -> str: - return f"" + return ( + f"" + ) def __init__(self, session: Session, name: str) -> None: self.name = name @@ -236,6 +240,19 @@ class Playlists(Base): self.loaded = False + @classmethod + def create_playlist_from_template(cls, + session: Session, + template: "Playlists", + playlist_name: str) \ + -> "Playlists": + """Create a new playlist from template""" + + playlist = cls(session, playlist_name) + PlaylistRows.copy_playlist(session, template.id, playlist.id) + + return playlist + @classmethod def get_all(cls, session: Session) -> List["Playlists"]: """Returns a list of all playlists ordered by last use""" @@ -243,12 +260,27 @@ class Playlists(Base): return ( session.execute( select(cls) + .filter(cls.is_template.is_(False)) .order_by(cls.loaded.desc(), cls.last_used.desc()) ) .scalars() .all() ) + @classmethod + def get_all_templates(cls, session: Session) -> List["Playlists"]: + """Returns a list of all templates ordered by name""" + + return ( + session.execute( + select(cls) + .filter(cls.is_template.is_(True)) + .order_by(cls.name) + ) + .scalars() + .all() + ) + @classmethod def get_closed(cls, session: Session) -> List["Playlists"]: """Returns a list of all closed playlists ordered by last use""" @@ -256,7 +288,10 @@ class Playlists(Base): return ( session.execute( select(cls) - .filter(cls.loaded.is_(False)) + .filter( + cls.loaded.is_(False), + cls.is_template.is_(False) + ) .order_by(cls.last_used.desc()) ) .scalars() @@ -285,20 +320,16 @@ class Playlists(Base): self.loaded = True self.last_used = datetime.now() - # def remove_track(self, session: Session, row: int) -> None: - # log.debug(f"Playlist.remove_track({self.id=}, {row=})") - # - # # Refresh self first (this is necessary when calling - # remove_track - # # multiple times before session.commit()) - # session.refresh(self) - # # Get tracks collection for this playlist - # # Tracks are a dictionary of tracks keyed on row - # # number. Remove the relevant row. - # del self.tracks[row] - # # Save the new tracks collection - # session.flush() - # + @staticmethod + def save_as_template(session: Session, + playlist_id: int, template_name: str) -> None: + """Save passed playlist as new template""" + + template = Playlists(session, template_name) + template.is_template = True + session.commit() + + PlaylistRows.copy_playlist(session, playlist_id, template.id) class PlaylistRows(Base): @@ -320,17 +351,37 @@ class PlaylistRows(Base): f"note={self.note} row_number={self.row_number}>" ) - def __init__( - self, session: Session, playlist_id: int, track_id: int, - row_number: int) -> None: + def __init__(self, + session: Session, + playlist_id: int, + track_id: int, + row_number: int, + note: Optional[str] = None + ) -> None: """Create PlaylistRows object""" self.playlist_id = playlist_id self.track_id = track_id self.row_number = row_number + self.note = note session.add(self) session.flush() + @staticmethod + def copy_playlist(session: Session, + src_id: int, + dst_id: int) -> None: + """Copy playlist entries""" + + src_rows = session.execute( + select(PlaylistRows) + .filter(PlaylistRows.playlist_id == src_id) + ).scalars().all() + + for plr in src_rows: + PlaylistRows(session, dst_id, plr.track_id, plr.row_number, + plr.note) + @staticmethod def delete_higher_rows(session: Session, playlist_id: int, row: int) \ -> None: diff --git a/app/musicmuster.py b/app/musicmuster.py index ce19dfc..8db848b 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -199,9 +199,11 @@ class Window(QMainWindow, Ui_MainWindow): self.actionInsertSectionHeader.triggered.connect(self.insert_header) self.actionInsertTrack.triggered.connect(self.insert_track) self.actionMoveSelected.triggered.connect(self.move_selected) - self.actionNewPlaylist.triggered.connect(self.create_playlist) + self.actionNew_from_template.triggered.connect(self.new_from_template) + self.actionNewPlaylist.triggered.connect(self.create_and_show_playlist) self.actionOpenPlaylist.triggered.connect(self.open_playlist) self.actionPlay_next.triggered.connect(self.play_next) + self.actionSave_as_template.triggered.connect(self.save_as_template) self.actionSearch.triggered.connect(self.search_playlist) self.actionSelect_next_track.triggered.connect(self.select_next_row) self.actionSelect_previous_track.triggered.connect( @@ -224,18 +226,23 @@ class Window(QMainWindow, Ui_MainWindow): self.timer.timeout.connect(self.tick) - def create_playlist(self) -> None: + def create_playlist(self, + session: Session, + playlist_name: Optional[str] = None) -> Playlists: """Create new playlist""" - dlg = QInputDialog(self) - dlg.setInputMode(QInputDialog.TextInput) - dlg.setLabelText("Playlist name:") - dlg.resize(500, 100) - ok = dlg.exec() - if ok: - with Session() as session: - playlist = Playlists(session, dlg.textValue()) - self.create_playlist_tab(session, playlist) + if not playlist_name: + playlist_name = self.solicit_playlist_name() + + playlist = Playlists(session, playlist_name) + return playlist + + def create_and_show_playlist(self) -> None: + """Create new playlist and display it""" + + with Session() as session: + playlist = self.create_playlist(session) + self.create_playlist_tab(session, playlist) def create_playlist_tab(self, session: Session, playlist: Playlists) -> None: @@ -609,6 +616,24 @@ class Window(QMainWindow, Ui_MainWindow): session, playlist_id) self.move_playlist_rows(session, unplayed_playlist_rows) + def new_from_template(self) -> None: + """Create new playlist from template""" + + with Session() as session: + templates = Playlists.get_all_templates(session) + dlg = SelectPlaylistDialog(self, playlists=templates, + session=session) + dlg.exec() + template = dlg.playlist + if template: + playlist_name = self.solicit_playlist_name() + if not playlist_name: + return + playlist = Playlists.create_playlist_from_template( + session, template, playlist_name) + playlist.mark_open(session) + self.create_playlist_tab(session, playlist) + def open_playlist(self): """Open existing playlist""" @@ -706,6 +731,35 @@ class Window(QMainWindow, Ui_MainWindow): self.label_end_time.setText( self.current_track_end_time.strftime(Config.TRACK_TIME_FORMAT)) + def save_as_template(self) -> None: + """Save current playlist as template""" + + with Session() as session: + template_names = [ + a.name for a in Playlists.get_all_templates(session) + ] + + while True: + # Get name for new template + dlg = QInputDialog(self) + dlg.setInputMode(QInputDialog.TextInput) + dlg.setLabelText("Template name:") + dlg.resize(500, 100) + ok = dlg.exec() + if not ok: + return + + template_name = dlg.textValue() + if template_name not in template_names: + break + helpers.show_warning("Duplicate template", + "Template name already in use" + ) + Playlists.save_as_template(session, + self.visible_playlist_tab().playlist_id, + template_name) + helpers.show_OK("Template", "Template saved") + def search_playlist(self) -> None: """Show text box to search playlist""" @@ -784,6 +838,19 @@ class Window(QMainWindow, Ui_MainWindow): self.tabPlaylist.setCurrentWidget(self.next_track_playlist_tab) self.tabPlaylist.currentWidget().scroll_next_to_top() + def solicit_playlist_name(self) -> Optional[str]: + """Get name of playlist from user""" + + dlg = QInputDialog(self) + dlg.setInputMode(QInputDialog.TextInput) + dlg.setLabelText("Playlist name:") + dlg.resize(500, 100) + ok = dlg.exec() + if ok: + return dlg.textValue() + else: + return None + def stop(self) -> None: """Stop playing immediately""" diff --git a/app/playlists.py b/app/playlists.py index 4a9d076..8f4e380 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -572,8 +572,7 @@ class PlaylistTab(QTableWidget): # can be reset by calling PlaylistRows.fixup_rownumbers() later, # so just fudge a row number for now. row_number = 0 - plr = PlaylistRows(session, self.playlist_id, None, row_number) - plr.note = note + plr = PlaylistRows(session, self.playlist_id, None, row_number, note) self.insert_row(session, plr) PlaylistRows.fixup_rownumbers(session, self.playlist_id) if repaint: diff --git a/app/ui/main_window.ui b/app/ui/main_window.ui index a9a4e35..cfe268a 100644 --- a/app/ui/main_window.ui +++ b/app/ui/main_window.ui @@ -761,7 +761,7 @@ text-align: left; 0 0 1280 - 24 + 26 @@ -775,12 +775,14 @@ text-align: left; + + + - @@ -1093,6 +1095,16 @@ text-align: left; &About + + + Save as template... + + + + + New from template... + + diff --git a/app/ui/main_window_ui.py b/app/ui/main_window_ui.py index ce60ee4..eaac3cc 100644 --- a/app/ui/main_window_ui.py +++ b/app/ui/main_window_ui.py @@ -348,7 +348,7 @@ class Ui_MainWindow(object): self.gridLayout_4.addWidget(self.frame_5, 3, 0, 1, 1) MainWindow.setCentralWidget(self.centralwidget) self.menubar = QtWidgets.QMenuBar(MainWindow) - self.menubar.setGeometry(QtCore.QRect(0, 0, 1280, 24)) + self.menubar.setGeometry(QtCore.QRect(0, 0, 1280, 26)) self.menubar.setObjectName("menubar") self.menuFile = QtWidgets.QMenu(self.menubar) self.menuFile.setObjectName("menuFile") @@ -458,6 +458,10 @@ class Ui_MainWindow(object): self.actionFind_previous.setObjectName("actionFind_previous") self.action_About = QtWidgets.QAction(MainWindow) self.action_About.setObjectName("action_About") + self.actionSave_as_template = QtWidgets.QAction(MainWindow) + self.actionSave_as_template.setObjectName("actionSave_as_template") + self.actionNew_from_template = QtWidgets.QAction(MainWindow) + self.actionNew_from_template.setObjectName("actionNew_from_template") self.menuFile.addAction(self.actionNewPlaylist) self.menuFile.addAction(self.actionOpenPlaylist) self.menuFile.addAction(self.actionClosePlaylist) @@ -465,12 +469,14 @@ class Ui_MainWindow(object): self.menuFile.addAction(self.actionExport_playlist) self.menuFile.addAction(self.actionDeletePlaylist) self.menuFile.addSeparator() + self.menuFile.addAction(self.actionNew_from_template) + self.menuFile.addAction(self.actionSave_as_template) + self.menuFile.addSeparator() self.menuFile.addAction(self.actionMoveSelected) self.menuFile.addAction(self.actionMoveUnplayed) self.menuFile.addAction(self.actionDownload_CSV_of_played_tracks) self.menuFile.addSeparator() self.menuFile.addAction(self.actionE_xit) - self.menuFile.addSeparator() self.menuPlaylist.addSeparator() self.menuPlaylist.addAction(self.actionPlay_next) self.menuPlaylist.addAction(self.actionFade) @@ -587,5 +593,7 @@ class Ui_MainWindow(object): self.actionFind_previous.setText(_translate("MainWindow", "Find previous")) self.actionFind_previous.setShortcut(_translate("MainWindow", "P")) self.action_About.setText(_translate("MainWindow", "&About")) + self.actionSave_as_template.setText(_translate("MainWindow", "Save as template...")) + self.actionNew_from_template.setText(_translate("MainWindow", "New from template...")) from infotabs import InfoTabs import icons_rc diff --git a/migrations/versions/b4f524e2140c_add_templates.py b/migrations/versions/b4f524e2140c_add_templates.py new file mode 100644 index 0000000..2523174 --- /dev/null +++ b/migrations/versions/b4f524e2140c_add_templates.py @@ -0,0 +1,32 @@ +"""Add templates + +Revision ID: b4f524e2140c +Revises: ed3100326c38 +Create Date: 2022-10-01 13:30:21.663287 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b4f524e2140c' +down_revision = 'ed3100326c38' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_foreign_key(None, 'playlist_rows', 'tracks', ['track_id'], ['id']) + op.create_foreign_key(None, 'playlist_rows', 'playlists', ['playlist_id'], ['id']) + op.add_column('playlists', sa.Column('is_template', sa.Boolean(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('playlists', 'is_template') + op.drop_constraint(None, 'playlist_rows', type_='foreignkey') + op.drop_constraint(None, 'playlist_rows', type_='foreignkey') + # ### end Alembic commands ###