1""" Databases update widget """
2import os
3import sys
4import json
5import threading
6from shutil import copyfile
7from datetime import datetime as d_time
8from functools import partial
9from collections import OrderedDict, namedtuple
10
11from requests.exceptions import Timeout, ConnectionError
12
13from AnyQt.QtCore import Qt, QSize, Signal, QThreadPool
14from AnyQt.QtWidgets import (
15    QLabel,
16    QDialog,
17    QWidget,
18    QCheckBox,
19    QComboBox,
20    QLineEdit,
21    QFileDialog,
22    QHBoxLayout,
23    QPushButton,
24    QToolButton,
25    QTreeWidget,
26    QVBoxLayout,
27    QApplication,
28    QTreeWidgetItem,
29    QDialogButtonBox,
30    QAbstractItemView,
31    QStyledItemDelegate,
32)
33
34from Orange.widgets import gui
35from Orange.widgets.widget import OWWidget
36
37from orangecontrib.bioinformatics.utils import serverfiles
38from orangecontrib.bioinformatics.geneset import DOMAIN as gene_sets_domain
39from orangecontrib.bioinformatics.geneset import filename
40from orangecontrib.bioinformatics.go.config import DOMAIN as gene_ontology_domain
41from orangecontrib.bioinformatics.go.config import FILENAME_ANNOTATION
42from orangecontrib.bioinformatics.ncbi.taxonomy import common_taxids, common_taxid_to_name, species_name_to_taxid
43from orangecontrib.bioinformatics.widgets.utils.gui import TokenListCompleter
44from orangecontrib.bioinformatics.widgets.utils.concurrent import Worker
45
46# File states
47AVAILABLE, CURRENT, OUTDATED, DEPRECATED, USER_FILE = range(5)
48# File sources
49SOURCE_SERVER = 'server_file'  # files on the serverfiles-bio repository
50SOURCE_USER = 'user_file'  # user defined files
51INFO_FILE_SCHEMA = {
52    'domain': None,
53    'filename': None,
54    'source': None,
55    'title': None,
56    'tags': [],
57    'size': None,
58    'datetime': None
59    # used only if files are compressed
60    # 'uncompressed': None,
61    # 'compression': None,
62}
63
64
65def file_size_bytes(file_path):
66    """ returns file size in bytes """
67    return os.stat(file_path).st_size
68
69
70def create_info_file(file_path, **kwargs):
71    info_dict = OrderedDict(INFO_FILE_SCHEMA)
72
73    info_dict.update(**kwargs)
74    info_dict['datetime'] = '{0:%Y-%m-%d %H:%M:%S.%f}'.format(d_time.today())
75    info_dict['size'] = file_size_bytes(file_path)
76
77    with open(file_path + '.info', 'wt') as f:
78        json.dump(info_dict, f)
79
80
81def create_folder(path):
82    try:
83        os.makedirs(path)
84    except OSError:
85        if os.path.exists(path):
86            pass
87        else:
88            # There was an error on creation, so make sure we know about it
89            raise
90
91
92class UpdateOptionsItemDelegate(QStyledItemDelegate):
93    """ An item delegate for the updates tree widget.
94
95    note:
96        Must be a child of a QTreeWidget.
97
98    """
99
100    def sizeHint(self, option, index):
101        size = QStyledItemDelegate.sizeHint(self, option, index)
102        parent = self.parent()
103        item = parent.itemFromIndex(index)
104        widget = parent.itemWidget(item, 0)
105        if widget:
106            size = QSize(size.width(), widget.sizeHint().height() / 2)
107        return size
108
109
110file_state = namedtuple('file_state', ['info_local', 'info_server', 'state'])
111
112header_labels = ['', 'Title', 'Update', 'Updated', 'Size', 'Source']
113header_index = namedtuple('header_index', ['Download'] + header_labels[1:])
114header = header_index(*[index for index, _ in enumerate(header_labels)])
115
116
117def UpdateItem_match(item, string):
118    """
119    Return `True` if the `UpdateItem` item contains a string in tags
120    or in the title.
121
122    """
123    string = string.lower()
124    return any(string.lower() in tag.lower() for tag in item.tags + [item.title])
125
126
127def evaluate_all_info(local, server):
128    """ Return FileState
129
130    Args:
131        local: info files from LocalFiles
132        server: info files from ServerFiles
133
134    """
135    files = set(local.keys()).union(server.keys())
136
137    for domain, file_name in sorted(files):
138        yield FileState(domain, file_name, server.get((domain, file_name), None), local.get((domain, file_name), None))
139
140
141def evaluate_files_state(progress_callback):
142    progress_callback.emit()
143    files = []
144
145    # fetch remote info
146    try:
147        server_info = serverfiles.ServerFiles().allinfo()
148    except (Timeout, ConnectionError) as e:
149        raise e
150    progress_callback.emit()
151
152    # fetch local info
153    local_info = serverfiles.allinfo()
154
155    all_info = set(local_info.keys()).union(server_info.keys())
156
157    for domain, file_name in sorted(all_info):
158        files.append(
159            FileState(
160                domain,
161                file_name,
162                server_info.get((domain, file_name), None),
163                local_info.get((domain, file_name), None),
164            )
165        )
166    progress_callback.emit()
167    return files
168
169
170def download_server_file(fs, index, progress_callback):
171    try:
172        serverfiles.download(fs.domain, fs.filename, callback=progress_callback.emit)
173    except Exception:
174        # send FileState and index with Exception
175        raise ValueError(fs, index)
176
177    return fs, index
178
179
180class OWDatabasesUpdate(OWWidget):
181
182    name = "Databases Update"
183    description = "Update local systems biology databases."
184    icon = "../widgets/icons/OWDatabasesUpdate.svg"
185    priority = 1
186
187    inputs = []
188    outputs = []
189
190    want_main_area = False
191
192    def __init__(self, parent=None, signalManager=None, name="Databases update"):
193        OWWidget.__init__(self, parent, signalManager, name, wantMainArea=False)
194
195        self.searchString = ""
196
197        fbox = gui.widgetBox(self.controlArea, "Filter")
198        self.completer = TokenListCompleter(self, caseSensitivity=Qt.CaseInsensitive)
199        self.lineEditFilter = QLineEdit(textChanged=self.search_update)
200        self.lineEditFilter.setCompleter(self.completer)
201
202        fbox.layout().addWidget(self.lineEditFilter)
203
204        box = gui.widgetBox(self.controlArea, "Files")
205        self.filesView = QTreeWidget(self)
206        self.filesView.setHeaderLabels(header_labels)
207        self.filesView.setRootIsDecorated(False)
208        self.filesView.setUniformRowHeights(True)
209        self.filesView.setSelectionMode(QAbstractItemView.NoSelection)
210        self.filesView.setSortingEnabled(True)
211        self.filesView.sortItems(header.Title, Qt.AscendingOrder)
212        self.filesView.setItemDelegateForColumn(0, UpdateOptionsItemDelegate(self.filesView))
213
214        self.filesView.model().layoutChanged.connect(self.search_update)
215
216        box.layout().addWidget(self.filesView)
217
218        layout = QHBoxLayout()
219        gui.widgetBox(self.controlArea, margin=0, orientation=layout)
220
221        self.updateButton = gui.button(
222            box, self, "Update all", callback=self.update_all, tooltip="Update all updatable files"
223        )
224
225        self.downloadButton = gui.button(
226            box, self, "Download all", callback=self.download_filtered, tooltip="Download all filtered files shown"
227        )
228
229        self.cancelButton = gui.button(
230            box, self, "Cancel", callback=self.cancel_active_threads, tooltip="Cancel scheduled downloads/updates."
231        )
232
233        self.addButton = gui.button(
234            box, self, "Add ...", callback=self.__handle_dialog, tooltip="Add files for personal use."
235        )
236
237        layout.addWidget(self.updateButton)
238        layout.addWidget(self.downloadButton)
239        layout.addWidget(self.cancelButton)
240        layout.addStretch()
241        layout.addWidget(self.addButton)
242
243        # Enable retryButton once connection is established
244        # self.retryButton = gui.button(
245        #     box, self, "Reconnect", callback=self.initialize_files_view
246        # )
247        # self.retryButton.hide()
248
249        self.resize(800, 600)
250
251        self.update_items = []
252        self._dialog = None
253        self.progress_bar = None
254
255        # threads
256        self.threadpool = QThreadPool(self)
257        # self.threadpool.setMaxThreadCount(1)
258        self.workers = []
259
260        self.initialize_files_view()
261
262    def __handle_dialog(self):
263        if not self._dialog:
264            self._dialog = FileUploadHelper(self)
265        self._dialog.show()
266
267    def __progress_advance(self):
268        # GUI should be updated in main thread. That's why we are calling advance method here
269        if self.progress_bar:
270            self.progress_bar.advance()
271
272    def handle_worker_exception(self, ex):
273        self.progress_bar.finish()
274        self.setStatusMessage('')
275
276        if isinstance(ex, ConnectionError):
277            # TODO: set warning messages
278            pass
279
280        print(ex)
281
282    def initialize_files_view(self):
283        # self.retryButton.hide()
284
285        # clear view
286        self.filesView.clear()
287        # init progress bar
288        self.progress_bar = gui.ProgressBar(self, iterations=3)
289        # status message
290        self.setStatusMessage('initializing')
291
292        worker = Worker(evaluate_files_state, progress_callback=True)
293        worker.signals.progress.connect(self.__progress_advance)
294        worker.signals.result.connect(self.set_files_list)
295        worker.signals.error.connect(self.handle_worker_exception)
296
297        # move download process to worker thread
298        self.threadpool.start(worker)
299        self.setEnabled(False)
300
301    def __create_action_button(self, fs, retry=None):
302        if not fs.state not in [OUTDATED, USER_FILE] or not retry:
303            self.filesView.setItemWidget(fs.tree_item, header.Update, None)
304
305        button = QToolButton(None)
306        if not retry:
307            if fs.state == OUTDATED:
308                button.setText('Update')
309                button.clicked.connect(partial(self.submit_download_task, fs.domain, fs.filename, True))
310            elif fs.state == USER_FILE:
311                if not fs.info_server:
312                    button.setText('Remove')
313                    button.clicked.connect(partial(self.submit_remove_task, fs.domain, fs.filename))
314                else:
315                    button.setText('Use server version')
316                    button.clicked.connect(partial(self.submit_download_task, fs.domain, fs.filename, True))
317        else:
318            button.setText('Retry')
319            button.clicked.connect(partial(self.submit_download_task, fs.domain, fs.filename, True))
320
321        button.setMaximumWidth(120)
322        button.setMaximumHeight(20)
323        button.setMinimumHeight(20)
324
325        if sys.platform == "darwin":
326            button.setAttribute(Qt.WA_MacSmallSize)
327
328        self.filesView.setItemWidget(fs.tree_item, header.Update, button)
329
330    def set_files_list(self, result):
331        """ Set the files to show.
332        """
333        assert threading.current_thread() == threading.main_thread()
334        self.progress_bar.finish()
335        self.setStatusMessage('')
336        self.setEnabled(True)
337
338        self.update_items = result
339        all_tags = set()
340
341        for fs in self.update_items:
342            fs.tree_item = FileStateItem(fs)
343            fs.download_option = DownloadOption(state=fs.state)
344
345            fs.download_option.download_clicked.connect(partial(self.submit_download_task, fs.domain, fs.filename))
346            fs.download_option.remove_clicked.connect(partial(self.submit_remove_task, fs.domain, fs.filename))
347
348        # add widget items to the QTreeWidget
349        self.filesView.addTopLevelItems([fs.tree_item for fs in self.update_items])
350
351        # add action widgets to tree items
352        for fs in self.update_items:
353            self.filesView.setItemWidget(fs.tree_item, header.Download, fs.download_option)
354            if fs.state in [USER_FILE, OUTDATED]:
355                self.__create_action_button(fs)
356
357            all_tags.update(fs.tags)
358
359        self.filesView.setColumnWidth(header.Download, self.filesView.sizeHintForColumn(header.Download))
360
361        for column in range(1, len(header_labels)):
362            self.filesView.resizeColumnToContents(column)
363
364        hints = [hint for hint in sorted(all_tags) if not hint.startswith("#")]
365        self.completer.setTokenList(hints)
366        self.search_update()
367        self.toggle_action_buttons()
368        self.cancelButton.setEnabled(False)
369
370    def toggle_action_buttons(self):
371        selected_items = [fs for fs in self.update_items if not fs.tree_item.isHidden()]
372
373        def button_check(sel_items, state, button):
374            for item in sel_items:
375                if item.state != state:
376                    button.setEnabled(False)
377                else:
378                    button.setEnabled(True)
379                    break
380
381        button_check(selected_items, OUTDATED, self.updateButton)
382        button_check(selected_items, AVAILABLE, self.downloadButton)
383
384    def search_update(self, searchString=None):
385        strings = str(self.lineEditFilter.text()).split()
386        for fs in self.update_items:
387            hide = not all(UpdateItem_match(fs, string) for string in strings)
388            fs.tree_item.setHidden(hide)
389        self.toggle_action_buttons()
390
391    def update_all(self):
392        for fs in self.update_items:
393            if fs.state == OUTDATED and not fs.tree_item.isHidden():
394                self.submit_download_task(fs.domain, fs.filename)
395
396    def download_filtered(self):
397        for fs in self.update_items:
398            if not fs.tree_item.isHidden() and fs.state in [AVAILABLE, OUTDATED]:
399                self.submit_download_task(fs.domain, fs.filename, start=False)
400
401        self.run_download_tasks()
402
403    def submit_download_task(self, domain, filename, start=True):
404        """ Submit the (domain, filename) to be downloaded/updated.
405        """
406        # get selected tree item
407        index = self.tree_item_index(domain, filename)
408        fs = self.update_items[index]
409
410        worker = Worker(download_server_file, fs, index, progress_callback=True)
411        worker.signals.progress.connect(self.__progress_advance)
412        worker.signals.result.connect(self.on_download_finished)
413        worker.signals.error.connect(self.on_download_exception)
414
415        self.workers.append(worker)
416
417        if start:
418            self.run_download_tasks()
419
420    def run_download_tasks(self):
421        self.cancelButton.setEnabled(True)
422        # init progress bar
423
424        self.progress_bar = gui.ProgressBar(self, iterations=len(self.workers) * 100)
425
426        # status message
427        self.setStatusMessage('downloading')
428
429        # move workers to threadpool
430        [self.threadpool.start(worker) for worker in self.workers]
431        self.filesView.setDisabled(True)
432        # reset list of workers
433        self.workers = []
434
435    def on_download_exception(self, ex):
436        assert threading.current_thread() == threading.main_thread()
437        self.progress_bar.finish()
438        self.setStatusMessage('')
439        print(ex)
440        if isinstance(ex, ValueError):
441            fs, index = ex.args
442
443            # restore state and retry
444            fs.refresh_state()
445            fs.tree_item.update_data(fs)
446            fs.download_option.state = fs.state
447            self.__create_action_button(fs, retry=True)
448
449    def on_download_finished(self, result):
450        assert threading.current_thread() == threading.main_thread()
451
452        # We check if all workers have completed. If not, continue
453        if self.progress_bar.count == 100 or self.threadpool.activeThreadCount() == 0:
454            self.filesView.setDisabled(False)
455            self.progress_bar.finish()
456            self.setStatusMessage('')
457
458        fs, index = result
459        # re-evaluate File State
460        info = serverfiles.info(fs.domain, fs.filename)
461        fs.refresh_state(info_local=info, info_server=info)
462        # reinitialize treeWidgetItem
463        fs.tree_item.update_data(fs)
464        # reinitialize OptionWidget
465        fs.download_option.state = fs.state
466        self.filesView.setItemWidget(fs.tree_item, header.Update, None)
467
468        self.toggle_action_buttons()
469        for column in range(1, len(header_labels)):
470            self.filesView.resizeColumnToContents(column)
471
472    def submit_remove_task(self, domain, filename):
473        serverfiles.LOCALFILES.remove(domain, filename)
474
475        index = self.tree_item_index(domain, filename)
476        fs = self.update_items[index]
477
478        if fs.state == USER_FILE:
479            self.filesView.takeTopLevelItem(self.filesView.indexOfTopLevelItem(fs.tree_item))
480            self.update_items.remove(fs)
481            # self.filesView.removeItemWidget(index)
482        else:
483            # refresh item state
484            fs.info_local = None
485            fs.refresh_state()
486            # reinitialize treeWidgetItem
487            fs.tree_item.update_data(fs)
488            # reinitialize OptionWidget
489            fs.download_option.state = fs.state
490
491        self.toggle_action_buttons()
492
493    def cancel_active_threads(self):
494        """ Cancel all pending update/download tasks (that have not yet started).
495        """
496        if self.threadpool:
497            self.threadpool.clear()
498
499    def tree_item_index(self, domain, filename):
500        for i, fs in enumerate(self.update_items):
501            if fs.domain == domain and fs.filename == filename:
502                return i
503        raise ValueError("%r, %r not in update list" % (domain, filename))
504
505    def onDeleteWidget(self):
506        self.cancel_active_threads()
507        OWWidget.onDeleteWidget(self)
508
509
510class DownloadOption(QWidget):
511    """ A Widget with download/update/remove options.
512    """
513
514    download_clicked = Signal()
515    remove_clicked = Signal()
516
517    def __init__(self, state=AVAILABLE, parent=None):
518        QWidget.__init__(self, parent)
519        layout = QHBoxLayout()
520        layout.setSpacing(1)
521        layout.setContentsMargins(1, 1, 1, 1)
522
523        self.checkButton = QCheckBox()
524
525        layout.addWidget(self.checkButton)
526        self.setLayout(layout)
527
528        self.setMinimumHeight(20)
529        self.setMaximumHeight(20)
530
531        self._state = state
532        self._update()
533
534    @property
535    def state(self):
536        return self._state
537
538    @state.setter
539    def state(self, state):
540        self._state = state
541        self._update()
542
543    def _update(self):
544        self.checkButton.setDisabled(False)
545
546        if self.state == AVAILABLE:
547            self.checkButton.setChecked(False)
548        elif self.state == CURRENT:
549            self.checkButton.setChecked(True)
550        elif self.state == OUTDATED:
551            self.checkButton.setChecked(True)
552        elif self.state == DEPRECATED:
553            self.checkButton.setChecked(True)
554        elif self.state == USER_FILE:
555            self.checkButton.setChecked(False)
556            self.checkButton.setDisabled(True)
557        else:
558            raise ValueError("Invalid state %r" % self.state)
559
560        try:
561            self.checkButton.clicked.disconnect()  # Remove old signals if they exist
562        except Exception:
563            pass
564
565        if not self.checkButton.isChecked():  # Switch signals if the file is present or not
566            self.checkButton.clicked.connect(self.download_clicked)
567        else:
568            self.checkButton.clicked.connect(self.remove_clicked)
569
570
571class FileState:
572    def __init__(self, domain, file_name, info_server, info_local):
573        self.domain = domain
574        self.filename = file_name
575
576        self.info_server = self.parse_info_file(info_server)
577        self.info_local = self.parse_info_file(info_local)
578
579        # self.source = None
580        self.state = self.__item_state()
581
582        self.tree_item = None
583        self.download_option = None
584
585    def refresh_state(self, info_server=None, info_local=None):
586        if info_local:
587            self.info_local = self.parse_info_file(info_local)
588        if info_server:
589            self.info_server = self.parse_info_file(info_server)
590
591        self.state = self.__item_state()
592
593    @property
594    def tags(self):
595        if self.state in [AVAILABLE]:
596            return self.info_server.tags
597        elif self.state in [OUTDATED, USER_FILE, CURRENT]:
598            return self.info_local.tags
599        else:
600            return None
601
602    @property
603    def title(self):
604        if self.state in [AVAILABLE]:
605            return self.info_server.title
606        elif self.state in [OUTDATED, USER_FILE, CURRENT]:
607            return self.info_local.title
608        else:
609            return None
610
611    @property
612    def size(self):
613        if self.state in [AVAILABLE]:
614            return self.info_server.size
615        elif self.state in [OUTDATED, USER_FILE, CURRENT]:
616            return self.info_local.size
617        else:
618            return None
619
620    @property
621    def datetime(self):
622        if self.state in [USER_FILE, CURRENT, OUTDATED]:
623            return self.info_local.datetime
624        else:
625            return self.info_server.datetime
626
627    @property
628    def source(self):
629        if self.state is USER_FILE:
630            return self.info_local.source.capitalize().replace('_', ' ')
631        else:
632            return self.info_server.source.capitalize().replace('_', ' ')
633
634    def __item_state(self):
635        """ Return item state
636
637        Note:
638            available  -> available for download
639            current    -> latest version downloaded
640            outdated   -> needs update (newer version on serverfiles-bio repository)
641            deprecated -> removed from serverfiles-bio repository
642            user_file  -> not in serverfiles-bio repository (user defined)
643
644        """
645
646        if not self.info_server and self.info_local:
647            # we check source of the file
648            if self.info_local.source == SOURCE_USER:
649                # this is user defined file
650                return USER_FILE
651
652            elif self.info_local.source == SOURCE_SERVER:
653                # there is no record of this file on the server
654                return DEPRECATED
655
656        if not self.info_local and self.info_server:
657            return AVAILABLE
658
659        if self.info_server and self.info_local:
660            if not self.info_local.source == SOURCE_USER:
661
662                if self.info_local.datetime < self.info_server.datetime:
663                    return OUTDATED
664                else:
665                    return CURRENT
666
667            else:
668                return USER_FILE
669
670    @staticmethod
671    def parse_info_file(info):
672        """ Parse .info file from JSON like format to namedtuple
673        """
674        if info is not None:
675            if not isinstance(info['datetime'], d_time):
676                info['datetime'] = d_time.strptime(info['datetime'], "%Y-%m-%d %H:%M:%S.%f")
677            return namedtuple('file_info', info.keys())(**info)
678
679
680class FileStateItem(QTreeWidgetItem):
681
682    STATE_STRINGS = {
683        0: 'not downloaded',
684        1: 'downloaded, current',
685        2: 'downloaded, needs update',
686        3: 'obsolete',
687        4: 'custom file',
688    }
689
690    #: A role for the state item data.
691    StateRole = next(gui.OrangeUserRole)
692
693    # QTreeWidgetItem stores the DisplayRole and EditRole as the same role,
694    # so we can't use EditRole to store the actual item data, instead we use
695    # custom role.
696
697    #: A custom edit role for the item's data
698    #: (QTreeWidget treats Qt.EditRole as a alias for Qt.DisplayRole)
699    EditRole2 = next(gui.OrangeUserRole)
700
701    def __init__(self, fs):
702        """ A QTreeWidgetItem for displaying a FileState.
703        """
704        QTreeWidgetItem.__init__(self, type=QTreeWidgetItem.UserType)
705        self.update_data(fs)
706        self._update_tool_tip(fs)
707
708    def update_data(self, fs):
709        self.setData(header.Download, FileStateItem.StateRole, fs.state)
710
711        self.setData(header.Source, Qt.DisplayRole, fs.source)
712        self.setData(header.Source, self.EditRole2, fs.source)
713
714        self.setData(header.Title, Qt.DisplayRole, fs.title)
715        self.setData(header.Title, self.EditRole2, fs.title)
716
717        if not fs.state == AVAILABLE:
718            self.setData(header.Updated, Qt.DisplayRole, fs.datetime.date().isoformat())
719            self.setData(header.Updated, self.EditRole2, fs.datetime)
720        else:
721            self.setData(header.Updated, Qt.DisplayRole, '')
722            self.setData(header.Updated, self.EditRole2, '')
723
724        self.setData(header.Size, Qt.DisplayRole, serverfiles.sizeformat(fs.size))
725        self.setData(header.Size, self.EditRole2, fs.size)
726
727    def _update_tool_tip(self, fs):
728        state_str = self.STATE_STRINGS[fs.state]
729        if fs == DEPRECATED:
730            diff_date = fs.info_server.datetime - fs.info_local.datetime
731        else:
732            diff_date = None
733
734        tooltip = "State: {}\nTags: {}".format(state_str, ', '.join(tag for tag in fs.tags if not tag.startswith("#")))
735
736        if fs.state in [CURRENT, OUTDATED, DEPRECATED]:
737            tooltip += "\nFile: {}".format(serverfiles.localpath(fs.domain, fs.filename))
738
739        if fs.state == OUTDATED and diff_date:
740            tooltip += "\nServer version: {}\nStatus: old {} days".format(fs.datetime, diff_date.days)
741        else:
742            tooltip += "\nServer version: {}".format(fs.datetime)
743
744        for i in range(1, len(header_labels) - 1):
745            self.setToolTip(i, tooltip)
746
747    def __lt__(self, other):
748        widget = self.treeWidget()
749        column = widget.sortColumn()
750        if column == 0:
751            role = FileStateItem.StateRole
752        else:
753            role = self.EditRole2
754
755        left = self.data(column, role)
756        right = other.data(column, role)
757        try:
758            return left < right
759        except TypeError:
760            pass
761        # order lexically by str representation, but ensure `None`
762        # always orders on one side
763        left = (0, "") if left is None else (1, str(left))
764        right = (0, "") if right is None else (1, str(right))
765        return left < right
766
767
768class FileUploadHelper(QDialog):
769
770    # settings
771    kegg_domain = 'KEGG'
772
773    supported_domains = OrderedDict({'Gene Ontology': gene_ontology_domain, 'Gene Sets': gene_sets_domain})
774
775    supported_organisms = [common_taxid_to_name(tax_id) for tax_id in common_taxids()]
776
777    hierarchies = {
778        'GO - Biological Process': ('GO', 'biological_process'),
779        'GO - Molecular Function': ('GO', 'molecular_function'),
780        'GO - Cellular Component': ('GO', 'cellular_component'),
781        'KEGG - Pathways': ('KEGG', 'pathways'),
782        'KEGG - Orthologs': ('KEGG', 'orthologs'),
783    }
784
785    def __init__(self, parent=None):
786        super(FileUploadHelper, self).__init__(
787            parent,
788            Qt.Window
789            | Qt.WindowTitleHint
790            | Qt.CustomizeWindowHint
791            | Qt.WindowCloseButtonHint
792            | Qt.WindowMaximizeButtonHint,
793        )
794        self.setAttribute(Qt.WA_DeleteOnClose)
795        self.setWindowTitle('Add new file')
796
797        self.info_state = INFO_FILE_SCHEMA
798        self.layout = QVBoxLayout(self)
799
800        # domain selection combobox
801        self.domain_selection = QComboBox()
802        self.domain_selection.addItems(self.supported_domains.keys())
803        self.domain_selection.currentIndexChanged.connect(self.__on_domain_selection)
804        self.__create_selection_row('Domain: ', self.domain_selection)
805
806        # domain selection combobox
807        self.hierarchy_selection = QComboBox()
808        self.hierarchy_selection.addItems(self.hierarchies.keys())
809        self.layout.addWidget(self.hierarchy_selection, alignment=Qt.AlignVCenter)
810        self.__on_domain_selection()
811
812        # select organism
813        self.organism_selection = QComboBox()
814        self.organism_selection.addItems(self.supported_organisms)
815        self.__create_selection_row('Organism: ', self.organism_selection)
816
817        # title
818        self.line_edit_title = QLineEdit()
819        self.__create_selection_row('Title: ', self.line_edit_title)
820
821        # tags
822        self.line_edit_tags = QLineEdit()
823        self.__create_selection_row('Tags (comma-separated): ', self.line_edit_tags)
824
825        # file selector
826        self.file_info = QLabel()
827        self.file_select_btn = QPushButton('Select File', self)
828        self.file_select_btn.clicked.connect(self.__handle_file_selector)
829        self.__create_selection_row(' ', self.file_select_btn)
830
831        # add file info section
832        self.layout.addWidget(self.file_info, alignment=Qt.AlignCenter)
833
834        self.layout.addStretch(1)
835
836        # Ok and Cancel buttons
837        self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, Qt.Horizontal, self)
838        self.layout.addWidget(self.buttons, alignment=Qt.AlignJustify)
839
840        self.buttons.accepted.connect(self.__accept)
841        self.buttons.rejected.connect(self.__close)
842
843        # path to a selected file
844        self.file_path = None
845
846    def __on_domain_selection(self):
847        selected = self.__get_selected_domain() == gene_sets_domain
848        self.hierarchy_selection.setVisible(selected)
849
850    def __get_selected_domain(self):
851        domain_label = list(self.supported_domains.keys())[self.domain_selection.currentIndex()]
852        return self.supported_domains[domain_label]
853
854    def __get_selected_hier(self):
855        hier_label = list(self.hierarchies.keys())[self.hierarchy_selection.currentIndex()]
856        return self.hierarchies[hier_label]
857
858    def __create_selection_row(self, label, widget):
859        self.layout.addWidget(QLabel(label), alignment=Qt.AlignLeft)
860        self.layout.addWidget(widget, alignment=Qt.AlignVCenter)
861
862    def __accept(self):
863        if self.file_path:
864            self.info_state = self.__parse_selection()
865            self.__move_to_serverfiles_folder(self.file_path)
866
867            self.parent().initialize_files_view()
868            self.close()
869
870    def __close(self):
871        self.close()
872
873    def closeEvent(self, event):
874        # clean-up
875        self.parent()._dialog = None
876
877    def __filename(self, domain, organism):
878        """ Create filename based od domain name and organism.
879        """
880
881        if domain in self.supported_domains.values() and domain == gene_ontology_domain and organism:
882            return FILENAME_ANNOTATION.format(organism)
883
884        elif domain in self.supported_domains.values() and domain == gene_sets_domain and organism:
885            return filename((self.__get_selected_hier()), organism)
886
887    def __parse_selection(self):
888        try:
889            domain = self.__get_selected_domain()
890            organism = species_name_to_taxid(self.supported_organisms[self.organism_selection.currentIndex()])
891        except KeyError as e:
892            raise e
893
894        return {
895            'domain': domain,
896            'organism': organism,
897            'filename': self.__filename(domain, organism),
898            'title': self.line_edit_title.text(),
899            'tags': self.line_edit_tags.text().split(','),
900            'source': SOURCE_USER,
901        }
902
903    def __move_to_serverfiles_folder(self, selected_file_path):
904        domain_path = serverfiles.localpath(self.info_state['domain'])
905        file_path = os.path.join(domain_path, self.info_state['filename'])
906        create_folder(domain_path)
907
908        try:
909            copyfile(selected_file_path, file_path)
910        except IOError as e:
911            # TODO: handle error properly
912            raise e
913
914        # if copy successful create .info file
915        create_info_file(file_path, **self.info_state)
916
917    def __handle_file_selector(self):
918        self.file_path = QFileDialog.getOpenFileName(self, 'Open File')[0]
919        self.file_info.setText('Selected File: {}'.format(os.path.basename(self.file_path)))
920
921
922if __name__ == "__main__":
923
924    def main_test():
925        app = QApplication(sys.argv)
926        w = OWDatabasesUpdate()
927        w.show()
928        w.raise_()
929        return app.exec_()
930
931    sys.exit(main_test())
932