diff --git a/app/activeproject.cpp b/app/activeproject.cpp index acfb53093..2614e9f6a 100644 --- a/app/activeproject.cpp +++ b/app/activeproject.cpp @@ -184,6 +184,9 @@ bool ActiveProject::forceLoad( const QString &filePath, bool force ) CoreUtils::log( QStringLiteral( "Project load" ), QStringLiteral( "Could not find project in local projects: " ) + filePath ); } + QString role = MerginProjectMetadata::fromCachedJson( CoreUtils::getProjectMetadataPath( mLocalProject.projectDir ) ).role; + setProjectRole( role ); + updateMapTheme(); updateRecordingLayers(); updateActiveLayer(); @@ -553,3 +556,18 @@ bool ActiveProject::positionTrackingSupported() const return mQgsProject->readBoolEntry( QStringLiteral( "Mergin" ), QStringLiteral( "PositionTracking/Enabled" ), false ); } + +QString ActiveProject::projectRole() const +{ + return mProjectRole; +} + +void ActiveProject::setProjectRole( const QString &role ) +{ + if ( mProjectRole != role ) + { + mProjectRole = role; + + emit projectRoleChanged(); + } +} diff --git a/app/activeproject.h b/app/activeproject.h index b775303f6..c9a828fee 100644 --- a/app/activeproject.h +++ b/app/activeproject.h @@ -22,6 +22,7 @@ #include "localprojectsmanager.h" #include "autosynccontroller.h" #include "inputmapsettings.h" +#include "merginprojectmetadata.h" /** * \brief The ActiveProject class can load a QGIS project and holds its data. @@ -33,6 +34,7 @@ class ActiveProject: public QObject Q_PROPERTY( QgsProject *qgsProject READ qgsProject NOTIFY qgsProjectChanged ) // QgsProject instance of active project, never changes Q_PROPERTY( AutosyncController *autosyncController READ autosyncController NOTIFY autosyncControllerChanged ) Q_PROPERTY( InputMapSettings *mapSettings READ mapSettings WRITE setMapSettings NOTIFY mapSettingsChanged ) + Q_PROPERTY( QString projectRole READ projectRole WRITE setProjectRole NOTIFY projectRoleChanged ) Q_PROPERTY( QString mapTheme READ mapTheme WRITE setMapTheme NOTIFY mapThemeChanged ) Q_PROPERTY( bool positionTrackingSupported READ positionTrackingSupported NOTIFY positionTrackingSupportedChanged ) @@ -118,6 +120,12 @@ class ActiveProject: public QObject bool positionTrackingSupported() const; + /** + * Returns role/permission level of current user for this project + */ + Q_INVOKABLE QString projectRole() const; + void setProjectRole( const QString &role ); + signals: void qgsProjectChanged(); void localProjectChanged( LocalProject project ); @@ -145,6 +153,8 @@ class ActiveProject: public QObject // Emited when the app (UI) should show tracking because there is a running tracking service void startPositionTracking(); + void projectRoleChanged(); + public slots: // Reloads project if current project path matches given path (its the same project) bool reloadProject( QString projectDir ); @@ -182,10 +192,10 @@ class ActiveProject: public QObject LayersProxyModel &mRecordingLayerPM; LocalProjectsManager &mLocalProjectsManager; InputMapSettings *mMapSettings = nullptr; - std::unique_ptr mAutosyncController; QString mProjectLoadingLog; + QString mProjectRole; /** * Reloads project. diff --git a/app/main.cpp b/app/main.cpp index 2ff044e83..6f741af8f 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -495,6 +495,7 @@ int main( int argc, char *argv[] ) ActiveLayer al; ActiveProject activeProject( as, al, recordingLpm, localProjectsManager ); + std::unique_ptr vm( new VariablesManager( ma.get() ) ); vm->registerInputExpressionFunctions(); @@ -569,6 +570,27 @@ int main( int argc, char *argv[] ) syncManager.syncProject( project, SyncOptions::Authorized, SyncOptions::Retry ); } ); + QObject::connect( &activeProject, &ActiveProject::projectReloaded, &lambdaContext, [merginApi = ma.get(), &activeProject]() + { + merginApi->reloadProjectRole( activeProject.projectFullName() ); + } ); + + QObject::connect( ma.get(), &MerginApi::authChanged, &lambdaContext, [merginApi = ma.get(), &activeProject]() + { + if ( activeProject.isProjectLoaded() ) + { + merginApi->reloadProjectRole( activeProject.projectFullName() ); + } + } ); + + QObject::connect( ma.get(), &MerginApi::projectRoleUpdated, &activeProject, [&activeProject]( const QString & projectFullName, const QString & role ) + { + if ( projectFullName == activeProject.projectFullName() ) + { + activeProject.setProjectRole( role ); + } + } ); + QObject::connect( ma.get(), &MerginApi::notifyInfo, &lambdaContext, [¬ificationModel]( const QString & message ) { notificationModel.addInfo( message ); diff --git a/app/qml/form/MMFormPage.qml b/app/qml/form/MMFormPage.qml index f12a9647f..1168a06d0 100644 --- a/app/qml/form/MMFormPage.qml +++ b/app/qml/form/MMFormPage.qml @@ -201,7 +201,7 @@ Page { footer: MMComponents.MMToolbar { - visible: !root.layerIsReadOnly + visible: !root.layerIsReadOnly && __activeProject.projectRole !== "reader" ObjectModel { id: readStateButtons @@ -231,7 +231,7 @@ Page { id: editGeometry text: qsTr( "Edit geometry" ) iconSource: __style.editIcon - visible: root.layerIsSpatial + visible: root.layerIsSpatial && __activeProject.projectRole !== "reader" onClicked: root.editGeometryRequested( root.controller.featureLayerPair ) } } diff --git a/app/qml/form/MMPreviewDrawer.qml b/app/qml/form/MMPreviewDrawer.qml index ca6753fc0..328d55ebb 100644 --- a/app/qml/form/MMPreviewDrawer.qml +++ b/app/qml/form/MMPreviewDrawer.qml @@ -295,7 +295,7 @@ Item { property bool isHTMLType: root.controller.type === MM.AttributePreviewController.HTML property bool isEmptyType: root.controller.type === MM.AttributePreviewController.Empty - property bool showEditButton: !root.layerIsReadOnly + property bool showEditButton: !root.layerIsReadOnly && __activeProject.projectRole !== "reader" property bool showStakeoutButton: __inputUtils.isPointLayerFeature( controller.featureLayerPair ) property bool showButtons: showEditButton || showStakeoutButton diff --git a/app/qml/form/components/MMFeaturesListPageDrawer.qml b/app/qml/form/components/MMFeaturesListPageDrawer.qml index c6f3a266a..ce9c22933 100644 --- a/app/qml/form/components/MMFeaturesListPageDrawer.qml +++ b/app/qml/form/components/MMFeaturesListPageDrawer.qml @@ -103,6 +103,7 @@ Drawer { } text: qsTr( "Add feature" ) + visible: __activeProject.projectRole !== "reader" onClicked: root.buttonClicked() } diff --git a/app/qml/layers/MMFeaturesListPage.qml b/app/qml/layers/MMFeaturesListPage.qml index 04b8bb256..5bf9c3f9f 100644 --- a/app/qml/layers/MMFeaturesListPage.qml +++ b/app/qml/layers/MMFeaturesListPage.qml @@ -89,7 +89,7 @@ MMComponents.MMPage { anchors.bottom: parent.bottom anchors.bottomMargin: root.hasToolbar ? __style.margin20 : ( __style.safeAreaBottom + __style.margin8 ) - visible: __inputUtils.isNoGeometryLayer( root.selectedLayer ) && !root.layerIsReadOnly + visible: __inputUtils.isNoGeometryLayer( root.selectedLayer ) && !root.layerIsReadOnly && __activeProject.projectRole !== "reader" text: qsTr("Add feature") diff --git a/app/qml/main.qml b/app/qml/main.qml index d6b7ea116..4cb127ddb 100644 --- a/app/qml/main.qml +++ b/app/qml/main.qml @@ -276,6 +276,7 @@ ApplicationWindow { MMToolbarButton { text: qsTr("Add") iconSource: __style.addIcon + visible: __activeProject.projectRole !== "reader" onClicked: { if ( __recordingLayersModel.rowCount() > 0 ) { stateManager.state = "map" diff --git a/app/test/testcoreutils.cpp b/app/test/testcoreutils.cpp index 4eb099e1b..23d6d78e7 100644 --- a/app/test/testcoreutils.cpp +++ b/app/test/testcoreutils.cpp @@ -272,3 +272,83 @@ void TestCoreUtils::testNameAbbr() QCOMPARE( CoreUtils::nameAbbr( name, email ), test.second ); } } + +void TestCoreUtils::testReplaceValueInJson() +{ + // temporary test file + QString testFilePath = QDir::tempPath() + "/test_replace_value.json"; + + // basic replacement in valid JSON with int value + { + QFile file( testFilePath ); + QVERIFY( file.open( QIODevice::WriteOnly ) ); + file.write( R"({"name": "test", "value": 123})" ); + file.close(); + + QVERIFY( CoreUtils::replaceValueInJson( testFilePath, "value", 456 ) ); + + // verify + QVERIFY( file.open( QIODevice::ReadOnly ) ); + QJsonDocument doc = QJsonDocument::fromJson( file.readAll() ); + file.close(); + QVERIFY( doc.isObject() ); + QJsonObject obj = doc.object(); + QCOMPARE( obj["value"].toInt(), 456 ); + QCOMPARE( obj["name"].toString(), QString( "test" ) ); + } + // valid JSON with string value + { + QFile file( testFilePath ); + QVERIFY( file.open( QIODevice::WriteOnly ) ); + file.write( R"({"name": "test", "status": "active"})" ); + file.close(); + + QVERIFY( CoreUtils::replaceValueInJson( testFilePath, "status", "inactive" ) ); + + // verify replacement + QVERIFY( file.open( QIODevice::ReadOnly ) ); + QJsonDocument doc = QJsonDocument::fromJson( file.readAll() ); + file.close(); + QVERIFY( doc.isObject() ); + QJsonObject obj = doc.object(); + QCOMPARE( obj["status"].toString(), QString( "inactive" ) ); + QCOMPARE( obj["name"].toString(), QString( "test" ) ); + } + + // add new key-value pair + { + QFile file( testFilePath ); + QVERIFY( file.open( QIODevice::WriteOnly ) ); + file.write( R"({"name": "test"})" ); + file.close(); + + QVERIFY( CoreUtils::replaceValueInJson( testFilePath, "newKey", "newValue" ) ); + + // verify the addition + QVERIFY( file.open( QIODevice::ReadOnly ) ); + QJsonDocument doc = QJsonDocument::fromJson( file.readAll() ); + file.close(); + QVERIFY( doc.isObject() ); + QJsonObject obj = doc.object(); + QCOMPARE( obj["newKey"].toString(), QString( "newValue" ) ); + QCOMPARE( obj["name"].toString(), QString( "test" ) ); + } + + // invalid JSON file + { + QFile file( testFilePath ); + QVERIFY( file.open( QIODevice::WriteOnly ) ); + file.write( "invalid json content" ); + file.close(); + + QVERIFY( !CoreUtils::replaceValueInJson( testFilePath, "key", "value" ) ); + } + + // non-existent file + { + QString nonExistentPath = QDir::tempPath() + "/non_existent.json"; + QVERIFY( !CoreUtils::replaceValueInJson( nonExistentPath, "key", "value" ) ); + } + + QFile::remove( testFilePath ); +} diff --git a/app/test/testcoreutils.h b/app/test/testcoreutils.h index 5f78b067c..8f4541b78 100644 --- a/app/test/testcoreutils.h +++ b/app/test/testcoreutils.h @@ -27,6 +27,7 @@ class TestCoreUtils : public QObject void testHasProjectFileExtension(); void testNameValidation(); void testNameAbbr(); + void testReplaceValueInJson(); }; #endif // TESTCOREUTILS_H diff --git a/app/test/testmerginapi.cpp b/app/test/testmerginapi.cpp index 8e7f648c3..a7ee5a4be 100644 --- a/app/test/testmerginapi.cpp +++ b/app/test/testmerginapi.cpp @@ -2943,6 +2943,42 @@ void TestMerginApi::testParseVersion() QCOMPARE( minor, 4 ); } +void TestMerginApi::testUpdateProjectMetadataRole() +{ + QString projectName = "testUpdateProjectMetadataRole"; + + createRemoteProject( mApiExtra, mWorkspaceName, projectName, mTestDataPath + "/" + TEST_PROJECT_NAME + "/" ); + downloadRemoteProject( mApi, mWorkspaceName, projectName ); + + LocalProject projectInfo = mApi->localProjectsManager().projectFromMerginName( mWorkspaceName, projectName ); + QVERIFY( projectInfo.isValid() ); + + QString fullProjectName = MerginApi::getFullProjectName( mWorkspaceName, projectName ); + + // Test 1: Initial role should be 'owner' + QString cachedRole = mApi->getCachedProjectRole( fullProjectName ); + QCOMPARE( cachedRole, QString( "owner" ) ); + + // Test 2: Update cached role to 'reader' + QString newRole = "reader"; + bool updateSuccess = mApi->updateCachedProjectRole( fullProjectName, newRole ); + QVERIFY( updateSuccess ); + + // Verify role was updated in cache + cachedRole = mApi->getCachedProjectRole( fullProjectName ); + QCOMPARE( cachedRole, QString( "reader" ) ); + + // Role in server wasn't updated and stills "owner" => let's reload it from server and see if it updates in cached + QSignalSpy spy( mApi, &MerginApi::projectRoleUpdated ); + mApi->reloadProjectRole( fullProjectName ); + QVERIFY( spy.wait() ); + cachedRole = mApi->getCachedProjectRole( fullProjectName ); + QCOMPARE( cachedRole, QString( "owner" ) ); + + // Clean up + deleteRemoteProjectNow( mApi, mWorkspaceName, projectName ); +} + void TestMerginApi::testDownloadWithNetworkError() { // Store original manager @@ -3081,4 +3117,3 @@ void TestMerginApi::testDownloadWithNetworkErrorRecovery() mApi->setNetworkManager( originalManager ); delete failingManager; } - diff --git a/app/test/testmerginapi.h b/app/test/testmerginapi.h index 9a9640447..bb8b56591 100644 --- a/app/test/testmerginapi.h +++ b/app/test/testmerginapi.h @@ -148,6 +148,7 @@ class TestMerginApi: public QObject void testSynchronizationViaManager(); void testAutosync(); void testAutosyncFailure(); + void testUpdateProjectMetadataRole(); void testRegisterAndDelete(); void testCreateWorkspace(); diff --git a/core/coreutils.cpp b/core/coreutils.cpp index 834e24c12..5d6976768 100644 --- a/core/coreutils.cpp +++ b/core/coreutils.cpp @@ -21,6 +21,8 @@ #include #include #include +#include +#include #include "qcoreapplication.h" @@ -335,3 +337,43 @@ QString CoreUtils::bytesToHumanSize( double bytes ) return QString::number( bytes / 1024.0 / 1024.0 / 1024.0 / 1024.0, 'f', precision ) + " TB"; } } + +QString CoreUtils::getProjectMetadataPath( QString projectDir ) +{ + if ( projectDir.isEmpty() ) + return QString(); + + return projectDir + "/.mergin/mergin.json"; +} + +bool CoreUtils::replaceValueInJson( const QString &filePath, const QString &key, const QJsonValue &value ) +{ + QFile file( filePath ); + if ( !file.open( QIODevice::ReadOnly ) ) + { + return false; + } + + QByteArray data = file.readAll(); + file.close(); + + QJsonDocument doc = QJsonDocument::fromJson( data ); + if ( !doc.isObject() ) + { + return false; + } + + QJsonObject obj = doc.object(); + obj[key] = value; + doc.setObject( obj ); + + if ( !file.open( QIODevice::WriteOnly ) ) + { + return false; + } + + bool success = ( file.write( doc.toJson() ) != -1 ); + file.close(); + + return success; +} diff --git a/core/coreutils.h b/core/coreutils.h index f39754737..bcacaa91a 100644 --- a/core/coreutils.h +++ b/core/coreutils.h @@ -125,6 +125,16 @@ class CoreUtils */ static QString bytesToHumanSize( double bytes ); + /** + * Returns path to project metadata file for a given project directory + */ + static QString getProjectMetadataPath( QString projectDir ); + + /** + * Updates a value in a JSON file at the specified top-level key + */ + static bool replaceValueInJson( const QString &filePath, const QString &key, const QJsonValue &value ); + private: static QString sLogFile; static int CHECKSUM_CHUNK_SIZE; diff --git a/core/merginapi.cpp b/core/merginapi.cpp index 5a6804903..efb1bd1dd 100644 --- a/core/merginapi.cpp +++ b/core/merginapi.cpp @@ -3457,6 +3457,17 @@ bool MerginApi::writeData( const QByteArray &data, const QString &path ) return true; } +bool MerginApi::updateCachedProjectRole( const QString &projectFullName, const QString &newRole ) +{ + LocalProject project = mLocalProjects.projectFromMerginName( projectFullName ); + if ( !project.isValid() ) + { + return false; + } + + QString metadataPath = project.projectDir + "/" + sMetadataFile; + return CoreUtils::replaceValueInJson( metadataPath, "role", newRole ); +} void MerginApi::createPathIfNotExists( const QString &filePath ) { @@ -3953,6 +3964,64 @@ DownloadQueueItem::DownloadQueueItem( const QString &fp, qint64 s, int v, qint64 tempFileName = CoreUtils::uuidWithoutBraces( QUuid::createUuid() ); } +void MerginApi::reloadProjectRole( const QString &projectFullName ) +{ + if ( projectFullName.isEmpty() ) + { + return; + } + + QNetworkReply *reply = getProjectInfo( projectFullName ); + if ( !reply ) + return; + + reply->request().setAttribute( static_cast( AttrProjectFullName ), projectFullName ); + connect( reply, &QNetworkReply::finished, this, &MerginApi::reloadProjectRoleReplyFinished ); +} + +void MerginApi::reloadProjectRoleReplyFinished() +{ + QNetworkReply *r = qobject_cast( sender() ); + Q_ASSERT( r ); + + QString projectFullName = r->request().attribute( static_cast( AttrProjectFullName ) ).toString(); + QString cachedRole = MerginApi::getCachedProjectRole( projectFullName ); + + if ( r->error() == QNetworkReply::NoError ) + { + QByteArray data = r->readAll(); + MerginProjectMetadata serverProject = MerginProjectMetadata::fromJson( data ); + QString role = serverProject.role; + + if ( role != cachedRole ) + { + if ( updateCachedProjectRole( projectFullName, role ) ) + emit projectRoleUpdated( projectFullName, role ); + } + } + else + { + CoreUtils::log( "metadata", QString( "Failed to update cached role for project %1" ).arg( projectFullName ) ); + } + + r->deleteLater(); +} + +QString MerginApi::getCachedProjectRole( const QString &projectFullName ) const +{ + if ( projectFullName.isEmpty() ) + return QString(); + + QString projectDir = mLocalProjects.projectFromMerginName( projectFullName ).projectDir; + + if ( projectDir.isEmpty() ) + return QString(); + + MerginProjectMetadata cachedProjectMetadata = MerginProjectMetadata::fromCachedJson( projectDir + "/" + sMetadataFile ); + + return cachedProjectMetadata.role; +} + bool MerginApi::isRetryableNetworkError( QNetworkReply *reply ) { Q_ASSERT( reply ); diff --git a/core/merginapi.h b/core/merginapi.h index 4646e9361..f36874b4a 100644 --- a/core/merginapi.h +++ b/core/merginapi.h @@ -577,6 +577,11 @@ class MerginApi: public QObject */ bool apiSupportsWorkspaces(); + /** + * Reloads project metadata role by fetching latest information from server. + */ + Q_INVOKABLE void reloadProjectRole( const QString &projectFullName ); + /** * Returns the network manager used for Mergin API requests */ @@ -665,6 +670,9 @@ class MerginApi: public QObject void apiSupportsWorkspacesChanged(); void serverWasUpgraded(); + + void projectRoleUpdated( const QString &projectFullName, const QString &role ); + void networkManagerChanged(); void downloadItemRetried( const QString &projectFullName, int retryCount ); @@ -814,7 +822,18 @@ class MerginApi: public QObject bool projectFileHasBeenUpdated( const ProjectDiff &diff ); + //! Checks if retrieving the project role from the server was successful and + //! if it differs from the current project role, emits a signal with new project role + void reloadProjectRoleReplyFinished(); + + //! Updates project role in metadata file + bool updateCachedProjectRole( const QString &projectFullName, const QString &newRole ); + + //! Retrieves cached role from metadata file + QString getCachedProjectRole( const QString &projectFullName ) const; + QNetworkAccessManager *mManager = nullptr; + QString mApiRoot; LocalProjectsManager &mLocalProjects; QString mDataDir; // dir with all projects @@ -829,7 +848,7 @@ class MerginApi: public QObject AttrProjectFullName = QNetworkRequest::User, AttrTempFileName = QNetworkRequest::User + 1, AttrWorkspaceName = QNetworkRequest::User + 2, - AttrAcceptFlag = QNetworkRequest::User + 3, + AttrAcceptFlag = QNetworkRequest::User + 3 }; Transactions mTransactionalStatus; //projectFullname -> transactionStatus diff --git a/core/merginprojectmetadata.cpp b/core/merginprojectmetadata.cpp index ba1ff95c3..7b4f2610e 100644 --- a/core/merginprojectmetadata.cpp +++ b/core/merginprojectmetadata.cpp @@ -99,6 +99,7 @@ MerginProjectMetadata MerginProjectMetadata::fromJson( const QByteArray &data ) project.name = docObj.value( QStringLiteral( "name" ) ).toString(); project.projectNamespace = docObj.value( QStringLiteral( "namespace" ) ).toString(); + project.role = docObj.value( QStringLiteral( "role" ) ).toString(); QString versionStr = docObj.value( QStringLiteral( "version" ) ).toString(); if ( versionStr.isEmpty() ) diff --git a/core/merginprojectmetadata.h b/core/merginprojectmetadata.h index b4bd178c8..00705add7 100644 --- a/core/merginprojectmetadata.h +++ b/core/merginprojectmetadata.h @@ -59,6 +59,7 @@ struct MerginProjectMetadata { QString name; QString projectNamespace; + QString role; int version = -1; QList files; QString projectId; //!< unique project ID (only available in API that supports project IDs)