changeset 678:72f186c739d0 large-queries

merged default -> large-queries
author Alain Mazy <am@orthanc.team>
date Thu, 05 Sep 2024 18:49:09 +0200
parents 599ff47f609d (current diff) 85c1447fa86b (diff)
children db7cf82a881b 60cdae616275
files NewTests/Authorization/test_authorization.py Tests/Tests.py
diffstat 28 files changed, 639 insertions(+), 62 deletions(-) [+]
line wrap: on
line diff
--- a/.hgignore	Thu Sep 05 18:46:25 2024 +0200
+++ b/.hgignore	Thu Sep 05 18:49:09 2024 +0200
@@ -10,4 +10,7 @@
 *~
 NewTests/storages/**
 NewTests/configurations/*.json
-.env/
\ No newline at end of file
+.env/
+Tests/*.crt
+Tests/*.key
+Tests/dicom-tls.json
\ No newline at end of file
--- a/.hgtags	Thu Sep 05 18:46:25 2024 +0200
+++ b/.hgtags	Thu Sep 05 18:49:09 2024 +0200
@@ -44,3 +44,4 @@
 86456045ac80545a2eca1fe0d8d7eb337a0b4ceb Orthanc-1.12.0
 855c3720902a1dade9accf91571ee6719e0c1eb6 Orthanc-1.12.1
 ec657d1a62a6c5eebfe5255a8afe082e92d973c1 Orthanc-1.12.3
+7bfc8992ab8fc44bd811bc60ebf3332303bc87ed Orthanc-1.12.4
--- a/AUTHORS	Thu Sep 05 18:46:25 2024 +0200
+++ b/AUTHORS	Thu Sep 05 18:49:09 2024 +0200
@@ -14,16 +14,22 @@
   4000 Liege
   Belgium
 
-* Osimis S.A. <info@osimis.io>
+* Osimis S.A.
   Quai Banning 6
   4000 Liege
   Belgium
-  http://www.osimis.io/
+
+* Orthanc Team SRL <info@orthanc.team>
+  Rue Joseph Marchal 14
+  4910 Theux
+  Belgium
+  https://orthanc.team/
 
 * ICTEAM, UCLouvain
   Place de l'Universite 1
   1348 Ottignies-Louvain-la-Neuve
   Belgium
+  https://uclouvain.be/icteam
 
 
 Contributors
--- a/CITATION.cff	Thu Sep 05 18:46:25 2024 +0200
+++ b/CITATION.cff	Thu Sep 05 18:49:09 2024 +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.3
-date-released: 2024-01-31
+version: 1.12.4
+date-released: 2024-06-05
Binary file Database/2024-05-30-GuillemVela.dcm has changed
Binary file Database/Formats/jp2k91YBR-ICT.dcm has changed
Binary file Database/WithEmptyPatientComments.dcm has changed
--- a/GenerateConfigurationForTests.py	Thu Sep 05 18:46:25 2024 +0200
+++ b/GenerateConfigurationForTests.py	Thu Sep 05 18:49:09 2024 +0200
@@ -3,7 +3,8 @@
 # Orthanc - A Lightweight, RESTful DICOM Store
 # Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
 # Department, University Hospital of Liege, Belgium
-# Copyright (C) 2017-2024 Osimis S.A., Belgium
+# Copyright (C) 2017-2023 Osimis S.A., Belgium
+# Copyright (C) 2024-2024 Orthanc Team SRL, Belgium
 # Copyright (C) 2021-2024 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
 #
 # This program is free software: you can redistribute it and/or
--- a/NewTests/Authorization/auth_service.py	Thu Sep 05 18:46:25 2024 +0200
+++ b/NewTests/Authorization/auth_service.py	Thu Sep 05 18:49:09 2024 +0200
@@ -47,6 +47,13 @@
                 authorized_labels=["label_a"],
                 validity=60
             )
+        elif user_profile_request.token_value == "token-uploader-a":  # this use shall be able to upload anything but view only the labeled studies
+            p = UserProfileResponse(
+                name="uploader-a",
+                permissions=["view", "upload"],
+                authorized_labels=["label_a"],
+                validity=60
+            )
 
     return p        
 
--- a/NewTests/Authorization/test_authorization.py	Thu Sep 05 18:46:25 2024 +0200
+++ b/NewTests/Authorization/test_authorization.py	Thu Sep 05 18:49:09 2024 +0200
@@ -177,6 +177,10 @@
         self.assert_is_forbidden(lambda: o.studies.get_tags(self.label_b_study_id))
         self.assert_is_forbidden(lambda: o.studies.get_tags(self.no_label_study_id))
 
+        # user_a shall not be able to upload a study
+        self.assert_is_forbidden(lambda: o.upload_file(here / "../../Database/Beaufix/IM-0001-0001.dcm"))
+        self.assert_is_forbidden(lambda: o.upload_files_dicom_web(paths = [here / "../../Database/Beaufix/IM-0001-0001.dcm"]))
+
         # should not raise
         o.studies.get_tags(self.label_a_study_id)
 
@@ -269,6 +273,32 @@
             i = o_admin.get_json(f"dicom-web/studies/{self.label_a_study_dicom_id}/series?includefield=00080021%2C00080031%2C0008103E%2C00200011")
 
 
+    def test_uploader_a(self):
+        o_admin = OrthancApiClient(self.o._root_url, headers={"user-token-key": "token-admin"})
+        o = OrthancApiClient(self.o._root_url, headers={"user-token-key": "token-uploader-a"})
+
+        if o_admin.is_plugin_version_at_least("authorization", 0, 7, 3):
+
+            # # make sure we can access all these urls (they would throw if not)
+            system = o.get_system()
+
+            all_labels = o.get_all_labels()
+            self.assertEqual(1, len(all_labels))
+            self.assertEqual("label_a", all_labels[0])
+
+            # make sure we can access only the label_a studies
+            self.assert_is_forbidden(lambda: o.studies.get_tags(self.label_b_study_id))
+            self.assert_is_forbidden(lambda: o.studies.get_tags(self.no_label_study_id))
+
+            # uploader-a shall be able to upload a study
+            instances_ids = o.upload_file(here / "../../Database/Beaufix/IM-0001-0001.dcm")
+            o_admin.instances.delete(orthanc_ids=instances_ids)
+
+            # uploader-a shall be able to upload a study through DICOMWeb too
+            o.upload_files_dicom_web(paths = [here / "../../Database/Beaufix/IM-0001-0001.dcm"])
+            o_admin.instances.delete(orthanc_ids=instances_ids)
+
+
     def test_resource_token(self):
 
         o = OrthancApiClient(self.o._root_url, headers={"resource-token-key": "token-a-study"})
--- a/NewTests/Concurrency/test_transfer.py	Thu Sep 05 18:46:25 2024 +0200
+++ b/NewTests/Concurrency/test_transfer.py	Thu Sep 05 18:49:09 2024 +0200
@@ -76,9 +76,9 @@
 
             for i in range(0, repeat_count):
                 oa.transfers.send(target_peer='b',
-                                resources_ids=all_studies_ids,
-                                resource_type=ResourceType.STUDY,
-                                compress=compression)
+                                  resources_ids=all_studies_ids,
+                                  resource_type=ResourceType.STUDY,
+                                  compress=compression)
                 
                 self.assertEqual(instances_count, ob.get_statistics().instances_count)
                 self.assertEqual(disk_size, ob.get_statistics().total_disk_size)
--- a/Plugins/CGet/Run.py	Thu Sep 05 18:46:25 2024 +0200
+++ b/Plugins/CGet/Run.py	Thu Sep 05 18:49:09 2024 +0200
@@ -3,7 +3,8 @@
 # Orthanc - A Lightweight, RESTful DICOM Store
 # Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
 # Department, University Hospital of Liege, Belgium
-# Copyright (C) 2017-2024 Osimis S.A., Belgium
+# Copyright (C) 2017-2023 Osimis S.A., Belgium
+# Copyright (C) 2024-2024 Orthanc Team SRL, Belgium
 # Copyright (C) 2021-2024 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
 #
 # This program is free software: you can redistribute it and/or
--- a/Plugins/DicomWeb/DicomWeb.py	Thu Sep 05 18:46:25 2024 +0200
+++ b/Plugins/DicomWeb/DicomWeb.py	Thu Sep 05 18:49:09 2024 +0200
@@ -3,7 +3,8 @@
 # Orthanc - A Lightweight, RESTful DICOM Store
 # Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
 # Department, University Hospital of Liege, Belgium
-# Copyright (C) 2017-2024 Osimis S.A., Belgium
+# Copyright (C) 2017-2023 Osimis S.A., Belgium
+# Copyright (C) 2024-2024 Orthanc Team SRL, Belgium
 # Copyright (C) 2021-2024 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
 #
 # This program is free software: you can redistribute it and/or
--- a/Plugins/DicomWeb/Run.py	Thu Sep 05 18:46:25 2024 +0200
+++ b/Plugins/DicomWeb/Run.py	Thu Sep 05 18:49:09 2024 +0200
@@ -1,11 +1,12 @@
-#!/usr/bin/python
+#!/usr/bin/python3
 # -*- coding: utf-8 -*-
 
 
 # Orthanc - A Lightweight, RESTful DICOM Store
 # Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
 # Department, University Hospital of Liege, Belgium
-# Copyright (C) 2017-2024 Osimis S.A., Belgium
+# Copyright (C) 2017-2023 Osimis S.A., Belgium
+# Copyright (C) 2024-2024 Orthanc Team SRL, Belgium
 # Copyright (C) 2021-2024 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
 #
 # This program is free software: you can redistribute it and/or
@@ -302,7 +303,10 @@
                                  { 'Resources' : [ 'nope' ],
                                    'Synchronous' : True }))  # inexisting resource
 
-        l = 3   # For >= 1.10.1
+        if IsPluginVersionAbove(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',
@@ -311,6 +315,8 @@
 
         self.assertEqual(l, len(r))
         self.assertEqual("0a9b3153-2512774b-2d9580de-1fc3dcf6-3bd83918", r['Resources']['Studies'][0])
+        if IsPluginVersionAbove(ORTHANC, "dicom-web", 1, 18, 0):
+            self.assertEqual("sample", r['Server'])
 
         # series
         r = DoPost(ORTHANC, '/dicom-web/servers/sample/stow',
@@ -599,6 +605,16 @@
         self.assertEqual('Wang^XiaoDong', pn['Value'][0]['Alphabetic'])
         self.assertEqual(u'王^小東', pn['Value'][0]['Ideographic'])
 
+        # new derivated test added later
+        if IsPluginVersionAbove(ORTHANC, "dicom-web", 1, 18, 0):
+            a = DoGet(ORTHANC, '/dicom-web/studies?StudyInstanceUID=1.3.6.1.4.1.5962.1.2.0.1175775771.5711.0')
+            self.assertEqual(1, len(a))
+            pn = a[0]['00100010']  # Patient name
+            self.assertEqual('PN', pn['vr'])
+            self.assertEqual(1, len(pn['Value']))
+            self.assertEqual('Wang^XiaoDong', pn['Value'][0]['Alphabetic'])     # before 1.18, one of the 2 values was empty !
+            self.assertEqual(u'王^小東', pn['Value'][0]['Ideographic'])
+
 
     def test_bitbucket_issue_96(self):
         # WADO-RS RetrieveFrames rejects valid accept headers
@@ -681,6 +697,14 @@
         self.assertTrue('00280010' in a[0])
         self.assertEqual(512, a[0]['00280010']['Value'][0])
 
+        if IsPluginVersionAbove(ORTHANC, "dicom-web", 1, 17, 0):
+            a = DoGet(ORTHANC, '/dicom-web/studies/1.2.840.113619.2.176.2025.1499492.7391.1171285944.390/series/1.2.840.113619.2.176.2025.1499492.7391.1171285944.394/instances?includefield=00081140')
+            self.assertEqual(1, len(a))
+            self.assertTrue('00081140' in a[0])
+            self.assertEqual(2, len(a[0]['00081140']['Value']))
+            self.assertEqual('1.2.840.113619.2.176.2025.1499492.7040.1171286241.719', a[0]['00081140']['Value'][0]['00081155']['Value'][0])
+
+
         
     def test_stow_errors(self):
         def CheckSequences(a):
--- a/Plugins/Recycling/Run.py	Thu Sep 05 18:46:25 2024 +0200
+++ b/Plugins/Recycling/Run.py	Thu Sep 05 18:49:09 2024 +0200
@@ -3,7 +3,8 @@
 # Orthanc - A Lightweight, RESTful DICOM Store
 # Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
 # Department, University Hospital of Liege, Belgium
-# Copyright (C) 2017-2024 Osimis S.A., Belgium
+# Copyright (C) 2017-2023 Osimis S.A., Belgium
+# Copyright (C) 2024-2024 Orthanc Team SRL, Belgium
 # Copyright (C) 2021-2024 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
 #
 # This program is free software: you can redistribute it and/or
--- a/Plugins/Transfers/Run.py	Thu Sep 05 18:46:25 2024 +0200
+++ b/Plugins/Transfers/Run.py	Thu Sep 05 18:49:09 2024 +0200
@@ -5,7 +5,8 @@
 # Orthanc - A Lightweight, RESTful DICOM Store
 # Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
 # Department, University Hospital of Liege, Belgium
-# Copyright (C) 2017-2024 Osimis S.A., Belgium
+# Copyright (C) 2017-2023 Osimis S.A., Belgium
+# Copyright (C) 2024-2024 Orthanc Team SRL, Belgium
 # Copyright (C) 2021-2024 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
 #
 # This program is free software: you can redistribute it and/or
--- a/Plugins/WSI/Run.py	Thu Sep 05 18:46:25 2024 +0200
+++ b/Plugins/WSI/Run.py	Thu Sep 05 18:49:09 2024 +0200
@@ -5,7 +5,8 @@
 # Orthanc - A Lightweight, RESTful DICOM Store
 # Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
 # Department, University Hospital of Liege, Belgium
-# Copyright (C) 2017-2024 Osimis S.A., Belgium
+# Copyright (C) 2017-2023 Osimis S.A., Belgium
+# Copyright (C) 2024-2024 Orthanc Team SRL, Belgium
 # Copyright (C) 2021-2024 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
 #
 # This program is free software: you can redistribute it and/or
@@ -459,12 +460,21 @@
         self.assertEqual(512, info['height'])
 
         self.assertEqual(3, len(info['sizes']))
-        self.assertEqual(512, info['sizes'][0]['width'])
-        self.assertEqual(512, info['sizes'][0]['height'])
-        self.assertEqual(256, info['sizes'][1]['width'])
-        self.assertEqual(256, info['sizes'][1]['height'])
-        self.assertEqual(128, info['sizes'][2]['width'])
-        self.assertEqual(128, info['sizes'][2]['height'])
+        
+        if IsPluginVersionAbove(ORTHANC, "wsi", 2, 1, 0):   # https://orthanc.uclouvain.be/hg/orthanc-wsi/rev/9dc7f1e8716d
+            self.assertEqual(512, info['sizes'][2]['width'])
+            self.assertEqual(512, info['sizes'][2]['height'])
+            self.assertEqual(256, info['sizes'][1]['width'])
+            self.assertEqual(256, info['sizes'][1]['height'])
+            self.assertEqual(128, info['sizes'][0]['width'])
+            self.assertEqual(128, info['sizes'][0]['height'])
+        else:
+            self.assertEqual(512, info['sizes'][0]['width'])
+            self.assertEqual(512, info['sizes'][0]['height'])
+            self.assertEqual(256, info['sizes'][1]['width'])
+            self.assertEqual(256, info['sizes'][1]['height'])
+            self.assertEqual(128, info['sizes'][2]['width'])
+            self.assertEqual(128, info['sizes'][2]['height'])
 
         self.assertEqual(1, len(info['tiles']))
         self.assertEqual(128, info['tiles'][0]['width'])
--- a/Plugins/WebDav/Run.py	Thu Sep 05 18:46:25 2024 +0200
+++ b/Plugins/WebDav/Run.py	Thu Sep 05 18:49:09 2024 +0200
@@ -3,7 +3,8 @@
 # Orthanc - A Lightweight, RESTful DICOM Store
 # Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
 # Department, University Hospital of Liege, Belgium
-# Copyright (C) 2017-2024 Osimis S.A., Belgium
+# Copyright (C) 2017-2023 Osimis S.A., Belgium
+# Copyright (C) 2024-2024 Orthanc Team SRL, Belgium
 # Copyright (C) 2021-2024 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
 #
 # This program is free software: you can redistribute it and/or
--- a/Plugins/Worklists/Run.py	Thu Sep 05 18:46:25 2024 +0200
+++ b/Plugins/Worklists/Run.py	Thu Sep 05 18:49:09 2024 +0200
@@ -5,7 +5,8 @@
 # Orthanc - A Lightweight, RESTful DICOM Store
 # Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
 # Department, University Hospital of Liege, Belgium
-# Copyright (C) 2017-2024 Osimis S.A., Belgium
+# Copyright (C) 2017-2023 Osimis S.A., Belgium
+# Copyright (C) 2024-2024 Orthanc Team SRL, Belgium
 # Copyright (C) 2021-2024 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
 #
 # This program is free software: you can redistribute it and/or
--- a/README	Thu Sep 05 18:46:25 2024 +0200
+++ b/README	Thu Sep 05 18:49:09 2024 +0200
@@ -139,6 +139,23 @@
 To run the IngestTranscoding tests:
 # rm -rf /tmp/OrthancTest && python ./Tests/CheckIngestTranscoding.py /home/alain/o/build/orthanc/orthanc
 
+To run the DICOM TLS tests without Client certificate checks:
+# cd ./Tests
+# python CheckDicomTls.py --config-no-check-client
+# /home/alain/o/build/orthanc/Orthanc --verbose dicom-tls.json
+#### or
+# docker run -p 8042:8042 -p 4242:4242 -e ORTHANC__DICOM_TLS_ENABLED=true -e ORTHANC__DICOM_TLS_CERTIFICATE=/certs/dicom-tls-a.crt -e ORTHANC__DICOM_TLS_PRIVATE_KEY=/certs/dicom-tls-a.key -e ORTHANC__DICOM_TLS_REMOTE_CERTIFICATE_REQUIRED=false -e ORTHANC__DICOM_TLS_TRUSTED_CERTIFICATES=/certs/dicom-tls-trusted.crt -e ORTHANC__EXECUTE_LUA_ENABLED=true -v .:/certs/ -e ORTHANC__AUTHENTICATION_ENABLED=false -e VERBOSE_ENABLED=true orthancteam/orthanc:24.6.1
+# python CheckDicomTls.py --force OrthancNoCheckClient
+
+To run the DICOM TLS tests without Client certificate checks:
+# cd ./Tests
+# python CheckDicomTls.py --config-check-client
+# /home/alain/o/build/orthanc/Orthanc --verbose dicom-tls.json
+#### or
+# docker run -p 8042:8042 -p 4242:4242 -e ORTHANC__DICOM_TLS_ENABLED=true -e ORTHANC__DICOM_TLS_CERTIFICATE=/certs/dicom-tls-a.crt -e ORTHANC__DICOM_TLS_PRIVATE_KEY=/certs/dicom-tls-a.key -e ORTHANC__DICOM_TLS_REMOTE_CERTIFICATE_REQUIRED=true -e ORTHANC__DICOM_TLS_TRUSTED_CERTIFICATES=/certs/dicom-tls-trusted.crt -e ORTHANC__EXECUTE_LUA_ENABLED=true -v .:/certs/ -e ORTHANC__AUTHENTICATION_ENABLED=false -e VERBOSE_ENABLED=true orthancteam/orthanc:24.6.1
+# python CheckDicomTls.py --force OrthancCheckClient
+
+
 
 (Option 2b) With Docker:
 
--- a/Tests/CheckDicomTls.py	Thu Sep 05 18:46:25 2024 +0200
+++ b/Tests/CheckDicomTls.py	Thu Sep 05 18:49:09 2024 +0200
@@ -3,7 +3,8 @@
 # Orthanc - A Lightweight, RESTful DICOM Store
 # Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
 # Department, University Hospital of Liege, Belgium
-# Copyright (C) 2017-2024 Osimis S.A., Belgium
+# Copyright (C) 2017-2023 Osimis S.A., Belgium
+# Copyright (C) 2024-2024 Orthanc Team SRL, Belgium
 # Copyright (C) 2021-2024 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
 #
 # This program is free software: you can redistribute it and/or
@@ -60,7 +61,9 @@
                     help = 'Password to the REST API')
 parser.add_argument('--force', help = 'Do not warn the user',
                     action = 'store_true')
-parser.add_argument('--config', help = 'Create the configuration files for this test in the current folder',
+parser.add_argument('--config-no-check-client', help = 'Create the configuration files for the "no-check-client" tests in the current folder',
+                    action = 'store_true')
+parser.add_argument('--config-check-client', help = 'Create the configuration files for the "check-client" tests test in the current folder',
                     action = 'store_true')
 parser.add_argument('options', metavar = 'N', nargs = '*',
                     help='Arguments to Python unittest')
@@ -73,14 +76,14 @@
 ##
 
 
-if args.config:
+if args.config_no_check_client or args.config_check_client:
     def CreateCertificate(name):
         subprocess.check_call([ 'openssl', 'req', '-x509', '-nodes', '-days', '365', '-newkey', 'rsa:2048',
                                 '-keyout', '%s.key' % name,
                                 '-out', '%s.crt' % name,
                                 '-subj', '/C=BE/CN=localhost' ])
 
-    print('Writing configuration to folder: %s' % args.config)
+    print('Writing configuration for the %s tests to current folder' % ('no-check-client' if args.config_no_check_client else 'check-client'))
     CreateCertificate('dicom-tls-a')
     CreateCertificate('dicom-tls-b')
     CreateCertificate('dicom-tls-c')  # Not trusted by Orthanc
@@ -101,7 +104,7 @@
             'RegisteredUsers' : {
                 'alice' : 'orthanctest'
             },
-            'DicomTlsRemoteCertificateRequired' : False,  # New in Orthanc 1.9.3
+            'DicomTlsRemoteCertificateRequired' : args.config_check_client,  # New in Orthanc 1.9.3
         }))
 
     exit(0)
@@ -135,7 +138,8 @@
 FNULL = open(os.devnull, 'w')  # Emulates "subprocess.DEVNULL" on Python 2.7
 
     
-class Orthanc(unittest.TestCase):
+# in these tests, Orthanc does not check client certificates
+class OrthancNoCheckClient(unittest.TestCase):
     def setUp(self):
         if (sys.version_info >= (3, 0)):
             # Remove annoying warnings about unclosed socket in Python 3
@@ -146,7 +150,7 @@
 
         
     def test_incoming(self):
-        # No certificate     
+        # No client certificate provided and client does not check server cert -> raise
         self.assertRaises(Exception, lambda: subprocess.check_call([
             FindExecutable('echoscu'),
             ORTHANC['Server'], 
@@ -154,6 +158,16 @@
             '-aec', 'ORTHANC',
         ], stderr = FNULL))
 
+        # No client certificate provided and client does check server cert -> no raise
+        self.assertRaises(Exception, lambda: subprocess.check_call([
+            FindExecutable('echoscu'),
+            ORTHANC['Server'], 
+            str(ORTHANC['DicomPort']),
+            '-aec', 'ORTHANC',
+            '+cf', 'dicom-tls-a.crt'
+        ], stderr = FNULL))
+
+        # random client certificate provided and client does check server cert -> no raise since Orthanc does not check the client cert
         subprocess.check_call([
             FindExecutable('echoscu'),
             ORTHANC['Server'], 
@@ -163,23 +177,6 @@
             '+cf', 'dicom-tls-a.crt',
         ], stderr = FNULL)
 
-        self.assertRaises(Exception, lambda: subprocess.check_call([
-            FindExecutable('echoscu'),
-            ORTHANC['Server'], 
-            str(ORTHANC['DicomPort']),
-            '-aec', 'ORTHANC',
-            '+tls', 'dicom-tls-c.key', 'dicom-tls-c.crt',  # Not trusted by Orthanc
-            '+cf', 'dicom-tls-a.crt',
-        ], stderr = FNULL))
-
-        self.assertRaises(Exception, lambda: subprocess.check_call([
-            FindExecutable('echoscu'),
-            ORTHANC['Server'], 
-            str(ORTHANC['DicomPort']),
-            '-aec', 'ORTHANC',
-            '+tls', 'dicom-tls-b.key', 'dicom-tls-b.crt',
-            '+cf', 'dicom-tls-b.crt',  # Not the certificate of Orthanc
-        ], stderr = FNULL))
 
         
     def test_outgoing_to_self(self):
@@ -217,7 +214,40 @@
             '+cf', 'dicom-tls-a.crt',
         ], stderr = FNULL)
         
+
+# in these tests, Orthanc do checks client certificates
+class OrthancCheckClient(unittest.TestCase):
+    def setUp(self):
+        if (sys.version_info >= (3, 0)):
+            # Remove annoying warnings about unclosed socket in Python 3
+            import warnings
+            warnings.simplefilter('ignore', ResourceWarning)
+
+        DropOrthanc(ORTHANC)
+
         
+    def test_check_client_incoming(self):
+        # client provides an untrusted certificate -> Orthanc will complain -> raise
+        self.assertRaises(Exception, lambda: subprocess.check_call([
+            FindExecutable('echoscu'),
+            ORTHANC['Server'], 
+            str(ORTHANC['DicomPort']),
+            '-aec', 'ORTHANC',
+            '+tls', 'dicom-tls-c.key', 'dicom-tls-c.crt',  # Not trusted by Orthanc
+            '+cf', 'dicom-tls-a.crt',
+        ], stderr = FNULL))
+
+        # client provides a trusted certificate but expects another cert from Orthanc -> raise
+        self.assertRaises(Exception, lambda: subprocess.check_call([
+            FindExecutable('echoscu'),
+            ORTHANC['Server'], 
+            str(ORTHANC['DicomPort']),
+            '-aec', 'ORTHANC',
+            '+tls', 'dicom-tls-b.key', 'dicom-tls-b.crt',
+            '+cf', 'dicom-tls-b.crt',  # Not the certificate of Orthanc
+        ], stderr = FNULL))
+
+
 try:
     print('\nStarting the tests...')
     unittest.main(argv = [ sys.argv[0] ] + args.options)
--- a/Tests/CheckHttpServerSecurity.py	Thu Sep 05 18:46:25 2024 +0200
+++ b/Tests/CheckHttpServerSecurity.py	Thu Sep 05 18:49:09 2024 +0200
@@ -3,7 +3,8 @@
 # Orthanc - A Lightweight, RESTful DICOM Store
 # Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
 # Department, University Hospital of Liege, Belgium
-# Copyright (C) 2017-2024 Osimis S.A., Belgium
+# Copyright (C) 2017-2023 Osimis S.A., Belgium
+# Copyright (C) 2024-2024 Orthanc Team SRL, Belgium
 # Copyright (C) 2021-2024 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
 #
 # This program is free software: you can redistribute it and/or
--- a/Tests/CheckIngestTranscoding.py	Thu Sep 05 18:46:25 2024 +0200
+++ b/Tests/CheckIngestTranscoding.py	Thu Sep 05 18:49:09 2024 +0200
@@ -3,7 +3,8 @@
 # Orthanc - A Lightweight, RESTful DICOM Store
 # Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
 # Department, University Hospital of Liege, Belgium
-# Copyright (C) 2017-2024 Osimis S.A., Belgium
+# Copyright (C) 2017-2023 Osimis S.A., Belgium
+# Copyright (C) 2024-2024 Orthanc Team SRL, Belgium
 # Copyright (C) 2021-2024 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
 #
 # This program is free software: you can redistribute it and/or
--- a/Tests/CheckScuTranscoding.py	Thu Sep 05 18:46:25 2024 +0200
+++ b/Tests/CheckScuTranscoding.py	Thu Sep 05 18:49:09 2024 +0200
@@ -3,7 +3,8 @@
 # Orthanc - A Lightweight, RESTful DICOM Store
 # Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
 # Department, University Hospital of Liege, Belgium
-# Copyright (C) 2017-2024 Osimis S.A., Belgium
+# Copyright (C) 2017-2023 Osimis S.A., Belgium
+# Copyright (C) 2024-2024 Orthanc Team SRL, Belgium
 # Copyright (C) 2021-2024 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
 #
 # This program is free software: you can redistribute it and/or
--- a/Tests/CheckZipStreams.py	Thu Sep 05 18:46:25 2024 +0200
+++ b/Tests/CheckZipStreams.py	Thu Sep 05 18:49:09 2024 +0200
@@ -3,7 +3,8 @@
 # Orthanc - A Lightweight, RESTful DICOM Store
 # Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
 # Department, University Hospital of Liege, Belgium
-# Copyright (C) 2017-2024 Osimis S.A., Belgium
+# Copyright (C) 2017-2023 Osimis S.A., Belgium
+# Copyright (C) 2024-2024 Orthanc Team SRL, Belgium
 # Copyright (C) 2021-2024 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
 #
 # This program is free software: you can redistribute it and/or
--- a/Tests/Run.py	Thu Sep 05 18:46:25 2024 +0200
+++ b/Tests/Run.py	Thu Sep 05 18:49:09 2024 +0200
@@ -3,7 +3,8 @@
 # Orthanc - A Lightweight, RESTful DICOM Store
 # Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
 # Department, University Hospital of Liege, Belgium
-# Copyright (C) 2017-2024 Osimis S.A., Belgium
+# Copyright (C) 2017-2023 Osimis S.A., Belgium
+# Copyright (C) 2024-2024 Orthanc Team SRL, Belgium
 # Copyright (C) 2021-2024 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
 #
 # This program is free software: you can redistribute it and/or
--- a/Tests/Tests.py	Thu Sep 05 18:46:25 2024 +0200
+++ b/Tests/Tests.py	Thu Sep 05 18:49:09 2024 +0200
@@ -5,7 +5,8 @@
 # Orthanc - A Lightweight, RESTful DICOM Store
 # Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
 # Department, University Hospital of Liege, Belgium
-# Copyright (C) 2017-2024 Osimis S.A., Belgium
+# Copyright (C) 2017-2023 Osimis S.A., Belgium
+# Copyright (C) 2024-2024 Orthanc Team SRL, Belgium
 # Copyright (C) 2021-2024 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
 #
 # This program is free software: you can redistribute it and/or
@@ -1581,6 +1582,12 @@
         self.assertTrue('Test Patient BG ' in patientNames)
         self.assertTrue('Anonymized' in patientNames)
 
+        i = CallFindScu([ '-k', '0008,0052=PATIENT', '-k', '0010,0010=*' ])
+        patientNames = re.findall('\(0010,0010\).*?\[(.*?)\]', i)
+        self.assertEqual(2, len(patientNames))
+        self.assertTrue('Test Patient BG ' in patientNames)
+        self.assertTrue('Anonymized' in patientNames)
+
         i = CallFindScu([ '-k', '0008,0052=SERIES', '-k', '0008,0021' ])
         series = re.findall('\(0008,0021\).*?\[\s*(.*?)\s*\]', i)
         self.assertEqual(2, len(series))
@@ -2253,7 +2260,16 @@
                                              'Expand' : True,
                                              'Query' : { 'StudyDate' : '20080820-' }})
         self.assertEqual(0, len(a))
-        
+
+        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
+                                             'Expand' : True,
+                                             'Query' : { 'PatientPosition' : 'HFS' }})
+        self.assertEqual(2, len(a))
+
+        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
+                                             'Expand' : False,
+                                             'Query' : { 'PatientPosition' : 'HFS' }})
+        self.assertEqual(2, len(a))
         
 
     def test_rest_query_retrieve(self):
@@ -2981,7 +2997,8 @@
         self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/patients&since=10' % i))
         self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/patients&limit=10' % i))
 
-        self.assertEqual(0, len(DoGet(_REMOTE, '/patients?since=0&limit=0')))
+        if not IsOrthancVersionAbove(_REMOTE, 1, 12, 5):   # with ExtendedFind, the limit=0 means no-limit like in /tools/find
+            self.assertEqual(0, len(DoGet(_REMOTE, '/patients?since=0&limit=0')))
         self.assertEqual(2, len(DoGet(_REMOTE, '/patients?since=0&limit=100')))
         self.assertEqual(2, len(DoGet(_REMOTE, '/studies?since=0&limit=100')))
         self.assertEqual(4, len(DoGet(_REMOTE, '/series?since=0&limit=100')))
@@ -4217,6 +4234,7 @@
             knee.append(UploadInstance(_REMOTE, 'Knee/T1/IM-0001-000%d.dcm' % (i + 1)) ['ID'])
 
         # Check using BRAINIX
+        # The tests below correspond to "isSimpleLookup_ == true" in "ResourceFinder"
         a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Instance',
                                              'Query' : { 'PatientName' : 'B*' },
                                              'Limit' : 10 })
@@ -4238,6 +4256,11 @@
                                              'Limit' : 3 })
         self.assertEqual(3, len(a))
 
+        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Instance',
+                                             'Query' : { 'PatientName' : 'B*' },
+                                             'Limit' : 0 })  # This is an arbitrary convention
+        self.assertEqual(4, len(a))
+
         b = []
         for i in range(4):
             a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Instance',
@@ -4250,6 +4273,12 @@
         # Check whether the two sets are equal through symmetric difference
         self.assertEqual(0, len(set(b) ^ set(brainix)))
 
+        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Instance',
+                                             'Query' : { 'PatientName' : 'B*' },
+                                             'Limit' : 1,
+                                             'Since' : 4 })
+        self.assertEqual(0, len(a))
+
         # Check using KNEE
         a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Instance',
                                              'Query' : { 'PatientName' : 'K*' },
@@ -4272,6 +4301,99 @@
 
         self.assertEqual(0, len(set(b) ^ set(knee)))
 
+        # Now test "isSimpleLookup_ == false"
+        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
+                                             'Query' : { 'PatientPosition' : '*' }})
+        self.assertEqual(3, len(a))
+
+        b = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
+                                             'Query' : { 'PatientPosition' : '*' },
+                                             'Limit' : 0})
+        self.assertEqual(3, len(b))
+        self.assertEqual(a[0], b[0])
+        self.assertEqual(a[1], b[1])
+        self.assertEqual(a[2], b[2])
+
+        b = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
+                                             'Query' : { 'PatientPosition' : '*' },
+                                             'Limit' : 1})
+        self.assertEqual(1, len(b))
+        self.assertEqual(a[0], b[0])
+
+        b = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
+                                             'Query' : { 'PatientPosition' : '*' },
+                                             'Since' : 0,
+                                             'Limit' : 1})
+        self.assertEqual(1, len(b))
+        self.assertEqual(a[0], b[0])
+
+        b = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
+                                             'Query' : { 'PatientPosition' : '*' },
+                                             'Since' : 0,
+                                             'Limit' : 3})
+        self.assertEqual(3, len(b))
+        self.assertEqual(a[0], b[0])
+        self.assertEqual(a[1], b[1])
+        self.assertEqual(a[2], b[2])
+
+        b = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
+                                             'Query' : { 'PatientPosition' : '*' },
+                                             'Since' : 0,
+                                             'Limit' : 4})
+        self.assertEqual(3, len(b))
+        self.assertEqual(a[0], b[0])
+        self.assertEqual(a[1], b[1])
+        self.assertEqual(a[2], b[2])
+
+        b = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
+                                             'Query' : { 'PatientPosition' : '*' },
+                                             'Since' : 1,
+                                             'Limit' : 1})
+        self.assertEqual(1, len(b))
+        self.assertEqual(a[1], b[0])
+
+        b = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
+                                             'Query' : { 'PatientPosition' : '*' },
+                                             'Since' : 1,
+                                             'Limit' : 2})
+        self.assertEqual(2, len(b))
+        self.assertEqual(a[1], b[0])
+        self.assertEqual(a[2], b[1])
+
+        b = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
+                                             'Query' : { 'PatientPosition' : '*' },
+                                             'Since' : 1,
+                                             'Limit' : 3})
+        self.assertEqual(2, len(b))
+        self.assertEqual(a[1], b[0])
+        self.assertEqual(a[2], b[1])
+
+        b = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
+                                             'Query' : { 'PatientPosition' : '*' },
+                                             'Since' : 2,
+                                             'Limit' : 1})
+        self.assertEqual(1, len(b))
+        self.assertEqual(a[2], b[0])
+
+        b = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
+                                             'Query' : { 'PatientPosition' : '*' },
+                                             'Since' : 2,
+                                             'Limit' : 2})
+        self.assertEqual(1, len(b))
+        self.assertEqual(a[2], b[0])
+
+        b = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
+                                             'Query' : { 'PatientPosition' : '*' },
+                                             'Since' : 3,
+                                             'Limit' : 1})
+        self.assertEqual(0, len(b))
+
+        b = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
+                                             'Query' : { 'PatientPosition' : '*' },
+                                             'Since' : 3,
+                                             'Limit' : 10})
+        self.assertEqual(0, len(b))
+
 
     def test_bitbucket_issue_46(self):
         # "PHI remaining after anonymization"
@@ -5411,13 +5533,18 @@
         UploadInstance(_REMOTE, 'DummyCT.dcm')
         UploadInstance(_REMOTE, 'DummyCTInvalidRows.dcm')
 
-        i = CallFindScu([ '-k', '0008,0052=IMAGES', '-k', 'Rows', '-k', 'PatientName' ])
+        i = CallFindScu([ '-k', '0008,0052=IMAGES', '-k', 'PatientName', '-k', 'Rows', '-k', 'Columns' ])
 
         # We have 2 instances...
         patientNames = re.findall('\(0010,0010\).*?\[(.*?)\]', i)
         self.assertEqual(2, len(patientNames))
         self.assertEqual('KNIX', patientNames[0])
         self.assertEqual('KNIX', patientNames[1])
+
+        columns = re.findall('\(0028,0011\) US ([0-9]+)', i)
+        self.assertEqual(2, len(columns))
+        self.assertEqual('512', columns[0])
+        self.assertEqual('512', columns[1])
         
         # ...but only 1 value for the "Rows" tag
         rows = re.findall('\(0028,0010\) US ([0-9]+)', i)
@@ -5825,7 +5952,14 @@
             # This test fails on Orthanc <= 1.5.8
             'Level' : 'Study',
             'Query' : {
-                'SeriesDescription' : '*'  # Wildcard matching => no match, as the tag is absent
+                'ImageComments' : '*'  # Wildcard matching => no match, as the tag is absent
+            },
+            'Normalize' : False
+        }))
+        self.assertEqual(1, CountAnswers({
+            'Level' : 'Study',
+            'Query' : {
+                'ImageComments' : ''
             },
             'Normalize' : False
         }))
@@ -5839,7 +5973,14 @@
         self.assertEqual(1, CountAnswers({
             'Level' : 'Study',
             'Query' : {
-                'SeriesDescription' : '*'  # Matches, as wiped out by the normalization
+                'ImageComments' : '*'  # Matches, as wiped out by the normalization
+            },
+            'Normalize' : True
+        }))
+        self.assertEqual(1, CountAnswers({
+            'Level' : 'Study',
+            'Query' : {
+                'ImageComments' : ''
             },
             'Normalize' : True
         }))
@@ -9554,7 +9695,6 @@
             a = UploadInstance(_REMOTE, '2023-04-21-RLEPlanarConfigurationYBR_FULL.dcm') ['ID']
             uri = '/instances/%s/preview' % a
             im = GetImage(_REMOTE, uri)
-            pprint.pprint(im)
             self.assertEqual('RGB', im.mode)
             self.assertEqual(1260, im.size[0])
             self.assertEqual(910, im.size[1])
@@ -10238,3 +10378,299 @@
         # print(i)
         s = re.findall('\(0008,0000\).*?\[(.*?)\]', i)
         self.assertEqual(0, len(s))
+
+
+    def test_tags_after_pixel_data(self):
+        if IsOrthancVersionAbove(_REMOTE, 1, 12, 4):
+            # https://discourse.orthanc-server.org/t/private-tags-with-group-7fe0-are-not-provided-via-rest-api/4744
+            u = UploadInstance(_REMOTE, '2024-05-30-GuillemVela.dcm') ['ID']
+
+            a = DoGet(_REMOTE, '/instances/%s/tags' % u)
+            self.assertFalse('8e05,1000' in a)
+
+            a = DoGet(_REMOTE, '/instances/%s/tags?whole' % u)
+            self.assertTrue('8e05,1000' in a)
+            self.assertEqual('XEOS_Attributes', a['8e05,0010']['Value'])
+            self.assertEqual('acquisition', a['8e05,1000']['Value'])
+            self.assertEqual('specimen', a['8e05,1001']['Value'])
+
+            a = DoGet(_REMOTE, '/instances/%s/tags?full' % u)
+            self.assertFalse('8e05,1000' in a)
+
+            a = DoGet(_REMOTE, '/instances/%s/tags?full&whole' % u)
+            self.assertTrue('8e05,1000' in a)
+            self.assertEqual('XEOS_Attributes', a['8e05,0010']['Value'])
+            self.assertEqual('acquisition', a['8e05,1000']['Value'])
+            self.assertEqual('specimen', a['8e05,1001']['Value'])
+
+            a = DoGet(_REMOTE, '/instances/%s/tags?short' % u)
+            self.assertFalse('8e05,1000' in a)
+
+            a = DoGet(_REMOTE, '/instances/%s/tags?short&whole' % u)
+            self.assertTrue('8e05,1000' in a)
+            self.assertEqual('XEOS_Attributes', a['8e05,0010'])
+            self.assertEqual('acquisition', a['8e05,1000'])
+            self.assertEqual('specimen', a['8e05,1001'])
+
+            a = DoGet(_REMOTE, '/instances/%s/tags?simplify' % u)
+            self.assertFalse('Unknown Tag & Data' in a)
+
+            a = DoGet(_REMOTE, '/instances/%s/tags?simplify&whole' % u)
+            self.assertTrue('Unknown Tag & Data' in a)
+
+            a = DoGet(_REMOTE, '/instances/%s/simplified-tags' % u)
+            self.assertFalse('Unknown Tag & Data' in a)
+
+            a = DoGet(_REMOTE, '/instances/%s/simplified-tags?whole' % u)
+            self.assertTrue('Unknown Tag & Data' in a)
+
+
+    def test_requested_tags(self):
+        u = UploadInstance(_REMOTE, 'DummyCT.dcm')
+
+        def CheckPatientContent(patient):
+            self.assertEqual(u['ParentPatient'], patient['ID'])
+            self.assertEqual('Patient', patient['Type'])
+            self.assertFalse(patient['IsStable'])
+            self.assertEqual(0, len(patient['Labels']))
+            self.assertTrue('LastUpdate' in patient)
+            self.assertEqual(2, len(patient['MainDicomTags']))
+            self.assertEqual('ozp00SjY2xG', patient['MainDicomTags']['PatientID'])
+            self.assertEqual('KNIX', patient['MainDicomTags']['PatientName'])
+            self.assertEqual(1, len(patient['Studies']))
+            self.assertEqual(u['ParentStudy'], patient['Studies'][0])
+
+        def CheckStudyContent(study):
+            self.assertEqual(u['ParentStudy'], study['ID'])
+            self.assertEqual(u['ParentPatient'], study['ParentPatient'])
+            self.assertEqual('Study', study['Type'])
+            self.assertFalse(study['IsStable'])
+            self.assertEqual(0, len(study['Labels']))
+            self.assertTrue('LastUpdate' in study)
+            self.assertEqual(7, len(study['MainDicomTags']))
+            self.assertEqual('0ECJ52puWpVIjTuhnBA0um', study['MainDicomTags']['InstitutionName'])
+            self.assertEqual('1', study['MainDicomTags']['ReferringPhysicianName'])
+            self.assertEqual('20070101', study['MainDicomTags']['StudyDate'])
+            self.assertEqual('Knee (R)', study['MainDicomTags']['StudyDescription'])
+            self.assertEqual('1', study['MainDicomTags']['StudyID'])
+            self.assertEqual('1.2.840.113619.2.176.2025.1499492.7391.1171285944.390', study['MainDicomTags']['StudyInstanceUID'])
+            self.assertEqual('120000.000000', study['MainDicomTags']['StudyTime'])
+            self.assertEqual(2, len(study['PatientMainDicomTags']))
+            self.assertEqual('ozp00SjY2xG', study['PatientMainDicomTags']['PatientID'])
+            self.assertEqual('KNIX', study['PatientMainDicomTags']['PatientName'])
+            self.assertEqual(1, len(study['Series']))
+            self.assertEqual(u['ParentSeries'], study['Series'][0])
+
+        def CheckSeriesContent(series):
+            self.assertEqual(None, series['ExpectedNumberOfInstances'])
+            self.assertEqual('Unknown', series['Status'])
+            self.assertEqual(u['ParentSeries'], series['ID'])
+            self.assertEqual(u['ParentStudy'], series['ParentStudy'])
+            self.assertEqual('Series', series['Type'])
+            self.assertFalse(series['IsStable'])
+            self.assertEqual(0, len(series['Labels']))
+            self.assertTrue('LastUpdate' in series)
+            self.assertEqual(13, len(series['MainDicomTags']))
+            self.assertEqual('0', series['MainDicomTags']['CardiacNumberOfImages'])
+            self.assertEqual('0.999841\\0.000366209\\0.0178227\\-0.000427244\\0.999995\\0.00326545', series['MainDicomTags']['ImageOrientationPatient'])
+            self.assertEqual('24', series['MainDicomTags']['ImagesInAcquisition'])
+            self.assertEqual('GE MEDICAL SYSTEMS', series['MainDicomTags']['Manufacturer'])
+            self.assertEqual('MR', series['MainDicomTags']['Modality'])
+            self.assertEqual('ca', series['MainDicomTags']['OperatorsName'])
+            self.assertEqual('324-58-2995/6', series['MainDicomTags']['ProtocolName'])
+            self.assertEqual('20070101', series['MainDicomTags']['SeriesDate'])
+            self.assertEqual('AX.  FSE PD', series['MainDicomTags']['SeriesDescription'])
+            self.assertEqual('1.2.840.113619.2.176.2025.1499492.7391.1171285944.394', series['MainDicomTags']['SeriesInstanceUID'])
+            self.assertEqual('5', series['MainDicomTags']['SeriesNumber'])
+            self.assertEqual('120000.000000', series['MainDicomTags']['SeriesTime'])
+            self.assertEqual('TWINOW', series['MainDicomTags']['StationName'])
+            self.assertEqual(1, len(series['Instances']))
+            self.assertEqual(u['ID'], series['Instances'][0])
+
+        def CheckInstanceContent(instance):
+            self.assertEqual(2472, instance['FileSize'])
+            self.assertTrue('FileUuid' in instance)
+            self.assertEqual(u['ID'], instance['ID'])
+            self.assertEqual(u['ParentSeries'], instance['ParentSeries'])
+            self.assertEqual('Instance', instance['Type'])
+            self.assertEqual(1, instance['IndexInSeries'])
+            self.assertEqual(0, len(instance['Labels']))
+            self.assertEqual(7, len(instance['MainDicomTags']))
+            self.assertEqual('1', instance['MainDicomTags']['AcquisitionNumber'])
+            self.assertEqual('0.999841\\0.000366209\\0.0178227\\-0.000427244\\0.999995\\0.00326545', instance['MainDicomTags']['ImageOrientationPatient'])
+            self.assertEqual('-149.033\\-118.499\\-61.0464', instance['MainDicomTags']['ImagePositionPatient'])
+            self.assertEqual('20070101', instance['MainDicomTags']['InstanceCreationDate'])
+            self.assertEqual('120000.000000', instance['MainDicomTags']['InstanceCreationTime'])
+            self.assertEqual('1', instance['MainDicomTags']['InstanceNumber'])
+            self.assertEqual('1.2.840.113619.2.176.2025.1499492.7040.1171286242.109', instance['MainDicomTags']['SOPInstanceUID'])
+
+        def CheckRequestedTags(resource):
+            self.assertEqual(6, len(resource['RequestedTags']))
+            self.assertEqual('ozp00SjY2xG', resource['RequestedTags']['PatientID'])
+            self.assertEqual('Knee (R)', resource['RequestedTags']['StudyDescription'])
+            self.assertEqual('AX.  FSE PD', resource['RequestedTags']['SeriesDescription'])
+            self.assertEqual('1.2.840.10008.5.1.4.1.1.4', resource['RequestedTags']['SOPClassUID'])
+            self.assertEqual('2800', resource['RequestedTags']['RepetitionTime'])
+            self.assertEqual(3, len(resource['RequestedTags']['DerivationCodeSequence'][0]))
+            self.assertEqual('121327', resource['RequestedTags']['DerivationCodeSequence'][0]['CodeValue'])
+
+        requestedTags = 'PatientID;StudyDescription;SeriesDescription;SOPClassUID;RepetitionTime;DerivationCodeSequence'
+
+        a = DoGet(_REMOTE, '/patients?expand')
+        self.assertEqual(1, len(a))
+        self.assertEqual(7, len(a[0]))
+        CheckPatientContent(a[0])
+        self.assertFalse('RequestedTags' in a[0])
+
+        a = DoGet(_REMOTE, '/patients?expand&requestedTags=%s' % requestedTags)
+        self.assertEqual(1, len(a))
+        self.assertEqual(8, len(a[0]))
+        CheckPatientContent(a[0])
+        CheckRequestedTags(a[0])
+
+        a = DoGet(_REMOTE, '/studies?expand')
+        self.assertEqual(1, len(a))
+        self.assertEqual(9, len(a[0]))
+        CheckStudyContent(a[0])
+        self.assertFalse('RequestedTags' in a[0])
+
+        a = DoGet(_REMOTE, '/studies?expand&requestedTags=%s' % requestedTags)
+        self.assertEqual(1, len(a))
+        self.assertEqual(10, len(a[0]))
+        CheckStudyContent(a[0])
+        CheckRequestedTags(a[0])
+
+        a = DoGet(_REMOTE, '/series?expand')
+        self.assertEqual(1, len(a))
+        self.assertEqual(10, len(a[0]))
+        CheckSeriesContent(a[0])
+        self.assertFalse('RequestedTags' in a[0])
+
+        a = DoGet(_REMOTE, '/series?expand&requestedTags=%s' % requestedTags)
+        self.assertEqual(1, len(a))
+        self.assertEqual(11, len(a[0]))
+        CheckSeriesContent(a[0])
+        CheckRequestedTags(a[0])
+
+        a = DoGet(_REMOTE, '/instances?expand')
+        self.assertEqual(1, len(a))
+        self.assertEqual(8, len(a[0]))
+        CheckInstanceContent(a[0])
+        self.assertFalse('RequestedTags' in a[0])
+
+        a = DoGet(_REMOTE, '/instances?expand&requestedTags=%s' % requestedTags)
+        self.assertEqual(1, len(a))
+        self.assertEqual(9, len(a[0]))
+        CheckInstanceContent(a[0])
+        CheckRequestedTags(a[0])
+
+        a = DoGet(_REMOTE, '/patients/%s' % u['ParentPatient'])
+        self.assertEqual(7, len(a))
+        CheckPatientContent(a)
+        self.assertFalse('RequestedTags' in a)
+
+        a = DoGet(_REMOTE, '/patients/%s?requestedTags=%s' % (u['ParentPatient'], requestedTags))
+        self.assertEqual(8, len(a))
+        CheckPatientContent(a)
+        CheckRequestedTags(a)
+
+        a = DoGet(_REMOTE, '/studies/%s' % u['ParentStudy'])
+        self.assertEqual(9, len(a))
+        CheckStudyContent(a)
+        self.assertFalse('RequestedTags' in a)
+
+        a = DoGet(_REMOTE, '/studies/%s?requestedTags=%s' % (u['ParentStudy'], requestedTags))
+        self.assertEqual(10, len(a))
+        CheckStudyContent(a)
+        CheckRequestedTags(a)
+
+        a = DoGet(_REMOTE, '/series/%s' % u['ParentSeries'])
+        self.assertEqual(10, len(a))
+        CheckSeriesContent(a)
+        self.assertFalse('RequestedTags' in a)
+
+        a = DoGet(_REMOTE, '/series/%s?requestedTags=%s' % (u['ParentSeries'], requestedTags))
+        self.assertEqual(11, len(a))
+        CheckSeriesContent(a)
+        CheckRequestedTags(a)
+
+        a = DoGet(_REMOTE, '/instances/%s' % u['ID'])
+        self.assertEqual(8, len(a))
+        CheckInstanceContent(a)
+        self.assertFalse('RequestedTags' in a)
+
+        a = DoGet(_REMOTE, '/instances/%s?requestedTags=%s' % (u['ID'], requestedTags))
+        self.assertEqual(9, len(a))
+        CheckInstanceContent(a)
+        CheckRequestedTags(a)
+
+
+    def test_computed_tags(self):
+        # curl  'http://alice:orthanctest@localhost:8042/patients/0946fcb6-cf12ab43-bad958c1-bf057ad5-0fc6f54c?requested-tags=0020,1200;0020,1202;0020,1204'
+        # curl   'http://alice:orthanctest@localhost:8042/studies/6c65289b-db2fcb71-7eaf73f4-8e12470c-a4d6d7cf?requested-tags=0008,0061;0008,0062;0020,1206;0020,1208'
+        # curl    'http://alice:orthanctest@localhost:8042/series/318603c5-03e8cffc-a82b6ee1-3ccd3c1e-18d7e3bb?requested-tags=0020,1209'
+        # curl 'http://alice:orthanctest@localhost:8042/instances/ee693caa-9786a685-4f0f9fb0-4411cc8b-988f5574?requested-tags=0008,0056'
+
+        UploadInstance(_REMOTE, 'Comunix/Ct/IM-0001-0001.dcm')
+        UploadInstance(_REMOTE, 'Comunix/Ct/IM-0001-0002.dcm')
+        UploadInstance(_REMOTE, 'Comunix/Pet/IM-0001-0001.dcm')
+        UploadInstance(_REMOTE, 'Comunix/Pet/IM-0001-0002.dcm')
+
+        instance = 'ee693caa-9786a685-4f0f9fb0-4411cc8b-988f5574'
+        series = '318603c5-03e8cffc-a82b6ee1-3ccd3c1e-18d7e3bb'
+        study = '6c65289b-db2fcb71-7eaf73f4-8e12470c-a4d6d7cf'
+        patient = '0946fcb6-cf12ab43-bad958c1-bf057ad5-0fc6f54c'
+
+        a = DoGet(_REMOTE, '/instances/%s?requested-tags=0008,0056' % instance)
+        self.assertEqual(1, len(a['RequestedTags']))
+        self.assertEqual('ONLINE', a['RequestedTags']['InstanceAvailability'])
+
+        a = DoGet(_REMOTE, '/series/%s?requested-tags=0020,1209' % series)
+        self.assertEqual(1, len(a['RequestedTags']))
+        self.assertEqual(2, int(a['RequestedTags']['NumberOfSeriesRelatedInstances']))
+
+        a = DoGet(_REMOTE, '/studies/%s?requested-tags=0008,0061;0008,0062;0020,1206;0020,1208' % study)
+        self.assertEqual(4, len(a['RequestedTags']))
+        self.assertEqual('CT\\PT', a['RequestedTags']['ModalitiesInStudy'])
+        self.assertEqual('1.2.840.10008.5.1.4.1.1.128\\1.2.840.10008.5.1.4.1.1.2', a['RequestedTags']['SOPClassesInStudy'])
+        self.assertEqual(2, int(a['RequestedTags']['NumberOfStudyRelatedSeries']))
+        self.assertEqual(4, int(a['RequestedTags']['NumberOfStudyRelatedInstances']))
+
+        a = DoGet(_REMOTE, '/patients/%s?requested-tags=0020,1200;0020,1202;0020,1204' % patient)
+        self.assertEqual(3, len(a['RequestedTags']))
+        self.assertEqual(1, int(a['RequestedTags']['NumberOfPatientRelatedStudies']))
+        self.assertEqual(2, int(a['RequestedTags']['NumberOfPatientRelatedSeries']))
+        self.assertEqual(4, int(a['RequestedTags']['NumberOfPatientRelatedInstances']))
+
+    def test_computed_tags_and_patient_comments(self):
+        UploadInstance(_REMOTE, 'WithEmptyPatientComments.dcm')
+
+        # without requesting PatientComments, we get the computed tags
+        i = CallFindScu([ '-k', 'PatientID=WITH_COMMENTS',  '-k', 'QueryRetrieveLevel=Study', '-k', 'ModalitiesInStudy', '-k', 'NumberOfStudyRelatedSeries', '-k', 'NumberOfStudyRelatedInstances' ])
+        modalitiesInStudy = re.findall('\(0008,0061\).*?\[(.*?)\]', i)
+        self.assertEqual(1, len(modalitiesInStudy))
+        self.assertEqual('CT', modalitiesInStudy[0])
+
+        if IsOrthancVersionAbove(_REMOTE, 1, 12, 5):
+            # when requesting PatientComments, with 1.12.4, we did not get the computed tags
+            i = CallFindScu([ '-k', 'PatientID=WITH_COMMENTS',  '-k', 'QueryRetrieveLevel=Study', '-k', 'ModalitiesInStudy', '-k', 'NumberOfStudyRelatedSeries', '-k', 'NumberOfStudyRelatedInstances', '-k', 'PatientComments' ])
+            modalitiesInStudy = re.findall('\(0008,0061\).*?\[(.*?)\]', i)
+            self.assertEqual(1, len(modalitiesInStudy))
+            self.assertEqual('CT', modalitiesInStudy[0])
+            numberOfStudyRelatedSeries = re.findall('\(0020,1206\).*?\[(.*?)\]', i)
+            self.assertEqual(1, len(numberOfStudyRelatedSeries))
+            self.assertEqual(1, int(numberOfStudyRelatedSeries[0]))
+            numberOfStudyRelatedInstances = re.findall('\(0020,1208\).*?\[(.*?)\]', i)
+            self.assertEqual(1, len(numberOfStudyRelatedInstances))
+            self.assertEqual(1, int(numberOfStudyRelatedInstances[0]))
+
+        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Study',
+                                             'Expand': True,
+                                             'Query' : { 'PatientID' : 'WITH_COMMENTS'},
+                                             'RequestedTags': ['ModalitiesInStudy', 'NumberOfStudyRelatedSeries', 'NumberOfStudyRelatedInstances', 'PatientComments']})
+
+        self.assertEqual(4, len(a[0]['RequestedTags'].keys()))
+        self.assertEqual(1, int(a[0]['RequestedTags']['NumberOfStudyRelatedSeries']))
+        self.assertEqual(1, int(a[0]['RequestedTags']['NumberOfStudyRelatedInstances']))
+        self.assertEqual('CT', a[0]['RequestedTags']['ModalitiesInStudy'])
+        self.assertEqual('', a[0]['RequestedTags']['PatientComments'])
--- a/Tests/Toolbox.py	Thu Sep 05 18:46:25 2024 +0200
+++ b/Tests/Toolbox.py	Thu Sep 05 18:49:09 2024 +0200
@@ -3,7 +3,8 @@
 # Orthanc - A Lightweight, RESTful DICOM Store
 # Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
 # Department, University Hospital of Liege, Belgium
-# Copyright (C) 2017-2024 Osimis S.A., Belgium
+# Copyright (C) 2017-2023 Osimis S.A., Belgium
+# Copyright (C) 2024-2024 Orthanc Team SRL, Belgium
 # Copyright (C) 2021-2024 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
 #
 # This program is free software: you can redistribute it and/or