Projet

Général

Profil

Anomalie #284 » menu_builder_dialog.py

Leslie Lemaire, 22/06/2020 18:22

 
1
# -*- coding: utf-8 -*-
2
"""
3
MenuBuilder - Create your own menus with your favorite layers
4

    
5
copyright            : (C) 2015 by Oslandia
6
email                : infos@oslandia.com
7

    
8
/***************************************************************************
9
 *                                                                         *
10
 *   This program is free software; you can redistribute it and/or modify  *
11
 *   it under the terms of the GNU General Public License as published by  *
12
 *   the Free Software Foundation; either version 2 of the License, or     *
13
 *   (at your option) any later version.                                   *
14
 *                                                                         *
15
 ***************************************************************************/
16
"""
17
import os
18
import re
19
import json
20
from contextlib import contextmanager
21
from collections import defaultdict
22
from functools import wraps, partial
23

    
24
import psycopg2
25

    
26
from PyQt5.QtCore import Qt, QRect, QSortFilterProxyModel
27
from PyQt5.QtWidgets import (
28
    QAction, QMessageBox, QDialog, QMenu, QTreeView,
29
    QAbstractItemView, QDockWidget, QWidget, QVBoxLayout,
30
    QSizePolicy, QLineEdit, QDialogButtonBox
31
)
32

    
33
from PyQt5.QtGui import (
34
    QIcon, QStandardItem,
35
    QStandardItemModel
36
)
37
from qgis.core import (
38
    QgsProject, QgsBrowserModel, QgsDataSourceUri, QgsSettings,
39
    QgsCredentials, QgsVectorLayer, QgsMimeDataUtils, QgsRasterLayer
40
)
41

    
42
from .menu_builder_dialog_base import Ui_Dialog
43

    
44
QGIS_MIMETYPE = 'application/x-vnd.qgis.qgis.uri'
45

    
46

    
47
ICON_MAPPER = {
48
    'postgres': ":/plugins/MenuBuilder/resources/postgis.svg",
49
    'WMS': ":/plugins/MenuBuilder/resources/wms.svg",
50
    'WFS': ":/plugins/MenuBuilder/resources/wfs.svg",
51
    'OWS': ":/plugins/MenuBuilder/resources/ows.svg",
52
    'spatialite': ":/plugins/MenuBuilder/resources/spatialite.svg",
53
    'mssql': ":/plugins/MenuBuilder/resources/mssql.svg",
54
    'gdal': ":/plugins/MenuBuilder/resources/gdal.svg",
55
    'ogr': ":/plugins/MenuBuilder/resources/ogr.svg",
56
}
57

    
58

    
59
class MenuBuilderDialog(QDialog, Ui_Dialog):
60

    
61
    def __init__(self, uiparent):
62
        super().__init__()
63

    
64
        self.setupUi(self)
65

    
66
        # reference to caller
67
        self.uiparent = uiparent
68

    
69
        self.combo_profile.lineEdit().setPlaceholderText(self.tr("Profile name"))
70

    
71
        # add icons
72
        self.button_add_menu.setIcon(QIcon(":/plugins/MenuBuilder/resources/plus.svg"))
73
        self.button_delete_profile.setIcon(QIcon(":/plugins/MenuBuilder/resources/delete.svg"))
74

    
75
        # custom qtreeview
76
        self.target = CustomQtTreeView(self)
77
        self.target.setGeometry(QRect(440, 150, 371, 451))
78
        self.target.setAcceptDrops(True)
79
        self.target.setDragEnabled(True)
80
        self.target.setDragDropMode(QAbstractItemView.DragDrop)
81
        self.target.setObjectName("target")
82
        self.target.setDropIndicatorShown(True)
83
        self.target.setSelectionMode(QAbstractItemView.ExtendedSelection)
84
        self.target.setHeaderHidden(True)
85
        sizePolicy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
86
        sizePolicy.setHorizontalStretch(0)
87
        sizePolicy.setVerticalStretch(0)
88
        sizePolicy.setHeightForWidth(self.target.sizePolicy().hasHeightForWidth())
89
        self.target.setSizePolicy(sizePolicy)
90
        self.target.setAutoFillBackground(True)
91
        self.verticalLayout_2.addWidget(self.target)
92
        self.menumodel = MenuTreeModel(self)
93
        self.target.setModel(self.menumodel)
94
        self.target.setAnimated(True)
95
        self.target.setDefaultDropAction(Qt.MoveAction)
96

    
97
        self.browser = QgsBrowserModel()
98
        self.browser.initialize()
99
        self.source.setModel(self.browser)
100
        self.source.setHeaderHidden(True)
101
        self.source.setDragEnabled(True)
102
        self.source.setSelectionMode(QAbstractItemView.ExtendedSelection)
103

    
104
        # add a dock widget
105
        self.dock_widget = QDockWidget("Menus")
106
        self.dock_widget.resize(400, 300)
107
        self.dock_widget.setFloating(True)
108
        self.dock_widget.setObjectName(self.tr("Menu Tree"))
109
        self.dock_widget_content = QWidget()
110
        self.dock_widget.setWidget(self.dock_widget_content)
111
        dock_layout = QVBoxLayout()
112
        self.dock_widget_content.setLayout(dock_layout)
113
        self.dock_view = DockQtTreeView(self.dock_widget_content)
114
        self.dock_view.setDragDropMode(QAbstractItemView.DragOnly)
115
        self.dock_menu_filter = QLineEdit()
116
        self.dock_menu_filter.setPlaceholderText(self.tr("Filter by table description (postgis only)"))
117
        dock_layout.addWidget(self.dock_menu_filter)
118
        dock_layout.addWidget(self.dock_view)
119
        self.dock_view.setHeaderHidden(True)
120
        self.dock_view.setDragEnabled(True)
121
        self.dock_view.setSelectionMode(QAbstractItemView.ExtendedSelection)
122
        self.dock_view.setAnimated(True)
123
        self.dock_view.setObjectName("treeView")
124
        self.proxy_model = LeafFilterProxyModel(self)
125
        self.proxy_model.setFilterRole(Qt.ToolTipRole)
126
        self.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive)
127

    
128
        self.profile_list = []
129
        self.table = 'qgis_menubuilder_metadata'
130

    
131
        self.layer_handler = {
132
            'vector': self.load_vector,
133
            'raster': self.load_raster
134
        }
135

    
136
        # connect signals and handlers
137
        self.combo_database.activated.connect(partial(self.set_connection, dbname=None))
138
        self.combo_schema.activated.connect(self.update_profile_list)
139
        self.combo_profile.activated.connect(partial(self.update_model_idx, self.menumodel))
140
        self.button_add_menu.released.connect(self.add_menu)
141
        self.button_delete_profile.released.connect(self.delete_profile)
142
        self.dock_menu_filter.textEdited.connect(self.filter_update)
143
        self.dock_view.doubleClicked.connect(self.load_from_index)
144

    
145
        self.buttonBox.rejected.connect(self.reject)
146
        self.buttonBox.accepted.connect(self.accept)
147
        self.buttonBox.button(QDialogButtonBox.Apply).clicked.connect(self.apply)
148

    
149
    def filter_update(self):
150
        text = self.dock_menu_filter.displayText()
151
        self.proxy_model.setFilterRegExp(text)
152

    
153
    def show_dock(self, state, profile=None, schema=None):
154
        if not state:
155
            # just hide widget
156
            self.dock_widget.setVisible(state)
157
            return
158
        # dock must be read only and deepcopy of model is not supported (c++ inside!)
159
        self.dock_model = MenuTreeModel(self)
160
        if profile:
161
            # bypass combobox
162
            self.update_model(self.dock_model, schema, profile)
163
        else:
164
            self.update_model_idx(self.dock_model, self.combo_profile.currentIndex())
165
        self.dock_model.setHorizontalHeaderLabels(["Menus"])
166
        self.dock_view.setEditTriggers(QAbstractItemView.NoEditTriggers)
167
        self.proxy_model.setSourceModel(self.dock_model)
168
        self.dock_view.setModel(self.proxy_model)
169
        self.dock_widget.setVisible(state)
170

    
171
    def show_menus(self, state, profile=None, schema=None):
172
        if state:
173
            self.load_menus(profile=profile, schema=schema)
174
            return
175
        # remove menus
176
        for menu in self.uiparent.menus:
177
            self.uiparent.iface.mainWindow().menuBar().removeAction(menu.menuAction())
178

    
179
    def add_menu(self):
180
        """
181
        Add a menu inside qtreeview
182
        """
183
        item = QStandardItem('NewMenu')
184
        item.setIcon(QIcon(':/plugins/MenuBuilder/resources/menu.svg'))
185
        # select current index selected and insert as a sibling
186
        brother = self.target.selectedIndexes()
187

    
188
        if not brother or not brother[0].parent():
189
            # no selection, add menu at the top level
190
            self.menumodel.insertRow(self.menumodel.rowCount(), item)
191
            return
192

    
193
        parent = self.menumodel.itemFromIndex(brother[0].parent())
194
        if not parent:
195
            self.menumodel.insertRow(self.menumodel.rowCount(), item)
196
            return
197
        parent.appendRow(item)
198

    
199
    def update_database_list(self):
200
        """update list of defined postgres connections"""
201
        settings = QgsSettings()
202
        settings.beginGroup("/PostgreSQL/connections")
203
        keys = settings.childGroups()
204
        self.combo_database.clear()
205
        self.combo_schema.clear()
206
        self.menumodel.clear()
207
        self.combo_database.addItems(keys)
208
        self.combo_database.setCurrentIndex(-1)
209
        settings.endGroup()
210
        # clear profile list
211
        self.combo_profile.clear()
212
        self.combo_profile.setCurrentIndex(-1)
213

    
214
    def set_connection(self, databaseidx, dbname=None):
215
        """
216
        Connect to selected postgresql database
217
        """
218
        selected = self.combo_database.itemText(databaseidx) or dbname
219
        if not selected:
220
            return
221

    
222
        settings = QgsSettings()
223
        settings.beginGroup("/PostgreSQL/connections/{}".format(selected))
224

    
225
        if not settings.contains("database"):
226
            # no entry?
227
            QMessageBox.critical(self, "Error", "There is no defined database connection")
228
            return
229

    
230
        uri = QgsDataSourceUri()
231

    
232
        settingsList = ["service", "host", "port", "database", "username", "password"]
233
        service, host, port, database, username, password = map(
234
            lambda x: settings.value(x, "", type=str), settingsList)
235

    
236
        useEstimatedMetadata = settings.value("estimatedMetadata", False, type=bool)
237
        sslmode = settings.enumValue("sslmode", uri.SslPrefer)
238

    
239
        settings.endGroup()
240

    
241
        if service:
242
            uri.setConnection(service, database, username, password, sslmode)
243
        else:
244
            uri.setConnection(host, port, database, username, password, sslmode)
245

    
246
        uri.setUseEstimatedMetadata(useEstimatedMetadata)
247

    
248
        # connect to db
249
        try:
250
            self.connect_to_uri(uri)
251
        except self.pg_error_types():
252
            QMessageBox.warning(
253
                self,
254
                "Plugin MenuBuilder: Message",
255
                self.tr("The database containing Menu's configuration is unavailable"),
256
                QMessageBox.Ok,
257
            )
258
            # connection not available
259
            return False
260

    
261
        # update schema list
262
        self.update_schema_list()
263
        return True
264

    
265
    @contextmanager
266
    def transaction(self):
267
        try:
268
            yield
269
            self.connection.commit()
270
        except self.pg_error_types() as e:
271
            self.connection.rollback()
272
            raise e
273

    
274
    def check_connected(func):
275
        """
276
        Decorator that checks if a database connection is active before executing function
277
        """
278
        @wraps(func)
279
        def wrapped(inst, *args, **kwargs):
280
            if not getattr(inst, 'connection', False):
281
                QMessageBox(
282
                    QMessageBox.Warning,
283
                    "Menu Builder",
284
                    inst.tr("Not connected to any database, please select one"),
285
                    QMessageBox.Ok,
286
                    inst
287
                )
288
                return
289
            if inst.connection.closed:
290
                QMessageBox(
291
                    QMessageBox.Warning,
292
                    "Menu Builder",
293
                    inst.tr("Not connected to any database, please select one"),
294
                    QMessageBox.Ok,
295
                    inst
296
                )
297
                return
298
            return func(inst, *args, **kwargs)
299
        return wrapped
300

    
301
    def connect_to_uri(self, uri):
302
        self.close_connection()
303
        self.host = uri.host() or os.environ.get('PGHOST')
304
        self.port = uri.port() or os.environ.get('PGPORT')
305

    
306
        username = uri.username() or os.environ.get('PGUSER') or os.environ.get('USER')
307
        password = uri.password() or os.environ.get('PGPASSWORD')
308
        
309
        conninfo = uri.connectionInfo()
310

    
311
        while True:
312
            try:
313
                self.connection = psycopg2.connect(uri.connectionInfo(), application_name="QGIS:MenuBuilder")
314
                break
315
            except self.pg_error_types() as e:
316
                err = str(e) or "Erreur d'authentification. Vérifiez les informations saisies."
317
                                
318
                ok, username, password = QgsCredentials.instance().get(
319
                    conninfo, username, password, err)
320
                if not ok:
321
                    raise e
322
                if username:
323
                    uri.setUsername(username)
324
                if password:
325
                    uri.setPassword(password)
326
        
327
        QgsCredentials.instance().put(conninfo, username, password) 
328
        
329
        self.pgencoding = self.connection.encoding
330

    
331
        return True
332

    
333
    def pg_error_types(self):
334
        return (
335
            psycopg2.InterfaceError,
336
            psycopg2.OperationalError,
337
            psycopg2.ProgrammingError
338
        )
339

    
340
    @check_connected
341
    def update_schema_list(self):
342
        self.combo_schema.clear()
343
        with self.transaction():
344
            cur = self.connection.cursor()
345
            cur.execute("""
346
                select nspname
347
                from pg_namespace
348
                where nspname not ilike 'pg_%'
349
                and nspname not in ('pg_catalog', 'information_schema')
350
                """)
351
            schemas = [row[0] for row in cur.fetchall()]
352
            self.combo_schema.addItems(schemas)
353

    
354
    @check_connected
355
    def update_profile_list(self, schemaidx):
356
        """
357
        update profile list from database
358
        """
359
        schema = self.combo_schema.itemText(schemaidx)
360
        with self.transaction():
361
            cur = self.connection.cursor()
362
            cur.execute("""
363
                select 1
364
                from pg_tables
365
                    where schemaname = '{0}'
366
                    and tablename = '{1}'
367
                union
368
                select 1
369
                from pg_matviews
370
                    where schemaname = '{0}'
371
                    and matviewname = '{1}'
372
                union
373
                select 1
374
                from pg_views
375
                    where schemaname = '{0}'
376
                    and viewname = '{1}'
377
                """.format(schema, self.table))
378
            tables = cur.fetchone()
379
            if not tables:
380
                box = QMessageBox(
381
                    QMessageBox.Warning,
382
                    "Menu Builder",
383
                    self.tr("Table '{}.{}' not found in this database, "
384
                            "would you like to create it now ?")
385
                    .format(schema, self.table),
386
                    QMessageBox.Cancel | QMessageBox.Yes,
387
                    self
388
                )
389
                ret = box.exec_()
390
                if ret == QMessageBox.Cancel:
391
                    return False
392
                elif ret == QMessageBox.Yes:
393
                    cur.execute("""
394
                        create table {}.{} (
395
                            id serial,
396
                            name varchar,
397
                            profile varchar,
398
                            model_index varchar,
399
                            datasource_uri text
400
                        )
401
                        """.format(schema, self.table))
402
                    self.connection.commit()
403
                    return False
404

    
405
            cur.execute("""
406
                select distinct(profile) from {}.{}
407
                """.format(schema, self.table))
408
            profiles = [row[0] for row in cur.fetchall()]
409
            saved_profile = self.combo_profile.currentText()
410
            self.combo_profile.clear()
411
            self.combo_profile.addItems(profiles)
412
            self.combo_profile.setCurrentIndex(self.combo_profile.findText(saved_profile))
413

    
414
    @check_connected
415
    def delete_profile(self):
416
        """
417
        Delete profile currently selected
418
        """
419
        idx = self.combo_profile.currentIndex()
420
        schema = self.combo_schema.currentText()
421
        profile = self.combo_profile.itemText(idx)
422
        box = QMessageBox(
423
            QMessageBox.Warning,
424
            "Menu Builder",
425
            self.tr("Delete '{}' profile ?").format(profile),
426
            QMessageBox.Cancel | QMessageBox.Yes,
427
            self
428
        )
429
        ret = box.exec_()
430
        if ret == QMessageBox.Cancel:
431
            return False
432
        elif ret == QMessageBox.Yes:
433
            self.combo_profile.removeItem(idx)
434
            with self.transaction():
435
                cur = self.connection.cursor()
436
                cur.execute("""
437
                    delete from {}.{}
438
                    where profile = '{}'
439
                    """.format(schema, self.table, profile))
440
        self.menumodel.clear()
441
        self.combo_profile.setCurrentIndex(-1)
442

    
443
    def update_model_idx(self, model, profile_index):
444
        """
445
        wrapper that checks combobox
446
        """
447
        profile = self.combo_profile.itemText(profile_index)
448
        schema = self.combo_schema.currentText()
449
        self.update_model(model, schema, profile)
450

    
451
    def sortby_modelindex(self, rows):
452
        return sorted(
453
            rows,
454
            key=lambda line: '/'.join(
455
                ['{:04}'.format(elem[0]) for elem in json.loads(line[2])]
456
            ))
457

    
458
    @check_connected
459
    def update_model(self, model, schema, profile):
460
        """
461
        Update the model by retrieving the profile given in database
462
        """
463
        menudict = {}
464

    
465
        with self.transaction():
466
            cur = self.connection.cursor()
467
            select = """
468
                select name, profile, model_index, datasource_uri
469
                from {}.{}
470
                where profile = '{}'
471
                """.format(schema, self.table, profile)
472
            cur.execute(select)
473
            rows = cur.fetchall()
474
            model.clear()
475
            for name, profile, model_index, datasource_uri in self.sortby_modelindex(rows):
476
                menu = model.invisibleRootItem()
477
                indexes = json.loads(model_index)
478
                parent = ''
479
                for idx, subname in indexes[:-1]:
480
                    parent += '{}-{}/'.format(idx, subname)
481
                    if parent in menudict:
482
                        # already created entry
483
                        menu = menudict[parent]
484
                        continue
485
                    # create menu
486
                    item = QStandardItem(subname)
487
                    uri_struct = QgsMimeDataUtils.Uri(datasource_uri)
488
                    item.setData(uri_struct)
489
                    item.setIcon(QIcon(':/plugins/MenuBuilder/resources/menu.svg'))
490
                    item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsUserCheckable |
491
                                  Qt.ItemIsEnabled | Qt.ItemIsDropEnabled |
492
                                  Qt.ItemIsEditable)
493
                    item.setWhatsThis("menu")
494
                    menu.appendRow(item)
495
                    menudict[parent] = item
496
                    # set current menu to the new created item
497
                    menu = item
498

    
499
                # add leaf (layer item)
500
                item = QStandardItem(name)
501
                uri_struct = QgsMimeDataUtils.Uri(datasource_uri)
502
                # fix layer name instead of table name
503
                # usefull when the layer has been renamed in menu
504
                uri_struct.name = name
505
                if uri_struct.providerKey in ICON_MAPPER:
506
                    item.setIcon(QIcon(ICON_MAPPER[uri_struct.providerKey]))
507
                item.setData(uri_struct)
508
                # avoid placing dragged layers on it
509
                item.setDropEnabled(False)
510
                if uri_struct.providerKey == 'postgres':
511
                    # set tooltip to postgres comment
512
                    comment = self.get_table_comment(uri_struct.uri)
513
                    item.setToolTip(comment)
514
                menudict[parent].appendRow(item)
515

    
516
    @check_connected
517
    def save_changes(self, save_to_db=True):
518
        """
519
        Save changes in the postgres table
520
        """
521
        schema = self.combo_schema.currentText()
522
        profile = self.combo_profile.currentText()
523
        if not profile:
524
            QMessageBox(
525
                QMessageBox.Warning,
526
                "Menu Builder",
527
                self.tr("Profile cannot be empty"),
528
                QMessageBox.Ok,
529
                self
530
            ).exec_()
531
            return False
532

    
533
        if save_to_db:
534
            try:
535
                with self.transaction():
536
                    cur = self.connection.cursor()
537
                    cur.execute("delete from {}.{} where profile = '{}'".format(
538
                        schema, self.table, profile))
539
                    for item, data in self.target.iteritems():
540
                        if not data:
541
                            continue
542
                        cur.execute("""
543
                        insert into {}.{} (name,profile,model_index,datasource_uri)
544
                        values (%s, %s, %s, %s)
545
                        """.format(schema, self.table), (
546
                            item[-1][1],
547
                            profile,
548
                            json.dumps(item),
549
                            data.data())
550
                        )
551
            except Exception as exc:
552
                QMessageBox(
553
                    QMessageBox.Warning,
554
                    "Menu Builder",
555
                    exc.message.decode(self.pgencoding),
556
                    QMessageBox.Ok,
557
                    self
558
                ).exec_()
559
                return False
560

    
561
        self.save_session(
562
            self.combo_database.currentText(),
563
            schema,
564
            profile,
565
            self.activate_dock.isChecked(),
566
            self.activate_menubar.isChecked()
567
        )
568
        self.update_profile_list(self.combo_schema.currentIndex())
569
        self.show_dock(self.activate_dock.isChecked())
570
        self.show_menus(self.activate_menubar.isChecked())
571
        return True
572

    
573
    @check_connected
574
    def load_menus(self, profile=None, schema=None):
575
        """
576
        Load menus in the main windows qgis bar
577
        """
578
        if not schema:
579
            schema = self.combo_schema.currentText()
580
        if not profile:
581
            profile = self.combo_profile.currentText()
582
        # remove previous menus
583
        for menu in self.uiparent.menus:
584
            self.uiparent.iface.mainWindow().menuBar().removeAction(menu.menuAction())
585

    
586
        with self.transaction():
587
            cur = self.connection.cursor()
588
            select = """
589
                select name, profile, model_index, datasource_uri
590
                from {}.{}
591
                where profile = '{}'
592
                """.format(schema, self.table, profile)
593
            cur.execute(select)
594
            rows = cur.fetchall()
595
        # item accessor ex: '0-menu/0-submenu/1-item/'
596
        menudict = {}
597
        # reference to parent item
598
        parent = ''
599
        # reference to qgis main menu bar
600
        menubar = self.uiparent.iface.mainWindow().menuBar()
601

    
602
        for name, profile, model_index, datasource_uri in self.sortby_modelindex(rows):
603
            uri_struct = QgsMimeDataUtils.Uri(datasource_uri)
604
            indexes = json.loads(model_index)
605
            # root menu
606
            parent = '{}-{}/'.format(indexes[0][0], indexes[0][1])
607
            if parent not in menudict:
608
                menu = QMenu(self.uiparent.iface.mainWindow())
609
                self.uiparent.menus.append(menu)
610
                menu.setObjectName(indexes[0][1])
611
                menu.setTitle(indexes[0][1])
612
                menubar.insertMenu(
613
                    self.uiparent.iface.firstRightStandardMenu().menuAction(),
614
                    menu)
615
                menudict[parent] = menu
616
            else:
617
                # menu already there
618
                menu = menudict[parent]
619

    
620
            for idx, subname in indexes[1:-1]:
621
                # intermediate submenus
622
                parent += '{}-{}/'.format(idx, subname)
623
                if parent not in menudict:
624
                    submenu = menu.addMenu(subname)
625
                    submenu.setObjectName(subname)
626
                    submenu.setTitle(subname)
627
                    menu = submenu
628
                    # store it for later use
629
                    menudict[parent] = menu
630
                    continue
631
                # already treated
632
                menu = menudict[parent]
633

    
634
            # last item = layer
635
            layer = QAction(name, self)
636

    
637
            if uri_struct.providerKey in ICON_MAPPER:
638
                layer.setIcon(QIcon(ICON_MAPPER[uri_struct.providerKey]))
639

    
640
            if uri_struct.providerKey == 'postgres':
641
                # set tooltip to postgres comment
642
                comment = self.get_table_comment(uri_struct.uri)
643
                layer.setStatusTip(comment)
644
                layer.setToolTip(comment)
645

    
646
            layer.setData(uri_struct.uri)
647
            layer.setWhatsThis(uri_struct.providerKey)
648
            layer.triggered.connect(self.layer_handler[uri_struct.layerType])
649
            menu.addAction(layer)  
650

    
651
    def get_table_comment(self, uri):
652
        schema, table = re.match(r'.*table="(.*"\.".*)"', uri) \
653
            .group(1) \
654
            .strip() \
655
            .split('"."')
656

    
657
        with self.transaction():
658
            cur = self.connection.cursor()
659
            select = """
660
                select description from pg_description
661
                join pg_class on pg_description.objoid = pg_class.oid
662
                join pg_namespace on pg_class.relnamespace = pg_namespace.oid
663
                where relname = '{}' and nspname='{}'
664
                """.format(table, schema)
665
            cur.execute(select)
666
            row = cur.fetchone()
667
            if row:
668
                return row[0]
669
        return ''
670

    
671
    def load_from_index(self, index):
672
        """Load layers from selected item index"""
673
        item = self.dock_model.itemFromIndex(self.proxy_model.mapToSource(index))
674
        if item.whatsThis() == 'menu':
675
            return
676
        if item.data().layerType == 'vector':
677
            layer = QgsVectorLayer(
678
                item.data().uri,  # uri
679
                item.text(),  # layer name
680
                item.data().providerKey  # provider name
681
            )
682
        elif item.data().layerType == 'raster':
683
            layer = QgsRasterLayer(
684
                item.data().uri,  # uri
685
                item.text(),  # layer name
686
                item.data().providerKey  # provider name
687
            )
688
        if not layer:
689
            return
690
        QgsProject.instance().addMapLayer(layer)
691

    
692
    def load_vector(self):
693
        action = self.sender()
694

    
695
        layer = QgsVectorLayer(
696
            action.data(),  # uri
697
            action.text(),  # layer name
698
            action.whatsThis()  # provider name
699
        )
700
        QgsProject.instance().addMapLayer(layer)
701

    
702
    def load_raster(self):
703
        action = self.sender()
704
        layer = QgsRasterLayer(
705
            action.data(),  # uri
706
            action.text(),  # layer name
707
            action.whatsThis()  # provider name
708
        )
709
        QgsProject.instance().addMapLayer(layer)
710

    
711
    def accept(self):
712
        if self.save_changes():
713
            QDialog.reject(self)
714
            self.close_connection()
715

    
716
    def apply(self):
717
        if self.save_changes(save_to_db=False):
718
            QDialog.reject(self)
719
            self.close_connection()
720

    
721
    def reject(self):
722
        self.close_connection()
723
        QDialog.reject(self)
724

    
725
    def close_connection(self):
726
        """close current pg connection if exists"""
727
        if getattr(self, 'connection', False):
728
            if self.connection.closed:
729
                return
730
            self.connection.close()
731

    
732
    def save_session(self, database, schema, profile, dock, menubar):
733
        """save current profile for next session"""
734
        settings = QgsSettings()
735
        settings.setValue("MenuBuilder/database", database)
736
        settings.setValue("MenuBuilder/schema", schema)
737
        settings.setValue("MenuBuilder/profile", profile)
738
        settings.setValue("MenuBuilder/dock", dock)
739
        settings.setValue("MenuBuilder/menubar", menubar)
740

    
741
    def restore_session(self):
742
        settings = QgsSettings()
743
        database = settings.value("MenuBuilder/database", False)
744
        schema = settings.value("MenuBuilder/schema", 'public')
745
        profile = settings.value("MenuBuilder/profile", False)
746
        dock = settings.value("MenuBuilder/dock", False)
747
        menubar = settings.value("MenuBuilder/menubar", False)
748
        if not any([database, profile]):
749
            return
750

    
751
        connected = self.set_connection(0, dbname=database)
752
        if not connected:
753
            # don't try to continue
754
            return
755

    
756
        self.show_dock(bool(dock), profile=profile, schema=schema)
757
        if bool(dock):
758
            self.uiparent.iface.addDockWidget(Qt.LeftDockWidgetArea, self.dock_widget)
759
        self.show_menus(bool(menubar), profile=profile, schema=schema)
760
        self.close_connection()  
761

    
762

    
763
class CustomQtTreeView(QTreeView):
764

    
765
    def dragEnterEvent(self, event):
766
        if not event.mimeData():
767
            # don't drag menu entry
768
            return False
769
        # refuse if it's not a qgis mimetype
770
        if event.mimeData().hasFormat(QGIS_MIMETYPE):
771
            event.acceptProposedAction()
772

    
773
    def keyPressEvent(self, event):
774
        if event.key() == Qt.Key_Delete:
775
            model = self.selectionModel().model()
776
            parents = defaultdict(list)
777
            for idx in self.selectedIndexes():
778
                parents[idx.parent()].append(idx)
779
            for parent, idx_list in parents.items():
780
                for diff, index in enumerate(idx_list):
781
                    model.removeRow(index.row() - diff, parent)
782
        elif event.key() == Qt.Key_Return:
783
            pass
784
        else:
785
            super().keyPressEvent(event)
786

    
787
    def iteritems(self, level=0):
788
        """
789
        Dump model to store in database.
790
        Generates each level recursively
791
        """
792
        rowcount = self.model().rowCount()
793
        for itemidx in range(rowcount):
794
            # iterate over parents
795
            parent = self.model().itemFromIndex(self.model().index(itemidx, 0))
796
            for item, uri in self.traverse_tree(parent, []):
797
                yield item, uri
798

    
799
    def traverse_tree(self, parent, identifier):
800
        """
801
        Iterate over childs, recursively
802
        """
803
        identifier.append([parent.row(), parent.text()])
804
        for row in range(parent.rowCount()):
805
            child = parent.child(row)
806
            if child.hasChildren():
807
                # child is a menu ?
808
                for item in self.traverse_tree(child, identifier):
809
                    yield item
810
                identifier.pop()
811
            else:
812
                # add leaf
813
                sibling = list(identifier)
814
                sibling.append([child.row(), child.text()])
815
                yield sibling, child.data()
816

    
817

    
818
class DockQtTreeView(CustomQtTreeView):
819

    
820
    def keyPressEvent(self, event):
821
        """override keyevent to avoid deletion of items in the dock"""
822
        pass
823

    
824

    
825
class MenuTreeModel(QStandardItemModel):
826

    
827
    def dropMimeData(self, mimedata, action, row, column, parentIndex):
828
        """
829
        Handles the dropping of an item onto the model.
830
        De-serializes the data and inserts it into the model.
831
        """
832
        # decode data using qgis helpers
833
        uri_list = QgsMimeDataUtils.decodeUriList(mimedata)
834
        if not uri_list:
835
            return False
836
        # find parent item
837
        parent_item = self.itemFromIndex(parentIndex)
838
        if not parent_item:
839
            return False
840

    
841
        items = []
842
        for uri in uri_list:
843
            item = QStandardItem(uri.name)
844
            item.setData(uri)
845
            # avoid placing dragged layers on it
846
            item.setDropEnabled(False)
847
            if uri.providerKey in ICON_MAPPER:
848
                item.setIcon(QIcon(ICON_MAPPER[uri.providerKey]))
849
            items.append(item)
850

    
851
        if row == -1:
852
            # dropped on a Menu
853
            # add as a child at the end
854
            parent_item.appendRows(items)
855
        else:
856
            # add items at the separator shown
857
            parent_item.insertRows(row, items)
858

    
859
        return True
860

    
861
    def mimeData(self, indexes):
862
        """
863
        Used to serialize data
864
        """
865
        if not indexes:
866
            return 0
867
        items = [self.itemFromIndex(idx) for idx in indexes]
868
        if not items:
869
            return 0
870
        if not all(it.data() for it in items):
871
            return 0
872
        # reencode items
873
        mimedata = QgsMimeDataUtils.encodeUriList([item.data() for item in items])
874
        return mimedata
875

    
876
    def mimeTypes(self):
877
        return [QGIS_MIMETYPE]
878

    
879
    def supportedDropActions(self):
880
        return Qt.CopyAction | Qt.MoveAction
881

    
882

    
883
class LeafFilterProxyModel(QSortFilterProxyModel):
884
    """
885
    Class to override the following behaviour:
886
        If a parent item doesn't match the filter,
887
        none of its children will be shown.
888

    
889
    This Model matches items which are descendants
890
    or ascendants of matching items.
891
    """
892

    
893
    def filterAcceptsRow(self, row_num, source_parent):
894
        """Overriding the parent function"""
895

    
896
        # Check if the current row matches
897
        if self.filter_accepts_row_itself(row_num, source_parent):
898
            return True
899

    
900
        # Traverse up all the way to root and check if any of them match
901
        if self.filter_accepts_any_parent(source_parent):
902
            return True
903

    
904
        # Finally, check if any of the children match
905
        return self.has_accepted_children(row_num, source_parent)
906

    
907
    def filter_accepts_row_itself(self, row_num, parent):
908
        return super(LeafFilterProxyModel, self).filterAcceptsRow(row_num, parent)
909

    
910
    def filter_accepts_any_parent(self, parent):
911
        """
912
        Traverse to the root node and check if any of the
913
        ancestors match the filter
914
        """
915
        while parent.isValid():
916
            if self.filter_accepts_row_itself(parent.row(), parent.parent()):
917
                return True
918
            parent = parent.parent()
919
        return False
920

    
921
    def has_accepted_children(self, row_num, parent):
922
        """
923
        Starting from the current node as root, traverse all
924
        the descendants and test if any of the children match
925
        """
926
        model = self.sourceModel()
927
        source_index = model.index(row_num, 0, parent)
928

    
929
        children_count = model.rowCount(source_index)
930
        for i in range(children_count):
931
            if self.filterAcceptsRow(i, source_index):
932
                return True
933
        return False
    (1-1/1)