diff --git a/src/providers/postgres/CMakeLists.txt b/src/providers/postgres/CMakeLists.txt index 89371a0c758d..6b0d7c602bd9 100644 --- a/src/providers/postgres/CMakeLists.txt +++ b/src/providers/postgres/CMakeLists.txt @@ -29,6 +29,8 @@ if (WITH_GUI) raster/qgspostgresrastertemporalsettingswidget.cpp qgspostgresutils.cpp qgspostgresimportprojectdialog.cpp + qgspostgresprojectversionsdialog.cpp + qgspostgresprojectversionsmodel.cpp ) set(PG_UIS ${CMAKE_SOURCE_DIR}/src/ui/qgspostgresrastertemporalsettingswidgetbase.ui) diff --git a/src/providers/postgres/qgspostgresdataitemguiprovider.cpp b/src/providers/postgres/qgspostgresdataitemguiprovider.cpp index e1167a7799fe..d12af45e40c3 100644 --- a/src/providers/postgres/qgspostgresdataitemguiprovider.cpp +++ b/src/providers/postgres/qgspostgresdataitemguiprovider.cpp @@ -29,6 +29,8 @@ #include "qgspostgresconn.h" #include "qgspostgresdataitems.h" #include "qgspostgresimportprojectdialog.h" +#include "qgspostgresprojectstoragedialog.h" +#include "qgspostgresprojectversionsdialog.h" #include "qgspostgresutils.h" #include "qgsproject.h" #include "qgsprovidermetadata.h" @@ -138,6 +140,14 @@ void QgsPostgresDataItemGuiProvider::populateContextMenu( QgsDataItem *item, QMe QAction *actionImportProject = new QAction( tr( "Import Projects…" ), projectMenu ); projectMenu->addAction( actionImportProject ); connect( actionImportProject, &QAction::triggered, this, [schemaItem, context] { saveProjects( schemaItem, context ); } ); + + QAction *enableAllowProjectVersioning = new QAction( tr( "Enable Projects Versioning…" ), projectMenu ); + projectMenu->addAction( enableAllowProjectVersioning ); + enableAllowProjectVersioning->setEnabled( !schemaItem->projectVersioningEnabled() ); + connect( enableAllowProjectVersioning, &QAction::triggered, this, [schemaItem, context] { + bool enabled = enableProjectsVersioning( schemaItem->connectionName(), schemaItem->name(), context ); + schemaItem->setProjectVersioningEnabled( enabled ); + } ); } } @@ -196,6 +206,40 @@ void QgsPostgresDataItemGuiProvider::populateContextMenu( QgsDataItem *item, QMe QAction *setProjectCommentAction = new QAction( tr( "Set Comment…" ), menu ); connect( setProjectCommentAction, &QAction::triggered, this, [projectItem, context] { setProjectComment( projectItem, context ); } ); menu->addAction( setProjectCommentAction ); + + // Project versioning + QgsPGSchemaItem *parentSchemaItem = qobject_cast( item->parent() ); + + if ( parentSchemaItem && parentSchemaItem->projectVersioningEnabled() ) + { + QAction *showProjectVersions = new QAction( tr( "Show Project Versions…" ), menu ); + menu->addAction( showProjectVersions ); + connect( showProjectVersions, &QAction::triggered, this, [projectItem] { + QgsPostgresProjectVersionsDialog dlg = QgsPostgresProjectVersionsDialog( projectItem->connectionName(), projectItem->schemaName(), projectItem->name(), nullptr ); + if ( dlg.exec() == QDialog::Accepted ) + // TODO if provider would have access to QgsInterface we could handle closing currently open project correctly, right now the project is just closed + { + const QString uri = dlg.selectedProjectUri(); + if ( !uri.isEmpty() ) + { + QgsTemporaryCursorOverride override( Qt::WaitCursor ); + QgsProject::instance()->read( uri ); + } + } + } ); + } + else + { + QAction *enableAllowProjectVersioning = new QAction( tr( "Enable Projects Versioning…" ), menu ); + menu->addAction( enableAllowProjectVersioning ); + connect( enableAllowProjectVersioning, &QAction::triggered, this, [projectItem, parentSchemaItem, context] { + bool enabled = enableProjectsVersioning( projectItem->connectionName(), projectItem->schemaName(), context ); + if ( parentSchemaItem ) + { + parentSchemaItem->setProjectVersioningEnabled( enabled ); + } + } ); + } } else { @@ -1262,3 +1306,36 @@ void QgsPostgresDataItemGuiProvider::saveProjects( QgsPGSchemaItem *schemaItem, conn->unref(); } + + +bool QgsPostgresDataItemGuiProvider::enableProjectsVersioning( const QString connectionName, const QString &schemaName, QgsDataItemGuiContext context ) +{ + const QgsDataSourceUri uri = QgsPostgresConn::connUri( connectionName ); + QgsPostgresConn *conn = QgsPostgresConn::connectDb( uri, false ); + + if ( QgsPostgresUtils::qgisProjectVersioningEnabled( conn, schemaName ) ) + { + notify( tr( "QGIS Project Versioning" ), tr( "Versioning of QGIS projects already active in schema “%1”." ).arg( schemaName ), context, Qgis::MessageLevel::Info ); + conn->unref(); + return false; + } + + QMessageBox::StandardButton result = QgsPostgresProjectStorageDialog::questionAllowProjectVersioning( nullptr, schemaName ); + + if ( result == QMessageBox::StandardButton::Yes ) + { + if ( !QgsPostgresUtils::enableQgisProjectVersioning( conn, schemaName ) ) + { + notify( tr( "QGIS Project Versioning" ), tr( "Cannot setup versioning of QGIS projects in schema “%1”." ).arg( schemaName ), context, Qgis::MessageLevel::Critical ); + conn->unref(); + return false; + } + + notify( tr( "QGIS Project Versioning" ), tr( "Versioning of QGIS projects setup in schema “%1”." ).arg( schemaName ), context, Qgis::MessageLevel::Success ); + conn->unref(); + return true; + } + + conn->unref(); + return false; +} diff --git a/src/providers/postgres/qgspostgresdataitemguiprovider.h b/src/providers/postgres/qgspostgresdataitemguiprovider.h index 2fdd415eaf15..808e3d23e4d5 100644 --- a/src/providers/postgres/qgspostgresdataitemguiprovider.h +++ b/src/providers/postgres/qgspostgresdataitemguiprovider.h @@ -67,6 +67,7 @@ class QgsPostgresDataItemGuiProvider : public QObject, public QgsDataItemGuiProv static void saveCurrentProject( QgsPGSchemaItem *schemaItem, QgsDataItemGuiContext context ); static void saveProjects( QgsPGSchemaItem *schemaItem, QgsDataItemGuiContext context ); static void setProjectComment( QgsPGProjectItem *projectItem, QgsDataItemGuiContext context ); + static bool enableProjectsVersioning( const QString connectionName, const QString &schemaName, QgsDataItemGuiContext context ); }; #endif // QGSPOSTGRESDATAITEMGUIPROVIDER_H diff --git a/src/providers/postgres/qgspostgresdataitems.cpp b/src/providers/postgres/qgspostgresdataitems.cpp index de3b7a80bacd..49c402bd1721 100644 --- a/src/providers/postgres/qgspostgresdataitems.cpp +++ b/src/providers/postgres/qgspostgresdataitems.cpp @@ -171,6 +171,14 @@ QgsPGSchemaItem::QgsPGSchemaItem( QgsDataItem *parent, const QString &connection , mConnectionName( connectionName ) { mIconName = u"mIconDbSchema.svg"_s; + + const QgsDataSourceUri uri = QgsPostgresConn::connUri( mConnectionName ); + QgsPostgresConn *conn = QgsPostgresConn::connectDb( uri, false ); + if ( conn ) + { + mProjectVersioningEnabled = QgsPostgresUtils::qgisProjectVersioningEnabled( conn, mName ); + } + conn->unref(); } QVector QgsPGSchemaItem::createChildren() diff --git a/src/providers/postgres/qgspostgresdataitems.h b/src/providers/postgres/qgspostgresdataitems.h index 46c6aaa9f2c0..f04f297acb96 100644 --- a/src/providers/postgres/qgspostgresdataitems.h +++ b/src/providers/postgres/qgspostgresdataitems.h @@ -76,10 +76,25 @@ class QgsPGSchemaItem : public QgsDatabaseSchemaItem QString connectionName() const { return mConnectionName; } + /** + * Set if versioning of QGIS projects is enabled for this schema. + * + * \since QGIS 4.0 + */ + void setProjectVersioningEnabled( const bool enabled ) { mProjectVersioningEnabled = enabled; } + + /** + * Returns if versioning of QGIS projects is enabled for this schema. + * + * \since QGIS 4.0 + */ + bool projectVersioningEnabled() const { return mProjectVersioningEnabled; } + private: QgsPGLayerItem *createLayer( QgsPostgresLayerProperty layerProperty ); QString mConnectionName; + bool mProjectVersioningEnabled = false; // QgsDataItem interface public: diff --git a/src/providers/postgres/qgspostgresprojectstorage.cpp b/src/providers/postgres/qgspostgresprojectstorage.cpp index 888bdbd66f1e..0b8d395ca64e 100644 --- a/src/providers/postgres/qgspostgresprojectstorage.cpp +++ b/src/providers/postgres/qgspostgresprojectstorage.cpp @@ -102,7 +102,17 @@ bool QgsPostgresProjectStorage::readProject( const QString &uri, QIODevice *devi } bool ok = false; - QString sql( u"SELECT content FROM %1.qgis_projects WHERE name = %2"_s.arg( QgsPostgresConn::quotedIdentifier( projectUri.schemaName ), QgsPostgresConn::quotedValue( projectUri.projectName ) ) ); + QString sql; + + if ( projectUri.isVersion ) + { + sql = u"SELECT content FROM %1.qgis_projects_versions WHERE name = %2 AND date_saved = %3"_s.arg( QgsPostgresConn::quotedIdentifier( projectUri.schemaName ), QgsPostgresConn::quotedValue( projectUri.projectName ), QgsPostgresConn::quotedValue( projectUri.dateSaved ) ); + } + else + { + sql = u"SELECT content FROM %1.qgis_projects WHERE name = %2"_s.arg( QgsPostgresConn::quotedIdentifier( projectUri.schemaName ), QgsPostgresConn::quotedValue( projectUri.projectName ) ); + } + QgsPostgresResult result( conn->PQexec( sql ) ); if ( result.PQresultStatus() == PGRES_TUPLES_OK ) { @@ -261,6 +271,12 @@ QString QgsPostgresProjectStorage::encodeUri( const QgsPostgresProjectUri &postU if ( !postUri.projectName.isEmpty() ) urlQuery.addQueryItem( "project", postUri.projectName ); + if ( postUri.isVersion ) + { + urlQuery.addQueryItem( "isVersion", "true" ); + urlQuery.addQueryItem( "dateSaved", QVariant( postUri.dateSaved ).toString() ); + } + u.setQuery( urlQuery ); return QString::fromUtf8( u.toEncoded() ); @@ -290,5 +306,12 @@ QgsPostgresProjectUri QgsPostgresProjectStorage::decodeUri( const QString &uri ) postUri.schemaName = urlQuery.queryItemValue( "schema" ); postUri.projectName = urlQuery.queryItemValue( "project" ); + + if ( urlQuery.hasQueryItem( "isVersion" ) ) + postUri.isVersion = QVariant( urlQuery.queryItemValue( "isVersion" ) ).toBool(); + + if ( urlQuery.hasQueryItem( "dateSaved" ) ) + postUri.dateSaved = urlQuery.queryItemValue( "dateSaved" ); + return postUri; } diff --git a/src/providers/postgres/qgspostgresprojectstorage.h b/src/providers/postgres/qgspostgresprojectstorage.h index 298e523e2969..e344c941b5c9 100644 --- a/src/providers/postgres/qgspostgresprojectstorage.h +++ b/src/providers/postgres/qgspostgresprojectstorage.h @@ -21,7 +21,7 @@ #include "qgsprojectstorage.h" //! Stores information parsed from postgres project URI -typedef struct +struct QgsPostgresProjectUri { bool valid; @@ -29,8 +29,9 @@ typedef struct QString schemaName; QString projectName; - -} QgsPostgresProjectUri; + bool isVersion = false; + QString dateSaved; +}; //! Implements storage of QGIS projects inside a PostgreSQL table diff --git a/src/providers/postgres/qgspostgresprojectstoragedialog.cpp b/src/providers/postgres/qgspostgresprojectstoragedialog.cpp index 193935399b07..71170269d21c 100644 --- a/src/providers/postgres/qgspostgresprojectstoragedialog.cpp +++ b/src/providers/postgres/qgspostgresprojectstoragedialog.cpp @@ -15,14 +15,16 @@ #include "qgspostgresprojectstoragedialog.h" #include "qgsapplication.h" +#include "qgsguiutils.h" #include "qgspostgresconn.h" #include "qgspostgresconnpool.h" #include "qgspostgresprojectstorage.h" +#include "qgspostgresprojectversionsdialog.h" +#include "qgspostgresutils.h" #include "qgsprojectstorage.h" #include "qgsprojectstorageregistry.h" #include -#include #include #include "moc_qgspostgresprojectstoragedialog.cpp" @@ -42,18 +44,38 @@ QgsPostgresProjectStorageDialog::QgsPostgresProjectStorageDialog( bool saving, Q btnManageProjects->setMenu( menuManageProjects ); buttonBox->addButton( btnManageProjects, QDialogButtonBox::ActionRole ); + mVersionsTreeView->setSelectionBehavior( QAbstractItemView::SelectRows ); + mVersionsTreeView->setSelectionMode( QAbstractItemView::SingleSelection ); + + mVersionsModel = new QgsPostgresProjectVersionsModel( QString(), this ); + mVersionsTreeView->setModel( mVersionsModel ); + + connect( mVersionsModel, &QAbstractTableModel::modelReset, this, [this] { + mVersionsTreeView->resizeColumnToContents( 0 ); + mVersionsTreeView->setCurrentIndex( mVersionsModel->index( 0, 0, QModelIndex() ) ); + } ); + if ( saving ) { setWindowTitle( tr( "Save project to PostgreSQL" ) ); mCboProject->setEditable( true ); + mGroupBoxVersions->setVisible( false ); } else { setWindowTitle( tr( "Load project from PostgreSQL" ) ); + mLabelProjectVersions->setVisible( false ); + mEnableProjectVersions->setVisible( false ); + + mGroupBoxVersions->setCollapsed( true ); } connect( mCboConnection, qOverload( &QComboBox::currentIndexChanged ), this, &QgsPostgresProjectStorageDialog::populateSchemas ); + connect( mCboSchema, qOverload( &QComboBox::currentIndexChanged ), this, &QgsPostgresProjectStorageDialog::onSchemaChanged ); + + connect( mEnableProjectVersions, &QCheckBox::clicked, this, &QgsPostgresProjectStorageDialog::setupQgisProjectVersioning ); + mLblProjectsNotAllowed->setVisible( false ); // populate connections @@ -64,7 +86,6 @@ QgsPostgresProjectStorageDialog::QgsPostgresProjectStorageDialog( bool saving, Q mCboConnection->setCurrentIndex( mCboConnection->findText( toSelect ) ); populateProjects(); - connect( mCboSchema, qOverload( &QComboBox::currentIndexChanged ), this, &QgsPostgresProjectStorageDialog::populateProjects ); connect( mCboProject, &QComboBox::currentTextChanged, this, &QgsPostgresProjectStorageDialog::projectChanged ); projectChanged(); @@ -90,8 +111,10 @@ void QgsPostgresProjectStorageDialog::populateSchemas() mCboSchema->clear(); mCboProject->clear(); - QString name = mCboConnection->currentText(); - QgsDataSourceUri uri = QgsPostgresConn::connUri( name ); + mVersionsModel->setConnection( mCboConnection->currentText() ); + + const QString name = mCboConnection->currentText(); + const QgsDataSourceUri uri = QgsPostgresConn::connUri( name ); bool projectsAllowed = QgsPostgresConn::allowProjectsInDatabase( name ); mLblProjectsNotAllowed->setVisible( !projectsAllowed ); @@ -163,6 +186,17 @@ void QgsPostgresProjectStorageDialog::onOK() void QgsPostgresProjectStorageDialog::projectChanged() { mActionRemoveProject->setEnabled( mCboProject->count() != 0 && mExistingProjects.contains( mCboProject->currentText() ) ); + + if ( !mCboProject->currentText().isEmpty() ) + { + QgsTemporaryCursorOverride override( Qt::WaitCursor ); + + mVersionsModel->populateVersions( mCboSchema->currentText(), mCboProject->currentText() ); + } + else + { + mVersionsModel->clear(); + } } void QgsPostgresProjectStorageDialog::removeProject() @@ -180,9 +214,86 @@ void QgsPostgresProjectStorageDialog::removeProject() QString QgsPostgresProjectStorageDialog::currentProjectUri( bool schemaOnly ) { QgsPostgresProjectUri postUri; - postUri.connInfo = QgsPostgresConn::connUri( mCboConnection->currentText() ); - postUri.schemaName = mCboSchema->currentText(); - if ( !schemaOnly ) - postUri.projectName = mCboProject->currentText(); + + // either project is empty (schema uri is requested) or nothig from versions is selected - return simple uri + if ( mCboProject->currentText().isEmpty() || mVersionsModel->rowCount() == 0 ) + { + postUri.connInfo = QgsPostgresConn::connUri( mCboConnection->currentText() ); + postUri.schemaName = mCboSchema->currentText(); + if ( !schemaOnly ) + postUri.projectName = mCboProject->currentText(); + } + else + { + postUri = mVersionsModel->projectUriForRow( mVersionsTreeView->currentIndex().row() ); + } + return QgsPostgresProjectStorage::encodeUri( postUri ); } + +void QgsPostgresProjectStorageDialog::onSchemaChanged() +{ + QgsTemporaryCursorOverride override( Qt::WaitCursor ); + + const QString name = mCboConnection->currentText(); + const QgsDataSourceUri uri = QgsPostgresConn::connUri( name ); + + QgsPostgresConn *conn = QgsPostgresConn::connectDb( QgsPostgresConn::connectionInfo( uri, false ), false ); + + const bool versioningEnabled = QgsPostgresUtils::qgisProjectVersioningEnabled( conn, mCboSchema->currentText() ); + + conn->unref(); + + QgsSignalBlocker( mEnableProjectVersions )->setChecked( versioningEnabled ); + mEnableProjectVersions->setEnabled( !versioningEnabled ); + + mGroupBoxVersions->setEnabled( versioningEnabled ); + + populateProjects(); +} + +void QgsPostgresProjectStorageDialog::setupQgisProjectVersioning() +{ + if ( mEnableProjectVersions->isChecked() ) + { + QMessageBox::StandardButton result = QgsPostgresProjectStorageDialog::questionAllowProjectVersioning( this, mCboSchema->currentText() ); + + if ( result == QMessageBox::StandardButton::Yes ) + { + QgsTemporaryCursorOverride override( Qt::WaitCursor ); + + const QString name = mCboConnection->currentText(); + const QgsDataSourceUri uri = QgsPostgresConn::connUri( name ); + + QgsPostgresConn *conn = QgsPostgresConn::connectDb( QgsPostgresConn::connectionInfo( uri, false ), false ); + if ( !conn ) + { + QMessageBox::critical( this, tr( "Error" ), tr( "Connection failed" ) + "\n" + QgsPostgresConn::connectionInfo( uri, false ) ); + return; + } + + if ( !QgsPostgresUtils::createProjectsTable( conn, mCboSchema->currentText() ) ) + { + QMessageBox::critical( this, tr( "Error" ), tr( "Could not create qgis_projects table." ) ); + return; + } + + if ( !QgsPostgresUtils::enableQgisProjectVersioning( conn, mCboSchema->currentText() ) ) + { + QMessageBox::critical( this, tr( "Error" ), tr( "Could not setup QGIS project versioning." ) ); + return; + } + + mEnableProjectVersions->setEnabled( false ); + } + else + { + QgsSignalBlocker( mEnableProjectVersions )->setChecked( false ); + } + } +} + +QMessageBox::StandardButton QgsPostgresProjectStorageDialog::questionAllowProjectVersioning( QWidget *parent, const QString &schemaName ) +{ + return QMessageBox::question( parent, tr( "Enable versioning of QGIS projects" ), tr( "Do you want to enable versioning of QGIS projects in the schema “%1”?\nThis will create a new table in the schema and store older versions of QGIS projects there." ).arg( schemaName ) ); +} diff --git a/src/providers/postgres/qgspostgresprojectstoragedialog.h b/src/providers/postgres/qgspostgresprojectstoragedialog.h index 6001192195f7..1a15d559b408 100644 --- a/src/providers/postgres/qgspostgresprojectstoragedialog.h +++ b/src/providers/postgres/qgspostgresprojectstoragedialog.h @@ -17,7 +17,11 @@ #include "ui_qgspostgresprojectstoragedialog.h" +#include "qgspostgresprojectversionsdialog.h" +#include "qgspostgresprojectversionsmodel.h" + #include +#include class QgsPostgresProjectStorageDialog : public QDialog, private Ui::QgsPostgresProjectStorageDialog { @@ -31,6 +35,8 @@ class QgsPostgresProjectStorageDialog : public QDialog, private Ui::QgsPostgresP QString currentProjectUri( bool schemaOnly = false ); + static QMessageBox::StandardButton questionAllowProjectVersioning( QWidget *parent, const QString &schemaName ); + signals: private slots: @@ -41,9 +47,13 @@ class QgsPostgresProjectStorageDialog : public QDialog, private Ui::QgsPostgresP void removeProject(); private: + void onSchemaChanged(); + void setupQgisProjectVersioning(); + bool mSaving = false; //!< Whether using this dialog for loading or saving a project QAction *mActionRemoveProject = nullptr; QStringList mExistingProjects; + QgsPostgresProjectVersionsModel *mVersionsModel = nullptr; }; #endif // QGSPOSTGRESPROJECTSTORAGEDIALOG_H diff --git a/src/providers/postgres/qgspostgresprojectversionsdialog.cpp b/src/providers/postgres/qgspostgresprojectversionsdialog.cpp new file mode 100644 index 000000000000..711770dba035 --- /dev/null +++ b/src/providers/postgres/qgspostgresprojectversionsdialog.cpp @@ -0,0 +1,65 @@ +/*************************************************************************** + qgspostgresprojectversionsdialog.cpp + --------------------- + begin : October 2025 + copyright : (C) 2025 by Jan Caha + email : jan.caha at outlook dot com + *************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + + +#include "qgspostgresprojectversionsdialog.h" + +#include "qgspostgresprojectstorage.h" +#include "qgsproject.h" + +#include +#include + +#include "moc_qgspostgresprojectversionsdialog.cpp" + +QgsPostgresProjectVersionsDialog::QgsPostgresProjectVersionsDialog( const QString &connectionName, const QString &schema, const QString &project, QWidget *parent ) + : QDialog { parent } +{ + setWindowTitle( tr( "Project Versions for “%1” in Schema “%2”" ).arg( project, schema ) ); + setMinimumWidth( 600 ); + + QVBoxLayout *layout = new QVBoxLayout(); + setLayout( layout ); + + mTreeView = new QTreeView( this ); + mTreeView->setSelectionBehavior( QAbstractItemView::SelectRows ); + mTreeView->setSelectionMode( QAbstractItemView::SingleSelection ); + + mModel = new QgsPostgresProjectVersionsModel( connectionName, this ); + mTreeView->setModel( mModel ); + mModel->populateVersions( schema, project ); + mTreeView->resizeColumnToContents( 0 ); + mTreeView->setCurrentIndex( mModel->index( 0, 0, QModelIndex() ) ); + + mButtonBox = new QDialogButtonBox( QDialogButtonBox::Cancel | QDialogButtonBox::Yes, this ); + QPushButton *yesButton = mButtonBox->button( QDialogButtonBox::Yes ); + if ( yesButton ) + { + yesButton->setText( tr( "Load selected version of the project" ) ); + } + + connect( mButtonBox, &QDialogButtonBox::rejected, this, &QgsPostgresProjectVersionsDialog::reject ); + connect( mButtonBox, &QDialogButtonBox::accepted, this, &QgsPostgresProjectVersionsDialog::accept ); + + layout->addWidget( mTreeView ); + layout->addWidget( mButtonBox ); +} + +QString QgsPostgresProjectVersionsDialog::selectedProjectUri() const +{ + QgsPostgresProjectUri projectUri = mModel->projectUriForRow( mTreeView->currentIndex().row() ); + QString encodedUri = QgsPostgresProjectStorage::encodeUri( projectUri ); + return encodedUri; +} diff --git a/src/providers/postgres/qgspostgresprojectversionsdialog.h b/src/providers/postgres/qgspostgresprojectversionsdialog.h new file mode 100644 index 000000000000..d5c769496d55 --- /dev/null +++ b/src/providers/postgres/qgspostgresprojectversionsdialog.h @@ -0,0 +1,41 @@ +/*************************************************************************** + qgspostgresprojectversionsdialog.h + --------------------- + begin : October 2025 + copyright : (C) 2025 by Jan Caha + email : jan.caha at outlook dot com + *************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ +#ifndef QGSPOSTGRESPROJECTVERSIONSDIALOG_H +#define QGSPOSTGRESPROJECTVERSIONSDIALOG_H + +#include "qgspostgresprojectversionsmodel.h" + +#include +#include +#include + +class QgsPostgresProjectVersionsDialog : public QDialog +{ + Q_OBJECT + public: + QgsPostgresProjectVersionsDialog( const QString &connectionName, const QString &schema, const QString &project, QWidget *parent = nullptr ); + + /** + * Uri of the selected project version. + */ + QString selectedProjectUri() const; + + private: + QgsPostgresProjectVersionsModel *mModel = nullptr; + QTreeView *mTreeView = nullptr; + QDialogButtonBox *mButtonBox = nullptr; +}; + +#endif // QGSPOSTGRESPROJECTVERSIONSDIALOG_H diff --git a/src/providers/postgres/qgspostgresprojectversionsmodel.cpp b/src/providers/postgres/qgspostgresprojectversionsmodel.cpp new file mode 100644 index 000000000000..b9c404c2b147 --- /dev/null +++ b/src/providers/postgres/qgspostgresprojectversionsmodel.cpp @@ -0,0 +1,207 @@ +/*************************************************************************** + qgspostgresprojectversionsmodel.cpp + --------------------- + begin : October 2025 + copyright : (C) 2025 by Jan Caha + email : jan.caha at outlook dot com + *************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#include "qgspostgresprojectversionsmodel.h" + +#include "qgsguiutils.h" +#include "qgspostgresutils.h" + +#include "moc_qgspostgresprojectversionsmodel.cpp" + +QgsPostgresProjectVersionsModel::QgsPostgresProjectVersionsModel( const QString &connectionName, QObject *parent ) + : QAbstractItemModel( parent ) + , mConnectionName( connectionName ) +{ + if ( !mConnectionName.isEmpty() ) + { + mConn = QgsPostgresConn::connectDb( QgsPostgresConn::connUri( connectionName ), true ); + } +} + +QgsPostgresProjectVersionsModel::~QgsPostgresProjectVersionsModel() +{ + if ( mConn ) + { + mConn->unref(); + } +} + +void QgsPostgresProjectVersionsModel::setConnection( const QString &connectionName ) +{ + if ( mConn ) + { + mConn->unref(); + mConn = nullptr; + } + + mConnectionName = connectionName; + + if ( !connectionName.isEmpty() ) + { + mConn = QgsPostgresConn::connectDb( QgsPostgresConn::connUri( connectionName ), true ); + } +} + +int QgsPostgresProjectVersionsModel::rowCount( const QModelIndex &parent ) const +{ + if ( parent.isValid() ) + return 0; + + return static_cast( mVersions.size() ); +} + +int QgsPostgresProjectVersionsModel::columnCount( const QModelIndex &parent ) const +{ + if ( parent.isValid() ) + return 0; + + return 3; // ModifiedTime, ModifiedUser, Comment +} + +QVariant QgsPostgresProjectVersionsModel::data( const QModelIndex &index, int role ) const +{ + if ( !index.isValid() || index.row() >= mVersions.size() ) + return QVariant(); + + const PgProjectVersionData &version = mVersions.at( index.row() ); + + if ( role == Qt::DisplayRole ) + { + switch ( index.column() ) + { + case ModifiedTime: + return version.modifiedTime; + case ModifiedUser: + return version.modifiedUser; + case Comment: + return version.comment; + default: + return QVariant(); + } + } + else if ( role == Qt::UserRole && index.column() == ModifiedTime ) + { + // Store date_saved in UserRole for version identification + return version.dateSaved; + } + + return QVariant(); +} + +QVariant QgsPostgresProjectVersionsModel::headerData( int section, Qt::Orientation orientation, int role ) const +{ + if ( orientation == Qt::Horizontal && role == Qt::DisplayRole ) + { + switch ( section ) + { + case ModifiedTime: + return tr( "Modified Time" ); + case ModifiedUser: + return tr( "Modified User" ); + case Comment: + return tr( "Comment" ); + default: + return QVariant(); + } + } + + return QAbstractItemModel::headerData( section, orientation, role ); +} + +void QgsPostgresProjectVersionsModel::populateVersions( const QString &schema, const QString &project ) +{ + beginResetModel(); + + mVersions.clear(); + mSchema = schema; + mProject = project; + + if ( !mConn ) + { + endResetModel(); + return; + } + + QgsTemporaryCursorOverride override( Qt::WaitCursor ); + + const bool versioningEnabled = QgsPostgresUtils::qgisProjectVersioningEnabled( mConn, schema ); + + if ( versioningEnabled ) + { + const QString sqlVersions = QStringLiteral( "SELECT '', CONCAT((metadata->>'last_modified_time')::TIMESTAMP(0)::TEXT, ' (latest version)'), (metadata->>'last_modified_user'), comment FROM %1.qgis_projects WHERE name = %2 " + "UNION ALL " + "SELECT * FROM ( SELECT date_saved, (metadata->>'last_modified_time')::TIMESTAMP(0)::TEXT, (metadata->>'last_modified_user'), comment FROM %1.qgis_projects_versions WHERE name = %2 ORDER BY (metadata->>'last_modified_time')::TIMESTAMP DESC)" ) + .arg( QgsPostgresConn::quotedIdentifier( schema ), QgsPostgresConn::quotedValue( project ) ); + + QgsPostgresResult resultVersions( mConn->PQexec( sqlVersions ) ); + + mVersions.reserve( resultVersions.PQntuples() ); + + for ( int i = 0; i < resultVersions.PQntuples(); ++i ) + { + PgProjectVersionData version; + version.dateSaved = resultVersions.PQgetvalue( i, 0 ); // date_saved (empty for latest) + version.modifiedTime = resultVersions.PQgetvalue( i, 1 ); // metadata->>'last_modified_time' + version.modifiedUser = resultVersions.PQgetvalue( i, 2 ); // metadata->>'last_modified_user' + version.comment = resultVersions.PQgetvalue( i, 3 ); // comment + + mVersions.append( version ); + } + } + + endResetModel(); +} + +QgsPostgresProjectUri QgsPostgresProjectVersionsModel::projectUriForRow( int row ) const +{ + QgsPostgresProjectUri postUri; + postUri.connInfo = QgsPostgresConn::connUri( mConnectionName ); + postUri.schemaName = mSchema; + postUri.projectName = mProject; + + if ( row >= 0 && row < mVersions.size() ) + { + const PgProjectVersionData &version = mVersions.at( row ); + if ( !version.dateSaved.isEmpty() ) + { + postUri.isVersion = true; + postUri.dateSaved = version.dateSaved; + } + } + + return postUri; +} + +void QgsPostgresProjectVersionsModel::clear() +{ + beginResetModel(); + mVersions.clear(); + mSchema.clear(); + mProject.clear(); + endResetModel(); +} + +QModelIndex QgsPostgresProjectVersionsModel::index( int row, int column, const QModelIndex &parent ) const +{ + Q_UNUSED( parent ); + return createIndex( row, column ); +} + +QModelIndex QgsPostgresProjectVersionsModel::parent( const QModelIndex &child ) const +{ + Q_UNUSED( child ); + + return QModelIndex(); +} diff --git a/src/providers/postgres/qgspostgresprojectversionsmodel.h b/src/providers/postgres/qgspostgresprojectversionsmodel.h new file mode 100644 index 000000000000..38bec26f9f09 --- /dev/null +++ b/src/providers/postgres/qgspostgresprojectversionsmodel.h @@ -0,0 +1,97 @@ +/*************************************************************************** + qgspostgresprojectversionsmodel.h + --------------------- + begin : October 2025 + copyright : (C) 2025 by Jan Caha + email : jan.caha at outlook dot com + *************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ +#ifndef QGSPOSTGRESPROJECTVERSIONSMODEL_H +#define QGSPOSTGRESPROJECTVERSIONSMODEL_H + +#include "qgspostgresconn.h" +#include "qgspostgresprojectstorage.h" + +#include +#include + +/** + * A table model for displaying PostgreSQL project versions + * + * This model displays project version information including: + * + * - Modified Time + * - Modified User + * - Comment + * + * The model stores the date_saved internally for version identification. + */ +class QgsPostgresProjectVersionsModel : public QAbstractItemModel +{ + Q_OBJECT + + public: + enum Column + { + ModifiedTime = 0, //!< Modified timestamp + ModifiedUser = 1, //!< User who modified the project + Comment = 2 //!< Comment for the version + }; + + explicit QgsPostgresProjectVersionsModel( const QString &connectionName, QObject *parent = nullptr ); + + ~QgsPostgresProjectVersionsModel() override; + + int rowCount( const QModelIndex &parent = QModelIndex() ) const override; + int columnCount( const QModelIndex &parent = QModelIndex() ) const override; + QVariant data( const QModelIndex &index, int role = Qt::DisplayRole ) const override; + QVariant headerData( int section, Qt::Orientation orientation, int role = Qt::DisplayRole ) const override; + QModelIndex index( int row, int column, const QModelIndex &parent ) const override; + QModelIndex parent( const QModelIndex &child ) const override; + + /** + * Populate the model with versions for a given schema and project + */ + void populateVersions( const QString &schema, const QString &project ); + + /** + * Get the project URI for a specific row + */ + QgsPostgresProjectUri projectUriForRow( int row ) const; + + /** + * Set the database connection + */ + void setConnection( const QString &connectionName ); + + /** + * Clear all data from the model + */ + void clear(); + + private: + /** + * Structure to hold version data + */ + struct PgProjectVersionData + { + QString dateSaved; //!< Date saved (empty for latest version) + QString modifiedTime; //!< Display modified time + QString modifiedUser; //!< User who modified + QString comment; //!< Comment + }; + + QgsPostgresConn *mConn = nullptr; + QString mConnectionName; + QString mSchema; + QString mProject; + QVector mVersions; +}; + +#endif // QGSPOSTGRESPROJECTVERSIONSMODEL_H diff --git a/src/providers/postgres/qgspostgresutils.cpp b/src/providers/postgres/qgspostgresutils.cpp index 4681f04cc87d..d5acb6754ab6 100644 --- a/src/providers/postgres/qgspostgresutils.cpp +++ b/src/providers/postgres/qgspostgresutils.cpp @@ -611,6 +611,19 @@ bool QgsPostgresUtils::moveProjectToSchema( QgsPostgresConn *conn, const QString return false; } + if ( QgsPostgresUtils::qgisProjectVersioningEnabled( conn, originalSchema ) ) + { + if ( !QgsPostgresUtils::enableQgisProjectVersioning( conn, targetSchema ) ) + { + return false; + } + + if ( !QgsPostgresUtils::moveProjectVersions( conn, originalSchema, projectName, targetSchema ) ) + { + return false; + } + } + if ( !QgsPostgresUtils::deleteProjectFromSchema( conn, projectName, originalSchema ) ) { return false; @@ -692,3 +705,132 @@ bool QgsPostgresUtils::addCommentColumnToProjectsTable( QgsPostgresConn *conn, c QgsPostgresResult resAddColumn( conn->PQexec( sqlAddColumn ) ); return resAddColumn.PQresultStatus() == PGRES_COMMAND_OK; } + +bool QgsPostgresUtils::enableQgisProjectVersioning( QgsPostgresConn *conn, const QString &schema ) +{ + // ensure that the qgis_projects table exists + if ( !QgsPostgresUtils::createProjectsTable( conn, schema ) ) + { + return false; + } + + // if the qgis_projects table has old format (no comment column) it needs to be updated first + if ( !QgsPostgresUtils::addCommentColumnToProjectsTable( conn, schema ) ) + { + return false; + } + + // create the necessary table for project versioning + const QString sqlCreateTable = QStringLiteral( "CREATE TABLE IF NOT EXISTS %1.qgis_projects_versions (" + "id SERIAL PRIMARY KEY, " + "name TEXT, " + "metadata JSONB, " + "content BYTEA, " + "date_saved TEXT NOT NULL, " + "comment TEXT" + ")" ) + .arg( QgsPostgresConn::quotedIdentifier( schema ) ); + + QgsPostgresResult resultCreateTable( conn->PQexec( sqlCreateTable ) ); + if ( resultCreateTable.PQresultStatus() != PGRES_COMMAND_OK ) + { + return false; + } + + const QString sqlFunctionTrigger = QStringLiteral( R"( +CREATE OR REPLACE FUNCTION %1.sync_qgis_project_version() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + DELETE FROM %1.qgis_projects_versions + WHERE name = OLD.NAME; + + ELSIF TG_OP = 'UPDATE' THEN + IF NEW.content IS DISTINCT FROM OLD.content THEN + INSERT INTO %1.qgis_projects_versions ( name, metadata, content, comment, date_saved ) + VALUES (OLD.name, OLD.metadata, OLD.content, OLD.comment, + NOW()::TIMESTAMP(0)::TEXT + ); + + DELETE FROM %1.qgis_projects_versions + WHERE id IN( + SELECT id FROM %1.qgis_projects_versions + WHERE name = NEW.name + ORDER BY (metadata->>'last_modified_time')::TIMESTAMP DESC + OFFSET 10 + ); + END IF; + END IF; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE TRIGGER qgis_project_versions + BEFORE UPDATE OR DELETE ON %1.qgis_projects + FOR EACH ROW EXECUTE FUNCTION %1.sync_qgis_project_version(); +)" ) + .arg( QgsPostgresConn::quotedIdentifier( schema ) ); + + QgsPostgresResult resultCreateTrigger( conn->PQexec( sqlFunctionTrigger ) ); + return resultCreateTrigger.PQresultStatus() == PGRES_COMMAND_OK; +} + +bool QgsPostgresUtils::disableQgisProjectVersioning( QgsPostgresConn *conn, const QString &schema ) +{ + const QString sqlDropTrigger = u"DROP TRIGGER IF EXISTS qgis_project_versions ON %1.qgis_projects;"_s + .arg( QgsPostgresConn::quotedIdentifier( schema ) ); + + QgsPostgresResult result( conn->PQexec( sqlDropTrigger ) ); + return result.PQresultStatus() == PGRES_COMMAND_OK; +} + +bool QgsPostgresUtils::qgisProjectVersioningEnabled( QgsPostgresConn *conn, const QString &schema ) +{ + const QString sqlCheck = QStringLiteral( "SELECT EXISTS (" + "SELECT 1 " + "FROM information_schema.triggers " + "WHERE trigger_schema = %1 " + "AND trigger_name = 'qgis_project_versions' " + "AND event_object_table = 'qgis_projects'" + ") AS trigger_exists, " + "EXISTS (" + "SELECT 1 " + "FROM information_schema.tables " + "WHERE table_schema = %1 " + "AND table_name = 'qgis_projects_versions' " + ") AS table_exists;" ) + .arg( QgsPostgresConn::quotedValue( schema ) ); + + QgsPostgresResult res( conn->PQexec( sqlCheck ) ); + return res.PQgetvalue( 0, 0 ).startsWith( 't'_L1 ) && res.PQgetvalue( 0, 1 ).startsWith( 't'_L1 ); +} + +bool QgsPostgresUtils::moveProjectVersions( QgsPostgresConn *conn, const QString &originalSchema, const QString &project, const QString &targetSchema ) +{ + const QString sqlCopy = u"INSERT INTO %1.qgis_projects_versions SELECT * FROM %2.qgis_projects_versions WHERE name=%3;"_s + .arg( QgsPostgresConn::quotedIdentifier( targetSchema ) ) + .arg( QgsPostgresConn::quotedIdentifier( originalSchema ) ) + .arg( QgsPostgresConn::quotedValue( project ) ); + + QgsPostgresResult resCopy( conn->PQexec( sqlCopy ) ); + + if ( resCopy.PQresultStatus() != PGRES_COMMAND_OK ) + { + return false; + } + + const QString sqlDelete = u"DELETE FROM %1.qgis_projects_versions WHERE name=%2;"_s + .arg( QgsPostgresConn::quotedIdentifier( originalSchema ) ) + .arg( QgsPostgresConn::quotedValue( project ) ); + ; + + QgsPostgresResult resDelete( conn->PQexec( sqlDelete ) ); + + if ( resDelete.PQresultStatus() != PGRES_COMMAND_OK ) + { + return false; + } + + return true; +} diff --git a/src/providers/postgres/qgspostgresutils.h b/src/providers/postgres/qgspostgresutils.h index d7990cac1983..a06dd2e5df50 100644 --- a/src/providers/postgres/qgspostgresutils.h +++ b/src/providers/postgres/qgspostgresutils.h @@ -179,6 +179,34 @@ class QgsPostgresUtils * \since QGIS 4.0 */ static bool addCommentColumnToProjectsTable( QgsPostgresConn *conn, const QString &schemaName ); + + /** + * Sets up the necessary database structures for QGIS project versioning in \a schema. + * + * \since QGIS 4.0 + */ + static bool enableQgisProjectVersioning( QgsPostgresConn *conn, const QString &schema ); + + /** + * Disables QGIS project versioning for the specified \a schema. + * + * \since QGIS 4.0 + */ + static bool disableQgisProjectVersioning( QgsPostgresConn *conn, const QString &schema ); + + /** + * Check if QGIS project versioning is active for the specified \a schema. + * + * \since QGIS 4.0 + */ + static bool qgisProjectVersioningEnabled( QgsPostgresConn *conn, const QString &schema ); + + /** + * Move project versions to \a targetSchema + * + * \since QGIS 4.0 + */ + static bool moveProjectVersions( QgsPostgresConn *conn, const QString &originalSchema, const QString &project, const QString &targetSchema ); }; #endif diff --git a/src/ui/qgspostgresprojectstoragedialog.ui b/src/ui/qgspostgresprojectstoragedialog.ui index 8eaf4e401e47..31e6dd7f3c14 100644 --- a/src/ui/qgspostgresprojectstoragedialog.ui +++ b/src/ui/qgspostgresprojectstoragedialog.ui @@ -20,8 +20,15 @@ - - + + + + Project + + + + + @@ -30,18 +37,25 @@ - - + + + + + - + - Project + Enable storing project versions: - + + + + + @@ -51,7 +65,7 @@ Storage of QGIS projects is not enabled for this database connection. - Qt::AlignCenter + Qt::AlignmentFlag::AlignCenter true @@ -59,30 +73,108 @@ - - - Qt::Vertical + + + QFrame::Shape::NoFrame - - - 20 - 40 - + + true - + + + + 0 + 0 + 534 + 252 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Previous Versions of the Project + + + false + + + false + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + - Qt::Horizontal + Qt::Orientation::Horizontal - QDialogButtonBox::Cancel|QDialogButtonBox::Ok + QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok + + + QgsCollapsibleGroupBox + QGroupBox +
qgscollapsiblegroupbox.h
+ 1 +
+ + QgsScrollArea + QScrollArea +
qgsscrollarea.h
+ 1 +
+