changeset 599:11f795017763 annotations

implemented sharing rules
author Sebastien Jodogne <s.jodogne@gmail.com>
date Mon, 07 Sep 2026 18:01:00 +0200
parents 008409bc9978
children 06eec59e726b
files ViewerPlugin/Annotations/AnnotationsRestApi.cpp ViewerPlugin/Annotations/AnnotationsWorkspace.cpp ViewerPlugin/Annotations/AnnotationsWorkspace.h ViewerPlugin/Annotations/UserLayer.cpp ViewerPlugin/ViewerConfiguration.cpp ViewerPlugin/ViewerConfiguration.h ViewerPlugin/WebApplication/viewer.html ViewerPlugin/WebApplication/viewer.js
diffstat 8 files changed, 180 insertions(+), 41 deletions(-) [+]
line wrap: on
line diff
--- a/ViewerPlugin/Annotations/AnnotationsRestApi.cpp	Mon Sep 07 11:34:02 2026 +0200
+++ b/ViewerPlugin/Annotations/AnnotationsRestApi.cpp	Mon Sep 07 18:01:00 2026 +0200
@@ -151,6 +151,9 @@
                         const char* url,
                         const OrthancPluginHttpRequest* request)
   {
+    static const char* const KEY_IS_LEARNER = "is_learner";
+    static const char* const KEY_IS_INSTRUCTOR = "is_instructor";
+
     if (ProtectPostRequest(output, request))
     {
       AnnotationsCommandContext context(request);
@@ -163,17 +166,20 @@
       answer["enabled"] = ViewerConfiguration::GetInstance().AreAnnotationsEnabled();
       answer["sharing"] = (ViewerConfiguration::GetInstance().AreAnnotationsEnabled() &&
                            ViewerConfiguration::GetInstance().IsAnnotationsSharingEnabled());
+      answer["learner_to_learner_sharing"] = ViewerConfiguration::GetInstance().IsLearnerToLearnerSharingEnabled();
       answer["user"] = context.GetUser().Format();
 
       std::string role;
       switch (context.GetRole())
       {
         case ProjectRole_Learner:
-          role = "learner";
+          answer[KEY_IS_LEARNER] = true;
+          answer[KEY_IS_INSTRUCTOR] = false;
           break;
 
         case ProjectRole_Instructor:
-          role = "instructor";
+          answer[KEY_IS_LEARNER] = false;
+          answer[KEY_IS_INSTRUCTOR] = true;
           break;
 
         default:
@@ -335,7 +341,11 @@
       const std::string query = context.GetBodyString("query");
 
       std::set<UserId> users;
-      context.GetWorkspace().SearchActiveUsers(users, query);
+
+      {
+        std::unique_ptr<AnnotationsWorkspace::UserReader> reader(context.CreateUserReader());
+        reader->SearchActiveUsers(users, query);
+      }
 
       Json::Value answer = Json::arrayValue;
 
--- a/ViewerPlugin/Annotations/AnnotationsWorkspace.cpp	Mon Sep 07 11:34:02 2026 +0200
+++ b/ViewerPlugin/Annotations/AnnotationsWorkspace.cpp	Mon Sep 07 18:01:00 2026 +0200
@@ -24,6 +24,7 @@
 #include "../../Framework/PrecompiledHeadersWSI.h"
 #include "AnnotationsWorkspace.h"
 
+#include "../ViewerConfiguration.h"
 #include "../ViewerToolbox.h"
 
 #include <OrthancException.h>
@@ -337,29 +338,6 @@
   }
 
 
-  void AnnotationsWorkspace::SearchActiveUsers(std::set<UserId>& target,
-                                               const std::string& query)
-  {
-    Orthanc::ReaderWriterLock::ReadLock lock(mutex_);
-
-    target.clear();
-
-    const boost::regex re(query);
-
-    PersistentInfo::ActiveUsersIterator iterator(*persistentInfo_);
-
-    while (!iterator.IsDone())
-    {
-      if (boost::regex_search(iterator.GetUser().GetName(), re))
-      {
-        target.insert(iterator.GetUser());
-      }
-
-      iterator.Next();
-    }
-  }
-
-
   AnnotationsWorkspace::UserReader::UserReader(AnnotationsWorkspace& that,
                                                const UserId& userId,
                                                ProjectRole userRole) :
@@ -463,6 +441,62 @@
   }
 
 
+  void AnnotationsWorkspace::UserReader::SearchActiveUsers(std::set<UserId>& target,
+                                                           const std::string& query) const
+  {
+    target.clear();
+
+    const boost::regex re(query);
+
+    PersistentInfo::ActiveUsersIterator iterator(*that_.persistentInfo_);
+
+    while (!iterator.IsDone())
+    {
+      if (boost::regex_search(iterator.GetUser().GetName(), re))
+      {
+        bool add = false;
+
+        switch (userRole_)
+        {
+          case ProjectRole_Instructor:
+            add = true;
+            break;
+
+          case ProjectRole_Learner:
+            switch (iterator.GetRole())  // Consider the role of the other user
+            {
+              case ProjectRole_Instructor:
+                // Learners can always share with instructors
+                add = true;
+                break;
+
+              case ProjectRole_Learner:
+                add = ViewerConfiguration::GetInstance().IsLearnerToLearnerSharingEnabled();
+                break;
+
+              default:
+                throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+            }
+            break;
+
+          case ProjectRole_Guest:
+            throw Orthanc::OrthancException(Orthanc::ErrorCode_ForbiddenAccess);
+
+          default:
+            throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+        }
+
+        if (add)
+        {
+          target.insert(iterator.GetUser());
+        }
+      }
+
+      iterator.Next();
+    }
+  }
+
+
   void AnnotationsWorkspace::UserReader::ListImportedLayers(std::set<UserId>& authors,
                                                             std::set<std::string>& layerIds) const
   {
--- a/ViewerPlugin/Annotations/AnnotationsWorkspace.h	Mon Sep 07 11:34:02 2026 +0200
+++ b/ViewerPlugin/Annotations/AnnotationsWorkspace.h	Mon Sep 07 18:01:00 2026 +0200
@@ -73,9 +73,6 @@
       return projectInformation_.GetDescription();
     }
 
-    void SearchActiveUsers(std::set<UserId>& target,
-                           const std::string& query);
-
 
     class UserReader : public boost::noncopyable
     {
@@ -105,6 +102,9 @@
 
       void ListImportedLayers(std::set<UserId>& authors,
                               std::set<std::string>& layerIds) const;
+
+      void SearchActiveUsers(std::set<UserId>& target,
+                             const std::string& query) const;
     };
 
 
--- a/ViewerPlugin/Annotations/UserLayer.cpp	Mon Sep 07 11:34:02 2026 +0200
+++ b/ViewerPlugin/Annotations/UserLayer.cpp	Mon Sep 07 18:01:00 2026 +0200
@@ -24,6 +24,8 @@
 #include "../../Framework/PrecompiledHeadersWSI.h"
 #include "UserLayer.h"
 
+#include "../ViewerConfiguration.h"
+
 #include <OrthancException.h>
 #include <SerializationToolbox.h>
 #include <Toolbox.h>
@@ -79,12 +81,83 @@
                                const UserId& viewerId,
                                ProjectRole viewerRole) const
   {
-    assert(viewerId.GetType() == UserId::Type_Root ||
-           viewerId.GetType() == UserId::Type_Standard);
+    /**
+
+       Instructors can see:
+
+       - All layers tagged "publicly shared with instructors"
+         (i.e. public), created by anyone (instructors or learners).
+
+       - Any layer explicitly shared with them, by anyone.
+
+       Learners can see:
+
+       - All layers tagged public that were created by instructors
+         (this is true "class-wide public" for instructor content).
+
+       - Any instructor layer explicitly shared with them.
+
+       - Any learner layer explicitly shared with them by name, only
+         if learner-to-learner sharing is enabled (cf. configuration
+         option "EnableLearnerToLearnerSharing").
+
+       Note 1: Learner layers tagged "public" are visible only to
+       instructors, never to other learners, regardless of the
+       learner-to-learner sharing configuration. This is a deliberate
+       asymmetry: for a learner, "public" means "submitted/visible to
+       instructors," not "visible to the class." This prevents one
+       learner's work from becoming broadcast to the whole cohort,
+       while still allowing small, named-group collaboration (e.g.,
+       project teams) through explicit sharing.
+
+       Note 2: Learner-to-learner sharing (configuration option)
+       governs only the explicit-share-list channel between
+       learners. It has no effect on instructor visibility and no
+       effect on the behavior of the "public" tag (public learner
+       layers are never learner-visible whether this option is "true"
+       or "false").
+
+     **/
 
-    return (isPublic_ ||
-            viewerId.GetType() == UserId::Type_Root ||
-            sharedWith_.find(viewerId) != sharedWith_.end());
+    if (viewerId.GetType() != UserId::Type_Standard)
+    {
+      return false;
+    }
+
+    const bool explicitlyShared = sharedWith_.find(viewerId) != sharedWith_.end();
+
+    switch (authorRole)
+    {
+      case ProjectRole_Instructor:
+        // Instructor layers: "public" truly means public to everyone,
+        // and explicit sharing is unconditional
+        return isPublic_ || explicitlyShared;
+
+      case ProjectRole_Learner:
+        switch (viewerRole)
+        {
+          case ProjectRole_Instructor:
+            // Instructors see public learner layers, and anything shared with them
+            return isPublic_ || explicitlyShared;
+
+          case ProjectRole_Learner:
+            // Learner viewing another learner's layer: "public" never applies,
+            // explicit sharing is gated by the configuration switch.
+            return explicitlyShared && ViewerConfiguration::GetInstance().IsLearnerToLearnerSharingEnabled();
+
+          case ProjectRole_Guest:
+            throw Orthanc::OrthancException(Orthanc::ErrorCode_ForbiddenAccess);
+
+          default:
+            throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+        }
+
+      case ProjectRole_Guest:
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_ForbiddenAccess);
+
+      default:
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+    }
   }
 
 
--- a/ViewerPlugin/ViewerConfiguration.cpp	Mon Sep 07 11:34:02 2026 +0200
+++ b/ViewerPlugin/ViewerConfiguration.cpp	Mon Sep 07 18:01:00 2026 +0200
@@ -230,4 +230,10 @@
       return instructors_.find(username) != instructors_.end();
     }
   }
+
+
+  bool ViewerConfiguration::IsLearnerToLearnerSharingEnabled() const
+  {
+    return wsiConfiguration_.GetBooleanValue("EnableLearnerToLearnerSharing", false);
+  }
 }
--- a/ViewerPlugin/ViewerConfiguration.h	Mon Sep 07 11:34:02 2026 +0200
+++ b/ViewerPlugin/ViewerConfiguration.h	Mon Sep 07 18:01:00 2026 +0200
@@ -72,5 +72,7 @@
     bool IsAnnotationsSharingEnabled() const;
 
     bool IsInstructor(const std::string& username) const;
+
+    bool IsLearnerToLearnerSharingEnabled() const;
   };
 }
--- a/ViewerPlugin/WebApplication/viewer.html	Mon Sep 07 11:34:02 2026 +0200
+++ b/ViewerPlugin/WebApplication/viewer.html	Mon Sep 07 18:01:00 2026 +0200
@@ -541,10 +541,16 @@
               <div class="form-check mb-3">
                 <input class="form-check-input" type="checkbox" id="share-layer-public"
                        v-model="shareLayerPublic">
-                <label class="form-check-label" for="share-layer-public">Make this layer public (accessible to any user)</label>
+                <label class="form-check-label" for="share-layer-public">
+                  <span v-if="workspaceInfo.is_instructor">Make this layer public (accessible to any instructor or learner)</span>
+                  <span v-if="workspaceInfo.is_learner">Make this layer visible to instructors</span>
+                </label>
               </div>
 
-              <label class="form-label small mb-1">Shared with specific users:</label>
+              <label class="form-label small mb-1">
+                <span v-if="shareLayerCanAddLearner">Shared with specific learners or instructors:</span>
+                <span v-if="!shareLayerCanAddLearner">Shared with specific instructors (learners in this list are ignored):</span>
+              </label>
               <div class="mb-3 border rounded p-2" style="min-height:2.5em; max-height:8em; overflow-y:auto">
                 <span v-if="shareLayerUsers.length === 0" class="text-muted small">No users added</span>
                 <span v-for="(user, index) in shareLayerUsers" :key="user.name"
@@ -556,7 +562,10 @@
                 </span>
               </div>
 
-              <label class="form-label small mb-1">Add user:</label>
+              <label class="form-label small mb-1">
+                <span v-if="shareLayerCanAddLearner">Add learner or instructor:</span>
+                <span v-if="!shareLayerCanAddLearner">Add instructor:</span>
+              </label>
               <div class="input-group input-group-sm mb-1">
                 <input type="text" class="form-control" placeholder="Type to search or enter a user ID..."
                        v-model="shareLayerSearchQuery"
--- a/ViewerPlugin/WebApplication/viewer.js	Mon Sep 07 11:34:02 2026 +0200
+++ b/ViewerPlugin/WebApplication/viewer.js	Mon Sep 07 18:01:00 2026 +0200
@@ -88,7 +88,7 @@
        * Magnification at full-resolution image pixels, convention
        * commonly used for pathology WSI: 40x scan = 0.25 µm/pixel
        **/
-      referenceMagnification: 40,
+      referenceMagnification: 40,  // TODO - Could be read from pyramid
 
       // Share layer modal
       modalShareUserLayer: null,
@@ -110,6 +110,11 @@
   },
 
   computed: {
+    shareLayerCanAddLearner: function() {
+      return (this.workspaceInfo.is_instructor === true ||
+              (this.workspaceInfo.is_learner === true &&
+               this.workspaceInfo.learner_to_learner_sharing === true));
+    }
   },
 
   watch: {
@@ -133,7 +138,7 @@
       new bootstrap.Tooltip(el, { trigger: 'hover' });
     });
 
-    // bootstrap.Offcanvas.getOrCreateInstance(document.getElementById('right-panel')).show();  // Open side menu on startup
+    // document.getElementById('right-panel-toggle').click();  // Open the side menu at startup
 
     const params = new URLSearchParams(document.location.search);
 
@@ -884,10 +889,10 @@
       this.map.once('postrender', function() {
         // Match Bootstrap button size to OL button size
         /*var olBtnSize = document.querySelector('.ol-zoom button').offsetWidth + 'px';
-        document.querySelectorAll('.icon-btn').forEach(function(el) {
+          document.querySelectorAll('.icon-btn').forEach(function(el) {
           el.style.width = olBtnSize;
           el.style.height = olBtnSize;
-        });*/
+          });*/
 
         // Move the top toolbar directly right to the zoom control, regardless of scaling
         var zoomEl = document.querySelector('.ol-zoom');