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