changeset 816:b359461f750d attach-custom-data

merged default -> attach-custom-data
author Alain Mazy <am@orthanc.team>
date Tue, 20 May 2025 10:56:10 +0200
parents f0cc40ca6fb1 (current diff) 1aa6cfc19cc8 (diff)
children 1562c38ab7aa
files NewTests/PostgresUpgrades/test_pg_upgrades.py NewTests/requirements.txt Tests/Tests.py
diffstat 10 files changed, 304 insertions(+), 78 deletions(-) [+]
line wrap: on
line diff
--- a/.hgtags	Mon Mar 17 17:02:56 2025 +0100
+++ b/.hgtags	Tue May 20 10:56:10 2025 +0200
@@ -47,3 +47,4 @@
 7bfc8992ab8fc44bd811bc60ebf3332303bc87ed Orthanc-1.12.4
 847b3c6b360b9b0daeab327133703c60e14e51f0 Orthanc-1.12.5
 287aae544b3133f6cecdf768f5a09dacbd75cf91 Orthanc-1.12.6
+2eca398d9676e2378343c48769a4b3938ba96005 Orthanc-1.12.7
--- a/CITATION.cff	Mon Mar 17 17:02:56 2025 +0100
+++ b/CITATION.cff	Tue May 20 10:56:10 2025 +0200
@@ -10,5 +10,5 @@
 doi: "10.1007/s10278-018-0082-y"
 license: "GPL-3.0-or-later"
 repository-code: "https://orthanc.uclouvain.be/hg/orthanc/"
-version: 1.12.6
-date-released: 2025-01-22
+version: 1.12.7
+date-released: 2025-04-07
--- a/NewTests/Authorization/test_authorization.py	Mon Mar 17 17:02:56 2025 +0100
+++ b/NewTests/Authorization/test_authorization.py	Tue May 20 10:56:10 2025 +0200
@@ -99,6 +99,7 @@
         cls.label_a_instance_id = o.upload_file(here / "../../Database/Knix/Loc/IM-0001-0001.dcm")[0]
         cls.label_a_study_id = o.instances.get_parent_study_id(cls.label_a_instance_id)
         cls.label_a_series_id = o.instances.get_parent_series_id(cls.label_a_instance_id)
+        cls.label_a_patient_dicom_id = o.studies.get_tags(cls.label_a_study_id)["PatientID"]
         cls.label_a_study_dicom_id = o.studies.get_tags(cls.label_a_study_id)["StudyInstanceUID"]
         cls.label_a_series_dicom_id = o.series.get_tags(cls.label_a_series_id)["SeriesInstanceUID"]
         cls.label_a_instance_dicom_id = o.instances.get_tags(cls.label_a_instance_id)["SOPInstanceUID"]
@@ -107,6 +108,7 @@
         cls.label_b_instance_id = o.upload_file(here / "../../Database/Brainix/Epi/IM-0001-0001.dcm")[0]
         cls.label_b_study_id = o.instances.get_parent_study_id(cls.label_b_instance_id)
         cls.label_b_series_id = o.instances.get_parent_series_id(cls.label_b_instance_id)
+        cls.label_b_patient_dicom_id = o.studies.get_tags(cls.label_b_study_id)["PatientID"]
         cls.label_b_study_dicom_id = o.studies.get_tags(cls.label_b_study_id)["StudyInstanceUID"]
         cls.label_b_series_dicom_id = o.series.get_tags(cls.label_b_series_id)["SeriesInstanceUID"]
         cls.label_b_instance_dicom_id = o.instances.get_tags(cls.label_b_instance_id)["SOPInstanceUID"]
@@ -118,6 +120,7 @@
         cls.no_label_instance_id = o.upload_file(here / "../../Database/Comunix/Pet/IM-0001-0001.dcm")[0]
         cls.no_label_study_id = o.instances.get_parent_study_id(cls.no_label_instance_id)
         cls.no_label_series_id = o.instances.get_parent_series_id(cls.no_label_instance_id)
+        cls.no_label_patient_dicom_id = o.studies.get_tags(cls.no_label_study_id)["PatientID"]
         cls.no_label_study_dicom_id = o.studies.get_tags(cls.no_label_study_id)["StudyInstanceUID"]
         cls.no_label_series_dicom_id = o.series.get_tags(cls.no_label_series_id)["SeriesInstanceUID"]
         cls.no_label_instance_dicom_id = o.instances.get_tags(cls.no_label_instance_id)["SOPInstanceUID"]
@@ -315,6 +318,15 @@
             self.assertEqual(1, len(r["Labels"]))
             self.assertEqual("label_a", r["Labels"][0])
 
+        if o_admin.is_plugin_version_at_least("authorization", 0, 9, 2):
+            i = o.get_json(f"dicom-web/studies?StudyInstanceUID={self.label_a_study_dicom_id}")
+            
+            # this one is forbidden because we specify the study (and the study is forbidden)
+            self.assert_is_forbidden(lambda: o.get_json(f"dicom-web/studies?StudyInstanceUID={self.label_b_study_dicom_id}"))
+            
+            # this one is empty because no studies are specified
+            self.assertEqual(0, len(o.get_json(f"dicom-web/studies?PatientID={self.label_b_patient_dicom_id}")))
+
 
     def test_uploader_a(self):
         o_admin = OrthancApiClient(self.o._root_url, headers={"user-token-key": "token-admin"})
@@ -341,6 +353,16 @@
             o.upload_files_dicom_web(paths = [here / "../../Database/Beaufix/IM-0001-0001.dcm"])
             o_admin.instances.delete(orthanc_ids=instances_ids)
 
+        if o_admin.is_plugin_version_at_least("authorization", 0, 9, 1):
+
+            # uploader-a shall not be able to upload a study through DICOMWeb using /dicom-web/studies/<StudyInstanceUID of label_b>
+            self.assert_is_forbidden(lambda: o.upload_files_dicom_web(paths = [here / "../../Database/Knix/Loc/IM-0001-0002.dcm"], endpoint=f"/dicom-web/studies/{self.label_b_study_dicom_id}"))
+
+            # uploader-a shall be able to upload a study through DICOMWeb using /dicom-web/studies/<StudyInstanceUID of label_a>
+            o.upload_files_dicom_web(paths = [here / "../../Database/Knix/Loc/IM-0001-0002.dcm"], endpoint=f"/dicom-web/studies/{self.label_a_study_dicom_id}")
+
+            # note that, uploader-a is allowed to upload to /dicom-web/studies without checking any labels :-()
+            o.upload_files_dicom_web(paths = [here / "../../Database/Knix/Loc/IM-0001-0002.dcm"], endpoint=f"/dicom-web/studies")
 
     def test_resource_token(self):
 
--- a/NewTests/DelayedDeletion/test_delayed_deletion.py	Mon Mar 17 17:02:56 2025 +0100
+++ b/NewTests/DelayedDeletion/test_delayed_deletion.py	Tue May 20 10:56:10 2025 +0200
@@ -8,6 +8,7 @@
 
 import pathlib
 import glob
+import pprint
 here = pathlib.Path(__file__).parent.resolve()
 
 
@@ -23,7 +24,8 @@
                 "DelayedDeletion": {
                     "Enable": True,
                     "ThrottleDelayMs": 200
-                }
+                },
+                "DatabaseServerIdentifier": "delayed-test"
             }
 
         config_path = cls.generate_configuration(
@@ -98,11 +100,15 @@
 
     def test_resumes_pending_deletion(self):
 
+        # plugin_status = self.o.get_json("plugins/delayed-deletion/status")
+        # pprint.pprint(plugin_status)
+        
         completed = False
         while not completed:
             print('-------------- waiting for DelayedDeletion to finish processing')
             time.sleep(1)
             plugin_status = self.o.get_json("plugins/delayed-deletion/status")
+            # pprint.pprint(plugin_status)
             completed = plugin_status["FilesPendingDeletion"] == 0
 
         self.assertTrue(completed)
--- a/NewTests/PostgresUpgrades/test_pg_upgrades.py	Mon Mar 17 17:02:56 2025 +0100
+++ b/NewTests/PostgresUpgrades/test_pg_upgrades.py	Tue May 20 10:56:10 2025 +0200
@@ -71,6 +71,8 @@
         subprocess.run(["docker", "compose", "stop", "orthanc-pg-15-under-tests"], check=True)
         time.sleep(2)
 
+    
+    # test a full upgrade from a very old version, and a downgrade to the previous version (where we run the integration tests)
     def test_upgrades_downgrades_with_pg_15(self):
 
         # remove everything including the DB from previous tests
@@ -144,10 +146,11 @@
         o.wait_started()
 
         # time.sleep(10000)
-        subprocess.run(["docker", "compose", "up", "orthanc-tests"], check=True)
+        subprocess.run(["docker", "compose", "up", "orthanc-tests", "--exit-code-from", "orthanc-tests"], check=True)
 
 
 
+    # make sure we can still start a new Orthanc with an Old PG server
     def test_latest_orthanc_with_pg_9(self):
 
         # remove everything including the DB from previous tests
--- a/NewTests/requirements.txt	Mon Mar 17 17:02:56 2025 +0100
+++ b/NewTests/requirements.txt	Tue May 20 10:56:10 2025 +0200
@@ -1,3 +1,3 @@
-orthanc-api-client>=0.18.4
+orthanc-api-client>=0.18.5
 orthanc-tools>=0.16.5
 uvicorn
\ No newline at end of file
--- a/Plugins/DicomWeb/DicomWeb.py	Mon Mar 17 17:02:56 2025 +0100
+++ b/Plugins/DicomWeb/DicomWeb.py	Tue May 20 10:56:10 2025 +0200
@@ -41,7 +41,7 @@
         body += bytearray('\r\n', 'ascii')
 
 
-def SendStowRaw(orthanc, uri, dicom):
+def SendStowRaw(orthanc, uri, dicom, partsContentType='application/dicom'):
     # We do not use Python's "email" package, as it uses LF (\n) for line
     # endings instead of CRLF (\r\n) for binary messages, as required by
     # RFC 1341
@@ -54,9 +54,9 @@
 
     if isinstance(dicom, list):
         for i in range(dicom):
-            _AttachPart(body, dicom[i], 'application/dicom', boundary)
+            _AttachPart(body, dicom[i], partsContentType, boundary)
     else:
-        _AttachPart(body, dicom, 'application/dicom', boundary)
+        _AttachPart(body, dicom, partsContentType, boundary)
 
     # Closing boundary
     body += bytearray('--%s--' % boundary, 'ascii')
@@ -72,8 +72,8 @@
     return (response.status, DecodeJson(content))
 
 
-def SendStow(orthanc, uri, dicom):
-    (status, content) = SendStowRaw(orthanc, uri, dicom)
+def SendStow(orthanc, uri, dicom, partsContentType='application/dicom'):
+    (status, content) = SendStowRaw(orthanc, uri, dicom, partsContentType)
     if not (status in [ 200 ]):
         raise Exception('Bad status: %d' % status)
     else:
--- a/Plugins/DicomWeb/Run.py	Mon Mar 17 17:02:56 2025 +0100
+++ b/Plugins/DicomWeb/Run.py	Tue May 20 10:56:10 2025 +0200
@@ -189,7 +189,10 @@
         a = SendStow(ORTHANC, args.dicomweb + '/studies', GetDatabasePath('Phenix/IM-0001-0001.dcm'))
         self.assertEqual(1, len(DoGet(ORTHANC, '/instances')))
 
-        self.assertEqual(4, len(a))
+        if IsPluginVersionAtLeast(ORTHANC, "dicom-web", 1, 19, 0):
+            self.assertEqual(3, len(a))  # DICOM_TAG_FAILED_SOP_SEQUENCE has been removed in 1.19
+        else:
+            self.assertEqual(4, len(a))
 
         # Specific character set
         self.assertTrue('00080005' in a)
@@ -198,8 +201,11 @@
         self.assertTrue(a['00081190']['Value'][0].endswith('studies/2.16.840.1.113669.632.20.1211.10000098591'))
         self.assertEqual('UR', a['00081190']['vr'])
         
-        self.assertFalse('Value' in a['00081198'])  # No error => empty sequence
-        self.assertEqual('SQ', a['00081198']['vr'])
+        if IsPluginVersionAtLeast(ORTHANC, "dicom-web", 1, 19, 0):
+            self.assertNotIn('00081198', a)  # No errors => the DICOM_TAG_FAILED_SOP_SEQUENCE shall not be present
+        else:
+            self.assertFalse('Value' in a['00081198'])  # No error => empty sequence
+            self.assertEqual('SQ', a['00081198']['vr'])
 
         self.assertEqual(1, len(a['00081199']['Value']))  # 1 success
         self.assertEqual('SQ', a['00081199']['vr'])
@@ -236,6 +242,16 @@
         self.assertEqual(1, len(parts))
         self.assertEqual(os.path.getsize(GetDatabasePath('Phenix/IM-0001-0001.dcm')), int(parts[0]))
 
+    def test_stow_like_dcm4chee(self):
+        # https://discourse.orthanc-server.org/t/orthanc-dicomweb-stowrs-server-request-response-compatibility/5763
+
+        self.assertEqual(0, len(DoGet(ORTHANC, '/instances')))
+        a = SendStow(ORTHANC, args.dicomweb + '/studies', GetDatabasePath('Phenix/IM-0001-0001.dcm'), 'application/dicom;transfer-syntax=1.2.840.10008.1.2.1')
+        self.assertEqual(1, len(DoGet(ORTHANC, '/instances')))
+
+        self.assertNotIn('00081198', a)  # No errors => the DICOM_TAG_FAILED_SOP_SEQUENCE shall not be present
+
+
         
     def test_server_get(self):
         try:
@@ -296,56 +312,85 @@
 
 
     def test_server_stow(self):
-        UploadInstance(ORTHANC, 'Knee/T1/IM-0001-0001.dcm')
+        # UploadInstance(ORTHANC, 'Knee/T1/IM-0001-0001.dcm')
+
+        # self.assertRaises(Exception, lambda: 
+        #                   DoPost(ORTHANC, '/dicom-web/servers/sample/stow',
+        #                          { 'Resources' : [ 'nope' ],
+        #                            'Synchronous' : True }))  # inexisting resource
 
-        self.assertRaises(Exception, lambda: 
-                          DoPost(ORTHANC, '/dicom-web/servers/sample/stow',
-                                 { 'Resources' : [ 'nope' ],
-                                   'Synchronous' : True }))  # inexisting resource
+        # if IsPluginVersionAtLeast(ORTHANC, "dicom-web", 1, 18, 0):
+        #     l = 4   # "Server" has been added
+        # else:
+        #     l = 3   # For >= 1.10.1
+
+        # # study
+        # r = DoPost(ORTHANC, '/dicom-web/servers/sample/stow',
+        #                                { 'Resources' : [ '0a9b3153-2512774b-2d9580de-1fc3dcf6-3bd83918' ],
+        #                                  'Synchronous' : True })
 
-        if IsPluginVersionAtLeast(ORTHANC, "dicom-web", 1, 18, 0):
-            l = 4   # "Server" has been added
-        else:
-            l = 3   # For >= 1.10.1
+        # self.assertEqual(l, len(r))
+        # self.assertEqual("0a9b3153-2512774b-2d9580de-1fc3dcf6-3bd83918", r['Resources']['Studies'][0])
+        # if IsPluginVersionAtLeast(ORTHANC, "dicom-web", 1, 18, 0):
+        #     self.assertEqual("sample", r['Server'])
 
-        # study
-        r = DoPost(ORTHANC, '/dicom-web/servers/sample/stow',
-                                       { 'Resources' : [ '0a9b3153-2512774b-2d9580de-1fc3dcf6-3bd83918' ],
-                                         'Synchronous' : True })
+        # # series
+        # r = DoPost(ORTHANC, '/dicom-web/servers/sample/stow',
+        #                                { 'Resources' : [ '6de73705-c4e65c1b-9d9ea1b5-cabcd8e7-f15e4285' ],
+        #                                  'Synchronous' : True })
+        # self.assertEqual(l, len(r))
+        # self.assertEqual("6de73705-c4e65c1b-9d9ea1b5-cabcd8e7-f15e4285", r['Resources']['Series'][0])
 
-        self.assertEqual(l, len(r))
-        self.assertEqual("0a9b3153-2512774b-2d9580de-1fc3dcf6-3bd83918", r['Resources']['Studies'][0])
-        if IsPluginVersionAtLeast(ORTHANC, "dicom-web", 1, 18, 0):
-            self.assertEqual("sample", r['Server'])
+        # # instances
+        # r = DoPost(ORTHANC, '/dicom-web/servers/sample/stow',
+        #                                { 'Resources' : [ 'c8df6478-d7794217-0f11c293-a41237c9-31d98357' ],
+        #                                  'Synchronous' : True })
+        # self.assertEqual(l, len(r))
+        # self.assertEqual("c8df6478-d7794217-0f11c293-a41237c9-31d98357", r['Resources']['Instances'][0])
 
-        # series
-        r = DoPost(ORTHANC, '/dicom-web/servers/sample/stow',
-                                       { 'Resources' : [ '6de73705-c4e65c1b-9d9ea1b5-cabcd8e7-f15e4285' ],
-                                         'Synchronous' : True })
-        self.assertEqual(l, len(r))
-        self.assertEqual("6de73705-c4e65c1b-9d9ea1b5-cabcd8e7-f15e4285", r['Resources']['Series'][0])
+        # # altogether
+        # r = DoPost(ORTHANC, '/dicom-web/servers/sample/stow',
+        #                                { 'Resources' : [ 
+        #                                    'ca29faea-b6a0e17f-067743a1-8b778011-a48b2a17',
+        #                                    '0a9b3153-2512774b-2d9580de-1fc3dcf6-3bd83918',
+        #                                    '6de73705-c4e65c1b-9d9ea1b5-cabcd8e7-f15e4285',
+        #                                    'c8df6478-d7794217-0f11c293-a41237c9-31d98357' ],
+        #                                  'Synchronous' : True })
+        # # pprint.pprint(r)
+        # self.assertEqual(l, len(r))
+        # self.assertEqual("ca29faea-b6a0e17f-067743a1-8b778011-a48b2a17", r['Resources']['Patients'][0])
+        # self.assertEqual("0a9b3153-2512774b-2d9580de-1fc3dcf6-3bd83918", r['Resources']['Studies'][0])
+        # self.assertEqual("6de73705-c4e65c1b-9d9ea1b5-cabcd8e7-f15e4285", r['Resources']['Series'][0])
+        # self.assertEqual("c8df6478-d7794217-0f11c293-a41237c9-31d98357", r['Resources']['Instances'][0])
 
-        # instances
-        r = DoPost(ORTHANC, '/dicom-web/servers/sample/stow',
-                                       { 'Resources' : [ 'c8df6478-d7794217-0f11c293-a41237c9-31d98357' ],
-                                         'Synchronous' : True })
-        self.assertEqual(l, len(r))
-        self.assertEqual("c8df6478-d7794217-0f11c293-a41237c9-31d98357", r['Resources']['Instances'][0])
+
+        if IsPluginVersionAtLeast(ORTHANC, "dicom-web", 1, 20, 0):
+            a = UploadInstance(ORTHANC, 'Brainix/Epi/IM-0001-0001.dcm')
+            b = UploadInstance(ORTHANC, 'Brainix/Epi/IM-0001-0002.dcm')
 
-        # altogether
-        r = DoPost(ORTHANC, '/dicom-web/servers/sample/stow',
-                                       { 'Resources' : [ 
-                                           'ca29faea-b6a0e17f-067743a1-8b778011-a48b2a17',
-                                           '0a9b3153-2512774b-2d9580de-1fc3dcf6-3bd83918',
-                                           '6de73705-c4e65c1b-9d9ea1b5-cabcd8e7-f15e4285',
-                                           'c8df6478-d7794217-0f11c293-a41237c9-31d98357' ],
-                                         'Synchronous' : True })
-        # pprint.pprint(r)
-        self.assertEqual(l, len(r))
-        self.assertEqual("ca29faea-b6a0e17f-067743a1-8b778011-a48b2a17", r['Resources']['Patients'][0])
-        self.assertEqual("0a9b3153-2512774b-2d9580de-1fc3dcf6-3bd83918", r['Resources']['Studies'][0])
-        self.assertEqual("6de73705-c4e65c1b-9d9ea1b5-cabcd8e7-f15e4285", r['Resources']['Series'][0])
-        self.assertEqual("c8df6478-d7794217-0f11c293-a41237c9-31d98357", r['Resources']['Instances'][0])
+            # study
+            r = DoPost(ORTHANC, '/dicom-web/servers/sample/stow',
+                                        { 'Resources' : [ a['ParentStudy'] ],
+                                          'Synchronous' : True })
+
+            self.assertEqual(1, len(r['Resources']['Studies']))
+            self.assertNotIn('Series', r['Resources'])
+            self.assertEqual(2, len(r['Resources']['Instances']))
+            self.assertEqual(a['ParentStudy'], r['Resources']['Studies'][0])
+            self.assertIn(a['ID'], r['Resources']['Instances'])
+            self.assertIn(b['ID'], r['Resources']['Instances'])
+
+            # series
+            r = DoPost(ORTHANC, '/dicom-web/servers/sample/stow',
+                                        { 'Resources' : [ a['ParentSeries'] ],
+                                          'Synchronous' : True })
+
+            self.assertEqual(1, len(r['Resources']['Series']))
+            self.assertNotIn('Studies', r['Resources'])
+            self.assertEqual(2, len(r['Resources']['Instances']))
+            self.assertEqual(a['ParentSeries'], r['Resources']['Series'][0])
+            self.assertIn(a['ID'], r['Resources']['Instances'])
+            self.assertIn(b['ID'], r['Resources']['Instances'])
 
 
 
@@ -708,19 +753,24 @@
 
         
     def test_stow_errors(self):
-        def CheckSequences(a):
-            self.assertEqual(3, len(a))
+        def CheckSequences(a, expectFailedSopSequence):
+            if IsPluginVersionAtLeast(ORTHANC, "dicom-web", 1, 19, 0) and not expectFailedSopSequence:
+                self.assertEqual(2, len(a))
+                self.assertNotIn('00081198', a)
+            else:
+                self.assertEqual(3, len(a))
+                self.assertTrue('00081198' in a)
+                self.assertEqual('SQ', a['00081198']['vr'])
+
             self.assertTrue('00080005' in a)
-            self.assertTrue('00081198' in a)
             self.assertTrue('00081199' in a)
             self.assertEqual('CS', a['00080005']['vr'])
-            self.assertEqual('SQ', a['00081198']['vr'])
             self.assertEqual('SQ', a['00081199']['vr'])
         
         # Pushing an instance to a study that is not its parent
         (status, a) = SendStowRaw(ORTHANC, args.dicomweb + '/studies/nope', GetDatabasePath('Phenix/IM-0001-0001.dcm'))
         self.assertEqual(409, status)
-        CheckSequences(a)
+        CheckSequences(a, True)
 
         self.assertFalse('Value' in a['00081199'])  # No success instance
         
@@ -735,23 +785,21 @@
         # Pushing an instance with missing tags
         (status, a) = SendStowRaw(ORTHANC, args.dicomweb + '/studies', GetDatabasePath('Issue111.dcm'))
         self.assertEqual(400, status)
-        CheckSequences(a)
+        CheckSequences(a, False) # No failed instance, as tags are missing
 
-        self.assertFalse('Value' in a['00081198'])  # No failed instance, as tags are missing
         self.assertFalse('Value' in a['00081199'])  # No success instance
 
         # Pushing a file that is not in the DICOM format
         (status, a) = SendStowRaw(ORTHANC, args.dicomweb + '/studies', GetDatabasePath('Issue111.dump'))
         self.assertEqual(400, status)
-        CheckSequences(a)
+        CheckSequences(a, False) # No failed instance, as non-DICOM
 
-        self.assertFalse('Value' in a['00081198'])  # No failed instance, as non-DICOM
         self.assertFalse('Value' in a['00081199'])  # No success instance
 
         # Pushing a DICOM instance with only SOP class and instance UID
         (status, a) = SendStowRaw(ORTHANC, args.dicomweb + '/studies', GetDatabasePath('Issue196.dcm'))
         self.assertEqual(400, status)
-        CheckSequences(a)
+        CheckSequences(a, True)
 
         self.assertFalse('Value' in a['00081199'])  # No success instance
 
@@ -1196,6 +1244,39 @@
         self.assertEqual(len(a), len(c))
         self.assertEqual(a, c)
 
+        if IsPluginVersionAtLeast(ORTHANC, "dicom-web", 1, 20, 0):
+            # test with 2 instances: https://discourse.orthanc-server.org/t/thumbnail-orthanc-stone-viewer-issue/5827/3
+            i = UploadInstance(ORTHANC, 'Brainix/Epi/IM-0001-0001.dcm') ['ID']
+            UploadInstance(ORTHANC, 'Brainix/Epi/IM-0001-0002.dcm') ['ID']
+
+            study = DoGet(ORTHANC, '/instances/%s/tags?simplify' % i) ['StudyInstanceUID']
+            series = DoGet(ORTHANC, '/instances/%s/tags?simplify' % i) ['SeriesInstanceUID']
+            instance = DoGet(ORTHANC, '/instances/%s/tags?simplify' % i) ['SOPInstanceUID']
+
+            a = DoPost(ORTHANC, '/dicom-web/servers/sample/get', {
+                'Uri' : '/studies/%s/series/%s/instances/%s/rendered' % (study, series, instance)
+            })
+            
+            im = UncompressImage(a)
+            self.assertEqual("L", im.mode)
+            self.assertEqual(256, im.size[0])
+            self.assertEqual(256, im.size[1])
+
+            b = DoPost(ORTHANC, '/dicom-web/servers/sample/get', {
+                'Uri' : '/studies/%s/series/%s/rendered' % (study, series)
+            })
+            
+            self.assertEqual(len(a), len(b))
+            self.assertEqual(a, b)
+
+            c = DoPost(ORTHANC, '/dicom-web/servers/sample/get', {
+                'Uri' : '/studies/%s/rendered' % study
+            })
+            
+            self.assertEqual(len(a), len(c))
+            self.assertEqual(a, c)
+
+
 
     def test_multiple_mime_accept_wado_rs(self):
         # "Multiple MIME type Accept Headers for Wado-RS"
--- a/Plugins/Transfers/Run.py	Mon Mar 17 17:02:56 2025 +0100
+++ b/Plugins/Transfers/Run.py	Tue May 20 10:56:10 2025 +0200
@@ -1,4 +1,4 @@
-#!/usr/bin/python
+#!/usr/bin/env python3
 # -*- coding: utf-8 -*-
 
 
--- a/Tests/Tests.py	Mon Mar 17 17:02:56 2025 +0100
+++ b/Tests/Tests.py	Tue May 20 10:56:10 2025 +0200
@@ -538,6 +538,32 @@
         self.assertEqual(0, len(DoGet(_REMOTE, '/patients')))
 
 
+    def test_delete_cascade_with_multiple_instances(self):
+        # make sure deleting the last instance of a study deletes the series, study and patient
+
+        self.assertEqual(0, len(DoGet(_REMOTE, '/instances')))  # make sure orthanc is empty when starting the test
+        a = UploadInstance(_REMOTE, 'Knix/Loc/IM-0001-0001.dcm')
+        b = UploadInstance(_REMOTE, 'Knix/Loc/IM-0001-0002.dcm')
+
+        self.assertEqual(2, len(DoGet(_REMOTE, '/instances')))
+        self.assertEqual(1, len(DoGet(_REMOTE, '/series')))
+        self.assertEqual(1, len(DoGet(_REMOTE, '/studies')))
+        self.assertEqual(1, len(DoGet(_REMOTE, '/patients')))
+
+        DoDelete(_REMOTE, '/instances/%s' % b['ID'])        
+
+        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))
+        self.assertEqual(1, len(DoGet(_REMOTE, '/series')))
+        self.assertEqual(1, len(DoGet(_REMOTE, '/studies')))
+        self.assertEqual(1, len(DoGet(_REMOTE, '/patients')))
+
+        DoDelete(_REMOTE, '/instances/%s' % a['ID'])        
+
+        self.assertEqual(0, len(DoGet(_REMOTE, '/instances')))
+        self.assertEqual(0, len(DoGet(_REMOTE, '/series')))
+        self.assertEqual(0, len(DoGet(_REMOTE, '/studies')))
+        self.assertEqual(0, len(DoGet(_REMOTE, '/patients')))
+
     def test_multiframe(self):
         i = UploadInstance(_REMOTE, 'Multiframe.dcm')['ID']
         self.assertEqual(76, len(DoGet(_REMOTE, '/instances/%s/frames' % i)))
@@ -2562,30 +2588,30 @@
         a = ExtractDicomTags(Anonymize(u, { 'PatientName' : 'toto' }), tags)
         for i in range(4):
             self.assertNotEqual(ids[i], a[i])
-        self.assertFalse(a[4].startswith('Orthanc'))
+        self.assertNotIn('PS 3.15', a[4])
 
         a = ExtractDicomTags(Anonymize(u, { 'SOPInstanceUID' : 'instance' }), tags)
         self.assertEqual('instance', a[3])
-        self.assertFalse(a[4].startswith('Orthanc'))
+        self.assertNotIn('PS 3.15', a[4])
 
         a = ExtractDicomTags(Anonymize(u, { 'SeriesInstanceUID' : 'series' }), tags)
         self.assertEqual('series', a[2])
-        self.assertFalse(a[4].startswith('Orthanc'))
+        self.assertNotIn('PS 3.15', a[4])
 
         a = ExtractDicomTags(Anonymize(u, { 'StudyInstanceUID' : 'study' }), tags)
         self.assertEqual('study', a[1])
-        self.assertFalse(a[4].startswith('Orthanc'))
+        self.assertNotIn('PS 3.15', a[4])
 
         a = ExtractDicomTags(Anonymize(u, { 'PatientID' : 'patient' }), tags)
         self.assertEqual('patient', a[0])
-        self.assertFalse(a[4].startswith('Orthanc'))
+        self.assertNotIn('PS 3.15', a[4])
 
         a = ExtractDicomTags(Anonymize(u, { 'PatientID' : 'patient',
                                             'StudyInstanceUID' : 'study',
                                             'SeriesInstanceUID' : 'series',
                                             'SOPInstanceUID' : 'instance' }), tags)
         self.assertEqual('patient', a[0])
-        self.assertFalse(a[4].startswith('Orthanc'))
+        self.assertNotIn('PS 3.15', a[4])
 
         self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))
 
@@ -6702,7 +6728,6 @@
         SYNTAXES = [
             '1.2.840.10008.1.2',        
             '1.2.840.10008.1.2.1',
-            '1.2.840.10008.1.2.1.99',  # Deflated Explicit VR Little Endian (cannot be decoded in debug mode if Orthanc is statically linked against DCMTK 3.6.5)
             '1.2.840.10008.1.2.2',
             '1.2.840.10008.1.2.4.50',
             '1.2.840.10008.1.2.4.51',
@@ -6710,6 +6735,10 @@
             '1.2.840.10008.1.2.4.70',
         ]
 
+        if IsOrthancVersionAbove(_REMOTE, 1, 12, 7):
+            SYNTAXES.append('1.2.840.10008.1.2.1.99')  # Deflated Explicit VR Little Endian (cannot be decoded in debug mode if Orthanc is statically linked against DCMTK 3.6.5)
+
+
         if HasGdcmPlugin(_REMOTE):
             SYNTAXES = SYNTAXES + [
                 '1.2.840.10008.1.2.4.80',  # This makes DCMTK 3.6.2 crash
@@ -7377,7 +7406,6 @@
         SYNTAXES = [
             '1.2.840.10008.1.2',        
             '1.2.840.10008.1.2.1',
-            '1.2.840.10008.1.2.1.99',  # Deflated Explicit VR Little Endian (cannot be decoded in debug mode if Orthanc is statically linked against DCMTK 3.6.5)
             '1.2.840.10008.1.2.2',
             '1.2.840.10008.1.2.4.50',
             '1.2.840.10008.1.2.4.51',
@@ -7385,6 +7413,11 @@
             '1.2.840.10008.1.2.4.70',
         ]
 
+        if IsOrthancVersionAbove(_REMOTE, 1, 12, 7):
+            SYNTAXES.append('1.2.840.10008.1.2.1.99')  # Deflated Explicit VR Little Endian (cannot be decoded in debug mode if Orthanc is statically linked against DCMTK 3.6.5)
+
+
+
         if HasGdcmPlugin(_REMOTE):
             SYNTAXES = SYNTAXES + [
                 '1.2.840.10008.1.2.4.80',  # This makes DCMTK 3.6.2 crash
@@ -8356,6 +8389,7 @@
         tags2021b = GetTags(study, { 'DicomVersion' : '2021b' })
         tags2023b = GetTags(study, { 'DicomVersion' : '2023b' })
         tagsDefault = GetTags(study, {})
+        tagsReplace = GetTags(study, { 'Replace' : { 'StationName': 'tutu' }})
 
         orthancVersion = DoGet(_REMOTE, '/system') ['Version']
         if orthancVersion.startswith('mainline-'):  # happens in unstable orthancteam/orthanc images
@@ -8365,6 +8399,9 @@
         self.assertEqual('Orthanc %s - PS 3.15-2017c Table E.1-1 Basic Profile' % orthancVersion, tags2017c['0012,0063'])
         self.assertEqual('Orthanc %s - PS 3.15-2021b Table E.1-1 Basic Profile' % orthancVersion, tags2021b['0012,0063'])
         self.assertEqual('Orthanc %s - PS 3.15-2023b Table E.1-1 Basic Profile' % orthancVersion, tags2023b['0012,0063'])
+        if IsOrthancVersionAbove(_REMOTE, 1, 12, 7):
+            self.assertEqual('Orthanc %s' % orthancVersion, tagsReplace['0012,0063'])
+
         self.assertEqual(tagsDefault['0012,0063'], tags2023b['0012,0063'])
 
         self.assertEqual(len(tags2021b), len(tags2023b))
@@ -11955,7 +11992,7 @@
             self.assertEqual(1, len(a))
 
     def test_deflated_invalid_size(self):  # https://discourse.orthanc-server.org/t/transcoding-to-deflated-transfer-syntax-fails/5489
-        if IsOrthancVersionAbove(_REMOTE, 1, 12, 7) and HasExtendedFind(_REMOTE):
+        if IsOrthancVersionAbove(_REMOTE, 1, 12, 7):
             instanceId = '6582b1c0-292ad5ab-ba0f088f-f7a1766f-9a29a54f'
 
             r = UploadInstance(_REMOTE, 'TransferSyntaxes/1.2.840.10008.1.2.1.99.dcm')
@@ -11969,5 +12006,81 @@
                                 _REMOTE['Server'], str(_REMOTE['DicomPort']),
                                 GetDatabasePath('TransferSyntaxes/1.2.840.10008.1.2.1.99.dcm') ])
             attachments = DoGet(_REMOTE, '/instances/' + instanceId + '/attachments/dicom/info/')
-            self.assertEqual(181071, int(attachments['UncompressedSize']))
-
+            self.assertLessEqual(181071, int(attachments['UncompressedSize']))
+            self.assertGreaterEqual(181073, int(attachments['UncompressedSize']))  # there might be some padding added
+
+    def test_embed_jpeg(self):
+        if not IsOrthancVersionAbove(_REMOTE, 1, 12, 7):
+            return
+
+        with open(GetDatabasePath('Lena.jpg'), 'rb') as f:
+            jpeg = f.read()
+
+        i = DoPost(_REMOTE, '/tools/create-dicom', json.dumps({
+            'Content' : 'data:image/jpeg;base64,%s' % base64.b64encode(jpeg).decode(),
+            'Encapsulate' : True,
+            'Tags' : {
+                'SOPClassUID' : '1.2.840.10008.5.1.4.1.1.7',
+            }
+        })) ['ID']
+
+        tags = DoGet(_REMOTE, '/instances/%s/tags?simplify' % i)
+        self.assertEqual(tags['BitsAllocated'], '8')
+        self.assertEqual(tags['BitsStored'], '8')
+        self.assertEqual(tags['Columns'], '512')
+        self.assertEqual(tags['HighBit'], '7')
+        self.assertEqual(tags['PhotometricInterpretation'], 'YBR_FULL_422')
+        self.assertEqual(tags['PixelData'], None)
+        self.assertEqual(tags['PixelRepresentation'], '0')
+        self.assertEqual(tags['PlanarConfiguration'], '0')
+        self.assertEqual(tags['Rows'], '512')
+        self.assertEqual(tags['SOPClassUID'], '1.2.840.10008.5.1.4.1.1.7')
+        self.assertEqual(tags['SamplesPerPixel'], '3')
+        self.assertEqual(tags['SpecificCharacterSet'], 'ISO_IR 100')
+        pixelData = DoGet(_REMOTE, '/instances/%s/content/7fe0,0010' % i)
+        self.assertEqual(len(pixelData), 2)
+        self.assertEqual(pixelData[0], '0')
+        self.assertEqual(pixelData[1], '1')
+        resp, embedded = DoGetRaw(_REMOTE, '/instances/%s/content/7fe0,0010/1' % i)
+        self.assertEqual('200', resp['status'])
+        self.assertEqual(len(embedded), len(jpeg))
+        self.assertEqual(embedded, jpeg)
+
+        b = io.BytesIO()
+        UncompressImage(jpeg).convert('L').save(b, format = 'jpeg')
+
+        b.seek(0)
+        grayscale = b.read()
+
+        if len(grayscale) % 2 != 0:
+            grayscale = grayscale + '\0'   # Add padding to OW boundaries (2 bytes)
+
+        i = DoPost(_REMOTE, '/tools/create-dicom', json.dumps({
+            'Content' : 'data:image/jpeg;base64,%s' % base64.b64encode(grayscale).decode(),
+            'Encapsulate' : True,
+            'Tags' : {
+                'SOPClassUID' : '1.2.840.10008.5.1.4.1.1.7',
+            }
+        })) ['ID']
+
+        tags = DoGet(_REMOTE, '/instances/%s/tags?simplify' % i)
+        self.assertEqual(tags['BitsAllocated'], '8')
+        self.assertEqual(tags['BitsStored'], '8')
+        self.assertEqual(tags['Columns'], '512')
+        self.assertEqual(tags['HighBit'], '7')
+        self.assertEqual(tags['PhotometricInterpretation'], 'MONOCHROME2')
+        self.assertEqual(tags['PixelData'], None)
+        self.assertEqual(tags['PixelRepresentation'], '0')
+        self.assertFalse('PlanarConfiguration' in tags)
+        self.assertEqual(tags['Rows'], '512')
+        self.assertEqual(tags['SOPClassUID'], '1.2.840.10008.5.1.4.1.1.7')
+        self.assertEqual(tags['SamplesPerPixel'], '1')
+        self.assertEqual(tags['SpecificCharacterSet'], 'ISO_IR 100')
+        pixelData = DoGet(_REMOTE, '/instances/%s/content/7fe0,0010' % i)
+        self.assertEqual(len(pixelData), 2)
+        self.assertEqual(pixelData[0], '0')
+        self.assertEqual(pixelData[1], '1')
+        resp, embedded = DoGetRaw(_REMOTE, '/instances/%s/content/7fe0,0010/1' % i)
+        self.assertEqual('200', resp['status'])
+        self.assertEqual(len(embedded), len(grayscale))
+        self.assertEqual(embedded, grayscale)