mirror of https://gitlab.com/openlp/openlp.git
Merge branch 'remote-sync' into 'master'
WIP: Add new remote-sync plugin See merge request openlp/openlp!9
This commit is contained in:
commit
90e417684a
|
@ -149,6 +149,25 @@ class SongSearch(IntEnum):
|
|||
|
||||
|
||||
@unique
|
||||
class SyncType(IntEnum):
|
||||
"""
|
||||
The synchronization types available
|
||||
"""
|
||||
Disabled = 0
|
||||
Folder = 1
|
||||
Ftp = 2
|
||||
WebService = 3
|
||||
|
||||
|
||||
@unique
|
||||
class FtpType(IntEnum):
|
||||
"""
|
||||
The supported FTP types
|
||||
"""
|
||||
Ftp = 1
|
||||
FtpTls = 2
|
||||
|
||||
|
||||
class SongFirstSlideMode(IntEnum):
|
||||
"""
|
||||
An enumeration for song first slide types.
|
||||
|
|
|
@ -33,7 +33,7 @@ from PyQt5 import QtCore, QtGui
|
|||
|
||||
from openlp.core.common import SlideLimits, ThemeLevel
|
||||
from openlp.core.common.enum import AlertLocation, BibleSearch, CustomSearch, HiDPIMode, ImageThemeMode, LayoutStyle, \
|
||||
DisplayStyle, LanguageSelection, SongFirstSlideMode, SongSearch, PluginStatus
|
||||
DisplayStyle, LanguageSelection, SongFirstSlideMode, SongSearch, PluginStatus, SyncType, FtpType
|
||||
from openlp.core.common.json import OpenLPJSONDecoder, OpenLPJSONEncoder, is_serializable
|
||||
from openlp.core.common.path import files_to_paths, str_to_path
|
||||
from openlp.core.common.platform import is_linux, is_win
|
||||
|
@ -410,7 +410,21 @@ class Settings(QtCore.QSettings):
|
|||
'projector/poll time': 20, # PJLink timeout is 30 seconds
|
||||
'projector/socket timeout': 5, # 5 second socket timeout
|
||||
'projector/source dialog type': 0, # Source select dialog box type
|
||||
'projector/udp broadcast listen': False # Enable/disable listening for PJLink 2 UDP broadcast packets
|
||||
'projector/udp broadcast listen': False, # Enable/disable listening for PJLink 2 UDP broadcast packets
|
||||
'remotesync/status': PluginStatus.Active,
|
||||
'remotesync/db type': 'sqlite',
|
||||
'remotesync/db username': '',
|
||||
'remotesync/db password': '',
|
||||
'remotesync/db hostname': '',
|
||||
'remotesync/db database': '',
|
||||
'remotesync/type': SyncType.Disabled,
|
||||
'remotesync/folder path': '',
|
||||
'remotesync/folder pc id': None,
|
||||
'remotesync/ftp server': '',
|
||||
'remotesync/ftp type': FtpType.Ftp,
|
||||
'remotesync/ftp username': '',
|
||||
'remotesync/ftp password': '',
|
||||
'remotesync/ftp data folder': '',
|
||||
}
|
||||
__file_path__ = ''
|
||||
# Settings upgrades prior to 3.0
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
|
||||
|
||||
##########################################################################
|
||||
# OpenLP - Open Source Lyrics Projection #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Copyright (c) 2008-2024 OpenLP Developers #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
##########################################################################
|
||||
"""
|
||||
The :mod:`remotesync` module contains the Remote Sync plugin. The remotesync plugin provides the ability to synchronize
|
||||
songs, custom slides and service-files between multiple OpenLP instances.
|
||||
"""
|
|
@ -0,0 +1,25 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
|
||||
|
||||
##########################################################################
|
||||
# OpenLP - Open Source Lyrics Projection #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Copyright (c) 2008-2024 OpenLP Developers #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
##########################################################################
|
||||
|
||||
from .remotesynctab import RemoteSyncTab
|
||||
|
||||
__all__ = ['RemoteSyncTab']
|
|
@ -0,0 +1,21 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
|
||||
|
||||
##########################################################################
|
||||
# OpenLP - Open Source Lyrics Projection #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Copyright (c) 2008-2024 OpenLP Developers #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
##########################################################################
|
|
@ -0,0 +1,522 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
|
||||
|
||||
##########################################################################
|
||||
# OpenLP - Open Source Lyrics Projection #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Copyright (c) 2008-2024 OpenLP Developers #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
##########################################################################
|
||||
import datetime
|
||||
import shutil
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from openlp.core.common.registry import Registry
|
||||
from openlp.plugins.custom.lib.customxmlhandler import CustomXML
|
||||
from openlp.plugins.custom.lib.db import CustomSlide
|
||||
from openlp.plugins.songs.lib import delete_song as delete_in_song_plugin
|
||||
from openlp.plugins.remotesync.lib.backends.synchronizer import Synchronizer, SyncItemType, ConflictException, \
|
||||
LockException, ConflictReason
|
||||
from openlp.plugins.remotesync.lib.db import RemoteSyncItem
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FolderSynchronizer(Synchronizer):
|
||||
"""
|
||||
The FolderSynchronizer uses xml-files in a simple holder structure for synchronizing data.
|
||||
The folder-structure looks like this:
|
||||
<base-folder>
|
||||
+---songs
|
||||
| +---history
|
||||
| +---deleted
|
||||
+---customs
|
||||
| +---history
|
||||
| +---deleted
|
||||
+---services
|
||||
The files are named after the uuid generated for each song or custom slide, the id of the
|
||||
OpenLP instance and the version of the song, like this: {uuid}={version}={computer_id}.xml
|
||||
An example could be: bd5bc6c2-4fd2-4a42-925f-48d00de835ec=4=churchpc1.xml
|
||||
When a file is updated a lock file is created to signal that the song is locked. The filename
|
||||
of the lock file is: {uuid}.lock={computer_id}={version}
|
||||
As part of the updating of the file, the old version is moved to the appropriate history folder.
|
||||
When a song/custom slide is deleted, its file is moved to the history-folder, and an empty file
|
||||
named as the items uuid is placed in the deleted-folder.
|
||||
"""
|
||||
|
||||
def __init__(self, manager, base_folder_path, pc_id):
|
||||
"""
|
||||
Initilize the synchronizer
|
||||
:param manager:
|
||||
:type Manager:
|
||||
:param base_folder_path:
|
||||
:type str:
|
||||
:param pc_id:
|
||||
:type str:
|
||||
"""
|
||||
super(FolderSynchronizer, self).__init__(manager)
|
||||
self.base_folder_path = base_folder_path
|
||||
self.pc_id = pc_id
|
||||
self.song_folder_path = self.base_folder_path / 'songs'
|
||||
self.song_history_folder_path = self.song_folder_path / 'history'
|
||||
self.song_deleted_folder_path = self.song_folder_path / 'deleted'
|
||||
self.custom_folder_path = self.base_folder_path / 'customs'
|
||||
self.custom_history_folder_path = self.custom_folder_path / 'history'
|
||||
self.custom_deleted_folder_path = self.custom_folder_path / 'deleted'
|
||||
self.service_folder_path = self.base_folder_path / 'services'
|
||||
|
||||
def check_configuration(self):
|
||||
return True
|
||||
|
||||
def check_connection(self):
|
||||
return self.base_folder_path.exists() and self.song_history_folder_path.exists() and \
|
||||
self.song_deleted_folder_path.exists() and self.custom_folder_path.exists() and \
|
||||
self.custom_history_folder_path.exists() and self.custom_deleted_folder_path.exists() and \
|
||||
self.service_folder_path.exists()
|
||||
|
||||
def initialize_remote(self):
|
||||
self.song_history_folder_path.mkdir(parents=True, exist_ok=True)
|
||||
self.song_deleted_folder_path.mkdir(parents=True, exist_ok=True)
|
||||
self.custom_history_folder_path.mkdir(parents=True, exist_ok=True)
|
||||
self.custom_deleted_folder_path.mkdir(parents=True, exist_ok=True)
|
||||
self.service_folder_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_file_list(self, path, mask):
|
||||
return list(path.glob(mask))
|
||||
|
||||
def _remove_lock_file(self, lock_filename):
|
||||
Path(lock_filename).unlink()
|
||||
|
||||
def _move_file(self, src, dst):
|
||||
shutil.move(src, dst)
|
||||
|
||||
def _create_file(self, filename, file_content):
|
||||
out_file = open(filename, 'wt')
|
||||
out_file.write(file_content)
|
||||
out_file.close()
|
||||
|
||||
def _read_file(self, filename):
|
||||
in_file = open(filename, 'rt')
|
||||
content = in_file.read()
|
||||
in_file.close()
|
||||
return content
|
||||
|
||||
def _check_for_lock_file(self, type, path, uuid, first_sync_attempt, prev_lock_id):
|
||||
"""
|
||||
Check for lock file. Raises exception if a valid lock file is found. If an expired lock file is found
|
||||
it is deleted.
|
||||
:param type:
|
||||
:type str:
|
||||
:param path:
|
||||
:type str:
|
||||
:param uuid:
|
||||
:type str:
|
||||
:param first_sync_attempt:
|
||||
:type datetime:
|
||||
:param prev_lock_id:
|
||||
:type str:
|
||||
"""
|
||||
existing_lock_file = self._get_file_list(path, uuid + '.lock*')
|
||||
if existing_lock_file:
|
||||
log.debug('Found a lock file!')
|
||||
current_lock_id = str(existing_lock_file[0]).split('.lock=')[-1]
|
||||
if first_sync_attempt:
|
||||
# Have we seen this lock before?
|
||||
if current_lock_id == prev_lock_id:
|
||||
# If the lock is more than 60 seconds old it is deleted
|
||||
delta = datetime.datetime.now() - first_sync_attempt
|
||||
if delta.total_seconds() > 60:
|
||||
# Remove expired lock
|
||||
self._remove_lock_file(existing_lock_file[0])
|
||||
else:
|
||||
# Lock is still valid, keep waiting
|
||||
raise LockException(type, uuid, current_lock_id, first_sync_attempt)
|
||||
else:
|
||||
# New lock encountered, now we have to wait - again
|
||||
raise LockException(type, uuid, current_lock_id, datetime.datetime.now())
|
||||
else:
|
||||
# New lock encountered, now we have to wait for it
|
||||
raise LockException(type, uuid, current_lock_id, datetime.datetime.now())
|
||||
|
||||
def get_remote_song_changes(self):
|
||||
"""
|
||||
Check for changes in the remote/shared folder. If a changed/new item is found it is fetched using the
|
||||
fetch_song method, and if a conflict is detected the mark_item_for_conflict is used. If items has been deleted
|
||||
remotely, they are also deleted locally.
|
||||
:return: True if one or more songs was updated, otherwise False
|
||||
"""
|
||||
updated = self._get_remote_changes(SyncItemType.Song)
|
||||
return updated
|
||||
|
||||
def send_song(self, song, song_uuid, last_known_version, first_sync_attempt, prev_lock_id):
|
||||
"""
|
||||
Sends a song to the shared folder.
|
||||
:param song: The song object to synchronize
|
||||
:param song_uuid: The uuid of the song
|
||||
:param last_known_version: The last known version of the song
|
||||
:param first_sync_attempt: If the song has been attempted synchronized before,
|
||||
this is the timestamp of the first sync attempt.
|
||||
:param prev_lock_id: If the song has been attempted synchronized before, this is the id of the lock that
|
||||
prevented the synchronization.
|
||||
:return: The new version.
|
||||
"""
|
||||
if last_known_version:
|
||||
counter = int(last_known_version.split('=')[0]) + 1
|
||||
else:
|
||||
counter = 0
|
||||
version = '{counter}={computer_id}'.format(counter=counter, computer_id=self.pc_id)
|
||||
content = self.open_lyrics.song_to_xml(song, version)
|
||||
self._send_item(SyncItemType.Song, content, song_uuid, version, last_known_version, first_sync_attempt,
|
||||
prev_lock_id)
|
||||
return version
|
||||
|
||||
def fetch_song(self, song_uuid, song_id):
|
||||
"""
|
||||
Fetch a specific song from the shared folder and stores it in the song db
|
||||
:param song_uuid: uuid of the song
|
||||
:param song_id: song db id, None if song does not yet exists in the song db
|
||||
:return: The song object
|
||||
"""
|
||||
version, item_content = self._fetch_item(SyncItemType.Song, song_uuid)
|
||||
if not version:
|
||||
return None
|
||||
# this also stores the song in the song database
|
||||
song = self.open_lyrics.xml_to_song(item_content, update_song_id=song_id)
|
||||
sync_item = self.manager.get_object_filtered(RemoteSyncItem, RemoteSyncItem.uuid == song_uuid)
|
||||
if not sync_item:
|
||||
sync_item = RemoteSyncItem()
|
||||
sync_item.type = SyncItemType.Song
|
||||
sync_item.item_id = song.id
|
||||
sync_item.uuid = song_uuid
|
||||
sync_item.version = version
|
||||
self.manager.save_object(sync_item, True)
|
||||
return song
|
||||
|
||||
def delete_song(self, song_uuid, first_del_attempt, prev_lock_id):
|
||||
"""
|
||||
Delete song from the remote location. Does the following:
|
||||
1. Check for an existing lock, raise LockException if one found.
|
||||
2. Create a lock file and move the existing file to the history folder.
|
||||
3. Place a file in the deleted folder, named after the song uuid.
|
||||
4. Delete lock file.
|
||||
:param song_uuid:
|
||||
:type str:
|
||||
:param first_del_attempt:
|
||||
:type DateTime:
|
||||
:param prev_lock_id:
|
||||
:type str:
|
||||
"""
|
||||
self._delete_item(SyncItemType.Song, song_uuid, first_del_attempt, prev_lock_id)
|
||||
|
||||
def get_remote_custom_changes(self):
|
||||
"""
|
||||
Check for changes in the remote/shared folder. If a changed/new item is found it is fetched using the
|
||||
fetch_custom method, and if a conflict is detected the mark_item_for_conflict is used. If items has been deleted
|
||||
remotely, they are also deleted locally.
|
||||
:return: True if one or more songs was updated, otherwise False
|
||||
"""
|
||||
updated = self._get_remote_changes(SyncItemType.Custom)
|
||||
return updated
|
||||
|
||||
def send_custom(self, custom, custom_uuid, last_known_version, first_sync_attempt, prev_lock_id):
|
||||
"""
|
||||
Sends a custom slide to the shared folder.
|
||||
:param custom: The custom object to synchronize
|
||||
:param custom_uuid: The uuid of the custom slide
|
||||
:param last_known_version: The last known version of the custom slide
|
||||
:param first_sync_attempt: If the custom slide has been attempted synchronized before,
|
||||
this is the timestamp of the first sync attempt.
|
||||
:param prev_lock_id: If the custom slide has been attempted synchronized before, this is the id of the lock
|
||||
that prevented the synchronization.
|
||||
:return: The new version.
|
||||
"""
|
||||
if last_known_version:
|
||||
counter = int(last_known_version.split('=')[0]) + 1
|
||||
else:
|
||||
counter = 0
|
||||
version = '{counter}={computer_id}'.format(counter=counter, computer_id=self.pc_id)
|
||||
custom_xml = CustomXML(custom.text)
|
||||
custom_xml.add_title_and_credit(custom.title, custom.credits)
|
||||
content = str(custom_xml.extract_xml(True), 'utf-8')
|
||||
self._send_item(SyncItemType.Custom, content, custom_uuid, version, last_known_version, first_sync_attempt,
|
||||
prev_lock_id)
|
||||
return version
|
||||
|
||||
def fetch_custom(self, custom_uuid, custom_id):
|
||||
"""
|
||||
Fetch a specific custom slide from the shared folder and stores it in the custom db
|
||||
:param custom_uuid: uuid of the custom slide
|
||||
:param custom_id: custom db id, None if the custom slide does not yet exists in the custom db
|
||||
:return: The custom object
|
||||
"""
|
||||
version, item_content = self._fetch_item(SyncItemType.Custom, custom_uuid)
|
||||
if not version:
|
||||
return None
|
||||
custom = CustomXML(item_content)
|
||||
# save the slide in the custom db
|
||||
custom_manager = Registry().get('custom_manager')
|
||||
log.debug('fetched custom item: %s' % (custom_id))
|
||||
if custom_id:
|
||||
custom_slide = custom_manager.get_object(CustomSlide, custom_id)
|
||||
else:
|
||||
custom_slide = CustomSlide()
|
||||
custom_slide.title = custom.get_title()
|
||||
custom_slide.text = str(custom.extract_xml(), 'utf-8')
|
||||
custom_slide.credits = custom.get_credit()
|
||||
# custom_slide.theme_name =
|
||||
custom_manager.save_object(custom_slide)
|
||||
# save to the sync map
|
||||
sync_item = self.manager.get_object_filtered(RemoteSyncItem, RemoteSyncItem.uuid == custom_uuid)
|
||||
if not sync_item:
|
||||
sync_item = RemoteSyncItem()
|
||||
sync_item.type = SyncItemType.Custom
|
||||
sync_item.item_id = custom_slide.id
|
||||
sync_item.uuid = custom_uuid
|
||||
sync_item.version = version
|
||||
self.manager.save_object(sync_item, True)
|
||||
return custom
|
||||
|
||||
def delete_custom(self, custom_uuid, first_del_attempt, prev_lock_id):
|
||||
"""
|
||||
Delete custom slide from the remote location. Does the following:
|
||||
1. Check for an existing lock, raise LockException if one found.
|
||||
2. Create a lock file and move the existing file to the history folder.
|
||||
3. Place a file in the deleted folder, named after the custom slide uuid.
|
||||
4. Delete lock file.
|
||||
:param custom_uuid:
|
||||
:type str:
|
||||
:param first_del_attempt:
|
||||
:type DateTime:
|
||||
:param prev_lock_id:
|
||||
:type str:
|
||||
"""
|
||||
self._delete_item(SyncItemType.Custom, custom_uuid, first_del_attempt, prev_lock_id)
|
||||
|
||||
def send_service(self, service):
|
||||
pass
|
||||
|
||||
def fetch_service(self):
|
||||
pass
|
||||
|
||||
def _get_remote_changes(self, item_type):
|
||||
"""
|
||||
Check for changes in the remote/shared folder. If a changed/new item is found it is fetched using the
|
||||
fetch_song method, and if a conflict is detected the mark_item_for_conflict is used. If items has been deleted
|
||||
remotely, they are also deleted locally.
|
||||
:return: True if one or more songs was updated or deleted, otherwise False
|
||||
"""
|
||||
updated = False
|
||||
# Check for updated or new files
|
||||
if item_type == SyncItemType.Song:
|
||||
folder_path = self.song_folder_path
|
||||
else:
|
||||
folder_path = self.custom_folder_path
|
||||
item_files = self._get_file_list(folder_path, '*.xml')
|
||||
conflicts = []
|
||||
for item_file in item_files:
|
||||
# skip conflicting files
|
||||
if item_file in conflicts:
|
||||
continue
|
||||
# Check if this item is already sync'ed
|
||||
filename = item_file.name
|
||||
filename_elements = filename.split('=', 1)
|
||||
uuid = filename_elements[0]
|
||||
file_version = filename_elements[1].replace('.xml', '')
|
||||
# Detect if there are multiple files for the same item, which would mean that we have a conflict
|
||||
files = []
|
||||
for item_file2 in item_files:
|
||||
if uuid in str(item_file2):
|
||||
files.append(item_file2)
|
||||
# if more than one item file has the same uuid, then we have a conflict
|
||||
if len(files) > 1:
|
||||
# Add conflicting files to the "blacklist"
|
||||
conflicts += files
|
||||
# Mark item as conflicted!
|
||||
self.mark_item_for_conflict(item_type, uuid, ConflictReason.MultipleRemoteEntries)
|
||||
existing_item = self.get_sync_item(uuid, item_type)
|
||||
item_id = existing_item.item_id if existing_item else None
|
||||
# If we do not have a local version or if the remote version is different, then we update
|
||||
if not existing_item or existing_item.version != file_version:
|
||||
if existing_item:
|
||||
log.debug('Local version (%s) and file version (%s) mismatch - updated triggered!' % (
|
||||
existing_item.version, file_version))
|
||||
log.debug('About to fetch item: %s %d' % (uuid, item_id))
|
||||
else:
|
||||
log.debug('About to fetch new item: %s' % (uuid))
|
||||
try:
|
||||
if item_type == SyncItemType.Song:
|
||||
self.fetch_song(uuid, item_id)
|
||||
else:
|
||||
self.fetch_custom(uuid, item_id)
|
||||
except ConflictException as ce:
|
||||
log.debug('Conflict detected while fetching item %d / %s!' % (item_id, uuid))
|
||||
self.mark_item_for_conflict(item_type, uuid, ce.reason)
|
||||
continue
|
||||
updated = True
|
||||
# Check for deleted files
|
||||
if item_type == SyncItemType.Song:
|
||||
deleted_folder_path = self.custom_deleted_folder_path
|
||||
else:
|
||||
deleted_folder_path = self.song_deleted_folder_path
|
||||
deleted_item_files = self._get_file_list(deleted_folder_path, '*')
|
||||
for deleted_item_file in deleted_item_files:
|
||||
# if the a deleted item exists in the local database it means it must be deleted locally
|
||||
sync_item = self.manager.get_object_filtered(RemoteSyncItem, RemoteSyncItem.uuid == deleted_item_file.name)
|
||||
if sync_item:
|
||||
updated = True
|
||||
# delete the item from its plugin db
|
||||
if item_type == SyncItemType.Song:
|
||||
delete_in_song_plugin(sync_item.item_id, False)
|
||||
else:
|
||||
custom_manager = Registry().get('custom_manager')
|
||||
custom_manager.delete_object(CustomSlide, sync_item.item_id)
|
||||
# remove the item from the remotesync db
|
||||
self.manager.delete_all_objects(RemoteSyncItem, RemoteSyncItem.item_id == sync_item.item_id)
|
||||
return updated
|
||||
|
||||
def _send_item(self, item_type, item_content, item_uuid, version, last_known_version, first_sync_attempt,
|
||||
prev_lock_id):
|
||||
"""
|
||||
Sends an item to the shared folder. Does the following:
|
||||
1. Check for an existing lock, raise LockException if one found.
|
||||
2. Check if the item already exists on remote. If so, check if the latest version is available locally, raise
|
||||
ConflictException if the remote version is not known locally. If the latest version is known, create a lock
|
||||
file and move the existing file to the history folder. If the item does not exists already, just create a
|
||||
lock file.
|
||||
3. Place file with item in folder.
|
||||
4. Delete lock file.
|
||||
:param item_type: The type of the item
|
||||
:param item_content: The content to save and syncronize
|
||||
:param item_uuid: The uuid of the item
|
||||
:param version: The new version of the item
|
||||
:param last_known_version: The last known version of the item
|
||||
:param first_sync_attempt: If the item has been attempted synchronized before,
|
||||
this is the timestamp of the first sync attempt.
|
||||
:param prev_lock_id: If the item has been attempted synchronized before, this is the id of the lock that
|
||||
prevented the synchronization.
|
||||
:return: The new version.
|
||||
"""
|
||||
if item_type == SyncItemType.Song:
|
||||
folder_path = self.song_folder_path
|
||||
history_folder_path = self.song_history_folder_path
|
||||
else:
|
||||
folder_path = self.custom_folder_path
|
||||
history_folder_path = self.custom_history_folder_path
|
||||
# Check for lock file. Will raise exception on lock
|
||||
self._check_for_lock_file(item_type, folder_path, item_uuid, first_sync_attempt, prev_lock_id)
|
||||
# Check if item already exists
|
||||
existing_item_files = self._get_file_list(folder_path, item_uuid + '*.xml')
|
||||
counter = -1
|
||||
if existing_item_files:
|
||||
# Handle case with multiple files returned, which indicates a conflict!
|
||||
if len(existing_item_files) > 1:
|
||||
raise ConflictException(item_type, item_uuid, ConflictReason.MultipleRemoteEntries)
|
||||
existing_file = existing_item_files[0].name
|
||||
filename_elements = existing_file.split('=')
|
||||
counter = int(filename_elements[1])
|
||||
if last_known_version:
|
||||
current_local_counter = int(last_known_version.split('=')[0])
|
||||
# Check if we do have the latest version locally, if not we flag a conflict
|
||||
if current_local_counter != counter:
|
||||
raise ConflictException(item_type, item_uuid, ConflictReason.VersionMismatch)
|
||||
counter += 1
|
||||
# Create lock file
|
||||
lock_filename = '{path}.lock={pcid}={counter}'.format(path=str(folder_path / item_uuid),
|
||||
pcid=self.pc_id, counter=counter)
|
||||
self._create_file(lock_filename, '')
|
||||
# Move old file to history folder
|
||||
self._move_file(folder_path / existing_file, history_folder_path)
|
||||
else:
|
||||
# TODO: Check for missing (deleted) file
|
||||
counter += 1
|
||||
lock_filename = '{path}.lock={pcid}={counter}'.format(path=str(folder_path / item_uuid),
|
||||
pcid=self.pc_id, counter=counter)
|
||||
# Create lock file
|
||||
self._create_file(lock_filename, '')
|
||||
basename = item_uuid + "=" + version + '.xml'
|
||||
basename_tmp = basename + '-tmp'
|
||||
new_filename = folder_path / basename
|
||||
new_tmp_filename = folder_path / basename_tmp
|
||||
self._create_file(new_tmp_filename, item_content)
|
||||
self._move_file(new_tmp_filename, new_filename)
|
||||
# Delete lock file
|
||||
self._remove_lock_file(lock_filename)
|
||||
return version
|
||||
|
||||
def _fetch_item(self, item_type, item_uuid):
|
||||
"""
|
||||
Fetch a specific item from the shared folder
|
||||
:param item_type: type of the item
|
||||
:param item_uuid: uuid of the item
|
||||
:return: The song object
|
||||
"""
|
||||
if item_type == SyncItemType.Song:
|
||||
folder_path = self.song_folder_path
|
||||
else:
|
||||
folder_path = self.custom_folder_path
|
||||
# Check for lock file - is this actually needed? should we create a read lock?
|
||||
if self._get_file_list(folder_path, item_uuid + '.lock'):
|
||||
log.debug('Found a lock file! Ignoring it for now.')
|
||||
existing_item_files = self._get_file_list(folder_path, item_uuid + '*')
|
||||
if existing_item_files:
|
||||
# Handle case with multiple files returned, which indicates a conflict!
|
||||
if len(existing_item_files) > 1:
|
||||
raise ConflictException(item_type, item_uuid, ConflictReason.MultipleRemoteEntries)
|
||||
existing_file = existing_item_files[0].name
|
||||
filename_elements = existing_file.split('=', 1)
|
||||
version = filename_elements[1].replace('.xml', '')
|
||||
item_content = self._read_file(existing_item_files[0])
|
||||
return version, item_content
|
||||
else:
|
||||
return None, None
|
||||
|
||||
def _delete_item(self, item_type, item_uuid, first_del_attempt, prev_lock_id):
|
||||
"""
|
||||
Delete item from the remote location. Does the following:
|
||||
1. Check for an existing lock, raise LockException if one found.
|
||||
2. Create a lock file and move the existing file to the history folder.
|
||||
3. Place a file in the deleted folder, named after the item uuid.
|
||||
4. Delete lock file.
|
||||
:param item_type:
|
||||
:param item_uuid:
|
||||
:type str:
|
||||
:param first_del_attempt:
|
||||
:type DateTime:
|
||||
:param prev_lock_id:
|
||||
:type str:
|
||||
"""
|
||||
if item_type == SyncItemType.Song:
|
||||
folder_path = self.song_folder_path
|
||||
history_folder_path = self.song_history_folder_path
|
||||
deleted_folder_path = self.song_deleted_folder_path
|
||||
else:
|
||||
folder_path = self.custom_folder_path
|
||||
history_folder_path = self.custom_history_folder_path
|
||||
deleted_folder_path = self.custom_deleted_folder_path
|
||||
# Check for lock file. Will raise exception on lock
|
||||
self._check_for_lock_file(item_type, folder_path, item_uuid, first_del_attempt, prev_lock_id)
|
||||
# Move the song xml file to the history folder
|
||||
existing_item_files = self._get_file_list(folder_path, item_uuid + '*.xml')
|
||||
if existing_item_files:
|
||||
# Handle case with multiple files returned, which indicates a conflict!
|
||||
if len(existing_item_files) > 1:
|
||||
raise ConflictException(item_type, item_uuid, ConflictReason.MultipleRemoteEntries)
|
||||
existing_file = existing_item_files[0].name
|
||||
# Move old file to deleted folder
|
||||
self._move_file(folder_path / existing_file, history_folder_path)
|
||||
# Create a file in the deleted-folder
|
||||
delete_filename = deleted_folder_path / item_uuid
|
||||
self._create_file(delete_filename, '')
|
|
@ -0,0 +1,91 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
|
||||
|
||||
##########################################################################
|
||||
# OpenLP - Open Source Lyrics Projection #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Copyright (c) 2008-2024 OpenLP Developers #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
##########################################################################
|
||||
|
||||
import fnmatch
|
||||
from ftplib import FTP, FTP_TLS
|
||||
from io import TextIOBase
|
||||
from pathlib import Path
|
||||
|
||||
from openlp.core.common.enum import FtpType
|
||||
from openlp.plugins.remotesync.lib.backends.foldersynchronizer import FolderSynchronizer
|
||||
|
||||
|
||||
class FtpSynchronizer(FolderSynchronizer):
|
||||
|
||||
def __init__(self, manager, base_folder_path, pc_id, ftp_type, ftp_server, ftp_user, ftp_pwd):
|
||||
super(FtpSynchronizer, self).__init__(manager, base_folder_path, pc_id)
|
||||
self.ftp = None
|
||||
self.ftp_type = ftp_type
|
||||
self.ftp_server = ftp_server
|
||||
self.ftp_user = ftp_user
|
||||
self.ftp_pwd = ftp_pwd
|
||||
|
||||
def check_configuration(self):
|
||||
return True
|
||||
|
||||
def check_connection(self):
|
||||
self.connect()
|
||||
base_folder_content = self._get_file_list(self.base_folder_path, '*')
|
||||
self.disconnect()
|
||||
if base_folder_content:
|
||||
return True
|
||||
return False
|
||||
|
||||
def initialize_remote(self):
|
||||
self.connect()
|
||||
self.ftp.mkd(self.song_history_folder_path)
|
||||
self.ftp.mkd(self.custom_folder_path)
|
||||
self.ftp.mkd(self.service_folder_path)
|
||||
self.disconnect()
|
||||
|
||||
def connect(self):
|
||||
if self.ftp_type == FtpType.Ftp:
|
||||
self.ftp = FTP(self.ftp_server, self.ftp_user, self.ftp_pwd)
|
||||
else:
|
||||
self.ftp = FTP_TLS(self.ftp_server, self.ftp_user, self.ftp_pwd)
|
||||
|
||||
def disconnect(self):
|
||||
self.ftp.close()
|
||||
self.ftp = None
|
||||
|
||||
def _get_file_list(self, path, mask):
|
||||
file_list = self.ftp.nlst(path)
|
||||
filtered_list = fnmatch.filter(file_list, mask)
|
||||
# The returned list must be a list of Path objects
|
||||
path_filtered_list = [Path(f) for f in filtered_list]
|
||||
return path_filtered_list
|
||||
|
||||
def _remove_lock_file(self, lock_filename):
|
||||
self.ftp.remove(str(lock_filename))
|
||||
|
||||
def _move_file(self, src, dst):
|
||||
self.ftp.move(src, dst)
|
||||
|
||||
def _create_file(self, filename, file_content):
|
||||
text_stream = TextIOBase()
|
||||
text_stream.write(file_content)
|
||||
self.ftp.storbinary('STOR ' + str(filename), text_stream)
|
||||
|
||||
def _read_file(self, filename):
|
||||
text_stream = TextIOBase()
|
||||
self.ftp.retrbinary('RETR ' + str(filename), text_stream, 1024)
|
||||
return text_stream.read()
|
|
@ -0,0 +1,3 @@
|
|||
from .api_config import *
|
||||
from .models import *
|
||||
from .services import *
|
|
@ -0,0 +1,30 @@
|
|||
import os
|
||||
from typing import Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class APIConfig(BaseModel):
|
||||
base_path: str = "http://localhost:8888/api/v1"
|
||||
verify: Union[bool, str] = True
|
||||
|
||||
def get_access_token(self) -> Optional[str]:
|
||||
try:
|
||||
return os.environ["access_token"]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
def set_access_token(self, value: str):
|
||||
raise Exception(
|
||||
"This client was generated with an environment variable for the access token. Please set the environment variable 'access_token' to the access token."
|
||||
)
|
||||
|
||||
|
||||
class HTTPException(Exception):
|
||||
def __init__(self, status_code: int, message: str):
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
super().__init__(f"{status_code} {message}")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.status_code} {self.message}"
|
|
@ -0,0 +1,16 @@
|
|||
from typing import *
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ApiResponse(BaseModel):
|
||||
"""
|
||||
None model
|
||||
|
||||
"""
|
||||
|
||||
code: Optional[int] = Field(alias="code", default=None)
|
||||
|
||||
type: Optional[str] = Field(alias="type", default=None)
|
||||
|
||||
message: Optional[str] = Field(alias="message", default=None)
|
|
@ -0,0 +1,20 @@
|
|||
from typing import *
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ItemInfo(BaseModel):
|
||||
"""
|
||||
None model
|
||||
|
||||
"""
|
||||
|
||||
uuid: Optional[str] = Field(alias="uuid", default=None)
|
||||
|
||||
version: Optional[int] = Field(alias="version", default=None)
|
||||
|
||||
user: Optional[str] = Field(alias="user", default=None)
|
||||
|
||||
timestamp: Optional[str] = Field(alias="timestamp", default=None)
|
||||
|
||||
title: Optional[str] = Field(alias="title", default=None)
|
|
@ -0,0 +1,14 @@
|
|||
from typing import *
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .ItemInfo import ItemInfo
|
||||
|
||||
|
||||
class ItemList(BaseModel):
|
||||
"""
|
||||
None model
|
||||
|
||||
"""
|
||||
|
||||
list: Optional[List[Optional[ItemInfo]]] = Field(alias="list", default=None)
|
|
@ -0,0 +1,22 @@
|
|||
from typing import *
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TextItem(BaseModel):
|
||||
"""
|
||||
None model
|
||||
|
||||
"""
|
||||
|
||||
uuid: Optional[str] = Field(alias="uuid", default=None)
|
||||
|
||||
version: Optional[int] = Field(alias="version", default=None)
|
||||
|
||||
user: Optional[str] = Field(alias="user", default=None)
|
||||
|
||||
timestamp: Optional[str] = Field(alias="timestamp", default=None)
|
||||
|
||||
title: Optional[str] = Field(alias="title", default=None)
|
||||
|
||||
itemxml: Optional[str] = Field(alias="itemxml", default=None)
|
|
@ -0,0 +1,4 @@
|
|||
from .ApiResponse import *
|
||||
from .ItemInfo import *
|
||||
from .ItemList import *
|
||||
from .TextItem import *
|
|
@ -0,0 +1,3 @@
|
|||
The restclient client was created using the OpenAPI python generator
|
||||
|
||||
See https://marcomuellner.github.io/openapi-python-generator/
|
|
@ -0,0 +1,165 @@
|
|||
import json
|
||||
from typing import *
|
||||
|
||||
import requests
|
||||
|
||||
from ..api_config import APIConfig, HTTPException
|
||||
from ..models import *
|
||||
|
||||
|
||||
def getCustomslideList(api_config_override: Optional[APIConfig] = None) -> ItemList:
|
||||
api_config = api_config_override if api_config_override else APIConfig()
|
||||
|
||||
base_path = api_config.base_path
|
||||
path = f"/custom-list"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer { api_config.get_access_token() }",
|
||||
}
|
||||
query_params: Dict[str, Any] = {}
|
||||
|
||||
query_params = {key: value for (key, value) in query_params.items() if value is not None}
|
||||
|
||||
response = requests.request(
|
||||
"get",
|
||||
f"{base_path}{path}",
|
||||
headers=headers,
|
||||
params=query_params,
|
||||
verify=api_config.verify,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
|
||||
|
||||
return ItemList(**response.json()) if response.json() is not None else ItemList()
|
||||
|
||||
|
||||
def getCustomslide(uuid: str, api_config_override: Optional[APIConfig] = None) -> TextItem:
|
||||
api_config = api_config_override if api_config_override else APIConfig()
|
||||
|
||||
base_path = api_config.base_path
|
||||
path = f"/custom/{uuid}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer { api_config.get_access_token() }",
|
||||
}
|
||||
query_params: Dict[str, Any] = {}
|
||||
|
||||
query_params = {key: value for (key, value) in query_params.items() if value is not None}
|
||||
|
||||
response = requests.request(
|
||||
"get",
|
||||
f"{base_path}{path}",
|
||||
headers=headers,
|
||||
params=query_params,
|
||||
verify=api_config.verify,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
|
||||
|
||||
return TextItem(**response.json()) if response.json() is not None else TextItem()
|
||||
|
||||
|
||||
def updateCustomslide(uuid: str, data: TextItem, api_config_override: Optional[APIConfig] = None) -> None:
|
||||
api_config = api_config_override if api_config_override else APIConfig()
|
||||
|
||||
base_path = api_config.base_path
|
||||
path = f"/custom/{uuid}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer { api_config.get_access_token() }",
|
||||
}
|
||||
query_params: Dict[str, Any] = {}
|
||||
|
||||
query_params = {key: value for (key, value) in query_params.items() if value is not None}
|
||||
|
||||
response = requests.request(
|
||||
"put", f"{base_path}{path}", headers=headers, params=query_params, verify=api_config.verify, json=data.dict()
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def deleteCustomslide(uuid: str, api_config_override: Optional[APIConfig] = None) -> None:
|
||||
api_config = api_config_override if api_config_override else APIConfig()
|
||||
|
||||
base_path = api_config.base_path
|
||||
path = f"/custom/{uuid}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer { api_config.get_access_token() }",
|
||||
}
|
||||
query_params: Dict[str, Any] = {}
|
||||
|
||||
query_params = {key: value for (key, value) in query_params.items() if value is not None}
|
||||
|
||||
response = requests.request(
|
||||
"delete",
|
||||
f"{base_path}{path}",
|
||||
headers=headers,
|
||||
params=query_params,
|
||||
verify=api_config.verify,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def getCustomslideVersion(uuid: str, version: int, api_config_override: Optional[APIConfig] = None) -> TextItem:
|
||||
api_config = api_config_override if api_config_override else APIConfig()
|
||||
|
||||
base_path = api_config.base_path
|
||||
path = f"/custom/{uuid}/{version}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer { api_config.get_access_token() }",
|
||||
}
|
||||
query_params: Dict[str, Any] = {}
|
||||
|
||||
query_params = {key: value for (key, value) in query_params.items() if value is not None}
|
||||
|
||||
response = requests.request(
|
||||
"get",
|
||||
f"{base_path}{path}",
|
||||
headers=headers,
|
||||
params=query_params,
|
||||
verify=api_config.verify,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
|
||||
|
||||
return TextItem(**response.json()) if response.json() is not None else TextItem()
|
||||
|
||||
|
||||
def getCustomslideHistory(uuid: str, api_config_override: Optional[APIConfig] = None) -> TextItem:
|
||||
api_config = api_config_override if api_config_override else APIConfig()
|
||||
|
||||
base_path = api_config.base_path
|
||||
path = f"/custom-history/{uuid}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer { api_config.get_access_token() }",
|
||||
}
|
||||
query_params: Dict[str, Any] = {}
|
||||
|
||||
query_params = {key: value for (key, value) in query_params.items() if value is not None}
|
||||
|
||||
response = requests.request(
|
||||
"get",
|
||||
f"{base_path}{path}",
|
||||
headers=headers,
|
||||
params=query_params,
|
||||
verify=api_config.verify,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
|
||||
|
||||
return TextItem(**response.json()) if response.json() is not None else TextItem()
|
|
@ -0,0 +1,219 @@
|
|||
import json
|
||||
from typing import *
|
||||
|
||||
import requests
|
||||
|
||||
from ..api_config import APIConfig, HTTPException
|
||||
from ..models import *
|
||||
|
||||
|
||||
def getSongList(api_config_override: Optional[APIConfig] = None) -> ItemList:
|
||||
api_config = api_config_override if api_config_override else APIConfig()
|
||||
|
||||
base_path = api_config.base_path
|
||||
path = f"/song-list"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer { api_config.get_access_token() }",
|
||||
}
|
||||
query_params: Dict[str, Any] = {}
|
||||
|
||||
query_params = {key: value for (key, value) in query_params.items() if value is not None}
|
||||
|
||||
response = requests.request(
|
||||
"get",
|
||||
f"{base_path}{path}",
|
||||
headers=headers,
|
||||
params=query_params,
|
||||
verify=api_config.verify,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
|
||||
|
||||
return ItemList(**response.json()) if response.json() is not None else ItemList()
|
||||
|
||||
|
||||
def getSongListChangesSince(sinceDateTime: str, api_config_override: Optional[APIConfig] = None) -> ItemList:
|
||||
api_config = api_config_override if api_config_override else APIConfig()
|
||||
|
||||
base_path = api_config.base_path
|
||||
path = f"/song-list/changes/{sinceDateTime}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer { api_config.get_access_token() }",
|
||||
}
|
||||
query_params: Dict[str, Any] = {}
|
||||
|
||||
query_params = {key: value for (key, value) in query_params.items() if value is not None}
|
||||
|
||||
response = requests.request(
|
||||
"get",
|
||||
f"{base_path}{path}",
|
||||
headers=headers,
|
||||
params=query_params,
|
||||
verify=api_config.verify,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
|
||||
|
||||
return ItemList(**response.json()) if response.json() is not None else ItemList()
|
||||
|
||||
|
||||
def getSong(uuid: str, api_config_override: Optional[APIConfig] = None) -> TextItem:
|
||||
api_config = api_config_override if api_config_override else APIConfig()
|
||||
|
||||
base_path = api_config.base_path
|
||||
path = f"/song/{uuid}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer { api_config.get_access_token() }",
|
||||
}
|
||||
query_params: Dict[str, Any] = {}
|
||||
|
||||
query_params = {key: value for (key, value) in query_params.items() if value is not None}
|
||||
|
||||
response = requests.request(
|
||||
"get",
|
||||
f"{base_path}{path}",
|
||||
headers=headers,
|
||||
params=query_params,
|
||||
verify=api_config.verify,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
|
||||
|
||||
return TextItem(**response.json()) if response.json() is not None else TextItem()
|
||||
|
||||
|
||||
def updateSong(uuid: str, data: TextItem, api_config_override: Optional[APIConfig] = None) -> None:
|
||||
api_config = api_config_override if api_config_override else APIConfig()
|
||||
|
||||
base_path = api_config.base_path
|
||||
path = f"/song/{uuid}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer { api_config.get_access_token() }",
|
||||
}
|
||||
query_params: Dict[str, Any] = {}
|
||||
|
||||
query_params = {key: value for (key, value) in query_params.items() if value is not None}
|
||||
|
||||
response = requests.request(
|
||||
"put", f"{base_path}{path}", headers=headers, params=query_params, verify=api_config.verify, json=data.dict()
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def deleteSong(uuid: str, api_config_override: Optional[APIConfig] = None) -> None:
|
||||
api_config = api_config_override if api_config_override else APIConfig()
|
||||
|
||||
base_path = api_config.base_path
|
||||
path = f"/song/{uuid}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer { api_config.get_access_token() }",
|
||||
}
|
||||
query_params: Dict[str, Any] = {}
|
||||
|
||||
query_params = {key: value for (key, value) in query_params.items() if value is not None}
|
||||
|
||||
response = requests.request(
|
||||
"delete",
|
||||
f"{base_path}{path}",
|
||||
headers=headers,
|
||||
params=query_params,
|
||||
verify=api_config.verify,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def getSongVersion(uuid: str, version: int, api_config_override: Optional[APIConfig] = None) -> TextItem:
|
||||
api_config = api_config_override if api_config_override else APIConfig()
|
||||
|
||||
base_path = api_config.base_path
|
||||
path = f"/song/{uuid}/{version}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer { api_config.get_access_token() }",
|
||||
}
|
||||
query_params: Dict[str, Any] = {}
|
||||
|
||||
query_params = {key: value for (key, value) in query_params.items() if value is not None}
|
||||
|
||||
response = requests.request(
|
||||
"get",
|
||||
f"{base_path}{path}",
|
||||
headers=headers,
|
||||
params=query_params,
|
||||
verify=api_config.verify,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
|
||||
|
||||
return TextItem(**response.json()) if response.json() is not None else TextItem()
|
||||
|
||||
|
||||
def getSongHistory(uuid: str, api_config_override: Optional[APIConfig] = None) -> TextItem:
|
||||
api_config = api_config_override if api_config_override else APIConfig()
|
||||
|
||||
base_path = api_config.base_path
|
||||
path = f"/song-history/{uuid}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer { api_config.get_access_token() }",
|
||||
}
|
||||
query_params: Dict[str, Any] = {}
|
||||
|
||||
query_params = {key: value for (key, value) in query_params.items() if value is not None}
|
||||
|
||||
response = requests.request(
|
||||
"get",
|
||||
f"{base_path}{path}",
|
||||
headers=headers,
|
||||
params=query_params,
|
||||
verify=api_config.verify,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
|
||||
|
||||
return TextItem(**response.json()) if response.json() is not None else TextItem()
|
||||
|
||||
|
||||
def getCustomSlideListChangesSince(sinceDateTime: str, api_config_override: Optional[APIConfig] = None) -> ItemList:
|
||||
api_config = api_config_override if api_config_override else APIConfig()
|
||||
|
||||
base_path = api_config.base_path
|
||||
path = f"/custom-list/changes/{sinceDateTime}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer { api_config.get_access_token() }",
|
||||
}
|
||||
query_params: Dict[str, Any] = {}
|
||||
|
||||
query_params = {key: value for (key, value) in query_params.items() if value is not None}
|
||||
|
||||
response = requests.request(
|
||||
"get",
|
||||
f"{base_path}{path}",
|
||||
headers=headers,
|
||||
params=query_params,
|
||||
verify=api_config.verify,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(response.status_code, f" failed with status code: {response.status_code}")
|
||||
|
||||
return ItemList(**response.json()) if response.json() is not None else ItemList()
|
|
@ -0,0 +1,225 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
|
||||
|
||||
##########################################################################
|
||||
# OpenLP - Open Source Lyrics Projection #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Copyright (c) 2008-2024 OpenLP Developers #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
##########################################################################
|
||||
|
||||
from sqlalchemy.sql import and_
|
||||
|
||||
from openlp.core.common.registry import Registry
|
||||
from openlp.plugins.remotesync.lib.db import RemoteSyncItem, ConflictItem
|
||||
from openlp.plugins.songs.lib.openlyricsxml import OpenLyrics
|
||||
|
||||
|
||||
class ConflictReason:
|
||||
"""
|
||||
Conflict reason type definitions
|
||||
"""
|
||||
MultipleRemoteEntries = 'MultipleRemoteEntries'
|
||||
VersionMismatch = 'VersionMismatch'
|
||||
|
||||
|
||||
class ConflictException(Exception):
|
||||
"""
|
||||
Exception thrown in case of conflicts
|
||||
"""
|
||||
def __init__(self, type, uuid, reason):
|
||||
self.type = type
|
||||
self.uuid = uuid
|
||||
self.reason = reason
|
||||
|
||||
|
||||
class LockException(Exception):
|
||||
"""
|
||||
Exception thrown in case of a locked item
|
||||
"""
|
||||
def __init__(self, type, uuid, lock_id, first_attempt):
|
||||
self.type = type
|
||||
self.uuid = uuid
|
||||
self.lock_id = lock_id
|
||||
self.first_attempt = first_attempt
|
||||
|
||||
|
||||
class SyncItemType:
|
||||
"""
|
||||
Sync item type definitions
|
||||
"""
|
||||
Song = 'song'
|
||||
Custom = 'custom'
|
||||
|
||||
|
||||
class SyncItemAction:
|
||||
"""
|
||||
Sync item Action definitions
|
||||
"""
|
||||
Update = 'update'
|
||||
Delete = 'delete'
|
||||
|
||||
|
||||
class Synchronizer(object):
|
||||
"""
|
||||
The base class used for synchronization.
|
||||
Any Synchronizer implementation must override the functions needed to actually synchronize songs, custom slides
|
||||
and services.
|
||||
"""
|
||||
|
||||
def __init__(self, manager):
|
||||
self.manager = manager
|
||||
self.song_manager = Registry().get('songs_manager')
|
||||
self.open_lyrics = OpenLyrics(Registry().get('songs_manager'))
|
||||
|
||||
def connect(self):
|
||||
pass
|
||||
|
||||
def disconnect(self):
|
||||
pass
|
||||
|
||||
def get_sync_item(self, uuid, type):
|
||||
item = self.manager.get_object_filtered(RemoteSyncItem, and_(RemoteSyncItem.uuid == uuid,
|
||||
RemoteSyncItem.type == type))
|
||||
if item:
|
||||
return item
|
||||
else:
|
||||
return None
|
||||
|
||||
def mark_item_for_conflict(self, type, uuid, reason):
|
||||
"""
|
||||
Marks item as having a conflict
|
||||
:param type: Type of the item
|
||||
:param uuid: The uuid of the item
|
||||
:param reason: The reason for the conflict
|
||||
"""
|
||||
# Check if it is already marked with a conflict
|
||||
item = self.manager.get_object_filtered(ConflictItem, and_(ConflictItem.uuid == uuid,
|
||||
ConflictItem.conflict_reason == reason))
|
||||
if not item:
|
||||
item = ConflictItem()
|
||||
item.type = type
|
||||
item.uuid = uuid
|
||||
item.conflict_reason = reason
|
||||
self.manager.save_object(item)
|
||||
|
||||
def check_configuration(self):
|
||||
return False
|
||||
|
||||
def check_connection(self):
|
||||
"""
|
||||
Check that it is possible to connect to the remote server/folder.
|
||||
"""
|
||||
return False
|
||||
|
||||
def initialize_remote(self):
|
||||
"""
|
||||
Setup connection to the remote server and do remote initialization.
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_remote_song_changes(self):
|
||||
"""
|
||||
Check for changes in the remote/shared folder. If a changed/new item is found it is fetched using the
|
||||
fetch_song method, and if a conflict is detected the mark_item_for_conflict is used. If items has been deleted
|
||||
remotely, they are also deleted locally.
|
||||
:return: True if one or more songs was updated, otherwise False
|
||||
"""
|
||||
pass
|
||||
|
||||
def send_song(self, song, song_uuid, last_known_version, first_sync_attempt, prev_lock_id):
|
||||
"""
|
||||
Sends a song to the remote location
|
||||
:param song: The song object to synchronize
|
||||
:param song_uuid: The uuid of the song
|
||||
:param last_known_version: The last known version of the song
|
||||
:param first_sync_attempt: If the song has been attempted synchronized before,
|
||||
this is the timestamp of the first sync attempt.
|
||||
:param prev_lock_id: If the song has been attempted synchronized before, this is the id of the lock that
|
||||
prevented the synchronization.
|
||||
:return: The new version.
|
||||
"""
|
||||
pass
|
||||
|
||||
def fetch_song(self, song_uuid, song_id):
|
||||
"""
|
||||
Fetch a specific song from the remote location and saves it to the song db.
|
||||
:param song_uuid: uuid of the song
|
||||
:param song_id: song db id, None if song does not yet exists in the song db
|
||||
:return: The song object
|
||||
"""
|
||||
pass
|
||||
|
||||
def delete_song(self, song_uuid, first_del_attempt, prev_lock_id):
|
||||
"""
|
||||
Delete song from the remote location
|
||||
:param song_uuid:
|
||||
:type str:
|
||||
:param first_del_attempt:
|
||||
:type DateTime:
|
||||
:param prev_lock_id:
|
||||
:type str:
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_remote_custom_changes(self):
|
||||
"""
|
||||
Check for changes in the remote/shared folder. If a changed/new item is found it is fetched using the
|
||||
fetch_song method, and if a conflict is detected the mark_item_for_conflict is used. If items has been deleted
|
||||
remotely, they are also deleted locally.
|
||||
:return: True if one or more songs was updated, otherwise False
|
||||
"""
|
||||
pass
|
||||
|
||||
def send_custom(self, custom, custom_uuid, last_known_version, first_sync_attempt, prev_lock_id):
|
||||
"""
|
||||
Sends a custom slide to the remote location.
|
||||
:param custom: The custom object to synchronize
|
||||
:param custom_uuid: The uuid of the custom slide
|
||||
:param last_known_version: The last known version of the custom slide
|
||||
:param first_sync_attempt: If the custom slide has been attempted synchronized before,
|
||||
this is the timestamp of the first sync attempt.
|
||||
:param prev_lock_id: If the custom slide has been attempted synchronized before, this is the id of the lock
|
||||
that prevented the synchronization.
|
||||
:return: The new version.
|
||||
"""
|
||||
pass
|
||||
|
||||
def fetch_custom(self, custom_uuid, custom_id):
|
||||
"""
|
||||
Fetch a specific custom slide from the remote location and stores it in the custom db
|
||||
:param custom_uuid: uuid of the custom slide
|
||||
:param custom_id: custom db id, None if the custom slide does not yet exists in the custom db
|
||||
:return: The custom object
|
||||
"""
|
||||
pass
|
||||
|
||||
def delete_custom(self, custom_uuid, first_del_attempt, prev_lock_id):
|
||||
"""
|
||||
Delete custom slide from the remote location.
|
||||
:param custom_uuid:
|
||||
:type str:
|
||||
:param first_del_attempt:
|
||||
:type DateTime:
|
||||
:param prev_lock_id:
|
||||
:type str:
|
||||
"""
|
||||
pass
|
||||
|
||||
def send_service(self, service):
|
||||
pass
|
||||
|
||||
def fetch_service(self):
|
||||
pass
|
|
@ -0,0 +1,200 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
|
||||
|
||||
##########################################################################
|
||||
# OpenLP - Open Source Lyrics Projection #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Copyright (c) 2008-2024 OpenLP Developers #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
##########################################################################
|
||||
|
||||
import requests
|
||||
|
||||
from openlp.core.common import Settings, registry
|
||||
from openlp.core.lib.db import Manager
|
||||
from openlp.plugins.remotesync.lib.backends.synchronizer import Synchronizer
|
||||
from openlp.plugins.songs.lib.db import init_schema, Song
|
||||
from openlp.plugins.remotesync.lib.backends.restclient.services import song_service, custom_service
|
||||
|
||||
|
||||
class WebServiceSynchronizer(Synchronizer):
|
||||
baseurl = 'http://localhost:8000/'
|
||||
auth_token = 'afd9a4aa979534edf0015f7379cd6d61a52f9e10'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
port = kwargs['port']
|
||||
address = kwargs['address']
|
||||
self.auth_token = kwargs['auth_token']
|
||||
self.base_url = '{}:{}/'.format(address, port)
|
||||
self.manager = registry.Registry().get('songs_manager')
|
||||
registry.Registry().register('remote_synchronizer', self)
|
||||
|
||||
def connect(self):
|
||||
pass
|
||||
|
||||
def disconnect(self):
|
||||
pass
|
||||
|
||||
def check_configuration(self):
|
||||
return False
|
||||
|
||||
def check_connection(self):
|
||||
"""
|
||||
Check that it is possible to connect to the remote server/folder.
|
||||
"""
|
||||
return False
|
||||
|
||||
def initialize_remote(self):
|
||||
"""
|
||||
Setup connection to the remote server and do remote initialization.
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_remote_song_changes(self):
|
||||
"""
|
||||
Check for changes in the remote/shared folder. If a changed/new item is found it is fetched using the
|
||||
fetch_song method, and if a conflict is detected the mark_item_for_conflict is used. If items has been deleted
|
||||
remotely, they are also deleted locally.
|
||||
:return: True if one or more songs was updated, otherwise False
|
||||
"""
|
||||
# GET song/list
|
||||
pass
|
||||
|
||||
def send_song(self, song, song_uuid, last_known_version, first_sync_attempt, prev_lock_id):
|
||||
"""
|
||||
Sends a song to the remote location
|
||||
:param song: The song object to synchronize
|
||||
:param song_uuid: The uuid of the song
|
||||
:param last_known_version: The last known version of the song
|
||||
:param first_sync_attempt: If the song has been attempted synchronized before,
|
||||
this is the timestamp of the first sync attempt.
|
||||
:param prev_lock_id: If the song has been attempted synchronized before, this is the id of the lock that
|
||||
prevented the synchronization.
|
||||
:return: The new version.
|
||||
"""
|
||||
# PUT song/<uuid>
|
||||
pass
|
||||
|
||||
def fetch_song(self, song_uuid, song_id):
|
||||
"""
|
||||
Fetch a specific song from the remote location and saves it to the song db.
|
||||
:param song_uuid: uuid of the song
|
||||
:param song_id: song db id, None if song does not yet exists in the song db
|
||||
:return: The song object
|
||||
"""
|
||||
# GET song/<uuid>
|
||||
pass
|
||||
|
||||
def delete_song(self, song_uuid, first_del_attempt, prev_lock_id):
|
||||
"""
|
||||
Delete song from the remote location
|
||||
:param song_uuid:
|
||||
:type str:
|
||||
:param first_del_attempt:
|
||||
:type DateTime:
|
||||
:param prev_lock_id:
|
||||
:type str:
|
||||
"""
|
||||
# DELETE song/<uuid>
|
||||
pass
|
||||
|
||||
def get_remote_custom_changes(self):
|
||||
"""
|
||||
Check for changes in the remote/shared folder. If a changed/new item is found it is fetched using the
|
||||
fetch_song method, and if a conflict is detected the mark_item_for_conflict is used. If items has been deleted
|
||||
remotely, they are also deleted locally.
|
||||
:return: True if one or more songs was updated, otherwise False
|
||||
"""
|
||||
# GET custom/list
|
||||
pass
|
||||
|
||||
def send_custom(self, custom, custom_uuid, last_known_version, first_sync_attempt, prev_lock_id):
|
||||
"""
|
||||
Sends a custom slide to the remote location.
|
||||
:param custom: The custom object to synchronize
|
||||
:param custom_uuid: The uuid of the custom slide
|
||||
:param last_known_version: The last known version of the custom slide
|
||||
:param first_sync_attempt: If the custom slide has been attempted synchronized before,
|
||||
this is the timestamp of the first sync attempt.
|
||||
:param prev_lock_id: If the custom slide has been attempted synchronized before, this is the id of the lock
|
||||
that prevented the synchronization.
|
||||
:return: The new version.
|
||||
"""
|
||||
# PUT custom/<uuid>
|
||||
pass
|
||||
|
||||
def fetch_custom(self, custom_uuid, custom_id):
|
||||
"""
|
||||
Fetch a specific custom slide from the remote location and stores it in the custom db
|
||||
:param custom_uuid: uuid of the custom slide
|
||||
:param custom_id: custom db id, None if the custom slide does not yet exists in the custom db
|
||||
:return: The custom object
|
||||
"""
|
||||
# GET custom/<uuid>
|
||||
pass
|
||||
|
||||
def delete_custom(self, custom_uuid, first_del_attempt, prev_lock_id):
|
||||
"""
|
||||
Delete custom slide from the remote location.
|
||||
:param custom_uuid:
|
||||
:type str:
|
||||
:param first_del_attempt:
|
||||
:type DateTime:
|
||||
:param prev_lock_id:
|
||||
:type str:
|
||||
"""
|
||||
# DELETE custom/<uuid>
|
||||
pass
|
||||
|
||||
def send_service(self, service):
|
||||
pass
|
||||
|
||||
def fetch_service(self):
|
||||
pass
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _handle(response, expected_status_code=200):
|
||||
if response.status_code != expected_status_code:
|
||||
print('whoops got {} expected {}'.format(response.status_code, expected_status_code))
|
||||
return response
|
||||
|
||||
def _get(self, url):
|
||||
return self._handle(requests.get(url, headers={'Authorization': 'access_token {}'.format(self.auth_token)}),
|
||||
200)
|
||||
|
||||
def _post(self, url, data, return_code):
|
||||
return self._handle(requests.post(url, headers={'Authorization': 'access_token {}'.format(self.auth_token)},
|
||||
json=data), return_code)
|
||||
|
||||
def _put(self, url, data):
|
||||
return self._handle(requests.put(url, headers={'Authorization': 'access_token {}'.format(self.auth_token)},
|
||||
data=data))
|
||||
|
||||
def _delete(self, url):
|
||||
return self._handle(requests.delete(url, headers={'Authorization': 'access_token {}'.format(self.auth_token)}))
|
||||
|
||||
def check_connection(self):
|
||||
return False
|
||||
|
||||
def send_song(self, song):
|
||||
self._post('http://localhost:8000/songs/', song, 201)
|
||||
|
||||
def receive_songs(self):
|
||||
self._get('http://localhost:8000/songs/')
|
||||
|
||||
def send_all_songs(self):
|
||||
for song in self.manager.get_all_objects(Song):
|
||||
self._post('http://localhost:8000/songs/', self.open_lyrics.song_to_xml(song), 201)
|
|
@ -0,0 +1,80 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
|
||||
|
||||
##########################################################################
|
||||
# OpenLP - Open Source Lyrics Projection #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Copyright (c) 2008-2024 OpenLP Developers #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
##########################################################################
|
||||
"""
|
||||
The :mod:`db` module provides the database and schema that is the backend for
|
||||
the Custom plugin
|
||||
"""
|
||||
from sqlalchemy import Column
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from sqlalchemy.types import DateTime, Integer, Unicode
|
||||
|
||||
from openlp.core.db.helpers import init_db
|
||||
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class RemoteSyncItem(Base):
|
||||
"""
|
||||
RemoteSyncItem model
|
||||
"""
|
||||
__tablename__ = 'remote_sync_map'
|
||||
|
||||
item_id = Column(Integer, primary_key=True)
|
||||
type = Column(Unicode(64), primary_key=True)
|
||||
uuid = Column(Unicode(36), nullable=False)
|
||||
version = Column(Unicode(64), nullable=False)
|
||||
|
||||
|
||||
class SyncQueueItem(Base):
|
||||
"""
|
||||
SyncQueueItem model
|
||||
"""
|
||||
__tablename__ = 'sync_queue_table'
|
||||
|
||||
item_id = Column(Integer, primary_key=True)
|
||||
type = Column(Unicode(64), primary_key=True)
|
||||
action = Column(Unicode(32))
|
||||
lock_id = Column(Unicode(128))
|
||||
first_attempt = Column(DateTime())
|
||||
|
||||
|
||||
class ConflictItem(Base):
|
||||
"""
|
||||
ConflictItem model
|
||||
"""
|
||||
__tablename__ = 'conflicts_table'
|
||||
|
||||
type = Column(Unicode(64), primary_key=True)
|
||||
uuid = Column(Unicode(36), nullable=False)
|
||||
conflict_reason = Column(Unicode(64), nullable=False)
|
||||
|
||||
|
||||
def init_schema(url):
|
||||
"""
|
||||
Setup the custom database connection and initialise the database schema
|
||||
|
||||
:param url: The database to setup
|
||||
"""
|
||||
session, metadata = init_db(url, base=Base)
|
||||
metadata.create_all(bind=metadata.bind, checkfirst=True)
|
||||
return session
|
|
@ -0,0 +1,236 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
|
||||
|
||||
##########################################################################
|
||||
# OpenLP - Open Source Lyrics Projection #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Copyright (c) 2008-2024 OpenLP Developers #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
##########################################################################
|
||||
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
|
||||
from openlp.core.common.enum import FtpType, SyncType
|
||||
from openlp.core.common.registry import Registry
|
||||
from openlp.core.common.i18n import translate
|
||||
from openlp.core.lib.settingstab import SettingsTab
|
||||
from openlp.core.widgets.edits import PathEdit
|
||||
from openlp.core.widgets.enums import PathEditType
|
||||
|
||||
|
||||
class RemoteSyncTab(SettingsTab):
|
||||
"""
|
||||
RemoteSyncTab is the RemoteSync settings tab in the settings dialog.
|
||||
"""
|
||||
def setup_ui(self):
|
||||
self.setObjectName('RemoteSyncTab')
|
||||
super(RemoteSyncTab, self).setup_ui()
|
||||
# sync type setup
|
||||
self.sync_type_group_box = QtWidgets.QGroupBox(self.left_column)
|
||||
self.sync_type_group_box.setObjectName('sync_type_group_box')
|
||||
self.sync_type_group_box_layout = QtWidgets.QFormLayout(self.sync_type_group_box)
|
||||
self.sync_type_group_box_layout.setObjectName('sync_type_group_box_layout')
|
||||
self.sync_type_label = QtWidgets.QLabel(self.sync_type_group_box)
|
||||
self.sync_type_label.setObjectName('sync_type_label')
|
||||
self.sync_type_radio_group = QtWidgets.QButtonGroup(self)
|
||||
self.disabled_sync_radio = QtWidgets.QRadioButton('', self)
|
||||
self.sync_type_radio_group.addButton(self.disabled_sync_radio, SyncType.Disabled)
|
||||
self.sync_type_group_box_layout.setWidget(0, QtWidgets.QFormLayout.SpanningRole, self.disabled_sync_radio)
|
||||
self.folder_sync_radio = QtWidgets.QRadioButton('', self)
|
||||
self.sync_type_radio_group.addButton(self.folder_sync_radio, SyncType.Folder)
|
||||
self.sync_type_group_box_layout.setWidget(1, QtWidgets.QFormLayout.SpanningRole, self.folder_sync_radio)
|
||||
self.ftp_sync_radio = QtWidgets.QRadioButton('', self)
|
||||
self.sync_type_radio_group.addButton(self.ftp_sync_radio, SyncType.Ftp)
|
||||
self.sync_type_group_box_layout.setWidget(2, QtWidgets.QFormLayout.SpanningRole, self.ftp_sync_radio)
|
||||
self.left_layout.addWidget(self.sync_type_group_box)
|
||||
# Folder sync settings
|
||||
self.folder_settings_group_box = QtWidgets.QGroupBox(self.left_column)
|
||||
self.folder_settings_group_box.setObjectName('folder_settings_group_box')
|
||||
self.folder_settings_layout = QtWidgets.QFormLayout(self.folder_settings_group_box)
|
||||
self.folder_settings_layout.setObjectName('folder_settings_layout')
|
||||
# Sync folder path
|
||||
self.folder_label = QtWidgets.QLabel(self.folder_settings_group_box)
|
||||
self.folder_label.setObjectName('folder_label')
|
||||
self.folder_path_edit = PathEdit(self.folder_settings_group_box, path_type=PathEditType.Directories,
|
||||
show_revert=False)
|
||||
self.folder_settings_layout.addRow(self.folder_label, self.folder_path_edit)
|
||||
self.left_layout.addWidget(self.folder_settings_group_box)
|
||||
# FTP server settings
|
||||
self.ftp_settings_group_box = QtWidgets.QGroupBox(self.left_column)
|
||||
self.ftp_settings_group_box.setObjectName('ftp_settings_group_box')
|
||||
self.ftp_settings_layout = QtWidgets.QFormLayout(self.ftp_settings_group_box)
|
||||
self.ftp_settings_layout.setObjectName('ftp_settings_layout')
|
||||
self.ftp_type_label = QtWidgets.QLabel(self.ftp_settings_group_box)
|
||||
self.ftp_type_label.setObjectName('ftp_type_label')
|
||||
# FTP type
|
||||
self.ftp_type_radio_group = QtWidgets.QButtonGroup(self)
|
||||
self.ftp_unsecure_radio = QtWidgets.QRadioButton('', self)
|
||||
self.ftp_type_radio_group.addButton(self.ftp_unsecure_radio, FtpType.Ftp)
|
||||
self.ftp_settings_layout.setWidget(0, QtWidgets.QFormLayout.SpanningRole, self.ftp_unsecure_radio)
|
||||
self.ftp_secure_radio = QtWidgets.QRadioButton('', self)
|
||||
self.ftp_type_radio_group.addButton(self.ftp_secure_radio, FtpType.FtpTls)
|
||||
self.ftp_settings_layout.setWidget(1, QtWidgets.QFormLayout.SpanningRole, self.ftp_secure_radio)
|
||||
# FTP server address
|
||||
self.ftp_address_label = QtWidgets.QLabel(self.ftp_settings_group_box)
|
||||
self.ftp_address_label.setObjectName('address_label')
|
||||
self.ftp_server_edit = QtWidgets.QLineEdit(self.ftp_settings_group_box)
|
||||
self.ftp_server_edit.setSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed)
|
||||
self.ftp_server_edit.setObjectName('address_edit')
|
||||
self.ftp_settings_layout.addRow(self.ftp_address_label, self.ftp_server_edit)
|
||||
# FTP server username
|
||||
self.ftp_username_label = QtWidgets.QLabel(self.ftp_settings_group_box)
|
||||
self.ftp_username_label.setObjectName('ftp_username_label')
|
||||
self.ftp_username_edit = QtWidgets.QLineEdit(self.ftp_settings_group_box)
|
||||
self.ftp_username_edit.setObjectName('ftp_username_edit')
|
||||
self.ftp_settings_layout.addRow(self.ftp_username_label, self.ftp_username_edit)
|
||||
# FTP server password
|
||||
self.ftp_pswd_label = QtWidgets.QLabel(self.ftp_settings_group_box)
|
||||
self.ftp_pswd_label.setObjectName('ftp_pswd_label')
|
||||
self.ftp_pswd_edit = QtWidgets.QLineEdit(self.ftp_settings_group_box)
|
||||
self.ftp_pswd_edit.setObjectName('ftp_pswd_edit')
|
||||
self.ftp_settings_layout.addRow(self.ftp_pswd_label, self.ftp_pswd_edit)
|
||||
# FTP server data folder
|
||||
self.ftp_folder_label = QtWidgets.QLabel(self.ftp_settings_group_box)
|
||||
self.ftp_folder_label.setObjectName('ftp_folder_label')
|
||||
self.ftp_folder_edit = QtWidgets.QLineEdit(self.ftp_settings_group_box)
|
||||
self.ftp_folder_edit.setObjectName('ftp_folder_edit')
|
||||
self.ftp_settings_layout.addRow(self.ftp_folder_label, self.ftp_folder_edit)
|
||||
self.left_layout.addWidget(self.ftp_settings_group_box)
|
||||
|
||||
"""
|
||||
# Manual trigger actions
|
||||
self.actions_group_box = QtWidgets.QGroupBox(self.left_column)
|
||||
self.actions_group_box.setObjectName('actions_group_box')
|
||||
self.actions_layout = QtWidgets.QFormLayout(self.actions_group_box)
|
||||
self.actions_layout.setObjectName('actions_layout')
|
||||
# send songs
|
||||
self.send_songs_btn = QtWidgets.QPushButton(self.actions_group_box)
|
||||
self.send_songs_btn.setObjectName('send_songs_btn')
|
||||
self.send_songs_btn.clicked.connect(self.on_send_songs_clicked)
|
||||
# receive songs
|
||||
self.receive_songs_btn = QtWidgets.QPushButton(self.actions_group_box)
|
||||
self.receive_songs_btn.setObjectName('receive_songs_btn')
|
||||
self.receive_songs_btn.clicked.connect(self.on_receive_songs_clicked)
|
||||
self.actions_layout.addRow(self.send_songs_btn, self.receive_songs_btn)
|
||||
self.left_layout.addWidget(self.actions_group_box)
|
||||
"""
|
||||
# statistics
|
||||
self.remote_statistics_group_box = QtWidgets.QGroupBox(self.left_column)
|
||||
self.remote_statistics_group_box.setObjectName('remote_statistics_group_box')
|
||||
self.remote_statistics_layout = QtWidgets.QFormLayout(self.remote_statistics_group_box)
|
||||
self.remote_statistics_layout.setObjectName('remote_statistics_layout')
|
||||
self.update_policy_label = QtWidgets.QLabel(self.remote_statistics_group_box)
|
||||
self.update_policy_label.setObjectName('update_policy_label')
|
||||
self.update_policy = QtWidgets.QLabel(self.remote_statistics_group_box)
|
||||
self.update_policy.setObjectName('update_policy')
|
||||
self.remote_statistics_layout.addRow(self.update_policy_label, self.update_policy)
|
||||
self.last_sync_label = QtWidgets.QLabel(self.remote_statistics_group_box)
|
||||
self.last_sync_label.setObjectName('last_sync_label')
|
||||
self.last_sync = QtWidgets.QLabel(self.remote_statistics_group_box)
|
||||
self.last_sync.setObjectName('last_sync')
|
||||
self.remote_statistics_layout.addRow(self.last_sync_label, self.last_sync)
|
||||
self.left_layout.addWidget(self.remote_statistics_group_box)
|
||||
|
||||
self.left_layout.addStretch()
|
||||
self.right_column.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred)
|
||||
self.right_layout.addStretch()
|
||||
# Set up the connections and things
|
||||
self.sync_type_radio_group.buttonToggled.connect(self.on_sync_type_radio_group_button_toggled)
|
||||
|
||||
def retranslate_ui(self):
|
||||
self.sync_type_group_box.setTitle(translate('RemotePlugin.RemoteSyncTab', 'Synchronization Type'))
|
||||
self.disabled_sync_radio.setText(translate('RemotePlugin.RemoteSyncTab', 'Disabled'))
|
||||
self.folder_sync_radio.setText(translate('RemotePlugin.RemoteSyncTab', 'Folder'))
|
||||
self.folder_label.setText(translate('RemotePlugin.RemoteSyncTab', 'Synchronization Folder'))
|
||||
self.ftp_sync_radio.setText(translate('RemotePlugin.RemoteSyncTab', 'FTP'))
|
||||
self.folder_settings_group_box.setTitle(translate('RemotePlugin.RemoteSyncTab', 'Folder Settings'))
|
||||
self.ftp_settings_group_box.setTitle(translate('RemotePlugin.RemoteSyncTab', 'FTP Settings'))
|
||||
self.ftp_unsecure_radio.setText(translate('RemotePlugin.RemoteSyncTab', 'FTP (unencrypted)'))
|
||||
self.ftp_secure_radio.setText(translate('RemotePlugin.RemoteSyncTab', 'FTPS (encrypted)'))
|
||||
#self.ftp_type_label.setText(translate('RemotePlugin.RemoteSyncTab', 'FTP Type:'))
|
||||
self.ftp_address_label.setText(translate('RemotePlugin.RemoteSyncTab', 'FTP server address:'))
|
||||
self.ftp_username_label.setText(translate('RemotePlugin.RemoteSyncTab', 'Username:'))
|
||||
self.ftp_pswd_label.setText(translate('RemotePlugin.RemoteSyncTab', 'Password:'))
|
||||
self.ftp_folder_label.setText(translate('RemotePlugin.RemoteSyncTab', 'FTP data folder:'))
|
||||
#self.actions_group_box.setTitle(translate('RemotePlugin.RemoteSyncTab', 'Actions'))
|
||||
#self.receive_songs_btn.setText(translate('RemotePlugin.RemoteSyncTab', 'Receive Songs'))
|
||||
#self.send_songs_btn.setText(translate('RemotePlugin.RemoteSyncTab', 'Send Songs'))
|
||||
self.remote_statistics_group_box.setTitle(translate('RemotePlugin.RemoteSyncTab', 'Remote Statistics'))
|
||||
self.update_policy_label.setText(translate('RemotePlugin.RemoteSyncTab', 'Update Policy:'))
|
||||
self.last_sync_label.setText(translate('RemotePlugin.RemoteSyncTab', 'Last Sync:'))
|
||||
|
||||
def load(self):
|
||||
"""
|
||||
Load the configuration and update the server configuration if necessary
|
||||
"""
|
||||
checked_radio_sync_type = self.sync_type_radio_group.button(self.settings.value('remotesync/type'))
|
||||
checked_radio_sync_type.setChecked(True)
|
||||
self.folder_path_edit.path = self.settings.value('remotesync/folder path')
|
||||
checked_radio_ftp_type = self.ftp_type_radio_group.button(self.settings.value('remotesync/ftp type'))
|
||||
checked_radio_ftp_type.setChecked(True)
|
||||
self.ftp_server_edit.setText(self.settings.value('remotesync/ftp server'))
|
||||
self.ftp_username_edit.setText(self.settings.value('remotesync/ftp username'))
|
||||
self.ftp_pswd_edit.setText(self.settings.value('remotesync/ftp password'))
|
||||
self.ftp_folder_edit.setText(self.settings.value('remotesync/ftp data folder'))
|
||||
#self.port_spin_box.setValue(Settings().value(self.settings_section + '/port'))
|
||||
#self.address_edit.setText(Settings().value(self.settings_section + '/ip address'))
|
||||
#self.auth_token.setText(Settings().value(self.settings_section + '/auth token'))
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Save the configuration and update the server configuration if necessary
|
||||
"""
|
||||
self.settings.setValue('remotesync/type', self.sync_type_radio_group.checkedId())
|
||||
self.settings.setValue('remotesync/folder path', self.folder_path_edit.path)
|
||||
self.settings.setValue('remotesync/ftp type', self.ftp_type_radio_group.checkedId())
|
||||
self.settings.setValue('remotesync/ftp server', self.ftp_server_edit.text())
|
||||
self.settings.setValue('remotesync/ftp username', self.ftp_username_edit.text())
|
||||
self.settings.setValue('remotesync/ftp password', self.ftp_pswd_edit.text())
|
||||
#Settings().setValue(self.settings_section + '/port', self.port_spin_box.value())
|
||||
#Settings().setValue(self.settings_section + '/ip address', self.address_edit.text())
|
||||
#Settings().setValue(self.settings_section + '/auth token', self.auth_token.text())
|
||||
self.generate_icon()
|
||||
|
||||
def on_sync_type_radio_group_button_toggled(self, button, checked):
|
||||
"""
|
||||
Handles the toggled signal on the radio buttons. The signal is emitted twice if a radio butting being toggled on
|
||||
causes another radio button in the group to be toggled off.
|
||||
|
||||
En/Disables the Sync type settings groups depending on the currently selected radio button
|
||||
|
||||
:param QtWidgets.QRadioButton button: The button that has toggled
|
||||
:param bool checked: The buttons new state
|
||||
"""
|
||||
group_id = self.sync_type_radio_group.id(button) # The work around (see above comment)
|
||||
self.folder_settings_group_box.setEnabled(group_id == SyncType.Folder)
|
||||
self.ftp_settings_group_box.setEnabled(group_id == SyncType.Ftp)
|
||||
|
||||
def on_send_songs_clicked(self):
|
||||
Registry().execute('synchronize_to_remote')
|
||||
#self.remote_synchronizer.send_all_songs()
|
||||
|
||||
def on_receive_songs_clicked(self):
|
||||
Registry().execute('synchronize_from_remote')
|
||||
#self.remote_synchronizer.receive_songs()
|
||||
|
||||
def generate_icon(self):
|
||||
"""
|
||||
Generate icon for main window
|
||||
"""
|
||||
self.remote_sync_icon.hide()
|
||||
icon = QtGui.QImage(':/remote/network_server.png')
|
||||
icon = icon.scaled(80, 80, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)
|
||||
self.remote_sync_icon.setPixmap(QtGui.QPixmap.fromImage(icon))
|
||||
self.remote_sync_icon.show()
|
|
@ -0,0 +1,547 @@
|
|||
openapi: 3.1.0
|
||||
info:
|
||||
title: OpenLP Remote Sync Api - OpenAPI 3.1
|
||||
description: |-
|
||||
This is the description for the OpenLP remote sync API.
|
||||
|
||||
Some useful links:
|
||||
- [The OpenLP remotesync MR](https://gitlab.com/openlp/openlp/-/merge_requests/9)
|
||||
contact:
|
||||
email: dev@openlp.io
|
||||
license:
|
||||
name: Apache 2.0
|
||||
url: http://www.apache.org/licenses/LICENSE-2.0.html
|
||||
version: 0.9.1
|
||||
externalDocs:
|
||||
description: Find out more about Swagger
|
||||
url: http://swagger.io
|
||||
servers:
|
||||
- url: http://localhost:8888/api/v1
|
||||
tags:
|
||||
- name: song
|
||||
description: Operations about songs
|
||||
- name: custom
|
||||
description: Operations about custom slides
|
||||
paths:
|
||||
/song-list:
|
||||
get:
|
||||
tags:
|
||||
- song
|
||||
summary: Get list of songs
|
||||
description: ''
|
||||
operationId: getSongList
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ItemList'
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ItemList'
|
||||
'400':
|
||||
description: Invalid uuid supplied
|
||||
'404':
|
||||
description: Song not found
|
||||
security:
|
||||
- openlp_auth:
|
||||
- write:song
|
||||
- read:song
|
||||
/song-list/changes/{sinceDateTime}:
|
||||
get:
|
||||
tags:
|
||||
- song
|
||||
summary: Get list of songs changed since given timestamp
|
||||
description: ''
|
||||
operationId: getSongListChangesSince
|
||||
parameters:
|
||||
- name: sinceDateTime
|
||||
in: path
|
||||
description: The timestamp to look for changes after
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ItemList'
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ItemList'
|
||||
'400':
|
||||
description: Invalid uuid supplied
|
||||
'404':
|
||||
description: Song not found
|
||||
security:
|
||||
- openlp_auth:
|
||||
- write:song
|
||||
- read:song
|
||||
/song/{uuid}:
|
||||
get:
|
||||
tags:
|
||||
- song
|
||||
summary: Get song by uuid
|
||||
description: Get song by uuid
|
||||
operationId: getSong
|
||||
parameters:
|
||||
- name: uuid
|
||||
in: path
|
||||
description: The uuid of the song that needs to be fetched.
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TextItem'
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TextItem'
|
||||
'400':
|
||||
description: Invalid uuid supplied
|
||||
'404':
|
||||
description: Song not found
|
||||
security:
|
||||
- openlp_auth:
|
||||
- write:song
|
||||
- read:song
|
||||
put:
|
||||
tags:
|
||||
- song
|
||||
summary: Update or create song
|
||||
description: This can only be done by the logged in user.
|
||||
operationId: updateSong
|
||||
parameters:
|
||||
- name: uuid
|
||||
in: path
|
||||
description: uuid of song that need to be deleted
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
description: Update an existent song in the db
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TextItem'
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TextItem'
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TextItem'
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
'409':
|
||||
description: A conflict occured. The version requested to be saved is not the newest.
|
||||
security:
|
||||
- openlp_auth:
|
||||
- write:song
|
||||
- read:song
|
||||
delete:
|
||||
tags:
|
||||
- song
|
||||
summary: Delete song
|
||||
description: This can only be done by the logged in user.
|
||||
operationId: deleteSong
|
||||
parameters:
|
||||
- name: uuid
|
||||
in: path
|
||||
description: The uuid of the song to delete
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'400':
|
||||
description: Invalid uuid supplied
|
||||
'404':
|
||||
description: Song not found
|
||||
security:
|
||||
- openlp_auth:
|
||||
- write:song
|
||||
- read:song
|
||||
/song/{uuid}/{version}:
|
||||
get:
|
||||
tags:
|
||||
- song
|
||||
summary: Get song by uuid and version
|
||||
description: Get song by uuid and version
|
||||
operationId: getSongVersion
|
||||
parameters:
|
||||
- name: uuid
|
||||
in: path
|
||||
description: The uuid of the song that needs to be fetched.
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: version
|
||||
in: path
|
||||
description: The version of the song that needs to be fetched.
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TextItem'
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TextItem'
|
||||
'400':
|
||||
description: Invalid uuid supplied
|
||||
'404':
|
||||
description: Song not found
|
||||
security:
|
||||
- openlp_auth:
|
||||
- write:song
|
||||
- read:song
|
||||
/song-history/{uuid}:
|
||||
get:
|
||||
tags:
|
||||
- song
|
||||
summary: Get history of song by uuid
|
||||
description: fwef
|
||||
operationId: getSongHistory
|
||||
parameters:
|
||||
- name: uuid
|
||||
in: path
|
||||
description: The uuid of the song that needs to be fetched.
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TextItem'
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TextItem'
|
||||
'400':
|
||||
description: Invalid uuid supplied
|
||||
'404':
|
||||
description: Song not found
|
||||
security:
|
||||
- openlp_auth:
|
||||
- write:song
|
||||
- read:song
|
||||
/custom-list:
|
||||
get:
|
||||
tags:
|
||||
- custom
|
||||
summary: Get list of custom slides
|
||||
description: ''
|
||||
operationId: getCustomslideList
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ItemList'
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ItemList'
|
||||
'400':
|
||||
description: Invalid uuid supplied
|
||||
'404':
|
||||
description: Custom slide not found
|
||||
security:
|
||||
- openlp_auth:
|
||||
- write:custom
|
||||
- read:custom
|
||||
/custom-list/changes/{sinceDateTime}:
|
||||
get:
|
||||
tags:
|
||||
- song
|
||||
summary: Get list of custom slides changed since given timestamp
|
||||
description: ''
|
||||
operationId: getCustomSlideListChangesSince
|
||||
parameters:
|
||||
- name: sinceDateTime
|
||||
in: path
|
||||
description: The timestamp to look for changes after
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ItemList'
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ItemList'
|
||||
'400':
|
||||
description: Invalid uuid supplied
|
||||
'404':
|
||||
description: Song not found
|
||||
security:
|
||||
- openlp_auth:
|
||||
- write:song
|
||||
- read:song
|
||||
/custom/{uuid}:
|
||||
get:
|
||||
tags:
|
||||
- custom
|
||||
summary: Get custom slide by uuid
|
||||
description: fwef
|
||||
operationId: getCustomslide
|
||||
parameters:
|
||||
- name: uuid
|
||||
in: path
|
||||
description: The uuid of the custom slide that needs to be fetched.
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TextItem'
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TextItem'
|
||||
'400':
|
||||
description: Invalid uuid supplied
|
||||
'404':
|
||||
description: Custom slide not found
|
||||
security:
|
||||
- openlp_auth:
|
||||
- write:custom
|
||||
- read:custom
|
||||
put:
|
||||
tags:
|
||||
- custom
|
||||
summary: Update or create custom slide
|
||||
description: This can only be done by the logged in user.
|
||||
operationId: updateCustomslide
|
||||
parameters:
|
||||
- name: uuid
|
||||
in: path
|
||||
description: uuid of custom slide that need to be deleted
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
description: Update an existent custom slide in the db
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TextItem'
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TextItem'
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TextItem'
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
'409':
|
||||
description: A conflict occured. The version requested to be saved is not the newest.
|
||||
security:
|
||||
- openlp_auth:
|
||||
- write:custom
|
||||
- read:custom
|
||||
delete:
|
||||
tags:
|
||||
- custom
|
||||
summary: Delete custom slide
|
||||
description: This can only be done by the logged in user.
|
||||
operationId: deleteCustomslide
|
||||
parameters:
|
||||
- name: uuid
|
||||
in: path
|
||||
description: The uuid of the custom slide to delete
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'400':
|
||||
description: Invalid uuid supplied
|
||||
'404':
|
||||
description: Custom slide not found
|
||||
security:
|
||||
- openlp_auth:
|
||||
- write:custom
|
||||
- read:custom
|
||||
/custom/{uuid}/{version}:
|
||||
get:
|
||||
tags:
|
||||
- custom
|
||||
summary: Get custom slide by uuid and version
|
||||
description: fwef
|
||||
operationId: getCustomslideVersion
|
||||
parameters:
|
||||
- name: uuid
|
||||
in: path
|
||||
description: The uuid of the custom slide that needs to be fetched.
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: version
|
||||
in: path
|
||||
description: The version of the custom slide that needs to be fetched.
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TextItem'
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TextItem'
|
||||
'400':
|
||||
description: Invalid uuid supplied
|
||||
'404':
|
||||
description: Custom slide not found
|
||||
security:
|
||||
- openlp_auth:
|
||||
- write:custom
|
||||
- read:custom
|
||||
/custom-history/{uuid}:
|
||||
get:
|
||||
tags:
|
||||
- custom
|
||||
summary: Get history of custom slide by uuid
|
||||
description: Get history of custom slide by uuid
|
||||
operationId: getCustomslideHistory
|
||||
parameters:
|
||||
- name: uuid
|
||||
in: path
|
||||
description: The uuid of the custom slide that needs to be fetched.
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TextItem'
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TextItem'
|
||||
'400':
|
||||
description: Invalid uuid supplied
|
||||
'404':
|
||||
description: Custom slide not found
|
||||
security:
|
||||
- openlp_auth:
|
||||
- write:custom
|
||||
- read:custom
|
||||
components:
|
||||
schemas:
|
||||
TextItem:
|
||||
type: object
|
||||
properties:
|
||||
uuid:
|
||||
type: string
|
||||
examples: ['43f74822-c620-40c7-8723-0136878bab23']
|
||||
version:
|
||||
type: integer
|
||||
format: int32
|
||||
examples: [2]
|
||||
user:
|
||||
type: string
|
||||
examples: ['user1', '354364']
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
title:
|
||||
type: string
|
||||
examples: ['Text item title']
|
||||
itemxml:
|
||||
type: string
|
||||
description: Text item xml
|
||||
examples: ['<xml>dsf</xml>']
|
||||
xml:
|
||||
name: textitem
|
||||
ItemList:
|
||||
type: object
|
||||
properties:
|
||||
list:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ItemInfo'
|
||||
ItemInfo:
|
||||
type: object
|
||||
properties:
|
||||
uuid:
|
||||
type: string
|
||||
examples: ['43f74822-c620-40c7-8723-0136878bab23']
|
||||
version:
|
||||
type: integer
|
||||
format: int32
|
||||
examples: [2]
|
||||
user:
|
||||
type: string
|
||||
examples: ['user1', '354364']
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
title:
|
||||
type: string
|
||||
examples: ['Text item title']
|
||||
ApiResponse:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: integer
|
||||
format: int32
|
||||
type:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
xml:
|
||||
name: '##default'
|
||||
requestBodies:
|
||||
Song:
|
||||
description: Song object that needs to be added to the db
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TextItem'
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TextItem'
|
||||
securitySchemes:
|
||||
openlp_auth:
|
||||
type: oauth2
|
||||
flows:
|
||||
implicit:
|
||||
authorizationUrl: http://localhost:8888/oauth/authorize
|
||||
scopes:
|
||||
write:songs: modify songs in your account
|
||||
read:songs: read your songs
|
||||
write:customs: modify custom slides in your account
|
||||
read:customs: read your custom slides
|
||||
api_key:
|
||||
type: apiKey
|
||||
name: api_key
|
||||
in: header
|
|
@ -0,0 +1,356 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
|
||||
|
||||
##########################################################################
|
||||
# OpenLP - Open Source Lyrics Projection #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Copyright (c) 2008-2024 OpenLP Developers #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
##########################################################################
|
||||
"""
|
||||
The RemoteSync plugin makes it possible to synchronize songs, custom slides and service files.
|
||||
There is currently 2 different Synchronizer backends: FolderSynchronizer and FtpSynchronizer.
|
||||
When synchronizing there is 3 things to do:
|
||||
1. Pull updates from the remote.
|
||||
2. Push updates to the remote.
|
||||
3. Handle conflicts.
|
||||
"""
|
||||
import logging
|
||||
import uuid
|
||||
import getpass
|
||||
import socket
|
||||
|
||||
from sqlalchemy.sql import and_
|
||||
from PyQt5 import QtWidgets, QtCore
|
||||
|
||||
from openlp.core.common.settings import Settings
|
||||
from openlp.core.common.registry import Registry
|
||||
from openlp.core.common.i18n import translate
|
||||
from openlp.core.lib.plugin import Plugin, StringContent
|
||||
from openlp.core.ui.icons import UiIcons
|
||||
from openlp.core.db.manager import DBManager
|
||||
from openlp.core.common.enum import SyncType
|
||||
from openlp.plugins.remotesync.lib.backends.synchronizer import SyncItemType, SyncItemAction, ConflictException, \
|
||||
LockException
|
||||
from openlp.core.state import State
|
||||
from openlp.plugins.custom.lib.db import CustomSlide
|
||||
from openlp.plugins.songs.lib.db import Song
|
||||
|
||||
from openlp.plugins.remotesync.lib import RemoteSyncTab
|
||||
from openlp.plugins.remotesync.lib.backends.foldersynchronizer import FolderSynchronizer
|
||||
from openlp.plugins.remotesync.lib.backends.ftpsynchronizer import FtpSynchronizer
|
||||
from openlp.plugins.remotesync.lib.db import init_schema, SyncQueueItem, RemoteSyncItem
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RemoteSyncPlugin(Plugin):
|
||||
log.info('RemoteSync Plugin loaded')
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
remotes constructor
|
||||
"""
|
||||
super(RemoteSyncPlugin, self).__init__('remotesync', None, RemoteSyncTab)
|
||||
self.weight = -1
|
||||
self.manager = DBManager('remotesync', init_schema)
|
||||
self.icon = UiIcons().network_stream
|
||||
self.icon_path = self.icon
|
||||
self.synchronizer = None
|
||||
self.sync_timer = QtCore.QTimer()
|
||||
self.sync_timer_disabled = False
|
||||
State().add_service('remote_sync', self.weight, is_plugin=True)
|
||||
State().update_pre_conditions('remote_sync', self.check_pre_conditions())
|
||||
|
||||
def check_pre_conditions(self):
|
||||
"""
|
||||
Check the plugin can run.
|
||||
"""
|
||||
return self.manager.session is not None
|
||||
|
||||
def initialise(self):
|
||||
"""
|
||||
Initialise the remotesync plugin
|
||||
"""
|
||||
log.debug('initialise')
|
||||
super(RemoteSyncPlugin, self).initialise()
|
||||
if not hasattr(self, 'remote_sync_icon'):
|
||||
self.remote_sync_icon = QtWidgets.QLabel(self.main_window.status_bar)
|
||||
size_policy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed)
|
||||
size_policy.setHorizontalStretch(0)
|
||||
size_policy.setVerticalStretch(0)
|
||||
size_policy.setHeightForWidth(self.remote_sync_icon.sizePolicy().hasHeightForWidth())
|
||||
self.remote_sync_icon.setSizePolicy(size_policy)
|
||||
self.remote_sync_icon.setFrameShadow(QtWidgets.QFrame.Plain)
|
||||
self.remote_sync_icon.setLineWidth(1)
|
||||
self.remote_sync_icon.setScaledContents(True)
|
||||
self.remote_sync_icon.setFixedSize(20, 20)
|
||||
self.remote_sync_icon.setObjectName('remote_sync_icon')
|
||||
self.main_window.status_bar.insertPermanentWidget(2, self.remote_sync_icon)
|
||||
self.settings_tab.remote_sync_icon = self.remote_sync_icon
|
||||
# Generate a pc id if not already done
|
||||
if not Settings().value('remotesync/folder pc id'):
|
||||
username = getpass.getuser()
|
||||
hostname = socket.gethostname()
|
||||
Settings().setValue('remotesync/folder pc id', '{u}-{h}'.format(u=username, h=hostname))
|
||||
self.settings_tab.generate_icon()
|
||||
sync_type = Settings().value('remotesync/type')
|
||||
if sync_type == SyncType.Folder:
|
||||
self.synchronizer = FolderSynchronizer(self.manager, Settings().value('remotesync/folder path'),
|
||||
Settings().value('remotesync/folder pc id'))
|
||||
elif sync_type == SyncType.Ftp:
|
||||
self.synchronizer = FtpSynchronizer(self.manager, Settings().value('remotesync/ftp data folder'),
|
||||
Settings().value('remotesync/folder pc id'),
|
||||
Settings().value('remotesync/ftp type'),
|
||||
Settings().value('remotesync/ftp server'),
|
||||
Settings().value('remotesync/ftp username'),
|
||||
Settings().value('remotesync/ftp password'))
|
||||
elif sync_type == SyncType.WebService:
|
||||
self.synchronizer = WebServiceSynchronizer()
|
||||
else:
|
||||
self.synchronizer = None
|
||||
if self.synchronizer and not self.synchronizer.check_connection():
|
||||
self.synchronizer.initialize_remote()
|
||||
# TODO: register delete functions
|
||||
Registry().register_function('song_changed', self.queue_song_for_sync)
|
||||
Registry().register_function('custom_changed', self.queue_custom_for_sync)
|
||||
Registry().register_function('service_changed', self.save_service)
|
||||
Registry().register_function('synchronize_to_remote', self.push_to_remote)
|
||||
Registry().register_function('synchronize_from_remote', self.pull_from_remote)
|
||||
Registry().register_function('song_deleted', self.queue_song_for_deletion)
|
||||
Registry().register_function('custom_deleted', self.queue_custom_for_deletion) # TODO: implement executing
|
||||
# prevent sync timer activation during startup check
|
||||
self.sync_timer_disabled = True
|
||||
self.startup_check()
|
||||
self.sync_timer_disabled = False
|
||||
self.sync_timer.timeout.connect(self.synchronize)
|
||||
self.sync_timer.setSingleShot(True)
|
||||
if self.synchronizer:
|
||||
# Set a timer to start the processing of the queue in 2 seconds
|
||||
self.sync_timer.start(2000)
|
||||
log.debug('remotesync init done')
|
||||
|
||||
def finalise(self):
|
||||
log.debug('finalise')
|
||||
super(RemoteSyncPlugin, self).finalise()
|
||||
|
||||
@staticmethod
|
||||
def about():
|
||||
"""
|
||||
Information about this plugin
|
||||
"""
|
||||
about_text = translate('RemoteSyncPlugin', '<strong>RemoteSync Plugin</strong>'
|
||||
'<br />The remotesync plugin provides the ability to synchronize '
|
||||
'songs, custom slides and service-files between multiple OpenLP '
|
||||
'instances.')
|
||||
return about_text
|
||||
|
||||
def set_plugin_text_strings(self):
|
||||
"""
|
||||
Called to define all translatable texts of the plugin
|
||||
"""
|
||||
# Name PluginList
|
||||
self.text_strings[StringContent.Name] = {
|
||||
'singular': translate('RemoteSyncPlugin', 'RemoteSync', 'name singular'),
|
||||
'plural': translate('RemoteSyncPlugin', 'RemotesSync', 'name plural')
|
||||
}
|
||||
# Name for MediaDockManager, SettingsManager
|
||||
self.text_strings[StringContent.VisibleName] = {
|
||||
'title': translate('RemoteSyncPlugin', 'RemoteSync', 'container title')
|
||||
}
|
||||
|
||||
def startup_check(self):
|
||||
"""
|
||||
Run through all songs and custom slides to see if they have been synchronized. Queue them if they have not
|
||||
"""
|
||||
song_manager = Registry().get('songs_manager')
|
||||
all_songs = song_manager.get_all_objects(Song)
|
||||
for song in all_songs:
|
||||
# TODO: Check that songs actually exists remotely - should we delete if not?
|
||||
synced_songs = self.manager.get_object_filtered(RemoteSyncItem,
|
||||
and_(RemoteSyncItem.type == SyncItemType.Song,
|
||||
RemoteSyncItem.item_id == song.id))
|
||||
if not synced_songs:
|
||||
self.queue_song_for_sync(song.id)
|
||||
|
||||
custom_manager = Registry().get('custom_manager')
|
||||
all_custom_slides = custom_manager.get_all_objects(CustomSlide)
|
||||
for custom in all_custom_slides:
|
||||
# TODO: Check that custom slide actually exists remotely - should we delete if not?
|
||||
synced_custom = self.manager.get_object_filtered(RemoteSyncItem,
|
||||
and_(RemoteSyncItem.type == SyncItemType.Song,
|
||||
RemoteSyncItem.item_id == custom.id))
|
||||
if not synced_custom:
|
||||
self.queue_custom_for_sync(custom.id)
|
||||
|
||||
def synchronize(self):
|
||||
"""
|
||||
Synchronize by first pulling data from remote and then pushing local changes to the remote
|
||||
"""
|
||||
if self.synchronizer:
|
||||
self.synchronizer.connect()
|
||||
self.pull_from_remote()
|
||||
self.push_to_remote()
|
||||
self.synchronizer.disconnect()
|
||||
# Set a timer to start the synchronization again in 1 minutes.
|
||||
self.sync_timer.start(6000)
|
||||
|
||||
def push_to_remote(self):
|
||||
"""
|
||||
Run through the queue and push songs and custom slides to remote
|
||||
"""
|
||||
queue_items = self.manager.get_all_objects(SyncQueueItem)
|
||||
song_manager = Registry().get('songs_manager')
|
||||
custom_manager = Registry().get('custom_manager')
|
||||
for queue_item in queue_items:
|
||||
sync_item = self.manager.get_object_filtered(RemoteSyncItem,
|
||||
and_(RemoteSyncItem.type == queue_item.type,
|
||||
RemoteSyncItem.item_id == queue_item.item_id))
|
||||
if queue_item.action == SyncItemAction.Update:
|
||||
if queue_item.type == SyncItemType.Song:
|
||||
item = song_manager.get_object(Song, queue_item.item_id)
|
||||
item_type = SyncItemType.Song
|
||||
else:
|
||||
item = custom_manager.get_object(CustomSlide, queue_item.item_id)
|
||||
item_type = SyncItemType.Custom
|
||||
# If item has not been sync'ed before we generate a uuid
|
||||
if not sync_item:
|
||||
sync_item = RemoteSyncItem()
|
||||
sync_item.type = item_type
|
||||
sync_item.item_id = item.id
|
||||
sync_item.uuid = str(uuid.uuid4())
|
||||
# Synchronize the item
|
||||
try:
|
||||
if queue_item.type == SyncItemType.Song:
|
||||
version = self.synchronizer.send_song(item, sync_item.uuid, sync_item.version,
|
||||
queue_item.first_attempt, queue_item.lock_id)
|
||||
else:
|
||||
version = self.synchronizer.send_custom(item, sync_item.uuid, sync_item.version,
|
||||
queue_item.first_attempt, queue_item.lock_id)
|
||||
except ConflictException:
|
||||
log.debug('Conflict detected for item %d / %s' % (sync_item.item_id, sync_item.uuid))
|
||||
# TODO: Store the conflict in the DB and turn on the conflict icon
|
||||
continue
|
||||
except LockException as le:
|
||||
# Store the lock time in the DB and keep it in the queue
|
||||
log.debug('Lock detected for item %d / %s' % (sync_item.item_id, sync_item.uuid))
|
||||
queue_item.first_attempt = le.first_attempt
|
||||
queue_item.lock_id = le.lock_id
|
||||
self.manager.save_object(queue_item)
|
||||
continue
|
||||
sync_item.version = version
|
||||
# Save the RemoteSyncItem so we know which version we have locally
|
||||
self.manager.save_object(sync_item, True)
|
||||
elif queue_item.action == SyncItemAction.Delete:
|
||||
# Delete the item
|
||||
try:
|
||||
if queue_item.type == SyncItemType.Song:
|
||||
version = self.synchronizer.delete_song(sync_item.uuid,
|
||||
queue_item.first_attempt, queue_item.lock_id)
|
||||
else:
|
||||
version = self.synchronizer.delete_custom(sync_item.uuid,
|
||||
queue_item.first_attempt, queue_item.lock_id)
|
||||
except ConflictException:
|
||||
log.debug('Conflict detected for item %d / %s' % (item.item_id, item.uuid))
|
||||
# TODO: Store the conflict in the DB and turn on the conflict icon
|
||||
continue
|
||||
except LockException as le:
|
||||
# Store the lock time in the DB and keep it in the queue
|
||||
log.debug('Lock detected for item %d / %s' % (item.item_id, item.uuid))
|
||||
queue_item.first_attempt = le.first_attempt
|
||||
queue_item.lock_id = le.lock_id
|
||||
self.manager.save_object(queue_item)
|
||||
continue
|
||||
# Delete the SyncQueueItem from the queue since the synchronization is now complete
|
||||
self.manager.delete_all_objects(SyncQueueItem, and_(SyncQueueItem.item_id == queue_item.item_id,
|
||||
SyncQueueItem.type == queue_item.type))
|
||||
|
||||
def start_short_sync_timer(self):
|
||||
if not self.sync_timer_disabled:
|
||||
if self.sync_timer.isActive():
|
||||
self.sync_timer.stop()
|
||||
self.sync_timer.start(2000)
|
||||
|
||||
def queue_song_for_sync(self, song_id):
|
||||
"""
|
||||
Put song in queue to be sync'ed
|
||||
:param song_id:
|
||||
"""
|
||||
# First check that the song isn't already in the queue
|
||||
queue_item = self.manager.get_object_filtered(SyncQueueItem, and_(SyncQueueItem.item_id == song_id,
|
||||
SyncQueueItem.type == SyncItemType.Song))
|
||||
if not queue_item:
|
||||
queue_item = SyncQueueItem()
|
||||
queue_item.item_id = song_id
|
||||
queue_item.type = SyncItemType.Song
|
||||
queue_item.action = SyncItemAction.Update
|
||||
self.manager.save_object(queue_item, True)
|
||||
self.start_short_sync_timer()
|
||||
|
||||
def queue_custom_for_sync(self, custom_id):
|
||||
"""
|
||||
Put custom slide in queue to be sync'ed
|
||||
:param custom_id:
|
||||
"""
|
||||
# First check that the custom slide isn't already in the queue
|
||||
queue_item = self.manager.get_object_filtered(SyncQueueItem, and_(SyncQueueItem.item_id == custom_id,
|
||||
SyncQueueItem.type == SyncItemType.Custom))
|
||||
if not queue_item:
|
||||
queue_item = SyncQueueItem()
|
||||
queue_item.item_id = custom_id
|
||||
queue_item.type = SyncItemType.Custom
|
||||
queue_item.action = SyncItemAction.Update
|
||||
self.manager.save_object(queue_item, True)
|
||||
self.start_short_sync_timer()
|
||||
|
||||
def queue_song_for_deletion(self, song_id):
|
||||
"""
|
||||
Put song in queue to be deleted
|
||||
:param song_id:
|
||||
"""
|
||||
queue_item = SyncQueueItem()
|
||||
queue_item.item_id = song_id
|
||||
queue_item.type = SyncItemType.Song
|
||||
queue_item.action = SyncItemAction.Delete
|
||||
self.manager.save_object(queue_item, True)
|
||||
self.start_short_sync_timer()
|
||||
|
||||
def queue_custom_for_deletion(self, custom_id):
|
||||
"""
|
||||
Put custom slide in queue to be deleted
|
||||
:param custom_id:
|
||||
"""
|
||||
queue_item = SyncQueueItem()
|
||||
queue_item.item_id = custom_id
|
||||
queue_item.type = SyncItemType.Song
|
||||
queue_item.action = SyncItemAction.Delete
|
||||
self.manager.save_object(queue_item, True)
|
||||
self.start_short_sync_timer()
|
||||
|
||||
def pull_from_remote(self):
|
||||
songs_updated = self.synchronizer.get_remote_song_changes()
|
||||
if songs_updated:
|
||||
Registry().execute('songs_load_list')
|
||||
customs_updated = self.synchronizer.get_remote_custom_changes()
|
||||
if customs_updated:
|
||||
Registry().execute('custom_load_list')
|
||||
|
||||
def save_service(self, service_item):
|
||||
pass
|
||||
|
||||
def handle_conflicts(self):
|
||||
# (Re)use the duplicate song UI to let the user manually handle the conflicts. Also allow for batch
|
||||
# processing, where either local or remote always wins.
|
||||
pass
|
Loading…
Reference in New Issue