From b635bad83f104b9e7be943a864c88e75e823af7b Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Wed, 8 Mar 2023 03:27:49 +0000 Subject: [PATCH] Migrate to using Declarative Base in Songs --- openlp/plugins/songs/forms/editsongform.py | 20 +- .../songs/forms/songmaintenanceform.py | 28 +- openlp/plugins/songs/lib/__init__.py | 2 +- openlp/plugins/songs/lib/db.py | 454 ++++++++---------- .../songs/lib/importers/foilpresenter.py | 12 +- openlp/plugins/songs/lib/importers/openlp.py | 286 ++++------- .../plugins/songs/lib/importers/songimport.py | 16 +- openlp/plugins/songs/lib/mediaitem.py | 14 +- openlp/plugins/songs/lib/openlyricsxml.py | 14 +- openlp/plugins/songs/lib/songselect.py | 6 +- setup.cfg | 4 + .../songs/forms/test_songmaintenanceform.py | 14 +- tests/openlp_plugins/songs/test_db.py | 4 +- tests/openlp_plugins/songs/test_mediaitem.py | 2 +- .../songs/test_openlpimporter.py | 130 +++-- .../songs/test_openlyricsxml.py | 2 +- tests/openlp_plugins/songs/test_songselect.py | 10 +- tests/resources/songs/openlp/songs-1.9.7.json | 36 ++ .../resources/songs/openlp/songs-1.9.7.sqlite | Bin 0 -> 35840 bytes tests/resources/songs/openlp/songs-2.4.6.json | 56 +++ .../resources/songs/openlp/songs-2.4.6.sqlite | Bin 0 -> 69632 bytes 21 files changed, 559 insertions(+), 551 deletions(-) create mode 100644 tests/resources/songs/openlp/songs-1.9.7.json create mode 100644 tests/resources/songs/openlp/songs-1.9.7.sqlite create mode 100644 tests/resources/songs/openlp/songs-2.4.6.json create mode 100644 tests/resources/songs/openlp/songs-2.4.6.sqlite diff --git a/openlp/plugins/songs/forms/editsongform.py b/openlp/plugins/songs/forms/editsongform.py index d7b0acde0..29a2af99c 100644 --- a/openlp/plugins/songs/forms/editsongform.py +++ b/openlp/plugins/songs/forms/editsongform.py @@ -44,7 +44,7 @@ from openlp.plugins.songs.forms.editsongdialog import Ui_EditSongDialog from openlp.plugins.songs.forms.editverseform import EditVerseForm from openlp.plugins.songs.forms.mediafilesform import MediaFilesForm from openlp.plugins.songs.lib import VerseType, clean_song -from openlp.plugins.songs.lib.db import Author, AuthorType, Book, MediaFile, Song, SongBookEntry, Topic +from openlp.plugins.songs.lib.db import Author, AuthorType, SongBook, MediaFile, Song, SongBookEntry, Topic from openlp.plugins.songs.lib.openlyricsxml import SongXML from openlp.plugins.songs.lib.ui import SongStrings, show_key_warning @@ -406,7 +406,7 @@ class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties): Load the Songbooks into the combobox """ self.songbooks = [] - self._load_objects(Book, self.songbooks_combo_box, self.songbooks) + self._load_objects(SongBook, self.songbooks_combo_box, self.songbooks) def load_themes(self, theme_list: list): """ @@ -599,10 +599,10 @@ class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties): translate('SongsPlugin.EditSongForm', 'This author does not exist, do you want to add them?'), defaultButton=QtWidgets.QMessageBox.Yes) == QtWidgets.QMessageBox.Yes: if text.find(' ') == -1: - author = Author.populate(first_name='', last_name='', display_name=text) + author = Author(first_name='', last_name='', display_name=text) else: - author = Author.populate(first_name=text.rsplit(' ', 1)[0], last_name=text.rsplit(' ', 1)[1], - display_name=text) + author = Author(first_name=text.rsplit(' ', 1)[0], last_name=text.rsplit(' ', 1)[1], + display_name=text) self.manager.save_object(author) self._add_author_to_list(author, author_type) self.load_authors() @@ -676,7 +676,7 @@ class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties): self, translate('SongsPlugin.EditSongForm', 'Add Topic'), translate('SongsPlugin.EditSongForm', 'This topic does not exist, do you want to add it?'), defaultButton=QtWidgets.QMessageBox.Yes) == QtWidgets.QMessageBox.Yes: - topic = Topic.populate(name=text) + topic = Topic(name=text) self.manager.save_object(topic) topic_item = QtWidgets.QListWidgetItem(str(topic.name)) topic_item.setData(QtCore.Qt.UserRole, topic.id) @@ -722,7 +722,7 @@ class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties): self, translate('SongsPlugin.EditSongForm', 'Add Songbook'), translate('SongsPlugin.EditSongForm', 'This Songbook does not exist, do you want to add it?'), defaultButton=QtWidgets.QMessageBox.Yes) == QtWidgets.QMessageBox.Yes: - songbook = Book.populate(name=text) + songbook = SongBook(name=text) self.manager.save_object(songbook) self.add_songbook_entry_to_list(songbook.id, songbook.name, self.songbook_entry_edit.text()) self.load_songbooks() @@ -733,7 +733,7 @@ class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties): return elif item >= 0: item_id = (self.songbooks_combo_box.itemData(item)) - songbook = self.manager.get_object(Book, item_id) + songbook = self.manager.get_object(SongBook, item_id) if self.songbooks_list_view.findItems(str(songbook.name), QtCore.Qt.MatchExactly): critical_error_message_box( message=translate('SongsPlugin.EditSongForm', 'This Songbook is already in the list.')) @@ -1029,7 +1029,7 @@ class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties): def save_song(self, preview=False): """ Get all the data from the widgets on the form, and then save it to the database. The form has been validated - and all reference items (Authors, Books and Topics) have been saved before this function is called. + and all reference items (Authors, SongBooks and Topics) have been saved before this function is called. :param preview: Should be ``True`` if the song is also previewed (boolean). """ @@ -1075,7 +1075,7 @@ class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties): for row in range(self.songbooks_list_view.count()): item = self.songbooks_list_view.item(row) songbook_id = item.data(QtCore.Qt.UserRole)[0] - songbook = self.manager.get_object(Book, songbook_id) + songbook = self.manager.get_object(SongBook, songbook_id) entry = item.data(QtCore.Qt.UserRole)[1] self.song.add_songbook_entry(songbook, entry) # Save the song here because we need a valid id for the audio files. diff --git a/openlp/plugins/songs/forms/songmaintenanceform.py b/openlp/plugins/songs/forms/songmaintenanceform.py index 1da39a6e9..86d0190a3 100644 --- a/openlp/plugins/songs/forms/songmaintenanceform.py +++ b/openlp/plugins/songs/forms/songmaintenanceform.py @@ -30,7 +30,7 @@ from openlp.core.lib.ui import critical_error_message_box from openlp.plugins.songs.forms.authorsform import AuthorsForm from openlp.plugins.songs.forms.songbookform import SongBookForm from openlp.plugins.songs.forms.topicsform import TopicsForm -from openlp.plugins.songs.lib.db import Author, Book, Song, Topic, SongBookEntry +from openlp.plugins.songs.lib.db import Author, SongBook, Song, Topic, SongBookEntry from .songmaintenancedialog import Ui_SongMaintenanceDialog @@ -163,7 +163,7 @@ class SongMaintenanceForm(QtWidgets.QDialog, Ui_SongMaintenanceDialog, RegistryP return get_natural_key(book.name) self.song_books_list_widget.clear() - books = self.manager.get_all_objects(Book) + books = self.manager.get_all_objects(SongBook) books.sort(key=get_book_key) for book in books: book_name = QtWidgets.QListWidgetItem('{name} ({publisher})'.format(name=book.name, @@ -206,7 +206,7 @@ class SongMaintenanceForm(QtWidgets.QDialog, Ui_SongMaintenanceDialog, RegistryP :param edit: Are we editing the song? """ books = self.manager.get_all_objects( - Book, and_(Book.name == new_book.name, Book.publisher == new_book.publisher)) + SongBook, and_(SongBook.name == new_book.name, SongBook.publisher == new_book.publisher)) return self._check_object_exists(books, new_book, edit) def _check_object_exists(self, existing_objects, new_object, edit): @@ -236,7 +236,7 @@ class SongMaintenanceForm(QtWidgets.QDialog, Ui_SongMaintenanceDialog, RegistryP """ self.author_form.auto_display_name = True if self.author_form.exec(): - author = Author.populate( + author = Author( first_name=self.author_form.first_name, last_name=self.author_form.last_name, display_name=self.author_form.display_name @@ -256,7 +256,7 @@ class SongMaintenanceForm(QtWidgets.QDialog, Ui_SongMaintenanceDialog, RegistryP Add a topic to the list. """ if self.topic_form.exec(): - topic = Topic.populate(name=self.topic_form.name) + topic = Topic(name=self.topic_form.name) if self.check_topic_exists(topic): if self.manager.save_object(topic): self.reset_topics() @@ -272,8 +272,8 @@ class SongMaintenanceForm(QtWidgets.QDialog, Ui_SongMaintenanceDialog, RegistryP Add a book to the list. """ if self.song_book_form.exec(): - book = Book.populate(name=self.song_book_form.name_edit.text(), - publisher=self.song_book_form.publisher_edit.text()) + book = SongBook(name=self.song_book_form.name_edit.text(), + publisher=self.song_book_form.publisher_edit.text()) if self.check_song_book_exists(book): if self.manager.save_object(book): self.reset_song_books() @@ -370,7 +370,7 @@ class SongMaintenanceForm(QtWidgets.QDialog, Ui_SongMaintenanceDialog, RegistryP book_id = self._get_current_item_id(self.song_books_list_widget) if book_id == -1: return - book = self.manager.get_object(Book, book_id) + book = self.manager.get_object(SongBook, book_id) if book.publisher is None: book.publisher = '' self.song_book_form.name_edit.setText(book.name) @@ -466,11 +466,11 @@ class SongMaintenanceForm(QtWidgets.QDialog, Ui_SongMaintenanceDialog, RegistryP """ # Find the duplicate. existing_book = self.manager.get_object_filtered( - Book, + SongBook, and_( - Book.name == old_song_book.name, - Book.publisher == old_song_book.publisher, - Book.id != old_song_book.id + SongBook.name == old_song_book.name, + SongBook.publisher == old_song_book.publisher, + SongBook.id != old_song_book.id ) ) if existing_book is None: @@ -508,7 +508,7 @@ class SongMaintenanceForm(QtWidgets.QDialog, Ui_SongMaintenanceDialog, RegistryP self.manager.save_object(song) self.manager.delete_object(SongBookEntry, (old_song_book.id, song_id, old_book_song_number)) - self.manager.delete_object(Book, old_song_book.id) + self.manager.delete_object(SongBook, old_song_book.id) def on_delete_author_button_clicked(self): """ @@ -537,7 +537,7 @@ class SongMaintenanceForm(QtWidgets.QDialog, Ui_SongMaintenanceDialog, RegistryP """ Delete the Book if the Book is not attached to any songs. """ - self._delete_item(Book, self.song_books_list_widget, self.reset_song_books, + self._delete_item(SongBook, self.song_books_list_widget, self.reset_song_books, translate('SongsPlugin.SongMaintenanceForm', 'Delete Book'), translate('SongsPlugin.SongMaintenanceForm', 'Are you sure you want to delete the selected book?'), diff --git a/openlp/plugins/songs/lib/__init__.py b/openlp/plugins/songs/lib/__init__.py index 49c1be68f..83504ec91 100644 --- a/openlp/plugins/songs/lib/__init__.py +++ b/openlp/plugins/songs/lib/__init__.py @@ -387,7 +387,7 @@ def clean_song(manager, song): name = SongStrings.AuthorUnknown author = manager.get_object_filtered(Author, Author.display_name == name) if author is None: - author = Author.populate(display_name=name, last_name='', first_name='') + author = Author(display_name=name, last_name='', first_name='') song.add_author(author) if song.copyright: song.copyright = CONTROL_CHARS.sub('', song.copyright).strip() diff --git a/openlp/plugins/songs/lib/db.py b/openlp/plugins/songs/lib/db.py index 9d2ac5672..4b8717248 100644 --- a/openlp/plugins/songs/lib/db.py +++ b/openlp/plugins/songs/lib/db.py @@ -21,40 +21,124 @@ """ The :mod:`db` module provides the database and schema that is the backend for the Songs plugin -""" -from sqlalchemy import Column, ForeignKey, Table, types -from sqlalchemy.orm import class_mapper, mapper, reconstructor, relation -from sqlalchemy.sql.expression import func, text -from sqlalchemy.orm.exc import UnmappedClassError +The song database contains the following tables: + + * authors + * authors_songs + * media_files + * media_files_songs + * song_books + * songs + * songs_songbooks + * songs_topics + * topics + +**authors** Table + This table holds the names of all the authors. It has the following + columns: + + * id + * first_name + * last_name + * display_name + +**authors_songs Table** + This is a bridging table between the *authors* and *songs* tables, which + serves to create a many-to-many relationship between the two tables. It + has the following columns: + + * author_id + * song_id + * author_type + +**media_files Table** + * id + * file_path + * file_hash + * type + * weight + +**song_books Table** + The *song_books* table holds a list of books that a congregation gets + their songs from, or old hymnals now no longer used. This table has the + following columns: + + * id + * name + * publisher + +**songs Table** + This table contains the songs, and each song has a list of attributes. + The *songs* table has the following columns: + + * id + * title + * alternate_title + * lyrics + * verse_order + * copyright + * comments + * ccli_number + * theme_name + * search_title + * search_lyrics + +**songs_songsbooks Table** + This is a mapping table between the *songs* and the *song_books* tables. It has the following columns: + + * songbook_id + * song_id + * entry # The song number, like 120 or 550A + +**songs_topics Table** + This is a bridging table between the *songs* and *topics* tables, which + serves to create a many-to-many relationship between the two tables. It + has the following columns: + + * song_id + * topic_id + +**topics Table** + The topics table holds a selection of topics that songs can cover. This + is useful when a worship leader wants to select songs with a certain + theme. This table has the following columns: + + * id + * name +""" +from typing import Optional + +from sqlalchemy import Column, ForeignKey, MetaData, Table +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm import reconstructor, relationship +from sqlalchemy.sql.expression import func, text +from sqlalchemy.types import Boolean, DateTime, Integer, Unicode, UnicodeText + +# Maintain backwards compatibility with older versions of SQLAlchemy while supporting SQLAlchemy 1.4+ +try: + from sqlalchemy.orm import declarative_base +except ImportError: + from sqlalchemy.ext.declarative import declarative_base + from openlp.core.common.i18n import get_natural_key, translate -from openlp.core.lib.db import BaseModel, PathType, init_db +from openlp.core.lib.db import PathType, init_db -class Author(BaseModel): - """ - Author model - """ - def get_display_name(self, author_type=None): - if author_type: - return "{name} ({author})".format(name=self.display_name, author=AuthorType.Types[author_type]) - return self.display_name +Base = declarative_base(MetaData()) -class AuthorSong(BaseModel): - """ - Relationship between Authors and Songs (many to many). - Need to define this relationship table explicit to get access to the - Association Object (author_type). - http://docs.sqlalchemy.org/en/latest/orm/relationships.html#association-object - """ - pass +songs_topics_table = Table( + 'songs_topics', Base.metadata, + Column('song_id', Integer, ForeignKey('songs.id'), primary_key=True), + Column('topic_id', Integer, ForeignKey('topics.id'), primary_key=True) +) class AuthorType(object): """ - Enumeration for Author types. + Enumeration for Author They are defined by OpenLyrics: http://openlyrics.info/dataformat.html#authors The 'words+music' type is not an official type, but is provided for convenience. @@ -99,37 +183,109 @@ class AuthorType(object): return AuthorType.NoType -class Book(BaseModel): +class Author(Base): """ - Book model + Author model """ + __tablename__ = 'authors' + + id = Column(Integer, primary_key=True) + first_name = Column(Unicode(128)) + last_name = Column(Unicode(128)) + display_name = Column(Unicode(255), index=True, nullable=False) + + authors_songs = relationship('AuthorSong', back_populates='author') + + def get_display_name(self, author_type: Optional[str] = None) -> str: + if author_type: + return "{name} ({author})".format(name=self.display_name, author=AuthorType.Types[author_type]) + return self.display_name + + +class AuthorSong(Base): + """ + Relationship between Authors and Songs (many to many). + Need to define this relationship table explicit to get access to the + Association Object (author_type). + http://docs.sqlalchemy.org/en/latest/orm/relationships.html#association-object + """ + __tablename__ = 'authors_songs' + + author_id = Column(Integer, ForeignKey('authors.id'), primary_key=True) + song_id = Column(Integer, ForeignKey('songs.id'), primary_key=True) + author_type = Column(Unicode(255), primary_key=True, nullable=False, server_default=text('""')) + + author = relationship('Author', back_populates='authors_songs') + song = relationship('Song', back_populates='authors_songs') + + +class SongBook(Base): + """ + SongBook model + """ + __tablename__ = 'song_books' + + id = Column(Integer, primary_key=True) + name = Column(Unicode(128), nullable=False) + publisher = Column(Unicode(128)) + + songbook_entries = relationship('SongBookEntry', back_populates='songbook') + @property def songs(self): """ A property to return the songs associated with this book. """ - return [sbe.song for sbe in self.entries] + return [sbe.song for sbe in self.songbook_entries] def __repr__(self): - return ''.format(myid=self.id, - name=self.name, - publisher=self.publisher) + return f'' -class MediaFile(BaseModel): +class MediaFile(Base): """ MediaFile model """ - pass + __tablename__ = 'media_files' + + id = Column(Integer, primary_key=True) + song_id = Column(Integer, ForeignKey('songs.id'), default=None) + file_path = Column(PathType, nullable=False) + file_hash = Column(Unicode(128), nullable=False) + type = Column(Unicode(64), nullable=False, default='audio') + weight = Column(Integer, default=0) + + songs = relationship('Song', back_populates='media_files') -class Song(BaseModel): +class Song(Base): """ Song model """ + __tablename__ = 'songs' + id = Column(Integer, primary_key=True) + title = Column(Unicode(255), nullable=False) + alternate_title = Column(Unicode(255)) + lyrics = Column(UnicodeText, nullable=False) + verse_order = Column(Unicode(128)) + copyright = Column(Unicode(255)) + comments = Column(UnicodeText) + ccli_number = Column(Unicode(64)) + theme_name = Column(Unicode(128)) + search_title = Column(Unicode(255), index=True, nullable=False) + search_lyrics = Column(UnicodeText, nullable=False) + create_date = Column(DateTime, default=func.now()) + last_modified = Column(DateTime, default=func.now(), onupdate=func.now()) + temporary = Column(Boolean, default=False) - def __init__(self): - self.sort_key = [] + authors_songs = relationship('AuthorSong', back_populates='song', cascade='all, delete-orphan') + media_files = relationship('MediaFile', back_populates='songs', order_by='MediaFile.weight') + songbook_entries = relationship('SongBookEntry', back_populates='song', cascade='all, delete-orphan') + topics = relationship('Topic', back_populates='songs', secondary=songs_topics_table) + + @hybrid_property + def authors(self): + return [author_song.author for author_song in self.authors_songs] @reconstructor def init_on_load(self): @@ -151,7 +307,7 @@ class Song(BaseModel): for author_song in self.authors_songs: if author_song.author == author and author_song.author_type == author_type: return - new_author_song = AuthorSong() + new_author_song = AuthorSong(author=author, author_type=author_type) new_author_song.author = author new_author_song.author_type = author_type self.authors_songs.append(new_author_song) @@ -185,10 +341,19 @@ class Song(BaseModel): self.songbook_entries.append(new_songbook_entry) -class SongBookEntry(BaseModel): +class SongBookEntry(Base): """ SongBookEntry model """ + __tablename__ = 'songs_songbooks' + + songbook_id = Column(Integer, ForeignKey('song_books.id'), primary_key=True) + song_id = Column(Integer, ForeignKey('songs.id'), primary_key=True) + entry = Column(Unicode(255), primary_key=True, nullable=False) + + songbook = relationship('SongBook', back_populates='songbook_entries') + song = relationship('Song', back_populates='songbook_entries') + def __repr__(self): return SongBookEntry.get_display_name(self.songbook.name, self.entry) @@ -199,11 +364,15 @@ class SongBookEntry(BaseModel): return songbook_name -class Topic(BaseModel): +class Topic(Base): """ Topic model """ - pass + __tablename__ = 'topics' + id = Column(Integer, primary_key=True) + name = Column(Unicode(128), index=True, nullable=False) + + songs = relationship('Song', back_populates='topics', secondary=songs_topics_table) def init_schema(url): @@ -212,214 +381,7 @@ def init_schema(url): :param url: The database to setup - The song database contains the following tables: - - * authors - * authors_songs - * media_files - * media_files_songs - * song_books - * songs - * songs_songbooks - * songs_topics - * topics - - **authors** Table - This table holds the names of all the authors. It has the following - columns: - - * id - * first_name - * last_name - * display_name - - **authors_songs Table** - This is a bridging table between the *authors* and *songs* tables, which - serves to create a many-to-many relationship between the two tables. It - has the following columns: - - * author_id - * song_id - * author_type - - **media_files Table** - * id - * file_path - * file_hash - * type - * weight - - **song_books Table** - The *song_books* table holds a list of books that a congregation gets - their songs from, or old hymnals now no longer used. This table has the - following columns: - - * id - * name - * publisher - - **songs Table** - This table contains the songs, and each song has a list of attributes. - The *songs* table has the following columns: - - * id - * title - * alternate_title - * lyrics - * verse_order - * copyright - * comments - * ccli_number - * theme_name - * search_title - * search_lyrics - - **songs_songsbooks Table** - This is a mapping table between the *songs* and the *song_books* tables. It has the following columns: - - * songbook_id - * song_id - * entry # The song number, like 120 or 550A - - **songs_topics Table** - This is a bridging table between the *songs* and *topics* tables, which - serves to create a many-to-many relationship between the two tables. It - has the following columns: - - * song_id - * topic_id - - **topics Table** - The topics table holds a selection of topics that songs can cover. This - is useful when a worship leader wants to select songs with a certain - theme. This table has the following columns: - - * id - * name """ - session, metadata = init_db(url) - - # Definition of the "authors" table - authors_table = Table( - 'authors', metadata, - Column('id', types.Integer(), primary_key=True), - Column('first_name', types.Unicode(128)), - Column('last_name', types.Unicode(128)), - Column('display_name', types.Unicode(255), index=True, nullable=False) - ) - - # Definition of the "media_files" table - media_files_table = Table( - 'media_files', metadata, - Column('id', types.Integer(), primary_key=True), - Column('song_id', types.Integer(), ForeignKey('songs.id'), default=None), - Column('file_path', PathType, nullable=False), - Column('file_hash', types.Unicode(128), nullable=False), - Column('type', types.Unicode(64), nullable=False, default='audio'), - Column('weight', types.Integer(), default=0) - ) - - # Definition of the "song_books" table - song_books_table = Table( - 'song_books', metadata, - Column('id', types.Integer(), primary_key=True), - Column('name', types.Unicode(128), nullable=False), - Column('publisher', types.Unicode(128)) - ) - - # Definition of the "songs" table - songs_table = Table( - 'songs', metadata, - Column('id', types.Integer(), primary_key=True), - Column('title', types.Unicode(255), nullable=False), - Column('alternate_title', types.Unicode(255)), - Column('lyrics', types.UnicodeText, nullable=False), - Column('verse_order', types.Unicode(128)), - Column('copyright', types.Unicode(255)), - Column('comments', types.UnicodeText), - Column('ccli_number', types.Unicode(64)), - Column('theme_name', types.Unicode(128)), - Column('search_title', types.Unicode(255), index=True, nullable=False), - Column('search_lyrics', types.UnicodeText, nullable=False), - Column('create_date', types.DateTime(), default=func.now()), - Column('last_modified', types.DateTime(), default=func.now(), onupdate=func.now()), - Column('temporary', types.Boolean(), default=False) - ) - - # Definition of the "topics" table - topics_table = Table( - 'topics', metadata, - Column('id', types.Integer(), primary_key=True), - Column('name', types.Unicode(128), index=True, nullable=False) - ) - - # Definition of the "authors_songs" table - authors_songs_table = Table( - 'authors_songs', metadata, - Column('author_id', types.Integer(), ForeignKey('authors.id'), primary_key=True), - Column('song_id', types.Integer(), ForeignKey('songs.id'), primary_key=True), - Column('author_type', types.Unicode(255), primary_key=True, nullable=False, server_default=text('""')) - ) - - # Definition of the "songs_songbooks" table - songs_songbooks_table = Table( - 'songs_songbooks', metadata, - Column('songbook_id', types.Integer(), ForeignKey('song_books.id'), primary_key=True), - Column('song_id', types.Integer(), ForeignKey('songs.id'), primary_key=True), - Column('entry', types.Unicode(255), primary_key=True, nullable=False) - ) - - # Definition of the "songs_topics" table - songs_topics_table = Table( - 'songs_topics', metadata, - Column('song_id', types.Integer(), ForeignKey('songs.id'), primary_key=True), - Column('topic_id', types.Integer(), ForeignKey('topics.id'), primary_key=True) - ) - - # try/except blocks are for the purposes of tests - the mappers could have been defined in a previous test - try: - class_mapper(Author) - except UnmappedClassError: - mapper(Author, authors_table, properties={ - 'songs': relation(Song, secondary=authors_songs_table, viewonly=True) - }) - try: - class_mapper(AuthorSong) - except UnmappedClassError: - mapper(AuthorSong, authors_songs_table, properties={ - 'author': relation(Author) - }) - try: - class_mapper(SongBookEntry) - except UnmappedClassError: - mapper(SongBookEntry, songs_songbooks_table, properties={ - 'songbook': relation(Book, backref='entries') - }) - try: - class_mapper(Book) - except UnmappedClassError: - mapper(Book, song_books_table) - try: - class_mapper(MediaFile) - except UnmappedClassError: - mapper(MediaFile, media_files_table) - try: - class_mapper(Song) - except UnmappedClassError: - mapper(Song, songs_table, properties={ - # Use the authors_songs relation when you need access to the 'author_type' attribute - # or when creating new relations - 'authors_songs': relation(AuthorSong, cascade="all, delete-orphan"), - # Use lazy='joined' to always load authors when the song is fetched from the database (bug 1366198) - 'authors': relation(Author, secondary=authors_songs_table, viewonly=True, lazy='joined'), - 'media_files': relation(MediaFile, backref='songs', order_by=media_files_table.c.weight), - 'songbook_entries': relation(SongBookEntry, backref='song', cascade='all, delete-orphan'), - 'topics': relation(Topic, backref='songs', secondary=songs_topics_table) - }) - try: - class_mapper(Topic) - except UnmappedClassError: - mapper(Topic, topics_table) - + session, metadata = init_db(url, base=Base) metadata.create_all(checkfirst=True) return session diff --git a/openlp/plugins/songs/lib/importers/foilpresenter.py b/openlp/plugins/songs/lib/importers/foilpresenter.py index 7079b1153..a6f81b07f 100644 --- a/openlp/plugins/songs/lib/importers/foilpresenter.py +++ b/openlp/plugins/songs/lib/importers/foilpresenter.py @@ -89,7 +89,7 @@ from lxml import etree, objectify from openlp.core.common.i18n import translate from openlp.core.widgets.wizard import WizardStrings from openlp.plugins.songs.lib import VerseType, clean_song -from openlp.plugins.songs.lib.db import Author, Book, Song, Topic +from openlp.plugins.songs.lib.db import Author, SongBook, Song, Topic from openlp.plugins.songs.lib.importers.songimport import SongImport from openlp.plugins.songs.lib.openlyricsxml import SongXML from openlp.plugins.songs.lib.ui import SongStrings @@ -325,8 +325,8 @@ class FoilPresenter(object): author = self.manager.get_object_filtered(Author, Author.display_name == display_name) if author is None: # We need to create a new author, as the author does not exist. - author = Author.populate(display_name=display_name, last_name=display_name.split(' ')[-1], - first_name=' '.join(display_name.split(' ')[:-1])) + author = Author(display_name=display_name, last_name=display_name.split(' ')[-1], + first_name=' '.join(display_name.split(' ')[:-1])) self.manager.save_object(author) song.add_author(author) @@ -478,10 +478,10 @@ class FoilPresenter(object): for bucheintrag in foilpresenterfolie.buch.bucheintrag: book_name = to_str(bucheintrag.name) if book_name: - book = self.manager.get_object_filtered(Book, Book.name == book_name) + book = self.manager.get_object_filtered(SongBook, SongBook.name == book_name) if book is None: # We need to create a book, because it does not exist. - book = Book.populate(name=book_name, publisher='') + book = SongBook(name=book_name, publisher='') self.manager.save_object(book) song.song_book_id = book.id try: @@ -527,7 +527,7 @@ class FoilPresenter(object): topic = self.manager.get_object_filtered(Topic, Topic.name == topic_text) if topic is None: # We need to create a topic, because it does not exist. - topic = Topic.populate(name=topic_text) + topic = Topic(name=topic_text) self.manager.save_object(topic) song.topics.append(topic) except AttributeError: diff --git a/openlp/plugins/songs/lib/importers/openlp.py b/openlp/plugins/songs/lib/importers/openlp.py index a03fdb5a8..20aa150cb 100644 --- a/openlp/plugins/songs/lib/importers/openlp.py +++ b/openlp/plugins/songs/lib/importers/openlp.py @@ -24,23 +24,39 @@ song databases into the current installation database. """ import logging from pathlib import Path +from sqlite3 import Row, connect as sqlite3_connect -from sqlalchemy import MetaData, Table, create_engine -from sqlalchemy.orm import class_mapper, mapper, relation, scoped_session, sessionmaker -from sqlalchemy.orm.exc import UnmappedClassError - +from openlp.core.common import sha256_file_hash from openlp.core.common.i18n import translate -from openlp.core.lib.db import BaseModel from openlp.core.widgets.wizard import WizardStrings from openlp.plugins.songs.lib import clean_song -from openlp.plugins.songs.lib.db import Author, Book, MediaFile, Song, Topic +from openlp.plugins.songs.lib.db import Author, SongBook, MediaFile, Song, Topic from .songimport import SongImport - log = logging.getLogger(__name__) +SONG_AUTHORS = 'SELECT * FROM authors AS a JOIN authors_songs AS s ON a.id = s.author_id WHERE s.song_id = ?' +SONG_TOPICS = 'SELECT t.name AS name FROM topics AS t JOIN songs_topics AS st ON t.id = st.topic_id ' \ + 'WHERE st.song_id = ?' +SONG_BOOKS = 'SELECT b.name, b.publisher, e.entry FROM song_books AS b ' \ + 'JOIN songs_songbooks AS e ON b.id = e.songbook_id WHERE e.song_id = ?' +SONG_MEDIA = 'SELECT * FROM media_files WHERE song_id = ?' + + +def does_table_exist(conn, table_name: str) -> bool: + """Check if a table exists in the database""" + res = conn.execute('SELECT name FROM sqlite_master WHERE type = "table" AND name = ?', (table_name,)) + return res.fetchone() is not None + + +def does_column_exist(conn, table_name: str, column_name: str) -> bool: + """Check if a table exists in the database""" + res = conn.execute(f'SELECT * FROM {table_name} LIMIT 1') + return column_name in res.fetchone().keys() + + class OpenLPSongImport(SongImport): """ The :class:`OpenLPSongImport` class provides OpenLP with the ability to @@ -62,228 +78,109 @@ class OpenLPSongImport(SongImport): :param progress_dialog: The QProgressDialog used when importing songs from the FRW. """ - - class OldAuthorSong(BaseModel): - """ - Maps to the authors_songs table - """ - pass - - class OldAuthor(BaseModel): - """ - Maps to the authors table - """ - pass - - class OldBook(BaseModel): - """ - Maps to the songbooks table - """ - pass - - class OldMediaFile(BaseModel): - """ - Maps to the media_files table - """ - pass - - class OldSong(BaseModel): - """ - Maps to the songs table - """ - pass - - class OldTopic(BaseModel): - """ - Maps to the topics table - """ - pass - - class OldSongBookEntry(BaseModel): - """ - Maps to the songs_songbooks table - """ - pass - # Check the file type if self.import_source.suffix != '.sqlite': self.log_error(self.import_source, translate('SongsPlugin.OpenLPSongImport', 'Not a valid OpenLP 2 song database.')) return - self.import_source = 'sqlite:///{url}'.format(url=self.import_source) - # Load the db file and reflect it - engine = create_engine(self.import_source) - source_meta = MetaData() - source_meta.reflect(engine) - self.source_session = scoped_session(sessionmaker(bind=engine)) - # Run some checks to see which version of the database we have - table_list = list(source_meta.tables.keys()) - if 'media_files' in table_list: - has_media_files = True - else: - has_media_files = False - if 'songs_songbooks' in table_list: - has_songs_books = True - else: - has_songs_books = False - if 'authors_songs' in table_list: - has_authors_songs = True - else: - has_authors_songs = False - # Load up the tabls and map them out - try: - source_authors_table = source_meta.tables['authors'] - source_song_books_table = source_meta.tables['song_books'] - source_songs_table = source_meta.tables['songs'] - source_topics_table = source_meta.tables['topics'] - source_authors_songs_table = source_meta.tables['authors_songs'] - source_songs_topics_table = source_meta.tables['songs_topics'] - source_media_files_songs_table = None - except KeyError: + # Connect to the database + conn = sqlite3_connect(self.import_source) + conn.row_factory = Row + # Check that we have some of the mandatory tables + if not any([does_table_exist(conn, 'songs'), does_table_exist(conn, 'authors'), + does_table_exist(conn, 'topics'), does_table_exist(conn, 'song_books')]): self.log_error(self.import_source, translate('SongsPlugin.OpenLPSongImport', 'Not a valid OpenLP 2 song database.')) return - # Set up media_files relations - if has_media_files: - source_media_files_table = source_meta.tables['media_files'] - source_media_files_songs_table = source_meta.tables.get('media_files_songs') - try: - class_mapper(OldMediaFile) - except UnmappedClassError: - mapper(OldMediaFile, source_media_files_table) - if has_songs_books: - source_songs_songbooks_table = source_meta.tables['songs_songbooks'] - try: - class_mapper(OldSongBookEntry) - except UnmappedClassError: - mapper(OldSongBookEntry, source_songs_songbooks_table, properties={'songbook': relation(OldBook)}) - if has_authors_songs: - try: - class_mapper(OldAuthorSong) - except UnmappedClassError: - mapper(OldAuthorSong, source_authors_songs_table) - if has_authors_songs and 'author_type' in source_authors_songs_table.c.keys(): - has_author_type = True - else: - has_author_type = False - # Set up the songs relationships - song_props = { - 'authors': relation(OldAuthor, backref='songs', secondary=source_authors_songs_table), - 'topics': relation(OldTopic, backref='songs', secondary=source_songs_topics_table) - } - if has_media_files: - if isinstance(source_media_files_songs_table, Table): - song_props['media_files'] = relation(OldMediaFile, backref='songs', - secondary=source_media_files_songs_table) - else: - song_props['media_files'] = \ - relation(OldMediaFile, backref='songs', - foreign_keys=[source_media_files_table.c.song_id], - primaryjoin=source_songs_table.c.id == source_media_files_table.c.song_id) - if has_songs_books: - song_props['songbook_entries'] = relation(OldSongBookEntry, backref='song', cascade='all, delete-orphan') - else: - song_props['book'] = relation(OldBook, backref='songs') - if has_authors_songs: - song_props['authors_songs'] = relation(OldAuthorSong) - # Map the rest of the tables - try: - class_mapper(OldAuthor) - except UnmappedClassError: - mapper(OldAuthor, source_authors_table) - try: - class_mapper(OldBook) - except UnmappedClassError: - mapper(OldBook, source_song_books_table) - try: - class_mapper(OldSong) - except UnmappedClassError: - mapper(OldSong, source_songs_table, properties=song_props) - try: - class_mapper(OldTopic) - except UnmappedClassError: - mapper(OldTopic, source_topics_table) - - source_songs = self.source_session.query(OldSong).all() + # Determine the database structure + has_media_files = does_table_exist(conn, 'media_files') + has_songs_books = does_table_exist(conn, 'songs_songbooks') + # has_authors_songs = does_table_exist(conn, 'authors_songs') + # has_author_type = has_authors_songs and does_column_exist(conn, 'authors_songs', 'author_type') + # Set up wizard if self.import_wizard: - self.import_wizard.progress_bar.setMaximum(len(source_songs)) - for song in source_songs: + song_count = conn.execute('SELECT COUNT(id) AS song_count FROM songs').fetchone() + self.import_wizard.progress_bar.setMaximum(song_count['song_count']) + for song in conn.execute('SELECT * FROM songs'): new_song = Song() - new_song.title = song.title - if has_media_files and hasattr(song, 'alternate_title'): - new_song.alternate_title = song.alternate_title + new_song.title = song['title'] + if 'alternate_title' in song.keys(): + new_song.alternate_title = song['alternate_title'] else: - old_titles = song.search_title.split('@') + old_titles = song['search_title'].split('@') if len(old_titles) > 1: new_song.alternate_title = old_titles[1] # Transfer the values to the new song object new_song.search_title = '' new_song.search_lyrics = '' - new_song.lyrics = song.lyrics - new_song.verse_order = song.verse_order - new_song.copyright = song.copyright - new_song.comments = song.comments - new_song.theme_name = song.theme_name - new_song.ccli_number = song.ccli_number - if hasattr(song, 'song_number') and song.song_number: - new_song.song_number = song.song_number + new_song.lyrics = song['lyrics'] + new_song.verse_order = song['verse_order'] + new_song.copyright = song['copyright'] + new_song.comments = song['comments'] + new_song.theme_name = song['theme_name'] + new_song.ccli_number = song['ccli_number'] + if 'song_number' in song.keys(): + new_song.song_number = song['song_number'] # Find or create all the authors and add them to the new song object - for author in song.authors: - existing_author = self.manager.get_object_filtered(Author, Author.display_name == author.display_name) + song_authors = conn.execute(SONG_AUTHORS, (song['id'],)) + for author in song_authors: + existing_author = self.manager.get_object_filtered(Author, + Author.display_name == author['display_name']) if not existing_author: - existing_author = Author.populate( - first_name=author.first_name, - last_name=author.last_name, - display_name=author.display_name + existing_author = Author( + first_name=author['first_name'], + last_name=author['last_name'], + display_name=author['display_name'] ) # If this is a new database, we need to import the author_type too - author_type = None - if has_author_type: - for author_song in song.authors_songs: - if author_song.author_id == author.id: - author_type = author_song.author_type - break + try: + author_type = author['author_type'] + except IndexError: + author_type = '' new_song.add_author(existing_author, author_type) # Find or create all the topics and add them to the new song object - if song.topics: - for topic in song.topics: - existing_topic = self.manager.get_object_filtered(Topic, Topic.name == topic.name) - if not existing_topic: - existing_topic = Topic.populate(name=topic.name) - new_song.topics.append(existing_topic) + for topic in conn.execute(SONG_TOPICS, (song['id'],)): + existing_topic = self.manager.get_object_filtered(Topic, Topic.name == topic['name']) + if not existing_topic: + existing_topic = Topic(name=topic['name']) + new_song.topics.append(existing_topic) # Find or create all the songbooks and add them to the new song object - if has_songs_books and song.songbook_entries: - for entry in song.songbook_entries: - existing_book = self.manager.get_object_filtered(Book, Book.name == entry.songbook.name) + if has_songs_books: + for entry in conn.execute(SONG_BOOKS, (song['id'],)): + existing_book = self.manager.get_object_filtered(SongBook, SongBook.name == entry['name']) if not existing_book: - existing_book = Book.populate(name=entry.songbook.name, publisher=entry.songbook.publisher) - new_song.add_songbook_entry(existing_book, entry.entry) - elif hasattr(song, 'book') and song.book: - existing_book = self.manager.get_object_filtered(Book, Book.name == song.book.name) + existing_book = SongBook(name=entry['name'], publisher=entry['publisher']) + new_song.add_songbook_entry(existing_book, entry['entry']) + elif does_column_exist(conn, 'songs', 'book_id'): + book = conn.execute('SELECT name, publisher FROM song_books WHERE id = ?', (song['book_id'],)) + existing_book = self.manager.get_object_filtered(SongBook, SongBook.name == book['name']) if not existing_book: - existing_book = Book.populate(name=song.book.name, publisher=song.book.publisher) + existing_book = SongBook(name=book['name'], publisher=book['publisher']) # Get the song_number from "songs" table "song_number" field. (This is db structure from 2.2.1) # If there's a number, add it to the song, otherwise it will be "". - existing_number = song.song_number if hasattr(song, 'song_number') else '' - if existing_number: - new_song.add_songbook_entry(existing_book, existing_number) + if 'song_number' in song.keys(): + new_song.add_songbook_entry(existing_book, song['song_number']) else: new_song.add_songbook_entry(existing_book, '') # Find or create all the media files and add them to the new song object - if has_media_files and song.media_files: - for media_file in song.media_files: + if has_media_files: + for media_file in conn.execute(SONG_MEDIA, (song['id'],)): # Database now uses paths rather than strings for media files, and the key name has # changed appropriately. This catches any databases using the old key name. try: - media_path = media_file.file_path - except Exception: - media_path = Path(media_file.file_name) - existing_media_file = self.manager.get_object_filtered( - MediaFile, MediaFile.file_path == media_path) + media_path = media_file['file_path'] + except (IndexError, KeyError): + media_path = Path(media_file['file_name']) + existing_media_file = self.manager.get_object_filtered(MediaFile, + MediaFile.file_path == media_path) if existing_media_file: new_song.media_files.append(existing_media_file) else: - new_song.media_files.append(MediaFile.populate(file_path=media_path)) + if 'file_hash' in media_file.keys(): + file_hash = media_file['file_hash'] + else: + file_hash = sha256_file_hash(media_path) + new_song.media_files.append(MediaFile(file_path=media_path, file_hash=file_hash)) clean_song(self.manager, new_song) self.manager.save_object(new_song) if progress_dialog: @@ -293,5 +190,4 @@ class OpenLPSongImport(SongImport): self.import_wizard.increment_progress_bar(WizardStrings.ImportingType.format(source=new_song.title)) if self.stop_import_flag: break - self.source_session.close() - engine.dispose() + conn.close() diff --git a/openlp/plugins/songs/lib/importers/songimport.py b/openlp/plugins/songs/lib/importers/songimport.py index 59de77f27..bf7f523cc 100644 --- a/openlp/plugins/songs/lib/importers/songimport.py +++ b/openlp/plugins/songs/lib/importers/songimport.py @@ -32,7 +32,7 @@ from openlp.core.common.path import create_paths from openlp.core.common.registry import Registry from openlp.core.widgets.wizard import WizardStrings from openlp.plugins.songs.lib import VerseType, clean_song -from openlp.plugins.songs.lib.db import Author, Book, MediaFile, Song, Topic +from openlp.plugins.songs.lib.db import Author, SongBook, MediaFile, Song, Topic from openlp.plugins.songs.lib.openlyricsxml import SongXML from openlp.plugins.songs.lib.ui import SongStrings @@ -366,21 +366,21 @@ class SongImport(QtCore.QObject): for author_text, author_type in self.authors: author = self.manager.get_object_filtered(Author, Author.display_name == author_text) if not author: - author = Author.populate(display_name=author_text, - last_name=author_text.split(' ')[-1], - first_name=' '.join(author_text.split(' ')[:-1])) + author = Author(display_name=author_text, + last_name=author_text.split(' ')[-1], + first_name=' '.join(author_text.split(' ')[:-1])) song.add_author(author, author_type) if self.song_book_name: - song_book = self.manager.get_object_filtered(Book, Book.name == self.song_book_name) + song_book = self.manager.get_object_filtered(SongBook, SongBook.name == self.song_book_name) if song_book is None: - song_book = Book.populate(name=self.song_book_name, publisher=self.song_book_pub) + song_book = SongBook(name=self.song_book_name, publisher=self.song_book_pub) song.add_songbook_entry(song_book, song.song_number) for topic_text in self.topics: if not topic_text: continue topic = self.manager.get_object_filtered(Topic, Topic.name == topic_text) if topic is None: - topic = Topic.populate(name=topic_text) + topic = Topic(name=topic_text) song.topics.append(topic) song.temporary = temporary_flag # We need to save the song now, before adding the media files, so that @@ -394,7 +394,7 @@ class SongImport(QtCore.QObject): if not media_file: if file_path.parent: file_path = self.copy_media_file(song.id, file_path) - song.media_files.append(MediaFile.populate(file_path=file_path, weight=weight)) + song.media_files.append(MediaFile(file_path=file_path, weight=weight)) self.manager.save_object(song) self.set_defaults() return song.id diff --git a/openlp/plugins/songs/lib/mediaitem.py b/openlp/plugins/songs/lib/mediaitem.py index 698db3231..a179b5231 100644 --- a/openlp/plugins/songs/lib/mediaitem.py +++ b/openlp/plugins/songs/lib/mediaitem.py @@ -44,7 +44,7 @@ from openlp.plugins.songs.forms.songexportform import SongExportForm from openlp.plugins.songs.forms.songimportform import SongImportForm from openlp.plugins.songs.forms.songmaintenanceform import SongMaintenanceForm from openlp.plugins.songs.lib import VerseType, clean_string, delete_song -from openlp.plugins.songs.lib.db import Author, AuthorType, Book, MediaFile, Song, SongBookEntry, Topic +from openlp.plugins.songs.lib.db import Author, AuthorType, SongBook, MediaFile, Song, SongBookEntry, Topic from openlp.plugins.songs.lib.openlyricsxml import OpenLyrics, SongXML from openlp.plugins.songs.lib.ui import SongStrings @@ -84,7 +84,7 @@ class SongMediaItem(MediaManagerItem): AppLocation.get_section_data_path(self.plugin.name) / 'audio' / str(song.id) / os.path.split(bga[0])[1] create_paths(dest_path.parent) copyfile(AppLocation.get_section_data_path('servicemanager') / bga[0], dest_path) - song.media_files.append(MediaFile.populate(weight=i, file_path=dest_path, file_hash=bga[1])) + song.media_files.append(MediaFile(weight=i, file_path=dest_path, file_hash=bga[1])) self.plugin.manager.save_object(song, True) def add_middle_header_bar(self): @@ -194,10 +194,10 @@ class SongMediaItem(MediaManagerItem): search_keywords = search_keywords.rpartition(' ') search_book = '{text}%'.format(text=search_keywords[0]) search_entry = '{text}%'.format(text=search_keywords[2]) - search_results = (self.plugin.manager.session.query(SongBookEntry.entry, Book.name, Song.title, Song.id) + search_results = (self.plugin.manager.session.query(SongBookEntry.entry, SongBook.name, Song.title, Song.id) .join(Song) - .join(Book) - .filter(Book.name.like(search_book), SongBookEntry.entry.like(search_entry), + .join(SongBook) + .filter(SongBook.name.like(search_book), SongBookEntry.entry.like(search_entry), Song.temporary.is_(False)).all()) self.display_results_book(search_results) elif search_type == SongSearch.Themes: @@ -223,8 +223,8 @@ class SongMediaItem(MediaManagerItem): search_string = '%{text}%'.format(text=clean_string(search_keywords)) return self.plugin.manager.session.query(Song) \ .join(SongBookEntry, isouter=True) \ - .join(Book, isouter=True) \ - .filter(or_(Book.name.like(search_string), SongBookEntry.entry.like(search_string), + .join(SongBook, isouter=True) \ + .filter(or_(SongBook.name.like(search_string), SongBookEntry.entry.like(search_string), # hint: search_title contains alternate title Song.search_title.like(search_string), Song.search_lyrics.like(search_string), Song.comments.like(search_string))) \ diff --git a/openlp/plugins/songs/lib/openlyricsxml.py b/openlp/plugins/songs/lib/openlyricsxml.py index cc91fceaf..3ea1d3992 100644 --- a/openlp/plugins/songs/lib/openlyricsxml.py +++ b/openlp/plugins/songs/lib/openlyricsxml.py @@ -65,7 +65,7 @@ from openlp.core.common.registry import Registry from openlp.core.lib.formattingtags import FormattingTags from openlp.core.version import get_version from openlp.plugins.songs.lib import VerseType, clean_song -from openlp.plugins.songs.lib.db import Author, AuthorType, Book, Song, Topic +from openlp.plugins.songs.lib.db import Author, AuthorType, SongBook, Song, Topic log = logging.getLogger(__name__) @@ -536,9 +536,9 @@ class OpenLyrics(object): author = self.manager.get_object_filtered(Author, Author.display_name == display_name) if author is None: # We need to create a new author, as the author does not exist. - author = Author.populate(display_name=display_name, - last_name=display_name.split(' ')[-1], - first_name=' '.join(display_name.split(' ')[:-1])) + author = Author(display_name=display_name, + last_name=display_name.split(' ')[-1], + first_name=' '.join(display_name.split(' ')[:-1])) song.add_author(author, author_type) def _process_cclinumber(self, properties, song): @@ -784,10 +784,10 @@ class OpenLyrics(object): for songbook in properties.songbooks.songbook: book_name = songbook.get('name', '') if book_name: - book = self.manager.get_object_filtered(Book, Book.name == book_name) + book = self.manager.get_object_filtered(SongBook, SongBook.name == book_name) if book is None: # We need to create a book, because it does not exist. - book = Book.populate(name=book_name, publisher='') + book = SongBook(name=book_name, publisher='') self.manager.save_object(book) song.add_songbook_entry(book, songbook.get('entry', '')) @@ -819,7 +819,7 @@ class OpenLyrics(object): topic = self.manager.get_object_filtered(Topic, Topic.name == topic_text) if topic is None: # We need to create a topic, because it does not exist. - topic = Topic.populate(name=topic_text) + topic = Topic(name=topic_text) self.manager.save_object(topic) song.topics.append(topic) diff --git a/openlp/plugins/songs/lib/songselect.py b/openlp/plugins/songs/lib/songselect.py index fc92e7272..0a7b80cbb 100644 --- a/openlp/plugins/songs/lib/songselect.py +++ b/openlp/plugins/songs/lib/songselect.py @@ -233,7 +233,7 @@ class SongSelectImport(object): :param song: Dictionary of the song to save :return: """ - db_song = Song.populate(title=song['title'], copyright=song['copyright'], ccli_number=song['ccli_number']) + db_song = Song(title=song['title'], copyright=song['copyright'], ccli_number=song['ccli_number']) song_xml = SongXML() verse_order = [] for verse in song['verses']: @@ -265,12 +265,12 @@ class SongSelectImport(object): last_name = '' else: last_name = name_parts[1] - author = Author.populate(first_name=first_name, last_name=last_name, display_name=author_name) + author = Author(first_name=first_name, last_name=last_name, display_name=author_name) db_song.add_author(author) for topic_name in song.get('topics', []): topic = self.db_manager.get_object_filtered(Topic, Topic.name == topic_name) if not topic: - topic = Topic.populate(name=topic_name) + topic = Topic(name=topic_name) db_song.topics.append(topic) self.db_manager.save_object(db_song) return db_song diff --git a/setup.cfg b/setup.cfg index f1afb1348..c24465853 100644 --- a/setup.cfg +++ b/setup.cfg @@ -33,3 +33,7 @@ source = [tool:pytest] qt_api=pyqt5 + +[mypy] +# Ignore for now, we'll come back to this later +ignore_errors = true diff --git a/tests/openlp_plugins/songs/forms/test_songmaintenanceform.py b/tests/openlp_plugins/songs/forms/test_songmaintenanceform.py index cd7b82a41..ed58bcc79 100644 --- a/tests/openlp_plugins/songs/forms/test_songmaintenanceform.py +++ b/tests/openlp_plugins/songs/forms/test_songmaintenanceform.py @@ -31,7 +31,7 @@ from PyQt5 import QtCore, QtWidgets from openlp.core.common.i18n import UiStrings from openlp.core.common.registry import Registry from openlp.core.lib.db import Manager -from openlp.plugins.songs.lib.db import init_schema, Book, Song, SongBookEntry +from openlp.plugins.songs.lib.db import init_schema, SongBook, Song, SongBookEntry from openlp.plugins.songs.forms.songmaintenanceform import SongMaintenanceForm from sqlalchemy.sql import and_ @@ -220,7 +220,7 @@ def test_delete_book_assigned(mocked_critical_error_message_box, form_env): # GIVEN: Some mocked items form = form_env[0] mocked_manager = form_env[1] - mocked_item = create_autospec(Book, spec_set=True) + mocked_item = create_autospec(SongBook, spec_set=True) mocked_item.id = 1 mocked_manager.get_object.return_value = mocked_item mocked_critical_error_message_box.return_value = QtWidgets.QMessageBox.Yes @@ -315,7 +315,7 @@ def test_reset_topics(MockedTopic, MockedQListWidgetItem, form_env): @patch('openlp.plugins.songs.forms.songmaintenanceform.QtWidgets.QListWidgetItem') -@patch('openlp.plugins.songs.forms.songmaintenanceform.Book') +@patch('openlp.plugins.songs.forms.songmaintenanceform.SongBook') def test_reset_song_books(MockedBook, MockedQListWidgetItem, form_env): """ Test the reset_song_books() method @@ -402,7 +402,7 @@ def test_check_topic_exists(MockedTopic, form_env): @patch('openlp.plugins.songs.forms.songmaintenanceform.and_') -@patch('openlp.plugins.songs.forms.songmaintenanceform.Book') +@patch('openlp.plugins.songs.forms.songmaintenanceform.SongBook') def test_check_song_book_exists(MockedBook, mocked_and, form_env): """ Test the check_song_book_exists() method @@ -498,11 +498,11 @@ def test_merge_song_books(registry, settings, temp_folder): manager = Manager('songs', init_schema, db_file_path=db_tmp_path) # create 2 song books, both with the same name - book1 = Book() + book1 = SongBook() book1.name = 'test book1' book1.publisher = '' manager.save_object(book1) - book2 = Book() + book2 = SongBook() book2.name = 'test book1' book2.publisher = '' manager.save_object(book2) @@ -550,7 +550,7 @@ def test_merge_song_books(registry, settings, temp_folder): SongBookEntry.song_id == song2.id)) song3_book2_entry = manager.get_all_objects(SongBookEntry, and_(SongBookEntry.songbook_id == book2.id, SongBookEntry.song_id == song3.id)) - books = manager.get_all_objects(Book, Book.name == 'test book1') + books = manager.get_all_objects(SongBook, SongBook.name == 'test book1') # song records should not be deleted assert len(songs) == 3 diff --git a/tests/openlp_plugins/songs/test_db.py b/tests/openlp_plugins/songs/test_db.py index 34827dd8b..02939e848 100644 --- a/tests/openlp_plugins/songs/test_db.py +++ b/tests/openlp_plugins/songs/test_db.py @@ -26,7 +26,7 @@ import shutil from openlp.core.lib.db import upgrade_db from openlp.plugins.songs.lib import upgrade -from openlp.plugins.songs.lib.db import Author, AuthorType, Book, Song +from openlp.plugins.songs.lib.db import Author, AuthorType, SongBook, Song from tests.utils.constants import TEST_RESOURCES_PATH @@ -174,7 +174,7 @@ def test_add_songbooks(): # GIVEN: A mocked song and songbook song = Song() song.songbook_entries = [] - songbook = Book() + songbook = SongBook() songbook.name = "Thy Word" # WHEN: We add two songbooks to a Song diff --git a/tests/openlp_plugins/songs/test_mediaitem.py b/tests/openlp_plugins/songs/test_mediaitem.py index d582ecfc2..90bce0b89 100644 --- a/tests/openlp_plugins/songs/test_mediaitem.py +++ b/tests/openlp_plugins/songs/test_mediaitem.py @@ -548,7 +548,7 @@ def test_build_remote_search(media_item): assert search_results == [[123, 'My Song', 'My alternative']] -@patch('openlp.plugins.songs.lib.mediaitem.Book') +@patch('openlp.plugins.songs.lib.mediaitem.SongBook') @patch('openlp.plugins.songs.lib.mediaitem.SongBookEntry') @patch('openlp.plugins.songs.lib.mediaitem.Song') @patch('openlp.plugins.songs.lib.mediaitem.or_') diff --git a/tests/openlp_plugins/songs/test_openlpimporter.py b/tests/openlp_plugins/songs/test_openlpimporter.py index 0cf6b3399..f32c71dbf 100644 --- a/tests/openlp_plugins/songs/test_openlpimporter.py +++ b/tests/openlp_plugins/songs/test_openlpimporter.py @@ -21,54 +21,108 @@ """ This module contains tests for the OpenLP song importer. """ +import json from pathlib import Path -from unittest import TestCase -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, patch, call + +import pytest -from openlp.core.common.registry import Registry from openlp.plugins.songs.lib.importers.openlp import OpenLPSongImport +# from tests.helpers.songfileimport import SongImportTestHelper +from tests.utils.constants import RESOURCE_PATH -class TestOpenLPImport(TestCase): +TEST_PATH = RESOURCE_PATH / 'songs' / 'openlp' + + +def test_create_importer(registry): """ - Test the functions in the :mod:`openlp` importer module. + Test creating an instance of the OpenLP database importer """ - def setUp(self): - """ - Create the registry - """ - Registry.create() + # GIVEN: A mocked out SongImport class, and a mocked out "manager" + with patch('openlp.plugins.songs.lib.importers.openlp.SongImport'): + mocked_manager = MagicMock() - def test_create_importer(self): - """ - Test creating an instance of the OpenLP database importer - """ - # GIVEN: A mocked out SongImport class, and a mocked out "manager" - with patch('openlp.plugins.songs.lib.importers.openlp.SongImport'): - mocked_manager = MagicMock() + # WHEN: An importer object is created + importer = OpenLPSongImport(mocked_manager, file_paths=[]) - # WHEN: An importer object is created - importer = OpenLPSongImport(mocked_manager, file_paths=[]) + # THEN: The importer object should not be None + assert importer is not None, 'Import should not be none' - # THEN: The importer object should not be None - assert importer is not None, 'Import should not be none' - def test_invalid_import_source(self): - """ - Test OpenLPSongImport.do_import handles different invalid import_source values - """ - # GIVEN: A mocked out SongImport class, and a mocked out "manager" - with patch('openlp.plugins.songs.lib.importers.openlp.SongImport'): - mocked_manager = MagicMock() - mocked_import_wizard = MagicMock() - importer = OpenLPSongImport(mocked_manager, file_paths=[]) - importer.import_wizard = mocked_import_wizard - importer.stop_import_flag = True +def test_invalid_import_source(registry): + """ + Test OpenLPSongImport.do_import handles different invalid import_source values + """ + # GIVEN: A mocked out SongImport class, and a mocked out "manager" + with patch('openlp.plugins.songs.lib.importers.openlp.SongImport'): + mocked_manager = MagicMock() + mocked_import_wizard = MagicMock() + importer = OpenLPSongImport(mocked_manager, file_paths=[]) + importer.import_wizard = mocked_import_wizard + importer.stop_import_flag = True - # WHEN: Import source is not a list - importer.import_source = Path() + # WHEN: Import source is not a list + importer.import_source = Path() - # THEN: do_import should return none and the progress bar maximum should not be set. - assert importer.do_import() is None, 'do_import should return None when import_source is not a list' - assert mocked_import_wizard.progress_bar.setMaximum.called is False, \ - 'setMaximum on import_wizard.progress_bar should not have been called' + # THEN: do_import should return none and the progress bar maximum should not be set. + assert importer.do_import() is None, 'do_import should return None when import_source is not a list' + assert mocked_import_wizard.progress_bar.setMaximum.called is False, \ + 'setMaximum on import_wizard.progress_bar should not have been called' + + +@pytest.mark.parametrize('base_name', ['songs-1.9.7', 'songs-2.4.6']) +@patch('openlp.plugins.songs.lib.importers.openlp.Song') +@patch('openlp.plugins.songs.lib.importers.openlp.Author') +@patch('openlp.plugins.songs.lib.importers.openlp.Topic') +@patch('openlp.plugins.songs.lib.importers.openlp.SongBook') +@patch('openlp.plugins.songs.lib.importers.openlp.MediaFile') +def test_openlp_db_import(MockMediaFile, MockSongBook, MockTopic, MockAuthor, MockSong, mock_settings, base_name: str): + """Test that OpenLP is able to import an older OpenLP database""" + # GIVEN: An OpenLP importer and a bunch of mocks + mocked_progress_dialog = MagicMock() + mocked_author = MagicMock() + MockAuthor.return_value = mocked_author + mocked_song = MagicMock() + MockSong.return_value = mocked_song + mocked_topic_salvation = MagicMock() + mocked_topic_grace = MagicMock() + MockTopic.side_effect = [mocked_topic_grace, mocked_topic_salvation] + mocked_songbook = MagicMock() + MockSongBook.return_value = mocked_songbook + mocked_media_file = MagicMock() + MockMediaFile.return_value = mocked_media_file + mocked_manager = MagicMock() + mocked_manager.get_object_filtered.return_value = None + importer = OpenLPSongImport(mocked_manager, file_path=TEST_PATH / f'{base_name}.sqlite') + importer.import_wizard = MagicMock() + + # WHEN: The database is imported + importer.do_import(mocked_progress_dialog) + + # THEN: The correct songs should ahve been imported + expected_song = json.load((TEST_PATH / f'{base_name}.json').open()) + importer.import_wizard.progress_bar.setMaximum.assert_called_once_with(1) + assert mocked_song.title == expected_song['title'] + for author in expected_song['authors']: + MockAuthor.assert_called_with(first_name=author['first_name'], last_name=author['last_name'], + display_name=author['display_name']) + mocked_song.add_author.assert_called_with(mocked_author, author.get('type', '')) + if 'verse_order' in expected_song: + assert mocked_song.verse_order == expected_song['verse_order'] + if 'copyright' in expected_song: + assert mocked_song.copyright == expected_song['copyright'] + if 'theme_name' in expected_song: + assert mocked_song.theme_name == expected_song['theme_name'] + for topic in expected_song.get('topics', []): + assert call(name=topic) in MockTopic.call_args_list + if topic == 'Grace': + assert call(mocked_topic_grace) in mocked_song.topics.append.call_args_list + elif topic == 'Salvation': + assert call(mocked_topic_salvation) in mocked_song.topics.append.call_args_list + for songbook_entry in expected_song.get('songbooks', []): + MockSongBook.assert_called_with(name=songbook_entry['songbook'], publisher=None) + assert call(mocked_songbook, songbook_entry.get('entry', '')) in mocked_song.add_songbook_entry.call_args_list + for media_file in expected_song.get('media_files', []): + MockMediaFile.assert_called_once_with(file_path=Path(media_file["file_name"]), file_hash=None) + assert call(mocked_media_file) in mocked_song.media_files.append.call_args_list diff --git a/tests/openlp_plugins/songs/test_openlyricsxml.py b/tests/openlp_plugins/songs/test_openlyricsxml.py index e5dac2c7e..a95cae00e 100644 --- a/tests/openlp_plugins/songs/test_openlyricsxml.py +++ b/tests/openlp_plugins/songs/test_openlyricsxml.py @@ -166,7 +166,7 @@ def test_process_songbooks(registry, settings): Test that _process_songbooks works """ # GIVEN: A OpenLyric XML with songbooks and a mocked out manager - with patch('openlp.plugins.songs.lib.openlyricsxml.Book'): + with patch('openlp.plugins.songs.lib.openlyricsxml.SongBook'): mocked_manager = MagicMock() mocked_manager.get_object_filtered.return_value = None ol = OpenLyrics(mocked_manager) diff --git a/tests/openlp_plugins/songs/test_songselect.py b/tests/openlp_plugins/songs/test_songselect.py index d597d0e99..8b7dc66e5 100644 --- a/tests/openlp_plugins/songs/test_songselect.py +++ b/tests/openlp_plugins/songs/test_songselect.py @@ -435,7 +435,7 @@ class TestSongSelectImport(TestCase, TestMixin): assert 2 == mocked_db_manager.save_object.call_count, \ 'The save_object() method should have been called twice' mocked_db_manager.get_object_filtered.assert_called_with(MockedAuthor, False) - MockedAuthor.populate.assert_called_with(first_name='Public', last_name='Domain', display_name='Public Domain') + MockedAuthor.assert_called_with(first_name='Public', last_name='Domain', display_name='Public Domain') assert 1 == len(result.authors_songs), 'There should only be one author' @patch('openlp.plugins.songs.lib.songselect.clean_song') @@ -470,7 +470,7 @@ class TestSongSelectImport(TestCase, TestMixin): assert 2 == mocked_db_manager.save_object.call_count, \ 'The save_object() method should have been called twice' mocked_db_manager.get_object_filtered.assert_called_with(MockedAuthor, False) - assert 0 == MockedAuthor.populate.call_count, 'A new author should not have been instantiated' + assert 0 == MockedAuthor.call_count, 'A new author should not have been instantiated' assert 1 == len(result.authors_songs), 'There should only be one author' @patch('openlp.plugins.songs.lib.songselect.clean_song') @@ -505,7 +505,7 @@ class TestSongSelectImport(TestCase, TestMixin): assert 2 == mocked_db_manager.save_object.call_count, \ 'The save_object() method should have been called twice' mocked_db_manager.get_object_filtered.assert_called_with(MockedAuthor, False) - MockedAuthor.populate.assert_called_with(first_name='Unknown', last_name='', display_name='Unknown') + MockedAuthor.assert_called_with(first_name='Unknown', last_name='', display_name='Unknown') assert 1 == len(result.authors_songs), 'There should only be one author' @patch('openlp.plugins.songs.lib.songselect.clean_song') @@ -545,8 +545,8 @@ class TestSongSelectImport(TestCase, TestMixin): # THEN: The return value should be a Song class and the topics should have been added assert isinstance(result, Song), 'The returned value should be a Song object' mocked_clean_song.assert_called_with(mocked_db_manager, result) - assert MockedTopic.populate.call_count == 2, 'Should have created 2 new topics' - MockedTopic.populate.assert_called_with(name='Flood') + assert MockedTopic.call_count == 2, 'Should have created 2 new topics' + MockedTopic.assert_called_with(name='Flood') assert 1 == len(result.authors_songs), 'There should only be one author' diff --git a/tests/resources/songs/openlp/songs-1.9.7.json b/tests/resources/songs/openlp/songs-1.9.7.json new file mode 100644 index 000000000..37d1357fa --- /dev/null +++ b/tests/resources/songs/openlp/songs-1.9.7.json @@ -0,0 +1,36 @@ +{ + "title": "Amazing Grace", + "authors": [ + { + "first_name": "John", + "last_name": "Newton", + "display_name": "John Newton" + } + ], + "verses": [ + [ + "Amazing grace! How sweet the sound\nThat saved a wretch like me;\nI once was lost, but now am found,\nWas blind, but now I see.", + "v1" + ], + [ + "'Twas grace that taught my heart to fear,\nAnd grace my fears relieved;\nHow precious did that grace appear,\nThe hour I first believed!", + "v2" + ], + [ + "Through many dangers, toils and snares\nI have already come;\n'Tis grace that brought me safe thus far,\nAnd grace will lead me home.", + "v3" + ], + [ + "The Lord has promised good to me,\nHis word my hope secures;\nHe will my shield and portion be\nAs long as life endures.", + "v4" + ], + [ + "Yes, when this heart and flesh shall fail,\nAnd mortal life shall cease,\nI shall possess within the veil\nA life of joy and peace.", + "v5" + ], + [ + "When we've been there a thousand years,\nBright shining as the sun,\nWe've no less days to sing God's praise\nThan when we first begun.", + "v6" + ] + ] +} diff --git a/tests/resources/songs/openlp/songs-1.9.7.sqlite b/tests/resources/songs/openlp/songs-1.9.7.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..98505464bcec08c1a4cc6921e03d6f9ae1ff4f15 GIT binary patch literal 35840 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