1# Copyright (C) 2017-2020 Damon Lynch <damonlynch@gmail.com> 2 3# This file is part of Rapid Photo Downloader. 4# 5# Rapid Photo Downloader is free software: you can redistribute it and/or 6# modify it under the terms of the GNU General Public License as published by 7# the Free Software Foundation, either version 3 of the License, or 8# (at your option) any later version. 9# 10# Rapid Photo Downloader is distributed in the hope that it will be useful, 11# but WITHOUT ANY WARRANTY; without even the implied warranty of 12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13# GNU General Public License for more details. 14# 15# You should have received a copy of the GNU General Public License 16# along with Rapid Photo Downloader. If not, 17# see <http://www.gnu.org/licenses/>. 18 19""" 20Display backup preferences 21""" 22 23__author__ = 'Damon Lynch' 24__copyright__ = "Copyright 2017-2020, Damon Lynch" 25 26from typing import Optional, Dict, Tuple, Union, Set, List, DefaultDict 27import logging 28import os 29from collections import namedtuple, defaultdict 30 31from PyQt5.QtCore import (Qt, pyqtSlot, QAbstractListModel, QModelIndex, QSize) 32from PyQt5.QtWidgets import ( 33 QWidget, QSizePolicy, QVBoxLayout, QLabel, QLineEdit, QCheckBox, QScrollArea, QFrame, 34 QStyledItemDelegate, QStyleOptionViewItem, QStyle, QGroupBox, QHBoxLayout, QGridLayout 35) 36from PyQt5.QtGui import (QPainter, QFontMetrics, QFont, QColor, QPalette, QIcon) 37 38from raphodo.constants import ( 39 StandardFileLocations, ThumbnailBackgroundName, FileType, Roles, ViewRowType, 40 BackupLocationType 41) 42from raphodo.viewutils import (QFramedWidget, RowTracker) 43from raphodo.rpdfile import FileTypeCounter, Photo, Video 44from raphodo.panelview import QPanelView 45from raphodo.preferences import Preferences 46from raphodo.foldercombo import FolderCombo 47import raphodo.qrc_resources as qrc_resources 48from raphodo.storage import (ValidMounts, get_media_dir, StorageSpace, get_path_display_name) 49from raphodo.devices import (BackupDeviceCollection, BackupVolumeDetails) 50from raphodo.devicedisplay import (DeviceDisplay, BodyDetails, icon_size, DeviceView) 51from raphodo.destinationdisplay import make_body_details, adjusted_download_size 52from raphodo.storage import get_mount_size 53 54 55BackupVolumeUse = namedtuple( 56 'BackupVolumeUse', 'bytes_total bytes_free backup_type marked photos_size_to_download ' 57 'videos_size_to_download' 58) 59BackupViewRow = namedtuple('BackupViewRow', 'mount display_name backup_type os_stat_device') 60 61 62class BackupDeviceModel(QAbstractListModel): 63 """ 64 Stores 'devices' used for backing up photos and videos. 65 66 Want to display: 67 (1) destination on local files systems 68 (2) external devices, e.g. external hard drives 69 70 Need to account for when download destination is same file system 71 as backup destination. 72 """ 73 74 def __init__(self, parent) -> None: 75 super().__init__(parent) 76 self.raidApp = parent.rapidApp 77 self.prefs = parent.prefs 78 size = icon_size() 79 self.removableIcon = QIcon(':icons/drive-removable-media.svg').pixmap(size) 80 self.folderIcon = QIcon(':/icons/folder.svg').pixmap(size) 81 self._initValues() 82 83 def _initValues(self): 84 self.rows = RowTracker() # type: RowTracker 85 self.row_id_counter = 0 # type: int 86 # {row_id} 87 self.headers = set() # type: Set[int] 88 # path: BackupViewRow 89 self.backup_devices = dict() # type: Dict[str, BackupViewRow] 90 self.path_to_row_ids = defaultdict(list) # type: Dict[str, List[int]] 91 self.row_id_to_path = dict() # type: Dict[int, str] 92 93 self.marked = FileTypeCounter() 94 self.photos_size_to_download = self.videos_size_to_download = 0 95 96 # os_stat_device: Set[FileType] 97 self._downloading_to = defaultdict(list) # type: DefaultDict[int, Set[FileType]] 98 99 @property 100 def downloading_to(self): 101 return self._downloading_to 102 103 @downloading_to.setter 104 def downloading_to(self, downloading_to: DefaultDict[int, Set[FileType]]): 105 self._downloading_to = downloading_to 106 self.downloadSizeChanged() 107 108 def reset(self) -> None: 109 self.beginResetModel() 110 self._initValues() 111 self.endResetModel() 112 113 def columnCount(self, parent=QModelIndex()): 114 return 1 115 116 def rowCount(self, parent=QModelIndex()): 117 return max(len(self.rows), 1) 118 119 def insertRows(self, position, rows=2, index=QModelIndex()): 120 self.beginInsertRows(QModelIndex(), position, position + rows - 1) 121 self.endInsertRows() 122 return True 123 124 def removeRows(self, position, rows=2, index=QModelIndex()): 125 self.beginRemoveRows(QModelIndex(), position, position + rows - 1) 126 self.endRemoveRows() 127 return True 128 129 def addBackupVolume(self, mount_details: BackupVolumeDetails) -> None: 130 131 mount = mount_details.mount 132 display_name = mount_details.name 133 path = mount_details.path 134 backup_type = mount_details.backup_type 135 os_stat_device = mount_details.os_stat_device 136 137 assert mount is not None 138 assert display_name 139 assert path 140 assert backup_type 141 142 # two rows per device: header row, and detail row 143 row = len(self.rows) 144 self.insertRows(position=row) 145 logging.debug( 146 "Adding %s to backup device display with root path %s at rows %s - %s", 147 display_name, mount.rootPath(), row, row + 1 148 ) 149 150 for row_id in range(self.row_id_counter, self.row_id_counter + 2): 151 self.row_id_to_path[row_id] = path 152 self.rows[row] = row_id 153 row += 1 154 self.path_to_row_ids[path].append(row_id) 155 156 header_row_id = self.row_id_counter 157 self.headers.add(header_row_id) 158 159 self.row_id_counter += 2 160 161 self.backup_devices[path] = BackupViewRow( 162 mount=mount, display_name=display_name, 163 backup_type=backup_type, os_stat_device=os_stat_device 164 ) 165 166 def removeBackupVolume(self, path: str) -> None: 167 """ 168 :param path: the value of the volume (mount's path), NOT a 169 manually specified path! 170 """ 171 172 row_ids = self.path_to_row_ids[path] 173 header_row_id = row_ids[0] 174 row = self.rows.row(header_row_id) 175 logging.debug("Removing 2 rows from backup view, starting at row %s", row) 176 self.rows.remove_rows(row, 2) 177 self.headers.remove(header_row_id) 178 del self.path_to_row_ids[path] 179 del self.backup_devices[path] 180 for row_id in row_ids: 181 del self.row_id_to_path[row_id] 182 self.removeRows(row, 2) 183 184 def setDownloadAttributes(self, marked: FileTypeCounter, 185 photos_size: int, 186 videos_size: int, 187 merge: bool) -> None: 188 """ 189 Set the attributes used to generate the visual display of the 190 files marked to be downloaded 191 192 :param marked: number and type of files marked for download 193 :param photos_size: size in bytes of photos marked for download 194 :param videos_size: size in bytes of videos marked for download 195 :param merge: whether to replace or add to the current values 196 """ 197 198 if not merge: 199 self.marked = marked 200 self.photos_size_to_download = photos_size 201 self.videos_size_to_download = videos_size 202 else: 203 self.marked.update(marked) 204 self.photos_size_to_download += photos_size 205 self.videos_size_to_download += videos_size 206 self.downloadSizeChanged() 207 208 def downloadSizeChanged(self) -> None: 209 # TODO possibly optimize for photo vs video rows 210 for row in range(1, len(self.rows), 2): 211 self.dataChanged.emit(self.index(row, 0), self.index(row, 0)) 212 213 def _download_size_by_backup_type(self, backup_type: BackupLocationType) -> Tuple[int, int]: 214 """ 215 Include photos or videos in download size only if those file types 216 are being backed up to this backup device 217 :param backup_type: which file types are being backed up to this device 218 :return: photos_size_to_download, videos_size_to_download 219 """ 220 221 photos_size_to_download = videos_size_to_download = 0 222 if backup_type != BackupLocationType.videos: 223 photos_size_to_download = self.photos_size_to_download 224 if backup_type != BackupLocationType.photos: 225 videos_size_to_download = self.videos_size_to_download 226 return photos_size_to_download, videos_size_to_download 227 228 def data(self, index: QModelIndex, role=Qt.DisplayRole): 229 230 if not index.isValid(): 231 return None 232 233 row = index.row() 234 235 # check for special case where no backup devices are active 236 if len(self.rows) == 0: 237 if role == Qt.DisplayRole: 238 return ViewRowType.header 239 elif role == Roles.device_details: 240 if not self.prefs.backup_files: 241 return (_('Backups are not configured'), self.removableIcon) 242 elif self.prefs.backup_device_autodetection: 243 return (_('No backup devices detected'), self.removableIcon) 244 else: 245 return (_('Valid backup locations not yet specified'), self.folderIcon) 246 247 # at least one device / location is being used 248 if row >= len(self.rows) or row < 0: 249 return None 250 if row not in self.rows: 251 return None 252 253 row_id = self.rows[row] 254 path = self.row_id_to_path[row_id] 255 256 if role == Qt.DisplayRole: 257 if row_id in self.headers: 258 return ViewRowType.header 259 else: 260 return ViewRowType.content 261 else: 262 device = self.backup_devices[path] 263 mount = device.mount 264 265 if role == Qt.ToolTipRole: 266 return path 267 elif role == Roles.device_details: 268 if self.prefs.backup_device_autodetection: 269 icon = self.removableIcon 270 else: 271 icon = self.folderIcon 272 return device.display_name, icon 273 elif role == Roles.storage: 274 photos_size_to_download, videos_size_to_download = \ 275 self._download_size_by_backup_type(backup_type=device.backup_type) 276 277 photos_size_to_download, videos_size_to_download = adjusted_download_size( 278 photos_size_to_download=photos_size_to_download, 279 videos_size_to_download=videos_size_to_download, 280 os_stat_device=device.os_stat_device, 281 downloading_to=self._downloading_to) 282 283 bytes_total, bytes_free = get_mount_size(mount=mount) 284 285 return BackupVolumeUse( 286 bytes_total=bytes_total, 287 bytes_free=bytes_free, 288 backup_type=device.backup_type, 289 marked = self.marked, 290 photos_size_to_download=photos_size_to_download, 291 videos_size_to_download=videos_size_to_download 292 ) 293 294 return None 295 296 def sufficientSpaceAvailable(self) -> bool: 297 """ 298 Detect if each backup device has sufficient space for backing up, taking 299 into accoutn situations where downloads and backups are going to the same 300 partition. 301 302 :return: False if any backup device has insufficient space, else True. 303 True if there are no backup devices. 304 """ 305 306 for device in self.backup_devices.values(): 307 photos_size_to_download, videos_size_to_download = \ 308 self._download_size_by_backup_type(backup_type=device.backup_type) 309 photos_size_to_download, videos_size_to_download = adjusted_download_size( 310 photos_size_to_download=photos_size_to_download, 311 videos_size_to_download=videos_size_to_download, 312 os_stat_device=device.os_stat_device, 313 downloading_to=self._downloading_to 314 ) 315 316 bytes_total, bytes_free = get_mount_size(mount=device.mount) 317 if photos_size_to_download + videos_size_to_download >= bytes_free: 318 return False 319 return True 320 321 322class BackupDeviceView(DeviceView): 323 def __init__(self, rapidApp, parent=None) -> None: 324 super().__init__(rapidApp, parent) 325 self.setMouseTracking(False) 326 self.entered.disconnect() 327 328 329class BackupDeviceDelegate(QStyledItemDelegate): 330 def __init__(self, rapidApp, parent=None) -> None: 331 super().__init__(parent) 332 self.rapidApp = rapidApp 333 self.deviceDisplay = DeviceDisplay() 334 335 def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None: 336 painter.save() 337 338 x = option.rect.x() 339 y = option.rect.y() 340 width = option.rect.width() 341 342 view_type = index.data(Qt.DisplayRole) # type: ViewRowType 343 if view_type == ViewRowType.header: 344 display_name, icon = index.data(Roles.device_details) 345 346 self.deviceDisplay.paint_header( 347 painter=painter, x=x, y=y, width=width, icon=icon, display_name=display_name 348 ) 349 else: 350 assert view_type == ViewRowType.content 351 352 data = index.data(Roles.storage) # type: BackupVolumeUse 353 details = make_body_details( 354 bytes_total=data.bytes_total, 355 bytes_free=data.bytes_free, 356 files_to_display=data.backup_type, 357 marked=data.marked, 358 photos_size_to_download=data.photos_size_to_download, 359 videos_size_to_download=data.videos_size_to_download 360 ) 361 362 self.deviceDisplay.paint_body( 363 painter=painter, x=x, y=y, width=width, details=details 364 ) 365 366 painter.restore() 367 368 def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: 369 view_type = index.data(Qt.DisplayRole) # type: ViewRowType 370 if view_type == ViewRowType.header: 371 height = self.deviceDisplay.device_name_height 372 else: 373 storage_space = index.data(Roles.storage) 374 375 if storage_space is None: 376 height = self.deviceDisplay.base_height 377 else: 378 height = self.deviceDisplay.storage_height 379 return QSize(self.deviceDisplay.view_width, height) 380 381 382class BackupOptionsWidget(QFramedWidget): 383 """ 384 Display and allow editing of preference values for Downloads today 385 and Stored Sequence Number and associated options, as well as 386 the strip incompatible characters option. 387 """ 388 389 def __init__(self, prefs: Preferences, parent, rapidApp) -> None: 390 super().__init__(parent) 391 392 self.rapidApp = rapidApp 393 self.prefs = prefs 394 self.media_dir = get_media_dir() 395 396 self.setBackgroundRole(QPalette.Base) 397 self.setAutoFillBackground(True) 398 399 backupLayout = QGridLayout() 400 layout = QVBoxLayout() 401 layout.addLayout(backupLayout) 402 self.setLayout(layout) 403 404 self.backupExplanation = QLabel( 405 _( 406 'You can have your photos and videos backed up to ' 407 'multiple locations as they are downloaded, e.g. ' 408 'external hard drives.' 409 ) 410 ) 411 self.backupExplanation.setWordWrap(True) 412 self.backupExplanation.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Minimum) 413 414 self.backup = QCheckBox(_('Back up photos and videos when downloading')) 415 self.backup.setChecked(self.prefs.backup_files) 416 self.backup.stateChanged.connect(self.backupChanged) 417 418 checkbox_width = self.backup.style().pixelMetric(QStyle.PM_IndicatorWidth) 419 420 self.autoBackup = QCheckBox(_('Automatically detect backup devices')) 421 self.autoBackup.setChecked(self.prefs.backup_device_autodetection) 422 self.autoBackup.stateChanged.connect(self.autoBackupChanged) 423 424 self.folderExplanation = QLabel( 425 _( 426 'Specify the folder in which backups are stored on the ' 427 'device.<br><br>' 428 '<i>Note: the presence of a folder with this name ' 429 'is used to determine if the device is used for backups. ' 430 'For each device you wish to use for backing up to, ' 431 'create a folder in it with one of these folder names. ' 432 'By adding both folders, the same device can be used ' 433 'to back up both photos and videos.</i>' 434 ) 435 ) 436 self.folderExplanation.setWordWrap(True) 437 self.folderExplanation.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Minimum) 438 # Unless this next call is made, for some reason the widget is too high! :-( 439 self.folderExplanation.setContentsMargins(0, 0, 1, 0) 440 441 self.photoFolderNameLabel = QLabel(_('Photo folder name:')) 442 self.photoFolderName = QLineEdit() 443 self.photoFolderName.setText(self.prefs.photo_backup_identifier) 444 self.photoFolderName.editingFinished.connect(self.photoFolderIdentifierChanged) 445 446 self.videoFolderNameLabel = QLabel(_('Video folder name:')) 447 self.videoFolderName = QLineEdit() 448 self.videoFolderName.setText(self.prefs.video_backup_identifier) 449 self.videoFolderName.editingFinished.connect(self.videoFolderIdentifierChanged) 450 451 self.autoBackupExampleBox = QGroupBox(_('Example:')) 452 self.autoBackupExample = QLabel() 453 454 autoBackupExampleBoxLayout = QHBoxLayout() 455 autoBackupExampleBoxLayout.addWidget(self.autoBackupExample) 456 457 self.autoBackupExampleBox.setLayout(autoBackupExampleBoxLayout) 458 459 valid_mounts = ValidMounts(onlyExternalMounts=True) 460 461 self.manualLocationExplanation = QLabel( 462 _('If you disable automatic detection, choose the exact backup locations.') 463 ) 464 self.manualLocationExplanation.setWordWrap(True) 465 self.manualLocationExplanation.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Minimum) 466 # Translators: the word 'location' is optional in your translation. The left 467 # side of the folder chooser combo box will always line up with the left side of the 468 # the text entry boxes where the user can enter the photo folder name and the video 469 # folder name. See http://damonlynch.net/rapid/documentation/thumbnails/backup.png 470 self.photoLocationLabel = QLabel(_('Photo backup location:')) 471 self.photoLocationLabel.setWordWrap(True) 472 self.photoLocation = FolderCombo( 473 self, 474 prefs=self.prefs, 475 file_type=FileType.photo, 476 file_chooser_title=_('Select Photo Backup Location'), 477 special_dirs=(StandardFileLocations.pictures,), 478 valid_mounts=valid_mounts 479 ) 480 self.photoLocation.setPath(self.prefs.backup_photo_location) 481 self.photoLocation.pathChosen.connect(self.photoPathChosen) 482 483 # Translators: the word 'location' is optional in your translation. The left 484 # side of the folder chooser combo box will always line up with the left side of the 485 # the text entry boxes where the user can enter the photo folder name and the video 486 # folder name. See http://damonlynch.net/rapid/documentation/thumbnails/backup.png 487 self.videoLocationLabel = QLabel(_('Video backup location:')) 488 self.videoLocationLabel.setWordWrap(True) 489 self.videoLocation = FolderCombo( 490 self, 491 prefs=self.prefs, 492 file_type=FileType.video, 493 file_chooser_title=_('Select Video Backup Location'), 494 special_dirs=(StandardFileLocations.videos, ), 495 valid_mounts=valid_mounts 496 ) 497 self.videoLocation.setPath(self.prefs.backup_video_location) 498 self.videoLocation.pathChosen.connect(self.videoPathChosen) 499 500 backupLayout.addWidget(self.backupExplanation, 0, 0, 1, 4) 501 backupLayout.addWidget(self.backup, 1, 0, 1, 4) 502 backupLayout.addWidget(self.autoBackup, 2, 1, 1, 3) 503 backupLayout.addWidget(self.folderExplanation, 3, 2, 1, 2) 504 backupLayout.addWidget(self.photoFolderNameLabel, 4, 2, 1, 1) 505 backupLayout.addWidget(self.photoFolderName, 4, 3, 1, 1) 506 backupLayout.addWidget(self.videoFolderNameLabel, 5, 2, 1, 1) 507 backupLayout.addWidget(self.videoFolderName, 5, 3, 1, 1) 508 backupLayout.addWidget(self.autoBackupExampleBox, 6, 2, 1, 2) 509 backupLayout.addWidget(self.manualLocationExplanation, 7, 1, 1, 3, Qt.AlignBottom) 510 backupLayout.addWidget(self.photoLocationLabel, 8, 1, 1, 2) 511 backupLayout.addWidget(self.photoLocation, 8, 3, 1, 1) 512 backupLayout.addWidget(self.videoLocationLabel, 9, 1, 1, 2) 513 backupLayout.addWidget(self.videoLocation, 9, 3, 1, 1) 514 515 backupLayout.setColumnMinimumWidth(0, checkbox_width) 516 backupLayout.setColumnMinimumWidth(1, checkbox_width) 517 518 backupLayout.setRowMinimumHeight(7, checkbox_width * 2) 519 520 layout.addStretch() 521 522 self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Expanding) 523 self.setBackupButtonHighlight() 524 525 # Group controls to enable / disable sets of them 526 self._backup_controls_type = (self.autoBackup, ) 527 self._backup_controls_auto = ( 528 self.folderExplanation, self.photoFolderNameLabel, self.photoFolderName, 529 self.videoFolderNameLabel, self.videoFolderName, self.autoBackupExampleBox 530 ) 531 self._backup_controls_manual = ( 532 self.manualLocationExplanation, self.photoLocationLabel, self.photoLocation, 533 self.videoLocationLabel, self.videoLocation, 534 ) 535 self.updateExample() 536 self.enableControlsByBackupType() 537 538 @pyqtSlot(int) 539 def backupChanged(self, state: int) -> None: 540 backup = state == Qt.Checked 541 logging.info("Setting backup while downloading to %s", backup) 542 self.prefs.backup_files = backup 543 self.setBackupButtonHighlight() 544 self.enableControlsByBackupType() 545 self.rapidApp.resetupBackupDevices() 546 547 @pyqtSlot(int) 548 def autoBackupChanged(self, state: int) -> None: 549 autoBackup = state == Qt.Checked 550 logging.info("Setting automatically detect backup devices to %s", autoBackup) 551 self.prefs.backup_device_autodetection = autoBackup 552 self.setBackupButtonHighlight() 553 self.enableControlsByBackupType() 554 self.rapidApp.resetupBackupDevices() 555 556 @pyqtSlot(str) 557 def photoPathChosen(self, path: str) -> None: 558 logging.info("Setting backup photo location to %s", path) 559 self.prefs.backup_photo_location = path 560 self.setBackupButtonHighlight() 561 self.rapidApp.resetupBackupDevices() 562 563 @pyqtSlot(str) 564 def videoPathChosen(self, path: str) -> None: 565 logging.info("Setting backup video location to %s", path) 566 self.prefs.backup_video_location = path 567 self.setBackupButtonHighlight() 568 self.rapidApp.resetupBackupDevices() 569 570 @pyqtSlot() 571 def photoFolderIdentifierChanged(self) -> None: 572 name = self.photoFolderName.text() 573 logging.info("Setting backup photo folder name to %s", name) 574 self.prefs.photo_backup_identifier = name 575 self.setBackupButtonHighlight() 576 self.rapidApp.resetupBackupDevices() 577 578 @pyqtSlot() 579 def videoFolderIdentifierChanged(self) -> None: 580 name = self.videoFolderName.text() 581 logging.info("Setting backup video folder name to %s", name) 582 self.prefs.video_backup_identifier = name 583 self.setBackupButtonHighlight() 584 self.rapidApp.resetupBackupDevices() 585 586 def updateExample(self) -> None: 587 """ 588 Update the example paths in the backup panel 589 """ 590 591 if self.autoBackup.isChecked() and hasattr(self.rapidApp, 'backup_devices') and len( 592 self.rapidApp.backup_devices): 593 drives = self.rapidApp.backup_devices.sample_device_paths() 594 else: 595 # Translators: this value is used as an example device when automatic backup device 596 # detection is enabled. You should translate this. 597 drive1 = os.path.join(self.media_dir, _("drive1")) 598 # Translators: this value is used as an example device when automatic backup device 599 # detection is enabled. You should translate this. 600 drive2 = os.path.join(self.media_dir, _("drive2")) 601 drives = ( 602 os.path.join(path, identifier) for path, identifier in ( 603 (drive1, self.prefs.photo_backup_identifier), 604 (drive2, self.prefs.photo_backup_identifier), 605 (drive2, self.prefs.video_backup_identifier) 606 ) 607 ) 608 paths = '\n'.join(drives) 609 self.autoBackupExample.setText(paths) 610 611 def setBackupButtonHighlight(self) -> None: 612 """ 613 Indicate error status in GUI by highlighting Backup button. 614 615 Do so only if doing manual backups and there is a problem with one of the paths 616 """ 617 618 self.rapidApp.backupButton.setHighlighted( 619 self.prefs.backup_files and not self.prefs.backup_device_autodetection and ( 620 self.photoLocation.invalid_path or self.videoLocation.invalid_path)) 621 622 def enableControlsByBackupType(self) -> None: 623 """ 624 Enable or disable backup controls depending on what the user 625 has enabled. 626 """ 627 628 backupsEnabled = self.backup.isChecked() 629 autoEnabled = backupsEnabled and self.autoBackup.isChecked() 630 manualEnabled = not autoEnabled and backupsEnabled 631 632 for widget in self._backup_controls_type: 633 widget.setEnabled(backupsEnabled) 634 for widget in self._backup_controls_manual: 635 widget.setEnabled(manualEnabled) 636 for widget in self._backup_controls_auto: 637 widget.setEnabled(autoEnabled) 638 639 def updateLocationCombos(self) -> None: 640 """ 641 Update backup locatation comboboxes in case directory status has changed. 642 """ 643 for combo in self.photoLocation, self.videoLocation: 644 combo.refreshFolderList() 645 646 647class BackupPanel(QScrollArea): 648 """ 649 Backup preferences widget, for photos and video backups while 650 downloading. 651 """ 652 653 def __init__(self, parent) -> None: 654 super().__init__(parent) 655 656 assert parent is not None 657 self.rapidApp = parent 658 self.prefs = self.rapidApp.prefs # type: Preferences 659 660 self.backupDevices = BackupDeviceModel(parent=self) 661 662 self.setFrameShape(QFrame.NoFrame) 663 664 self.backupStoragePanel = QPanelView( 665 label=_('Projected Backup Storage Use'), 666 headerColor=QColor(ThumbnailBackgroundName), 667 headerFontColor=QColor(Qt.white) 668 ) 669 670 self.backupOptionsPanel = QPanelView( 671 label=_('Backup Options'), 672 headerColor=QColor(ThumbnailBackgroundName), 673 headerFontColor=QColor(Qt.white) 674 ) 675 676 self.backupDevicesView = BackupDeviceView(rapidApp=self.rapidApp, parent=self) 677 self.backupStoragePanel.addWidget(self.backupDevicesView) 678 self.backupDevicesView.setModel(self.backupDevices) 679 self.backupDevicesView.setItemDelegate(BackupDeviceDelegate(rapidApp=self.rapidApp)) 680 self.backupDevicesView.setSizePolicy( 681 QSizePolicy.MinimumExpanding, QSizePolicy.Fixed 682 ) 683 self.backupOptionsPanel.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.MinimumExpanding) 684 685 self.backupOptions = BackupOptionsWidget( 686 prefs=self.prefs, parent=self, rapidApp=self.rapidApp 687 ) 688 self.backupOptionsPanel.addWidget(self.backupOptions) 689 690 widget = QWidget() 691 layout = QVBoxLayout() 692 layout.setContentsMargins(0, 0, 0, 0) 693 widget.setLayout(layout) 694 layout.addWidget(self.backupStoragePanel) 695 layout.addWidget(self.backupOptionsPanel) 696 # layout.addStretch() 697 self.setWidget(widget) 698 self.setWidgetResizable(True) 699 self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) 700 701 def updateExample(self) -> None: 702 """ 703 Update the example paths in the backup panel 704 """ 705 706 self.backupOptions.updateExample() 707 708 def updateLocationCombos(self) -> None: 709 """ 710 Update backup locatation comboboxes in case directory status has changed. 711 """ 712 713 self.backupOptions.updateLocationCombos() 714 715 def addBackupVolume(self, mount_details: BackupVolumeDetails) -> None: 716 self.backupDevices.addBackupVolume(mount_details=mount_details) 717 self.backupDevicesView.updateGeometry() 718 719 def removeBackupVolume(self, path: str) -> None: 720 self.backupDevices.removeBackupVolume(path=path) 721 self.backupDevicesView.updateGeometry() 722 723 def resetBackupDisplay(self) -> None: 724 self.backupDevices.reset() 725 self.backupDevicesView.updateGeometry() 726 727 def setupBackupDisplay(self) -> None: 728 """ 729 Sets up the backup view list regardless of whether backups 730 are manual specified by the user, or auto-detection is on 731 """ 732 733 if not self.prefs.backup_files: 734 logging.debug("No backups configured: no backup destinations to display") 735 return 736 737 backup_devices = self.rapidApp.backup_devices # type: BackupDeviceCollection 738 if self.prefs.backup_device_autodetection: 739 for path in backup_devices: 740 self.backupDevices.addBackupVolume( 741 mount_details=backup_devices.get_backup_volume_details(path=path)) 742 else: 743 # manually specified backup paths 744 try: 745 mounts = backup_devices.get_manual_mounts() 746 if mounts is None: 747 return 748 749 self.backupDevices.addBackupVolume(mount_details=mounts[0]) 750 if len(mounts) > 1: 751 self.backupDevices.addBackupVolume(mount_details=mounts[1]) 752 except Exception: 753 logging.exception( 754 'An unexpected error occurred when adding backup destinations. Exception:' 755 ) 756 self.backupDevicesView.updateGeometry() 757 758 def setDownloadAttributes(self, marked: FileTypeCounter, 759 photos_size: int, 760 videos_size: int, 761 merge: bool) -> None: 762 """ 763 Set the attributes used to generate the visual display of the 764 files marked to be downloaded 765 766 :param marked: number and type of files marked for download 767 :param photos_size: size in bytes of photos marked for download 768 :param videos_size: size in bytes of videos marked for download 769 :param merge: whether to replace or add to the current values 770 """ 771 772 self.backupDevices.setDownloadAttributes( 773 marked=marked, photos_size=photos_size, videos_size=videos_size, merge=merge 774 ) 775 776 def sufficientSpaceAvailable(self) -> bool: 777 """ 778 Check to see that there is sufficient space with which to perform a download. 779 780 :return: True or False value if sufficient space. Will always return True if 781 backups are disabled or there are no backup devices. 782 """ 783 if self.prefs.backup_files: 784 return self.backupDevices.sufficientSpaceAvailable() 785 else: 786 return True 787 788 def setDownloadingTo(self, downloading_to: DefaultDict[int, Set[FileType]]) -> None: 789 self.backupDevices.downloading_to = downloading_to 790 791