view Tests/Tests.py @ 763:374dbe6651d4

"CaseSensitive" is not available in "/tools/count-resources
author Sebastien Jodogne <s.jodogne@gmail.com>
date Tue, 17 Dec 2024 15:37:07 +0100
parents 3eef1a13ee9f
children f3376bc9f514
line wrap: on
line source

#!/usr/bin/env python
# -*- 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-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
# modify it under the terms of the GNU General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
# 
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.


import base64
import bz2
import copy
import io
import numpy
import pprint
import shutil
import tempfile
import unittest
import time
import os

from PIL import ImageChops
from Toolbox import *
from xml.dom import minidom
from datetime import datetime

_LOCAL = None
_REMOTE = None
_DOCKER = False


def SetOrthancParameters(local, remote, withinDocker):
    global _LOCAL, _REMOTE, _DOCKER
    _LOCAL = local
    _REMOTE = remote
    _DOCKER = withinDocker

    
def ExtractDicomTags(rawDicom, tags):
    with tempfile.NamedTemporaryFile(delete = True) as f:
        f.write(rawDicom)
        f.flush()
        data = subprocess.check_output([ FindExecutable('dcm2xml'), f.name ])

    result = []
    for tag in tags:
        match = re.search('<element[^>]+name="%s">([^>]*)</element>' % tag, data)
        if match == None:
            result.append('')
        else:
            result.append(match.group(1))

    return result


def CompareLists(a, b):
    if len(a) != len(b):
        return False

    for i in range(len(a)):
        d = a[i] - b[i]
        if abs(d) >= 0.51:  # Add some tolerance for rounding errors
            return False

    return True


def CompareTags(a, b, tagsToIgnore):
    for i in tagsToIgnore:
        if i in a:
            del a[i]
        if i in b:
            del b[i]

    if a.keys() == b.keys():
        return True
    else:
        print('Mismatch in tags: %s' % str(set(a.keys()) ^ set(b.keys())))
        return False


def CallFindScu(args):
    p = subprocess.Popen([ FindExecutable('findscu'), 
                           '-P', '-aec', _REMOTE['DicomAet'], '-aet', _LOCAL['DicomAet'],
                           _REMOTE['Server'], str(_REMOTE['DicomPort']) ] + args,
                         stderr=subprocess.PIPE)
    return p.communicate()[1]


def GetMoveScuCommand():
    return [ 
        FindExecutable('movescu'), 
        '--move', _LOCAL['DicomAet'],      # Target AET (i.e. storescp)
        '--call', _REMOTE['DicomAet'],     # Called AET (i.e. Orthanc)
        '--aetitle', _LOCAL['DicomAet'],   # Calling AET (i.e. storescp)
        _REMOTE['Server'], 
        str(_REMOTE['DicomPort'])  
        ]


def IsDicomUntilPixelDataStored(orthanc):
    # This function detects whether the "StorageCompression" option is
    # "true", OR the storage area does not support read-range

    if IsOrthancVersionAbove(orthanc, 1, 9, 1):
        i = UploadInstance(orthanc, 'ColorTestMalaterre.dcm') ['ID']
        a = DoGet(orthanc, '/instances/%s/metadata/PixelDataOffset' % i)
        if a != 0x03a0:
            raise Exception('Internal error')

        a = DoGet(orthanc, '/instances/%s/attachments' % i)
        if len(a) != 1 and len(a) != 2 or not 'dicom' in a:
            raise Exception('Internal error')

        DoDelete(orthanc, '/instances/%s' % i)

        if len(a) == 1:
            return False
        elif 'dicom-until-pixel-data' in a:
            return True
        else:
            raise Exception('Internal error')
        
    else:
        return False


def CallMoveScu(args):
    try:
        subprocess.check_call(GetMoveScuCommand() + args,
                              stderr = subprocess.PIPE)
    except subprocess.CalledProcessError as e:
        # The error code "69" corresponds to "EXITCODE_CMOVE_ERROR",
        # that has been introduced in DCMTK 3.6.2. This error code is
        # expected by some tests that try and C-MOVE non-existing
        # DICOM instances.
        # https://groups.google.com/d/msg/orthanc-users/DCRc5NeSCbM/DG-pSWj-BwAJ
        if e.returncode != 69:
            raise e


def GenerateTestSequence():
    return [
        {
            'StudyDescription': 'Hello^',
            'ReferencedStudySequence' : [
                {
                    'StudyDescription': 'Toto',
                    },
                {
                    'StudyDescription': 'Tata',
                    },
                ]
            },
        {
            'StudyDescription': 'Sébastien^',
            'StudyDate' : '19700202',
            }
        ]


class Orthanc(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)

        print('running : %s' % self.id())

        DropOrthanc(_LOCAL)
        DropOrthanc(_REMOTE)
        UninstallLuaCallbacks(_REMOTE)

        # Reset stuff possibly set by some integration tests
        DoPut(_REMOTE, '/tools/default-encoding', 'Latin1')
        if IsOrthancVersionAbove(_REMOTE, 1, 9, 0):
            DoPut(_REMOTE, '/tools/accepted-transfer-syntaxes', [ '1.2.840.10008.1.*' ])
            DoPut(_REMOTE, '/tools/unknown-sop-class-accepted', '0')

        for i in [ 'toto', 'tata' ]:
            if i in DoGet(_REMOTE, '/modalities'):
                DoDelete(_REMOTE, '/modalities/%s' % i)
            if i in DoGet(_REMOTE, '/peers'):
                DoDelete(_REMOTE, '/peers/%s' % i)
        
        #print("%s: In test %s" % (datetime.now(), self._testMethodName))
        
    def AssertSameImages(self, truth, url):
        im = GetImage(_REMOTE, url)
        self.assertTrue(CompareLists(truth, im.getdata()))


    def test_system(self):
        self.assertTrue('Version' in DoGet(_REMOTE, '/system'))
        self.assertEqual('0', DoGet(_REMOTE, '/statistics')['TotalDiskSize'])
        self.assertEqual('0', DoGet(_REMOTE, '/statistics')['TotalUncompressedSize'])

        systemInfo = DoGet(_REMOTE, '/system')

        if IsOrthancVersionAbove(_REMOTE, 1, 11, 0):
            self.assertIn("MainDicomTags", systemInfo)
            self.assertIn("Patient", systemInfo["MainDicomTags"])
            self.assertIn("Study", systemInfo["MainDicomTags"])
            self.assertIn("Series", systemInfo["MainDicomTags"])
            self.assertIn("Instance", systemInfo["MainDicomTags"])

        if IsOrthancVersionAbove(_REMOTE, 1, 12, 0):
            self.assertIn("UserMetadata", systemInfo)
            self.assertEqual(1098, systemInfo['UserMetadata']['my-metadata'] )

        if systemInfo["Version"] == "mainline":
            print("Skipping version checks since you're currently in mainline")
            return

        if not IsOrthancVersionAbove(_LOCAL, 0, 8, 7):
            self.assertTrue(IsOrthancVersionAbove(_LOCAL, 0, 8, 6))
            self.assertFalse(IsOrthancVersionAbove(_LOCAL, 0, 8, 7))
            self.assertTrue(IsOrthancVersionAbove(_LOCAL, 0, 7, 6))
            self.assertFalse(IsOrthancVersionAbove(_LOCAL, 0, 9, 6))
            self.assertFalse(IsOrthancVersionAbove(_LOCAL, 1, 8, 6))



    def test_upload(self):
        self.assertEqual('0', DoGet(_REMOTE, '/statistics')['TotalDiskSize'])
        self.assertEqual('0', DoGet(_REMOTE, '/statistics')['TotalUncompressedSize'])

        sizeDummyCT = 2472
        sizeOverwrite = 2476
        instance = '66a662ce-7430e543-bad44d47-0dc5a943-ec7a538d'

        # This file has *no* pixel data => "dicom-until-pixel-data" is not created
        u = UploadInstance(_REMOTE, 'DummyCT.dcm')
        isCompressed = (DoGet(_REMOTE, '/instances/%s/attachments/dicom/is-compressed' % u['ID']) != 0)
        self.assertEqual(instance, u['ID'])
        self.assertEqual('Success', u['Status'])

        if True:
            # New test for Orthanc 1.4.3
            self.assertEqual('f2635388-f01d497a-15f7c06b-ad7dba06-c4c599fe', u['ParentSeries'])
            self.assertEqual('b9c08539-26f93bde-c81ab0d7-bffaf2cb-a4d0bdd0', u['ParentStudy'])
            self.assertEqual('6816cb19-844d5aee-85245eba-28e841e6-2414fae2', u['ParentPatient'])

        if IsOrthancVersionAbove(_REMOTE, 1, 9, 1):
            self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/attachments/dicom-as-json' % instance))
            self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/attachments/dicom-until-pixel-data' % instance))
            j = 0
        else:
            self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/attachments/dicom-until-pixel-data' % instance))
            j = int(DoGet(_REMOTE, '/instances/%s/attachments/dicom-as-json/size' % instance))

        if IsOrthancVersionAbove(_REMOTE, 1, 10, 0):
            attachmentInfo = DoGet(_REMOTE, '/instances/%s/attachments/dicom/info' % instance)

            if isCompressed:
                self.assertGreater(sizeDummyCT, attachmentInfo['CompressedSize'])
            else:
                self.assertEqual(sizeDummyCT, attachmentInfo['CompressedSize'])

            self.assertEqual(sizeDummyCT, attachmentInfo['UncompressedSize'])
            self.assertIn('Uuid', attachmentInfo)
            self.assertEqual(1, attachmentInfo['ContentType'])

        s = sizeDummyCT + j

        if isCompressed:
            self.assertGreater(s, int(DoGet(_REMOTE, '/statistics')['TotalDiskSize']))
        else:
            self.assertEqual(s, int(DoGet(_REMOTE, '/statistics')['TotalDiskSize']))
            
        self.assertEqual(s, int(DoGet(_REMOTE, '/statistics')['TotalUncompressedSize']))

        u = UploadInstance(_REMOTE, 'DummyCT.dcm')
        self.assertEqual(1, len(DoGet(_REMOTE, '/patients')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/studies')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/series')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))

        if isCompressed:
            self.assertGreater(s, int(DoGet(_REMOTE, '/statistics')['TotalDiskSize']))
        else:
            self.assertEqual(s, int(DoGet(_REMOTE, '/statistics')['TotalDiskSize']))
            
        self.assertEqual(s, int(DoGet(_REMOTE, '/statistics')['TotalUncompressedSize']))

        i = DoGet(_REMOTE, '/instances/%s/simplified-tags' % instance)
        self.assertEqual('20070101', i['StudyDate'])
        self.assertEqual('KNIX', i['PatientName'])

        if IsOrthancVersionAbove(_REMOTE, 1, 4, 2):
            # Overwriting
            self.assertEqual('Success', u['Status'])
        else:
            self.assertEqual('AlreadyStored', u['Status'])

        u = UploadInstance(_REMOTE, 'DummyCT-overwrite.dcm')
        self.assertEqual(1, len(DoGet(_REMOTE, '/patients')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/studies')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/series')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))

        if IsOrthancVersionAbove(_REMOTE, 1, 4, 2):
            # Overwriting
            self.assertEqual('Success', u['Status'])
            if IsOrthancVersionAbove(_REMOTE, 1, 9, 1):
                j2 = 0
            else:
                j2 = int(DoGet(_REMOTE, '/instances/%s/attachments/dicom-as-json/size' % instance))
                self.assertNotEqual(j, j2)
            s2 = sizeOverwrite + j2
            self.assertNotEqual(s, s2)
            if isCompressed:
                self.assertGreater(s2, int(DoGet(_REMOTE, '/statistics')['TotalDiskSize']))
            else:
                self.assertEqual(s2, int(DoGet(_REMOTE, '/statistics')['TotalDiskSize']))
                
            self.assertEqual(s2, int(DoGet(_REMOTE, '/statistics')['TotalUncompressedSize']))
            i = DoGet(_REMOTE, '/instances/%s/simplified-tags' % instance)
            self.assertEqual('ANOTHER', i['PatientName'])
        else:
            self.assertEqual('AlreadyStored', u['Status'])
            self.assertEqual(s, int(DoGet(_REMOTE, '/statistics')['TotalDiskSize']))
            self.assertEqual(s, int(DoGet(_REMOTE, '/statistics')['TotalUncompressedSize']))

    def test_upload_2(self):
        i = UploadInstance(_REMOTE, 'DummyCT.dcm')['ID']
        instance = DoGet(_REMOTE, '/instances/%s' % i)
        self.assertEqual(i, instance['ID'])
        self.assertEqual('1.2.840.113619.2.176.2025.1499492.7040.1171286242.109',
                         instance['MainDicomTags']['SOPInstanceUID'])

        series = DoGet(_REMOTE, '/series/%s' % instance['ParentSeries'])
        self.assertEqual('1.2.840.113619.2.176.2025.1499492.7391.1171285944.394', 
                         series['MainDicomTags']['SeriesInstanceUID'])

        study = DoGet(_REMOTE, '/studies/%s' % series['ParentStudy'])
        self.assertEqual('1.2.840.113619.2.176.2025.1499492.7391.1171285944.390',
                         study['MainDicomTags']['StudyInstanceUID'])

        patient = DoGet(_REMOTE, '/patients/%s' % study['ParentPatient'])
        self.assertEqual('ozp00SjY2xG',
                         patient['MainDicomTags']['PatientID'])

        dicom = DoGet(_REMOTE, '/instances/%s/file' % instance['ID'])
        self.assertEqual(2472, len(dicom))
        self.assertEqual('3e29b869978b6db4886355a2b1132124', ComputeMD5(dicom))
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances/%s/frames' % i)))
        self.assertEqual('TWINOW', DoGet(_REMOTE, '/instances/%s/simplified-tags' % i)['StationName'])
        self.assertEqual('TWINOW', DoGet(_REMOTE, '/instances/%s/tags' % i)['0008,1010']['Value'])
        self.assertEqual('TWINOW', DoGet(_REMOTE, '/instances/%s/tags?simplify' % i)['StationName'])
        self.assertEqual('TWINOW', DoGet(_REMOTE, '/instances/%s/tags?short' % i)['0008,1010'])


    def test_images(self):
        i = UploadInstance(_REMOTE, 'Phenix/IM-0001-0001.dcm')['ID']
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances/%s/frames' % i)))

        im = GetImage(_REMOTE, '/instances/%s/preview' % i)
        self.assertEqual("L", im.mode)
        self.assertEqual(512, im.size[0])
        self.assertEqual(358, im.size[1])

        im = GetImage(_REMOTE, '/instances/%s/image-uint8' % i)
        self.assertEqual("L", im.mode)
        self.assertEqual(512, im.size[0])
        self.assertEqual(358, im.size[1])

        im = GetImage(_REMOTE, '/instances/%s/image-uint16' % i)
        self.assertEqual(512, im.size[0])
        self.assertEqual(358, im.size[1])

        im = GetImage(_REMOTE, '/instances/%s/frames/0/preview' % i)
        self.assertEqual("L", im.mode)
        self.assertEqual(512, im.size[0])
        self.assertEqual(358, im.size[1])

        im = GetImage(_REMOTE, '/instances/%s/frames/0/image-uint8' % i)
        self.assertEqual("L", im.mode)
        self.assertEqual(512, im.size[0])
        self.assertEqual(358, im.size[1])

        im = GetImage(_REMOTE, '/instances/%s/frames/0/image-uint16' % i)
        self.assertEqual(512, im.size[0])
        self.assertEqual(358, im.size[1])

        # This is Little Endian Explicit
        self.assertEqual('1.2.840.10008.1.2.1', DoGet(_REMOTE, '/instances/%s/header?simplify' % i)['TransferSyntaxUID'])

    def test_images_implicit_vr(self):
        if IsOrthancVersionAbove(_REMOTE, 1, 10, 2):
            i = UploadInstance(_REMOTE, 'Implicit-vr-us-palette.dcm')['ID']

            im = GetImage(_REMOTE, '/instances/%s/preview' % i)
            self.assertEqual("RGB", im.mode)
            self.assertEqual(800, im.size[0])
            self.assertEqual(600, im.size[1])

        
    def test_hierarchy(self):
        UploadFolder(_REMOTE, 'Brainix/Epi')
        UploadFolder(_REMOTE, 'Brainix/Flair')
        UploadFolder(_REMOTE, 'Knee/T1')
        UploadFolder(_REMOTE, 'Knee/T2')

        p = DoGet(_REMOTE, '/patients')
        s = DoGet(_REMOTE, '/studies')
        t = DoGet(_REMOTE, '/series')
        self.assertEqual(2, len(p))
        self.assertEqual(2, len(s))
        self.assertEqual(4, len(t))
        self.assertEqual(94, len(DoGet(_REMOTE, '/instances')))

        brainixPatient = '16738bc3-e47ed42a-43ce044c-a3414a45-cb069bd0'
        brainixStudy = '27f7126f-4f66fb14-03f4081b-f9341db2-53925988'
        brainixEpi = '2ac1316d-3e432022-62eabff2-c59f5475-9b1ac3f8'
        brainixFlair = '1e2c125c-411b8e86-3f4fe68e-a7584dd3-c6da78f0'

        kneePatient = 'ca29faea-b6a0e17f-067743a1-8b778011-a48b2a17'
        kneeStudy = '0a9b3153-2512774b-2d9580de-1fc3dcf6-3bd83918'
        kneeT1 = '6de73705-c4e65c1b-9d9ea1b5-cabcd8e7-f15e4285'
        kneeT2 = 'bbf7a453-0d34251a-03663b55-46bb31b9-ffd74c59'

        self.assertTrue(brainixPatient in p)
        self.assertTrue(kneePatient in p)
        self.assertTrue(brainixStudy in s)
        self.assertTrue(kneeStudy in s)
        self.assertTrue(brainixEpi in t)
        self.assertTrue(brainixFlair in t)
        self.assertTrue(kneeT1 in t)
        self.assertTrue(kneeT2 in t)

        self.assertEqual(44, len(DoGet(_REMOTE, '/patients/%s/instances' % brainixPatient)))
        self.assertEqual(2, len(DoGet(_REMOTE, '/patients/%s/series' % brainixPatient)))
        self.assertEqual(1, len(DoGet(_REMOTE, '/patients/%s/studies' % brainixPatient)))

        self.assertEqual(50, len(DoGet(_REMOTE, '/patients/%s/instances' % kneePatient)))
        self.assertEqual(2, len(DoGet(_REMOTE, '/patients/%s/series' % kneePatient)))
        self.assertEqual(1, len(DoGet(_REMOTE, '/patients/%s/studies' % kneePatient)))

        self.assertEqual(2, len(DoGet(_REMOTE, '/studies/%s/series' % brainixStudy)))
        self.assertEqual(44, len(DoGet(_REMOTE, '/studies/%s/instances' % brainixStudy)))

        self.assertEqual(2, len(DoGet(_REMOTE, '/studies/%s/series' % kneeStudy)))
        self.assertEqual(50, len(DoGet(_REMOTE, '/studies/%s/instances' % kneeStudy)))

        self.assertEqual(22, len(DoGet(_REMOTE, '/series/%s/instances' % brainixEpi)))
        self.assertEqual(22, len(DoGet(_REMOTE, '/series/%s/instances' % brainixFlair)))
        self.assertEqual(24, len(DoGet(_REMOTE, '/series/%s/instances' % kneeT1)))
        self.assertEqual(26, len(DoGet(_REMOTE, '/series/%s/instances' % kneeT2)))

        for patient in p:
            for study in DoGet(_REMOTE, '/patients/%s/studies' % patient):
                self.assertEqual(patient, study['ParentPatient'])
                for series in DoGet(_REMOTE, '/studies/%s/series' % study['ID']):
                    self.assertEqual(study['ID'], series['ParentStudy'])
                    self.assertEqual('Unknown', series['Status'])
                    for instance in DoGet(_REMOTE, '/series/%s/instances' % series['ID']):
                        self.assertEqual(series['ID'], instance['ParentSeries'])

                        if not IsOrthancVersionAbove(_REMOTE, 1, 9, 1):
                            # The "dicom-as-json" attachment was removed in Orthanc 1.9.1
                            self.assertEqual(json.dumps(DoGet(_REMOTE, '/instances/%s/attachments/dicom-as-json/data' % instance['ID'])),
                                             json.dumps(DoGet(_REMOTE, '/instances/%s/tags' % instance['ID'])))


        r = DoDelete(_REMOTE, "/studies/%s" % brainixStudy)['RemainingAncestor']
        self.assertEqual(1, len(DoGet(_REMOTE, '/patients')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/studies')))
        self.assertEqual(2, len(DoGet(_REMOTE, '/series')))
        self.assertEqual(50, len(DoGet(_REMOTE, '/instances')))
        self.assertEqual(None, r)

        r = DoDelete(_REMOTE, "/series/%s" % kneeT2)['RemainingAncestor']
        self.assertEqual(1, len(DoGet(_REMOTE, '/patients')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/studies')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/series')))
        self.assertEqual(24, len(DoGet(_REMOTE, '/instances')))
        self.assertEqual('Study', r['Type'])
        self.assertEqual(kneeStudy, r['ID'])

        r = DoDelete(_REMOTE, "/instances/%s" % DoGet(_REMOTE, '/instances')[0])['RemainingAncestor']
        self.assertEqual(1, len(DoGet(_REMOTE, '/patients')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/studies')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/series')))
        self.assertEqual(23, len(DoGet(_REMOTE, '/instances')))
        self.assertEqual('Series', r['Type'])
        self.assertEqual(kneeT1, r['ID'])

        r = DoDelete(_REMOTE, "/patients/%s" % kneePatient)['RemainingAncestor']
        self.assertEqual(0, len(DoGet(_REMOTE, '/patients')))
        self.assertEqual(0, len(DoGet(_REMOTE, '/studies')))
        self.assertEqual(0, len(DoGet(_REMOTE, '/series')))
        self.assertEqual(0, len(DoGet(_REMOTE, '/instances')))
        self.assertEqual(None, r)

        DropOrthanc(_REMOTE)
        self.assertEqual('0', DoGet(_REMOTE, '/statistics')['TotalDiskSize'])
        self.assertEqual('0', DoGet(_REMOTE, '/statistics')['TotalUncompressedSize'])

    def test_delete_cascade(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, 'DummyCT.dcm')['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)        

        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)))

        im = GetImage(_REMOTE, '/instances/%s/frames/0/preview' % i)
        self.assertEqual("L", im.mode)
        self.assertEqual(512, im.size[0])
        self.assertEqual(512, im.size[1])
  
        DoGet(_REMOTE, '/instances/%s/frames/0/image-uint8' % i)
        DoGet(_REMOTE, '/instances/%s/frames/0/image-uint16' % i)
        DoGet(_REMOTE, '/instances/%s/frames/75/preview' % i)
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/frames/aaa/preview' % i))
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/frames/76/preview' % i))


    def test_changes(self):
        # Check emptiness
        c = DoGet(_REMOTE, '/changes')
        self.assertEqual(0, len(c['Changes']))
        #self.assertEqual(0, c['Last'])   # Not true anymore for Orthanc >= 1.5.2
        self.assertTrue(c['Done'])
        c = DoGet(_REMOTE, '/changes?last')
        self.assertEqual(0, len(c['Changes']))
        #self.assertEqual(0, c['Last'])   # Not true anymore for Orthanc >= 1.5.2
        self.assertTrue(c['Done'])

        # Add 1 instance
        i = UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm')['ID']
        c = DoGet(_REMOTE, '/changes')
        begin = c['Last']
        self.assertEqual(4, len(c['Changes']))
        self.assertTrue(c['Done'])
        self.assertEqual(c['Changes'][-1]['Seq'], c['Last'])

        # Check the order in which the creation events are reported
        self.assertEqual(c['Changes'][0]['ChangeType'], 'NewInstance')
        self.assertEqual(c['Changes'][1]['ChangeType'], 'NewSeries')
        self.assertEqual(c['Changes'][2]['ChangeType'], 'NewStudy')
        self.assertEqual(c['Changes'][3]['ChangeType'], 'NewPatient')

        c = DoGet(_REMOTE, '/changes?last')
        self.assertEqual(1, len(c['Changes']))
        self.assertEqual(begin, c['Last'])
        self.assertTrue(c['Done'])
        c = DoGet(_REMOTE, '/changes?limit=1&since=' + str(begin - 1))
        self.assertEqual(1, len(c['Changes']))
        self.assertEqual(begin, c['Last'])
        self.assertTrue(c['Done'])
        c = DoGet(_REMOTE, '/changes?limit=1&since=' + str(begin - 2))
        self.assertEqual(1, len(c['Changes']))
        self.assertEqual(begin - 1, c['Last'])
        self.assertFalse(c['Done'])
        c = DoGet(_REMOTE, '/changes?limit=1&since=' + str(begin - 3))
        self.assertEqual(1, len(c['Changes']))
        self.assertEqual(begin - 2, c['Last'])
        self.assertFalse(c['Done'])

        UploadFolder(_REMOTE, 'Knee/T1')
        UploadFolder(_REMOTE, 'Knee/T2')
        since = begin
        countPatients = 0
        countStudies = 0
        countSeries = 0
        countInstances = 0
        completed = 0
        while True:
            c = DoGet(_REMOTE, '/changes', { 'since' : since, 'limit' : 1000 })
            since = c['Last']
            for i in c['Changes']:
                # We have set StableAge to 1 -> we might have StabeStudy but this is not sure -> detect only the 'New' events

                if i['ResourceType'] == 'Instance' and i['ChangeType'] == 'NewInstance':
                    countInstances += 1
                if i['ResourceType'] == 'Patient' and i['ChangeType'] == 'NewPatient':
                    countPatients += 1
                if i['ResourceType'] == 'Study' and i['ChangeType'] == 'NewStudy':
                    countStudies += 1
                if i['ResourceType'] == 'Series' and i['ChangeType'] == 'NewSeries':
                    countSeries += 1
                if i['ChangeType'] == 'CompletedSeries':
                    completed += 1
                self.assertTrue('ID' in i)
                self.assertTrue('Path' in i)
                self.assertTrue('Seq' in i)
            if c['Done']:
                break
        # we count only the events since before the upload of 2 Knee series !
        self.assertEqual(50, countInstances)
        self.assertEqual(1, countPatients)
        self.assertEqual(1, countStudies)
        self.assertEqual(2, countSeries)
        self.assertEqual(0, completed)


    def test_changes_extended(self):
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 5) and HasExtendedChanges(_REMOTE):
            # Check emptiness
            c = DoGet(_REMOTE, '/changes')
            self.assertEqual(0, len(c['Changes']))
            #self.assertEqual(0, c['Last'])   # Not true anymore for Orthanc >= 1.5.2
            self.assertTrue(c['Done'])
            c = DoGet(_REMOTE, '/changes?last')
            self.assertEqual(0, len(c['Changes']))
            #self.assertEqual(0, c['Last'])   # Not true anymore for Orthanc >= 1.5.2
            self.assertTrue(c['Done'])

            # Add 1 instance
            i = UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm')['ID']
            c = DoGet(_REMOTE, '/changes')
            begin = c['Last']
            self.assertEqual(4, len(c['Changes']))
            self.assertTrue(c['Done'])
            self.assertEqual(c['Changes'][-1]['Seq'], c['Last'])

            # Check the order in which the creation events are reported
            self.assertEqual(c['Changes'][0]['ChangeType'], 'NewInstance')
            self.assertEqual(c['Changes'][1]['ChangeType'], 'NewSeries')
            self.assertEqual(c['Changes'][2]['ChangeType'], 'NewStudy')
            self.assertEqual(c['Changes'][3]['ChangeType'], 'NewPatient')

            c = DoGet(_REMOTE, '/changes?type=NewInstance')
            self.assertEqual(1, len(c['Changes']))
            self.assertEqual(begin-3, c['Last'])

            c = DoGet(_REMOTE, '/changes?type=NewPatient')
            self.assertEqual(1, len(c['Changes']))
            self.assertEqual(begin, c['Last'])

            c = DoGet(_REMOTE, '/changes?type=NewPatient;NewInstance')
            self.assertEqual(2, len(c['Changes']))
            self.assertEqual(begin, c['Last'])

            UploadFolder(_REMOTE, 'Knee/T1')
            UploadFolder(_REMOTE, 'Knee/T2')

            # Request the 1000 first NewInstance changes  -> all 50 shall be reported
            c = DoGet(_REMOTE, '/changes', { 'type': 'NewInstance', 'since' : begin, 'limit' : 1000 })
            self.assertEqual(50, len(c['Changes']))
            self.assertLess(begin, c['Changes'][0]['Seq'])
            self.assertTrue(c['Done'])   #w e have got them all so it's DONE
            lastFrom1000NewInstances = c['Last']
            firstFrom1000NewInstances = c['First']
            self.assertLess(firstFrom1000NewInstances, lastFrom1000NewInstances)

            # Only the 10 first NewInstance changes  -> only 10 shall be reported
            c = DoGet(_REMOTE, '/changes', { 'type': 'NewInstance', 'since' : begin, 'limit' : 10 })
            self.assertEqual(10, len(c['Changes']))
            self.assertFalse(c['Done'])
            lastFrom10firstNewInstances = c['Last']
            firstFrom10firstNewInstances = c['First']
            self.assertLess(firstFrom10firstNewInstances, lastFrom10firstNewInstances)
            self.assertLess(lastFrom10firstNewInstances, lastFrom1000NewInstances)
            self.assertEqual(firstFrom10firstNewInstances, firstFrom1000NewInstances)

            # between begin and begin+10 with a max of 10 and a filter -> less than 10 NewInstance since there are other changes in this range
            c = DoGet(_REMOTE, '/changes', { 'type': 'NewInstance', 'since' : begin, 'to': begin+10, 'limit' : 10 })
            self.assertLess(len(c['Changes']), 10)
            self.assertTrue(c['Done'])  # we have received ALL NewInstance that are between since and to so we consider it's done
            lastFrom10SubsetNewInstances = c['Last']
            firstFrom10SubsetNewInstances = c['First']
            self.assertLess(firstFrom10SubsetNewInstances, lastFrom10SubsetNewInstances)
            self.assertLess(lastFrom10SubsetNewInstances, lastFrom10firstNewInstances)
            self.assertEqual(firstFrom10SubsetNewInstances, firstFrom1000NewInstances)

            # test with only 'to' -> all 50 NewInstance shall be reported
            c = DoGet(_REMOTE, '/changes', { 'type': 'NewInstance', 'to': lastFrom1000NewInstances, 'limit' : 50 })
            self.assertEqual(lastFrom1000NewInstances, c['Changes'][-1]['Seq'])
            self.assertEqual(50, len(c['Changes']))
            self.assertFalse(c['Done'])  # Done can not be used when working in reverse direction
            lastFrom50Reverse = c['Last']
            firstFrom50Reverse = c['First']
            self.assertLess(firstFrom50Reverse, lastFrom50Reverse)
            self.assertEqual(lastFrom50Reverse, lastFrom1000NewInstances)
            self.assertEqual(firstFrom50Reverse, firstFrom1000NewInstances)

            # test with only 'to' and limit to 10 NewInstance changes
            c = DoGet(_REMOTE, '/changes', { 'type': 'NewInstance', 'to': lastFrom1000NewInstances, 'limit' : 10 })
            self.assertEqual(lastFrom1000NewInstances, c['Changes'][-1]['Seq'])
            self.assertEqual(10, len(c['Changes']))
            self.assertFalse(c['Done'])  # Done can not be used when working in reverse direction
            lastFrom10Reverse = c['Last']
            firstFrom10Reverse = c['First']
            self.assertLess(firstFrom10Reverse, lastFrom10Reverse)
            self.assertEqual(lastFrom10Reverse, lastFrom1000NewInstances)
            self.assertLessEqual(firstFrom50Reverse, firstFrom10Reverse)


    def test_archive(self):
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Knee/T2/IM-0001-0001.dcm')
        kneePatient = 'ca29faea-b6a0e17f-067743a1-8b778011-a48b2a17'
        kneeStudy = DoGet(_REMOTE, '/studies')[0]
        kneeSeries = DoGet(_REMOTE, '/series')[0]

        z = GetArchive(_REMOTE, '/patients/%s/archive' % kneePatient)
        self.assertEqual(2, len(z.namelist()))
        self.assertIn('887 KNEE/A10003245599 IRM DU GENOU/MR T1W_aTSE/MR000000.dcm', z.namelist())

        z = GetArchive(_REMOTE, '/studies/%s/archive' % kneeStudy)
        self.assertEqual(2, len(z.namelist()))
        self.assertIn('887 KNEE/A10003245599 IRM DU GENOU/MR T1W_aTSE/MR000000.dcm', z.namelist())

        z = GetArchive(_REMOTE, '/series/%s/archive' % kneeSeries)
        self.assertEqual(1, len(z.namelist()))
        self.assertIn('887 KNEE/A10003245599 IRM DU GENOU/MR T1W_aTSE/MR000000.dcm', z.namelist())

        UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm')
        brainixPatient = '16738bc3-e47ed42a-43ce044c-a3414a45-cb069bd0'
        brainixStudy = '27f7126f-4f66fb14-03f4081b-f9341db2-53925988'

        z = GetArchive(_REMOTE, '/patients/%s/archive' % kneePatient)
        self.assertEqual(2, len(z.namelist()))

        # archive with 2 patients
        z = PostArchive(_REMOTE, '/tools/create-archive', {
            'Resources' : [ brainixPatient, kneePatient ]
            })
        self.assertEqual(3, len(z.namelist()))
        self.assertIn('5Yp0E BRAINIX/0 IRM crbrale neurocrne/MR sT2WFLAIR/MR000000.dcm', z.namelist())
        self.assertIn('887 KNEE/A10003245599 IRM DU GENOU/MR T1W_aTSE/MR000000.dcm', z.namelist())

        z = PostArchive(_REMOTE, '/patients/%s/archive' % kneePatient, {
            'Synchronous' : True
            })
        self.assertEqual(2, len(z.namelist()))

        # archive with 2 studies
        z = PostArchive(_REMOTE, '/tools/create-archive', {
            'Resources' : [ brainixStudy, kneeStudy ]
            })
        self.assertEqual(3, len(z.namelist()))
        self.assertIn('5Yp0E BRAINIX/0 IRM crbrale neurocrne/MR sT2WFLAIR/MR000000.dcm', z.namelist())
        self.assertIn('887 KNEE/A10003245599 IRM DU GENOU/MR T1W_aTSE/MR000000.dcm', z.namelist())

        # archive with 1 patient & 1 study
        z = PostArchive(_REMOTE, '/tools/create-archive', {
            'Resources' : [ brainixPatient, kneeStudy ]
            })
        self.assertEqual(3, len(z.namelist()))
        self.assertIn('5Yp0E BRAINIX/0 IRM crbrale neurocrne/MR sT2WFLAIR/MR000000.dcm', z.namelist())
        self.assertIn('887 KNEE/A10003245599 IRM DU GENOU/MR T1W_aTSE/MR000000.dcm', z.namelist())


    def test_archive_with_patient_ids_collision(self):
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 2):
            # one PatientID: COMMON
            # 2 PatientName: HELLO & WORLD

            hello = UploadInstance(_REMOTE, 'PatientIdsCollision/Image1.dcm')
            world = UploadInstance(_REMOTE, 'PatientIdsCollision/Image2.dcm')
            helloStudy = hello['ParentStudy']
            worldStudy = world['ParentStudy']
            helloPatient = hello['ParentPatient']
            worldPatient = world['ParentPatient']

            self.assertEqual(helloPatient, worldPatient)

            # when downloading the Patient, we do not really know what PatientName we will get in the zip
            z = GetArchive(_REMOTE, '/patients/%s/archive' % helloPatient)
            self.assertEqual(2, len(z.namelist()))

            # when downloading studies individually, we want to have the PatientName that appears in the study
            z = GetArchive(_REMOTE, '/studies/%s/archive' % helloStudy)
            self.assertEqual(1, len(z.namelist()))
            self.assertIn('COMMON HELLO/HELLO SERIES/Unknown Series/00000000.dcm', z.namelist())

            z = GetArchive(_REMOTE, '/studies/%s/archive' % worldStudy)
            self.assertEqual(1, len(z.namelist()))
            self.assertIn('COMMON WORLD/WORLD SERIES/Unknown Series/00000000.dcm', z.namelist())



    def test_media_archive(self):
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Knee/T2/IM-0001-0001.dcm')

        z = GetArchive(_REMOTE, '/patients/%s/media' % DoGet(_REMOTE, '/patients')[0])
        self.assertEqual(3, len(z.namelist()))
        self.assertTrue('IMAGES/IM0' in z.namelist())
        self.assertTrue('IMAGES/IM1' in z.namelist())
        self.assertTrue('DICOMDIR' in z.namelist())

        try:
            os.remove('/tmp/DICOMDIR')
        except:
            # The file does not exist
            pass

        z.extract('DICOMDIR', '/tmp')
        a = subprocess.check_output([ FindExecutable('dciodvfy'), '/tmp/DICOMDIR' ],
                                    stderr = subprocess.STDOUT).split('\n')
        self.assertEqual(3, len(a))
        self.assertTrue(a[0].startswith('Warning'))
        self.assertEqual('BasicDirectory', a[1])
        self.assertEqual('', a[2])

        a = subprocess.check_output([ FindExecutable('dcentvfy'), '/tmp/DICOMDIR' ],
                                    stderr = subprocess.STDOUT).split('\n')
        self.assertEqual(1, len(a))
        self.assertEqual('', a[0])

        a = subprocess.check_output([ FindExecutable('dcm2xml'), '/tmp/DICOMDIR' ])
        self.assertTrue(re.search('1.3.46.670589.11.17521.5.0.3124.2008081908590448738', a) != None)
        self.assertTrue(re.search('1.3.46.670589.11.17521.5.0.3124.2008081909113806560', a) != None)

        os.remove('/tmp/DICOMDIR')


    def test_protection(self):
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm')
        self.assertEqual(2, len(DoGet(_REMOTE, '/patients')))
        a = DoGet(_REMOTE, '/patients')[0]
        b = DoGet(_REMOTE, '/patients')[1]
        self.assertEqual(0, DoGet(_REMOTE, '/patients/%s/protected' % a))
        DoPut(_REMOTE, '/patients/%s/protected' % a, '0', 'text/plain')
        self.assertEqual(0, DoGet(_REMOTE, '/patients/%s/protected' % a))
        DoPut(_REMOTE, '/patients/%s/protected' % a, '1', 'text/plain')
        self.assertEqual(1, DoGet(_REMOTE, '/patients/%s/protected' % a))
        DoPut(_REMOTE, '/patients/%s/protected' % a, '0', 'text/plain')
        self.assertEqual(0, DoGet(_REMOTE, '/patients/%s/protected' % a))


    def test_raw_tags(self):
        i = UploadInstance(_REMOTE, 'PrivateTags.dcm')['ID']

        dicom = DoGet(_REMOTE, '/instances/%s/file' % i)
        self.assertEqual('1a7c56cb02d6e742cc9c856a8ac182e3', ComputeMD5(dicom))

        s = '/instances/%s/content/' % i

        self.assertEqual('LOGIQBOOK', DoGet(_REMOTE, s + '0008-1010').strip())
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, s + '0008-1011'))

        self.assertEqual('Abdomen', DoGet(_REMOTE, s + '7fe1-1001/0/7fe1-1008/0/7fe1-1057').strip())
        self.assertEqual('cla_3c', DoGet(_REMOTE, s + '7fe1-1001/0/7fe1-1008/8/7fe1-1057').strip())

        UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Knee/T2/IM-0001-0001.dcm')

        for i in DoGet(_REMOTE, '/instances'):
            aid = DoGet(_REMOTE, '/instances/%s' % i)['MainDicomTags']['SOPInstanceUID']
            self.assertEqual(aid, DoGet(_REMOTE, '/instances/%s/content/0008-0018' % i).replace(chr(0), ''))


    def test_raw_tags_mdn(self):
        # Bug reported by Cyril Paulus
        i = UploadInstance(_REMOTE, 'PrivateMDNTags.dcm')['ID']
        self.assertAlmostEqual(0.000027, DoGet(_REMOTE, '/instances/%s/content/7053-1000' % i))


    def test_modify_instance(self):
        i = UploadInstance(_REMOTE, 'PrivateTags.dcm')['ID']
        modified = DoPost(_REMOTE, '/instances/%s/modify' % i,
                          json.dumps({
                    "Replace" : {
                        "PatientName" : "hello",
                        #"PatientID" : "world"
                        },
                    "Remove" : [ "StationName" ],
                    "RemovePrivateTags" : True
                    }),
                          'application/json')
        j = DoPost(_REMOTE, '/instances', modified, 'application/dicom')['ID']

        self.assertNotEqual('hello', DoGet(_REMOTE, '/instances/%s/content/0010-0010' % i).strip())
        #self.assertNotEqual('world', DoGet(_REMOTE, '/instances/%s/content/0010-0020' % i).strip())
        self.assertEqual('LOGIQBOOK', DoGet(_REMOTE, '/instances/%s/content/0008-1010' % i).strip())
        DoGet(_REMOTE, '/instances/%s/content/6003-1010' % i)  # Some private tag

        self.assertEqual('hello', DoGet(_REMOTE, '/instances/%s/content/0010-0010' % j).strip())
        #self.assertEqual('world', DoGet(_REMOTE, '/instances/%s/content/0010-0020' % j).strip())
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/content/0008-1010' % j))
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/content/6003-1010' % j))


    def test_modify_series(self):
        # Upload 4 images from the same series
        for i in range(4):
            UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-000%d.dcm' % (i + 1))

        origSeries = DoGet(_REMOTE, '/series')[0]
        newSeries = DoPost(_REMOTE, '/series/%s/modify' % origSeries,
                           '{"Replace":{"PatientName":"Jodogne"}}',
                           'application/json')['ID']

        self.assertEqual(origSeries, DoGet(_REMOTE, '/series/%s' % newSeries)['ModifiedFrom'])
        instances = DoGet(_REMOTE, '/series/%s' % newSeries)['Instances']
        self.assertEqual(4, len(instances))
        for i in instances:
            j = DoGet(_REMOTE, '/instances/%s' % i)['ModifiedFrom']
            self.assertEqual(newSeries, DoGet(_REMOTE, '/instances/%s' % i)['ParentSeries'])
            self.assertEqual(origSeries, DoGet(_REMOTE, '/instances/%s' % j)['ParentSeries'])

            self.assertEqual('Jodogne', DoGet(_REMOTE, '/instances/%s/content/0010-0010' % i).strip())
            self.assertNotEqual('Jodogne', DoGet(_REMOTE, '/instances/%s/content/0010-0010' % j).strip())


    def test_modify_study(self):
        # Upload 4 images from the 2 series of the same study
        for i in range(4):
            UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-000%d.dcm' % (i + 1))
            UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-000%d.dcm' % (i + 1))

        origStudy = DoGet(_REMOTE, '/studies')[0]
        newStudy = DoPost(_REMOTE, '/studies/%s/modify' % origStudy,
                          '{"Replace":{"PatientName":"Jodogne"}}',
                          'application/json')['ID']

        self.assertEqual(origStudy, DoGet(_REMOTE, '/studies/%s' % newStudy)['ModifiedFrom'])
        series = DoGet(_REMOTE, '/studies/%s' % newStudy)['Series']
        self.assertEqual(2, len(series))
        for s in series:
            ss = DoGet(_REMOTE, '/series/%s' % s)['ModifiedFrom']
            self.assertEqual(newStudy, DoGet(_REMOTE, '/series/%s' % s)['ParentStudy'])
            self.assertEqual(origStudy, DoGet(_REMOTE, '/series/%s' % ss)['ParentStudy'])

            instances = DoGet(_REMOTE, '/series/%s' % s)['Instances']
            for i in instances:
                j = DoGet(_REMOTE, '/instances/%s' % i)['ModifiedFrom']
                self.assertEqual(s, DoGet(_REMOTE, '/instances/%s' % i)['ParentSeries'])
                self.assertEqual(ss, DoGet(_REMOTE, '/instances/%s' % j)['ParentSeries'])

                self.assertEqual('Jodogne', DoGet(_REMOTE, '/instances/%s/content/0010-0010' % i).strip())
                self.assertNotEqual('Jodogne', DoGet(_REMOTE, '/instances/%s/content/0010-0010' % j).strip())


    def test_anonymize_series(self):
        # Upload 4 images from the same series
        for i in range(4):
            UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-000%d.dcm' % (i + 1))

        origSeries = DoGet(_REMOTE, '/series')[0]
        newSeries = DoPost(_REMOTE, '/series/%s/anonymize' % origSeries,
                           '{}',
                           'application/json')['ID']

        self.assertEqual(origSeries, DoGet(_REMOTE, '/series/%s' % newSeries)['AnonymizedFrom'])
        instances = DoGet(_REMOTE, '/series/%s' % newSeries)['Instances']
        self.assertEqual(4, len(instances))
        for i in instances:
            j = DoGet(_REMOTE, '/instances/%s' % i)['AnonymizedFrom']
            self.assertEqual(newSeries, DoGet(_REMOTE, '/instances/%s' % i)['ParentSeries'])
            self.assertEqual(origSeries, DoGet(_REMOTE, '/instances/%s' % j)['ParentSeries'])

            DoGet(_REMOTE, '/instances/%s/content/0008-1010' % j)
            self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/content/0008-1010' % i))


    def test_anonymize_study(self):
        # Upload 4 images from the 2 series of the same study
        for i in range(4):
            UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-000%d.dcm' % (i + 1))
            UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-000%d.dcm' % (i + 1))

        origStudy = DoGet(_REMOTE, '/studies')[0]
        newStudy = DoPost(_REMOTE,'/studies/%s/anonymize' % origStudy,
                          '{"Replace":{"PatientName":"Jodogne"}}',
                          'application/json')['ID']

        self.assertEqual(origStudy, DoGet(_REMOTE, '/studies/%s' % newStudy)['AnonymizedFrom'])
        series = DoGet(_REMOTE, '/studies/%s' % newStudy)['Series']
        self.assertEqual(2, len(series))
        for s in series:
            ss = DoGet(_REMOTE, '/series/%s' % s)['AnonymizedFrom']
            self.assertEqual(newStudy, DoGet(_REMOTE, '/series/%s' % s)['ParentStudy'])
            self.assertEqual(origStudy, DoGet(_REMOTE, '/series/%s' % ss)['ParentStudy'])

            instances = DoGet(_REMOTE, '/series/%s' % s)['Instances']
            for i in instances:
                j = DoGet(_REMOTE, '/instances/%s' % i)['AnonymizedFrom']
                self.assertEqual(s, DoGet(_REMOTE, '/instances/%s' % i)['ParentSeries'])
                self.assertEqual(ss, DoGet(_REMOTE, '/instances/%s' % j)['ParentSeries'])

                self.assertEqual('Jodogne', DoGet(_REMOTE, '/instances/%s/content/0010-0010' % i).strip())
                self.assertNotEqual('Jodogne', DoGet(_REMOTE, '/instances/%s/content/0010-0010' % j).strip())



    def test_storescu(self):
        # Check emptiness
        e = DoGet(_REMOTE, '/exports')
        self.assertEqual(0, len(e['Exports']))
        self.assertEqual(0, e['Last'])
        self.assertTrue(e['Done'])
        e = DoGet(_REMOTE, '/exports?last')
        self.assertEqual(0, len(e['Exports']))
        self.assertEqual(0, e['Last'])
        self.assertTrue(e['Done'])

        # Add 1 instance
        i = UploadInstance(_REMOTE, 'DummyCT.dcm')['ID']
        self.assertEqual(0, len(DoGet(_LOCAL, '/patients')))

        # Export the instance
        j = DoPost(_REMOTE, '/modalities/orthanctest/store', str(i), 'text/plain')  # instance
        self.assertEqual(1, len(DoGet(_LOCAL, '/patients')))
        self.assertEqual(1, len(DoGet(_LOCAL, '/studies')))
        self.assertEqual(1, len(DoGet(_LOCAL, '/series')))
        self.assertEqual(1, len(DoGet(_LOCAL, '/instances')))

        e = DoGet(_REMOTE, '/exports')
        self.assertEqual(1, len(e['Exports']))
        self.assertTrue(e['Done'])
        self.assertEqual(e['Exports'][-1]['Seq'], e['Last'])
        e = DoGet(_REMOTE, '/exports?limit=1')
        self.assertEqual(1, len(e['Exports']))
        self.assertTrue(e['Done'])
        self.assertEqual(e['Exports'][-1]['Seq'], e['Last'])
        e = DoGet(_REMOTE, '/exports?last')
        self.assertEqual(1, len(e['Exports']))
        self.assertTrue(e['Done'])
        self.assertEqual(e['Exports'][-1]['Seq'], e['Last'])
        seqInstance = e['Last']

        # Export the series
        j = DoPost(_REMOTE, '/modalities/orthanctest/store', 'f2635388-f01d497a-15f7c06b-ad7dba06-c4c599fe', 'text/plain')

        e = DoGet(_REMOTE, '/exports')
        self.assertEqual(2, len(e['Exports']))
        self.assertTrue(e['Done'])
        self.assertEqual(e['Exports'][-1]['Seq'], e['Last'])
        seqSeries = e['Last']
        self.assertNotEqual(seqInstance, seqSeries)
        e = DoGet(_REMOTE, '/exports?limit=1&since=0')
        self.assertEqual(1, len(e['Exports']))
        self.assertFalse(e['Done'])
        self.assertEqual(e['Exports'][-1]['Seq'], seqInstance)
        e = DoGet(_REMOTE, '/exports?limit=1&since=' + str(seqInstance))
        self.assertEqual(1, len(e['Exports']))
        self.assertTrue(e['Done'])
        self.assertEqual(e['Exports'][-1]['Seq'], seqSeries)
        e = DoGet(_REMOTE, '/exports?last')
        self.assertEqual(1, len(e['Exports']))
        self.assertTrue(e['Done'])
        self.assertEqual(e['Exports'][-1]['Seq'], seqSeries)

        # Export the study
        j = DoPost(_REMOTE, '/modalities/orthanctest/store', 'b9c08539-26f93bde-c81ab0d7-bffaf2cb-a4d0bdd0', 'text/plain')
        seqStudy = DoGet(_REMOTE, '/exports')['Last']

        # Export the patient
        j = DoPost(_REMOTE, '/modalities/orthanctest/store', '6816cb19-844d5aee-85245eba-28e841e6-2414fae2', 'text/plain')
        self.assertEqual(1, len(DoGet(_LOCAL, '/patients')))
        self.assertEqual(1, len(DoGet(_LOCAL, '/studies')))
        self.assertEqual(1, len(DoGet(_LOCAL, '/series')))
        self.assertEqual(1, len(DoGet(_LOCAL, '/instances')))

        e = DoGet(_REMOTE, '/exports')
        self.assertEqual(4, len(e['Exports']))
        self.assertTrue(e['Done'])
        self.assertEqual(e['Exports'][-1]['Seq'], e['Last'])
        seqPatient = e['Last']
        self.assertNotEqual(seqInstance, seqSeries)
        self.assertNotEqual(seqSeries, seqStudy)
        self.assertNotEqual(seqStudy, seqPatient)
        self.assertTrue(seqInstance < seqSeries)
        self.assertTrue(seqSeries < seqStudy)
        self.assertTrue(seqStudy < seqPatient)
        e = DoGet(_REMOTE, '/exports?limit=1&since=0')
        self.assertEqual(1, len(e['Exports']))
        self.assertFalse(e['Done'])
        self.assertEqual(e['Exports'][-1]['Seq'], seqInstance)
        e = DoGet(_REMOTE, '/exports?limit=1&since=' + str(seqInstance))
        self.assertEqual(1, len(e['Exports']))
        self.assertFalse(e['Done'])
        self.assertEqual(e['Exports'][-1]['Seq'], seqSeries)
        e = DoGet(_REMOTE, '/exports?limit=1&since=' + str(seqSeries))
        self.assertEqual(1, len(e['Exports']))
        self.assertFalse(e['Done'])
        self.assertEqual(e['Exports'][-1]['Seq'], seqStudy)
        e = DoGet(_REMOTE, '/exports?limit=1&since=' + str(seqStudy))
        self.assertEqual(1, len(e['Exports']))
        self.assertTrue(e['Done'])
        self.assertEqual(e['Exports'][-1]['Seq'], seqPatient)
        e = DoGet(_REMOTE, '/exports?last')
        self.assertEqual(1, len(e['Exports']))
        self.assertTrue(e['Done'])
        self.assertEqual(e['Exports'][-1]['Seq'], seqPatient)


        # Check the content of the logged information
        e = DoGet(_REMOTE, '/exports')['Exports']

        if 'PatientID' in e[0]:
            # Since Orthanc 0.8.6
            patient = 'PatientID'
            study = 'StudyInstanceUID'
            series = 'SeriesInstanceUID'
            instance = 'SOPInstanceUID'
        else:
            # Up to Orthanc 0.8.5
            patient = 'PatientId'
            study = 'StudyInstanceUid'
            series = 'SeriesInstanceUid'
            instance = 'SopInstanceUid'

        for k in range(4):
            self.assertTrue('Date' in e[k])
            self.assertTrue('Seq' in e[k])
            self.assertEqual('orthanctest', e[k]['RemoteModality'])

        self.assertEqual(10, len(e[0]))
        self.assertEqual('Instance', e[0]['ResourceType'])
        self.assertEqual('66a662ce-7430e543-bad44d47-0dc5a943-ec7a538d', e[0]['ID'])
        self.assertEqual('/instances/66a662ce-7430e543-bad44d47-0dc5a943-ec7a538d', e[0]['Path'])
        self.assertEqual('1.2.840.113619.2.176.2025.1499492.7040.1171286242.109', e[0][instance])
        self.assertEqual('1.2.840.113619.2.176.2025.1499492.7391.1171285944.394', e[0][series])
        self.assertEqual('1.2.840.113619.2.176.2025.1499492.7391.1171285944.390', e[0][study])
        self.assertEqual('ozp00SjY2xG', e[0][patient])

        self.assertEqual(9, len(e[1]))
        self.assertEqual('Series', e[1]['ResourceType'])
        self.assertEqual('f2635388-f01d497a-15f7c06b-ad7dba06-c4c599fe', e[1]['ID'])
        self.assertEqual('/series/f2635388-f01d497a-15f7c06b-ad7dba06-c4c599fe', e[1]['Path'])
        self.assertEqual('1.2.840.113619.2.176.2025.1499492.7391.1171285944.394', e[1][series])
        self.assertEqual('1.2.840.113619.2.176.2025.1499492.7391.1171285944.390', e[1][study])
        self.assertEqual('ozp00SjY2xG', e[1][patient])

        self.assertEqual(8, len(e[2]))
        self.assertEqual('Study', e[2]['ResourceType'])
        self.assertEqual('b9c08539-26f93bde-c81ab0d7-bffaf2cb-a4d0bdd0', e[2]['ID'])
        self.assertEqual('/studies/b9c08539-26f93bde-c81ab0d7-bffaf2cb-a4d0bdd0', e[2]['Path'])
        self.assertEqual('1.2.840.113619.2.176.2025.1499492.7391.1171285944.390', e[2][study])
        self.assertEqual('ozp00SjY2xG', e[2][patient])

        self.assertEqual(7, len(e[3]))
        self.assertEqual('Patient', e[3]['ResourceType'])
        self.assertEqual('6816cb19-844d5aee-85245eba-28e841e6-2414fae2', e[3]['ID'])
        self.assertEqual('/patients/6816cb19-844d5aee-85245eba-28e841e6-2414fae2', e[3]['Path'])
        self.assertEqual('ozp00SjY2xG', e[3][patient])

        DropOrthanc(_REMOTE)
        self.assertEqual(0, len(DoGet(_REMOTE, '/exports')['Exports']))


    def test_store_peer(self):
        self.assertEqual(0, len(DoGet(_LOCAL, '/exports')['Exports']))
        self.assertEqual(0, len(DoGet(_REMOTE, '/exports')['Exports']))

        i = UploadInstance(_REMOTE, 'DummyCT.dcm')['ID']
        self.assertEqual(0, len(DoGet(_LOCAL, '/patients')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/patients')))

        j = DoPost(_REMOTE, '/peers/peer/store', str(i), 'text/plain')
        self.assertEqual(1, len(DoGet(_LOCAL, '/patients')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/patients')))

        self.assertEqual(1, len(DoGet(_REMOTE, '/exports')['Exports']))

        DropOrthanc(_REMOTE)
        self.assertEqual(0, len(DoGet(_REMOTE, '/exports')['Exports']))


    def test_bulk_storescu(self):
        self.assertEqual(0, len(DoGet(_LOCAL, '/patients')))
        
        a = UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')
        b = UploadInstance(_REMOTE, 'Knee/T2/IM-0001-0001.dcm')

        j = DoPost(_REMOTE, '/modalities/orthanctest/store', [ a['ID'], b['ID'] ], 'application/json')
        self.assertEqual(2, len(DoGet(_LOCAL, '/instances')))

        DropOrthanc(_LOCAL)

        # Send using patient's UUID
        self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))
        j = DoPost(_REMOTE, '/modalities/orthanctest/store', 
                   [ 'ca29faea-b6a0e17f-067743a1-8b778011-a48b2a17' ], 'application/json')
        self.assertEqual(2, len(DoGet(_LOCAL, '/instances')))
        

    def test_color(self):
        i = UploadInstance(_REMOTE, 'ColorTestMalaterre.dcm')['ID']
        im = GetImage(_REMOTE, '/instances/%s/preview' % i)
        self.assertEqual("RGB", im.mode)
        self.assertEqual(41, im.size[0])
        self.assertEqual(41, im.size[1])

        # http://effbot.org/zone/pil-comparing-images.htm
        truth = Image.open(GetDatabasePath('ColorTestMalaterre.png'))
        self.assertTrue(ImageChops.difference(im, truth).getbbox() is None)


    def test_faking_ruby_put(self):
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm')
        self.assertEqual(2, len(DoGet(_REMOTE, '/patients')))
        a = DoGet(_REMOTE, '/patients')[0]
        b = DoGet(_REMOTE, '/patients')[1]
        self.assertEqual(0, DoGet(_REMOTE, '/patients/%s/protected' % a))
        DoGet(_REMOTE, '/patients/%s/protected' % a, data = { '_method' : 'PUT' }, body = '0')
        self.assertEqual(0, DoGet(_REMOTE, '/patients/%s/protected' % a))
        DoGet(_REMOTE, '/patients/%s/protected' % a, data = { '_method' : 'PUT' }, body = '1')
        self.assertEqual(1, DoGet(_REMOTE, '/patients/%s/protected' % a))
        DoGet(_REMOTE, '/patients/%s/protected' % a, data = { '_method' : 'PUT' }, body = '0')
        self.assertEqual(0, DoGet(_REMOTE, '/patients/%s/protected' % a))


    def test_faking_ruby_delete(self):
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm')
        self.assertEqual(2, len(DoGet(_REMOTE, '/patients')))
        a = DoGet(_REMOTE, '/patients')[0]
        b = DoGet(_REMOTE, '/patients')[1]
        DoGet(_REMOTE, '/patients/%s' % a, data = { '_method' : 'DELETE' })
        self.assertEqual(1, len(DoGet(_REMOTE, '/patients')))
        DoGet(_REMOTE, '/patients/%s' % b, data = { '_method' : 'DELETE' })
        self.assertEqual(0, len(DoGet(_REMOTE, '/patients')))


    def test_faking_google_put(self):
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm')
        self.assertEqual(2, len(DoGet(_REMOTE, '/patients')))
        a = DoGet(_REMOTE, '/patients')[0]
        b = DoGet(_REMOTE, '/patients')[1]
        self.assertEqual(0, DoGet(_REMOTE, '/patients/%s/protected' % a))
        DoPost(_REMOTE, '/patients/%s/protected' % a, headers = { 'X-HTTP-Method-Override' : 'PUT' }, data = '0')
        self.assertEqual(0, DoGet(_REMOTE, '/patients/%s/protected' % a))
        DoPost(_REMOTE, '/patients/%s/protected' % a, headers = { 'X-HTTP-Method-Override' : 'PUT' }, data = '1')
        self.assertEqual(1, DoGet(_REMOTE, '/patients/%s/protected' % a))
        DoPost(_REMOTE, '/patients/%s/protected' % a, headers = { 'X-HTTP-Method-Override' : 'PUT' }, data = '0')
        self.assertEqual(0, DoGet(_REMOTE, '/patients/%s/protected' % a))


    def test_faking_google_delete(self):
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm')
        self.assertEqual(2, len(DoGet(_REMOTE, '/patients')))
        a = DoGet(_REMOTE, '/patients')[0]
        b = DoGet(_REMOTE, '/patients')[1]
        DoPost(_REMOTE, '/patients/%s' % a, headers = { 'X-HTTP-Method-Override' : 'DELETE' })
        self.assertEqual(1, len(DoGet(_REMOTE, '/patients')))
        DoPost(_REMOTE, '/patients/%s' % b, headers = { 'X-HTTP-Method-Override' : 'DELETE' })
        self.assertEqual(0, len(DoGet(_REMOTE, '/patients')))


    def test_lua(self):
        self.assertEqual(42, DoPost(_REMOTE, '/tools/execute-script', 'print(42)'))
        self.assertTrue(IsDefinedInLua(_REMOTE, 'PrintRecursive'))
        self.assertFalse(IsDefinedInLua(_REMOTE, 'HelloWorld'))


    def test_metadata(self):
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')
        p = DoGet(_REMOTE, '/patients')[0]
        i = DoGet(_REMOTE, '/instances')[0]
        series = DoGet(_REMOTE, '/series')[0]

        m = DoGet(_REMOTE, '/patients/%s/metadata' % p)
        if IsOrthancVersionAbove(_REMOTE, 1, 11, 0):
            self.assertEqual(2, len(m))
            self.assertTrue('MainDicomTagsSignature' in m)
        else:
            self.assertEqual(1, len(m))
        self.assertTrue('LastUpdate' in m)

        # The lines below failed on Orthanc <= 1.8.2
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/studies/%s/metadata' % p))
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/series/%s/metadata' % p))
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/metadata' % p))

        m = DoGet(_REMOTE, '/studies/%s/metadata' % DoGet(_REMOTE, '/studies')[0])
        if IsOrthancVersionAbove(_REMOTE, 1, 11, 0):
            self.assertEqual(2, len(m))
            self.assertTrue('MainDicomTagsSignature' in m)
        else:
            self.assertEqual(1, len(m))
        self.assertTrue('LastUpdate' in m)

        m = DoGet(_REMOTE, '/series/%s/metadata' % series)
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 5):
            self.assertEqual(4, len(m))
            self.assertTrue('MainDicomSequences' in m)    # since RequestAttributeSequence is now in the MainDicomTags
        elif IsOrthancVersionAbove(_REMOTE, 1, 11, 0):
            self.assertEqual(3, len(m))
            self.assertTrue('MainDicomTagsSignature' in m)
        else:
            self.assertEqual(2, len(m))
        self.assertTrue('LastUpdate' in m)

        # New in Orthanc 1.9.0
        self.assertTrue('RemoteAET' in m)
        self.assertEqual(DoGet(_REMOTE, '/series/%s/metadata/RemoteAET' % series), '')  # None, received by REST API

        m = DoGet(_REMOTE, '/instances/%s/metadata' % i)
        if IsOrthancVersionAbove(_REMOTE, 1, 11, 0):
            self.assertEqual(10, len(m))
        elif IsOrthancVersionAbove(_REMOTE, 1, 9, 1):
            self.assertEqual(9, len(m))
        else:
            self.assertEqual(8, len(m))

        if IsOrthancVersionAbove(_REMOTE, 1, 11, 0):
            self.assertTrue('MainDicomTagsSignature' in m)

        if IsOrthancVersionAbove(_REMOTE, 1, 9, 1):
            self.assertTrue('PixelDataOffset' in m)  # New in Orthanc 1.9.1
            self.assertEqual(int(DoGet(_REMOTE, '/instances/%s/metadata/PixelDataOffset' % i)), 0x0c78)

        self.assertTrue('IndexInSeries' in m)
        self.assertTrue('ReceptionDate' in m)
        self.assertTrue('RemoteAET' in m)
        self.assertTrue('Origin' in m)
        self.assertTrue('TransferSyntax' in m)
        self.assertTrue('SopClassUid' in m)
        self.assertTrue('RemoteIP' in m)
        self.assertTrue('HttpUsername' in m)
        self.assertEqual(DoGet(_REMOTE, '/instances/%s/metadata/IndexInSeries' % i), 1)
        self.assertEqual(DoGet(_REMOTE, '/instances/%s/metadata/Origin' % i), 'RestApi')
        self.assertEqual(DoGet(_REMOTE, '/instances/%s/metadata/RemoteAET' % i), '')  # None, received by REST API
        self.assertEqual(DoGet(_REMOTE, '/instances/%s/metadata/TransferSyntax' % i), '1.2.840.10008.1.2.4.91')  # JPEG2k
        self.assertEqual(DoGet(_REMOTE, '/instances/%s/metadata/SopClassUid' % i), '1.2.840.10008.5.1.4.1.1.4')

        # Play with custom metadata
        (headers, body) = DoPutRaw(_REMOTE, '/patients/%s/metadata/5555' % p, 'coucou')
        self.assertEqual('200', headers['status'])
        self.assertEqual('', body)

        if IsOrthancVersionAbove(_REMOTE, 1, 9, 2):
            self.assertEqual('"0-%s"' % ComputeMD5('coucou'), headers['etag'])
        else:
            self.assertFalse('ETag' in headers)
            self.assertFalse('etag' in headers)
            
        m = DoGet(_REMOTE, '/patients/%s/metadata' % p)
        if IsOrthancVersionAbove(_REMOTE, 1, 11, 0):
            self.assertEqual(3, len(m))
            self.assertTrue('MainDicomTagsSignature' in m)
        else:
            self.assertEqual(2, len(m))
        self.assertTrue('LastUpdate' in m)
        self.assertTrue('5555' in m)
        self.assertEqual('coucou', DoGet(_REMOTE, '/patients/%s/metadata/5555' % p))

        if IsOrthancVersionAbove(_REMOTE, 1, 9, 2):
            DoPut(_REMOTE, '/patients/%s/metadata/5555' % p, 'hello', headers = {
                'If-Match' : headers['etag']
            })
        else:
            DoPut(_REMOTE, '/patients/%s/metadata/5555' % p, 'hello')

        (headers, body) = DoGetRaw(_REMOTE, '/patients/%s/metadata/5555' % p)
        self.assertEqual('200', headers['status'])
        self.assertEqual('hello', body)

        if IsOrthancVersionAbove(_REMOTE, 1, 9, 2):
            DoDelete(_REMOTE, '/patients/%s/metadata/5555' % p, headers = {
                'If-Match' : headers['etag']
            })
        else:
            DoDelete(_REMOTE, '/patients/%s/metadata/5555' % p)
            
        m = DoGet(_REMOTE, '/patients/%s/metadata' % p)
        if IsOrthancVersionAbove(_REMOTE, 1, 11, 0):
            self.assertEqual(2, len(m))
            self.assertTrue('MainDicomTagsSignature' in m)
        else:
            self.assertEqual(1, len(m))
        self.assertTrue('LastUpdate' in m)


    def test_statistics(self):
        # Upload 16 instances
        for i in range(4):
            UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-000%d.dcm' % (i + 1))
            UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-000%d.dcm' % (i + 1))
            UploadInstance(_REMOTE, 'Knee/T1/IM-0001-000%d.dcm' % (i + 1))
            UploadInstance(_REMOTE, 'Knee/T2/IM-0001-000%d.dcm' % (i + 1))

        s = DoGet(_REMOTE, '/statistics')
        self.assertEqual(16, s['CountInstances'])
        self.assertEqual(2, s['CountPatients'])
        self.assertEqual(2, s['CountStudies'])
        self.assertEqual(4, s['CountSeries'])
        d = int(s['TotalUncompressedSize'])

        e = 0
        for patient in DoGet(_REMOTE, '/patients'):
            s = DoGet(_REMOTE, '/patients/%s/statistics' % patient)
            self.assertEqual(8, s['CountInstances'])
            self.assertEqual(1, s['CountStudies'])
            self.assertEqual(2, s['CountSeries'])
            e += int(s['UncompressedSize'])

        for study in DoGet(_REMOTE, '/studies'):
            s = DoGet(_REMOTE, '/studies/%s/statistics' % study)
            self.assertEqual(8, s['CountInstances'])
            self.assertEqual(2, s['CountSeries'])
            e += int(s['UncompressedSize'])

        for series in DoGet(_REMOTE, '/series'):
            s = DoGet(_REMOTE, '/series/%s/statistics' % series)
            self.assertEqual(4, s['CountInstances'])
            e += int(s['UncompressedSize'])

        self.assertEqual(3 * d, e)


    def test_custom_attachment(self):
        u = UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm') ['ID']

        patient = DoGet(_REMOTE, '/patients')[0]
        instance = DoGet(_REMOTE, '/instances')[0]
        size = int(DoGet(_REMOTE, '/patients/%s/statistics' % patient)['DiskSize'])
        self.assertEqual(size, int(DoGet(_REMOTE, '/statistics')['TotalDiskSize']))
        
        self.assertEqual(0, len(DoGet(_REMOTE, '/patients/%s/attachments' % patient)))
        self.assertTrue('dicom' in DoGet(_REMOTE, '/instances/%s/attachments' % instance))

        if IsOrthancVersionAbove(_REMOTE, 1, 9, 1):
            if IsDicomUntilPixelDataStored(_REMOTE):
                self.assertEqual(2, len(DoGet(_REMOTE, '/instances/%s/attachments' % instance)))
                self.assertTrue('dicom-until-pixel-data' in DoGet(_REMOTE, '/instances/%s/attachments' % instance))

                # New in Orthanc 1.10.0
                a = DoGet(_REMOTE, '/instances/%s/attachments?full' % instance)
                self.assertEqual(2, len(a))
                self.assertEqual(1, a['dicom'])
                self.assertEqual(3, a['dicom-until-pixel-data'])

            else:
                self.assertEqual(1, len(DoGet(_REMOTE, '/instances/%s/attachments' % instance)))

                # New in Orthanc 1.10.0
                a = DoGet(_REMOTE, '/instances/%s/attachments?full' % instance)
                self.assertEqual(1, len(a))
                self.assertEqual(1, a['dicom'])
        else:
            self.assertEqual(2, len(DoGet(_REMOTE, '/instances/%s/attachments' % instance)))
            self.assertTrue('dicom-as-json' in DoGet(_REMOTE, '/instances/%s/attachments' % instance))

            # New in Orthanc 1.10.0
            self.assertRaises(Exception, lambda: DoGet(
                _REMOTE, '/instances/%s/attachments?full' % instance))

        self.assertRaises(Exception, lambda: DoPut(_REMOTE, '/patients/%s/attachments/22' % patient, 'hello'))
        hello = 'hellohellohellohellohellohellohellohellohello'
        DoPut(_REMOTE, '/patients/%s/attachments/1025' % patient, hello)
        self.assertEqual(int(DoGet(_REMOTE, '/patients/%s/statistics' % patient)['DiskSize']),
                         int(DoGet(_REMOTE, '/statistics')['TotalDiskSize']))
        self.assertEqual(int(DoGet(_REMOTE, '/patients/%s/statistics' % patient)['DiskSize']),
                         size + int(DoGet(_REMOTE, '/patients/%s/attachments/1025/compressed-size' % patient)))

        DoPut(_REMOTE, '/patients/%s/attachments/1026' % patient, 'world')
        self.assertEqual(int(DoGet(_REMOTE, '/patients/%s/statistics' % patient)['DiskSize']),
                         int(DoGet(_REMOTE, '/statistics')['TotalDiskSize']))
        self.assertEqual(int(DoGet(_REMOTE, '/patients/%s/statistics' % patient)['DiskSize']),
                         size + 
                         int(DoGet(_REMOTE, '/patients/%s/attachments/1025/compressed-size' % patient)) +
                         int(DoGet(_REMOTE, '/patients/%s/attachments/1026/compressed-size' % patient)))

        self.assertEqual(2, len(DoGet(_REMOTE, '/patients/%s/attachments' % patient)))
        self.assertEqual(hello, DoGet(_REMOTE, '/patients/%s/attachments/1025/data' % patient))
        self.assertEqual('world', DoGet(_REMOTE, '/patients/%s/attachments/1026/data' % patient))
        DoPost(_REMOTE, '/patients/%s/attachments/1025/verify-md5' % patient)
        DoPost(_REMOTE, '/patients/%s/attachments/1026/verify-md5' % patient)
        DoPut(_REMOTE, '/patients/%s/attachments/1026' % patient, 'world2', headers = {
            'If-Match' : '0-%s' % ComputeMD5('world'),
        })

        (headers, body) = DoGetRaw(_REMOTE, '/patients/%s/attachments/1026/data' % patient)
        self.assertEqual('200', headers['status'])
        self.assertEqual('world2', body)

        self.assertRaises(Exception, lambda: DoDelete(_REMOTE, '/instances/%s/attachments/dicom' % instance))
        DoDelete(_REMOTE, '/patients/%s/attachments/1025' % patient, headers = {
            'If-Match' : '0-%s' % ComputeMD5(hello),
        })
        self.assertEqual(int(DoGet(_REMOTE, '/patients/%s/statistics' % patient)['DiskSize']),
                         int(DoGet(_REMOTE, '/statistics')['TotalDiskSize']))
        self.assertEqual(int(DoGet(_REMOTE, '/patients/%s/statistics' % patient)['DiskSize']),
                         size + int(DoGet(_REMOTE, '/patients/%s/attachments/1026/compressed-size' % patient)))

        self.assertEqual(1, len(DoGet(_REMOTE, '/patients/%s/attachments' % patient)))
        
        if IsOrthancVersionAbove(_REMOTE, 1, 9, 2):
            DoDelete(_REMOTE, '/patients/%s/attachments/1026' % patient, headers = {
                'If-Match' : headers['etag']
            })
        else:
            self.assertFalse('etag' in headers)
            DoDelete(_REMOTE, '/patients/%s/attachments/1026' % patient)
            
        self.assertEqual(0, len(DoGet(_REMOTE, '/patients/%s/attachments' % patient)))

        self.assertEqual(int(DoGet(_REMOTE, '/patients/%s/statistics' % patient)['DiskSize']), size)
        self.assertEqual(size, int(DoGet(_REMOTE, '/statistics')['TotalDiskSize']))


    def test_incoming_storescu(self):
        self.assertEqual(0, len(DoGet(_REMOTE, '/patients')))
        subprocess.check_call([ FindExecutable('storescu'),
                                _REMOTE['Server'], str(_REMOTE['DicomPort']),
                                GetDatabasePath('ColorTestImageJ.dcm') ])
        self.assertEqual(1, len(DoGet(_REMOTE, '/patients')))

        i = DoGet(_REMOTE, '/instances')
        self.assertEqual(1, len(i))
        m = DoGet(_REMOTE, '/instances/%s/metadata' % i[0])

        if IsOrthancVersionAbove(_REMOTE, 1, 11, 0):
            self.assertEqual(10, len(m))
        elif IsOrthancVersionAbove(_REMOTE, 1, 9, 1):
            self.assertEqual(9, len(m))
        else:
            self.assertEqual(8, len(m))

        if IsOrthancVersionAbove(_REMOTE, 1, 11, 0):
            self.assertTrue('MainDicomTagsSignature' in m)  # New in Orthanc 1.11.0

        if IsOrthancVersionAbove(_REMOTE, 1, 9, 1):
            self.assertTrue('PixelDataOffset' in m)  # New in Orthanc 1.9.1
            self.assertEqual(2242, DoGet(_REMOTE, '/instances/%s/metadata/PixelDataOffset' % i[0]))

        self.assertTrue('IndexInSeries' in m)
        self.assertTrue('ReceptionDate' in m)
        self.assertTrue('RemoteAET' in m)
        self.assertTrue('Origin' in m)
        self.assertTrue('TransferSyntax' in m)
        self.assertTrue('SopClassUid' in m)
        self.assertTrue('RemoteIP' in m)
        self.assertTrue('CalledAET' in m)
        self.assertEqual(DoGet(_REMOTE, '/instances/%s/metadata/IndexInSeries' % i[0]), 1)
        self.assertEqual(DoGet(_REMOTE, '/instances/%s/metadata/Origin' % i[0]), 'DicomProtocol')
        self.assertEqual(DoGet(_REMOTE, '/instances/%s/metadata/RemoteAET' % i[0]), 'STORESCU')
        self.assertEqual(DoGet(_REMOTE, '/instances/%s/metadata/TransferSyntax' % i[0]), '1.2.840.10008.1.2.1')
        self.assertEqual(DoGet(_REMOTE, '/instances/%s/metadata/SopClassUid' % i[0]), '1.2.840.10008.5.1.4.1.1.7')

        series = DoGet(_REMOTE, '/series')[0]
        m = DoGet(_REMOTE, '/series/%s/metadata' % series)
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 5):
            self.assertEqual(4, len(m))
            self.assertTrue('MainDicomSequences' in m)    # since RequestAttributeSequence is now in the MainDicomTags
        elif IsOrthancVersionAbove(_REMOTE, 1, 11, 0):
            self.assertEqual(3, len(m))
            self.assertTrue('MainDicomTagsSignature' in m)
        else:
            self.assertEqual(2, len(m))
        self.assertTrue('LastUpdate' in m)
        self.assertTrue('RemoteAET' in m)
        self.assertEqual(DoGet(_REMOTE, '/series/%s/metadata/RemoteAET' % series), 'STORESCU')
        self.assertEqual(DoGet(_REMOTE, '/series/%s/metadata/LastUpdate' % series),
                         DoGet(_REMOTE, '/instances/%s/metadata/ReceptionDate' % i[0]))


    def test_incoming_findscu(self):
        UploadInstance(_REMOTE, 'Multiframe.dcm')
        UploadInstance(_REMOTE, 'ColorTestImageJ.dcm')

        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=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))
        self.assertTrue('20070208' in series)
        self.assertTrue('19980312' in series)
        
        i = CallFindScu([ '-k', '0008,0052=SERIES', '-k', '0008,0021', '-k', 'Modality=MR\\XA' ])
        series = re.findall('\(0008,0021\).*?\[\s*(.*?)\s*\]', i)
        self.assertEqual(1, len(series))
        self.assertTrue('19980312' in series)
        
        i = CallFindScu([ '-k', '0008,0052=SERIES', '-k', 'PatientName=Anonymized' ])
        series = re.findall('\(0010,0010\).*?\[\s*(.*?)\s*\]', i)
        self.assertEqual(1, len(series))

        # Test the "CaseSentitivePN" flag (false by default)
        i = CallFindScu([ '-k', '0008,0052=SERIES', '-k', 'PatientName=anonymized' ])
        series = re.findall('\(0010,0010\).*?\[\s*(.*?)\s*\]', i)
        self.assertEqual(1, len(series))

        # Test range search (buggy if Orthanc <= 0.9.6)
        i = CallFindScu([ '-k', '0008,0052=STUDY', '-k', 'StudyDate=19980312-' ])
        studies = re.findall('\(0008,0020\).*?\[\s*(.*?)\s*\]', i)
        self.assertEqual(2, len(studies))
        self.assertTrue('20070208' in studies)
        self.assertTrue('19980312' in studies)
        i = CallFindScu([ '-k', '0008,0052=STUDY', '-k', 'StudyDate=19980312-19980312' ])
        studies = re.findall('\(0008,0020\).*?\[\s*(.*?)\s*\]', i)
        self.assertEqual(1, len(studies))
        self.assertTrue('19980312' in studies)
        i = CallFindScu([ '-k', '0008,0052=STUDY', '-k', 'StudyDate=-19980312' ])
        studies = re.findall('\(0008,0020\).*?\[\s*(.*?)\s*\]', i)
        self.assertEqual(1, len(studies))
        self.assertTrue('19980312' in studies)

        # Test that "Retrieve AE Title (0008,0054)" is present, which
        # was *not* the case in Orthanc <= 1.7.2
        i = CallFindScu([ '-k', '0008,0052=INSTANCE' ])
        instances = re.findall('\(0008,0054\).*?\[\s*(.*?)\s*\]', i)
        self.assertEqual(2, len(instances))
        self.assertEqual('ORTHANC', instances[0].strip())
        self.assertEqual('ORTHANC', instances[1].strip())
        

    def test_incoming_findscu_2(self):
        # This test fails if "LookupMode_DatabaseOnly" is used
        # (sequences are not available in this mode, and only main
        # DICOM tags are returned)
        UploadInstance(_REMOTE, 'Multiframe.dcm')
        UploadInstance(_REMOTE, 'ColorTestImageJ.dcm')

        # Test returning sequence values (only since Orthanc 0.9.5)
        i = CallFindScu([ '-k', '0008,0052=SERIES', '-k', '0008,2112' ])  # "ColorTestImageJ" has this sequence tag
        sequences = re.findall('\(0008,2112\)', i)
        self.assertEqual(1, len(sequences))

        # Test returning a non-main DICOM tag,
        # "SecondaryCaptureDeviceID" (0018,1010), whose value is
        # "MEDPC" in "ColorTestImageJ.dcm"
        i = CallFindScu([ '-k', '0008,0052=SERIES', '-k', '0018,1010' ])
        tags = re.findall('\(0018,1010\).*MEDPC', i)
        self.assertEqual(1, len(tags))

        
    def test_incoming_findscu_3(self):
        # This test fails if "LookupMode_DatabaseOnly" or
        # "LookupMode_DiskOnAnswer" is used, as
        # "SecondaryCaptureDeviceID" (0018,1010) is not a main DICOM
        # tag, as thus a constraint cannot be applied to it
        UploadInstance(_REMOTE, 'ColorTestImageJ.dcm')

        i = CallFindScu([ '-k', '0008,0052=SERIES', '-k', '0018,1010=MEDPC' ])
        sequences = re.findall('\(0018,1010\)', i)
        self.assertEqual(1, len(sequences))

        
    def test_incoming_movescu(self):
        UploadInstance(_REMOTE, 'Multiframe.dcm')

        # No matching patient, so no job is created
        self.assertEqual(0, len(DoGet(_LOCAL, '/patients')))
        CallMoveScu([ '--patient', '-k', '0008,0052=PATIENT', '-k', 'PatientID=none' ])        
        self.assertEqual(0, len(DoGet(_LOCAL, '/patients')))

        # 1 Matching patient, track the job
        self.assertTrue(MonitorJob(_REMOTE, lambda: CallMoveScu([
            '--patient',
            '-k', '0008,0052=PATIENT',
            '-k', 'PatientID=12345678'
        ])))
        self.assertEqual(1, len(DoGet(_LOCAL, '/patients')))


    def test_findscu(self):
        i = UploadInstance(_REMOTE, 'DummyCT.dcm')['ID']
        j = UploadInstance(_REMOTE, 'Issue22.dcm')['ID']
        k = UploadInstance(_REMOTE, 'ColorTestImageJ.dcm')['ID']
        DoPost(_REMOTE, '/modalities/orthanctest/store', str(i), 'text/plain')
        
        # Test the "find-patient" level
        p = DoPost(_REMOTE, '/modalities/orthanctest/find-patient', { })
        self.assertEqual(1, len(p))
        self.assertEqual('ozp00SjY2xG', p[0]['PatientID'])
        
        # Test wildcards constraints. The "LO" value representation
        # for PatientID is always case-sensitive, but the "PN" for
        # PatientName might depend on the implementation:
        # "GenerateConfigurationForTests.py" will force it to be case
        # insensitive (which was the default until Orthanc 0.8.6).
        p = DoPost(_REMOTE, '/modalities/orthanctest/find-patient', { 'PatientName' : 'K*' })
        self.assertEqual(1, len(p))
        
        p = DoPost(_REMOTE, '/modalities/orthanctest/find-patient', { 'PatientName' : 'k*' })
        self.assertEqual(1, len(p))
        
        p = DoPost(_REMOTE, '/modalities/orthanctest/find-patient', { 'PatientID' : 'ozp*' })
        self.assertEqual(1, len(p))
        
        p = DoPost(_REMOTE, '/modalities/orthanctest/find-patient', { 'PatientID' : 'o?p*' })
        self.assertEqual(1, len(p))
        
        p = DoPost(_REMOTE, '/modalities/orthanctest/find-patient', { 'PatientID' : '0?q*' })
        self.assertEqual(0, len(p))
        
        p = DoPost(_REMOTE, '/modalities/orthanctest/find-patient', { 'PatientName' : 'B*' })
        self.assertEqual(0, len(p))
        
        p = DoPost(_REMOTE, '/modalities/orthanctest/find-patient', { 'PatientName' : 'b*' })
        self.assertEqual(0, len(p))
        
        DoPost(_REMOTE, '/modalities/orthanctest/store', str(j), 'text/plain')
        DoPost(_REMOTE, '/modalities/orthanctest/store', str(k), 'text/plain')
        DoPost(_REMOTE, '/modalities/orthanctest/find-patient', { })
        self.assertEqual(3, len(DoPost(_REMOTE, '/modalities/orthanctest/find-patient', { })))
        
        p = DoPost(_REMOTE, '/modalities/orthanctest/find-patient', { 'PatientName' : 'A*' })
        self.assertEqual(2, len(p))

        # Test the "find-study" level. This is the instance "ColorTestImageJ.dcm"
        s = DoPost(_REMOTE, '/modalities/orthanctest/find-study', { 'PatientID' : 'B9uTHKOZ' })
        self.assertEqual(1, len(s))
        self.assertEqual('20070208', s[0]['StudyDate'])
        
        # Test range searches
        t = DoPost(_REMOTE, '/modalities/orthanctest/find-study', { 'PatientID' : 'B9uTHKOZ',
                                                                    'StudyDate' : '-20070101' })
        self.assertEqual(0, len(t))
        
        t = DoPost(_REMOTE, '/modalities/orthanctest/find-study', { 'PatientID' : 'B9uTHKOZ',
                                                                    'StudyDate' : '20090101-' })
        self.assertEqual(0, len(t))
        
        t = DoPost(_REMOTE, '/modalities/orthanctest/find-study', { 'PatientID' : 'B9uTHKOZ',
                                                                    'StudyDate' : '20070101-' })
        self.assertEqual(1, len(t))
        
        t = DoPost(_REMOTE, '/modalities/orthanctest/find-study', { 'PatientID' : 'B9uTHKOZ',
                                                                    'StudyDate' : '-20090101' })
        self.assertEqual(1, len(t))
        
        t = DoPost(_REMOTE, '/modalities/orthanctest/find-study', { 'PatientID' : 'B9uTHKOZ',
                                                                    'StudyDate' : '20070207-20070207' })
        self.assertEqual(0, len(t))
        
        t = DoPost(_REMOTE, '/modalities/orthanctest/find-study', { 'PatientID' : 'B9uTHKOZ',
                                                                    'StudyDate' : '20070208-20070208' })
        self.assertEqual(1, len(t))
        
        t = DoPost(_REMOTE, '/modalities/orthanctest/find-study', { 'PatientID' : 'B9uTHKOZ',
                                                                    'StudyDate' : '20070209-20070209' })
        self.assertEqual(0, len(t))
        
        # Test the ModalitiesInStudy tag
        t = DoPost(_REMOTE, '/modalities/orthanctest/find-study', {
            'PatientID' : 'B9uTHKOZ', 
            'ModalitiesInStudy' : 'US' })
        self.assertEqual(0, len(t))

        t = DoPost(_REMOTE, '/modalities/orthanctest/find-study', {
            'PatientID' : 'B9uTHKOZ', 
            'ModalitiesInStudy' : 'CT' })
        self.assertEqual(1, len(t))

        t = DoPost(_REMOTE, '/modalities/orthanctest/find-study', {
            'PatientID' : 'B9uTHKOZ', 
            'ModalitiesInStudy' : 'US\\CT' })
        self.assertEqual(1, len(t))

        t = DoPost(_REMOTE, '/modalities/orthanctest/find-study', {
            'PatientID' : 'B9uTHKOZ', 
            'ModalitiesInStudy' : '' })
        self.assertEqual(1, len(t))

        # Test the "find-series" level
        t = DoPost(_REMOTE, '/modalities/orthanctest/find-series', {
            'PatientID' : 'B9uTHKOZ', 
            'StudyInstanceUID' : s[0]['StudyInstanceUID'] })
        self.assertEqual(1, len(t))

        # Test "\" separator
        t = DoPost(_REMOTE, '/modalities/orthanctest/find-series', {
            'PatientID' : 'B9uTHKOZ', 
            'StudyInstanceUID' : s[0]['StudyInstanceUID'],
            'Modality' : 'MR\\CT\\US' })
        self.assertEqual(1, len(t))

        t = DoPost(_REMOTE, '/modalities/orthanctest/find-series', {
            'PatientID' : 'B9uTHKOZ', 
            'StudyInstanceUID' : s[0]['StudyInstanceUID'],
            'Modality' : 'MR\\US' })
        self.assertEqual(0, len(t))


    def test_update_modalities(self):
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/modalities/toto'))
        self.assertRaises(Exception, lambda: DoDelete(_REMOTE, '/modalities/toto'))
        DoPut(_REMOTE, '/modalities/toto', [ "STORESCP", "localhost", 2000 ])
        DoPut(_REMOTE, '/modalities/tata', [ "STORESCP", "localhost", 2000, 'MedInria' ]) # check backward compatiblity with obsolete manufacturer
        DoDelete(_REMOTE, '/modalities/tata')
        DoPut(_REMOTE, '/modalities/tata', [ "STORESCP", "localhost", 2000, 'GenericNoUniversalWildcard' ])
        DoDelete(_REMOTE, '/modalities/tata')
        DoPut(_REMOTE, '/modalities/tata', [ "STORESCP", "localhost", 2000, 'GenericNoWildcardInDates' ])
        modalitiesReadback = DoGet(_REMOTE, '/modalities?expand')
        self.assertEqual('STORESCP', modalitiesReadback['tata']['AET'])
        self.assertEqual('localhost', modalitiesReadback['tata']['Host'])
        self.assertEqual(2000, modalitiesReadback['tata']['Port'])
        self.assertEqual('GenericNoWildcardInDates', modalitiesReadback['tata']['Manufacturer'])
        self.assertRaises(Exception, lambda: DoPut(_REMOTE, '/modalities/toto', [ "STORESCP", "localhost", 2000, 'InvalidManufacturerName' ]))
        self.assertTrue('store' in DoGet(_REMOTE, '/modalities/toto'))
        self.assertTrue('store' in DoGet(_REMOTE, '/modalities/tata'))

        # New in Orthanc 1.8.1
        self.assertTrue('configuration' in DoGet(_REMOTE, '/modalities/tata'))
        self.assertEqual(modalitiesReadback['tata'], DoGet(_REMOTE, '/modalities/tata/configuration'))
        
        DoDelete(_REMOTE, '/modalities/toto')
        DoDelete(_REMOTE, '/modalities/tata')
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/modalities/toto'))
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/modalities/tata'))

        if IsOrthancVersionAbove(_REMOTE, 1, 12, 2):
            self.assertEqual(DoGet(_REMOTE, '/modalities?expand'), DoGet(_REMOTE, '/modalities?expand=true'))
            self.assertEqual(DoGet(_REMOTE, '/modalities'), DoGet(_REMOTE, '/modalities?expand=false'))



    def test_update_peers(self):
        # curl -X PUT http://localhost:8042/peers/toto -d '["http://localhost:8042/"]' -v
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/peers/toto'))
        self.assertRaises(Exception, lambda: DoDelete(_REMOTE, '/peers/toto'))
        DoPut(_REMOTE, '/peers/toto', [ 'http://localhost:8042/' ])
        DoPut(_REMOTE, '/peers/tata', { 'Url': 'http://localhost:8042/',
                                        'Username': 'user',
                                        'Password' : 'pass',
                                        'RemoteSelf' : 'self' })
        self.assertTrue('tata' in DoGet(_REMOTE, '/peers'))
        peersReadback = DoGet(_REMOTE, '/peers?expand')
        self.assertEqual('http://localhost:8042/', peersReadback['tata']['Url'])
        self.assertEqual('user', peersReadback['tata']['Username'])

        if IsOrthancVersionAbove(_REMOTE, 1, 5, 4):
            self.assertEqual(None, peersReadback['tata']['Password']) # make sure no sensitive data is included
            self.assertFalse(peersReadback['tata']['Pkcs11']) # make sure no sensitive data is included
            self.assertEqual('self', peersReadback['tata']['RemoteSelf'])
        else:
            self.assertFalse('Password' in peersReadback['tata']) # make sure no sensitive data is included
            self.assertFalse('Pkcs11' in peersReadback['tata']) # make sure no sensitive data is included
            self.assertFalse('RemoteSelf' in peersReadback['tata'])

        self.assertFalse('CertificateFile' in peersReadback['tata']) # make sure no sensitive data is included
        self.assertFalse('CertificateKeyFile' in peersReadback['tata']) # make sure no sensitive data is included
        self.assertFalse('CertificateKeyPassword' in peersReadback['tata']) # make sure no sensitive data is included

        self.assertRaises(Exception, lambda: DoPut(_REMOTE, '/peers/toto', [ 'http://localhost:8042/', 'a' ]))
        self.assertRaises(Exception, lambda: DoPut(_REMOTE, '/peers/toto', [ 'http://localhost:8042/', 'a', 'b', 'c' ]))
        self.assertTrue('store' in DoGet(_REMOTE, '/peers/toto'))
        self.assertTrue('store' in DoGet(_REMOTE, '/peers/tata'))

        # New in Orthanc 1.8.1
        self.assertTrue('configuration' in DoGet(_REMOTE, '/peers/tata'))
        self.assertEqual(peersReadback['tata'], DoGet(_REMOTE, '/peers/tata/configuration'))

        DoDelete(_REMOTE, '/peers/toto')
        DoDelete(_REMOTE, '/peers/tata')
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/peers/toto'))
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/peers/tata'))

        if IsOrthancVersionAbove(_REMOTE, 1, 12, 2):
            self.assertEqual(DoGet(_REMOTE, '/peers?expand'), DoGet(_REMOTE, '/peers?expand=true'))
            self.assertEqual(DoGet(_REMOTE, '/peers'), DoGet(_REMOTE, '/peers?expand=false'))



    def test_mesterhazy_modification(self):
        # When I modify a series ( eg. curl
        # http://localhost:8042/series/uidhere/modify -X POST -d
        # '{"Replace":{"SeriesDate":"19990101"}}' ) the modified
        # series is added to a new Study, instead of the existing
        # Study. Fixed in Orthanc 0.7.5

        u = UploadInstance(_REMOTE, 'DummyCT.dcm')
        study = 'b9c08539-26f93bde-c81ab0d7-bffaf2cb-a4d0bdd0'
        series = 'f2635388-f01d497a-15f7c06b-ad7dba06-c4c599fe'

        modified = DoPost(_REMOTE, '/series/%s/modify' % series,
                          json.dumps({ "Replace" : { "SeriesDate" : "19990101" }}))

        self.assertEqual(study, DoGet(_REMOTE, '/series/%s' % modified['ID']) ['ParentStudy'])


    def test_create(self):
        payload = {
            'PatientName' : 'Jodogne',
            'Modality' : 'CT',
            'SOPClassUID' : '1.2.840.10008.5.1.4.1.1.1',
            'PixelData' : 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==' # red dot in RGBA
            }

        if IsOrthancVersionAbove(_REMOTE, 1, 12, 5):
            payload['TimeRange'] = '3.12\\4.12'                      # https://discourse.orthanc-server.org/t/multiplicity-on-dicom-tags/5144

        i = DoPost(_REMOTE, '/tools/create-dicom',
                   json.dumps(payload))

        self.assertEqual('Jodogne', DoGet(_REMOTE, '/instances/%s/content/PatientName' % i['ID']).strip())
        self.assertEqual('CT', DoGet(_REMOTE, '/instances/%s/content/Modality' % i['ID']).strip())
        tags = DoGet(_REMOTE, '/instances/%s/tags?simplify' % i['ID'])
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 5):
            self.assertIn("3.12", tags["TimeRange"])
            self.assertIn("4.12", tags["TimeRange"])
            self.assertIn("\\", tags["TimeRange"])

        png = GetImage(_REMOTE, '/instances/%s/preview' % i['ID'])
        self.assertEqual((5, 5), png.size)

        j = DoGet(_REMOTE, i['Path'])
        self.assertEqual('Instance', j['Type'])
        self.assertEqual(j['ID'], i['ID'])


    def test_pilates(self):
        # "SCU failed error when accessing orthanc with osirix" by
        # Pilates Agentur (Mar 10, 2014 at 9:33 PM)
        i = UploadInstance(_REMOTE, 'PilatesArgenturGEUltrasoundOsiriX.dcm')['ID']
        self.assertEqual(0, len(DoGet(_LOCAL, '/patients')))
        j = DoPost(_REMOTE, '/modalities/orthanctest/store', str(i), 'text/plain')
        self.assertEqual(1, len(DoGet(_LOCAL, '/patients')))


    def test_shared_tags(self):
        a = UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')['ID']
        b = UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0002.dcm')['ID']
        p = DoGet(_REMOTE, '/patients')[0]
        
        self.assertTrue('0010,0010' in DoGet(_REMOTE, '/patients/%s/shared-tags' % p))
        self.assertTrue('PatientName' in DoGet(_REMOTE, '/patients/%s/shared-tags?simplify' % p))
        self.assertTrue('0010,0010' in DoGet(_REMOTE, '/patients/%s/shared-tags?short' % p))

        self.assertEqual('KNEE', DoGet(_REMOTE, '/patients/%s/shared-tags' % p)['0010,0010']['Value'])
        self.assertEqual('KNEE', DoGet(_REMOTE, '/patients/%s/shared-tags?simplify' % p)['PatientName'])
        self.assertEqual('KNEE', DoGet(_REMOTE, '/patients/%s/shared-tags?short' % p)['0010,0010'])
        
        self.assertTrue('0008,1030' in DoGet(_REMOTE, '/patients/%s/shared-tags' % p))
        self.assertTrue('StudyDescription' in DoGet(_REMOTE, '/patients/%s/shared-tags?simplify' % p))
        self.assertTrue('0008,103e' in DoGet(_REMOTE, '/patients/%s/shared-tags' % p))
        self.assertTrue('SeriesDescription' in DoGet(_REMOTE, '/patients/%s/shared-tags?simplify' % p))
        self.assertFalse('0008,0018' in DoGet(_REMOTE, '/patients/%s/shared-tags' % p))
        self.assertFalse('SOPInstanceUID' in DoGet(_REMOTE, '/patients/%s/shared-tags?simplify' % p))

        self.assertTrue('0008,0018' in DoGet(_REMOTE, '/instances/%s/tags' % a))
        self.assertTrue('SOPInstanceUID' in DoGet(_REMOTE, '/instances/%s/tags?simplify' % a))


    def test_modules(self):
        a = UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')['ID']
        p = DoGet(_REMOTE, '/patients')[0]
        s = DoGet(_REMOTE, '/studies')[0]
        t = DoGet(_REMOTE, '/series')[0]
        
        self.assertTrue('0010,0010' in DoGet(_REMOTE, '/patients/%s/module' % p))
        self.assertTrue('PatientName' in DoGet(_REMOTE, '/patients/%s/module?simplify' % p))
        self.assertTrue('0010,0010' in DoGet(_REMOTE, '/studies/%s/module-patient' % s))
        self.assertTrue('PatientName' in DoGet(_REMOTE, '/studies/%s/module-patient?simplify' % s))
        self.assertTrue('0008,1030' in DoGet(_REMOTE, '/studies/%s/module' % s))
        self.assertTrue('StudyDescription' in DoGet(_REMOTE, '/studies/%s/module?simplify' % s))
        self.assertTrue('0008,103e' in DoGet(_REMOTE, '/series/%s/module' % t))
        self.assertTrue('SeriesDescription' in DoGet(_REMOTE, '/series/%s/module?simplify' % t))
        self.assertTrue('0008,0018' in DoGet(_REMOTE, '/instances/%s/module' % a))
        self.assertTrue('SOPInstanceUID' in DoGet(_REMOTE, '/instances/%s/module?simplify' % a))


    def test_auto_directory(self):
        a = UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')['ID']
        self.assertTrue('now' in DoGet(_REMOTE, '/tools'))
        self.assertTrue('dicom-conformance' in DoGet(_REMOTE, '/tools'))
        self.assertTrue('invalidate-tags' in DoGet(_REMOTE, '/tools'))
        self.assertTrue(len(DoGet(_REMOTE, '/tools/dicom-conformance')) > 1000)
        self.assertTrue('orthanctest' in DoGet(_REMOTE, '/modalities'))
        self.assertTrue('echo' in DoGet(_REMOTE, '/modalities/orthanctest'))
        self.assertTrue('find' in DoGet(_REMOTE, '/modalities/orthanctest'))
        self.assertTrue('find-instance' in DoGet(_REMOTE, '/modalities/orthanctest'))
        self.assertTrue('find-patient' in DoGet(_REMOTE, '/modalities/orthanctest'))
        self.assertTrue('find-series' in DoGet(_REMOTE, '/modalities/orthanctest'))
        self.assertTrue('find-study' in DoGet(_REMOTE, '/modalities/orthanctest'))
        self.assertTrue('store' in DoGet(_REMOTE, '/modalities/orthanctest'))
        self.assertTrue('store' in DoGet(_REMOTE, '/peers/peer'))
        self.assertTrue('matlab' in DoGet(_REMOTE, '/instances/%s/frames/0' % a))
        self.assertTrue('raw' in DoGet(_REMOTE, '/instances/%s/frames/0' % a))
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/tools/nope'))
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/nope'))
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/nope/nope.html'))
        self.assertEqual(404, DoGetRaw(_REMOTE, '/nope')[0].status)
        self.assertEqual(404, DoGetRaw(_REMOTE, '/nope/nope.html')[0].status)


    def test_echo(self):
        DoPost(_REMOTE, '/modalities/orthanctest/echo')
        DoPost(_REMOTE, '/modalities/orthanctest/echo', '{}')

        # The following was not working in Orthanc 1.7.0 -> 1.8.1
        DoPost(_REMOTE, '/modalities/orthanctest/echo', '')
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/modalities/nope/echo'))

        # New in Orthanc 1.8.1
        DoPost(_REMOTE, '/tools/dicom-echo', [
            _REMOTE['DicomAet'], _REMOTE['Server'], _REMOTE['DicomPort'] ])
        DoPost(_REMOTE, '/tools/dicom-echo', DoGet(_REMOTE, '/modalities/orthanctest/configuration'))

        # Use the 'CheckFind' new option in Orthanc 1.8.1
        DoPost(_REMOTE, '/modalities/self/echo', { 'CheckFind' : True })
        DoPost(_REMOTE, '/tools/dicom-echo', {
            'AET' : _REMOTE['DicomAet'],
            'Host' : _REMOTE['Server'],
            'Port' : _REMOTE['DicomPort'],
            'CheckFind' : True
            })
        

    def test_xml(self):
        json = DoGet(_REMOTE, '/tools', headers = { 'accept' : 'application/json' })
        xml = minidom.parseString(DoGet(_REMOTE, '/tools', headers = { 'accept' : 'application/xml' }))
        items = xml.getElementsByTagName('root')[0].getElementsByTagName('item') 
        self.assertEqual(len(items), len(json))

        self.assertTrue('dicom-conformance' in json)

        ok = False
        for i in items:
            if i.childNodes[0].data == 'dicom-conformance':
                ok = True
        self.assertTrue(ok)


    def test_googlecode_issue_16(self):
        i = UploadInstance(_REMOTE, 'Issue16.dcm')['ID']
        t = DoGet(_REMOTE, '/instances/%s/tags?simplify' % i)['FrameIncrementPointer']
        self.assertEqual('0018,1063', t)


    def test_googlecode_issue_22(self):
        s = UploadInstance(_REMOTE, 'Issue22.dcm')['ID']
        a = [
            "f804691f62197040438f4627c6b994f1",  # Frame 0
            "c69eee9a51eea3e8611e82e578897254",
            "315666be83e2d0111c77bc0996d84901",
            "3e27aa959d911172c48a1436443c72b1",
            "958642c9e7e9d232d3869faff546058c",
            "5e7ea8e3e4230cae707d143481355c59",
            "eda37f83558d858a596175aed8b2ad47",
            "486713bd2895c4ecbe0e97715ac7f80a",
            "091ef729eb169e67da8a0faa9631f9a8",
            "5aa2b8c7ffe0a483efaa8e12417686ca",
            "e2f39e85896fe58876654b94cd0b5013",
            "6fd2129e4950abbe1be053bc814d5da8",
            "c3331a8ba7a757f3d423735ab7fa81f9",
            "746f808582156734dd6b6fdfd3a0b72c",
            "8075ea2b227a70c60ea6b7b75a6bb190",
            "806b8b3e300c615099c11a5ec23465aa",
            "7c836aa298ba6eef96434579af631a11",
            "a0357dc9f4f72d73a885c33d7c287446",
            "f25ba3be1cc7d7fad95706adc199ea7d",
            "8b114c526b8cbed6cad8a3248b7b480c",
            "44e6670f127e612a2b4aa60a0d207698",
            "b8945f90fe02facf2ace24ca1ecbe0a5",
            "95c796c2fa8f59018b15cf2987b1f79b",
            "ce0a51ab30224205b44920221dc27351",  # Frame 23
            ]

        self.assertEqual(24, len(DoGet(_REMOTE, '/instances/%s/frames' % s)))
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/frames/24/preview' % s))

        for i in range(len(a)):
            self.assertEqual(a[i], ComputeMD5(DoGet(_REMOTE, '/instances/%s/frames/%d/preview' % (s, i))))


    def test_googlecode_issue_19(self):
        # This is an image with "YBR_FULL" photometric interpretation, it is not supported by Orthanc
        # gdcmconv -i /home/jodogne/DICOM/GdcmDatabase/US_DataSet/HDI5000_US/3EAF5E01 -w -o Issue19.dcm

        a = UploadInstance(_REMOTE, 'Issue19.dcm')['ID']
        if not HasGdcmPlugin(_REMOTE):
            self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/941ad3c8-05d05b88-560459f9-0eae0e20-6cddd533/preview'))


    def test_googlecode_issue_37(self):
        # Same test for issues 35 and 37. Fixed in Orthanc 0.9.1
        u = UploadInstance(_REMOTE, 'Beaufix/IM-0001-0001.dcm')['ID']

        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
                                             'CaseSensitive' : True,
                                             'Query' : { 'StationName' : 'SMR4-MP3' }})
        self.assertEqual(1, len(a))

        if IsOrthancVersionAbove(_REMOTE, 1, 12, 5):
            a = DoPost(_REMOTE, '/tools/count-resources', { 'Level' : 'Series',
                                                            'CaseSensitive' : False,
                                                            'Query' : { 'StationName' : 'SMR4-MP3' }})
            self.assertEqual(1, len(a))
            self.assertEqual(1, a['Count'])


    def test_rest_find(self):
        def CheckFind(query, expectedAnswers, executeCountResources = True):
            a = DoPost(_REMOTE, '/tools/find', query)
            self.assertEqual(expectedAnswers, len(a))

            if IsOrthancVersionAbove(_REMOTE, 1, 12, 5) and executeCountResources:
                if 'CaseSensitive' in query:
                    del query['CaseSensitive']
                b = DoPost(_REMOTE, '/tools/count-resources', query)
                self.assertEqual(1, len(b))
                self.assertEqual(expectedAnswers, b['Count'])

            return a

        # Upload 12 instances
        for i in range(3):
            UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-000%d.dcm' % (i + 1))
            UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-000%d.dcm' % (i + 1))
            UploadInstance(_REMOTE, 'Knee/T1/IM-0001-000%d.dcm' % (i + 1))
            UploadInstance(_REMOTE, 'Knee/T2/IM-0001-000%d.dcm' % (i + 1))


        query = { 'Level' : 'Study',
                  'CaseSensitive' : True,
                  'Query' : {
                      'PatientName' : '*NE*',
                      'StudyDate': '20080819'
                  }}
        CheckFind(query, 1)

        query = { 'Level' : 'Study',
                  'CaseSensitive' : True,
                  'Query' : {
                      'PatientName' : '*NE*',
                      'PatientBirthDate': '20080101-20081231',
                      'PatientSex': '0000'
                  }}
        CheckFind(query, 1)

        query = { 'Level' : 'Series',
                  'CaseSensitive' : True,
                  'Query' : {
                      'StudyInstanceUID' : '2.16.840.1.113669.632.20.121711.10000160881'
                  }}
        CheckFind(query, 2)

        query = { 'Level' : 'Instance',
                  'CaseSensitive' : True,
                  'Query' : {
                      'StudyInstanceUID' : '2.16.840.1.113669.632.20.121711.10000160881',
                      'SeriesInstanceUID': '1.3.46.670589.11.17521.5.0.3124.2008081908564160709'
                  }}
        CheckFind(query, 3)

        query = { 'Level' : 'Series',
                  'CaseSensitive' : True,
                  'Query' : {
                      'StudyDate' : '20080818-20080820',
                      'Modality': 'MR'
                  }}
        CheckFind(query, 2)

        query = { 'Level' : 'Study',
                  'CaseSensitive' : True,
                  'Query' : {
                      'StudyDate' : '20080818-',
                      'ModalitiesInStudy': 'MR'
                  }}
        CheckFind(query, 1, False)   # "ModalitiesInStudy" is not a main DICOM tag, so unavailable in "/tools/count-resources"

        query = { 'Level' : 'Patient',
                  'CaseSensitive' : False,
                  'Query' : { 'PatientName' : 'BRAINIX' }}
        CheckFind(query, 1)

        query = { 'Level' : 'Patient',
                  'CaseSensitive' : False,
                  'Query' : { 'PatientName' : 'BRAINIX\\KNEE\\NOPE' }}
        CheckFind(query, 2)

        query = { 'Level' : 'Patient',
                  'CaseSensitive' : False,
                  'Query' : { 'PatientName' : '*n*' }}
        CheckFind(query, 2)

        query = { 'Level' : 'Patient',
                  'CaseSensitive' : True,
                  'Query' : { 'PatientName' : '*n*' }}
        CheckFind(query, 0, False)  # "CaseSensitive" is not available in "/tools/count-resources"

        query = { 'Expand' : True,
                  'Level' : 'Patient',
                  'CaseSensitive' : False,
                  'Query' : { 'PatientName' : '*ne*' }}
        a = CheckFind(query, 1)
        self.assertEqual('20080822', a[0]['MainDicomTags']['PatientBirthDate'])

        query = { 'Level' : 'Patient',
                  'CaseSensitive' : True,
                  'Query' : { 'PatientName' : '*ne*' }}
        CheckFind(query, 0, False)  # "CaseSensitive" is not available in "/tools/count-resources"

        query = { 'Level' : 'Study',
                  'CaseSensitive' : True,
                  'Query' : { 'PatientName' : '*NE*' }}
        CheckFind(query, 1)

        query = { 'Level' : 'Series',
                  'CaseSensitive' : True,
                  'Query' : { 'PatientName' : '*NE*' }}
        CheckFind(query, 2)

        query = { 'Level' : 'Instance',
                  'CaseSensitive' : True,
                  'Query' : { 'PatientName' : '*NE*' }}
        CheckFind(query, 6)

        query = { 'Level' : 'Patient', 'Query' : { }}
        CheckFind(query, 2)

        query = { 'Level' : 'Study', 'Query' : { }}
        CheckFind(query, 2)

        query = { 'Level' : 'Series', 'Query' : { }}
        CheckFind(query, 4)

        query = { 'Level' : 'Instance', 'Query' : { }}
        CheckFind(query, 12)

        query = { 'Level' : 'Study',
                  'Expand' : True,
                  'Query' : { 'StudyDate' : '20061201-20061201' }}
        a = CheckFind(query, 1)
        self.assertEqual('BRAINIX', a[0]['PatientMainDicomTags']['PatientName'])

        query = { 'Level' : 'Study',
                'Expand' : True,
                  'Query' : { 'StudyDate' : '20061201-20091201' }}
        a = CheckFind(query, 2)
        for i in range(2):
            self.assertTrue(a[i]['PatientMainDicomTags']['PatientName'] in ['BRAINIX', 'KNEE'])

        query = { 'Level' : 'Study',
                  'Query' : { 'StudyDate' : '20061202-20061202' }}
        CheckFind(query, 0)

        query = { 'Level' : 'Study',
                  'Expand' : True,
                  'Query' : { 'StudyDate' : '-20061201' }}
        a = CheckFind(query, 1)
        self.assertEqual('BRAINIX', a[0]['PatientMainDicomTags']['PatientName'])

        query = { 'Level' : 'Study',
                  'Expand' : True,
                  'Query' : { 'StudyDate' : '-20051201' }}
        CheckFind(query, 0)

        query = { 'Level' : 'Study',
                  'Expand' : True,
                  'Query' : { 'StudyDate' : '20061201-' }}
        a = CheckFind(query, 2)
        for i in range(2):
            self.assertTrue(a[i]['PatientMainDicomTags']['PatientName'] in ['BRAINIX', 'KNEE'])

        query = { 'Level' : 'Study',
                  'Expand' : True,
                  'Query' : { 'StudyDate' : '20061202-' }}
        a = CheckFind(query, 1)
        self.assertEqual('KNEE', a[0]['PatientMainDicomTags']['PatientName'])

        query = { 'Level' : 'Study',
                  'Expand' : True,
                  'Query' : { 'StudyDate' : '20080819-' }}
        a = CheckFind(query, 1)
        self.assertEqual('KNEE', a[0]['PatientMainDicomTags']['PatientName'])

        query = { 'Level' : 'Study',
                  'Expand' : True,
                  'Query' : { 'StudyDate' : '20080820-' }}
        CheckFind(query, 0)

        query = { 'Level' : 'Series',
                  'Expand' : True,
                  'Query' : { 'PatientPosition' : 'HFS' }}
        CheckFind(query, 2, False)   # "PatientPosition" is not a main DICOM tag, so unavailable in "/tools/count-resources"

        query = { 'Level' : 'Series',
                  'Expand' : False,
                  'Query' : { 'PatientPosition' : 'HFS' }}
        CheckFind(query, 2, False)   # "PatientPosition" is not a main DICOM tag, so unavailable in "/tools/count-resources"
        

    def test_rest_query_retrieve(self):
        self.assertEqual(0, len(DoGet(_REMOTE, '/patients')))

        # Upload 8 instances
        for i in range(2):
            UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-000%d.dcm' % (i + 1))
            UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-000%d.dcm' % (i + 1))
            UploadInstance(_REMOTE, 'Knee/T1/IM-0001-000%d.dcm' % (i + 1))
            UploadInstance(_REMOTE, 'Knee/T2/IM-0001-000%d.dcm' % (i + 1))

        self.assertEqual(2, len(DoGet(_REMOTE, '/patients')))
        for p in DoGet(_REMOTE, '/patients'):
            DoPost(_REMOTE, '/modalities/orthanctest/store', p)
            DoDelete(_REMOTE, '/patients/%s' % p)

        for q in DoGet(_REMOTE, '/queries'):
            DoDelete(_REMOTE, '/queries/%s' % q)

        self.assertEqual(0, len(DoGet(_REMOTE, '/patients')))
        self.assertEqual(0, len(DoGet(_REMOTE, '/queries')))

        if not IsOrthancVersionAbove(_LOCAL, 1, 11, 2):  # TODO: check why this works with 0.8.6 and not with more recent versions
            a = DoPost(_REMOTE, '/modalities/orthanctest/query', { 'Level' : 'Series',
                                                                'Query' : { 
                                                                    'PatientName' : '*NE*',
                                                                    'StudyDate' : '*',
                                                                }})['ID']
            self.assertEqual(1, len(DoGet(_REMOTE, '/queries')))

            b = DoGet(_REMOTE, '/queries/%s' % a)
            self.assertTrue('answers' in b)
            self.assertTrue('level' in b)
            self.assertTrue('modality' in b)
            self.assertTrue('query' in b)
            self.assertTrue('retrieve' in b)
            self.assertEqual('Series', DoGet(_REMOTE, '/queries/%s/level' % a))
            self.assertEqual('orthanctest', DoGet(_REMOTE, '/queries/%s/modality' % a))
            
            q = DoGet(_REMOTE, '/queries/%s/query?simplify' % a)
            self.assertEqual(2, len(q))
            self.assertTrue('PatientName' in q)
            self.assertTrue('StudyDate' in q)
            self.assertEqual('*NE*', q['PatientName'])
            self.assertEqual('*', q['StudyDate'])

            self.assertEqual(2, len(DoGet(_REMOTE, '/queries/%s/answers' % a)))

            s = DoGet(_REMOTE, '/queries/%s/answers/0' % a)
            self.assertTrue('content' in s)
            self.assertTrue('retrieve' in s)

            s = DoGet(_REMOTE, '/queries/%s/answers/0/content?simplify' % a)
            self.assertEqual('887', s['PatientID'])
            self.assertEqual('2.16.840.1.113669.632.20.121711.10000160881', s['StudyInstanceUID'])

            self.assertEqual(0, len(DoGet(_REMOTE, '/patients')))
            DoPost(_REMOTE, '/queries/%s/answers/0/retrieve' % a, 'ORTHANC')
            self.assertEqual(1, len(DoGet(_REMOTE, '/patients')))
            self.assertEqual(1, len(DoGet(_REMOTE, '/studies')))
            self.assertEqual(1, len(DoGet(_REMOTE, '/series')))
            self.assertEqual(2, len(DoGet(_REMOTE, '/instances')))

            DoPost(_REMOTE, '/queries/%s/answers/1/retrieve' % a, 'ORTHANC', 'application/json') # make sure the issue #36 is fixed (query/retrieve Rest API: /retrieve route shall accept application/json content type)
            self.assertEqual(1, len(DoGet(_REMOTE, '/patients')))
            self.assertEqual(1, len(DoGet(_REMOTE, '/studies')))
            self.assertEqual(2, len(DoGet(_REMOTE, '/series')))
            self.assertEqual(4, len(DoGet(_REMOTE, '/instances')))

            # New in Orthanc 1.4.3
            s = DoGet(_REMOTE, '/queries/%s/answers?expand&simplify' % a)
            self.assertEqual(2, len(s))
            for i in range(2):
                self.assertEqual('SERIES', s[i]['QueryRetrieveLevel'])
                self.assertEqual('887', s[i]['PatientID'])
                self.assertEqual('2.16.840.1.113669.632.20.121711.10000160881', s[i]['StudyInstanceUID'])
            
            DoDelete(_REMOTE, '/queries/%s' % a)
            self.assertEqual(0, len(DoGet(_REMOTE, '/queries')))


    def test_parent(self):
        u = UploadInstance(_REMOTE, 'DummyCT.dcm')['ID']
        patient = '6816cb19-844d5aee-85245eba-28e841e6-2414fae2'
        study = 'b9c08539-26f93bde-c81ab0d7-bffaf2cb-a4d0bdd0'
        series = 'f2635388-f01d497a-15f7c06b-ad7dba06-c4c599fe'
        instance = '66a662ce-7430e543-bad44d47-0dc5a943-ec7a538d'
        self.assertEqual(instance, u)

        a = DoGet(_REMOTE, '/studies/%s/patient' % study)
        self.assertEqual('Patient', a['Type'])
        self.assertEqual(patient, a['ID'])
        
        a = DoGet(_REMOTE, '/series/%s/patient' % series)
        self.assertEqual('Patient', a['Type'])
        self.assertEqual(patient, a['ID'])
        
        a = DoGet(_REMOTE, '/series/%s/study' % series)
        self.assertEqual('Study', a['Type'])
        self.assertEqual(study, a['ID'])
        
        a = DoGet(_REMOTE, '/instances/%s/patient' % instance)
        self.assertEqual('Patient', a['Type'])
        self.assertEqual(patient, a['ID'])
        
        a = DoGet(_REMOTE, '/instances/%s/study' % instance)
        self.assertEqual('Study', a['Type'])
        self.assertEqual(study, a['ID'])
        
        a = DoGet(_REMOTE, '/instances/%s/series' % instance)
        self.assertEqual('Series', a['Type'])
        self.assertEqual(series, a['ID'])
        

    def test_shanon(self):
        def Anonymize(instance, replacements = {}):
            return DoPost(_REMOTE, '/instances/%s/anonymize' % instance, {
                'Replace' : replacements,
                'Force' : True,
            }, 'application/json')

        self.assertEqual(0, len(DoGet(_REMOTE, '/instances')))
        u = UploadInstance(_REMOTE, 'DummyCT.dcm')['ID']
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))

        tags = [ 'PatientID', 'StudyInstanceUID', 'SeriesInstanceUID', 'SOPInstanceUID', 'DeidentificationMethod' ]
        ids = [ 'ozp00SjY2xG',
                '1.2.840.113619.2.176.2025.1499492.7391.1171285944.390',
                '1.2.840.113619.2.176.2025.1499492.7391.1171285944.394',
                '1.2.840.113619.2.176.2025.1499492.7040.1171286242.109' ]

        a = ExtractDicomTags(Anonymize(u), tags)
        for i in range(4):
            self.assertNotEqual(ids[i], a[i])
        self.assertTrue(a[4].startswith('Orthanc'))

        a = ExtractDicomTags(Anonymize(u, { 'PatientName' : 'toto' }), tags)
        for i in range(4):
            self.assertNotEqual(ids[i], a[i])
        self.assertFalse(a[4].startswith('Orthanc'))

        a = ExtractDicomTags(Anonymize(u, { 'SOPInstanceUID' : 'instance' }), tags)
        self.assertEqual('instance', a[3])
        self.assertFalse(a[4].startswith('Orthanc'))

        a = ExtractDicomTags(Anonymize(u, { 'SeriesInstanceUID' : 'series' }), tags)
        self.assertEqual('series', a[2])
        self.assertFalse(a[4].startswith('Orthanc'))

        a = ExtractDicomTags(Anonymize(u, { 'StudyInstanceUID' : 'study' }), tags)
        self.assertEqual('study', a[1])
        self.assertFalse(a[4].startswith('Orthanc'))

        a = ExtractDicomTags(Anonymize(u, { 'PatientID' : 'patient' }), tags)
        self.assertEqual('patient', a[0])
        self.assertFalse(a[4].startswith('Orthanc'))

        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.assertEqual(1, len(DoGet(_REMOTE, '/instances')))


    def test_shanon_2(self):
        def Modify(instance, replacements = {}):
            return DoPost(_REMOTE, '/instances/%s/modify' % instance, {
                'Replace' : replacements,
                'Force': True,
            }, 'application/json')

        self.assertEqual(0, len(DoGet(_REMOTE, '/instances')))
        u = UploadInstance(_REMOTE, 'DummyCT.dcm')['ID']
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))

        tags = [ 'PatientID', 'StudyInstanceUID', 'SeriesInstanceUID', 'SOPInstanceUID', 'DeidentificationMethod' ]
        ids = [ 'ozp00SjY2xG',
                '1.2.840.113619.2.176.2025.1499492.7391.1171285944.390',
                '1.2.840.113619.2.176.2025.1499492.7391.1171285944.394',
                '1.2.840.113619.2.176.2025.1499492.7040.1171286242.109' ]

        a = ExtractDicomTags(Modify(u), tags)
        self.assertEqual(ids[0], a[0])
        self.assertEqual(ids[1], a[1])
        self.assertEqual(ids[2], a[2])
        self.assertNotEqual(ids[3], a[3])
        self.assertEqual(0, len(a[4]))

        a = ExtractDicomTags(Modify(u, { 'SOPInstanceUID' : 'instance' }), tags)
        self.assertEqual(ids[0], a[0])
        self.assertEqual(ids[1], a[1])
        self.assertEqual(ids[2], a[2])
        self.assertEqual('instance', a[3])

        a = ExtractDicomTags(Modify(u, { 'SeriesInstanceUID' : 'series' }), tags)
        self.assertEqual(ids[0], a[0])
        self.assertEqual(ids[1], a[1])
        self.assertEqual('series', a[2])
        self.assertNotEqual(ids[3], a[3])

        a = ExtractDicomTags(Modify(u, { 'StudyInstanceUID' : 'study' }), tags)
        self.assertEqual(ids[0], a[0])
        self.assertEqual('study', a[1])
        self.assertNotEqual(ids[2], a[2])
        self.assertNotEqual(ids[3], a[3])

        a = ExtractDicomTags(Modify(u, { 'PatientID' : 'patient' }), tags)
        self.assertEqual('patient', a[0])
        self.assertNotEqual(ids[1], a[1])
        self.assertNotEqual(ids[2], a[2])
        self.assertNotEqual(ids[3], a[3])

        a = ExtractDicomTags(Modify(u, { 'PatientID' : 'patient',
                                         'StudyInstanceUID' : 'study',
                                         'SeriesInstanceUID' : 'series',
                                         'SOPInstanceUID' : 'instance' }), tags)
        self.assertEqual('patient', a[0])
        self.assertEqual('study', a[1])
        self.assertEqual('series', a[2])
        self.assertEqual('instance', a[3])

        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))
        

    def test_instances_tags(self):
        a = UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')['ID']
        b = UploadInstance(_REMOTE, 'Knee/T2/IM-0001-0001.dcm')['ID']
        #a = UploadInstance(_REMOTE, 'Cardiac/MR.X.1.2.276.0.7230010.3.1.4.2831157719.2256.1336386937.676343')['ID']
        #b = UploadInstance(_REMOTE, 'Cardiac/MR.X.1.2.276.0.7230010.3.1.4.2831157719.2256.1336386925.676329')['ID']

        i = DoGet(_REMOTE, '/patients/%s/instances-tags?simplify' % DoGet(_REMOTE, '/patients')[0])
        self.assertEqual(2, len(i))
        self.assertEqual('887', i[i.keys()[0]]['PatientID'])
        self.assertEqual('887', i[i.keys()[1]]['PatientID'])

        i = DoGet(_REMOTE, '/studies/%s/instances-tags?simplify' % DoGet(_REMOTE, '/studies')[0])
        self.assertEqual(2, len(i))
        self.assertEqual('887', i[i.keys()[0]]['PatientID'])
        self.assertEqual('887', i[i.keys()[1]]['PatientID'])

        self.assertEqual(2, len(DoGet(_REMOTE, '/series')))
        i = DoGet(_REMOTE, '/series/%s/instances-tags?simplify' % DoGet(_REMOTE, '/series')[0])
        self.assertEqual(1, len(i))
        self.assertEqual('887', i[i.keys()[0]]['PatientID'])
        
        i = DoGet(_REMOTE, '/series/%s/instances-tags?simplify' % DoGet(_REMOTE, '/series')[1])
        self.assertEqual(1, len(i))
        self.assertEqual('887', i[i.keys()[0]]['PatientID'])

        i = DoGet(_REMOTE, '/series/%s/instances-tags?short' % DoGet(_REMOTE, '/series')[1])
        self.assertEqual(1, len(i))
        self.assertEqual('887', i[i.keys()[0]]['0010,0020'])


    def test_lookup(self):
        a = DoPost(_REMOTE, '/tools/lookup', 'ozp00SjY2xG')
        self.assertEqual(0, len(a))

        UploadInstance(_REMOTE, 'DummyCT.dcm')

        a = DoPost(_REMOTE, '/tools/lookup', 'ozp00SjY2xG')
        self.assertEqual(1, len(a))
        self.assertEqual('Patient', a[0]['Type'])
        self.assertEqual('6816cb19-844d5aee-85245eba-28e841e6-2414fae2', a[0]['ID'])
        self.assertEqual('/patients/%s' % a[0]['ID'], a[0]['Path'])
        
        a = DoPost(_REMOTE, '/tools/lookup', '1.2.840.113619.2.176.2025.1499492.7391.1171285944.390')
        self.assertEqual(1, len(a))
        self.assertEqual('Study', a[0]['Type'])
        self.assertEqual('b9c08539-26f93bde-c81ab0d7-bffaf2cb-a4d0bdd0', a[0]['ID'])
        self.assertEqual('/studies/%s' % a[0]['ID'], a[0]['Path'])
        
        a = DoPost(_REMOTE, '/tools/lookup', '1.2.840.113619.2.176.2025.1499492.7391.1171285944.394')
        self.assertEqual(1, len(a))
        self.assertEqual('Series', a[0]['Type'])
        self.assertEqual('f2635388-f01d497a-15f7c06b-ad7dba06-c4c599fe', a[0]['ID'])
        self.assertEqual('/series/%s' % a[0]['ID'], a[0]['Path'])
        
        a = DoPost(_REMOTE, '/tools/lookup', '1.2.840.113619.2.176.2025.1499492.7040.1171286242.109')
        self.assertEqual(1, len(a))
        self.assertEqual('Instance', a[0]['Type'])
        self.assertEqual('66a662ce-7430e543-bad44d47-0dc5a943-ec7a538d', a[0]['ID'])
        self.assertEqual('/instances/%s' % a[0]['ID'], a[0]['Path'])

        DropOrthanc(_REMOTE)
        a = DoPost(_REMOTE, '/tools/lookup', '3113719P')
        self.assertEqual(0, len(a))


    def test_autorouting(self):
        knee1 = 'Knee/T1/IM-0001-0001.dcm'
        knee2 = 'Knee/T2/IM-0001-0002.dcm'
        other = 'Brainix/Flair/IM-0001-0001.dcm'

        # Check that this version is >= 0.8.0
        self.assertTrue(IsDefinedInLua(_REMOTE, '_InitializeJob'))
        self.assertTrue('orthanctest' in DoGet(_REMOTE, '/modalities'))

        UploadInstance(_REMOTE, knee1)
        self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))

        DropOrthanc(_REMOTE)
        DropOrthanc(_LOCAL)
        InstallLuaScriptFromPath(_REMOTE, 'Lua/Autorouting.lua')
        UploadInstance(_REMOTE, knee1)
        UploadInstance(_REMOTE, knee2)
        UploadInstance(_REMOTE, other)
        WaitEmpty(_REMOTE)
        UninstallLuaCallbacks(_REMOTE)
        self.assertEqual(3, len(DoGet(_LOCAL, '/instances')))

        DropOrthanc(_REMOTE)
        DropOrthanc(_LOCAL)
        InstallLuaScriptFromPath(_REMOTE, 'Lua/AutoroutingConditional.lua')
        UploadInstance(_REMOTE, knee1)
        UploadInstance(_REMOTE, knee2)
        UploadInstance(_REMOTE, other)
        WaitEmpty(_REMOTE)
        UninstallLuaCallbacks(_REMOTE)
        self.assertEqual(2, len(DoGet(_LOCAL, '/instances')))
        
        DropOrthanc(_REMOTE)
        DropOrthanc(_LOCAL)
        InstallLuaScriptFromPath(_REMOTE, 'Lua/AutoroutingModification.lua')
        UploadInstance(_REMOTE, knee1)
        WaitEmpty(_REMOTE)
        UninstallLuaCallbacks(_REMOTE)
        i = DoGet(_LOCAL, '/instances')
        self.assertEqual(1, len(i))
        
        with tempfile.NamedTemporaryFile(delete = True) as f:
            f.write(DoGet(_LOCAL, '/instances/%s/file' % i[0]))
            f.flush()
            routed = subprocess.check_output([ FindExecutable('dcm2xml'), f.name ])
            self.assertEqual('My Medical Device', re.search('"StationName">(.*?)<', routed).group(1).strip())
            self.assertEqual(None, re.search('"MilitaryRank"', routed))
            self.assertEqual(None, re.search('"0051,0010"', routed))  # A private tag


    def test_storescu_rf(self):
        i = UploadInstance(_REMOTE, 'KarstenHilbertRF.dcm')['ID']
        self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))
        j = DoPost(_REMOTE, '/modalities/orthanctest/store', str(i), 'text/plain')
        self.assertEqual(1, len(DoGet(_LOCAL, '/instances')))


    def test_anonymize_instance(self):
        def AnonymizeAndUpload(instanceId, parameters):
            return DoPost(_REMOTE, '/instances', DoPost(_REMOTE, '/instances/%s/anonymize' % instanceId, parameters,
                                               'application/json'), 'application/dicom')['ID']

        def ModifyAndUpload(instanceId, parameters):
            return DoPost(_REMOTE, '/instances', DoPost(_REMOTE, '/instances/%s/modify' % instanceId, parameters,
                                               'application/json'), 'application/dicom')['ID']

        a = UploadInstance(_REMOTE, 'PrivateMDNTags.dcm')['ID']
        s1 = DoGet(_REMOTE, '/instances/%s/content/PatientName' % a)
        s2 = DoGet(_REMOTE, '/instances/%s/content/00e1-10c2' % a)  # Some private tag
        s3 = DoGet(_REMOTE, '/instances/%s/content/StudyDescription' % a)
        s4 = DoGet(_REMOTE, '/instances/%s/content/SeriesDescription' % a)
        s5 = DoGet(_REMOTE, '/instances/%s/content/InstitutionName' % a)
        
        b = AnonymizeAndUpload(a, '{}')
        self.assertNotEqual(s1, DoGet(_REMOTE, '/instances/%s/content/PatientName' % b))
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/content/00e1-10c2' % b))
        
        # Keep private tag (only OK since Orthanc 0.8.0)
        b = AnonymizeAndUpload(a, '{"Keep":["00e1-10c2"]}')  
        self.assertNotEqual(s1, DoGet(_REMOTE, '/instances/%s/content/PatientName' % b))
        self.assertEqual(s2, DoGet(_REMOTE, '/instances/%s/content/00e1-10c2' % b))
        
        b = AnonymizeAndUpload(a, '{"Keep":["00e1,10c2","PatientName"]}')
        self.assertEqual(s1, DoGet(_REMOTE, '/instances/%s/content/PatientName' % b))
        self.assertEqual(s2, DoGet(_REMOTE, '/instances/%s/content/00e1-10c2' % b))
        
        b = AnonymizeAndUpload(a, '{"Keep":["PatientName"],"Replace":{"00e1,10c2":"Hello"}}')
        self.assertEqual(s1, DoGet(_REMOTE, '/instances/%s/content/PatientName' % b))
        self.assertTrue(DoGet(_REMOTE, '/instances/%s/content/00e1-10c2' % b).startswith('Hello'))

        # Examples from the Wiki
        b = AnonymizeAndUpload(a, '{"Replace":{"PatientName":"hello","0010-0020":"world"},"Keep":["StudyDescription", "SeriesDescription"],"KeepPrivateTags": true,"Force":true}')
        self.assertEqual('hello', DoGet(_REMOTE, '/instances/%s/content/0010-0010' % b).strip())
        self.assertEqual('world', DoGet(_REMOTE, '/instances/%s/content/PatientID' % b).strip())
        self.assertEqual(s3, DoGet(_REMOTE, '/instances/%s/content/0008,1030' % b))
        self.assertEqual(s4, DoGet(_REMOTE, '/instances/%s/content/0008,103e' % b))
        self.assertEqual(s4, DoGet(_REMOTE, '/instances/%s/content/0008-103E' % b))
        self.assertEqual(s2, DoGet(_REMOTE, '/instances/%s/content/00e1-10c2' % b))
        DoGet(_REMOTE, '/instances/%s/content/InstitutionName' % a)
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/content/InstitutionName' % b))

        b = ModifyAndUpload(a, '{"Replace":{"PatientName":"hello","PatientID":"world"},"Remove":["InstitutionName"],"RemovePrivateTags": true,"Force":true}')
        self.assertEqual('hello', DoGet(_REMOTE, '/instances/%s/content/0010-0010' % b).strip())
        self.assertEqual('world', DoGet(_REMOTE, '/instances/%s/content/PatientID' % b).strip())
        self.assertEqual(s3, DoGet(_REMOTE, '/instances/%s/content/0008,1030' % b))
        self.assertEqual(s4, DoGet(_REMOTE, '/instances/%s/content/0008,103e' % b))
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/content/00e1-10c2' % b))
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/content/InstitutionName' % b))

        b = ModifyAndUpload(a, '{"Replace":{"PatientName":"hello","PatientID":"world"},"Force":true}')
        self.assertEqual('hello', DoGet(_REMOTE, '/instances/%s/content/0010-0010' % b).strip())
        self.assertEqual('world', DoGet(_REMOTE, '/instances/%s/content/PatientID' % b).strip())
        self.assertEqual(s2, DoGet(_REMOTE, '/instances/%s/content/00e1,10c2' % b))
        self.assertEqual(s3, DoGet(_REMOTE, '/instances/%s/content/0008,1030' % b))
        self.assertEqual(s4, DoGet(_REMOTE, '/instances/%s/content/0008-103E' % b))
        self.assertEqual(s5, DoGet(_REMOTE, '/instances/%s/content/InstitutionName' % b))


        # Test modify non-existing
        i = DoPost(_REMOTE, '/tools/create-dicom',
                   json.dumps({
                    'PatientName' : 'Jodogne',
                    'Modality' : 'CT',
                    }))['ID']

        self.assertEqual('Jodogne', DoGet(_REMOTE, '/instances/%s/content/PatientName' % i).strip())
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/content/StudyDescription' % i))
        self.assertEqual('CT', DoGet(_REMOTE, '/instances/%s/content/Modality' % i).strip())

        b = ModifyAndUpload(i, '{"Replace":{"StudyDescription":"hello","Modality":"world"}}')
        self.assertEqual('Jodogne', DoGet(_REMOTE, '/instances/%s/content/PatientName' % b).strip())
        self.assertEqual('hello', DoGet(_REMOTE, '/instances/%s/content/StudyDescription' % b).strip())
        self.assertEqual('world', DoGet(_REMOTE, '/instances/%s/content/Modality' % b).strip())


    def test_incoming_jpeg(self):
        # since this test fails regularly on CI, enable verbosity
        DoPut(_REMOTE, '/tools/log-level', 'verbose')

        def storescu(image, acceptUnknownSopClassUid, expectSuccess = True, retries = 1):
            if acceptUnknownSopClassUid:
                tmp = [ '-xf', GetDatabasePath('UnknownSopClassUid.cfg'), 'Default' ]
            else:
                tmp = [ '-xs' ]

            while retries > 0:
                retries -= 1
                with open(os.devnull, 'w') as FNULL:
                    try:
                        subprocess.check_call([ FindExecutable('storescu') ] + tmp +
                                            [ _REMOTE['Server'], str(_REMOTE['DicomPort']),
                                                GetDatabasePath(image) ],
                                            stderr = FNULL)

                        if expectSuccess:
                            return
                    except subprocess.CalledProcessError as e:
                        print('storescu failed with error code: %s' % str(e.returncode))
                        if not expectSuccess:
                            raise e

        self.assertEqual(0, len(DoGet(_REMOTE, '/patients')))

        if IsOrthancVersionAbove(_REMOTE, 1, 9, 0):
            a = DoPut(_REMOTE, '/tools/accepted-transfer-syntaxes', [
                '1.2.840.10008.1.2', '1.2.840.10008.1.2.1', '1.2.840.10008.1.2.2'
            ])
            self.assertTrue('1.2.840.10008.1.2' in a)
            self.assertTrue('1.2.840.10008.1.2.1' in a)
            self.assertTrue('1.2.840.10008.1.2.2' in a)
            self.assertEqual(3, len(a))
            self.assertRaises(Exception, lambda: DoPut(_REMOTE, '/tools/unknown-sop-class-accepted', 'nope'))
            DoPut(_REMOTE, '/tools/unknown-sop-class-accepted', '0')
            self.assertEqual(0, DoGet(_REMOTE, '/tools/unknown-sop-class-accepted'))
        else:
            InstallLuaScriptFromPath(_REMOTE, 'Lua/TransferSyntaxDisable.lua')
        
        # the following line regularly fails on CI because storescu still returns 0 although the C-Store fails -> that's why we have implemented retries
        self.assertRaises(Exception, lambda: storescu('Knix/Loc/IM-0001-0001.dcm', False, False, 3))
        self.assertRaises(Exception, lambda: storescu('UnknownSopClassUid.dcm', True, False, 3))
        self.assertEqual(0, len(DoGet(_REMOTE, '/patients')))

        if IsOrthancVersionAbove(_REMOTE, 1, 9, 0):
            a = DoPut(_REMOTE, '/tools/accepted-transfer-syntaxes', '*', 'text/plain')
            self.assertGreaterEqual(42, len(a))
            DoPut(_REMOTE, '/tools/unknown-sop-class-accepted', 'true')
            self.assertEqual(1, DoGet(_REMOTE, '/tools/unknown-sop-class-accepted'))
        else:
            InstallLuaScriptFromPath(_REMOTE, 'Lua/TransferSyntaxEnable.lua')

        DoPost(_REMOTE, '/tools/execute-script', "print('All special transfer syntaxes are now accepted')")
        storescu('Knix/Loc/IM-0001-0001.dcm', False)
        storescu('UnknownSopClassUid.dcm', True)
        self.assertEqual(2, len(DoGet(_REMOTE, '/patients')))

        # set back normal verbosity
        DoPut(_REMOTE, '/tools/log-level', 'default')

    def test_storescu_jpeg(self):
        self.assertEqual(0, len(DoGet(_REMOTE, '/exports')['Exports']))

        knixStudy = 'b9c08539-26f93bde-c81ab0d7-bffaf2cb-a4d0bdd0'
        i = UploadInstance(_REMOTE, 'Knix/Loc/IM-0001-0001.dcm')['ID']

        # This is JPEG lossless
        self.assertEqual('1.2.840.10008.1.2.4.70', DoGet(_REMOTE, '/instances/%s/header' % i)['0002,0010']['Value'])
        self.assertEqual('1.2.840.10008.1.2.4.70', DoGet(_REMOTE, '/instances/%s/header?simplify' % i)['TransferSyntaxUID'])
        self.assertEqual('1.2.840.10008.1.2.4.70', DoGet(_REMOTE, '/instances/%s/header?short' % i)['0002,0010'])

        UploadInstance(_REMOTE, 'Knix/Loc/IM-0001-0002.dcm')
        UploadInstance(_REMOTE, 'Knix/Loc/IM-0001-0003.dcm')

        a = UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm')['ID']
        b = UploadInstance(_REMOTE, 'ColorTestImageJ.dcm')['ID']
        self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))
        DoPost(_REMOTE, '/modalities/orthanctest/store', [ knixStudy, a, b ])
        self.assertEqual(5, len(DoGet(_LOCAL, '/instances')))

        self.assertEqual(3, len(DoGet(_REMOTE, '/exports')['Exports']))

        DropOrthanc(_REMOTE)
        self.assertEqual(0, len(DoGet(_REMOTE, '/exports')['Exports']))


    def test_pixel_data(self):
        jpeg = UploadInstance(_REMOTE, 'Knix/Loc/IM-0001-0001.dcm')['ID']
        color = UploadInstance(_REMOTE, 'ColorTestImageJ.dcm')['ID']
        phenix = UploadInstance(_REMOTE, 'Phenix/IM-0001-0001.dcm')['ID']
        
        phenixSize = 358 * 512 * 2
        colorSize = 1000 * 1000 * 3
        jpegSize = 51918

        self.assertEqual(1, len(DoGet(_REMOTE, '/instances/%s/content/7fe0-0010' % phenix)))
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances/%s/content/7fe0-0010' % color)))
        self.assertEqual(2, len(DoGet(_REMOTE, '/instances/%s/content/7fe0-0010' % jpeg)))
        
        self.assertEqual(0, len(DoGet(_REMOTE, '/instances/%s/content/7fe0-0010/0' % jpeg)))
        self.assertEqual(jpegSize, len(DoGet(_REMOTE, '/instances/%s/content/7fe0-0010/1' % jpeg)))       

        self.assertEqual(phenixSize, len(DoGet(_REMOTE, '/instances/%s/content/7fe0-0010/0' % phenix)))
        self.assertEqual(colorSize, len(DoGet(_REMOTE, '/instances/%s/content/7fe0-0010/0' % color)))


    def test_decode_brainix(self):
        brainix = [
            UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-0001.dcm')['ID'], # (*)
            UploadInstance(_REMOTE, 'Formats/JpegLossless.dcm')['ID'],  # JPEG-LS, same as (*) (since Orthanc 0.7.6) => doesn't work on big-endian
            UploadInstance(_REMOTE, 'Formats/Jpeg.dcm')['ID'],  # JPEG, same as (*) (since Orthanc 0.7.6)
            ]
        h = '6fb11b932d535c2be04beabd99793ff8'
        maxValue = 426.0

        truth = Image.open(GetDatabasePath('Formats/Brainix.png'))
        for i in brainix:
            self.AssertSameImages(truth.getdata(), '/instances/%s/image-int16' % i)
            self.AssertSameImages(truth.getdata(), '/instances/%s/image-uint16' % i)

        truth2 = map(lambda x: min(255, x), truth.getdata())
        for i in brainix:
            self.AssertSameImages(truth2, '/instances/%s/image-uint8' % i)

        truth2 = map(lambda x: x * 255.0 / maxValue, truth.getdata())
        for i in brainix:
            self.AssertSameImages(truth2, '/instances/%s/preview' % i)

        for i in brainix:
            self.assertEqual(h, ComputeMD5(DoGet(_REMOTE, '/instances/%s/matlab' % i)))


    def test_decode_color(self):
        imagej = UploadInstance(_REMOTE, 'ColorTestImageJ.dcm')['ID']
        color = UploadInstance(_REMOTE, 'ColorTestMalaterre.dcm')['ID']

        for i in [ imagej, color ]:
            for j in [ 'image-uint8', 'image-uint16', 'image-int16' ]:
                self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/%s' % (i, j)))

        self.assertEqual('c14c687f7a1ea9fe022479fc87c67274', ComputeMD5(DoGet(_REMOTE, '/instances/%s/preview' % imagej)))
        self.assertEqual('a87d122918a56f803bcfe9d2586b9125', ComputeMD5(DoGet(_REMOTE, '/instances/%s/preview' % color)))

        self.assertEqual('30cc46bfa7aba77a40e4178f6184c25a', ComputeMD5(DoGet(_REMOTE, '/instances/%s/matlab' % imagej)))
        self.assertEqual('ff195005cef06b59666fd220a9b4cd9a', ComputeMD5(DoGet(_REMOTE, '/instances/%s/matlab' % color)))


    def test_decode_rf(self):
        rf = UploadInstance(_REMOTE, 'KarstenHilbertRF.dcm')['ID']
        truth = Image.open(GetDatabasePath('Formats/KarstenHilbertRF.png'))

        self.AssertSameImages(truth.getdata(), '/instances/%s/image-uint8' % rf)
        self.AssertSameImages(truth.getdata(), '/instances/%s/image-uint16' % rf)
        self.AssertSameImages(truth.getdata(), '/instances/%s/image-int16' % rf)
        self.AssertSameImages(truth.getdata(), '/instances/%s/preview' % rf)

        self.assertEqual('42254d70efd2f4a1b8f3455909689f0e', ComputeMD5(DoGet(_REMOTE, '/instances/%s/matlab' % rf)))


    def test_decode_multiframe(self):
        mf = UploadInstance(_REMOTE, 'Multiframe.dcm')['ID']

        # Test the first frame
        truth = Image.open(GetDatabasePath('Formats/Multiframe0.png'))
        self.AssertSameImages(truth.getdata(), '/instances/%s/image-uint8' % mf)
        self.AssertSameImages(truth.getdata(), '/instances/%s/image-uint16' % mf)
        self.AssertSameImages(truth.getdata(), '/instances/%s/image-int16' % mf)
        self.AssertSameImages(truth.getdata(), '/instances/%s/preview' % mf)
        self.assertEqual('9812b99d93bbcd4e7684ded089b5dfb3', ComputeMD5(DoGet(_REMOTE, '/instances/%s/matlab' % mf)))

        self.AssertSameImages(truth.getdata(), '/instances/%s/frames/0/image-uint16' % mf)

        # Test the last frame
        truth = Image.open(GetDatabasePath('Formats/Multiframe75.png'))
        self.AssertSameImages(truth.getdata(), '/instances/%s/frames/75/image-uint16' % mf)


    def test_decode_signed(self):
        signed = UploadInstance(_REMOTE, 'SignedCT.dcm')['ID']
        minValue = -2000
        maxValue = 4042

        truth = Image.open(GetDatabasePath('Formats/SignedCT.png'))
        self.AssertSameImages(truth.getdata(), '/instances/%s/image-int16' % signed)

        truth2 = map(lambda x: 0 if x >= 32768 else x, truth.getdata())
        self.AssertSameImages(truth2, '/instances/%s/image-uint16' % signed)

        truth3 = map(lambda x: 255 if x >= 256 else x, truth2)
        self.AssertSameImages(truth3, '/instances/%s/image-uint8' % signed)

        tmp = map(lambda x: x - 65536 if x >= 32768 else x, truth.getdata())
        tmp = map(lambda x: (255.0 * (x - minValue)) / (maxValue - minValue), tmp)
        self.AssertSameImages(tmp, '/instances/%s/preview' % signed)

        self.assertEqual('b57e6c872a3da50877c7da689b03a444', ComputeMD5(DoGet(_REMOTE, '/instances/%s/matlab' % signed)))


    def test_googlecode_issue_32(self):
        self.assertRaises(Exception, lambda: DoPut(_REMOTE, '/tools/default-encoding', 'nope'))
        self.assertEqual('Windows1251', DoPut(_REMOTE, '/tools/default-encoding', 'Windows1251'))
        self.assertEqual('Windows1251', DoGet(_REMOTE, '/tools/default-encoding'))
        
        f = UploadInstance(_REMOTE, 'Issue32.dcm')['ID']
        tags = DoGet(_REMOTE, '/instances/%s/tags?simplify' % f)
        self.assertEqual(u'Рентгенография', tags['SeriesDescription'])
        self.assertEqual(u'Таз', tags['BodyPartExamined'])
        self.assertEqual(u'Прямая', tags['ViewPosition'])

        # Replay the same test using Latin1 as default encoding: This must fail
        self.assertEqual('Latin1', DoPut(_REMOTE, '/tools/default-encoding', 'Latin1'))

        DoDelete(_REMOTE, '/instances/%s' % f)
        f = UploadInstance(_REMOTE, 'Issue32.dcm')['ID']
        tags = DoGet(_REMOTE, '/instances/%s/tags?simplify' % f)
        self.assertNotEqual(u'Рентгенография', tags['SeriesDescription'])

        
    def test_encodings(self):
        # Latin-1 (ISO_IR 100)
        brainix = UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-0001.dcm')['ID']
        tags = DoGet(_REMOTE, '/instances/%s/tags?simplify' % brainix)
        self.assertEqual(u'IRM cérébrale, neuro-crâne', tags['StudyDescription'])

        # Latin-2 (ISO_IR 101)
        a = UploadInstance(_REMOTE, 'MarekLatin2.dcm')['ID']
        i = DoGet(_REMOTE, '/instances/%s/simplified-tags' % a)
        # dcm2xml MarekLatin2.dcm | iconv -f latin2 -t utf-8 | xmllint --format -
        self.assertEqual('Imię i Nazwisko osoby opisującej', 
                         i['ContentSequence'][4]['ConceptNameCodeSequence'][0]['CodeMeaning'].encode('utf-8'))
        

    def test_storescu_custom_aet(self):
        # This tests a feature introduced in Orthanc 0.9.1: "Custom
        # setting of the local AET during C-Store SCU (both in Lua and
        # in the REST API)."
        # https://groups.google.com/forum/#!msg/orthanc-users/o5qMULformU/wZjW2iSaMcAJ

        self.assertEqual(0, len(DoGet(_LOCAL, '/patients')))
        
        a = UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')
        b = UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0002.dcm')
        c = UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0003.dcm')

        j = DoPost(_REMOTE, '/modalities/orthanctest/store', {
            'LocalAet' : 'YOP',
            'Resources' : [ a['ID'], b['ID'] ]
        })

        self.assertEqual(2, len(DoGet(_LOCAL, '/instances')))
        self.assertEqual('YOP', DoGet(_LOCAL, '/instances/%s/metadata/RemoteAET' % a['ID']))

        DropOrthanc(_LOCAL)
        self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))

        j = DoPost(_REMOTE, '/modalities/orthanctest/store', {
            'Resources' : [ c['ID'] ]
        })

        self.assertEqual(1, len(DoGet(_LOCAL, '/instances')))
        self.assertEqual('ORTHANC', DoGet(_LOCAL, '/instances/%s/metadata/RemoteAET' % c['ID']))

        DropOrthanc(_REMOTE)        
        DropOrthanc(_LOCAL)        

        InstallLuaScriptFromPath(_REMOTE, 'Lua/AutoroutingChangeAet.lua')
        DoPost(_REMOTE, '/tools/execute-script', 'aet = "HELLO"', 'application/lua')

        self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')
        WaitEmpty(_REMOTE)
        self.assertEqual(1, len(DoGet(_LOCAL, '/instances')))
        self.assertEqual('HELLO', DoGet(_LOCAL, '/instances/%s/metadata/RemoteAET' % a['ID']))

        DoPost(_REMOTE, '/tools/execute-script', 'aet = nill', 'application/lua')
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0002.dcm')
        WaitEmpty(_REMOTE)
        self.assertEqual(2, len(DoGet(_LOCAL, '/instances')))
        self.assertEqual('ORTHANC', DoGet(_LOCAL, '/instances/%s/metadata/RemoteAET' % b['ID']))

    def test_resources_since_limit(self):
        # Upload 16 instances
        for i in range(4):
            UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-000%d.dcm' % (i + 1))
            UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-000%d.dcm' % (i + 1))
            UploadInstance(_REMOTE, 'Knee/T1/IM-0001-000%d.dcm' % (i + 1))
            UploadInstance(_REMOTE, 'Knee/T2/IM-0001-000%d.dcm' % (i + 1))

        self.assertEqual(2, len(DoGet(_REMOTE, '/patients')))
        self.assertEqual(2, len(DoGet(_REMOTE, '/studies')))
        self.assertEqual(4, len(DoGet(_REMOTE, '/series')))
        self.assertEqual(16, len(DoGet(_REMOTE, '/instances')))

        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/patients&since=10' % i))
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/patients&limit=10' % i))

        if IsOrthancVersionAbove(_REMOTE, 1, 12, 5): # with ExtendedFind, the limit=0 means no-limit like in /tools/find
            self.assertEqual(2, len(DoGet(_REMOTE, '/patients?since=0&limit=0')))
            self.assertEqual(1, len(DoGet(_REMOTE, '/patients?since=1&limit=0')))
            self.assertEqual(0, len(DoGet(_REMOTE, '/patients?since=2&limit=0')))
            self.assertEqual(0, len(DoGet(_REMOTE, '/patients?since=3&limit=0')))
        else:
            self.assertEqual(0, len(DoGet(_REMOTE, '/patients?since=0&limit=0')))
            self.assertEqual(0, len(DoGet(_REMOTE, '/patients?since=1&limit=0')))
            self.assertEqual(0, len(DoGet(_REMOTE, '/patients?since=2&limit=0')))
            self.assertEqual(0, len(DoGet(_REMOTE, '/patients?since=3&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')))
        self.assertEqual(16, len(DoGet(_REMOTE, '/instances?since=0&limit=100')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/patients?since=1&limit=100')))
        self.assertEqual(0, len(DoGet(_REMOTE, '/patients?since=2&limit=100')))
        self.assertEqual(0, len(DoGet(_REMOTE, '/patients?since=3&limit=100')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/studies?since=1&limit=100')))
        self.assertEqual(3, len(DoGet(_REMOTE, '/series?since=1&limit=100')))
        self.assertEqual(2, len(DoGet(_REMOTE, '/series?since=2&limit=100')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/series?since=3&limit=100')))
        self.assertEqual(0, len(DoGet(_REMOTE, '/series?since=4&limit=100')))
        self.assertEqual(0, len(DoGet(_REMOTE, '/series?since=100&limit=100')))
        self.assertEqual(15, len(DoGet(_REMOTE, '/instances?since=1&limit=100')))

        for limit in [ 1, 2, 3, 7, 16, 17 ]:
            s = {}
            since = 0
            while True:
                t = DoGet(_REMOTE, '/instances?since=%d&limit=%d' % (since, limit))
                if len(t) == 0:
                    break

                since += len(t)
                for i in t:
                    s[i] = None

            self.assertEqual(16, len(s))
            for instance in DoGet(_REMOTE, '/instances'):
                self.assertTrue(instance in s)


    def test_create_pdf(self):
        # Upload 4 instances
        brainixInstance = UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm')['ID']
        UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Knee/T2/IM-0001-0001.dcm')
        
        brainixPatient = '16738bc3-e47ed42a-43ce044c-a3414a45-cb069bd0'
        brainixStudy = '27f7126f-4f66fb14-03f4081b-f9341db2-53925988'
        brainixEpi = '2ac1316d-3e432022-62eabff2-c59f5475-9b1ac3f8'

        with open(GetDatabasePath('HelloWorld.pdf'), 'rb') as f:
            pdf = f.read()

        i = DoPost(_REMOTE, '/tools/create-dicom',
                   json.dumps({
                       'Tags' : {
                           'PatientName' : 'Jodogne',
                           'Modality' : 'CT',
                       },
                       'Content' : 'data:application/pdf;base64,' + base64.b64encode(pdf)
                   }))

        self.assertEqual('Jodogne', DoGet(_REMOTE, '/instances/%s/content/PatientName' % i['ID']).strip())
        self.assertEqual('1.2.840.10008.5.1.4.1.1.104.1', DoGet(_REMOTE, '/instances/%s/content/SOPClassUID' % i['ID']).strip('\x00'))
        self.assertEqual('WSD', DoGet(_REMOTE, '/instances/%s/content/ConversionType' % i['ID']).strip())
        self.assertEqual('application/pdf', DoGet(_REMOTE, '/instances/%s/content/MIMETypeOfEncapsulatedDocument' % i['ID']).strip())

        # In Orthanc <= 1.9.7, the "CT" would have been replaced by "OT"
        # https://groups.google.com/g/orthanc-users/c/eNSddNrQDtM/m/wc1HahimAAAJ
        self.assertEqual('CT', DoGet(_REMOTE, '/instances/%s/content/Modality' % i['ID']).strip())

        b = DoGet(_REMOTE, '/instances/%s/content/0042-0011' % i['ID'])
        self.assertEqual(len(b), len(pdf) + 1)
        self.assertEqual(ComputeMD5(b), ComputeMD5(pdf + '\0'))

        self.assertRaises(Exception, lambda: DoPost(_REMOTE, '/tools/create-dicom',
                                                    json.dumps({
                                                        'Parent' : brainixPatient,
                                                        'Tags' : {
                                                            'PatientName' : 'Jodogne',
                                                        }
                                                    })))

        i = DoPost(_REMOTE, '/tools/create-dicom',
                   json.dumps({
                       'Parent' : brainixPatient,
                       'Tags' : { 'StudyDescription' : 'PDF^Patient' },
                       'Content' : 'data:application/pdf;base64,' + base64.b64encode(pdf)
                   }))
        
        self.assertEqual(brainixPatient, DoGet(_REMOTE, '/instances/%s/patient' % i['ID'])['ID'])
        self.assertEqual('1.2.840.10008.5.1.4.1.1.104.1', DoGet(_REMOTE, '/instances/%s/content/SOPClassUID' % i['ID']).strip('\x00'))
        self.assertEqual('OT', DoGet(_REMOTE, '/instances/%s/content/Modality' % i['ID']).strip('\x00'))
        self.assertEqual('WSD', DoGet(_REMOTE, '/instances/%s/content/ConversionType' % i['ID']).strip())
        self.assertEqual('application/pdf', DoGet(_REMOTE, '/instances/%s/content/MIMETypeOfEncapsulatedDocument' % i['ID']).strip())

        i = DoPost(_REMOTE, '/tools/create-dicom',
                   json.dumps({
                       'Parent' : brainixStudy,
                       'Tags' : { 'SeriesDescription' : 'PDF^Study' },
                       'Content' : 'data:application/pdf;base64,' + base64.b64encode(pdf)
                   }))
        
        self.assertEqual(brainixStudy, DoGet(_REMOTE, '/instances/%s/study' % i['ID'])['ID'])

        i = DoPost(_REMOTE, '/tools/create-dicom',
                   json.dumps({
                       'Parent' : brainixEpi,
                       'Tags' : { 'SpecificCharacterSet' : 'ISO_IR 13' },
                       'Content' : 'data:application/pdf;base64,' + base64.b64encode(pdf)
                   }))
        
        self.assertEqual(brainixEpi, DoGet(_REMOTE, '/instances/%s/series' % i['ID'])['ID'])

        b = DoGet(_REMOTE, '/instances/%s/pdf' % i['ID'])
        self.assertEqual(len(b), len(pdf))
        self.assertEqual(ComputeMD5(b), ComputeMD5(pdf))

        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/pdf' % brainixInstance))


    def test_create_series(self):
        i = DoPost(_REMOTE, '/tools/create-dicom',
                   json.dumps({
                    'Tags' : {
                        'SpecificCharacterSet' : 'ISO_IR 100',
                        'PatientName' : 'Sébastien Jodogne',
                        'Modality' : 'CT',
                        },
                    'Content' : [
                        {
                            'Content': 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==', # red dot in RGBA
                            'Tags' : { 'ImageComments' : 'Tutu' }
                            },
                        'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAoUlEQVQ4jZ2SWw3EIBREjwUsYAELa2EtoAULFUCyXAtroRZqoRbox254BdLC/DZnZjoXWJFgCDg8egW2CBEhEnDzyRk+Ecxz2KP/0AL8S99T+jQccAVs22qKwAuPuq0uyNg9cPLh3am+pe/dkHLZtqJHj6vXJrZ7nvzvxxgemXgUwnGfXqpee09mUwp8m022OYP6bLF7mVuVe0y/umxinsAXRd9z0k1ubWsAAAAASUVORK5CYII=',
                        ]
                    }))

        s = DoGet(_REMOTE, i['Path'])
        self.assertEqual('Series', s['Type'])
        self.assertEqual(s['ID'], i['ID'])
        self.assertEqual(2, len(s['Instances']))
        self.assertEqual(2, s['ExpectedNumberOfInstances'])
        self.assertEqual('Complete', s['Status'])

        a = DoGet(_REMOTE, '/instances/%s/tags?simplify' % s['Instances'][0])
        b = DoGet(_REMOTE, '/instances/%s/tags?simplify' % s['Instances'][1])
        self.assertTrue('ImageComments' in a or 'ImageComments' in b)
        if 'ImageComments' in a:
            self.assertEqual('Tutu', a['ImageComments'])
        else:
            self.assertEqual('Tutu', b['ImageComments'])

        patient = DoGet(_REMOTE, '/instances/%s/patient' % s['Instances'][0])
        self.assertEqual(patient['MainDicomTags']['PatientName'].encode('utf-8'),
                         'Sébastien Jodogne')


    def test_create_binary(self):
        binary = ''.join(map(chr, range(256)))
        encoded = 'data:application/octet-stream;base64,' + base64.b64encode(binary)
        tags = {
            'PatientName' : 'Jodogne',
            '8899-8899' : encoded
        }

        i = DoPost(_REMOTE, '/tools/create-dicom',
                   json.dumps({
                       'Tags' : tags,
                       'PrivateCreator' : 'TestBinary',
                   }))

        self.assertEqual('Jodogne', DoGet(_REMOTE, '/instances/%s/content/PatientName' % i['ID']).strip())
        self.assertEqual(binary, DoGet(_REMOTE, '/instances/%s/content/8899-8899' % i['ID']).strip())

        i = DoPost(_REMOTE, '/tools/create-dicom',
                   json.dumps({
                       'InterpretBinaryTags' : False,
                       'Tags' : tags,
                       'PrivateCreator' : 'TestBinary',
                   }))

        self.assertEqual('Jodogne', DoGet(_REMOTE, '/instances/%s/content/PatientName' % i['ID']).strip())
        self.assertEqual(encoded, DoGet(_REMOTE, '/instances/%s/content/8899-8899' % i['ID'])[0:-1])


    def test_patient_ids_collision(self):
        # Upload 3 instances from 3 different studies, but with the
        # same PatientID
        for i in range(3):
            UploadInstance(_REMOTE, 'PatientIdsCollision/Image%d.dcm' % (i + 1))

        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Patient',
                                             'Query' : { 'PatientName' : '*' }})
        self.assertEqual(1, len(a))
        self.assertEqual('COMMON', DoGet(_REMOTE, '/patients/%s' % a[0]) ['MainDicomTags']['PatientID'])

        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Study',
                                             'CaseSensitive' : True,
                                             'Query' : { 'PatientName' : 'FOO\\HELLO' }})
        self.assertEqual(2, len(a))

        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
                                             'CaseSensitive' : False,
                                             'Query' : { 'PatientName' : 'Foo' }})
        self.assertEqual(1, len(a))
        self.assertEqual('FOO^SERIES', DoGet(_REMOTE, '/series/%s/study' % a[0]) ['MainDicomTags']['StudyDescription'])
        self.assertEqual('FOO', DoGet(_REMOTE, '/series/%s/study' % a[0]) ['PatientMainDicomTags']['PatientName'])
        self.assertEqual('COMMON', DoGet(_REMOTE, '/series/%s/study' % a[0]) ['PatientMainDicomTags']['PatientID'])

        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Study',
                                             'Query' : { 'PatientName' : '*' }})
        self.assertEqual(3, len(a))
        d = map(lambda x: DoGet(_REMOTE, '/studies/%s' % x) ['MainDicomTags']['StudyDescription'], a)
        self.assertTrue('FOO^SERIES' in d)
        self.assertTrue('HELLO^SERIES' in d)
        self.assertTrue('WORLD^SERIES' in d)

        d = map(lambda x: DoGet(_REMOTE, '/studies/%s' % x) ['PatientMainDicomTags']['PatientID'], a)
        self.assertEqual(1, len(set(d)))
        self.assertEqual('COMMON', d[0])

        for i in a:
            d = DoGet(_REMOTE, '/studies/%s' % i) ['MainDicomTags']['StudyDescription']
            p = DoGet(_REMOTE, '/studies/%s' % i) ['PatientMainDicomTags']['PatientName']
            self.assertEqual('%s^SERIES' % p, d)


    def test_bitbucket_issue_4(self):
        UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-0002.dcm')
        UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-0003.dcm')
        UploadInstance(_REMOTE, 'Formats/Jpeg.dcm')
        UploadInstance(_REMOTE, 'Formats/JpegLossless.dcm')
        UploadInstance(_REMOTE, 'Formats/Rle.dcm')
       
        self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))
        self.assertEqual(6, len(DoGet(_REMOTE, '/instances')))

        p = DoGet(_REMOTE, '/patients')
        self.assertEqual(2, len(p))
        i1 = map(lambda x: x['ID'], DoGet(_REMOTE, '/patients/%s/instances' % p[0]))
        i2 = map(lambda x: x['ID'], DoGet(_REMOTE, '/patients/%s/instances' % p[1]))
        self.assertEqual(3, len(i1))
        self.assertEqual(3, len(i2))

        j = DoPost(_REMOTE, '/modalities/orthanctest/store', i2[0:1] + i1 + i2[1:3])

        self.assertEqual(6, len(DoGet(_LOCAL, '/instances')))


    def test_create_sequence(self):
        i = DoPost(_REMOTE, '/tools/create-dicom',
                   json.dumps({
                    'Tags' : {
                        'SpecificCharacterSet': 'ISO_IR 100',  # Encode using Latin1
                        'PatientName': 'Jodogne^',
                        'ReferencedStudySequence': GenerateTestSequence(),
                    }
                   }))['ID']

        self.assertEqual('Jodogne^', DoGet(_REMOTE, '/instances/%s/content/PatientName' % i))
        self.assertEqual('Hello^', DoGet(_REMOTE, '/instances/%s/content/ReferencedStudySequence/0/StudyDescription' % i))
        self.assertEqual('Toto', DoGet(_REMOTE, '/instances/%s/content/ReferencedStudySequence/0/ReferencedStudySequence/0/StudyDescription' % i))
        self.assertEqual('Tata', DoGet(_REMOTE, '/instances/%s/content/ReferencedStudySequence/0/ReferencedStudySequence/1/StudyDescription' % i))
        self.assertEqual(u'Sébastien^'.encode('latin-1'),
                         DoGet(_REMOTE, '/instances/%s/content/ReferencedStudySequence/1/StudyDescription' % i))


    def test_modify_sequence(self):
        i = UploadInstance(_REMOTE, 'PrivateTags.dcm')['ID']
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/content/ReferencedStudySequence' % i))

        j = DoPost(_REMOTE, '/instances/%s/modify' % i,
                   json.dumps({
                    "Replace" : {
                        "PatientName" : "hello",
                        'ReferencedStudySequence': GenerateTestSequence(),
                        },
                    }),
                   'application/json')
        j = DoPost(_REMOTE, '/instances', j, 'application/dicom')['ID']
        DoDelete(_REMOTE, '/instances/%s' % i)

        self.assertEqual(2, len(DoGet(_REMOTE, '/instances/%s/content/ReferencedStudySequence' % j)))


    def test_compression(self):
        i = UploadInstance(_REMOTE, 'DummyCT.dcm')['ID']

        aa = DoGet(_REMOTE, '/instances/%s/attachments/' % i)
        if IsOrthancVersionAbove(_REMOTE, 1, 9, 1):
            # This file has *no* pixel data, so "dicom-until-pixel-data" is not stored
            self.assertEqual(1, len(aa))
            self.assertTrue('dicom' in aa)
        else:
            self.assertEqual(2, len(aa))
            self.assertTrue('dicom' in aa)
            self.assertTrue('dicom-as-json' in aa)
            
        data = DoGetRaw(_REMOTE, '/instances/%s/attachments/dicom/data' % i)[1]

        # If "StorageCompression" is enabled in the Orthanc to be
        # tested, uncompress the attachment before running the test
        if DoGet(_REMOTE, '/instances/%s/attachments/dicom/is-compressed' % i) != 0:
            DoPost(_REMOTE, '/instances/%s/attachments/dicom/uncompress' % i)
 
        cs = int(DoGet(_REMOTE, '/statistics')['TotalDiskSize'])
        us = int(DoGet(_REMOTE, '/statistics')['TotalUncompressedSize'])
        size = int(DoGet(_REMOTE, '/instances/%s/attachments/dicom/size' % i))
        md5 = DoGet(_REMOTE, '/instances/%s/attachments/dicom/md5' % i)
        self.assertEqual(data, DoGetRaw(_REMOTE, '/instances/%s/attachments/dicom/compressed-data' % i)[1])
        self.assertEqual(md5, DoGet(_REMOTE, '/instances/%s/attachments/dicom/compressed-md5' % i))
        self.assertEqual(size, int(DoGet(_REMOTE, '/instances/%s/attachments/dicom/compressed-size' % i)))

        ops = DoGet(_REMOTE, '/instances/%s/attachments/dicom' % i)
        self.assertTrue('compress' in ops)
        self.assertTrue('uncompress' in ops)
        self.assertTrue('is-compressed' in ops)
        self.assertEqual(0, DoGet(_REMOTE, '/instances/%s/attachments/dicom/is-compressed' % i))
        DoPost(_REMOTE, '/instances/%s/attachments/dicom/verify-md5' % i)

        # Re-compress the attachment
        DoPost(_REMOTE, '/instances/%s/attachments/dicom/compress' % i)
        DoPost(_REMOTE, '/instances/%s/attachments/dicom/verify-md5' % i)
        self.assertEqual(1, DoGet(_REMOTE, '/instances/%s/attachments/dicom/is-compressed' % i))
        self.assertGreater(cs, int(DoGet(_REMOTE, '/statistics')['TotalDiskSize']))
        self.assertEqual(us, int(DoGet(_REMOTE, '/statistics')['TotalUncompressedSize']))
        self.assertGreater(len(data), len(DoGetRaw(_REMOTE, '/instances/%s/attachments/dicom/compressed-data' % i)[1]))
        self.assertGreater(size, int(DoGet(_REMOTE, '/instances/%s/attachments/dicom/compressed-size' % i)))
        self.assertEqual(size, int(DoGet(_REMOTE, '/instances/%s/attachments/dicom/size' % i)))
        self.assertEqual(md5, DoGet(_REMOTE, '/instances/%s/attachments/dicom/md5' % i))
        self.assertNotEqual(md5, DoGet(_REMOTE, '/instances/%s/attachments/dicom/compressed-md5' % i))

        DoPost(_REMOTE, '/instances/%s/attachments/dicom/uncompress' % i)
        DoPost(_REMOTE, '/instances/%s/attachments/dicom/verify-md5' % i)
        self.assertEqual(0, DoGet(_REMOTE, '/instances/%s/attachments/dicom/is-compressed' % i))
        self.assertEqual(data, DoGetRaw(_REMOTE, '/instances/%s/attachments/dicom/compressed-data' % i)[1])
        self.assertEqual(size, int(DoGet(_REMOTE, '/instances/%s/attachments/dicom/compressed-size' % i)))
        self.assertEqual(size, int(DoGet(_REMOTE, '/instances/%s/attachments/dicom/size' % i)))
        self.assertEqual(md5, DoGet(_REMOTE, '/instances/%s/attachments/dicom/md5' % i))
        self.assertEqual(md5, DoGet(_REMOTE, '/instances/%s/attachments/dicom/compressed-md5' % i))
        self.assertEqual(cs, int(DoGet(_REMOTE, '/statistics')['TotalDiskSize']))
        self.assertEqual(us, int(DoGet(_REMOTE, '/statistics')['TotalUncompressedSize']))


    def test_ordered_slices(self):
        i = UploadInstance(_REMOTE, 'Multiframe.dcm')['ID']
        s = DoGet(_REMOTE, '/instances/%s' % i)['ParentSeries']
        o = DoGet(_REMOTE, '/series/%s/ordered-slices' % s)
        self.assertEqual('Sequence', o['Type'])
        self.assertEqual(1, len(o['Dicom']))
        self.assertEqual('/instances/9e05eb0a-18b6268c-e0d36085-8ddab517-3b5aec02/file', o['Dicom'][0])
        self.assertEqual(76, len(o['Slices']))
        for j in range(76):
            self.assertEqual('/instances/9e05eb0a-18b6268c-e0d36085-8ddab517-3b5aec02/frames/%d' % j, o['Slices'][j])

        self.assertEqual(1, len(o['SlicesShort']))
        self.assertEqual('9e05eb0a-18b6268c-e0d36085-8ddab517-3b5aec02', o['SlicesShort'][0][0])
        self.assertEqual(0, o['SlicesShort'][0][1])
        self.assertEqual(76, o['SlicesShort'][0][2])

        i = UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-0001.dcm')['ID']
        j = UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-0002.dcm')['ID']
        k = UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-0003.dcm')['ID']
        s = DoGet(_REMOTE, '/instances/%s' % i)['ParentSeries']
        o = DoGet(_REMOTE, '/series/%s/ordered-slices' % s)

        self.assertEqual('Volume', o['Type'])
        self.assertEqual(3, len(o['Dicom']))
        self.assertEqual(3, len(o['Slices']))
        self.assertEqual('/instances/%s/file' % i, o['Dicom'][2])
        self.assertEqual('/instances/%s/file' % j, o['Dicom'][1])
        self.assertEqual('/instances/%s/file' % k, o['Dicom'][0])
        self.assertEqual('/instances/%s/frames/0' % i, o['Slices'][2])
        self.assertEqual('/instances/%s/frames/0' % j, o['Slices'][1])
        self.assertEqual('/instances/%s/frames/0' % k, o['Slices'][0])

        self.assertEqual(3, len(o['SlicesShort']))
        self.assertEqual(k, o['SlicesShort'][0][0])
        self.assertEqual(0, o['SlicesShort'][0][1])
        self.assertEqual(1, o['SlicesShort'][0][2])
        self.assertEqual(j, o['SlicesShort'][1][0])
        self.assertEqual(0, o['SlicesShort'][1][1])
        self.assertEqual(1, o['SlicesShort'][1][2])
        self.assertEqual(i, o['SlicesShort'][2][0])
        self.assertEqual(0, o['SlicesShort'][2][1])
        self.assertEqual(1, o['SlicesShort'][2][2])

        i = UploadInstance(_REMOTE, 'Beaufix/IM-0001-0001.dcm')['ID']
        j = UploadInstance(_REMOTE, 'Beaufix/IM-0001-0002.dcm')['ID']
        s = DoGet(_REMOTE, '/instances/%s' % i)['ParentSeries']
        o = DoGet(_REMOTE, '/series/%s/ordered-slices' % s)

        self.assertEqual('Sequence', o['Type'])
        self.assertEqual(2, len(o['Dicom']))
        self.assertEqual(2, len(o['Slices']))
        self.assertEqual('/instances/%s/file' % i, o['Dicom'][0])
        self.assertEqual('/instances/%s/file' % j, o['Dicom'][1])
        self.assertEqual('/instances/%s/frames/0' % i, o['Slices'][0])
        self.assertEqual('/instances/%s/frames/0' % j, o['Slices'][1])

        self.assertEqual(2, len(o['SlicesShort']))
        self.assertEqual(i, o['SlicesShort'][0][0])
        self.assertEqual(0, o['SlicesShort'][0][1])
        self.assertEqual(1, o['SlicesShort'][0][2])
        self.assertEqual(j, o['SlicesShort'][1][0])
        self.assertEqual(0, o['SlicesShort'][1][1])
        self.assertEqual(1, o['SlicesShort'][1][2])



    def test_incoming_movescu_accession(self):
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')
        
        # No matching patient, so no job is created
        self.assertEqual(0, len(DoGet(_LOCAL, '/patients')))
        CallMoveScu([ '--study', '-k', '0008,0052=STUDY', '-k', 'AccessionNumber=nope' ])
        self.assertEqual(0, len(DoGet(_LOCAL, '/patients')))
        CallMoveScu([ '--study', '-k', '0008,0052=PATIENT', '-k', 'AccessionNumber=A10003245599' ])
        self.assertEqual(0, len(DoGet(_LOCAL, '/patients')))

        # 1 Matching patient, track the job
        self.assertTrue(MonitorJob(_REMOTE, lambda: CallMoveScu([
            '--study',
            '-k', '0008,0052=STUDY',
            '-k', 'AccessionNumber=A10003245599'
        ])))
        
        self.assertEqual(1, len(DoGet(_LOCAL, '/patients')))


    def test_dicom_to_json(self):
        i = UploadInstance(_REMOTE, 'PrivateMDNTags.dcm')['ID']
        j = UploadInstance(_REMOTE, 'PrivateTags.dcm')['ID']

        t = DoGet(_REMOTE, '/instances/%s/tags?simplify' % i)
        with open(GetDatabasePath('PrivateMDNTagsSimplify.json'), 'r') as f:
            self.assertTrue(CompareTags(t, json.loads(f.read()), [
                # Tags for compatibility with DCMTK 3.6.0
                'RETIRED_OtherPatientIDs',
                'OtherPatientIDs',
                'ACR_NEMA_2C_VariablePixelDataGroupLength',
            ]))

        t = DoGet(_REMOTE, '/instances/%s/tags' % i)
        with open(GetDatabasePath('PrivateMDNTagsFull.json'), 'r') as f:
            self.assertTrue(CompareTags(t, json.loads(f.read()), [ 
            ]))

        t = DoGet(_REMOTE, '/instances/%s/tags?simplify' % j)
        with open(GetDatabasePath('PrivateTagsSimplify.json'), 'r') as f:
            self.assertTrue(CompareTags(t, json.loads(f.read()), [
            ]))


        # NB: To get the actual value of the "tags" JSON file, use the
        # following command:
        # $ curl http://alice:orthanctest@localhost:8042/instances/d29ead49-43e8601d-72f1e922-7de676ee-ea77c2b4/tags
        t = DoGet(_REMOTE, '/instances/%s/tags' % j)
        with open(GetDatabasePath('PrivateTagsFull.json'), 'r') as f:
            a = json.loads(f.read())

            # Starting with Orthanc 1.9.1, the DICOM-as-JSON
            # reports are truncated starting with PixelData
            if IsOrthancVersionAbove(_REMOTE, 1, 9, 1):
                self.assertFalse('7fe1,0010' in t)
                self.assertFalse('7fe1,1001' in t)
                del a['7fe1,0010']
                del a['7fe1,1001']
            else:
                self.assertTrue('7fe1,0010' in t)
                self.assertTrue('7fe1,1001' in t)
                
            aa = json.dumps(a).replace('2e+022', '2e+22')
            tt = (json.dumps(t)
                  .replace('2e+022', '2e+22')
                  # The "IllegalPrivatePixelSequence" tag was introduced in DCMTK 3.6.6 dictionary
                  .replace('IllegalPrivatePixelSequence', 'Unknown Tag & Data'))
            self.assertEqual(aa, tt)


    def test_batch_archive(self):
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0002.dcm')
        UploadInstance(_REMOTE, 'Knee/T2/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Knee/T2/IM-0001-0002.dcm')
        UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-0002.dcm')
        UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0002.dcm')

        s = DoPost(_REMOTE, '/tools/create-archive', [ ])
        z = zipfile.ZipFile(StringIO(s), "r")
        self.assertEqual(0, len(z.namelist()))
       
        # One patient
        s = DoPost(_REMOTE, '/tools/create-archive', [ 'ca29faea-b6a0e17f-067743a1-8b778011-a48b2a17' ])
        z = zipfile.ZipFile(StringIO(s), "r")
        self.assertEqual(4, len(z.namelist()))

        # One patient + twice its study + one series from other patient
        s = DoPost(_REMOTE, '/tools/create-archive', [ 
            'ca29faea-b6a0e17f-067743a1-8b778011-a48b2a17',
            '0a9b3153-2512774b-2d9580de-1fc3dcf6-3bd83918',
            '1e2c125c-411b8e86-3f4fe68e-a7584dd3-c6da78f0'
        ])
        z = zipfile.ZipFile(StringIO(s), "r")
        self.assertEqual(6, len(z.namelist()))        

        # One patient + one series + one instance
        s = DoPost(_REMOTE, '/tools/create-archive', [ 
            'ca29faea-b6a0e17f-067743a1-8b778011-a48b2a17',
            '1e2c125c-411b8e86-3f4fe68e-a7584dd3-c6da78f0',
            '1d429ccb-bdcc78a1-7d129d6a-ba4966ed-fe4dbd87'
        ])
        z = zipfile.ZipFile(StringIO(s), "r")
        self.assertEqual(7, len(z.namelist()))

        if IsOrthancVersionAbove(_REMOTE, 1, 12, 2):
            s = DoGet(_REMOTE, '/tools/create-archive?resources=ca29faea-b6a0e17f-067743a1-8b778011-a48b2a17,1e2c125c-411b8e86-3f4fe68e-a7584dd3-c6da78f0,1d429ccb-bdcc78a1-7d129d6a-ba4966ed-fe4dbd87')
            z = zipfile.ZipFile(StringIO(s), "r")
            self.assertEqual(7, len(z.namelist()))



    def test_decode_brainix_as_jpeg(self):
        i = UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-0001.dcm')['ID']

        j = GetImage(_REMOTE, '/instances/%s/preview' % i)
        self.assertEqual('PNG', j.format)
        self.assertEqual(j.size[0], 256)
        self.assertEqual(j.size[1], 256)

        j = GetImage(_REMOTE, '/instances/%s/preview' % i, headers = { 'Accept' : '*/*' })
        self.assertEqual('PNG', j.format)

        j = GetImage(_REMOTE, '/instances/%s/preview' % i, headers = { 'Accept' : 'image/*' })
        self.assertEqual('PNG', j.format)

        j = GetImage(_REMOTE, '/instances/%s/preview' % i, headers = { 'Accept' : 'image/png' })
        self.assertEqual('PNG', j.format)

        j = GetImage(_REMOTE, '/instances/%s/preview' % i, headers = { 'Accept' : 'image/jpeg' })
        self.assertEqual('JPEG', j.format)
        self.assertEqual(j.size[0], 256)
        self.assertEqual(j.size[1], 256)

        a = len(DoGet(_REMOTE, '/instances/%s/preview?quality=50' % i, headers = { 'Accept' : 'image/jpeg' }))
        b = len(DoGet(_REMOTE, '/instances/%s/preview' % i, headers = { 'Accept' : 'image/jpeg' }))
        self.assertLess(a, b)

        j = GetImage(_REMOTE, '/instances/%s/image-uint8' % i, headers = { 'Accept' : 'image/jpeg' })
        self.assertEqual('JPEG', j.format)

        # 16bit encoding is not supported with JPEG
        self.assertRaises(Exception, lambda: GetImage(_REMOTE, '/instances/%s/image-uint16' % i, headers = { 'Accept' : 'image/jpeg' }))
        self.assertRaises(Exception, lambda: GetImage(_REMOTE, '/instances/%s/image-int16' % i, headers = { 'Accept' : 'image/jpeg' }))

        # No matching content type
        self.assertRaises(Exception, lambda: GetImage(_REMOTE, '/instances/%s/preview' % i, headers = { 'Accept' : 'application/pdf' }))



    def test_media_encodings(self):
        ascii = UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')['ID']
        latin1 = UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-0001.dcm')['ID']
        latin2 = UploadInstance(_REMOTE, 'MarekLatin2.dcm')['ID']

        tmp = DoPost(_REMOTE, '/tools/create-media', [ascii,latin1,latin2])
        z = zipfile.ZipFile(StringIO(tmp), "r")

        self.assertEqual(4, len(z.namelist()))
        self.assertTrue('IMAGES/IM0' in z.namelist())
        self.assertTrue('IMAGES/IM1' in z.namelist())
        self.assertTrue('IMAGES/IM2' in z.namelist())
        self.assertTrue('DICOMDIR' in z.namelist())

        try:
            os.remove('/tmp/DICOMDIR')
        except:
            # The file does not exist
            pass

        z.extract('DICOMDIR', '/tmp')
        a = subprocess.check_output([ FindExecutable('dciodvfy'), '/tmp/DICOMDIR' ],
                                    stderr = subprocess.STDOUT).split('\n')

        a = subprocess.check_output([ FindExecutable('dcentvfy'), '/tmp/DICOMDIR' ],
                                    stderr = subprocess.STDOUT).split('\n')
        self.assertEqual(1, len(a))
        self.assertEqual('', a[0])

        a = subprocess.check_output([ FindExecutable('dcm2xml'), '/tmp/DICOMDIR' ])
        self.assertTrue(re.search('1.3.46.670589.11.17521.5.0.3124.2008081908590448738', a) != None)
        self.assertTrue(re.search('1.3.46.670589.11.0.0.11.4.2.0.8743.5.5396.2006120114333648576', a) != None)
        self.assertTrue(re.search('1.2.826.0.1.3680043.2.1569.1.4.323026757.1700.1399452091.57', a) != None)

        os.remove('/tmp/DICOMDIR')


    def test_findscu_counters(self):
        UploadInstance(_REMOTE, 'Comunix/Ct/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Comunix/Pet/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Comunix/Pet/IM-0001-0002.dcm')

        i = CallFindScu([ '-k', '0008,0052=PATIENT', '-k', 'NumberOfPatientRelatedStudies' ])
        s = re.findall('\(0020,1200\).*?\[(.*?)\]', i)
        self.assertEqual(1, len(s))
        self.assertTrue('1 ' in s)

        i = CallFindScu([ '-k', '0008,0052=PATIENT', '-k', 'NumberOfPatientRelatedSeries' ])
        s = re.findall('\(0020,1202\).*?\[(.*?)\]', i)
        self.assertEqual(1, len(s))
        self.assertTrue('2 ' in s)

        i = CallFindScu([ '-k', '0008,0052=PATIENT', '-k', 'NumberOfPatientRelatedInstances' ])
        s = re.findall('\(0020,1204\).*?\[(.*?)\]', i)
        self.assertEqual(1, len(s))
        self.assertTrue('3 ' in s)

        i = CallFindScu([ '-k', '0008,0052=STUDY', '-k', 'NumberOfStudyRelatedSeries' ])
        s = re.findall('\(0020,1206\).*?\[(.*?)\]', i)
        self.assertEqual(1, len(s))
        self.assertTrue('2 ' in s)

        i = CallFindScu([ '-k', '0008,0052=STUDY', '-k', 'NumberOfStudyRelatedInstances' ])
        s = re.findall('\(0020,1208\).*?\[(.*?)\]', i)
        self.assertEqual(1, len(s))
        self.assertTrue('3 ' in s)

        i = CallFindScu([ '-k', '0008,0052=SERIES', '-k', 'NumberOfSeriesRelatedInstances' ])
        s = re.findall('\(0020,1209\).*?\[(.*?)\]', i)
        self.assertEqual(2, len(s))
        self.assertTrue('1 ' in s)
        self.assertTrue('2 ' in s)

        i = CallFindScu([ '-k', '0008,0052=STUDY', '-k', 'ModalitiesInStudy' ])
        s = re.findall('\(0008,0061\).*?\[(.*?)\]', i)
        self.assertEqual(1, len(s))
        t = map(lambda x: x.strip(), s[0].split('\\'))
        self.assertTrue('PT' in t)
        self.assertTrue('CT' in t)

        i = CallFindScu([ '-k', '0008,0052=STUDY', '-k', 'SOPClassesInStudy' ])
        s = re.findall('\(0008,0062\).*?\[(.*?)\]', i)
        self.assertEqual(1, len(s))
        t = map(lambda x: x.strip('\x00'), s[0].split('\\'))
        self.assertTrue('1.2.840.10008.5.1.4.1.1.2' in t)
        self.assertTrue('1.2.840.10008.5.1.4.1.1.128' in t)


    def test_decode_transfer_syntax(self):
        def Check(t, md5):
            i = UploadInstance(_REMOTE, 'TransferSyntaxes/%s.dcm' % t)['ID']

            if t != '1.2.840.10008.1.2':  # This file has no meta header
                transferSyntax = DoGet(_REMOTE, '/instances/%s/metadata/TransferSyntax' % i)
                self.assertEqual(t, transferSyntax)

            if md5 == None:
                self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/preview' % i))
            else:
                m = ComputeMD5(DoGet(_REMOTE, '/instances/%s/preview' % i))
                self.assertEqual(m, md5)

        Check('1.2.840.10008.1.2.1', 'fae08d5415c4c0cd2cdbae4522408631')
        Check('1.2.840.10008.1.2.2', 'f3d9784768b8feb54d6a50b6d5c37682')
        Check('1.2.840.10008.1.2.4.51', 'ccbe75909fe5c9f7361b48416a53fc41')
        Check('1.2.840.10008.1.2.4.57', '7bbefe11d976b1be4e568915c6a82fc3')
        Check('1.2.840.10008.1.2.4.70', '7132cfbc457305b04b59787030c785d2')
        Check('1.2.840.10008.1.2.5', '6ff51ae525d362e0d04f550a64075a0e')  # RLE, supported since Orthanc 1.0.1
        Check('1.2.840.10008.1.2', 'd54aed9f67a100984b42942cc2e9939b')

        # The 3 checks below don't work on big-endian
        Check('1.2.840.10008.1.2.4.50', '496326046974eea718dbc16b997c646b')  # TODO - Doesn't work with GDCM 3.0.7 alone
        Check('1.2.840.10008.1.2.4.80', '6ff51ae525d362e0d04f550a64075a0e')
        Check('1.2.840.10008.1.2.4.81', '801579ae7cbf28e604ea74f2c99fa2ca')

        # JPEG2k image, not supported without GDCM plugin
        if not HasGdcmPlugin(_REMOTE):
            Check('1.2.840.10008.1.2.4.90', None)
            Check('1.2.840.10008.1.2.4.91', None)


    def test_raw_frame(self):
        s = UploadInstance(_REMOTE, 'Issue22.dcm')['ID']
        self.assertEqual(24, len(DoGet(_REMOTE, '/instances/%s/frames' % s)))
        a = DoGet(_REMOTE, '/instances/%s/frames/0/raw' % s)
        self.assertEqual(512 * 512 * 2, len(a))
        self.assertEqual(512 * 512 * 2, len(DoGet(_REMOTE, '/instances/%s/frames/23/raw' % s)))
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/frames/24/raw' % s))
        self.assertEqual('1914287dc4d958eca21fdaacfb3482fa', ComputeMD5(a))

        s = UploadInstance(_REMOTE, 'Multiframe.dcm')['ID']
        self.assertEqual(76, len(DoGet(_REMOTE, '/instances/%s/frames' % s)))
        self.assertEqual(186274, len(DoGet(_REMOTE, '/instances/%s/frames/0/raw' % s)))
        self.assertEqual(189424, len(DoGet(_REMOTE, '/instances/%s/frames/75/raw' % s)))
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/frames/76/raw' % s))
        im = GetImage(_REMOTE, '/instances/%s/frames/0/raw' % s)
        self.assertEqual("L", im.mode)
        self.assertEqual(512, im.size[0])
        self.assertEqual(512, im.size[1])

        # Test an image with 2 JPEG frames spread over multiple fragments
        s = UploadInstance(_REMOTE, 'LenaTwiceWithFragments.dcm')['ID']
        self.assertEqual(2, len(DoGet(_REMOTE, '/instances/%s/frames' % s)))
        a = DoGet(_REMOTE, '/instances/%s/frames/0/raw' % s)
        b = DoGet(_REMOTE, '/instances/%s/frames/1/raw' % s)
        self.assertEqual(69214, len(a))
        self.assertEqual(ComputeMD5(a), ComputeMD5(b))
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/frames/2/raw' % s))
        im = GetImage(_REMOTE, '/instances/%s/frames/0/raw' % s)
        self.assertEqual("RGB", im.mode)
        self.assertEqual(512, im.size[0])
        self.assertEqual(512, im.size[1])
        im = GetImage(_REMOTE, '/instances/%s/frames/0/preview' % s)  # TODO - Doesn't work with GDCM 3.0.7 alone
        self.assertEqual("RGB", im.mode)
        self.assertEqual(512, im.size[0])
        self.assertEqual(512, im.size[1])



    def test_rest_movescu(self):
        self.assertEqual(0, len(DoGet(_REMOTE, '/patients')))

        # Upload 4 instances
        UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0002.dcm')
        UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Knee/T2/IM-0001-0001.dcm')

        self.assertEqual(2, len(DoGet(_REMOTE, '/patients')))
        for p in DoGet(_REMOTE, '/patients'):
            DoPost(_REMOTE, '/modalities/orthanctest/store', p)
            DoDelete(_REMOTE, '/patients/%s' % p)

        self.assertEqual(0, len(DoGet(_REMOTE, '/patients')))

        # Move instance Brainix/Flair/IM-0001-0001.dcm
        DoPost(_REMOTE, '/modalities/orthanctest/move', { 
            'Level' : 'Instance',
            'Resources' : [
                { 
                    'StudyInstanceUID' : '2.16.840.1.113669.632.20.1211.10000357775',
                    'SeriesInstanceUID' : '1.3.46.670589.11.0.0.11.4.2.0.8743.5.5396.2006120114285654497',
                    'SOPInstanceUID' : '1.3.46.670589.11.0.0.11.4.2.0.8743.5.5396.2006120114314079549',
                }
            ]})

        # Move series Brainix/Flair/*
        self.assertEqual(1, len(DoGet(_REMOTE, '/patients')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/studies')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/series')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))

        if IsOrthancVersionAbove(_REMOTE, 1, 11, 1):
            # Reset and test asynchronous C-Move at instance level
            for p in DoGet(_REMOTE, '/patients'):
                DoDelete(_REMOTE, '/patients/%s' % p)

            DoPost(_REMOTE, '/modalities/orthanctest/move', { 
                        'Level' : 'Instance',
                        'Resources' : [
                            { 
                                'StudyInstanceUID' : '2.16.840.1.113669.632.20.1211.10000357775',
                                'SeriesInstanceUID' : '1.3.46.670589.11.0.0.11.4.2.0.8743.5.5396.2006120114285654497',
                                'SOPInstanceUID' : '1.3.46.670589.11.0.0.11.4.2.0.8743.5.5396.2006120114314079549',
                            }
                        ],
                        'Asynchronous': True
                        })

            job = MonitorJob2(_REMOTE, lambda: DoPost
                            (_REMOTE, '/modalities/orthanctest/move', { 
                        'Level' : 'Instance',
                        'Resources' : [
                            { 
                                'StudyInstanceUID' : '2.16.840.1.113669.632.20.1211.10000357775',
                                'SeriesInstanceUID' : '1.3.46.670589.11.0.0.11.4.2.0.8743.5.5396.2006120114285654497',
                                'SOPInstanceUID' : '1.3.46.670589.11.0.0.11.4.2.0.8743.5.5396.2006120114314079549',
                            }
                        ],
                        'Asynchronous': True
                        }))

            self.assertNotEqual(None, job)

            # check the job was successfull
            self.assertEqual(1, len(DoGet(_REMOTE, '/patients')))
            self.assertEqual(1, len(DoGet(_REMOTE, '/studies')))
            self.assertEqual(1, len(DoGet(_REMOTE, '/series')))
            self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))

            # check the job content
            jobContent = DoGet(_REMOTE, '/jobs/%s' % job)['Content']
            self.assertEqual('2.16.840.1.113669.632.20.1211.10000357775', jobContent['Query'][0]['0020,000d'])
            self.assertEqual('1.3.46.670589.11.0.0.11.4.2.0.8743.5.5396.2006120114285654497', jobContent['Query'][0]['0020,000e'])
            self.assertEqual('1.3.46.670589.11.0.0.11.4.2.0.8743.5.5396.2006120114314079549', jobContent['Query'][0]['0008,0018'])

        # Reset and test synchronous C-Move at series level
        for p in DoGet(_REMOTE, '/patients'):
            DoDelete(_REMOTE, '/patients/%s' % p)

        DoPost(_REMOTE, '/modalities/orthanctest/move', { 'Level' : 'Series',
                                                          'Resources' : [
                    { 
                        'StudyInstanceUID' : '2.16.840.1.113669.632.20.1211.10000357775',
                        'SeriesInstanceUID' : '1.3.46.670589.11.0.0.11.4.2.0.8743.5.5396.2006120114285654497',
                        }
                    ]})
        self.assertEqual(1, len(DoGet(_REMOTE, '/patients')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/studies')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/series')))
        self.assertEqual(2, len(DoGet(_REMOTE, '/instances')))

        if IsOrthancVersionAbove(_REMOTE, 1, 11, 1):
            # Reset and test asynchronous C-Move at series level with additional PatientID filter
            for p in DoGet(_REMOTE, '/patients'):
                DoDelete(_REMOTE, '/patients/%s' % p)

            job = MonitorJob2(_REMOTE, lambda: DoPost
                            (_REMOTE, '/modalities/orthanctest/move', { 
                        'Level' : 'Series',
                        'Resources' : [
                            { 
                                'PatientID' : '5Yp0E',
                                'StudyInstanceUID' : '2.16.840.1.113669.632.20.1211.10000357775',
                                'SeriesInstanceUID' : '1.3.46.670589.11.0.0.11.4.2.0.8743.5.5396.2006120114285654497',
                            }
                        ],
                        'Asynchronous': True
                        }))

            self.assertNotEqual(None, job)

            # check the job was successfull
            self.assertEqual(1, len(DoGet(_REMOTE, '/patients')))
            self.assertEqual(1, len(DoGet(_REMOTE, '/studies')))
            self.assertEqual(1, len(DoGet(_REMOTE, '/series')))
            self.assertEqual(2, len(DoGet(_REMOTE, '/instances')))

            # check the job content
            jobContent = DoGet(_REMOTE, '/jobs/%s' % job)['Content']
            self.assertEqual('2.16.840.1.113669.632.20.1211.10000357775', jobContent['Query'][0]['0020,000d'])
            self.assertEqual('1.3.46.670589.11.0.0.11.4.2.0.8743.5.5396.2006120114285654497', jobContent['Query'][0]['0020,000e'])
            self.assertEqual('5Yp0E', jobContent['Query'][0]['0010,0020'])

        # Move series Brainix/Epi/*
        DoPost(_REMOTE, '/modalities/orthanctest/move', { 'Level' : 'Series',
                                                          'Resources' : [
                    { 
                        'StudyInstanceUID' : '2.16.840.1.113669.632.20.1211.10000357775',
                        'SeriesInstanceUID' : '1.3.46.670589.11.0.0.11.4.2.0.8743.5.5396.2006120114314125550',
                        }
                    ]})
        self.assertEqual(1, len(DoGet(_REMOTE, '/patients')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/studies')))
        self.assertEqual(2, len(DoGet(_REMOTE, '/series')))
        self.assertEqual(3, len(DoGet(_REMOTE, '/instances')))

        if IsOrthancVersionAbove(_REMOTE, 1, 11, 1):
            # Move study Knee asynchronously
            job = MonitorJob2(_REMOTE, lambda: DoPost
                            (_REMOTE, '/modalities/orthanctest/move', { 
                        'Level' : 'Study',
                        'Resources' : [
                            { 
                                'StudyInstanceUID' : '2.16.840.1.113669.632.20.121711.10000160881'
                            }
                        ],
                        'Asynchronous': True
                        }))

            self.assertNotEqual(None, job)

            # check the job was successfull
            self.assertEqual(2, len(DoGet(_REMOTE, '/patients')))
            self.assertEqual(2, len(DoGet(_REMOTE, '/studies')))
            self.assertEqual(3, len(DoGet(_REMOTE, '/series')))
            self.assertEqual(4, len(DoGet(_REMOTE, '/instances')))

            # check the job content
            jobContent = DoGet(_REMOTE, '/jobs/%s' % job)['Content']
            self.assertEqual('2.16.840.1.113669.632.20.121711.10000160881', jobContent['Query'][0]['0020,000d'])

        # Reset
        for p in DoGet(_REMOTE, '/patients'):
            DoDelete(_REMOTE, '/patients/%s' % p)

        self.assertEqual(0, len(DoGet(_REMOTE, '/patients')))


        if IsOrthancVersionAbove(_REMOTE, 1, 11, 1):
            # Move all at once asynchronously at PatientLevel
            job = MonitorJob2(_REMOTE, lambda: DoPost(_REMOTE, '/modalities/orthanctest/move', 
                { 
                    'Level' : 'Patient',
                    'Resources' : [
                        { 
                            'PatientID' : '5Yp0E',
                        },
                        { 
                            'PatientID': '887',
                            'PatientName' : 'KNEE',
                        }
                    ],
                    'Synchronous': False
                    }))

            self.assertEqual(2, len(DoGet(_REMOTE, '/patients')))
            self.assertEqual(2, len(DoGet(_REMOTE, '/studies')))
            self.assertEqual(3, len(DoGet(_REMOTE, '/series')))
            self.assertEqual(4, len(DoGet(_REMOTE, '/instances')))



    def test_reconstruct_json(self):
        self.assertEqual(0, len(DoGet(_REMOTE, '/patients')))

        instance = UploadInstance(_REMOTE, 'DummyCT.dcm')['ID']
        first = DoGet(_REMOTE, '/instances/%s/tags' % instance)

        self.assertEqual('TWINOW', first['0008,1010']['Value'])

        if IsOrthancVersionAbove(_REMOTE, 1, 9, 1):
            self.assertEqual(1, len(DoGet(_REMOTE, '/instances/%s/attachments' % instance)))
        else:
            self.assertEqual(2, len(DoGet(_REMOTE, '/instances/%s/attachments' % instance)))

        # Cannot delete the "DICOM" attachment
        self.assertRaises(Exception, lambda: DoDelete(_REMOTE, '/instances/%s/attachments/dicom' % instance))

        # Can delete the "DICOM as JSON" attachment
        if not IsOrthancVersionAbove(_REMOTE, 1, 9, 1):
            r = DoDelete(_REMOTE, '/instances/%s/attachments/dicom-as-json' % instance)
            self.assertTrue(type(r) is dict and len(r) == 0)

        # Only the "DICOM" attachment subsists
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances/%s/attachments' % instance)))

        # Cannot manually reconstruct the "DICOM as JSON" attachment
        self.assertRaises(Exception, lambda: DoPut(_REMOTE, '/patients/%s/attachments/dicom-as-json' % patient, 'hello'))

        # Transparently reconstruct the "DICOM as JSON" attachment
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/attachments/dicom-as-json' % instance))
        second = DoGet(_REMOTE, '/instances/%s/tags' % instance)
        self.assertEqual(str(first), str(second))

        if IsOrthancVersionAbove(_REMOTE, 1, 9, 1):
            self.assertEqual(1, len(DoGet(_REMOTE, '/instances/%s/attachments' % instance)))
        else:
            self.assertEqual(2, len(DoGet(_REMOTE, '/instances/%s/attachments' % instance)))
            third = DoGet(_REMOTE, '/instances/%s/attachments/dicom-as-json/data' % instance)
            self.assertEqual(str(first), str(third))


    def test_reconstruct_json2(self):
        self.assertEqual(0, len(DoGet(_REMOTE, '/patients')))

        a = UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm')['ID']
        b = UploadInstance(_REMOTE, 'Knee/T2/IM-0001-0001.dcm')['ID']

        self.assertEqual('BRAINIX', DoGet(_REMOTE, '/instances/%s/tags?simplify' % a)['PatientName'])
        self.assertEqual('KNEE', DoGet(_REMOTE, '/instances/%s/tags?simplify' % b)['PatientName'])

        aa = DoGet(_REMOTE, '/instances/%s/attachments' % a)
        bb = DoGet(_REMOTE, '/instances/%s/attachments' % b)

        if not IsOrthancVersionAbove(_REMOTE, 1, 9, 1):
            self.assertEqual(2, len(aa))
            self.assertEqual(aa, bb)
            self.assertTrue('dicom' in aa)
            self.assertTrue('dicom-as-json' in aa)
        elif IsDicomUntilPixelDataStored(_REMOTE):
            self.assertEqual(2, len(aa))
            self.assertEqual(aa, bb)
            self.assertTrue('dicom' in aa)
            self.assertTrue('dicom-until-pixel-data' in aa)
        else:
            self.assertEqual(1, len(aa))
            self.assertEqual(aa, bb)
            self.assertTrue('dicom' in aa)

        # In Orthanc <= 1.9.0, this call deletes "dicom-as-json"
        DoPost(_REMOTE, '/tools/invalidate-tags', '', 'text/plain')

        if IsOrthancVersionAbove(_REMOTE, 1, 9, 1) and IsDicomUntilPixelDataStored(_REMOTE):
            self.assertEqual(2, len(DoGet(_REMOTE, '/instances/%s/attachments' % a)))
            self.assertEqual(2, len(DoGet(_REMOTE, '/instances/%s/attachments' % b)))
        else:
            self.assertEqual(1, len(DoGet(_REMOTE, '/instances/%s/attachments' % a)))
            self.assertEqual(1, len(DoGet(_REMOTE, '/instances/%s/attachments' % b)))

        # In Orthanc <= 1.9.0, this call reconstructs "dicom-as-json"
        self.assertEqual('BRAINIX', DoGet(_REMOTE, '/instances/%s/tags?simplify' % a)['PatientName'])
        self.assertEqual('KNEE', DoGet(_REMOTE, '/instances/%s/tags?simplify' % b)['PatientName'])

        if IsOrthancVersionAbove(_REMOTE, 1, 9, 1) and not IsDicomUntilPixelDataStored(_REMOTE):
            self.assertEqual(1, len(DoGet(_REMOTE, '/instances/%s/attachments' % a)))
            self.assertEqual(1, len(DoGet(_REMOTE, '/instances/%s/attachments' % b)))
        else:
            self.assertEqual(2, len(DoGet(_REMOTE, '/instances/%s/attachments' % a)))
            self.assertEqual(2, len(DoGet(_REMOTE, '/instances/%s/attachments' % b)))


    def test_private_tags(self):
        i = UploadInstance(_REMOTE, 'PrivateMDNTags.dcm')['ID']
        t = DoGet(_REMOTE, '/instances/%s/tags?simplify' % i)
        self.assertEqual('1.2.840.113704.1.111.6320.1342451261.21', t['PET-CT Multi Modality Name'])
        self.assertEqual('p37s0_na_ctac.img', t['Original Image Filename'])


    def test_findscu_encoding(self):
        # Check out ../Database/Encodings/Generate.sh
        TEST = u'Test-éüäöòДΘĝדصķћ๛ネİ'
        ENCODINGS = {
            'Arabic' :   [ 'ISO_IR 127' ], 
            'Ascii' :    [ 'ISO_IR 6' ],   # More accurately, ISO 646
            'Cyrillic' : [ 'ISO_IR 144' ], 
            'Greek' :    [ 'ISO_IR 126' ], 
            'Hebrew' :   [ 'ISO_IR 138' ],
            'Japanese' : [ 'ISO_IR 13', 'shift-jis' ],
            'Latin1' :   [ 'ISO_IR 100' ],
            'Latin2' :   [ 'ISO_IR 101' ], 
            'Latin3' :   [ 'ISO_IR 109' ],
            'Latin4' :   [ 'ISO_IR 110' ], 
            'Latin5' :   [ 'ISO_IR 148' ], 
            'Thai' :     [ 'ISO_IR 166', 'tis-620' ],
            'Utf8' :     [ 'ISO_IR 192' ],
        }

        for name in ENCODINGS.iterkeys():
            if len(ENCODINGS[name]) == 1:
                ENCODINGS[name].append(name.lower())

        UploadInstance(_REMOTE, 'Encodings/Lena-utf8.dcm')
        
        for name in ENCODINGS.iterkeys():
            self.assertEqual(name, DoPut(_REMOTE, '/tools/default-encoding', name))
            self.assertEqual(name, DoGet(_REMOTE, '/tools/default-encoding'))

            i = CallFindScu([ '-k', '0008,0052=STUDY', 
                              '-k', 'SpecificCharacterSet',  
                              '-k', 'PatientName' ])

            characterSet = re.findall('\(0008,0005\).*?\[(.*?)\]', i)
            self.assertEqual(1, len(characterSet))
            self.assertEqual(ENCODINGS[name][0], characterSet[0].strip())

            patientName = re.findall('\(0010,0010\).*?\[(.*?)\]', i)
            self.assertEqual(1, len(patientName))

            expected = TEST.encode(ENCODINGS[name][1], 'ignore')
            self.assertEqual(expected, patientName[0].strip())


        #for master in ENCODINGS:
        for master in [ 'Latin1', 'Utf8', 'Cyrillic' ]:  # Shortcut to speedup tests
            self.assertEqual(master, DoPut(_REMOTE, '/tools/default-encoding', master))
            self.assertEqual(master, DoGet(_REMOTE, '/tools/default-encoding'))

            for name in ENCODINGS:
                DropOrthanc(_REMOTE)
                UploadInstance(_REMOTE, 'Encodings/Lena-%s.dcm' % ENCODINGS[name][1])

                i = CallFindScu([ '-k', '0008,0052=STUDY', 
                                  '-k', 'PatientID', 
                                  '-k', 'SpecificCharacterSet',  
                                  '-k', 'PatientName' ])
                i = i.decode(ENCODINGS[master][1])

                characterSet = re.findall('\(0008,0005\).*?\[(.*?)\]', i)
                self.assertEqual(1, len(characterSet))
                self.assertEqual(ENCODINGS[master][0], characterSet[0].strip())

                patientId = re.findall('\(0010,0020\).*?\[(.*?)\]', i)
                self.assertEqual(1, len(patientId))
                self.assertEqual(ENCODINGS[name][1], patientId[0].strip())

                patientName = re.findall('\(0010,0010\).*?\[(.*?)\]', i)
                self.assertEqual(1, len(patientName))

                tmp = ENCODINGS[name][1]
                expected = TEST.encode(tmp, 'ignore').decode(tmp)
                tmp = ENCODINGS[master][1]
                expected = expected.encode(tmp, 'ignore').decode(tmp)

                self.assertEqual(expected, patientName[0].strip())


                a = DoPost(_REMOTE, '/tools/find', { 'Expand' : True,
                                                     'Level' : 'Study',
                                                     'Query' : { }})
                self.assertEqual(1, len(a))

                tmp = ENCODINGS[name][1]
                self.assertEqual(TEST.encode(tmp, 'ignore').decode(tmp), a[0]["PatientMainDicomTags"]["PatientName"])


    def test_reconstruct(self):
        def CompareMainDicomTag(expected, instance, level, tag):
            self.assertEqual(expected, DoGet(_REMOTE, '/instances/%s/%s' % (instance, level))['MainDicomTags'][tag].strip())

        originalInstanceId = UploadInstance(_REMOTE, 'DummyCT.dcm')['ID']

        studies = DoGet(_REMOTE, '/studies/')
        self.assertEqual(1, len(DoGet(_REMOTE, '/patients/')))
        self.assertEqual(1, len(studies))
        self.assertEqual(1, len(DoGet(_REMOTE, '/series/')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances/')))

        modified = DoPost(_REMOTE, '/studies/%s/modify' % studies[0], {
            "Replace" : {
                "StudyDescription" : "hello",
                "SeriesDescription" : "world",
                "SOPClassUID" : "test",
                "SOPInstanceUID" : "myid",
            },
            "Keep" : [ "StudyInstanceUID", "SeriesInstanceUID" ],
            "Force" : True
        })

        instances = DoGet(_REMOTE, '/instances/')
        self.assertEqual(1, len(DoGet(_REMOTE, '/patients/')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/studies/')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/series/')))
        self.assertEqual(2, len(instances))

        modifiedInstanceId = instances[0] if instances[1] == originalInstanceId else instances[1]

        # in 1.11.3, we have added an automatic reconstruction at the end of the modification
        if not IsOrthancVersionAbove(_REMOTE, 1, 11, 3):
            CompareMainDicomTag('Knee (R)', originalInstanceId, 'study', 'StudyDescription')
            CompareMainDicomTag('AX.  FSE PD', originalInstanceId, 'series', 'SeriesDescription')
            CompareMainDicomTag('1.2.840.113619.2.176.2025.1499492.7040.1171286242.109', originalInstanceId, '', 'SOPInstanceUID')
            CompareMainDicomTag('myid', modifiedInstanceId, '', 'SOPInstanceUID')
            self.assertEqual('1.2.840.10008.5.1.4.1.1.4', DoGet(_REMOTE, '/instances/%s/metadata/SopClassUid' % originalInstanceId).strip())
            self.assertEqual('test', DoGet(_REMOTE, '/instances/%s/metadata/SopClassUid' % modifiedInstanceId).strip())

            if IsOrthancVersionAbove(_REMOTE, 1, 11, 0):
                # metadata before reconstruct
                mba = DoGet(_REMOTE, '/instances/%s/metadata?expand' % originalInstanceId)
                mbb = DoGet(_REMOTE, '/instances/%s/metadata?expand' % originalInstanceId)

            # reconstruct by taking the new instance as the reference -> should repopulate study fields from this instance tags
            DoPost(_REMOTE, '/instances/%s/reconstruct' % modifiedInstanceId, {})

        CompareMainDicomTag('hello', originalInstanceId, 'study', 'StudyDescription')
        CompareMainDicomTag('world', originalInstanceId, 'series', 'SeriesDescription')
        CompareMainDicomTag('1.2.840.113619.2.176.2025.1499492.7040.1171286242.109', originalInstanceId, '', 'SOPInstanceUID')

        if not IsOrthancVersionAbove(_REMOTE, 1, 11, 3):
            if IsOrthancVersionAbove(_REMOTE, 1, 11, 0):
                # metadata after reconstruct should have been preserved
                maa = DoGet(_REMOTE, '/instances/%s/metadata?expand' % originalInstanceId)
                mab = DoGet(_REMOTE, '/instances/%s/metadata?expand' % originalInstanceId)

                self.assertEqual(mba, maa)
                self.assertEqual(mbb, mab)


    @unittest.skip("httpbin.org is down as of 2022-12-22")  # TODO
    def test_httpClient_lua(self):
        retries = 4
        result = ''
        
        with open(GetDatabasePath('Lua/HttpClient.lua'), 'r') as f:
            scriptContent = f.read()
            # retry since this test sometimes fails if httpbin.org is unresponsive
            while retries > 0 and not ('OK' in result):
                print("Executing lua script HttpClient.lua")
                result = DoPost(_REMOTE, '/tools/execute-script', scriptContent, 'application/lua')
                retries -= 1

        self.assertIn('OK', result)


    def test_bitbucket_issue_44(self):
        # https://bugs.orthanc-server.com/show_bug.cgi?id=44
        UploadInstance(_REMOTE, 'Issue44/Monochrome1.dcm')
        UploadInstance(_REMOTE, 'Issue44/Monochrome2.dcm')

        # dcmcjpeg +ua +eb Monochrome1.dcm Monochrome1-Jpeg.dcm
        UploadInstance(_REMOTE, 'Issue44/Monochrome1-Jpeg.dcm')

        # dcmcjpeg +ua Monochrome1.dcm Monochrome1-JpegLS.dcm
        UploadInstance(_REMOTE, 'Issue44/Monochrome1-JpegLS.dcm')

        monochrome1 = 'bcdd600a-a6a9c522-5f0a6e84-8657c9f3-b76e59b7'
        monochrome1_jpeg = '9df82121-208a2da8-0038674a-3d7a773b-b7008cd2'
        monochrome1_jpegls = '0486d1a2-9165573f-b1976b20-e927b016-6b8d67ab'
        monochrome2 = 'f00947b7-f61f7164-c93414d1-c6fbda6a-9e92ed20'

        for i in [ monochrome1, monochrome1_jpeg, monochrome1_jpegls ]:
            im = GetImage(_REMOTE, '/instances/%s/preview' % i)
            self.assertEqual("L", im.mode)
            self.assertEqual(2010, im.size[0])
            self.assertEqual(2446, im.size[1])

            # This is the chest image, with MONOCHROME1. Raw background is
            # white (255), should be rendered as black (0) => invert
            if i == monochrome1_jpeg:
                # Add some tolerance because of JPEG destructive compression
                self.assertGreater(10, im.getpixel((0,0)))
            else:
                self.assertEqual(0, im.getpixel((0,0)))

        im = GetImage(_REMOTE, '/instances/%s/preview' % monochrome2)
        self.assertEqual("L", im.mode)
        self.assertEqual(1572, im.size[0])
        self.assertEqual(2010, im.size[1])
        
        # This is the key image, with MONOCHROME2. Raw background is
        # white (255), should be rendered as white (255)
        self.assertEqual(255, im.getpixel((0,0)))


    def test_bitbucket_issue_42(self):
        # https://bugs.orthanc-server.com/show_bug.cgi?id=42
        # This test fails on DCMTK 3.6.0, but succeeds in DCMTK 3.6.1 snapshots and DCMTK 3.6.2
        UploadInstance(_REMOTE, 'Issue42.dcm')['ID']
        modified = DoPost(_REMOTE,
                          '/patients/da128605-e040d0c4-310615d2-3475da63-df2d1ef4/modify',
                          '{"Replace":{"PatientID":"Hello","PatientName":"Sample patient name"},"Force":true}',
                          'application/json')
        self.assertTrue('PatientID' in modified)


    def test_rest_find_limit(self):
        # Check the "Since" and "Limit" parameters in URI "/tools/find"
        # Related to issue 53: https://bugs.orthanc-server.com/show_bug.cgi?id=53
        
        # Upload 6 instances
        brainix = []
        knee = []
        for i in range(2):
            brainix.append(UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-000%d.dcm' % (i + 1)) ['ID'])
            brainix.append(UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-000%d.dcm' % (i + 1)) ['ID'])
            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 })
        self.assertEqual(4, len(a))

        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Instance',
                                             'Query' : { 'PatientName' : 'B*' },
                                             'Limit' : 4 })
        self.assertEqual(4, len(a))

        if HasExtendedFind(_REMOTE):  # usage of since is not reliable without ExtendedFind
            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Instance',
                                                'Query' : { 'PatientName' : 'B*' },
                                                'Since' : 2,
                                                'Limit' : 4 })
            self.assertEqual(2, len(a))

        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Instance',
                                             'Query' : { 'PatientName' : 'B*' },
                                             '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))

        if HasExtendedFind(_REMOTE):  # usage of since is not reliable without ExtendedFind
            b = []
            for i in range(4):
                a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Instance',
                                                    'Query' : { 'PatientName' : 'B*' },
                                                    'Limit' : 1,
                                                    'Since' : i })
                self.assertEqual(1, len(a))
                b.append(a[0])

            # 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*' },
                                             'Limit' : 10 })
        self.assertEqual(2, len(a))

        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Instance',
                                             'Query' : { 'PatientName' : 'K*' },
                                             'Limit' : 2 })
        self.assertEqual(2, len(a))

        if HasExtendedFind(_REMOTE):  # usage of since is not reliable without ExtendedFind
            b = []
            for i in range(2):
                a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Instance',
                                                    'Query' : { 'PatientName' : 'K*' },
                                                    'Limit' : 1,
                                                    'Since' : i })
                self.assertEqual(1, len(a))
                b.append(a[0])

            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))

        # TODO: remove these tests for good once 1.12.5 is out
        # if not HasExtendedFind(_REMOTE):  # once you have ExtendedFind, usage of Limit and Since is forbidden when filtering on tags that are not in DB because that's just impossible to use on real life DB !


        #     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"
        # https://bugs.orthanc-server.com/show_bug.cgi?id=46

        def GetAnonymizedTags(study, version):
            anonymized = DoPost(_REMOTE, '/studies/%s/anonymize' % study,
                                { 'DicomVersion' : version },
                                'application/json') ['ID']
            a = DoGet(_REMOTE, '/studies/%s/instances' % anonymized)
            self.assertEqual(1, len(a))

            instance = a[0]['ID']
            
            return (instance, DoGet(_REMOTE, '/instances/%s/tags' % instance))

        UploadInstance(_REMOTE, 'Issue44/Monochrome1.dcm')
        origStudy = '6068a14b-d4df27af-9ec22145-538772d8-74f228ff'

        # Add the 0032,1033 (Requesting Service) and the 0010,1060
        # (Patient's Mother's Birth Name) tags
        newStudy = DoPost(_REMOTE, '/studies/%s/modify' % origStudy,
                          '{"Replace":{"0010,1060":"OSIMIS","0032,1033":"MOTHER"}}',
                          'application/json')['ID']

        # Use Table E.1-1 from PS 3.15-2008
        # https://raw.githubusercontent.com/jodogne/dicom-specification/master/2008/08_15pu.pdf
        (instance, tags) = GetAnonymizedTags(newStudy, "2008")
        self.assertTrue('0032,1033' in tags)
        self.assertTrue('0010,1060' in tags)

        # Use Table E.1-1 from PS 3.15-2011 (only if Orthanc >= 1.2.1)
        # https://raw.githubusercontent.com/jodogne/dicom-specification/master/2008/08_15pu.pdf
        (instance, tags) = GetAnonymizedTags(newStudy, "2017c")
        self.assertFalse('0032,1033' in tags)
        self.assertFalse('0010,1060' in tags)

        t = {}
        for (key, value) in tags.iteritems():
            t[value['Name']] = value['Value']

        self.assertEqual('', t['StudyDate'])  # Type 1 tag => cleared
        self.assertEqual('', t['StudyTime'])  # Type 1 tag => cleared
        self.assertEqual('', t['PatientSex']) # Type 1 tag => cleared
        self.assertFalse('SeriesDate' in t)   # Type 3 tag => null
        self.assertFalse('SeriesTime' in t)   # Type 3 tag => null

        with tempfile.NamedTemporaryFile(delete = True) as f:
            # Run "dciodvfy" on the anonymized file to be sure it is still valid
            f.write(DoGet(_REMOTE, '/instances/%s/file' % instance))
            f.flush()
            subprocess.check_output([ FindExecutable('dciodvfy'), f.name ],
                                    stderr = subprocess.STDOUT).split('\n')


    def test_bitbucket_issue_55(self):
        def Run(modify, query):
            self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))

            operation = 'modify' if modify else 'anonymize'
            
            self.assertRaises(Exception, lambda: DoPost(
                _REMOTE, '/studies/%s/%s' % (study, operation), query))
            self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))

            query["Force"] = True
            a = DoPost(_REMOTE, '/studies/%s/%s' % (study, operation), query)['Path']
            self.assertEqual(2, len(DoGet(_REMOTE, '/instances')))
            DoDelete(_REMOTE, a)
                         
            self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))
        
        UploadInstance(_REMOTE, 'DummyCT.dcm')
        study = 'b9c08539-26f93bde-c81ab0d7-bffaf2cb-a4d0bdd0'

        Run(True, { "Replace" : { "StudyInstanceUID" : "world" } })
        Run(True, { "Replace" : { "SeriesInstanceUID" : "world" } })
        Run(True, { "Replace" : { "SOPInstanceUID" : "world" } })

        Run(False, { "Keep" : [ "StudyInstanceUID" ]})
        Run(False, { "Keep" : [ "SeriesInstanceUID" ]})
        Run(False, { "Keep" : [ "SOPInstanceUID" ]})

        Run(False, { "Replace" : { "StudyInstanceUID" : "world" } })
        Run(False, { "Replace" : { "SeriesInstanceUID" : "world" } })
        Run(False, { "Replace" : { "SOPInstanceUID" : "world" } })


    def test_bitbucket_issue_56(self):
        # Case-insensitive matching over accents. This test assumes
        # that the "CaseSensitivePN" configuration option of Orthanc
        # is set to "false" (default value).
        # https://bugs.orthanc-server.com/show_bug.cgi?id=56

        def Check(name, expected, expectedSensitive):
            a = CallFindScu([ '-k', '0008,0005=ISO_IR 192',  # Use UTF-8
                              '-k', '0008,0052=PATIENT',
                              '-k', 'PatientName=%s' % name ])
            patientNames = re.findall('\(0010,0010\).*?\[(.*?)\]', a)
            self.assertEqual(expected, len(patientNames))

            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Patient',
                                                 'CaseSensitive' : False,
                                                 'Query' : { 'PatientName' : name }})
            self.assertEqual(expected, len(a))

            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Patient',
                                                 'CaseSensitive' : True,
                                                 'Query' : { 'PatientName' : name }})
            self.assertEqual(expectedSensitive, len(a))

        # SpecificCharacterSet = ISO_IR 100 (Latin1), PatientName=Test-éüäöò
        UploadInstance(_REMOTE, 'Encodings/Lena-latin1.dcm')

        # WildcardConstraint
        Check('TeSt*', 1, 0)
        Check('TeSt-a*', 0, 0)
        Check('TeSt-É*', 1, 0)
        Check('TeSt-é*', 1, 0)
        Check('Test-é*', 1, 1)

        # ListConstraint
        Check('Test-éüäöò\\nope', 1, 1)
        Check('Test-ÉÜÄÖÒ\\nope', 1, 0)

        # ValueConstraint
        Check('Test-éüäöò', 1, 1)
        Check('Test-ÉÜÄÖÒ', 1, 0)

        
    def test_gbk_alias(self):
        # https://groups.google.com/d/msg/orthanc-users/WMM8LMbjpUc/02-1f_yFCgAJ
        # This test fails on Orthanc <= 1.3.0
        i = UploadInstance(_REMOTE, '2017-09-19-GBK-Tumashu.dcm')['ID']
        tags = DoGet(_REMOTE, '/instances/%s/tags?simplify' % i)
        self.assertEqual(tags['PatientName'], u'徐浩凯')
        self.assertEqual(tags['InstitutionName'], u'灌云县疾病预防控制中心')

        
    def test_long_tag(self):
        i = UploadInstance(_REMOTE, 'DummyCTWithLongTag.dcm')['ID']
        series = 'f2635388-f01d497a-15f7c06b-ad7dba06-c4c599fe'

        tags = DoGet(_REMOTE, '/instances/%s/tags' % i)
        self.assertTrue('0018,1020' in tags)
        self.assertEqual('SoftwareVersions', tags['0018,1020']['Name'])
        self.assertEqual('TooLong', tags['0018,1020']['Type'])
        self.assertEqual(None, tags['0018,1020']['Value'])

        tags = DoGet(_REMOTE, '/instances/%s/tags?ignore-length=0018-1020' % i)
        self.assertTrue('0018,1020' in tags)
        self.assertEqual('SoftwareVersions', tags['0018,1020']['Name'])
        self.assertEqual('String', tags['0018,1020']['Type'])
        self.assertTrue(tags['0018,1020']['Value'].startswith('Lorem ipsum dolor sit amet'))

        tags = DoGet(_REMOTE, '/instances/%s/tags?simplify' % i)
        self.assertTrue('SoftwareVersions' in tags)
        self.assertEqual(None, tags['SoftwareVersions'])
        self.assertTrue('HeartRate' in tags)
        self.assertEqual(474, int(tags['HeartRate']))

        tags = DoGet(_REMOTE, '/instances/%s/simplified-tags' % i)
        self.assertTrue('SoftwareVersions' in tags)
        self.assertEqual(None, tags['SoftwareVersions'])

        tags = DoGet(_REMOTE, '/instances/%s/tags?simplify&ignore-length=0018-1020' % i)
        self.assertTrue('SoftwareVersions' in tags)
        self.assertTrue(tags['SoftwareVersions'].startswith('Lorem ipsum dolor sit amet'))

        tags = DoGet(_REMOTE, '/instances/%s/tags?simplify&ignore-length=SoftwareVersions' % i)
        self.assertTrue('SoftwareVersions' in tags)
        self.assertTrue(tags['SoftwareVersions'].startswith('Lorem ipsum dolor sit amet'))

        tags = DoGet(_REMOTE, '/series/%s/instances-tags' % series)
        self.assertEqual(1, len(tags))
        self.assertTrue(i in tags.keys())
        self.assertTrue('0018,1020' in tags[i])
        self.assertEqual('TooLong', tags[i]['0018,1020']['Type'])

        tags = DoGet(_REMOTE, '/series/%s/instances-tags?ignore-length=SoftwareVersions' % series)
        self.assertEqual(1, len(tags))
        self.assertTrue(i in tags.keys())
        self.assertTrue('0018,1020' in tags[i])
        self.assertEqual('String', tags[i]['0018,1020']['Type'])
        self.assertTrue(tags[i]['0018,1020']['Value'].startswith('Lorem ipsum dolor sit amet'))


    def test_extended_media(self):
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')

        z = GetArchive(_REMOTE, '/patients/%s/media?extended' % DoGet(_REMOTE, '/patients')[0])
        self.assertEqual(2, len(z.namelist()))
        self.assertTrue('IMAGES/IM0' in z.namelist())
        self.assertTrue('DICOMDIR' in z.namelist())

        try:
            os.remove('/tmp/DICOMDIR')
        except:
            # The file does not exist
            pass

        z.extract('DICOMDIR', '/tmp')
        a = subprocess.check_output([ FindExecutable('dciodvfy'), '/tmp/DICOMDIR' ],
                                    stderr = subprocess.STDOUT).split('\n')
        self.assertEqual(5, len(a))
        self.assertTrue(a[0].startswith('Warning'))
        self.assertEqual('BasicDirectory', a[1])
        self.assertTrue('not present in standard DICOM IOD' in a[2])
        self.assertTrue('not present in standard DICOM IOD' in a[3])
        self.assertEqual('', a[4])

        a = subprocess.check_output([ FindExecutable('dcentvfy'), '/tmp/DICOMDIR' ],
                                    stderr = subprocess.STDOUT).split('\n')
        self.assertEqual(1, len(a))
        self.assertEqual('', a[0])

        a = subprocess.check_output([ FindExecutable('dcm2xml'), '/tmp/DICOMDIR' ])
        self.assertTrue(re.search('1.3.46.670589.11.17521.5.0.3124.2008081908590448738', a) != None)

        # Check the presence of the series description (extended tag)
        self.assertTrue(re.search('T1W_aTSE', a) != None)

        os.remove('/tmp/DICOMDIR')


    def test_anonymize_relationships_1(self):
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0002.dcm')
        study = '0a9b3153-2512774b-2d9580de-1fc3dcf6-3bd83918'
        
        anonymized = DoPost(_REMOTE, '/studies/%s/anonymize' % study,
                            '{}',
                            'application/json')['ID']
        
        a = DoGet(_REMOTE, '/studies/%s/instances' % study)
        self.assertEqual(2, len(a))
        a1 = a[0]['ID']
        a2 = a[1]['ID']
        
        b = DoGet(_REMOTE, '/studies/%s/instances' % anonymized)
        self.assertEqual(2, len(b))
        b1 = b[0]['ID']
        b2 = b[1]['ID']
        
        SEQUENCE = '/instances/%s/content/ReferencedImageSequence'
        SOP = '/instances/%s/content/ReferencedImageSequence/%d/ReferencedSOPInstanceUID'
        CLASS = '/instances/%s/content/ReferencedImageSequence/%d/ReferencedSOPClassUID'
        FRAME = '/instances/%s/content/FrameOfReferenceUID'

        self.assertEqual(DoGet(_REMOTE, FRAME % a1), 
                         DoGet(_REMOTE, FRAME % a2))
        self.assertEqual(DoGet(_REMOTE, FRAME % b1), 
                         DoGet(_REMOTE, FRAME % b2))
        self.assertNotEqual(DoGet(_REMOTE, FRAME % a1),
                            DoGet(_REMOTE, FRAME % b1))
        self.assertNotEqual(DoGet(_REMOTE, FRAME % a2),
                            DoGet(_REMOTE, FRAME % b2))

        self.assertEqual(3, len(DoGet(_REMOTE, SEQUENCE % a1)))
        self.assertEqual(3, len(DoGet(_REMOTE, SEQUENCE % a2)))
        self.assertEqual(3, len(DoGet(_REMOTE, SEQUENCE % b1)))
        self.assertEqual(3, len(DoGet(_REMOTE, SEQUENCE % b2)))

        for i in range(3):
            self.assertEqual(DoGet(_REMOTE, SOP % (a1, i)),
                             DoGet(_REMOTE, SOP % (a2, i)))
            self.assertEqual(DoGet(_REMOTE, SOP % (b1, i)),
                             DoGet(_REMOTE, SOP % (b2, i)))
            self.assertNotEqual(DoGet(_REMOTE, SOP % (a1, i)),
                                DoGet(_REMOTE, SOP % (b1, i)))
            self.assertNotEqual(DoGet(_REMOTE, SOP % (a2, i)),
                                DoGet(_REMOTE, SOP % (b2, i)))
            self.assertEqual(DoGet(_REMOTE, CLASS % (a1, i)),
                             DoGet(_REMOTE, CLASS % (b1, i)))
            self.assertEqual(DoGet(_REMOTE, CLASS % (a2, i)),
                             DoGet(_REMOTE, CLASS % (b2, i)))


    def test_anonymize_relationships_2(self):
        UploadInstance(_REMOTE, 'Comunix/Ct/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Comunix/Ct/IM-0001-0002.dcm')
        study = '6c65289b-db2fcb71-7eaf73f4-8e12470c-a4d6d7cf'
        
        anonymized = DoPost(_REMOTE, '/studies/%s/anonymize' % study,
                            '{}',
                            'application/json')['ID']
        
        a = DoGet(_REMOTE, '/studies/%s/instances' % study)
        self.assertEqual(2, len(a))
        a1 = a[0]['ID']
        a2 = a[1]['ID']
        
        b = DoGet(_REMOTE, '/studies/%s/instances' % anonymized)
        self.assertEqual(2, len(b))
        b1 = b[0]['ID']
        b2 = b[1]['ID']
        
        SEQUENCE = '/instances/%s/content/SourceImageSequence'
        SOP = '/instances/%s/content/SourceImageSequence/%d/ReferencedSOPInstanceUID'
        CLASS = '/instances/%s/content/SourceImageSequence/%d/ReferencedSOPClassUID'

        self.assertEqual(1, len(DoGet(_REMOTE, SEQUENCE % a1)))
        self.assertEqual(1, len(DoGet(_REMOTE, SEQUENCE % a2)))
        self.assertEqual(1, len(DoGet(_REMOTE, SEQUENCE % b1)))
        self.assertEqual(1, len(DoGet(_REMOTE, SEQUENCE % b2)))
        self.assertEqual(DoGet(_REMOTE, SOP % (a1, 0)),
                         DoGet(_REMOTE, SOP % (a2, 0)))
        self.assertEqual(DoGet(_REMOTE, SOP % (b1, 0)),
                         DoGet(_REMOTE, SOP % (b2, 0)))
        self.assertNotEqual(DoGet(_REMOTE, SOP % (a1, 0)),
                            DoGet(_REMOTE, SOP % (b1, 0)))
        self.assertNotEqual(DoGet(_REMOTE, SOP % (a2, 0)),
                            DoGet(_REMOTE, SOP % (b2, 0)))
        self.assertEqual(DoGet(_REMOTE, CLASS % (a1, 0)),
                         DoGet(_REMOTE, CLASS % (b1, 0)))
        self.assertEqual(DoGet(_REMOTE, CLASS % (a2, 0)),
                         DoGet(_REMOTE, CLASS % (b2, 0)))


    def test_anonymize_relationships_3(self):
        sr1 = UploadInstance(_REMOTE, 'HierarchicalAnonymization/StructuredReports/IM0')['ID']
        mr1 = UploadInstance(_REMOTE, 'HierarchicalAnonymization/StructuredReports/IM631')['ID']
        study = 'ef351eb2-c1147229-062736b8-35a151e3-e32d526b'
        
        anonymized = DoPost(_REMOTE, '/studies/%s/anonymize' % study,
                            { "Keep" : [ "ContentSequence" ] }) ['ID']
                
        a = DoGet(_REMOTE, '/studies/%s/instances' % anonymized)
        self.assertEqual(2, len(a))

        if DoGet(_REMOTE, '/instances/%s/content/Modality' % a[0]['ID']) == 'SR':
            sr2 = a[0]['ID']
            mr2 = a[1]['ID']
        else:
            sr2 = a[1]['ID']
            mr2 = a[0]['ID']
            
        self.assertEqual(DoGet(_REMOTE, '/instances/%s/content/Modality' % sr1),
                         DoGet(_REMOTE, '/instances/%s/content/Modality' % sr2))
        self.assertEqual(DoGet(_REMOTE, '/instances/%s/content/Modality' % mr1),
                         DoGet(_REMOTE, '/instances/%s/content/Modality' % mr2))

        mrUid1 = DoGet(_REMOTE, '/instances/%s' % mr1)['MainDicomTags']['SOPInstanceUID']
        mrUid2 = DoGet(_REMOTE, '/instances/%s' % mr2)['MainDicomTags']['SOPInstanceUID']
        mrSeries1 = DoGet(_REMOTE, '/instances/%s/content/SeriesInstanceUID' % mr1).strip('\x00')
        mrSeries2 = DoGet(_REMOTE, '/instances/%s/content/SeriesInstanceUID' % mr2).strip('\x00')
        mrStudy1 = DoGet(_REMOTE, '/instances/%s/content/StudyInstanceUID' % mr1).strip('\x00')
        mrStudy2 = DoGet(_REMOTE, '/instances/%s/content/StudyInstanceUID' % mr2).strip('\x00')

        PATH1 = '/instances/%s/content/CurrentRequestedProcedureEvidenceSequence'
        PATH2 = PATH1 + '/0/ReferencedSeriesSequence'
        PATH3 = PATH2 + '/0/ReferencedSOPSequence'
        PATH4 = PATH3 + '/0/ReferencedSOPInstanceUID'
        PATH5 = PATH3 + '/0/ReferencedSOPClassUID'

        self.assertEqual(1, len(DoGet(_REMOTE, PATH1 % sr1)))
        self.assertEqual(1, len(DoGet(_REMOTE, PATH2 % sr1)))
        self.assertEqual(1, len(DoGet(_REMOTE, PATH3 % sr1)))
        self.assertEqual(DoGet(_REMOTE, PATH4 % sr1), mrUid1)
        self.assertEqual(mrSeries1, DoGet(_REMOTE, (PATH2 + '/0/SeriesInstanceUID') % sr1).strip('\x00'))
        self.assertEqual(mrStudy1, DoGet(_REMOTE, (PATH1 + '/0/StudyInstanceUID') % sr1).strip('\x00'))

        self.assertEqual(1, len(DoGet(_REMOTE, PATH1 % sr2)))
        self.assertEqual(1, len(DoGet(_REMOTE, PATH2 % sr2)))
        self.assertEqual(1, len(DoGet(_REMOTE, PATH3 % sr2)))
        self.assertEqual(DoGet(_REMOTE, PATH5 % sr1), DoGet(_REMOTE, PATH5 % sr2))

        self.assertEqual(mrUid2, DoGet(_REMOTE, PATH4 % sr2).strip('\x00'))
        self.assertEqual(mrSeries2, DoGet(_REMOTE, (PATH2 + '/0/SeriesInstanceUID') % sr2).strip('\x00'))
        self.assertEqual(mrStudy2, DoGet(_REMOTE, (PATH1 + '/0/StudyInstanceUID') % sr2).strip('\x00'))

        content1 = DoGet(_REMOTE, '/instances/%s/tags?simplify' % sr1) ['ContentSequence']
        content2 = DoGet(_REMOTE, '/instances/%s/tags?simplify' % sr2) ['ContentSequence']
        self.assertEqual(str(content1), str(content2))


    def test_bitbucket_issue_94(self):
        # "a simple instance modification should not modify FrameOfReferenceUID + ..."
        # https://bugs.orthanc-server.com/show_bug.cgi?id=94
        i = UploadInstance(_REMOTE, 'Issue94.dcm')['ID']

        source = DoGet(_REMOTE, '/instances/%s/attachments/dicom/data' % i)

        modified = DoPost(_REMOTE, '/instances/%s/modify' % i,
                          { "Replace" : {"PatientID" : "toto"}, "Force": True})

        anonymized = DoPost(_REMOTE, '/instances/%s/anonymize' % i)

        a = ExtractDicomTags(source, [ 'FrameOfReferenceUID' ])
        self.assertEqual(1, len(a))
        
        b = ExtractDicomTags(modified, [ 'FrameOfReferenceUID' ])
        self.assertEqual(1, len(b))
        
        c = ExtractDicomTags(anonymized, [ 'FrameOfReferenceUID' ])
        self.assertEqual(1, len(c))
        
        self.assertEqual(a, b)     # Modified DICOM
        self.assertNotEqual(a, c)  # Anonymized DICOM


    def test_metadata_origin(self):
        # Upload using the REST API
        i = UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')['ID']
        self.assertEqual('RestApi', DoGet(_REMOTE, '/instances/%s/metadata/Origin' % i))
        self.assertEqual('', DoGet(_REMOTE, '/instances/%s/metadata/RemoteAET' % i))
        self.assertNotEqual('', DoGet(_REMOTE, '/instances/%s/metadata/RemoteIP' % i))
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/metadata/CalledAET' % i))

        # "HttpUsername" is empty iff "AuthenticationEnabled" is "false"
        self.assertTrue(DoGet(_REMOTE, '/instances/%s/metadata/HttpUsername' % i) in [ '', 'alice' ])
        
        m = DoGet(_REMOTE, '/instances/%s/metadata?expand' % i)
        self.assertEqual('RestApi', m['Origin'])
        self.assertEqual('', m['RemoteAET'])
        self.assertNotEqual('', m['RemoteIP'])
        self.assertFalse('CalledAET' in m)
        self.assertTrue('HttpUsername' in m)
        self.assertTrue(m['HttpUsername'] in [ '', 'alice' ])

        self.assertEqual('1.2.840.10008.1.2.4.91', m['TransferSyntax'])
        self.assertEqual('1.2.840.10008.5.1.4.1.1.4', m['SopClassUid'])
        self.assertEqual('1', m['IndexInSeries'])
        self.assertTrue('ReceptionDate' in m)

        DoDelete(_REMOTE, '/instances/%s' % i)

        # Upload using the DICOM protocol
        subprocess.check_call([ FindExecutable('storescu'),
                                _REMOTE['Server'], str(_REMOTE['DicomPort']),
                                GetDatabasePath('Knee/T1/IM-0001-0001.dcm'),
                                '-xw' ])  # Propose JPEG2000
        self.assertEqual('DicomProtocol', DoGet(_REMOTE, '/instances/%s/metadata/Origin' % i))
        self.assertEqual('STORESCU', DoGet(_REMOTE, '/instances/%s/metadata/RemoteAET' % i))
        self.assertNotEqual('', DoGet(_REMOTE, '/instances/%s/metadata/RemoteIP' % i))
        self.assertEqual('ANY-SCP', DoGet(_REMOTE, '/instances/%s/metadata/CalledAET' % i))
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/metadata/HttpUsername' % i))

        m = DoGet(_REMOTE, '/instances/%s/metadata?expand' % i)
        self.assertEqual('DicomProtocol', m['Origin'])
        self.assertEqual('STORESCU', m['RemoteAET'])
        self.assertNotEqual('', m['RemoteIP'])
        self.assertEqual('ANY-SCP', m['CalledAET'])
        self.assertFalse('HttpUsername' in m)

        self.assertEqual('1.2.840.10008.1.2.4.91', m['TransferSyntax'])
        self.assertEqual('1.2.840.10008.5.1.4.1.1.4', m['SopClassUid'])
        self.assertEqual('1', m['IndexInSeries'])
        self.assertTrue('ReceptionDate' in m)


    def test_lua_deadlock(self):
        # Rana Asim Wajid (2018-07-14): "It does seem that the issue
        # is with the lua script I'm using for conversion of images to
        # JPEG2000. When the script is used with 1.4.0 the first
        # instance appears to be stored and then everything just
        # halts, ie Orthanc wont respond to anything after that."
        # https://groups.google.com/d/msg/orthanc-users/Rc-Beb42xc8/JUgdzrmCAgAJ
        InstallLuaScriptFromPath(_REMOTE, 'Lua/Jpeg2000Conversion.lua')

        subprocess.check_call([ FindExecutable('storescu'),
                                _REMOTE['Server'], str(_REMOTE['DicomPort']),
                                GetDatabasePath('Brainix/Flair/IM-0001-0001.dcm'),
                                GetDatabasePath('Brainix/Flair/IM-0001-0002.dcm'),
                                ])

        instances = DoGet(_REMOTE, '/instances')
        self.assertEqual(2, len(instances))

        t1 = DoGet(_REMOTE, '/instances/%s/metadata/TransferSyntax' % instances[0])
        t2 = DoGet(_REMOTE, '/instances/%s/metadata/TransferSyntax' % instances[1])
        self.assertEqual('1.2.840.10008.1.2.4.90', t1)   # this will fail if libgdcm-tools is not installed
        self.assertEqual(t1, t2);


    def test_find_group_length(self):
        # Orthanc <= 1.4.1 fails to answer C-FIND queries that contain
        # one of the Generic Group Length tags (*, 0x0000)
        a = UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm')['ID']
        result = CallFindScu([ '-k', '0008,0052=STUDY', '-k', '0008,0000=80' ])
        self.assertFalse('UnableToProcess' in result)
        self.assertFalse('E:' in result)


    def test_split(self):
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Knee/T2/IM-0001-0001.dcm')
        knee1Sop = '1.3.46.670589.11.17521.5.0.3124.2008081908590448738'
        knee2Sop = '1.3.46.670589.11.17521.5.0.3124.2008081909113806560'
        study = '0a9b3153-2512774b-2d9580de-1fc3dcf6-3bd83918'
        t1 = '6de73705-c4e65c1b-9d9ea1b5-cabcd8e7-f15e4285'
        t2 = 'bbf7a453-0d34251a-03663b55-46bb31b9-ffd74c59'

        self.assertEqual(1, len(DoGet(_REMOTE, '/studies')))
        self.assertEqual(2, len(DoGet(_REMOTE, '/series')))

        info = DoGet(_REMOTE, '/studies/%s' % study)
        self.assertTrue('ReferringPhysicianName' in info['MainDicomTags'])
            
        job = MonitorJob2(_REMOTE, lambda: DoPost
                          (_REMOTE, '/studies/%s/split' % study, {
                              'Series' : [ t2 ],
                              'Replace' : { 'PatientName' : 'Hello' },
                              'Remove' : [ 'ReferringPhysicianName' ],
                              'KeepSource' : False,
                              'Asynchronous' : True
                          }))

        self.assertNotEqual(None, job)

        studies = set(DoGet(_REMOTE, '/studies'))
        self.assertEqual(2, len(studies))

        series = set(DoGet(_REMOTE, '/series'))
        self.assertEqual(2, len(series))
        self.assertTrue(t1 in series)

        study2 = DoGet(_REMOTE, '/jobs/%s' % job)['Content']['TargetStudy']
        self.assertTrue(study in studies)
        self.assertTrue(study2 in studies)

        info = DoGet(_REMOTE, '/studies/%s' % study2)
        self.assertTrue('Hello', info['PatientMainDicomTags']['PatientName'])
        self.assertFalse('ReferringPhysicianName' in info['MainDicomTags'])

        sopInstanceUids = set()
        for i in DoGet(_REMOTE, '/instances?expand'):
            sopInstanceUids.add(i['MainDicomTags']['SOPInstanceUID'])

        self.assertTrue(knee1Sop in sopInstanceUids)

        # Fails if Orthanc <= 1.5.7
        self.assertFalse(knee2Sop in sopInstanceUids)  # Because "KeepSource" is False

        # One original instance is kept, another one is added because of the split
        self.assertEqual(2, len(sopInstanceUids))

        
    def test_merge(self):
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm')
        kneeSop = '1.3.46.670589.11.17521.5.0.3124.2008081908590448738'
        brainixSop = '1.3.46.670589.11.0.0.11.4.2.0.8743.5.5396.2006120114314079549'
        knee = '0a9b3153-2512774b-2d9580de-1fc3dcf6-3bd83918'
        t1 = '6de73705-c4e65c1b-9d9ea1b5-cabcd8e7-f15e4285'
        brainix = '27f7126f-4f66fb14-03f4081b-f9341db2-53925988'
        flair = '1e2c125c-411b8e86-3f4fe68e-a7584dd3-c6da78f0'

        self.assertEqual(2, len(DoGet(_REMOTE, '/studies')))
        self.assertEqual(2, len(DoGet(_REMOTE, '/series')))

        job = MonitorJob2(_REMOTE, lambda: DoPost
                          (_REMOTE, '/studies/%s/merge' % knee, {
                              'Resources' : [ brainix ],
                              'KeepSource' : True,
                              'Synchronous' : False
                          }))

        self.assertNotEqual(None, job)

        studies = set(DoGet(_REMOTE, '/studies'))
        self.assertEqual(2, len(studies))
        self.assertTrue(knee in studies)
        self.assertTrue(brainix in studies)

        series = set(DoGet(_REMOTE, '/studies/%s' % knee)['Series'])
        self.assertTrue(t1 in series)
        series.remove(t1)
        self.assertEqual(1, len(series))

        instances = DoGet(_REMOTE, '/series/%s' % list(series)[0])['Instances']
        self.assertEqual(1, len(instances))
        merged = DoGet(_REMOTE, '/instances/%s/tags?simplify' % instances[0])

        instances = DoGet(_REMOTE, '/series/%s' % t1)['Instances']
        self.assertEqual(1, len(instances))
        a = DoGet(_REMOTE, '/instances/%s/tags?simplify' % instances[0])

        instances = DoGet(_REMOTE, '/series/%s' % flair)['Instances']
        self.assertEqual(1, len(instances))
        b = DoGet(_REMOTE, '/instances/%s/tags?simplify' % instances[0])

        tags = DoGet(_REMOTE, '/studies/%s' % knee)
        
        for key in tags['PatientMainDicomTags']:
            self.assertEqual(a[key], merged[key])
            if (key in b and key != 'PatientSex'):
                self.assertNotEqual(a[key], b[key])
        
        for key in tags['MainDicomTags']:
            # Not in the patient/study module
            if (not key in [ 'InstitutionName',
                             'RequestingPhysician',
                             'RequestedProcedureDescription', ]):
                self.assertEqual(a[key], merged[key])
                if (key in b):
                    self.assertNotEqual(a[key], b[key])

        sopInstanceUids = set()
        for i in DoGet(_REMOTE, '/instances?expand'):
            sopInstanceUids.add(i['MainDicomTags']['SOPInstanceUID'])

        self.assertTrue(kneeSop in sopInstanceUids)
        self.assertTrue(brainixSop in sopInstanceUids)

        # Fails if Orthanc <= 1.5.7
        # The 2 original instances are kept, another one is added because of the merge
        self.assertEqual(3, len(sopInstanceUids))
 

    def test_async_archive(self):
        # Testing the asynchronous generation of archives/medias (new
        # in Orthanc 1.4.3)
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Knee/T2/IM-0001-0001.dcm')

        kneeT1 = '6de73705-c4e65c1b-9d9ea1b5-cabcd8e7-f15e4285'
        kneeT2 = 'bbf7a453-0d34251a-03663b55-46bb31b9-ffd74c59'

        job = MonitorJob2(_REMOTE, lambda: DoPost
                          (_REMOTE, '/series/%s/archive' % kneeT1, {
                              'Synchronous' : False
                          }))

        z = GetArchive(_REMOTE, '/jobs/%s/archive' % job)
        self.assertEqual(1, len(z.namelist()))
        self.assertFalse('DICOMDIR' in z.namelist())

        info = DoGet(_REMOTE, '/jobs/%s' % job)
        self.assertEqual(0, info['Content']['ArchiveSizeMB'])  # New in Orthanc 1.8.1
        self.assertEqual(1, info['Content']['InstancesCount'])
        self.assertEqual(0, info['Content']['UncompressedSizeMB'])
        
        job2 = MonitorJob2(_REMOTE, lambda: DoPost
                           (_REMOTE, '/series/%s/media' % kneeT1, {
                               'Synchronous' : False
                           }))

        # The archive from the first job has been replaced by the
        # archive from second job (as MediaArchiveSize == 1)
        self.assertRaises(Exception, lambda: GetArchive(_REMOTE, '/jobs/%s/archive' % job))

        z = GetArchive(_REMOTE, '/jobs/%s/archive' % job2)
        self.assertEqual(2, len(z.namelist()))
        self.assertTrue('DICOMDIR' in z.namelist())

        info = DoGet(_REMOTE, '/jobs/%s' % job2)
        self.assertEqual(0, info['Content']['ArchiveSizeMB'])  # New in Orthanc 1.8.1
        self.assertEqual(1, info['Content']['InstancesCount'])
        self.assertEqual(0, info['Content']['UncompressedSizeMB'])

        job = MonitorJob2(_REMOTE, lambda: DoPost
                          (_REMOTE, '/tools/create-archive', {
                              'Synchronous' : False,
                              'Resources' : [ kneeT1, kneeT2 ],
                          }))

        z = GetArchive(_REMOTE, '/jobs/%s/archive' % job)
        self.assertEqual(2, len(z.namelist()))
        self.assertFalse('DICOMDIR' in z.namelist())
        
        info = DoGet(_REMOTE, '/jobs/%s' % job)
        self.assertEqual(0, info['Content']['ArchiveSizeMB'])  # New in Orthanc 1.8.1
        self.assertEqual(2, info['Content']['InstancesCount'])
        self.assertEqual(0, info['Content']['UncompressedSizeMB'])

        job = MonitorJob2(_REMOTE, lambda: DoPost
                          (_REMOTE, '/tools/create-media', {
                              'Synchronous' : False,
                              'Resources' : [ kneeT1, kneeT2 ],
                          }))

        z = GetArchive(_REMOTE, '/jobs/%s/archive' % job)
        self.assertEqual(3, len(z.namelist()))
        self.assertTrue('DICOMDIR' in z.namelist())

        self.assertEqual(0, info['Content']['ArchiveSizeMB'])  # New in Orthanc 1.8.1
        self.assertEqual(2, info['Content']['InstancesCount'])
        self.assertEqual(0, info['Content']['UncompressedSizeMB'])
        
        
    def test_archive_job_delete_output(self):
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 1):
            UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')
            UploadInstance(_REMOTE, 'Knee/T2/IM-0001-0001.dcm')

            kneeT1 = '6de73705-c4e65c1b-9d9ea1b5-cabcd8e7-f15e4285'
            kneeT2 = 'bbf7a453-0d34251a-03663b55-46bb31b9-ffd74c59'

            job = MonitorJob2(_REMOTE, lambda: DoPost
                            (_REMOTE, '/series/%s/archive' % kneeT1, {
                                'Synchronous' : False
                            }))

            z = GetArchive(_REMOTE, '/jobs/%s/archive' % job)
            # delete the output
            DoDelete(_REMOTE, '/jobs/%s/archive' % job)
            # make sure it is not available anymore afterwards
            self.assertRaises(Exception, lambda: GetArchive(_REMOTE, '/jobs/%s/archive' % job))

            # repeat with another resource/job
            job = MonitorJob2(_REMOTE, lambda: DoPost
                            (_REMOTE, '/series/%s/archive' % kneeT2, {
                                'Synchronous' : False
                            }))
            z = GetArchive(_REMOTE, '/jobs/%s/archive' % job)
            # delete the output
            DoDelete(_REMOTE, '/jobs/%s/archive' % job)
            # make sure it is not available anymore afterwards
            self.assertRaises(Exception, lambda: GetArchive(_REMOTE, '/jobs/%s/archive' % job))
            # job is still available
            DoGet(_REMOTE, '/jobs/%s' % job)

            if IsOrthancVersionAbove(_REMOTE, 1, 12, 2):
                # delete the job itself
                DoDelete(_REMOTE, '/jobs/%s' % job)
                self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/jobs/%s' % job))

                # test deletion of jobs in history
                job = MonitorJob2(_REMOTE, lambda: DoPost
                                (_REMOTE, '/series/%s/archive' % kneeT2, {
                                    'Synchronous' : False
                                }))
                z = GetArchive(_REMOTE, '/jobs/%s/archive' % job)
                # delete the job itself
                DoDelete(_REMOTE, '/jobs/%s' % job)
                # make sure it is not available anymore afterwards (and its output is not available either)
                self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/jobs/%s' % job))
                self.assertRaises(Exception, lambda: GetArchive(_REMOTE, '/jobs/%s/archive' % job))


    def test_queries_hierarchy(self):
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm')

        tags = {
            'NumberOfPatientRelatedInstances' : '',
            'NumberOfPatientRelatedSeries' : '',
            'NumberOfPatientRelatedStudies' : '',
            'NumberOfStudyRelatedInstances' : '',
            'NumberOfStudyRelatedSeries' : '',
            'NumberOfSeriesRelatedInstances' : '',
        }

        tags2 = copy.copy(tags)
        tags2['PatientID'] = '887'  # Only consider the "Knee" patient

        patient = DoPost(_REMOTE, '/modalities/self/query', {
            'Level' : 'Patient',
            'Query' : tags2
        }) ['ID']

        study = DoPost(_REMOTE, '/modalities/self/query', {
            'Level' : 'Study',
            'Query' : tags2
        }) ['ID']

        series = DoPost(_REMOTE, '/modalities/self/query', {
            'Level' : 'Series',
            'Query' : tags2
        }) ['ID']

        instance = DoPost(_REMOTE, '/modalities/self/query', {
            'Level' : 'Instance',
            'Query' : tags2
        }) ['ID']

        p = DoGet(_REMOTE, '/queries/%s/answers?expand&simplify' % patient)
        self.assertEqual(1, len(p))
        self.assertEqual('887', p[0]['PatientID'])
        self.assertEqual('1', p[0]['NumberOfPatientRelatedInstances'])
        self.assertEqual('1', p[0]['NumberOfPatientRelatedSeries'])
        self.assertEqual('1', p[0]['NumberOfPatientRelatedStudies'])
        self.assertFalse('NumberOfStudyRelatedInstances' in p[0])
        self.assertFalse('NumberOfStudyRelatedSeries' in p[0])
        self.assertFalse('NumberOfSeriesRelatedInstances' in p[0])
        
        p = DoGet(_REMOTE, '/queries/%s/answers?expand&simplify' % study)
        self.assertEqual(1, len(p))
        self.assertEqual('2.16.840.1.113669.632.20.121711.10000160881', p[0]['StudyInstanceUID'])
        self.assertEqual('1', p[0]['NumberOfStudyRelatedInstances'])
        self.assertEqual('1', p[0]['NumberOfStudyRelatedSeries'])
        self.assertFalse('NumberOfPatientRelatedInstances' in p[0])
        self.assertFalse('NumberOfPatientRelatedSeries' in p[0])
        self.assertFalse('NumberOfPatientRelatedInstances' in p[0])
        self.assertFalse('NumberOfSeriesRelatedInstances' in p[0])

        p = DoGet(_REMOTE, '/queries/%s/answers?expand&simplify' % series)
        self.assertEqual(1, len(p))
        self.assertEqual('1.3.46.670589.11.17521.5.0.3124.2008081908564160709', p[0]['SeriesInstanceUID'])
        self.assertEqual('1', p[0]['NumberOfSeriesRelatedInstances'])
        self.assertFalse('NumberOfPatientRelatedInstances' in p[0])
        self.assertFalse('NumberOfPatientRelatedSeries' in p[0])
        self.assertFalse('NumberOfPatientRelatedInstances' in p[0])
        self.assertFalse('NumberOfStudyRelatedInstances' in p[0])
        self.assertFalse('NumberOfStudyRelatedSeries' in p[0])

        p = DoGet(_REMOTE, '/queries/%s/answers?expand&simplify' % instance)
        self.assertEqual(1, len(p))
        self.assertEqual('1.3.46.670589.11.17521.5.0.3124.2008081908590448738', p[0]['SOPInstanceUID'])

        j = DoPost(_REMOTE, '/queries/%s/answers/0/query-studies' % patient,
                   { 'Query' : tags }) ['ID']
        self.assertEqual(DoGet(_REMOTE, '/queries/%s/answers?expand&simplify' % j),
                         DoGet(_REMOTE, '/queries/%s/answers?expand&simplify' % study))

        j = DoPost(_REMOTE, '/queries/%s/answers/0/query-series' % patient,
                   { 'Query' : tags }) ['ID']
        self.assertEqual(DoGet(_REMOTE, '/queries/%s/answers?expand&simplify' % j),
                         DoGet(_REMOTE, '/queries/%s/answers?expand&simplify' % series))
        
        j = DoPost(_REMOTE, '/queries/%s/answers/0/query-instances' % patient,
                   { 'Query' : tags }) ['ID']
        self.assertEqual(DoGet(_REMOTE, '/queries/%s/answers?expand&simplify' % j),
                         DoGet(_REMOTE, '/queries/%s/answers?expand&simplify' % instance))
        
        j = DoPost(_REMOTE, '/queries/%s/answers/0/query-series' % study,
                   { 'Query' : tags }) ['ID']
        self.assertEqual(DoGet(_REMOTE, '/queries/%s/answers?expand&simplify' % j),
                         DoGet(_REMOTE, '/queries/%s/answers?expand&simplify' % series))
        
        j = DoPost(_REMOTE, '/queries/%s/answers/0/query-instances' % study,
                   { 'Query' : tags }) ['ID']
        self.assertEqual(DoGet(_REMOTE, '/queries/%s/answers?expand&simplify' % j),
                         DoGet(_REMOTE, '/queries/%s/answers?expand&simplify' % instance))
        
        j = DoPost(_REMOTE, '/queries/%s/answers/0/query-instances' % series,
                   { 'Query' : tags }) ['ID']
        self.assertEqual(DoGet(_REMOTE, '/queries/%s/answers?expand&simplify' % j),
                         DoGet(_REMOTE, '/queries/%s/answers?expand&simplify' % instance))
        

    def test_dicom_disk_size(self):
        dicomSize = 0

        a = UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm') ['ID']
        isCompressed = (DoGet(_REMOTE, '/instances/%s/attachments/dicom/is-compressed' % a) != 0)

        for i in range(2):
            p = 'Knee/T%d/IM-0001-0001.dcm' % (i + 1)
            UploadInstance(_REMOTE, p)
            dicomSize += os.path.getsize(GetDatabasePath(p))

        s = DoGet(_REMOTE, '/patients/ca29faea-b6a0e17f-067743a1-8b778011-a48b2a17/statistics')  # Consider Knee patient
        self.assertEqual(2, s['CountInstances'])
        self.assertEqual(2, s['CountSeries'])
        self.assertEqual(1, s['CountStudies'])

        self.assertEqual(dicomSize, int(s['DicomUncompressedSize']))
        
        if isCompressed:
            self.assertGreater(dicomSize, int(s['DicomDiskSize']))
            self.assertGreater(s['UncompressedSize'], s['DiskSize'])
            self.assertLess(dicomSize, int(s['UncompressedSize']))
        else:
            self.assertEqual(dicomSize, int(s['DicomDiskSize']))
            self.assertEqual(s['UncompressedSize'], s['DiskSize'])
            if IsOrthancVersionAbove(_REMOTE, 1, 9, 1):
                if IsDicomUntilPixelDataStored(_REMOTE):
                    self.assertLess(dicomSize, int(s['UncompressedSize']))
                else:
                    self.assertEqual(dicomSize, int(s['UncompressedSize']))
            else:
                # In Orthanc <= 1.9.0, there is the "dicom-as-json"
                # attachment in addition to the DICOM file
                self.assertLess(dicomSize, int(s['UncompressedSize']))
                

    def test_changes_2(self):
        # More consistent behavior since Orthanc 1.5.2
        # https://groups.google.com/d/msg/orthanc-users/QhzB6vxYeZ0/YxabgqpfBAAJ

        # Make sure that this is not the first change
        self.assertEqual(0, len(DoGet(_REMOTE, '/instances')))
        a = UploadInstance(_REMOTE, 'DummyCT.dcm')['ID']
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))
        DoDelete(_REMOTE, '/instances/%s' % a)        

        # No more instance, but there were previous changes
        self.assertEqual(0, len(DoGet(_REMOTE, '/instances')))
        
        c = DoGet(_REMOTE, '/changes')
        self.assertEqual(0, len(c['Changes']))
        self.assertTrue(c['Done'])
        seq = c['Last']

        c = DoGet(_REMOTE, '/changes?last')
        self.assertEqual(0, len(c['Changes']))
        self.assertTrue(c['Done'])
        self.assertEqual(seq, c['Last'])

        c = DoGet(_REMOTE, '/changes?since=%d' % (seq + 1000))
        self.assertEqual(0, len(c['Changes']))
        self.assertTrue(c['Done'])
        self.assertEqual(seq, c['Last'])

        # Add one instance
        UploadInstance(_REMOTE, 'DummyCT.dcm')
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))

        c = DoGet(_REMOTE, '/changes')
        self.assertEqual(4, len(c['Changes']))
        self.assertTrue(c['Done'])
        self.assertEqual(seq + 4, c['Last'])

        c = DoGet(_REMOTE, '/changes?last')
        self.assertEqual(1, len(c['Changes']))
        self.assertTrue(c['Done'])
        self.assertEqual(seq + 4, c['Last'])

        c = DoGet(_REMOTE, '/changes?since=%d' % (seq + 1000))
        self.assertEqual(0, len(c['Changes']))
        self.assertTrue(c['Done'])
        self.assertEqual(seq + 4, c['Last'])

        # Add, then delete, one user-defined metadata: This triggers 2
        # changes of type "UpdatedMetadata"
        i = DoGet(_REMOTE, '/instances') [0]
        DoPut(_REMOTE, '/instances/%s/metadata/4000' % i, 'hello', 'text/plain')

        (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/metadata/4000' % i)
        self.assertEqual('200', headers['status'])
        self.assertEqual('hello', body)

        c = DoGet(_REMOTE, '/changes?last')
        self.assertEqual(1, len(c['Changes']))
        self.assertTrue(c['Done'])
        self.assertEqual(seq + 5, c['Last'])
        self.assertEqual('UpdatedMetadata', c['Changes'][0]['ChangeType'])

        if IsOrthancVersionAbove(_REMOTE, 1, 9, 2):
            DoDelete(_REMOTE, '/instances/%s/metadata/4000' % i, headers = {
                'If-Match' : headers['etag']
            })
        else:
            self.assertFalse('etag' in headers)
            DoDelete(_REMOTE, '/instances/%s/metadata/4000' % i)
            
        c = DoGet(_REMOTE, '/changes?last')
        self.assertEqual(1, len(c['Changes']))
        self.assertTrue(c['Done'])
        self.assertEqual(seq + 6, c['Last'])
        self.assertEqual('UpdatedMetadata', c['Changes'][0]['ChangeType'])
        
        # Remove the uploaded instance
        DoDelete(_REMOTE, '/instances/%s' % a)
        self.assertEqual(0, len(DoGet(_REMOTE, '/instances')))

        c = DoGet(_REMOTE, '/changes')
        self.assertEqual(0, len(c['Changes']))
        self.assertTrue(c['Done'])
        self.assertEqual(seq + 6, c['Last'])

        c = DoGet(_REMOTE, '/changes?last')
        self.assertEqual(0, len(c['Changes']))
        self.assertTrue(c['Done'])
        self.assertEqual(seq + 6, c['Last'])

        c = DoGet(_REMOTE, '/changes?since=%d' % (seq + 1000))
        self.assertEqual(0, len(c['Changes']))
        self.assertTrue(c['Done'])
        self.assertEqual(seq + 6, c['Last'])
        

    def test_bitbucket_issue_124(self):
        a = UploadInstance(_REMOTE, 'Issue124.dcm')['ID']
        s = DoGet(_REMOTE, '/instances/%s/series' % a)['ID']

        z = GetArchive(_REMOTE, '/series/%s/media' % s)
        self.assertEqual(2, len(z.namelist()))


    def test_invalid_findscp(self):
        UploadInstance(_REMOTE, 'DummyCT.dcm')
        findscu = CallFindScu([ '-S', '-k', '8,52=IMAGE', '-k', '8,16', '-k', '2,2' ])
        self.assertEqual(0, len(re.findall('\(0002,0002\)', findscu)))


    def test_bitbucket_issue_90(self):
        def CountDicomResults(sex):
            a = CallFindScu([ '-S', '-k', '8,52=STUDY', '-k', sex ])
            return len(re.findall('\(0010,0040\)', a))

        def CountRestResults(sex):
            a = DoPost(_REMOTE, '/tools/find',
                       { 'Level' : 'Study', 'Query' : { 'PatientSex' : sex } })
            return len(a)

        # Just like the "CR000000.dcm" of the issue, the test image
        # "DummyCT.dcm" has the tag PatientSex (0010,0040) unset
        UploadInstance(_REMOTE, 'DummyCT.dcm')

        # Test that the behavior of DICOM vs. REST API is consistent on missing tags

        # In wildcard constraints, the patient sex must be set for a match to occur
        self.assertEqual(0, CountDicomResults('PatientSex=*'))
        self.assertEqual(0, CountRestResults('*'))

        # In single-valued constraints, the patient sex must be set
        self.assertEqual(0, CountDicomResults('PatientSex=F'))
        self.assertEqual(0, CountDicomResults('PatientSex=M'))
        self.assertEqual(0, CountRestResults('F'))
        self.assertEqual(0, CountRestResults('M'))

        # Empty constraints are only used to ask the actual value of
        # the tag to be added to the *answer*. The tag should not used
        # as a filter in such a situation.
        self.assertEqual(1, CountDicomResults('PatientSex'))
        self.assertEqual(1, CountDicomResults('PatientSex='))
        self.assertEqual(1, CountRestResults(''))   # This check fails on Orthanc <= 1.5.2 (issue 90)


    def test_rest_modalities_in_study(self):
        # Tests a regression that is present in Orthanc 1.5.2 and 1.5.3
        # https://groups.google.com/d/msg/orthanc-users/7lZyG3wpx-M/uOXzAzVCFwAJ
        UploadInstance(_REMOTE, 'ColorTestImageJ.dcm')

        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Study',
                                             'Query' : { 'ModalitiesInStudy' : 'US' }})
        self.assertEqual(0, len(a))

        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Study',
                                             'Query' : { 'ModalitiesInStudy' : 'US\\CT' }})
        self.assertEqual(1, len(a))

        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Study',
                                             'Query' : { 'ModalitiesInStudy' : 'CT' }})
        self.assertEqual(1, len(a))


    def test_series_status(self):
        def HasCompletedInChanges():
            for c in DoGet(_REMOTE, '/changes?limit=1000&since=0')['Changes']:
                if c['ChangeType'] == 'CompletedSeries':
                    return True;

            return False
        
        UploadInstance(_REMOTE, 'Knix/Loc/IM-0001-0001.dcm')
        series = '8ea120d7-5057d919-837dfbcc-ccd04e0f-7f3a94aa'
        self.assertEqual('Unknown', DoGet(_REMOTE, '/series/%s' % series)['Status'])
        self.assertFalse(HasCompletedInChanges())

        series = 'ce25ecb6-ed79d004-5ae43ca7-3fc89bc5-67511614'

        for i in range(3):
            UploadInstance(_REMOTE, 'Series/Lena-%d.dcm' % (i + 1))
            self.assertEqual('Missing', DoGet(_REMOTE, '/series/%s' % series)['Status'])
            self.assertFalse(HasCompletedInChanges())

        UploadInstance(_REMOTE, 'Series/Lena-4.dcm')
        self.assertEqual('Complete', DoGet(_REMOTE, '/series/%s' % series)['Status'])
        self.assertTrue(HasCompletedInChanges())

        DoDelete(_REMOTE, '/changes')
        self.assertFalse(HasCompletedInChanges())

        UploadInstance(_REMOTE, 'Series/Lena-5.dcm')
        self.assertEqual('Inconsistent', DoGet(_REMOTE, '/series/%s' % series)['Status'])
        self.assertFalse(HasCompletedInChanges())
            

    def test_dicomweb(self):
        if IsOrthancVersionAbove(_LOCAL, 1, 12, 5) and DoGet(_REMOTE, '/system')['ApiVersion'] >= 26:  # the references have changed with 1.12.5 -> we don't want to keep 2 references
            def Compare(dicom, reference):
                a = UploadInstance(_REMOTE, dicom) ['ID']
                b = DoGet(_REMOTE, '/instances/%s/file' % a,
                        headers = { 'Accept' : 'application/dicom+json' })
                with open(GetDatabasePath(reference), 'rb') as c:
                    d = json.load(c)
                    AssertAlmostEqualRecursive(self, d, b)
                        
            Compare('DummyCT.dcm', 'DummyCT.json')
            Compare('MarekLatin2.dcm', 'MarekLatin2.json')
            Compare('HierarchicalAnonymization/StructuredReports/IM0',
                    'HierarchicalAnonymization/StructuredReports/IM0.json')


    def test_issue_95_encodings(self):
        # https://bugs.orthanc-server.com/show_bug.cgi?id=95
        # Check out image: "../Database/Encodings/DavidClunie/charsettests.screenshot.png"

        # Very useful tool: "file2" from package "file-kanji"

        def GetPatientName(dicom):
            i = UploadInstance(_REMOTE, dicom) ['ID']
            j = DoGet(_REMOTE, '/instances/%s/tags?simplify' % i)
            return j['PatientName']
        
        def ComparePatientName(name, dicom):
            self.assertEqual(name, GetPatientName(dicom))

        # gdcmraw -t 10,10 -i SCSFREN -o /tmp/tag && uconv -f ISO-IR-100 -t UTF-8 /tmp/tag && echo
        ComparePatientName(u'Buc^Jérôme', 'Encodings/DavidClunie/SCSFREN')

        # gdcmraw -t 10,10 -i SCSI2 -o /tmp/tag && uconv -f KOREAN -t UTF-8 /tmp/tag && echo
        ComparePatientName(u'Hong^Gildong=洪^吉洞=홍^길동', 'Encodings/DavidClunie/SCSI2')  # Since Orthanc 1.5.5
        
        # gdcmraw -t 10,10 -i SCSX2 -o /tmp/tag && uconv -f GB18030 -t UTF-8 /tmp/tag && echo
        ComparePatientName(u'Wang^XiaoDong=王^小东=', 'Encodings/DavidClunie/SCSX2')

        # gdcmraw -t 10,10 -i SCSX1 -o /tmp/tag && cat /tmp/tag && echo
        ComparePatientName(u'Wang^XiaoDong=王^小東=', 'Encodings/DavidClunie/SCSX1')

        # gdcmraw -t 10,10 -i SCSH31 -o /tmp/tag && uconv -f JIS -t UTF-8 /tmp/tag && echo
        ComparePatientName(u'Yamada^Tarou=山田^太郎=やまだ^たろう', 'Encodings/DavidClunie/SCSH31')

        # gdcmraw -t 10,10 -i SCSGERM -o /tmp/tag && uconv -f ISO-IR-100 -t UTF-8 /tmp/tag && echo
        ComparePatientName(u'Äneas^Rüdiger', 'Encodings/DavidClunie/SCSGERM')

        # gdcmraw -t 10,10 -i SCSGREEK -o /tmp/tag && uconv -f ISO-IR-126 -t UTF-8 /tmp/tag && echo
        ComparePatientName(u'Διονυσιος', 'Encodings/DavidClunie/SCSGREEK')

        # gdcmraw -t 10,10 -i SCSRUSS -o /tmp/tag && uconv -f ISO-IR-144 -t UTF-8 /tmp/tag && echo
        ComparePatientName(u'Люкceмбypг', 'Encodings/DavidClunie/SCSRUSS')

        # gdcmraw -t 10,10 -i SCSHBRW -o /tmp/tag && uconv -f ISO-IR-138 -t UTF-8 /tmp/tag && echo
        # NB: Hebrew is a right-to-left encoding, copying/pasting from
        # Linux console into Emacs automatically reverse the string
        ComparePatientName(u'שרון^דבורה', 'Encodings/DavidClunie/SCSHBRW')

        # gdcmraw -t 10,10 -i SCSARAB -o /tmp/tag && uconv -f ISO-IR-127 -t UTF-8 /tmp/tag && echo
        # NB: Right-to-left as for Hebrew (SCSHBRW), and the Ubuntu console can't display such
        # characters by default, but copy/paste works with Emacs
        ComparePatientName(u'قباني^لنزار', 'Encodings/DavidClunie/SCSARAB')

        # SCSH32: This SpecificCharacterSet is composed of 2
        # codepages: "ISO 2022 IR 13" (i.e. "SHIFT_JIS") until the
        # first equal, then "ISO 2022 IR 87" (i.e. "JIS") for the
        # remainer. Orthanc only takes into consideration the first
        # codepage: This is a limitation.
        # gdcmraw -t 10,10 -i SCSH32 -o /tmp/tag && cut -d '=' -f 1 /tmp/tag | uconv -f SHIFT_JIS -t UTF-8
        self.assertTrue(GetPatientName('Encodings/DavidClunie/SCSH32').startswith(u'ヤマダ^タロウ='))


    def test_findscu_missing_tags(self):
        # dcmodify -e Rows DummyCTInvalidRows.dcm -gst -gse -gin
        UploadInstance(_REMOTE, 'DummyCT.dcm')
        UploadInstance(_REMOTE, 'DummyCTInvalidRows.dcm')

        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)
        self.assertEqual(1, len(rows))
        self.assertEqual('512', rows[0])



    def test_bitbucket_issue_131(self):
        # "Orthanc PACS silently fails to C-MOVE due to duplicate
        # StudyInstanceUID in it's database."
        # https://bugs.orthanc-server.com/show_bug.cgi?id=131

        # Insert 2 instances, with the same StudyInstanceUID, but with
        # different patient IDs. Orthanc will create 2 distincts
        # patients, and the hierarchy of resources above the two
        # instances will be fully disjoint.
        UploadInstance(_REMOTE, 'PatientIdsCollision/Issue131-a.dcm')
        UploadInstance(_REMOTE, 'PatientIdsCollision/Issue131-b.dcm')

        self.assertEqual(2, len(DoGet(_REMOTE, '/patients')))
        self.assertEqual(2, len(DoGet(_REMOTE, '/studies')))

        a = DoPost(_REMOTE, '/modalities/self/query', {
            'Level' : 'Study',
            'Query' : {"PatientID": "A" }})['ID']

        # 1 study is matched
        self.assertEqual(1, len(DoGet(_REMOTE, '/queries/%s/answers' % a)))

        self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))
        WaitAllNewJobsDone(_REMOTE, lambda: DoPost
                           (_REMOTE, '/queries/%s/retrieve' % a,
                            '{"TargetAet":"ORTHANCTEST","Synchronous":false}'))

        # The two studies are matched, as we made the request at the
        # Study level, thus the shared StudyInstanceUID is used as the key
        self.assertEqual(2, len(DoGet(_LOCAL, '/instances')))


        # Match the 2 studies
        a = DoPost(_REMOTE, '/modalities/self/query', {
            'Level' : 'Study',
            'Query' : {"StudyInstanceUID": "2.25.123" }})['ID']
        self.assertEqual(2, len(DoGet(_REMOTE, '/queries/%s/answers' % a)))
        DropOrthanc(_LOCAL)
        self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))
        WaitAllNewJobsDone(_REMOTE, lambda: DoPost
                           (_REMOTE, '/queries/%s/retrieve' % a,
                            '{"TargetAet":"ORTHANCTEST","Synchronous":false}'))
        self.assertEqual(2, len(DoGet(_LOCAL, '/instances')))

        
        # Same test, at the patient level => only 1 instance is transfered
        a = DoPost(_REMOTE, '/modalities/self/query', {
            'Level' : 'Patient',
            'Query' : {"PatientID": "A" }})['ID']
        self.assertEqual(1, len(DoGet(_REMOTE, '/queries/%s/answers' % a)))
        DropOrthanc(_LOCAL)
        self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))
        WaitAllNewJobsDone(_REMOTE, lambda: DoPost
                           (_REMOTE, '/queries/%s/retrieve' % a,
                            '{"TargetAet":"ORTHANCTEST","Synchronous":false}'))
        self.assertEqual(1, len(DoGet(_LOCAL, '/instances')))
        

        # Same test, at the series level => only 1 instance is transfered
        a = DoPost(_REMOTE, '/modalities/self/query', {
            'Level' : 'Series',
            'Query' : {"PatientID": "A" }})['ID']
        self.assertEqual(1, len(DoGet(_REMOTE, '/queries/%s/answers' % a)))
        DropOrthanc(_LOCAL)
        self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))
        WaitAllNewJobsDone(_REMOTE, lambda: DoPost
                           (_REMOTE, '/queries/%s/retrieve' % a,
                            '{"TargetAet":"ORTHANCTEST","Synchronous":false}'))
        self.assertEqual(1, len(DoGet(_LOCAL, '/instances')))
        

    def test_bitbucket_issue_136(self):
        UploadInstance(_REMOTE, 'Issue137.dcm')
        i = CallFindScu([ '-k', '0008,0052=STUDY', '-k', '0010,0010', '-k', '0028,0010', '-k', '0040,0275' ])
        patientNames = re.findall('\(0010,0010\).*?\[(.*?)\]', i)
        self.assertEqual(1, len(patientNames))
        self.assertEqual('John Doe', patientNames[0])


    def test_anonymize_relationships_4(self):
        # https://groups.google.com/d/msg/orthanc-users/UkcsqyTpszE/bXUpzU0vAAAJ
        sr1 = UploadInstance(_REMOTE, 'HierarchicalAnonymization/2019-03-28/CR000000.dcm')['ID']
        mr1 = UploadInstance(_REMOTE, 'HierarchicalAnonymization/2019-03-28/PR000000.dcm')['ID']
        study = '0c923249-d52121a9-2b7167f7-6b85534f-0943697e'
        
        anonymized = DoPost(_REMOTE, '/studies/%s/anonymize' % study, '{}',
                            'application/json')['ID']
        series = DoGet(_REMOTE, '/studies/%s/series' % anonymized)
        self.assertEqual(2, len(series))

        cr = list(filter(lambda x: x['MainDicomTags']['Modality'] == 'CR', series))
        pr = list(filter(lambda x: x['MainDicomTags']['Modality'] == 'PR', series))
        self.assertEqual(1, len(cr))
        self.assertEqual(1, len(pr))
        self.assertEqual(1, len(cr[0]['Instances']))
        self.assertEqual(1, len(pr[0]['Instances']))

        crinstance = DoGet(_REMOTE, '/instances/%s' % cr[0]['Instances'][0])
        tags = DoGet(_REMOTE, '/instances/%s/tags?short' % pr[0]['Instances'][0])

        self.assertEqual(tags['0008,1115'][0]['0008,1140'][0]['0008,1155'],
                         crinstance['MainDicomTags']['SOPInstanceUID'])
        self.assertEqual(tags['0008,1115'][0]['0008,1140'][0]['0008,1150'],
                         '1.2.840.10008.5.1.4.1.1.1')  # SOP class for CR Image Storage

        # This fails on Orthanc <= 1.5.6
        self.assertEqual(tags['0008,1115'][0]['0020,000e'],
                         cr[0]['MainDicomTags']['SeriesInstanceUID'])


    def test_anonymize_relationships_5(self):
        ct1 = UploadInstance(_REMOTE, 'HierarchicalAnonymization/RTH/CT01.dcm')
        rt1 = UploadInstance(_REMOTE, 'HierarchicalAnonymization/RTH/RT.dcm')
        oStudyId = ct1['ParentStudy']
        oCtInstanceId = ct1['ID']
        oRtInstanceId = rt1['ID']

        oCtTags = DoGet(_REMOTE, '/instances/%s/tags?simplify' % oCtInstanceId)
        oRtTags = DoGet(_REMOTE, '/instances/%s/tags?simplify' % oRtInstanceId)

        # first validate the relationships in the source data
        oStudyUID = oCtTags['StudyInstanceUID']
        oRtSeriesUID = oRtTags['SeriesInstanceUID']
        oRtInstanceUID = oRtTags['SOPInstanceUID']
        oRtFrameOfReferenceUID = oRtTags['ReferencedFrameOfReferenceSequence'][0]['FrameOfReferenceUID']
        oCtSeriesUID = oCtTags['SeriesInstanceUID']
        oCtInstanceUID = oCtTags['SOPInstanceUID']
        oCtFrameOfReferenceUID = oCtTags['FrameOfReferenceUID']

        oContourSequenceCount = len(oRtTags['ROIContourSequence'][0]['ContourSequence'])
        self.assertEqual(oCtFrameOfReferenceUID, oRtFrameOfReferenceUID)
        self.assertEqual(oStudyUID, oRtTags['ReferencedFrameOfReferenceSequence'][0]['RTReferencedStudySequence'][0]['ReferencedSOPInstanceUID'])
        self.assertEqual(oCtSeriesUID, oRtTags['ReferencedFrameOfReferenceSequence'][0]['RTReferencedStudySequence'][0]['RTReferencedSeriesSequence'][0]['SeriesInstanceUID'])
        self.assertEqual(oCtInstanceUID, oRtTags['ReferencedFrameOfReferenceSequence'][0]['RTReferencedStudySequence'][0]['RTReferencedSeriesSequence'][0]['ContourImageSequence'][0]['ReferencedSOPInstanceUID'])
        self.assertEqual(oCtInstanceUID, oRtTags['ROIContourSequence'][0]['ContourSequence'][oContourSequenceCount-1]['ContourImageSequence'][0]['ReferencedSOPInstanceUID'])

        ### anonymize

        aStudyId = DoPost(_REMOTE, '/studies/%s/anonymize' % oStudyId, '{}',
                            'application/json')['ID']

        ### validate

        aSeries = DoGet(_REMOTE, '/studies/%s/series' % aStudyId)
        self.assertEqual(2, len(aSeries))

        aCt = list(filter(lambda x: x['MainDicomTags']['Modality'] == 'CT', aSeries))
        aRt = list(filter(lambda x: x['MainDicomTags']['Modality'] == 'RTSTRUCT', aSeries))
        aCtInstanceId = aCt[0]['Instances'][0]
        aRtInstanceId = aRt[0]['Instances'][0]
        aCtTags = DoGet(_REMOTE, '/instances/%s/tags?simplify' % aCtInstanceId)
        aRtTags = DoGet(_REMOTE, '/instances/%s/tags?simplify' % aRtInstanceId)

        # now validate the relationships in the anonymized data
        aStudyUID = aCtTags['StudyInstanceUID']
        aRtSeriesUID = aRtTags['SeriesInstanceUID']
        aRtInstanceUID = aRtTags['SOPInstanceUID']
        aRtFrameOfReferenceUID = aRtTags['ReferencedFrameOfReferenceSequence'][0]['FrameOfReferenceUID']
        aCtSeriesUID = aCtTags['SeriesInstanceUID']
        aCtInstanceUID = aCtTags['SOPInstanceUID']
        aCtFrameOfReferenceUID = aCtTags['FrameOfReferenceUID']

        aContourSequenceCount = len(aRtTags['ROIContourSequence'][0]['ContourSequence'])
        # make sure all UIDs have been updated
        self.assertNotEqual(oStudyUID, aStudyUID)
        self.assertNotEqual(oRtSeriesUID, aRtSeriesUID)
        self.assertNotEqual(oRtInstanceUID, aRtInstanceUID)
        self.assertNotEqual(oRtFrameOfReferenceUID, aRtFrameOfReferenceUID)
        self.assertNotEqual(oCtSeriesUID, aCtSeriesUID)
        self.assertNotEqual(oCtInstanceUID, aCtInstanceUID)
        self.assertNotEqual(oCtFrameOfReferenceUID, aCtFrameOfReferenceUID)

        # validate the relationships
        self.assertEqual(oContourSequenceCount, aContourSequenceCount)
        self.assertEqual(aCtFrameOfReferenceUID, aRtFrameOfReferenceUID)
        self.assertEqual(aStudyUID, aRtTags['ReferencedFrameOfReferenceSequence'][0]['RTReferencedStudySequence'][0]['ReferencedSOPInstanceUID'])
        self.assertEqual(aCtSeriesUID, aRtTags['ReferencedFrameOfReferenceSequence'][0]['RTReferencedStudySequence'][0]['RTReferencedSeriesSequence'][0]['SeriesInstanceUID'])
        self.assertEqual(aCtInstanceUID, aRtTags['ReferencedFrameOfReferenceSequence'][0]['RTReferencedStudySequence'][0]['RTReferencedSeriesSequence'][0]['ContourImageSequence'][0]['ReferencedSOPInstanceUID'])
        self.assertEqual(aCtInstanceUID, aRtTags['ROIContourSequence'][0]['ContourSequence'][aContourSequenceCount-1]['ContourImageSequence'][0]['ReferencedSOPInstanceUID'])

    def test_anonymize_relationships_5b(self):
        # same test as previous one but, this time, we force the StudyInstanceUID
        ct1 = UploadInstance(_REMOTE, 'HierarchicalAnonymization/RTH/CT01.dcm')
        rt1 = UploadInstance(_REMOTE, 'HierarchicalAnonymization/RTH/RT.dcm')
        oStudyId = ct1['ParentStudy']
        oCtInstanceId = ct1['ID']
        oRtInstanceId = rt1['ID']

        oCtTags = DoGet(_REMOTE, '/instances/%s/tags?simplify' % oCtInstanceId)
        oRtTags = DoGet(_REMOTE, '/instances/%s/tags?simplify' % oRtInstanceId)

        ### anonymize while forcing the StudyInstanceUID

        aStudyId = DoPost(_REMOTE, '/studies/%s/anonymize' % oStudyId, '{ "Replace" : { "StudyInstanceUID" : "1.2.3.4"}, "Force": true}',
                            'application/json')['ID']

        ### validate

        aSeries = DoGet(_REMOTE, '/studies/%s/series' % aStudyId)
        self.assertEqual(2, len(aSeries))

        aCt = list(filter(lambda x: x['MainDicomTags']['Modality'] == 'CT', aSeries))
        aRt = list(filter(lambda x: x['MainDicomTags']['Modality'] == 'RTSTRUCT', aSeries))
        aCtInstanceId = aCt[0]['Instances'][0]
        aRtInstanceId = aRt[0]['Instances'][0]
        aCtTags = DoGet(_REMOTE, '/instances/%s/tags?simplify' % aCtInstanceId)
        aRtTags = DoGet(_REMOTE, '/instances/%s/tags?simplify' % aRtInstanceId)

        self.assertEqual("1.2.3.4", aCtTags['StudyInstanceUID'])
        self.assertEqual("1.2.3.4", aRtTags['StudyInstanceUID'])
        self.assertEqual("1.2.3.4", aRtTags['ReferencedFrameOfReferenceSequence'][0]['RTReferencedStudySequence'][0]['ReferencedSOPInstanceUID'])
        

    def test_bitbucket_issue_140(self):
        # "Modifying private tags with REST API changes VR from LO to
        # UN." This test fails if DCMTK <= 3.6.1 (e.g. fails on Ubuntu 16.04).
        # https://bugs.orthanc-server.com/show_bug.cgi?id=140
        source = UploadInstance(_REMOTE, 'Issue140.dcm') ['ID']
        series = DoGet(_REMOTE, '/instances/%s' % source) ['ParentSeries']

        target = DoPost(_REMOTE, '/series/%s/modify' % series, {
            'Replace' : { 'RadioButton3' : 'aaabbbccc' },
            'PrivateCreator' : 'RadioLogic',  # <= the trick is here
        }, 'application/json') ['ID']

        instances = DoGet(_REMOTE, '/series/%s/instances' % target)
        self.assertEqual(1, len(instances))

        tags = DoGet(_REMOTE, '/instances/%s/tags' % source)
        t = tags['4321,1012']
        self.assertEqual('String', t['Type'])
        self.assertEqual('RadioButton3', t['Name'])
        self.assertEqual('RadioLogic', t['PrivateCreator'])
        self.assertEqual('jklmopq', t['Value'])

        tags = DoGet(_REMOTE, '/instances/%s/tags' % instances[0]['ID'])
        t = tags['4321,1012']
        self.assertEqual('String', t['Type'])   # This fails if DCMTK <= 3.6.1
        self.assertEqual('RadioButton3', t['Name'])
        self.assertEqual('RadioLogic', t['PrivateCreator'])
        self.assertEqual('aaabbbccc', t['Value'])


    def test_find_normalize(self):
        # https://groups.google.com/d/msg/orthanc-users/AIwooGjsh94/YL28MNY4AgAJ
        
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')

        a = DoPost(_REMOTE, '/modalities/self/query', {
            'Level' : 'Instance',
            'Query' : { 'Rows' : '42' }
        }) ['ID']

        b = DoGet(_REMOTE, '/queries/%s/answers?expand&simplify' % a)
        self.assertEqual(1, len(b))
        self.assertFalse('Rows' in b[0])

        a = DoPost(_REMOTE, '/modalities/self/query', {
            'Level' : 'Instance',
            'Query' : { 'Rows' : '42' },
            'Normalize' : False
        }) ['ID']

        b = DoGet(_REMOTE, '/queries/%s/answers' % a)
        self.assertEqual(0, len(b))

        a = DoPost(_REMOTE, '/modalities/self/query', {
            'Level' : 'Instance',
            'Query' : { 'Rows' : '512' },
            'Normalize' : False
        }) ['ID']

        b = DoGet(_REMOTE, '/queries/%s/answers?expand&simplify' % a)
        self.assertEqual(1, len(b))
        self.assertTrue('Rows' in b[0])
        self.assertEqual('512', b[0]['Rows'])

        a = DoPost(_REMOTE, '/modalities/self/query', {
            'Level' : 'Instance',
            'Query' : { 'Rows' : '' },
            'Normalize' : False
        }) ['ID']

        b = DoGet(_REMOTE, '/queries/%s/answers?expand&simplify' % a)
        self.assertEqual(1, len(b))
        self.assertTrue('Rows' in b[0])
        self.assertEqual('512', b[0]['Rows'])

        self.assertRaises(Exception, lambda: DoPost(
            _REMOTE, '/modalities/self/query', {
                'Level' : 'Instance',
                'Query' : { 'Rows' : '*' },  # Out-of-range value
                'Normalize' : False
            }))


    def test_bitbucket_issue_141(self):
        # https://bugs.orthanc-server.com/show_bug.cgi?id=141
        a = UploadInstance(_REMOTE, 'Issue141.dcm') ['ID']
        study = '494c8037-b237f263-d8f15075-c8cb2280-daf39bd1'

        with open(GetDatabasePath('HelloWorld.pdf'), 'rb') as f:
            pdf = f.read()

        b = DoPost(_REMOTE, '/tools/create-dicom', {
                'Parent' : study,
                'Tags' : {},
                'Content' : 'data:application/pdf;base64,' + base64.b64encode(pdf)
                }) ['ID']
        
        tagsA = DoGet(_REMOTE, '/instances/%s/tags?short' % a)
        tagsB = DoGet(_REMOTE, '/instances/%s/tags?short' % b)
        self.assertEqual(tagsA['0008,0005'], tagsB['0008,0005'])
        self.assertEqual(tagsA['0008,1030'], tagsB['0008,1030'])


    def test_modifying_missing_patientid(self):
        # https://groups.google.com/d/msg/orthanc-users/aphG_h1AHVg/rfOTtTPTAgAJ
        UploadInstance(_REMOTE, '2019-06-17-VedranZdesic.dcm')
        DoPost(_REMOTE, '/studies/0c4aca1d-c107a241-6659d6aa-594c674a-a468b94a/modify', {})


    def test_log_level(self):
        # https://bugs.orthanc-server.com/show_bug.cgi?id=65
        original = DoGet(_REMOTE, '/tools/log-level')
        
        DoPut(_REMOTE, '/tools/log-level', 'default')
        self.assertEqual('default', DoGet(_REMOTE, '/tools/log-level'))
        DoGet(_REMOTE, '/system')

        DoPut(_REMOTE, '/tools/log-level', 'verbose')
        self.assertEqual('verbose', DoGet(_REMOTE, '/tools/log-level'))
        DoGet(_REMOTE, '/system')

        DoPut(_REMOTE, '/tools/log-level', 'trace')
        self.assertEqual('trace', DoGet(_REMOTE, '/tools/log-level'))
        DoGet(_REMOTE, '/system')

        self.assertRaises(Exception, lambda: DoPut(_REMOTE, '/tools/log-level', 'nope'))
        
        # Switch back to the original log level
        DoPut(_REMOTE, '/tools/log-level', original)


    def test_upload_compressed(self):
        # New in Orthanc 1.6.0
        with open(GetDatabasePath('DummyCT.dcm.gz'), 'rb') as f:
            d = f.read()

        self.assertRaises(Exception, lambda: DoPost(_REMOTE, '/instances', d, 'application/dicom'))
        
        self.assertRaises(Exception, lambda: DoPost(_REMOTE, '/instances', d, 'application/dicom',
                                                    headers = { 'Content-Encoding' : 'nope' }))
        
        a = DoPost(_REMOTE, '/instances', d, 'application/dicom',
                   headers = { 'Content-Encoding' : 'gzip' })
        self.assertEqual('66a662ce-7430e543-bad44d47-0dc5a943-ec7a538d', a['ID'])
        
        
    def test_study_series_find_inconsistency(self):
        # https://groups.google.com/forum/#!topic/orthanc-users/bLv6Z11COy0

        def CountAnswers(query):
            a = DoPost(_REMOTE, 'modalities/self/query', query)
            return len(DoGet(_REMOTE, '%s/answers' % a['Path']))

        # This instance has "SeriesDescription" (0008,103e) tag, but no
        # "ProtocolName" (0018,1030). Both of those tags are part of
        # the "main DICOM tags" of Orthanc.
        UploadInstance(_REMOTE, 'Issue137.dcm')

        
        self.assertEqual(1, CountAnswers({
            'Level' : 'Study',
        }))

        self.assertEqual(1, CountAnswers({
            'Level' : 'Series',
        }))

        
        ##
        ## "SeriesDescription" is present, and has VR "CS" => wildcard is allowed
        ## http://dicom.nema.org/medical/dicom/2019e/output/chtml/part04/sect_C.2.2.2.4.html
        ##

        # At the study level, the "SeriesDescription" tag is not allowed, but
        # is wiped out by the normalization
        self.assertEqual(0, CountAnswers({
            'Level' : 'Study',
            'Query' : {
                'SeriesDescription' : 'NOPE'
            },
            'Normalize' : False
        }))
        self.assertEqual(0, CountAnswers({
            # This test fails on Orthanc <= 1.5.8
            'Level' : 'Study',
            'Query' : {
                'ImageComments' : '*'  # Wildcard matching => no match, as the tag is absent
            },
            'Normalize' : False
        }))
        self.assertEqual(1, CountAnswers({
            'Level' : 'Study',
            'Query' : {
                'ImageComments' : ''
            },
            'Normalize' : False
        }))
        self.assertEqual(1, CountAnswers({
            'Level' : 'Study',
            'Query' : {
                'SeriesDescription' : 'THIS^VALUE^IS^WIPED^OUT'
            },
            'Normalize' : True
        }))
        self.assertEqual(1, CountAnswers({
            'Level' : 'Study',
            'Query' : {
                'ImageComments' : '*'  # Matches, as wiped out by the normalization
            },
            'Normalize' : True
        }))
        self.assertEqual(1, CountAnswers({
            'Level' : 'Study',
            'Query' : {
                'ImageComments' : ''
            },
            'Normalize' : True
        }))

        for normalize in [ True, False ]:
            # At the series level, the "SeriesDescription" tag is allowed, and
            # normalization has no effect

            # "Universal matching" will match all entities, including
            # those with the missing tag
            # http://dicom.nema.org/medical/dicom/2019e/output/chtml/part04/sect_C.2.2.2.3.html
            self.assertEqual(1, CountAnswers({
                'Level' : 'Series',
                'Query' : {
                    'SeriesDescription' : ''  # Universal matching
                },
                'Normalize' : normalize,
            }))
            # "Universal matching" will match all entities, including
            # those with the missing tag
            # http://dicom.nema.org/medical/dicom/2019e/output/chtml/part04/sect_C.2.2.2.3.html
            self.assertEqual(1, CountAnswers({
                'Level' : 'Series',
                'Query' : {
                    'SeriesDescription' : '*'  # Wildcard matching
                },
                'Normalize' : normalize,
            }))
            self.assertEqual(1, CountAnswers({
                'Level' : 'Series',
                'Query' : {
                    'SeriesDescription' : '*model*'  # The actual value is "STL model: intraop Report"
                },
                'Normalize' : normalize,
            }))
            self.assertEqual(0, CountAnswers({
                'Level' : 'Series',
                'Query' : {
                    'SeriesDescription' : '*MISMATCHED^VALUE*'
                },
                'Normalize' : normalize,
            }))

            # Universal matching matches any instance, even if the
            # query is at the study-level, and thus if "SeriesDescription"
            # makes no sense
            self.assertEqual(1, CountAnswers({
                'Level' : 'Study',
                'Query' : {
                    'SeriesDescription' : ''  # Universal matching
                },
                'Normalize' : normalize,
            }))
        


        ##
        ## "ProtocolName" is absent, and has VR "CS" => wildcard is allowed
        ##

        # At the study level, the "ProtocolName" tag is not allowed, but
        # is wiped out by the normalization
        self.assertEqual(0, CountAnswers({
            'Level' : 'Study',
            'Query' : {
                'ProtocolName' : 'NOPE'
            },
            'Normalize' : False
        }))
        self.assertEqual(0, CountAnswers({
            'Level' : 'Study',
            'Query' : {
                'ProtocolName' : '*'  # Wildcard matching => no match, as the tag is absent
            },
            'Normalize' : False
        }))
        self.assertEqual(1, CountAnswers({
            'Level' : 'Study',
            'Query' : {
                'ProtocolName' : 'THIS^VALUE^IS^WIPED^OUT'
            },
            'Normalize' : True
        }))
        self.assertEqual(1, CountAnswers({
            'Level' : 'Study',
            'Query' : {
                'ProtocolName' : '*'  # Matches, as wiped out by the normalization
            },
            'Normalize' : True
        }))

        for normalize in [ True, False ]:
            # At the series level, the "ProtocolName" tag is allowed, and
            # normalization has no effect

            self.assertEqual(1, CountAnswers({
                'Level' : 'Series',
                'Query' : {
                    'ProtocolName' : ''  # Universal matching
                },
                'Normalize' : normalize,
            }))
            self.assertEqual(0, CountAnswers({
                'Level' : 'Series',
                'Query' : {
                    'ProtocolName' : '*'  # Wildcard matching => no match, as the tag is absent
                },
                'Normalize' : normalize,
            }))
            self.assertEqual(0, CountAnswers({
                'Level' : 'Series',
                'Query' : {
                    'ProtocolName' : '*MISMATCHED^VALUE*'
                },
                'Normalize' : normalize,
            }))

            self.assertEqual(1, CountAnswers({
                'Level' : 'Study',
                'Query' : {
                    'ProtocolName' : '' # Universal matching
                },
                'Normalize' : normalize,
            }))
            

        ##
        ## "StudyInstanceUID" is present, and has VR "UI" => wildcard is not allowed
        ##

        for level in [ 'Study', 'Series' ] :
            for normalize in [ True, False ]:
                self.assertEqual(1, CountAnswers({
                    'Level' : level,
                    'Query' : {
                        'StudyInstanceUID' : ''  # Universal matching
                    },
                    'Normalize' : normalize,
                }))
                self.assertEqual(0, CountAnswers({
                    'Level' : level,
                    'Query' : {
                        'StudyInstanceUID' : 'MISMATCHED^VALUE'
                    },
                    'Normalize' : normalize,
                }))
                self.assertEqual(1, CountAnswers({
                    'Level' : level,
                    'Query' : {
                        'StudyInstanceUID' : '4.5.6'  # This is the actual value
                    },
                    'Normalize' : normalize,
                }))

                # Wildcard matching is not allowed for this VR
                # This test fails on Orthanc <= 1.5.8
                self.assertRaises(Exception, lambda: CountAnswers({
                    'Level' : level,
                    'Query' : {
                        'StudyInstanceUID' : '*'
                    },
                    'Normalize' : normalize,
                }))


    def test_rendered(self):
        # New in Orthanc 1.6.0
        i = UploadInstance(_REMOTE, 'ColorTestMalaterre.dcm')['ID']
        im = GetImage(_REMOTE, '/instances/%s/rendered' % i)
        self.assertEqual("RGB", im.mode)
        self.assertEqual(41, im.size[0])
        self.assertEqual(41, im.size[1])

        # http://effbot.org/zone/pil-comparing-images.htm
        truth = Image.open(GetDatabasePath('ColorTestMalaterre.png'))
        self.assertTrue(ImageChops.difference(im, truth).getbbox() is None)

        im = GetImage(_REMOTE, '/instances/%s/rendered?width=10' % i)
        self.assertEqual("RGB", im.mode)
        self.assertEqual(10, im.size[0])
        self.assertEqual(10, im.size[1])
        
        im = GetImage(_REMOTE, '/instances/%s/rendered?height=10' % i)
        self.assertEqual("RGB", im.mode)
        self.assertEqual(10, im.size[0])
        self.assertEqual(10, im.size[1])
                
        im = GetImage(_REMOTE, '/instances/%s/rendered?height=128' % i)
        self.assertEqual("RGB", im.mode)
        self.assertEqual(128, im.size[0])
        self.assertEqual(128, im.size[1])
                
        im = GetImage(_REMOTE, '/instances/%s/rendered?height=10&smooth=0' % i)
        self.assertEqual("RGB", im.mode)
        self.assertEqual(10, im.size[0])
        self.assertEqual(10, im.size[1])
                
        im = GetImage(_REMOTE, '/instances/%s/rendered?height=10&smooth=1' % i)
        self.assertEqual("RGB", im.mode)
        self.assertEqual(10, im.size[0])
        self.assertEqual(10, im.size[1])
                
        im = GetImage(_REMOTE, '/instances/%s/rendered?height=5&width=10' % i)
        self.assertEqual("RGB", im.mode)
        self.assertEqual(5, im.size[0])
        self.assertEqual(5, im.size[1])

        
        # Grayscale image
        i = UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm')['ID']
        im = GetImage(_REMOTE, '/instances/%s/rendered' % i)
        self.assertEqual("L", im.mode)
        self.assertEqual(288, im.size[0])
        self.assertEqual(288, im.size[1])
        self.assertEqual(0, im.getpixel((0, 0)))

        # Those are the original windowing parameters that are written
        # inside the DICOM file
        im2 = GetImage(_REMOTE, '/instances/%s/rendered?window-center=248.009544468547&window-width=431.351843817788' % i)
        self.assertEqual("L", im2.mode)
        self.assertEqual(288, im2.size[0])
        self.assertEqual(288, im2.size[1])
        self.assertTrue(ImageChops.difference(im, im2).getbbox() is None)        

        im = GetImage(_REMOTE, '/instances/%s/rendered?width=512&smooth=0' % i)
        self.assertEqual("L", im.mode)
        self.assertEqual(512, im.size[0])
        self.assertEqual(512, im.size[1])

        im = GetImage(_REMOTE, '/instances/%s/rendered?width=10&smooth=0' % i)
        self.assertEqual("L", im.mode)
        self.assertEqual(10, im.size[0])
        self.assertEqual(10, im.size[1])

        im = GetImage(_REMOTE, '/instances/%s/rendered?width=10&smooth=1' % i)
        self.assertEqual("L", im.mode)
        self.assertEqual(10, im.size[0])
        self.assertEqual(10, im.size[1])

        im = GetImage(_REMOTE, '/instances/%s/rendered?width=1&window-center=-1000' % i)
        self.assertEqual("L", im.mode)
        self.assertEqual(1, im.size[0])
        self.assertEqual(1, im.size[1])
        self.assertEqual(255, im.getpixel((0, 0)))

        im = GetImage(_REMOTE, '/instances/%s/rendered?width=1&window-center=1000' % i)
        self.assertEqual("L", im.mode)
        self.assertEqual(1, im.size[0])
        self.assertEqual(1, im.size[1])
        self.assertEqual(0, im.getpixel((0, 0)))

        
        # Test monochrome 1
        i = UploadInstance(_REMOTE, 'Issue44/Monochrome1.dcm')['ID']
        im = GetImage(_REMOTE, '/instances/%s/rendered' % i)
        self.assertEqual("L", im.mode)
        self.assertEqual(2010, im.size[0])
        self.assertEqual(2446, im.size[1])
        self.assertEqual(0, im.getpixel((0, 0)))
        im = GetImage(_REMOTE, '/instances/%s/rendered?width=20' % i)
        self.assertEqual("L", im.mode)
        self.assertEqual(20, im.size[0])
        self.assertEqual(24, im.size[1])
        im = GetImage(_REMOTE, '/instances/%s/rendered?height=24' % i)
        self.assertEqual("L", im.mode)
        self.assertEqual(20, im.size[0])
        self.assertEqual(24, im.size[1])
        im = GetImage(_REMOTE, '/instances/%s/rendered?width=10&height=24' % i)
        self.assertEqual("L", im.mode)
        self.assertEqual(10, im.size[0])
        self.assertEqual(12, im.size[1])
        im = GetImage(_REMOTE, '/instances/%s/rendered?width=40&height=24' % i)
        self.assertEqual("L", im.mode)
        self.assertEqual(20, im.size[0])
        self.assertEqual(24, im.size[1])


    def test_bitbucket_issue_154(self):
        # "Matching against list of UID-s by C-MOVE"
        # https://bugs.orthanc-server.com/show_bug.cgi?id=154
        a = UploadInstance(_REMOTE, 'Issue154-d1.dcm') ['ID']
        b = UploadInstance(_REMOTE, 'Issue154-d2.dcm') ['ID']

        study = '1.2.826.0.1.3680043.8.498.35214236271657363033644818354280454731'
        series1 = '1.2.826.0.1.3680043.8.498.12243321927795467590791662266352305113'
        series2 = '1.2.826.0.1.3680043.8.498.43769499931624584079690260699536473555'

        # C-FIND is working on list of UIDs
        i = CallFindScu([ '-k', 'QueryRetrieveLevel=SERIES',
                          '-k', 'StudyInstanceUID=%s' % study,
                          '-k', 'SeriesInstanceUID=%s\\%s' % (series1, series2) ])
        series = re.findall('\(0020,000e\).*?\[(.*?)\]', i)
        self.assertEqual(2, len(series))
        self.assertTrue(series1 in series)
        self.assertTrue(series2 in series)
        
        # Individual retrieval is working in Orthanc < 1.6.0
        self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))
        self.assertTrue(MonitorJob(_REMOTE, lambda: CallMoveScu([
            '--study', '-k', 'QueryRetrieveLevel=SERIES',
            '-k', 'StudyInstanceUID=%s' % study,
            '-k', 'SeriesInstanceUID=%s' % series1,
            ])))
        self.assertTrue(MonitorJob(_REMOTE, lambda: CallMoveScu([
            '--study', '-k', 'QueryRetrieveLevel=SERIES',
            '-k', 'StudyInstanceUID=%s' % study,
            '-k', 'SeriesInstanceUID=%s' % series2,
            ])))
        self.assertEqual(2, len(DoGet(_LOCAL, '/instances')))

        DropOrthanc(_LOCAL)

        # But list matching is working only in Orthanc >= 1.6.0
        self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))
        self.assertTrue(MonitorJob(_REMOTE, lambda: CallMoveScu([
            '--study', '-k', 'QueryRetrieveLevel=SERIES',
            '-k', 'StudyInstanceUID=%s' % study,
            '-k', 'SeriesInstanceUID=%s\\%s' % (series1, series2),
            ])))
        self.assertEqual(2, len(DoGet(_LOCAL, '/instances')))


    def test_storage_commitment_api(self):
        # Storage commitment is available since Orthanc 1.6.0

        def WaitTransaction(uid):
            while True:
                s = DoGet(_REMOTE, '/storage-commitment/%s' % uid)
                if s['Status'] != 'Pending':
                    return s
                else:
                    time.sleep(0.01)
        
        instance = UploadInstance(_REMOTE, 'DummyCT.dcm')
        sopClassUid = '1.2.840.10008.5.1.4.1.1.4'
        sopInstanceUid = '1.2.840.113619.2.176.2025.1499492.7040.1171286242.109'

        # Against self
        transaction = DoPost(_REMOTE, '/modalities/self/storage-commitment', {
            "DicomInstances" : [ [ sopClassUid, sopInstanceUid ] ],
        }) ['ID']
        self.assertTrue(transaction.startswith('2.25.'))

        result = WaitTransaction(transaction)
        self.assertEqual('ORTHANC', result['RemoteAET'])
        self.assertEqual('Success', result['Status'])
        self.assertEqual(1, len(result['Success']))
        self.assertEqual(0, len(result['Failures']))
        self.assertEqual(sopClassUid, result['Success'][0]['SOPClassUID'])
        self.assertEqual(sopInstanceUid, result['Success'][0]['SOPInstanceUID'])
        
        tmp = DoPost(_REMOTE, '/modalities/self/storage-commitment', {
            "DicomInstances" : [
                { 'SOPClassUID' : sopClassUid,
                  'SOPInstanceUID' : sopInstanceUid },
            ],
        })
        self.assertEqual(tmp['Path'], '/storage-commitment/%s' % tmp['ID'])
        self.assertEqual(result, WaitTransaction(transaction))

        tmp = DoPost(_REMOTE, '/modalities/self/storage-commitment', {
            "Resources" : [
                instance['ID'],
                instance['ParentSeries'],
                instance['ParentStudy'],
                instance['ParentPatient'],
            ]
        })
        self.assertEqual(tmp['Path'], '/storage-commitment/%s' % tmp['ID'])
        self.assertEqual(result, WaitTransaction(transaction))

        
        transaction = DoPost(_REMOTE, '/modalities/self/storage-commitment', {
            "DicomInstances" : [
                [ 'nope', 'nope2' ],
                [ sopClassUid, sopInstanceUid ],
            ],
        }) ['ID']
        self.assertTrue(transaction.startswith('2.25.'))

        result = WaitTransaction(transaction)
        self.assertEqual('ORTHANC', result['RemoteAET'])
        self.assertEqual('Failure', result['Status'])
        self.assertEqual(1, len(result['Success']))
        self.assertEqual(1, len(result['Failures']))
        self.assertEqual(sopClassUid, result['Success'][0]['SOPClassUID'])
        self.assertEqual(sopInstanceUid, result['Success'][0]['SOPInstanceUID'])
        self.assertEqual('nope', result['Failures'][0]['SOPClassUID'])
        self.assertEqual('nope2', result['Failures'][0]['SOPInstanceUID'])
        self.assertEqual(274, result['Failures'][0]['FailureReason'])

        # Cannot remove items from a failed storage commitment transaction
        self.assertRaises(Exception, lambda:
                          DoPost(_REMOTE, '/storage-commitment/%s/remove' % transaction))
        
        
        # Against Orthanc 0.8.6, that does not support storage commitment
        if not IsOrthancVersionAbove(_LOCAL, 1, 11, 2):  # don't know which specific version the behaviour changed but this fails with 0.8.6
            self.assertRaises(Exception, lambda:
                            DoPost(_REMOTE, '/modalities/orthanctest/storage-commitment', {
                                "DicomInstances" : [
                                    [ sopClassUid, sopInstanceUid ],
                                ]
                            }))



    def test_storage_commitment_store(self):
        # Storage commitment is available since Orthanc 1.6.0

        def WaitTransaction(uid):
            while True:
                s = DoGet(_REMOTE, '/storage-commitment/%s' % uid)
                if s['Status'] != 'Pending':
                    return s
                else:
                    time.sleep(0.01)

        i = UploadInstance(_REMOTE, 'DummyCT.dcm')['ID']
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))
        self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))

        # The Orthanc 0.8.6 from "_LOCAL" does not support storage commitment
        if not IsOrthancVersionAbove(_LOCAL, 1, 11, 2):  # don't know which specific version the behaviour changed but this fails with 0.8.6
            self.assertRaises(Exception, lambda: DoPost(_REMOTE, '/modalities/orthanctest/store', {
                'Resources' : [ i ],
                'StorageCommitment' : True,
                }))

        j = DoPost(_REMOTE, '/modalities/orthanctest/store', {
            'Resources' : [ i ],
            'StorageCommitment' : False,
            })
        self.assertEqual(1, len(DoGet(_LOCAL, '/instances')))

        j = DoPost(_REMOTE, '/modalities/self/store', {
            'Resources' : [ i ],
            'StorageCommitment' : True,
            })

        transaction = j['StorageCommitmentTransactionUID']
        self.assertTrue(transaction.startswith('2.25.'))

        result = WaitTransaction(transaction)
        self.assertEqual('ORTHANC', result['RemoteAET'])
        self.assertEqual('Success', result['Status'])
        self.assertEqual(1, len(result['Success']))
        self.assertEqual(0, len(result['Failures']))
        self.assertEqual('1.2.840.10008.5.1.4.1.1.4', result['Success'][0]['SOPClassUID'])
        self.assertEqual('1.2.840.113619.2.176.2025.1499492.7040.1171286242.109',
                         result['Success'][0]['SOPInstanceUID'])

        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))
        DoPost(_REMOTE, '/storage-commitment/%s/remove' % transaction)
        self.assertEqual(0, len(DoGet(_REMOTE, '/instances')))


    def test_store_straight(self):  # New in Orthanc 1.6.1
        self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))
        self.assertEqual(0, len(DoGet(_REMOTE, '/instances')))

        with open(GetDatabasePath('DummyCT.dcm'), 'rb') as f:
            dicom = f.read()

        self.assertRaises(Exception, lambda: DoPost(
            _REMOTE, '/modalities/orthanctest/store-straight', 'nope', 'nope'))

        answer = DoPost(_REMOTE, '/modalities/orthanctest/store-straight', dicom, 'nope')

        self.assertEqual('1.2.840.10008.5.1.4.1.1.4',
                         answer['SOPClassUID'])
        self.assertEqual('1.2.840.113619.2.176.2025.1499492.7040.1171286242.109',
                         answer['SOPInstanceUID'])
        
        self.assertEqual(1, len(DoGet(_LOCAL, '/instances')))
        self.assertEqual(0, len(DoGet(_REMOTE, '/instances')))


    def test_storescu_transcoding(self):  # New in Orthanc 1.7.0       
        # Add a RLE-encoded DICOM file
        i = UploadInstance(_REMOTE, 'TransferSyntaxes/1.2.840.10008.1.2.5.dcm')['ID']
        self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))
        rleSize = len(DoGet(_REMOTE, '/instances/%s/file' % i))

        # Export the instance, with transcoding: "_REMOTE" is the
        # Orthanc server being tested
        try:
            DoDelete(_REMOTE, '/modalities/toto')
        except:
            pass

        params = DoGet(_REMOTE, '/modalities?expand') ['orthanctest']
        DoPut(_REMOTE, '/modalities/toto', params)
        DoPost(_REMOTE, '/modalities/toto/store', str(i), 'text/plain')
        j = DoGet(_LOCAL, '/instances')
        self.assertEqual(1, len(j))
        uncompressedSize = len(DoGet(_LOCAL, '/instances/%s/file' % j[0]))
        self.assertTrue(uncompressedSize > rleSize / 2)

        # Export, with transcoding disabled => this fails with 0.8.6 but not with more recent versions
        params['AllowTranscoding'] = False
        DoPut(_REMOTE, '/modalities/toto', params)
        if not IsOrthancVersionAbove(_LOCAL, 1, 11, 2):  # don't know which specific version the behaviour changed but this fails with 0.8.6
            self.assertRaises(Exception, lambda: DoPost(_REMOTE, '/modalities/toto/store', str(i), 'text/plain'))
        else:
            DoPost(_REMOTE, '/modalities/toto/store', str(i), 'text/plain')
        DoDelete(_REMOTE, '/modalities/toto')


    def test_bitbucket_issue_169(self):
        with open(GetDatabasePath('Issue169.dcm.bz2'), 'rb') as f:
            dicom = bz2.decompress(f.read())

        self.assertEqual('1.2.840.10008.1.2.1', GetTransferSyntax(dicom))

        self.assertEqual(44350560, len(dicom))
        i = DoPost(_REMOTE, '/instances', dicom, 'application/dicom') ['ID']
        
        tags = DoGet(_REMOTE, '/instances/%s/tags' % i)
        self.assertEqual('NORMAL', tags['1337,1001']['Value'])
        
        self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))
        DoPost(_REMOTE, '/modalities/orthanctest/store', str(i), 'text/plain')
        j = DoGet(_LOCAL, '/instances')
        self.assertEqual(1, len(j))

        # In Orthanc <= 1.6.1, transfer syntax changed from "Explicit
        # VR Little Endian" (1.2.840.10008.1.2.1) to "Implicit VR
        # Little Endian" (1.2.840.10008.1.2)
        self.assertEqual('1.2.840.10008.1.2.1', GetTransferSyntax(
            DoGet(_LOCAL, '/instances/%s/file' % j[0])))

        # In Orthanc <= 1.6.1, the value of the private tags was lost
        # because of this transcoding
        tags = DoGet(_LOCAL, '/instances/%s/tags' % j[0])
        self.assertEqual('NORMAL', tags['1337,1001']['Value'])


    def test_modify_transcode_instance(self):
        i = UploadInstance(_REMOTE, 'KarstenHilbertRF.dcm')['ID']
        self.assertEqual('1.2.840.10008.1.2.1', GetTransferSyntax(
            DoGet(_REMOTE, '/instances/%s/file' % i)))

        a = ExtractDicomTags(DoGet(_REMOTE, '/instances/%s/file' % i), [ 'SOPInstanceUID' ]) [0]
        self.assertTrue(len(a) > 20)

        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',
            '1.2.840.10008.1.2.4.57',
            '1.2.840.10008.1.2.4.70',
        ]

        if HasGdcmPlugin(_REMOTE):
            SYNTAXES = SYNTAXES + [
                '1.2.840.10008.1.2.4.80',  # This makes DCMTK 3.6.2 crash
                '1.2.840.10008.1.2.4.81',  # This makes DCMTK 3.6.2 crash
                '1.2.840.10008.1.2.4.90',  # JPEG2k, unavailable without GDCM
                '1.2.840.10008.1.2.4.91',  # JPEG2k, unavailable without GDCM
            ]
        
        for syntax in SYNTAXES:
            transcoded = DoPost(_REMOTE, '/instances/%s/modify' % i, {
                'Transcode' : syntax,
                'Keep' : [ 'SOPInstanceUID' ],
                'Force' : True,
                })
            
            self.assertEqual(syntax, GetTransferSyntax(transcoded))

            b = ExtractDicomTags(transcoded, [ 'SOPInstanceUID' ]) [0]
            self.assertTrue(len(b) > 20)
            if syntax in [ '1.2.840.10008.1.2.4.50',
                           '1.2.840.10008.1.2.4.51',
                           '1.2.840.10008.1.2.4.81',
                           '1.2.840.10008.1.2.4.91' ]:
                # Lossy transcoding: The SOP instance UID must have changed
                self.assertNotEqual(a, b)
            else:
                self.assertEqual(a, b)

    def test_archive_transcode(self):
        info = UploadInstance(_REMOTE, 'KarstenHilbertRF.dcm')

        # GET on "/media"
        z = GetArchive(_REMOTE, '/patients/%s/media' % info['ParentPatient'])
        self.assertEqual(2, len(z.namelist()))
        self.assertEqual('1.2.840.10008.1.2.1', GetTransferSyntax(z.read('IMAGES/IM0')))

        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/patients/%s/media?transcode=nope' % info['ParentPatient']))

        z = GetArchive(_REMOTE, '/patients/%s/media?transcode=1.2.840.10008.1.2.4.50' % info['ParentPatient'])
        self.assertEqual('1.2.840.10008.1.2.4.50', GetTransferSyntax(z.read('IMAGES/IM0')))

        z = GetArchive(_REMOTE, '/studies/%s/media?transcode=1.2.840.10008.1.2.4.51' % info['ParentStudy'])
        self.assertEqual('1.2.840.10008.1.2.4.51', GetTransferSyntax(z.read('IMAGES/IM0')))

        z = GetArchive(_REMOTE, '/series/%s/media?transcode=1.2.840.10008.1.2.4.57' % info['ParentSeries'])
        self.assertEqual('1.2.840.10008.1.2.4.57', GetTransferSyntax(z.read('IMAGES/IM0')))


        # POST on "/media"
        self.assertRaises(Exception, lambda: PostArchive(
            _REMOTE, '/patients/%s/media' % info['ParentPatient'], { 'Transcode' : 'nope' }))

        z = PostArchive(_REMOTE, '/patients/%s/media' % info['ParentPatient'], {
            'Transcode' : '1.2.840.10008.1.2.4.50',
            })
        self.assertEqual('1.2.840.10008.1.2.4.50', GetTransferSyntax(z.read('IMAGES/IM0')))

        z = PostArchive(_REMOTE, '/studies/%s/media' % info['ParentStudy'], {
            'Transcode' : '1.2.840.10008.1.2.4.51',
            })
        self.assertEqual('1.2.840.10008.1.2.4.51', GetTransferSyntax(z.read('IMAGES/IM0')))

        z = PostArchive(_REMOTE, '/series/%s/media' % info['ParentSeries'], {
            'Transcode' : '1.2.840.10008.1.2.4.57',
            })
        self.assertEqual('1.2.840.10008.1.2.4.57', GetTransferSyntax(z.read('IMAGES/IM0')))

        
        # GET on "/archive"
        z = GetArchive(_REMOTE, '/patients/%s/archive' % info['ParentPatient'])
        self.assertEqual(1, len(z.namelist()))
        self.assertEqual('1.2.840.10008.1.2.1', GetTransferSyntax(z.read(z.namelist()[0])))

        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/patients/%s/archive?transcode=nope' % info['ParentPatient']))

        z = GetArchive(_REMOTE, '/patients/%s/archive?transcode=1.2.840.10008.1.2' % info['ParentPatient'])
        self.assertEqual('1.2.840.10008.1.2', GetTransferSyntax(z.read(z.namelist()[0])))

        z = GetArchive(_REMOTE, '/studies/%s/archive?transcode=1.2.840.10008.1.2.2' % info['ParentStudy'])
        self.assertEqual('1.2.840.10008.1.2.2', GetTransferSyntax(z.read(z.namelist()[0])))

        z = GetArchive(_REMOTE, '/series/%s/archive?transcode=1.2.840.10008.1.2.4.70' % info['ParentSeries'])
        self.assertEqual('1.2.840.10008.1.2.4.70', GetTransferSyntax(z.read(z.namelist()[0])))


        # POST on "/archive"
        self.assertRaises(Exception, lambda: PostArchive(
            _REMOTE, '/patients/%s/archive' % info['ParentPatient'], { 'Transcode' : 'nope' }))

        z = PostArchive(_REMOTE, '/patients/%s/archive' % info['ParentPatient'], {
            'Transcode' : '1.2.840.10008.1.2.4.50',
            })
        self.assertEqual('1.2.840.10008.1.2.4.50', GetTransferSyntax(z.read(z.namelist()[0])))

        z = PostArchive(_REMOTE, '/studies/%s/archive' % info['ParentStudy'], {
            'Transcode' : '1.2.840.10008.1.2.4.51',
            })
        self.assertEqual('1.2.840.10008.1.2.4.51', GetTransferSyntax(z.read(z.namelist()[0])))

        z = PostArchive(_REMOTE, '/series/%s/archive' % info['ParentSeries'], {
            'Transcode' : '1.2.840.10008.1.2.4.57',
            })
        self.assertEqual('1.2.840.10008.1.2.4.57', GetTransferSyntax(z.read(z.namelist()[0])))
        

        # "/tools/create-*"
        z = PostArchive(_REMOTE, '/tools/create-archive', {
            'Resources' : [ info['ParentStudy'] ],
            'Transcode' : '1.2.840.10008.1.2.4.50',
            })
        self.assertEqual(1, len(z.namelist()))
        self.assertEqual('1.2.840.10008.1.2.4.50', GetTransferSyntax(z.read(z.namelist()[0])))

        z = PostArchive(_REMOTE, '/tools/create-media', {
            'Resources' : [ info['ParentStudy'] ],
            'Transcode' : '1.2.840.10008.1.2.4.51',
            })
        self.assertEqual(2, len(z.namelist()))
        self.assertEqual('1.2.840.10008.1.2.4.51', GetTransferSyntax(z.read('IMAGES/IM0')))

        z = PostArchive(_REMOTE, '/tools/create-media-extended', {
            'Resources' : [ info['ParentStudy'] ],
            'Transcode' : '1.2.840.10008.1.2.4.57',
            })
        self.assertEqual(2, len(z.namelist()))
        self.assertEqual('1.2.840.10008.1.2.4.57', GetTransferSyntax(z.read('IMAGES/IM0')))

        if IsOrthancVersionAbove(_REMOTE, 1, 12, 2):
            z = GetArchive(_REMOTE, '/tools/create-archive?resources=%s&transcode=1.2.840.10008.1.2.4.50' % info['ParentStudy'])
            self.assertEqual(1, len(z.namelist()))
            self.assertEqual('1.2.840.10008.1.2.4.50', GetTransferSyntax(z.read(z.namelist()[0])))

            z = GetArchive(_REMOTE, '/tools/create-media?resources=%s&transcode=1.2.840.10008.1.2.4.51' % info['ParentStudy'])
            self.assertEqual(2, len(z.namelist()))
            self.assertEqual('1.2.840.10008.1.2.4.51', GetTransferSyntax(z.read('IMAGES/IM0')))

            z = GetArchive(_REMOTE, '/tools/create-media-extended?resources=%s&transcode=1.2.840.10008.1.2.4.57' % info['ParentStudy'])
            self.assertEqual(2, len(z.namelist()))
            self.assertEqual('1.2.840.10008.1.2.4.57', GetTransferSyntax(z.read('IMAGES/IM0')))




    def test_download_file_transcode(self):
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 2):

            info = UploadInstance(_REMOTE, 'TransferSyntaxes/1.2.840.10008.1.2.1.dcm')
            self.assertEqual('1.2.840.10008.1.2.1', GetTransferSyntax(
                DoGet(_REMOTE, '/instances/%s/file' % info['ID'])))

            self.assertEqual('1.2.840.10008.1.2.4.50', GetTransferSyntax(
                DoGet(_REMOTE, '/instances/%s/file?transcode=1.2.840.10008.1.2.4.50' % info['ID'])))


    def test_modify_keep_source(self):
        # https://groups.google.com/d/msg/orthanc-users/CgU-Wg8vDio/BY5ZWcDEAgAJ
        i = UploadInstance(_REMOTE, 'DummyCT.dcm')
        self.assertEqual(1, len(DoGet(_REMOTE, '/studies')))

        j = DoPost(_REMOTE, '/studies/%s/modify' % i['ParentStudy'], {
            'Replace' : {
                'StationName' : 'TEST',
                },
            'KeepSource' : True,
        })

        s = DoGet(_REMOTE, '/studies')
        self.assertEqual(2, len(s))
        self.assertTrue(i['ParentStudy'] in s)
        self.assertTrue(j['ID'] in s)

        DoDelete(_REMOTE, '/studies/%s' % j['ID'])
        self.assertEqual(1, len(DoGet(_REMOTE, '/studies')))

        j = DoPost(_REMOTE, '/studies/%s/modify' % i['ParentStudy'], {
            'Replace' : {
                'StationName' : 'TEST',
                },
            'KeepSource' : False,
        })

        s = DoGet(_REMOTE, '/studies')
        self.assertEqual(1, len(s))
        self.assertFalse(i['ParentStudy'] in s)
        self.assertTrue(j['ID'] in s)


    def test_modify_transcode_study(self):
        i = UploadInstance(_REMOTE, 'KarstenHilbertRF.dcm')
        self.assertEqual('1.2.840.10008.1.2.1', GetTransferSyntax(
            DoGet(_REMOTE, '/instances/%s/file' % i['ID'])))

        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))
        j = DoPost(_REMOTE, '/studies/%s/modify' % i['ParentStudy'], {
            'Transcode' : '1.2.840.10008.1.2.4.50',
            'KeepSource' : False
            })

        k = DoGet(_REMOTE, '/instances')
        self.assertEqual(1, len(k))
        self.assertEqual(i['ID'], DoGet(_REMOTE, '/instances/%s/metadata?expand' % k[0]) ['ModifiedFrom'])       
        self.assertEqual('1.2.840.10008.1.2.4.50', GetTransferSyntax(
            DoGet(_REMOTE, '/instances/%s/file' % k[0])))
        

    def test_modify_need_force_to_change_uids(self):
        def Modify(level, resourceId, replaceTags, force, keepSource):
            return DoPost(_REMOTE, '/%s/%s/modify' % (level, resourceId), {
                'Replace' : replaceTags,
                'Force': force,
                'KeepSource' : keepSource
                })
        
        self.assertEqual(0, len(DoGet(_REMOTE, '/studies')))

        i = UploadInstance(_REMOTE, 'DummyCT.dcm')
        self.assertEqual(1, len(DoGet(_REMOTE, '/studies')))

        # can not change the StudyInstanceUID unless you force it
        self.assertRaises(Exception, lambda: Modify('studies', i['ParentStudy'], {'StudyInstanceUID': '1.2'}, force=False, keepSource=True))
        Modify('studies', i['ParentStudy'], {'StudyInstanceUID': '1.2'}, force=True, keepSource=True)

        # can not change the SeriesInstanceUID unless you force it
        self.assertRaises(Exception, lambda: Modify('series', i['ParentSeries'], {'SeriesInstanceUID': '1.2'}, force=False, keepSource=True))
        Modify('series', i['ParentSeries'], {'SeriesInstanceUID': '1.2'}, force=True, keepSource=True)

        # can not change the SOPInstanceUID unless you force it
        self.assertRaises(Exception, lambda: Modify('instances', i['ID'], {'SOPInstanceUID': '1.2'}, force=False, keepSource=True))
        Modify('instances', i['ID'], {'SOPInstanceUID': '1.2'}, force=True, keepSource=True)


        # can not change the PatientID of a study unless you force it
        self.assertRaises(Exception, lambda: Modify('studies', i['ParentStudy'], {'PatientID': 'NEW'}, force=False, keepSource=True))
        self.assertRaises(Exception, lambda: Modify('series', i['ParentSeries'], {'StudyInstanceUID': '1.3'}, force=False, keepSource=True))
        self.assertRaises(Exception, lambda: Modify('instances', i['ID'], {'SeriesInstanceUID': '1.2'}, force=False, keepSource=True))

        if IsOrthancVersionAbove(_REMOTE, 1, 11, 3):
            # this was forbidden even with Force=true till 1.11.2 included
            Modify('studies', i['ParentStudy'], {'PatientID': 'NEW'}, force=True, keepSource=True)
            Modify('series', i['ParentSeries'], {'StudyInstanceUID': '1.3'}, force=True, keepSource=True)
            Modify('instances', i['ID'], {'SeriesInstanceUID': '1.2'}, force=True, keepSource=True)


    def test_modify_study_module_reconstruction(self):
        if IsOrthancVersionAbove(_REMOTE, 1, 11, 3):
            def UploadAndModify(level, resourceId, replaceTags, force, keepSource, dropOrthanc=True):

                if dropOrthanc:
                    DropOrthanc(_REMOTE)
    
                UploadFolder(_REMOTE, 'Knee/T1')
                UploadFolder(_REMOTE, 'Knee/T2')
    
                if dropOrthanc:
                    self.assertEqual(1, len(DoGet(_REMOTE, '/studies')))

                modifyResponse = DoPost(_REMOTE, '/%s/%s/modify' % (level, resourceId), {
                    'Replace' : replaceTags,
                    'Force': force,
                    'KeepSource' : keepSource
                    })
                modifiedResource = DoGet(_REMOTE, '/%s/%s' % (level, modifyResponse['ID']))
                return (modifyResponse, modifiedResource)

            kneeSeriesT1 = '6de73705-c4e65c1b-9d9ea1b5-cabcd8e7-f15e4285'
            kneeSeriesT2 = 'bbf7a453-0d34251a-03663b55-46bb31b9-ffd74c59'
            kneeStudy = '0a9b3153-2512774b-2d9580de-1fc3dcf6-3bd83918'
            kneePatient = 'ca29faea-b6a0e17f-067743a1-8b778011-a48b2a17'
            kneeStudyInstanceUID = '2.16.840.1.113669.632.20.121711.10000160881'
            kneeSeriesInstanceUIDT1 = '1.3.46.670589.11.17521.5.0.3124.2008081908564160709'
            kneeSeriesInstanceUIDT2 = '1.3.46.670589.11.17521.5.0.3124.2008081909090037350'

            ####### study level tests #######

            # modify study description and make sure the MainDicomTags are updated
            modifyResponse, modifiedResource = UploadAndModify('studies', kneeStudy, replaceTags={
                'StudyInstanceUID': kneeStudyInstanceUID, 
                'StudyDescription': 'TOTO'
                }, force=True, keepSource=True)
            self.assertEqual(kneeStudy, modifyResponse['ID'])
            self.assertEqual('TOTO', modifiedResource['MainDicomTags']['StudyDescription'])

            # modify patient name at study level and make sure the PatientMainDicomTags are updated + the patient has been updated
            modifyResponse, modifiedResource = UploadAndModify('studies', kneeStudy, replaceTags={
                'StudyInstanceUID': kneeStudyInstanceUID, 
                'PatientName': 'TOTO'
                }, force=True, keepSource=True)
            self.assertEqual(kneeStudy, modifyResponse['ID'])
            self.assertEqual('TOTO', modifiedResource['PatientMainDicomTags']['PatientName'])
            patient = DoGet(_REMOTE, '/patients/%s' % kneePatient)
            self.assertEqual('TOTO', patient['MainDicomTags']['PatientName'])

            # modify patient name and patient id at study level and make sure the PatientMainDicomTags are updated + a new patient has been created + the old one does not exist anymore
            modifyResponse, modifiedResource = UploadAndModify('studies', kneeStudy, replaceTags={
                'StudyInstanceUID': kneeStudyInstanceUID, 
                'PatientID': 'TOTO_ID',
                'PatientName': 'TOTO'
                }, force=True, keepSource=True)
            self.assertNotEqual(kneeStudy, modifyResponse['ID'])  # the study has changed since the PatientID has changed
            self.assertEqual('TOTO', modifiedResource['PatientMainDicomTags']['PatientName'])
            self.assertEqual('TOTO_ID', modifiedResource['PatientMainDicomTags']['PatientID'])
            patient = DoGet(_REMOTE, '/patients/%s' % modifyResponse['PatientID'])
            self.assertEqual('TOTO', patient['MainDicomTags']['PatientName'])
            self.assertEqual('TOTO_ID', patient['MainDicomTags']['PatientID'])

            ####### series level tests #######
            # modify series description and make sure the MainDicomTags are updated
            modifyResponse, modifiedResource = UploadAndModify('series', kneeStudy, replaceTags={
                'SeriesInstanceUID': kneeSeriesInstanceUIDT1,
                'StudyInstanceUID': kneeStudyInstanceUID,
                'SeriesDescription': 'TOTO'
                }, force=True, keepSource=True)
            self.assertEqual(kneeSeriesT1, modifyResponse['ID'])
            self.assertEqual('TOTO', modifiedResource['MainDicomTags']['SeriesDescription'])
            self.assertEqual(kneeStudy, modifyResponse['ParentResources'][0])


    def test_rename_patient_with_multiple_studies(self):
        if IsOrthancVersionAbove(_REMOTE, 1, 11, 3):

            patientOrthancId = '5436938e-7ae68340-5ea6ad3c-4e6e09bd-1bd335de'
            patientDicomId = 'TEST_1'
            patientName = 'Test'
            study1234 = '72de3b86-da4b2556-bb33f32f-d1d84f80-fb017059'
            study2345 = '3594f32b-dcf60e81-58252b67-66222714-c09fca81'

            DropOrthanc(_REMOTE)
            UploadFolder(_REMOTE, 'PatientWith2studies')

            # each sub-test is in a dedicated 'if' for clarity

            if True:
                # it shall be impossible to rename a patient when modifying a study if that patient already has other studies
                self.assertRaises(Exception, lambda: DoPost(_REMOTE, '/%s/%s/modify' % ('studies', study1234), {
                    'Replace' : {'PatientName': "TOTO"},
                    'Force': True,
                    'KeepSource' : True
                    }))

            if True:
                # rename the patient (at patient level)
                modifyResponse = DoPost(_REMOTE, '/%s/%s/modify' % ('patients', patientOrthancId), {
                    'Replace' : {
                        'PatientName': 'TOTO'
                    },
                    'Force': True,
                    'KeepSource' : False
                    })
                modifiedPatient = DoGet(_REMOTE, '/%s/%s' % ('patients', modifyResponse['ID']))
                # make sure the patient name has been edited at patient level
                self.assertEqual('TOTO', modifiedPatient['MainDicomTags']['PatientName'])

                # there should only be 2 studies since we have set KeepSource=False
                self.assertEqual(2, len(modifiedPatient['Studies']))
                modifiedStudy1 = DoGet(_REMOTE, '/%s/%s' % ('studies', modifiedPatient['Studies'][0]))
                modifiedStudy2 = DoGet(_REMOTE, '/%s/%s' % ('studies', modifiedPatient['Studies'][1]))
                self.assertEqual('TOTO', modifiedStudy1['PatientMainDicomTags']['PatientName'])
                self.assertEqual('TOTO', modifiedStudy2['PatientMainDicomTags']['PatientName'])

            if True:
                # rename the patient (at patient level) and don't keep sources and preserve StudyInstanceUID
                DropOrthanc(_REMOTE)
                UploadFolder(_REMOTE, 'PatientWith2studies')

                # rename the patient (at patient level) and don't keep sources and preserve StudyInstanceUID, SeriesInstanceUID
                modifyResponse = DoPost(_REMOTE, '/%s/%s/modify' % ('patients', patientOrthancId), {
                    'Replace' : {
                        'PatientName': 'TOTO'
                    },
                    'Keep': ['StudyInstanceUID', 'SeriesInstanceUID'],
                    'Force': True,
                    'KeepSource' : False
                    })
                modifiedPatient = DoGet(_REMOTE, '/%s/%s' % ('patients', modifyResponse['ID']))
                # make sure tha patient name has been edited at patient level
                self.assertEqual('TOTO', modifiedPatient['MainDicomTags']['PatientName'])

                # there should only be 2 studies since we have set KeepSource=False
                self.assertEqual(2, len(modifiedPatient['Studies']))
                modifiedStudy1 = DoGet(_REMOTE, '/%s/%s' % ('studies', modifiedPatient['Studies'][0]))
                modifiedStudy2 = DoGet(_REMOTE, '/%s/%s' % ('studies', modifiedPatient['Studies'][1]))
                # the StudyInstanceUID shall not have changed
                self.assertIn(modifiedStudy1['MainDicomTags']['StudyInstanceUID'], ['1.2.3', '2.3.4'])
                self.assertIn(modifiedStudy2['MainDicomTags']['StudyInstanceUID'], ['1.2.3', '2.3.4'])
                # the DB model of parent shall have been reconstructed
                self.assertEqual('TOTO', modifiedStudy1['PatientMainDicomTags']['PatientName'])
                self.assertEqual('TOTO', modifiedStudy2['PatientMainDicomTags']['PatientName'])

            if True:
                # it shall not be possible to keep all dicom UID and have KeepSource at False since the modified instances 
                # would have the same orthanc ids as the source ids -> they would be deleted
                self.assertRaises(Exception, lambda: DoPost(_REMOTE, '/%s/%s/modify' % ('patients', study1234), {
                    'Replace' : {'PatientName': "TOTO"},
                    'Force': True,
                    'Keep': ['StudyInstanceUID', 'SeriesInstanceUID', 'SOPInstanceUID'],
                    'KeepSource' : False
                    }))

            if True:
                # rename the patient (at patient level) and don't keep sources and preserve all DicomID
                DropOrthanc(_REMOTE)
                UploadFolder(_REMOTE, 'PatientWith2studies')

                # rename the patient (at patient level) and don't keep sources and preserve StudyInstanceUID
                modifyResponse = DoPost(_REMOTE, '/%s/%s/modify' % ('patients', patientOrthancId), {
                    'Replace' : {
                        'PatientName': 'TOTO'
                    },
                    'Keep': ['StudyInstanceUID', 'SeriesInstanceUID', 'SOPInstanceUID'],
                    'Force': True,
                    'KeepSource' : True
                    })
                modifiedPatient = DoGet(_REMOTE, '/%s/%s' % ('patients', modifyResponse['ID']))
                # make sure tha patient name has been edited at patient level
                self.assertEqual('TOTO', modifiedPatient['MainDicomTags']['PatientName'])

                # there should only be 2 studies since we have set KeepSource=False
                self.assertEqual(2, len(modifiedPatient['Studies']))
                modifiedStudy1 = DoGet(_REMOTE, '/%s/%s' % ('studies', modifiedPatient['Studies'][0]))
                modifiedStudy2 = DoGet(_REMOTE, '/%s/%s' % ('studies', modifiedPatient['Studies'][1]))
                # the StudyInstanceUID shall not have changed
                self.assertIn(modifiedStudy1['MainDicomTags']['StudyInstanceUID'], ['1.2.3', '2.3.4'])
                self.assertIn(modifiedStudy2['MainDicomTags']['StudyInstanceUID'], ['1.2.3', '2.3.4'])
                # the DB model of parent shall have been reconstructed
                self.assertEqual('TOTO', modifiedStudy1['PatientMainDicomTags']['PatientName'])
                self.assertEqual('TOTO', modifiedStudy2['PatientMainDicomTags']['PatientName'])


            if True:
                # try to attach the knee study to an existing patient
                DropOrthanc(_REMOTE)
                UploadFolder(_REMOTE, 'PatientWith2studies')
                UploadFolder(_REMOTE, 'Knee/T1')

                kneeStudy = '0a9b3153-2512774b-2d9580de-1fc3dcf6-3bd83918'

                # try to change the PatientID at study level.  This only works if we specify all Patient Tags and if they are identical to the existing Patient in DB
                
                # this should fail if only specifying the PatientID
                self.assertRaises(Exception, lambda: DoPost(_REMOTE, '/%s/%s/modify' % ('studies', kneeStudy), {
                    'Replace' : {
                        'PatientID': 'TEST_1'
                    },
                    'Force': True,
                    'KeepSource' : False
                    }))

                # this should fail if specifying all tags but one of them is not correct
                self.assertRaises(Exception, lambda: DoPost(_REMOTE, '/%s/%s/modify' % ('studies', kneeStudy), {
                    'Replace' : {
                        'PatientID': 'TEST_1',
                        'PatientName': 'Test',
                        'PatientBirthDate': '19000101',
                        'PatientSex': 'F'  # this is wrong !
                    },
                    'Force': True,
                    'KeepSource' : False
                    }))

                # this should fail if specifying a tag that is not defined in DB for that patient
                self.assertRaises(Exception, lambda: DoPost(_REMOTE, '/%s/%s/modify' % ('studies', kneeStudy), {
                    'Replace' : {
                        'PatientID': 'TEST_1',
                        'PatientName': 'Test',
                        'PatientBirthDate': '19000101',
                        'PatientSex': 'M',
                        '0010,1000': 'TUTU'  # this does not exist in DB
                    },
                    'Force': True,
                    'KeepSource' : False
                    }))

                # this should now work with all correct tags
                modifyResponse = DoPost(_REMOTE, '/%s/%s/modify' % ('studies', kneeStudy), {
                    'Replace' : {
                        'PatientID': 'TEST_1',
                        'PatientName': 'Test',
                        'PatientBirthDate': '19000101',
                        'PatientSex': 'M'
                    },
                    'Force': True,
                    'KeepSource' : False
                    })
                modifiedStudy = DoGet(_REMOTE, '/%s/%s' % ('studies', modifyResponse['ID']))
                self.assertEqual('Test', modifiedStudy['PatientMainDicomTags']['PatientName'])
                patient = DoGet(_REMOTE, '/%s/%s' % ('patients', patientOrthancId))
                # make sure tha patient name remains the same at patient level
                self.assertEqual('Test', patient['MainDicomTags']['PatientName'])


            if True:
                # try to edit patient in Knee (only study from this patient)
                DropOrthanc(_REMOTE)
                UploadFolder(_REMOTE, 'PatientWith2studies')
                UploadFolder(_REMOTE, 'Knee/T1')

                kneeStudy = '0a9b3153-2512774b-2d9580de-1fc3dcf6-3bd83918'
                originalKneePatientId = 'ca29faea-b6a0e17f-067743a1-8b778011-a48b2a17'

                originalKneePatient = DoGet(_REMOTE, '/%s/%s' % ('patients', originalKneePatientId))

                # try to change the PatientName and StudyDescription at study level.
                modifyResponse = DoPost(_REMOTE, '/%s/%s/modify' % ('studies', kneeStudy), {
                    'Replace' : {
                        'PatientName': 'Test Knee',
                        'StudyDescription': 'Knee study'
                    },
                    'Keep': ['StudyInstanceUID'],
                    'Force': True,
                    'KeepSource' : False
                    })
                modifiedStudy = DoGet(_REMOTE, '/%s/%s' % ('studies', modifyResponse['ID']))
                self.assertEqual('Test Knee', modifiedStudy['PatientMainDicomTags']['PatientName'])
                # reload the patient, it shall have been updated as well (and kept the same ID since we did not change the PatientID)
                modifiedPatient = DoGet(_REMOTE, '/%s/%s' % ('patients', originalKneePatientId))
                self.assertEqual('Test Knee', modifiedPatient['MainDicomTags']['PatientName'])

                # try to change the PatientID and PatientName and StudyDescription at study level.  Since we use a new PatientID, we can modify its name too
                modifyResponse = DoPost(_REMOTE, '/%s/%s/modify' % ('studies', kneeStudy), {
                    'Replace' : {
                        'PatientName': 'Test Knee 2',
                        'PatientID': 'TEST_KNEE_2',
                        'StudyDescription': 'Knee study 2'
                    },
                    'Force': True,
                    'KeepSource' : False
                    })
                modifiedStudy = DoGet(_REMOTE, '/%s/%s' % ('studies', modifyResponse['ID']))
                self.assertEqual('Test Knee 2', modifiedStudy['PatientMainDicomTags']['PatientName'])
                self.assertEqual('Knee study 2', modifiedStudy['MainDicomTags']['StudyDescription'])
                # reload the patient, now, its orthanc id has changed
                modifiedPatient = DoGet(_REMOTE, '/%s/%s' % ('patients', modifiedStudy['ParentPatient']))
                self.assertEqual('Test Knee 2', modifiedPatient['MainDicomTags']['PatientName'])
                # the previous patient shall not exist anymore
                self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/%s/%s' % ('patients', originalKneePatientId)))

    def test_store_peer_transcoding(self):
        i = UploadInstance(_REMOTE, 'KarstenHilbertRF.dcm')['ID']

        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',
            '1.2.840.10008.1.2.4.57',
            '1.2.840.10008.1.2.4.70',
        ]

        if HasGdcmPlugin(_REMOTE):
            SYNTAXES = SYNTAXES + [
                '1.2.840.10008.1.2.4.80',  # This makes DCMTK 3.6.2 crash
                '1.2.840.10008.1.2.4.81',  # This makes DCMTK 3.6.2 crash
                '1.2.840.10008.1.2.4.90',  # JPEG2k, unavailable without GDCM
                '1.2.840.10008.1.2.4.91',  # JPEG2k, unavailable without GDCM
            ]

        for syntax in SYNTAXES:
            body = {
                'Resources' : [ i ],
            }

            if syntax != '1.2.840.10008.1.2.1':
                body['Transcode'] = syntax
            
            self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))
            self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))
            DoPost(_REMOTE, '/peers/peer/store', body, 'text/plain')
            self.assertEqual(1, len(DoGet(_LOCAL, '/instances')))
            self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))
            self.assertEqual(syntax, GetTransferSyntax(
                DoGet(_LOCAL, '/instances/%s/file' % DoGet(_LOCAL, '/instances') [0])))

            DropOrthanc(_LOCAL)

        
    def test_getscu(self):
        def CleanTarget():
            if os.path.isdir('/tmp/GETSCU'):
                shutil.rmtree('/tmp/GETSCU')
            os.makedirs('/tmp/GETSCU')
        
        env = {}
        if _DOCKER:
            # This is "getscu" from DCMTK 3.6.5 compiled using LSB,
            # and running in a GNU/Linux distribution running DCMTK
            # 3.6.0. Tell "getscu" where it can find the DICOM dictionary.
            env['DCMDICTPATH'] = os.environ.get('DCMDICTPATH', '/usr/share/libdcmtk2/dicom.dic')

        # no transcoding required
        UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm')['ID']

        CleanTarget()

        subprocess.check_call([
            FindExecutable('getscu'),
            _REMOTE['Server'], 
            str(_REMOTE['DicomPort']),
            '-aec', 'ORTHANC',
            '-aet', 'ORTHANCTEST', # pretend to be the other orthanc
            '-k', '0020,000d=2.16.840.1.113669.632.20.1211.10000357775',
            '-k', '0008,0052=STUDY',
            '--output-directory', '/tmp/GETSCU/' 
        ], env = env)

        f1 = '/tmp/GETSCU/MR.1.3.46.670589.11.0.0.11.4.2.0.8743.5.5396.2006120114314079549'
        self.assertTrue(os.path.isfile(f1))
        with open(f1, 'rb') as f:
            self.assertEqual('1.2.840.10008.1.2.1', GetTransferSyntax(f.read(), encoding='ISO-8859-1'))

        CleanTarget()

        # transcoding required
        UploadInstance(_REMOTE, 'TransferSyntaxes/1.2.840.10008.1.2.4.50.dcm')

        subprocess.check_call([
            FindExecutable('getscu'),
            _REMOTE['Server'], 
            str(_REMOTE['DicomPort']),
            '-aec', 'ORTHANC',
            '-aet', 'ORTHANCTEST', # pretend to be the other orthanc
            '-k', '0020,000d=2.16.840.1.113669.632.20.1211.10000357775\\1.2.840.113663.1298.6234813.1.298.20000329.1115122',
            '-k', '0008,0052=STUDY',
            '--output-directory', '/tmp/GETSCU/' 
        ], env = env)

        self.assertTrue(os.path.isfile(f1))
        with open(f1, 'rb') as f:
            self.assertEqual('1.2.840.10008.1.2.1', GetTransferSyntax(f.read(), encoding='ISO-8859-1'))

        # This file is transcoded from "1.2.840.10008.1.2.4.50" to "1.2.840.10008.1.2.1"
        # (LittleEndianExplicit is proposed by default by "getscu")
        f2 = '/tmp/GETSCU/US.1.2.840.113663.1298.1.3.715.20000329.1115326'
        self.assertTrue(os.path.isfile(f2))
        with open(f2, 'rb') as f:
            self.assertEqual('1.2.840.10008.1.2.1', GetTransferSyntax(f.read()))


    def test_findscu_truncation(self):
        # https://groups.google.com/forum/#!msg/orthanc-users/FkckWAHvso8/UbRBAhQ5CwAJ
        # Fixed by: https://orthanc.uclouvain.be/hg/orthanc/rev/2724977419fb
        UploadInstance(_REMOTE, 'Multiframe.dcm')
        UploadInstance(_REMOTE, 'ColorTestImageJ.dcm')

        study = '1.3.46.670589.7.5.8.80001255161.20000323.151537.1'
        
        i = CallFindScu([ '-k', '0008,0052=STUDY', '-k', 'StudyInstanceUID' ])
        result = re.findall('\(0020,000d\).*?\[(.*?)\]', i)
        self.assertEqual(2, len(result))

        # The "StudyInstanceUID" is set as a list of 5 times the same
        # study, leading to a string of 249 characters
        i = CallFindScu([ '-k', '0008,0052=STUDY', '-k',
                          'StudyInstanceUID=%s\\%s\\%s\\%s\\%s' % (( study, ) * 5) ])
        result = re.findall('\(0020,000d\).*?\[(.*?)\]', i)
        self.assertEqual(1, len(result))
        
        # The "StudyInstanceUID" is set as a list of 6 times the same
        # study, leading to a string of 299 characters. In Orthanc <=
        # 1.7.2, this is above the value of ORTHANC_MAXIMUM_TAG_LENGTH
        # == 256, and is thus wiped out by C-FIND SCP. As a
        # consequence, Orthanc <= 1.7.2 doesn't apply the filter on
        # "StudyInstanceUID" and returns all the available
        # studies (i.e. 2). This issue was fixed in Orthanc 1.7.3.
        i = CallFindScu([ '-k', '0008,0052=STUDY', '-k',
                          'StudyInstanceUID=%s\\%s\\%s\\%s\\%s\\%s' % (( study, ) * 6) ])
        result = re.findall('\(0020,000d\).*?\[(.*?)\]', i)
        self.assertEqual(1, len(result))


    def test_store_compressed(self):
        with open(GetDatabasePath('DummyCT.dcm'), 'rb') as f:
            dicom = f.read()
            i = DoPost(_REMOTE, '/instances', dicom) ['ID']
            sourceSize = len(dicom)
        
        self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))

        # Sending to the local Orthanc 0.8.6 server, without compression: OK
        jobId = MonitorJob2(_REMOTE, lambda: DoPost(
            _REMOTE, '/peers/peer/store', {
                'Resources' : [ i ],
                'Synchronous' : False,
            }))

        job = DoGet(_REMOTE, '/jobs/%s' % jobId)
        self.assertFalse(job['Content']['Compress'])
        self.assertEqual('', job['Content']['Peer'][2])  # Password must not be reported
        self.assertEqual(str(sourceSize), job['Content']['Size'])

        self.assertEqual(1, len(DoGet(_LOCAL, '/instances')))
        DropOrthanc(_LOCAL)

        if not IsOrthancVersionAbove(_LOCAL, 1, 11, 2):  # don't know which specific version the behaviour changed but this fails with 0.8.6
            # Sending to the local Orthanc 0.8.6 server, with compression:
            # Not supported by Orthanc 0.8.6 => failure
            self.assertRaises(Exception, lambda: DoPost(_REMOTE, '/peers/peer/store', {
                'Resources' : [ i ],
                'Compress' : True,
            }))
            self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))

            # Sending to the tested remote server, with compression: OK
            jobId = MonitorJob2(_REMOTE, lambda: DoPost(
                _REMOTE, '/peers/self/store', {
                    'Resources' : [ i ],
                    'Compress' : True,
                    'Synchronous' : False,
                }))

            job = DoGet(_REMOTE, '/jobs/%s' % jobId)
            self.assertTrue(job['Content']['Compress'])

            # Compression must have divided the size of the sent data at least twice
            self.assertLess(int(job['Content']['Size']), sourceSize / 2)


    def test_move_ambra(self):
        # "Orthanc + Ambra: Query/Retrieve" (2020-08-25)
        # https://groups.google.com/g/orthanc-users/c/yIUnZ9v9-Zs/m/GQPXiAOiCQAJ

        UploadInstance(_REMOTE, '2019-06-17-VedranZdesic.dcm')
        
        self.assertFalse(MonitorJob(_REMOTE, lambda: CallMoveScu([
            '--study',
            '-k', 'StudyInstanceUID='
        ])))
        
        self.assertFalse(MonitorJob(_REMOTE, lambda: CallMoveScu([
            '--study',
            '-k', 'AccessionNumber=',
        ])))
        
        self.assertFalse(MonitorJob(_REMOTE, lambda: CallMoveScu([
            '--study',
            '-k', 'AccessionNumber=',
            '-k', 'StudyInstanceUID='
        ])))
        
        self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))
        self.assertTrue(MonitorJob(_REMOTE, lambda: CallMoveScu([
            '--study',
            '-k', 'AccessionNumber=CT16000988',
            '-k', 'StudyInstanceUID=',
        ])))
        self.assertEqual(1, len(DoGet(_LOCAL, '/instances')))
        DropOrthanc(_LOCAL)
        
        self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))
        self.assertTrue(MonitorJob(_REMOTE, lambda: CallMoveScu([
            '--study',
            '-k', 'AccessionNumber=CT16000988',
            '-k', 'StudyInstanceUID=1.2.840.113619.2.278.3.4194965761.659.1468842739.39',
        ])))
        self.assertEqual(1, len(DoGet(_LOCAL, '/instances')))
        DropOrthanc(_LOCAL)

        # This fails on Orthanc <= 1.7.3
        self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))
        self.assertTrue(MonitorJob(_REMOTE, lambda: CallMoveScu([
            '--study',
            '-k', 'AccessionNumber=',
            '-k', 'StudyInstanceUID=1.2.840.113619.2.278.3.4194965761.659.1468842739.39'
        ])))
        self.assertEqual(1, len(DoGet(_LOCAL, '/instances')))
        DropOrthanc(_LOCAL)


    def test_decode_elscint(self):
        # https://groups.google.com/g/orthanc-users/c/d9anAx6lSis/m/qEzm1x3PAAAJ
        a = UploadInstance(_REMOTE, '2020-09-12-ELSCINT1-PMSCT_RLE1.dcm')['ID']
        b = UploadInstance(_REMOTE, '2020-09-11-Christopher-ELSCINT1-Raw.dcm')['ID']
        
        im = GetImage(_REMOTE, '/instances/%s/frames/0/preview' % a)
        self.assertEqual("L", im.mode)
        self.assertEqual(512, im.size[0])
        self.assertEqual(512, im.size[1])

        im = GetImage(_REMOTE, '/instances/%s/frames/0/preview' % b)
        self.assertEqual("L", im.mode)
        self.assertEqual(512, im.size[0])
        self.assertEqual(512, im.size[1])

        # The two tests below fail on Orthanc <= 1.7.3
        raw = DoGet(_REMOTE, '/instances/%s/frames/0/raw' % a)
        self.assertEqual(512 * 512 * 2, len(raw))

        raw = DoGet(_REMOTE, '/instances/%s/frames/0/raw' % b)
        self.assertEqual(512 * 512 * 2, len(raw))
        

    def test_rest_modalities_in_study_2(self):
        # Problem reported by Alain Mazy on 2020-09-15
        UploadInstance(_REMOTE, 'Comunix/Ct/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Comunix/Pet/IM-0001-0001.dcm')

        i = CallFindScu([ '-k', '0008,0052=STUDY', '-k', '0020,000d=', '-k', '0008,0061=' ])
        modalitiesInStudy = re.findall('\(0008,0061\).*?\[(.*?)\]', i)
        self.assertEqual(1, len(modalitiesInStudy))
        self.assertEqual('CT\\PT ', modalitiesInStudy[0])
        
        for i in [ '', 'CT', 'PT', 'UX', 'UX\\MR', 'CT\\PT', 'UX\\PT', 'CT\\PT', 'UX\\CT\\PT' ]:
            # The empty string '' corresponds to universal matching.
            # The case where "i == 'CT'" failed in Orthanc <= 1.7.3.
            
            if i in [ 'UX', 'UX\\MR' ]:
                expected = 0
            else:
                expected = 1

            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Study',
                                                 'Query' : { 'ModalitiesInStudy' : i }})
            self.assertEqual(expected, len(a))

            i = CallFindScu([ '-k', '0008,0052=STUDY', '-k', '0020,000d=', '-k', '0008,0061=%s' % i ])
            studyInstanceUid = re.findall('\(0020,000d\).*?\[(.*?)\]', i)
            self.assertEqual(expected, len(studyInstanceUid))
        

    def test_webdav(self):
        self.assertRaises(Exception, lambda: DoPropFind(_REMOTE, '/webdav/', 2))

        for suffix in [ '', '/' ]:
            f = DoPropFind(_REMOTE, '/webdav' + suffix, 0)
            self.assertEqual(1, len(f))
            self.assertTrue('/webdav/' in f.keys())
            self.assertTrue(f['/webdav/']['folder'])
            self.assertEqual('webdav', f['/webdav/']['displayname'])

            f = DoPropFind(_REMOTE, '/webdav' + suffix, 1)
            self.assertEqual(6, len(f))
            self.assertTrue(f['/webdav/']['folder'])
            self.assertEqual('webdav', f['/webdav/']['displayname'])
            
            for i in [ 'by-dates', 'by-patients', 'by-studies', 'by-uids', 'uploads' ]:
                self.assertTrue(f['/webdav/%s' % i]['folder'])
                self.assertEqual(i, f['/webdav/%s' % i]['displayname'])

                for depth in [ 0, 1 ]:
                    for suffix2 in [ '', '/' ]:
                        g = DoPropFind(_REMOTE, '/webdav/%s%s' % (i, suffix2), depth)

                        if i == 'uploads':
                            # Empty folders might still exist in "/uploads/"
                            self.assertTrue('/webdav/uploads/' in g)
                            self.assertEqual('uploads', g['/webdav/uploads/']['displayname'])
                            for j in g.items():
                                self.assertTrue(g.items()[0][1]['folder'])
                        else:
                            self.assertEqual(1, len(g))
                            self.assertEqual('/webdav/%s/' % i, g.items()[0][0])
                            self.assertTrue(g.items()[0][1]['folder'])
                            self.assertEqual(i, g.items()[0][1]['displayname'])
        
        self.assertEqual(0, len(DoGet(_REMOTE, '/patients')))
        with open(GetDatabasePath('DummyCT.dcm'), 'rb') as f:
            DoPut(_REMOTE, '/webdav/uploads/dummy', f.read(), 'text/plain')        

        while len(DoGet(_REMOTE, '/patients')) == 0:
            time.sleep(0.01)
        self.assertEqual(1, len(DoGet(_REMOTE, '/patients')))
            

    def test_log_categories(self):
        original = DoGet(_REMOTE, '/tools/log-level-http')
        
        DoPut(_REMOTE, '/tools/log-level-http', 'default')
        self.assertEqual('default', DoGet(_REMOTE, '/tools/log-level-http'))
        DoGet(_REMOTE, '/system')

        DoPut(_REMOTE, '/tools/log-level-http', 'verbose')
        self.assertEqual('verbose', DoGet(_REMOTE, '/tools/log-level-http'))
        DoGet(_REMOTE, '/system')

        DoPut(_REMOTE, '/tools/log-level-http', 'trace')
        self.assertEqual('trace', DoGet(_REMOTE, '/tools/log-level-http'))
        DoGet(_REMOTE, '/system')

        self.assertRaises(Exception, lambda: DoPut(_REMOTE, '/tools/log-level-http', 'nope'))
        
        # Switch back to the original log level
        DoPut(_REMOTE, '/tools/log-level-http', original)

        for c in [ 'generic', 'http', 'dicom', 'plugins', 'sqlite', 'lua', 'jobs' ]:
            DoPut(_REMOTE, '/tools/log-level-%s' % c, DoGet(_REMOTE, '/tools/log-level-%s' % c))

        self.assertRaises(Exception, lambda: DoPut(_REMOTE, '/tools/log-level-nope', 'default'))


    def test_upload_zip(self):
        f = StringIO()
        with zipfile.ZipFile(f, 'w') as z:
            z.writestr('hello/world/invalid.txt', 'Hello world')
            with open(GetDatabasePath('DummyCT.dcm'), 'rb') as g:
                c = g.read()
                z.writestr('hello/world/dicom1.dcm', c)
                z.writestr('hello/world/dicom2.dcm', c)

        f.seek(0)
        i = DoPost(_REMOTE, '/instances', f.read())

        self.assertEqual(2, len(i))
        self.assertEqual(i[0], i[1])
        self.assertEqual(6, len(i[0]))
        self.assertEqual('66a662ce-7430e543-bad44d47-0dc5a943-ec7a538d', i[0]['ID'])
        self.assertEqual('f2635388-f01d497a-15f7c06b-ad7dba06-c4c599fe', i[0]['ParentSeries'])
        self.assertEqual('b9c08539-26f93bde-c81ab0d7-bffaf2cb-a4d0bdd0', i[0]['ParentStudy'])
        self.assertEqual('6816cb19-844d5aee-85245eba-28e841e6-2414fae2', i[0]['ParentPatient'])
        self.assertEqual('/instances/66a662ce-7430e543-bad44d47-0dc5a943-ec7a538d', i[0]['Path'])

        # Both are "Success" (instead of one "AlreadyStored"), because "OverwriteInstance" is true
        self.assertEqual('Success', i[0]['Status']) 


    def test_transfer_syntax_no_metaheader(self):
        a = UploadInstance(_REMOTE, 'TransferSyntaxes/1.2.840.10008.1.2.dcm')['ID']
        m = DoGet(_REMOTE, '/instances/%s/metadata?expand' % a)
        self.assertEqual('1.2.840.10008.5.1.4.1.1.4', m['SopClassUid'])

        # This fails on Orthanc <= 1.8.1
        self.assertTrue('TransferSyntax' in m)
        self.assertEqual('1.2.840.10008.1.2', m['TransferSyntax'])


    def test_upload_multipart_1(self):
        # This tests the "Upload" button in Orthanc Explorer

        def EncodeChunk(data, boundary, filename):
            return (('--%s\r\n' +
                     'Content-Disposition  :   form-data  ; name ="files[]"   ; filename = "%s"  \r\n' +
                     '\r\n%s\r\n') % (boundary, filename, data))
        
        with open(GetDatabasePath('DummyCT.dcm'), 'rb') as f:
            dcm1 = f.read()

        with open(GetDatabasePath('ColorTestMalaterre.dcm'), 'rb') as f:
            dcm2 = f.read()

        self.assertEqual(0, len(DoGet(_REMOTE, '/instances')))
            
        boundary = '----WebKitFormBoundarypJDNQqJPoXiorRmQ'
        m = DoPost(_REMOTE, '/instances', (EncodeChunk(dcm1, boundary, 'DummyCT.dcm') +
                                           EncodeChunk(dcm2, boundary, 'ColorTestMalaterre.dcm') +
                                           '--' + boundary + '--'), headers = {
                       'Content-Type' : 'multipart/form-data   ;    boundary  =  %s  ' % boundary,
                       'X-Requested-With' : 'XMLHttpRequest',
                   })

        self.assertEqual(2, len(DoGet(_REMOTE, '/instances')))


    def test_upload_multipart_2(self):
        # This tests the "maxChunkSize" option of "jQuery File Upload
        # 5.12", whose source code can be found in:
        # "OrthancServer/OrthancExplorer/libs/jquery-file-upload/"

        def EncodeBody(data, boundary, filename):
            return (('--%s\r\n' +
                     'Content-Disposition: form-data; name="files[]"; filename="%s"\r\n' +
                     '\r\n%s\r\n--%s') % (boundary, filename, data, boundary))
        
        with open(GetDatabasePath('DummyCT.dcm'), 'rb') as f:
            dcm = f.read()

        with open(GetDatabasePath('ColorTestMalaterre.dcm'), 'rb') as f:
            dcm2 = f.read()

        boundary = '----WebKitFormBoundarypJDNQqJPoXiorRmQ'

        self.assertEqual(0, len(DoGet(_REMOTE, '/instances')))
        m = DoPost(_REMOTE, '/instances',
                   EncodeBody(dcm[0:1000], boundary, 'DummyCT.dcm'),
                   headers = {
                       'Content-Type' : 'multipart/form-data; boundary=%s' % boundary,
                       'X-Requested-With' : 'XMLHttpRequest',
                       'X-File-Name' : 'DummyCT.dcm',
                       'X-File-Size' : str(len(dcm)),
                   })

        self.assertEqual(0, len(DoGet(_REMOTE, '/instances')))
        m = DoPost(_REMOTE, '/instances',
                   EncodeBody(dcm[1000:2000], boundary, 'DummyCT.dcm'),
                   headers = {
                       'Content-Type' : 'multipart/form-data; boundary=%s' % boundary,
                       'X-Requested-With' : 'XMLHttpRequest',
                       'X-File-Name' : 'DummyCT.dcm',
                       'X-File-Size' : str(len(dcm)),
                   })
        self.assertEqual(0, len(DoGet(_REMOTE, '/instances')))
        
        m = DoPost(_REMOTE, '/instances',
                   EncodeBody(dcm2, boundary, 'ColorTestMalaterre.dcm'),
                   headers = {
                       'Content-Type' : 'multipart/form-data; boundary=%s' % boundary,
                       'X-Requested-With' : 'XMLHttpRequest',
                       'X-File-Name' : 'ColorTestMalaterre.dcm',
                       'X-File-Size' : str(len(dcm2)),
                   })

        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))

        # Upload the last chunk => the file is now entirely available
        m = DoPost(_REMOTE, '/instances',
                   EncodeBody(dcm[2000:len(dcm)], boundary, 'DummyCT.dcm'),
                   headers = {
                       'Content-Type' : 'multipart/form-data; boundary=%s' % boundary,
                       'X-Requested-With' : 'XMLHttpRequest',
                       'X-File-Name' : 'DummyCT.dcm',
                       'X-File-Size' : str(len(dcm)),
                   })
        
        self.assertEqual(2, len(DoGet(_REMOTE, '/instances')))


    def test_pixel_data_offset(self):
        # New in Orthanc 1.9.1
        def Check(path, offset):
            i = UploadInstance(_REMOTE, path) ['ID']
            metadata = DoGetRaw(_REMOTE, '/instances/%s/metadata/PixelDataOffset' % i) [1]
            self.assertEqual(offset, metadata)

        Check('ColorTestMalaterre.dcm', str(0x03a0))
        Check('TransferSyntaxes/1.2.840.10008.1.2.1.dcm', str(0x037c))
        Check('TransferSyntaxes/1.2.840.10008.1.2.2.dcm', str(0x03e8))  # Big endian
        Check('TransferSyntaxes/1.2.840.10008.1.2.4.50.dcm', str(0x04ac))
        Check('TransferSyntaxes/1.2.840.10008.1.2.4.51.dcm', str(0x072c))
        Check('TransferSyntaxes/1.2.840.10008.1.2.4.57.dcm', str(0x0620))
        Check('TransferSyntaxes/1.2.840.10008.1.2.4.70.dcm', str(0x065a))
        Check('TransferSyntaxes/1.2.840.10008.1.2.4.80.dcm', str(0x0b46))
        Check('TransferSyntaxes/1.2.840.10008.1.2.4.81.dcm', str(0x073e))
        Check('TransferSyntaxes/1.2.840.10008.1.2.4.90.dcm', str(0x0b66))
        Check('TransferSyntaxes/1.2.840.10008.1.2.4.91.dcm', str(0x19b8))
        Check('TransferSyntaxes/1.2.840.10008.1.2.5.dcm', str(0x0b0a))
        Check('TransferSyntaxes/1.2.840.10008.1.2.dcm', '')  # No valid DICOM preamble


    def test_peer_store_straight(self):
        self.assertEqual(0, len(DoGet(_LOCAL, '/exports')['Exports']))
        self.assertEqual(0, len(DoGet(_REMOTE, '/exports')['Exports']))

        peer = DoGet(_REMOTE, '/peers/peer/system')
        if not IsOrthancVersionAbove(_LOCAL, 0, 8, 6):
            self.assertEqual(3, len(peer))
            self.assertEqual(5, peer['DatabaseVersion'])
            self.assertEqual('MyOrthanc', peer['Name'])
            self.assertEqual('0.8.6', peer['Version'])            
        
        with open(GetDatabasePath('DummyCT.dcm'), 'rb') as f:
            j = DoPost(_REMOTE, '/peers/peer/store-straight', f.read(), 'application/dicom')

            # Remote server is Orthanc 0.8.6, thus "ParentPatient",
            # "ParentStudy", "ParentSeries" are not reported
            if not IsOrthancVersionAbove(_LOCAL, 1, 11, 2):  # don't know which specific version the behaviour changed but this fails with 0.8.6
                self.assertEqual(3, len(j))
            else:
                self.assertEqual(6, len(j))
            self.assertEqual('66a662ce-7430e543-bad44d47-0dc5a943-ec7a538d', j['ID'])
            self.assertEqual('/instances/66a662ce-7430e543-bad44d47-0dc5a943-ec7a538d', j['Path'])
            self.assertEqual('Success', j['Status'])

        self.assertEqual(1, len(DoGet(_LOCAL, '/patients')))
        self.assertEqual(0, len(DoGet(_REMOTE, '/patients')))


    def test_cp246(self):
        # This fails on Orthanc <= 1.9.0
        a = UploadInstance(_REMOTE, '2021-02-19-MalaterreCP246.dcm')['ID']
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))

        tags = DoGet(_REMOTE, '/instances/%s/tags?short' % a)
        self.assertEqual('1.2.840.10008.5.1.4.1.1.128', tags['0008,0016'])
        self.assertEqual('1.3.12.2.1107.5.1.4.36085.2.0.517715415141633', tags['0008,0018'])
        self.assertEqual('1.2.840.113745.101000.1008000.38179.6792.6324567', tags['0020,000d'])
        self.assertEqual('1.3.12.2.1107.5.1.4.36085.2.0.517714246252254', tags['0020,000e'])

        study = DoGet(_REMOTE, '/instances/%s/study' % a)
        self.assertEqual(tags['0020,000d'], study['MainDicomTags']['StudyInstanceUID'])

        series = DoGet(_REMOTE, '/instances/%s/series' % a)
        self.assertEqual(tags['0020,000e'], series['MainDicomTags']['SeriesInstanceUID'])

        instance = DoGet(_REMOTE, '/instances/%s' % a)
        self.assertEqual(tags['0008,0018'], instance['MainDicomTags']['SOPInstanceUID'])


    def test_revisions_metadata(self):
        # This test fails on Orthanc <= 1.9.1 (support for revisions
        # was introduced in 1.9.2), or if configuration option
        # "CheckRevisions" is "False". Conventions for HTTP headers
        # related to revisions mimic CouchDB:
        # https://docs.couchdb.org/en/stable/api/document/common.html
        i = UploadInstance(_REMOTE, 'DummyCT.dcm') ['ID']
        
        (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/metadata/TransferSyntax' % i)
        self.assertEqual('200', headers['status'])
        self.assertEqual('"0-%s"' % ComputeMD5('1.2.840.10008.1.2.4.70'), headers['etag'])
        self.assertEqual('1.2.840.10008.1.2.4.70', body)
        
        (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/metadata/TransferSyntax' % i, headers = {
            'If-None-Match' : '"aaa"'
        })
        self.assertEqual('400', headers['status'])  # Bad header format

        (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/metadata/TransferSyntax' % i, headers = {
            'If-None-Match' : '"aaa-bbb"'
        })
        self.assertEqual('400', headers['status'])  # Bad header format
        
        (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/metadata/TransferSyntax' % i, headers = {
            'If-None-Match' : '"0-16de4d7060d0b9d102ef0fca8acc892a"'
        })
        self.assertEqual('304', headers['status'])  # Not modified
        self.assertEqual('"0-16de4d7060d0b9d102ef0fca8acc892a"', headers['etag'])
        self.assertEqual('', body)  # Body must be empty on 304 status
        
        (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/metadata/TransferSyntax' % i, headers = {
            'If-None-Match' : '"1-16de4d7060d0b9d102ef0fca8acc892a"'  # Bad revision, good MD5
        })
        self.assertEqual('200', headers['status'])
        self.assertEqual('"0-16de4d7060d0b9d102ef0fca8acc892a"', headers['etag'])
        self.assertEqual('1.2.840.10008.1.2.4.70', body)
        
        (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/metadata/TransferSyntax' % i, headers = {
            'If-None-Match' : '"0-aaa"'  # Good revision, bad MD5
        })
        self.assertEqual('200', headers['status'])
        self.assertEqual('"0-16de4d7060d0b9d102ef0fca8acc892a"', headers['etag'])
        self.assertEqual('1.2.840.10008.1.2.4.70', body)
        
        (headers, body) = DoDeleteRaw(_REMOTE, '/instances/%s/metadata/TransferSyntax' % i)
        self.assertEqual('403', headers['status'])  # Forbidden (system metadata)
        
        (headers, body) = DoPutRaw(_REMOTE, '/instances/%s/metadata/TransferSyntax' % i, 'hello')
        self.assertEqual('403', headers['status'])  # Forbidden (system metadata)
        
        (headers, body) = DoPutRaw(_REMOTE, '/instances/%s/metadata/1024' % i, 'hello')
        self.assertEqual('200', headers['status'])
        self.assertEqual('"0-%s"' % ComputeMD5('hello'), headers['etag'])
        
        (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/metadata/1024' % i)
        self.assertEqual('200', headers['status'])
        self.assertEqual('"0-5d41402abc4b2a76b9719d911017c592"', headers['etag'])
        self.assertEqual('hello', body)
        
        (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/metadata/1024' % i, headers = {
            'If-None-Match' : '"0-5d41402abc4b2a76b9719d911017c592"'
        })
        self.assertEqual('304', headers['status'])
        self.assertEqual('"0-5d41402abc4b2a76b9719d911017c592"', headers['etag'])
        self.assertEqual('', body)
        
        (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/metadata/1024' % i, headers = {
            'If-None-Match' : '"1-tata"'
        })
        self.assertEqual('200', headers['status'])
        self.assertEqual('"0-5d41402abc4b2a76b9719d911017c592"', headers['etag'])
        self.assertEqual('hello', body)
        self.assertEqual('hello', DoGet(_REMOTE, '/instances/%s/metadata/1024' % i))
        
        (headers, body) = DoDeleteRaw(_REMOTE, '/instances/%s/metadata/1024' % i)
        self.assertEqual('409', headers['status'])  # No revision given, but "CheckRevisions" is True
        
        (headers, body) = DoDeleteRaw(_REMOTE, '/instances/%s/metadata/1024' % i, headers = {
            'If-Match' : '45-5d41402abc4b2a76b9719d911017c592'
        })
        self.assertEqual('409', headers['status'])  # Conflict, as bad revision
        
        (headers, body) = DoDeleteRaw(_REMOTE, '/instances/%s/metadata/1024' % i, headers = {
            'If-Match' : '0-tata'
        })
        self.assertEqual('409', headers['status'])  # Conflict, as bad MD5
        
        (headers, body) = DoDeleteRaw(_REMOTE, '/instances/%s/metadata/1024' % i, headers = {
            'If-Match' : '0-5d41402abc4b2a76b9719d911017c592'
        })
        self.assertEqual('200', headers['status'])

        (headers, body) = DoDeleteRaw(_REMOTE, '/instances/%s/metadata/1024' % i, headers = {
            'If-Match' : '0-5d41402abc4b2a76b9719d911017c592'
        })
        self.assertEqual('404', headers['status'])

        (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/metadata/1024' % i)
        self.assertEqual('404', headers['status'])

        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/metadata/1024' % i))

        (headers, body) = DoPutRaw(_REMOTE, '/instances/%s/metadata/1024' % i, 'hello')
        self.assertEqual('200', headers['status'])
        self.assertEqual('"0-5d41402abc4b2a76b9719d911017c592"', headers['etag'])

        (headers, body) = DoPutRaw(_REMOTE, '/instances/%s/metadata/1024' % i, 'hello')
        self.assertEqual('409', headers['status'])

        (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/metadata/1024' % i, headers = {
            'If-None-Match' : '"0-5d41402abc4b2a76b9719d911017c592"'
        })
        self.assertEqual('304', headers['status'])  # Not modified
        self.assertEqual('"0-5d41402abc4b2a76b9719d911017c592"', headers['etag'])
        self.assertEqual('', body)  # Body must be empty on 304 status

        (headers, body) = DoPutRaw(_REMOTE, '/instances/%s/metadata/1024' % i, 'hello', headers = {
            'If-Match' : '0-5d41402abc4b2a76b9719d911017c592'            
        })
        self.assertEqual('200', headers['status'])
        self.assertEqual('"1-5d41402abc4b2a76b9719d911017c592"', headers['etag'])
        self.assertEqual('', body)
        
        (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/metadata/1024' % i, headers = {
            'If-None-Match' : headers['etag']
        })

        if headers['status'] == '200':
            print("Your database backend doesn't store revisions")
            (headers, body) = DoPutRaw(_REMOTE, '/instances/%s/metadata/1024' % i, 'hello2', headers = {
                'If-Match' : '1-5d41402abc4b2a76b9719d911017c592'
            })
            self.assertEqual('409', headers['status'])

            (headers, body) = DoPutRaw(_REMOTE, '/instances/%s/metadata/1024' % i, 'hello2', headers = {
                'If-Match' : '0-5d41402abc4b2a76b9719d911017c592'
            })
            self.assertEqual('200', headers['status'])
            self.assertEqual('"1-6e809cbda0732ac4845916a59016f954"', headers['etag'])
            self.assertEqual('', body)

        elif headers['status'] == '304':  # Revisions are supported
            (headers, body) = DoPutRaw(_REMOTE, '/instances/%s/metadata/1024' % i, 'hello2', headers = {
                'If-Match' : '0-5d41402abc4b2a76b9719d911017c592'
            })
            self.assertEqual('409', headers['status'])

            (headers, body) = DoPutRaw(_REMOTE, '/instances/%s/metadata/1024' % i, 'hello2', headers = {
                'If-Match' : '1-5d41402abc4b2a76b9719d911017c592'
            })
            self.assertEqual('200', headers['status'])
            self.assertEqual('"2-6e809cbda0732ac4845916a59016f954"', headers['etag'])
            self.assertEqual('', body)
        
        else:
            raise Exception('Internal error')

        self.assertEqual('hello2', DoGet(_REMOTE, '/instances/%s/metadata/1024' % i))
        

    def test_revisions_attachments(self):
        # This test fails on Orthanc <= 1.9.1 (support for revisions
        # was introduced in 1.9.2), or if configuration option
        # "CheckRevisions" is "False". Conventions for HTTP headers
        # related to revisions mimic CouchDB:
        # https://docs.couchdb.org/en/stable/api/document/common.html
        i = UploadInstance(_REMOTE, 'DummyCT.dcm') ['ID']

        with open(GetDatabasePath('DummyCT.dcm'), 'rb') as f:
            md5 = ComputeMD5(f.read())
        
        # "/compress", "/uncompress" and "/verify-md5" are POST
        # methods, and are not affected by revisions
        for suffix in [ '', '/compressed-data', '/compressed-md5', '/compressed-size',
                        '/data', '/is-compressed', '/md5', '/size' ]:
            (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/attachments/dicom/%s' % (i, suffix))
            self.assertEqual('200', headers['status'])
            self.assertEqual('"0-%s"' % md5, headers['etag'])

            (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/attachments/dicom/%s' % (i, suffix), headers = {
                'If-None-Match' : '"0-3e29b869978b6db4886355a2b1132124"',
            })
            self.assertEqual('304', headers['status'])  # Not modified
            self.assertEqual('"0-3e29b869978b6db4886355a2b1132124"', headers['etag'])
            self.assertEqual('', body)  # Body must be empty on 304 status

            (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/attachments/dicom/%s' % (i, suffix), headers = {
                'If-None-Match' : '"tata"',  # Invalid header
            })
            self.assertEqual('400', headers['status'])

            (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/attachments/dicom/%s' % (i, suffix), headers = {
                'If-None-Match' : '"1-%s"' % md5, # Bad revision, good MD5
            })
            self.assertEqual('200', headers['status'])
            self.assertEqual('"0-3e29b869978b6db4886355a2b1132124"', headers['etag'])

            (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/attachments/dicom/%s' % (i, suffix), headers = {
                'If-None-Match' : '"0-tata"' # Good revision, bad MD5
            })
            self.assertEqual('200', headers['status'])
            self.assertEqual('"0-3e29b869978b6db4886355a2b1132124"', headers['etag'])

        (headers, body) = DoDeleteRaw(_REMOTE, '/instances/%s/attachments/dicom' % i)
        self.assertEqual('403', headers['status'])  # Forbidden (system metadata)
        
        (headers, body) = DoPutRaw(_REMOTE, '/instances/%s/attachments/dicom' % i, 'hello')
        self.assertEqual('403', headers['status'])  # Forbidden (system metadata)
        
        (headers, body) = DoPutRaw(_REMOTE, '/instances/%s/attachments/1024' % i, 'hello')
        self.assertEqual('200', headers['status'])
        self.assertEqual('"0-%s"' % ComputeMD5('hello'), headers['etag'])
        
        (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/attachments/1024/data' % i)
        self.assertEqual('200', headers['status'])
        self.assertEqual('"0-5d41402abc4b2a76b9719d911017c592"', headers['etag'])
        self.assertEqual('hello', body)
        
        (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/attachments/1024/data' % i, headers = {
            'If-None-Match' : '"0-5d41402abc4b2a76b9719d911017c592"'
        })
        self.assertEqual('304', headers['status'])
        self.assertEqual('"0-5d41402abc4b2a76b9719d911017c592"', headers['etag'])
        self.assertEqual('', body)

        for h in [ '"1-5d41402abc4b2a76b9719d911017c592"', # Bad revision, good MD5
                   '"0-tata"']: # Good revision, bad MD5 
            (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/attachments/1024/data' % i, headers = {
                'If-None-Match' : h
            })
            self.assertEqual('200', headers['status'])
            self.assertEqual('"0-5d41402abc4b2a76b9719d911017c592"', headers['etag'])
            self.assertEqual('hello', body)
        
        (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/attachments/1024/data' % i, headers = {
            'If-None-Match' : 'tata'  # Bad header format
        })
        self.assertEqual('400', headers['status'])
        self.assertEqual('"0-5d41402abc4b2a76b9719d911017c592"', headers['etag'])

        self.assertEqual('hello', DoGet(_REMOTE, '/instances/%s/attachments/1024/data' % i))
        
        (headers, body) = DoDeleteRaw(_REMOTE, '/instances/%s/attachments/1024' % i)
        self.assertEqual('409', headers['status'])  # No revision given, but "CheckRevisions" is True
        
        (headers, body) = DoDeleteRaw(_REMOTE, '/instances/%s/attachments/1024' % i, headers = {
            'If-Match' : '45-5d41402abc4b2a76b9719d911017c592'
        })
        self.assertEqual('409', headers['status'])  # Conflict, as bad revision
        
        (headers, body) = DoDeleteRaw(_REMOTE, '/instances/%s/attachments/1024' % i, headers = {
            'If-Match' : '0-tata'
        })
        self.assertEqual('409', headers['status'])  # Conflict, as bad MD5
        
        (headers, body) = DoDeleteRaw(_REMOTE, '/instances/%s/attachments/1024' % i, headers = {
            'If-Match' : '0-5d41402abc4b2a76b9719d911017c592'
        })
        self.assertEqual('200', headers['status'])

        (headers, body) = DoDeleteRaw(_REMOTE, '/instances/%s/attachments/1024' % i, headers = {
            'If-Match' : '0-5d41402abc4b2a76b9719d911017c592'
        })
        self.assertEqual('404', headers['status'])

        (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/attachments/1024/data' % i)
        self.assertEqual('404', headers['status'])

        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/attachments/1024' % i))

        (headers, body) = DoPutRaw(_REMOTE, '/instances/%s/attachments/1024' % i, 'hello')
        self.assertEqual('200', headers['status'])
        self.assertEqual('"0-5d41402abc4b2a76b9719d911017c592"', headers['etag'])

        (headers, body) = DoPutRaw(_REMOTE, '/instances/%s/attachments/1024' % i, 'hello')
        self.assertEqual('409', headers['status'])

        (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/attachments/1024/data' % i, headers = {
            'If-None-Match' : '"0-5d41402abc4b2a76b9719d911017c592"'
        })
        self.assertEqual('304', headers['status'])  # Not modified
        self.assertEqual('"0-5d41402abc4b2a76b9719d911017c592"', headers['etag'])
        self.assertEqual('', body)  # Body must be empty on 304 status

        (headers, body) = DoPutRaw(_REMOTE, '/instances/%s/attachments/1024' % i, 'hello', headers = {
            'If-Match' : '0-5d41402abc4b2a76b9719d911017c592'            
        })
        self.assertEqual('200', headers['status'])
        self.assertEqual('"1-5d41402abc4b2a76b9719d911017c592"', headers['etag'])
        self.assertEqual('{}', body)
        
        (headers, body) = DoGetRaw(_REMOTE, '/instances/%s/attachments/1024/data' % i, headers = {
            'If-None-Match' : headers['etag']
        })

        if headers['status'] == '200':
            print("Your database backend doesn't store revisions")
            (headers, body) = DoPutRaw(_REMOTE, '/instances/%s/attachments/1024' % i, 'hello2', headers = {
                'If-Match' : '1-5d41402abc4b2a76b9719d911017c592'
            })
            self.assertEqual('409', headers['status'])

            (headers, body) = DoPutRaw(_REMOTE, '/instances/%s/attachments/1024' % i, 'hello2', headers = {
                'If-Match' : '0-5d41402abc4b2a76b9719d911017c592'
            })
            self.assertEqual('200', headers['status'])
            self.assertEqual('"1-6e809cbda0732ac4845916a59016f954"', headers['etag'])
            self.assertEqual('{}', body)

        elif headers['status'] == '304':  # Revisions are supported
            (headers, body) = DoPutRaw(_REMOTE, '/instances/%s/attachments/1024' % i, 'hello2', headers = {
                'If-Match' : '0-5d41402abc4b2a76b9719d911017c592'
            })
            self.assertEqual('409', headers['status'])

            (headers, body) = DoPutRaw(_REMOTE, '/instances/%s/attachments/1024' % i, 'hello2', headers = {
                'If-Match' : '1-5d41402abc4b2a76b9719d911017c592'
            })
            self.assertEqual('200', headers['status'])
            self.assertEqual('"2-6e809cbda0732ac4845916a59016f954"', headers['etag'])
            self.assertEqual('{}', body)
        
        else:
            raise Exception('Internal error')

        self.assertEqual('hello2', DoGet(_REMOTE, '/instances/%s/attachments/1024/data' % i))


    def test_issue_195(self):
        # This fails on Orthanc <= 1.9.2
        # https://bugs.orthanc-server.com/show_bug.cgi?id=195
        a = UploadInstance(_REMOTE, 'Issue195.dcm')['ID']
        b = DoGet(_REMOTE, '/instances/%s/file' % a,
                  headers = { 'Accept' : 'application/dicom+json' })

        # The expected result can be found by typing "dcm2json Database/Issue195.dcm"
        self.assertEqual(5, len(b))
        self.assertEqual(2, len(b["00080018"]))
        self.assertEqual("UI", b["00080018"]["vr"])
        self.assertEqual("1.2.276.0.7230010.3.1.4.8323329.13188.1620309604.848735",
                         b["00080018"]["Value"][0])

        self.assertEqual(2, len(b["0020000D"]))
        self.assertEqual("UI", b["0020000D"]["vr"])
        self.assertEqual("1.2.276.0.7230010.3.1.2.8323329.13188.1620309604.848733",
                         b["0020000D"]["Value"][0])

        self.assertEqual(2, len(b["0020000E"]))
        self.assertEqual("UI", b["0020000E"]["vr"])
        self.assertEqual("1.2.276.0.7230010.3.1.3.8323329.13188.1620309604.848734",
                         b["0020000E"]["Value"][0])

        self.assertEqual(1, len(b["00081030"]))  # Case of an empty value
        self.assertEqual("LO", b["00081030"]["vr"])

        self.assertEqual(2, len(b["0008103E"]))
        self.assertEqual("LO", b["0008103E"]["vr"])
        self.assertEqual("Hello1", b["0008103E"]["Value"][0])

        a = UploadInstance(_REMOTE, 'Issue195-bis.dcm')['ID']
        b = DoGet(_REMOTE, '/instances/%s/file' % a,
                  headers = { 'Accept' : 'application/dicom+json' })

        # The expected result can be found by typing "dcm2json Database/Issue195-bis.dcm"
        self.assertEqual(5, len(b))
        self.assertEqual(2, len(b["00080018"]))
        self.assertEqual("UI", b["00080018"]["vr"])
        self.assertEqual("1.2.276.0.7230010.3.1.4.8323329.6792.1625504071.652470",
                         b["00080018"]["Value"][0])

        self.assertEqual(2, len(b["0020000D"]))
        self.assertEqual("UI", b["0020000D"]["vr"])
        self.assertEqual("1.2.276.0.7230010.3.1.2.8323329.6792.1625504071.652468",
                         b["0020000D"]["Value"][0])

        self.assertEqual(2, len(b["0020000E"]))
        self.assertEqual("UI", b["0020000E"]["vr"])
        self.assertEqual("1.2.276.0.7230010.3.1.3.8323329.6792.1625504071.652469",
                         b["0020000E"]["Value"][0])

        self.assertEqual(2, len(b["00084567"]))
        self.assertEqual("UN", b["00084567"]["vr"])

        # NB: "QgA=" corresponds to the base64 encoding of (uint16_t) 0x42 in little endian:
        #     $ echo -n 'QgA=' | base64 -d | hexdump -C
        self.assertEqual("QgA=", b["00084567"]["InlineBinary"])

        # Case of an empty value, fails in Orthanc <= 1.9.2 because of issue #195
        self.assertEqual(1, len(b["00084565"]))
        self.assertEqual("UN", b["00084565"]["vr"])


    def test_modify_attribute(self):
        # This fails on Orthanc <= 1.9.3 (not implemented)
        # https://groups.google.com/g/orthanc-users/c/1pzCqT-ByXg/m/VyIGK5i5BgAJ
        i = UploadInstance(_REMOTE, 'DummyCT.dcm') ['ID']
        
        tags = DoGet(_REMOTE, '/instances/%s/tags?short' % i)
        self.assertFalse('0020,9165' in tags)
        
        i = DoPost(_REMOTE, '/studies/b9c08539-26f93bde-c81ab0d7-bffaf2cb-a4d0bdd0/modify', {
            "Replace": {
                "0020,9165": "0020,9056",
            }
        })
        instances = DoGet(_REMOTE, '/studies/%s/instances' % i['ID'])
        self.assertEqual(1, len(instances))
        
        tags = DoGet(_REMOTE, '/instances/%s/tags?short' % instances[0]['ID'])
        self.assertTrue('0020,9165' in tags)
        self.assertEqual('0020,9056', tags['0020,9165'])


    def test_issue_146(self):
        # "Update Anonyization to 2019c"
        # https://bugs.orthanc-server.com/show_bug.cgi?id=146

        def GetTags(study, params):
            a = DoPost(_REMOTE, '/studies/%s/anonymize' % study, params) ['ID']
            b = DoGet(_REMOTE, '/studies/%s/instances' % a)
            self.assertEqual(1, len(b))
            return DoGet(_REMOTE, '/instances/%s/tags?short' % b[0]['ID'])
            
        
        UploadInstance(_REMOTE, 'Issue146.dcm')
        study = '7c950970-321e4ab0-28446c5f-f94850f1-5c44634b'

        self.assertRaises(Exception, lambda: GetTags(study, { 'DicomVersion' : 'nope' }))

        tags2008 = GetTags(study, { 'DicomVersion' : '2008' })
        tags2017c = GetTags(study, { 'DicomVersion' : '2017c' })
        tags2021b = GetTags(study, { 'DicomVersion' : '2021b' })
        tags2023b = GetTags(study, { 'DicomVersion' : '2023b' })
        tagsDefault = GetTags(study, {})

        orthancVersion = DoGet(_REMOTE, '/system') ['Version']
        if orthancVersion.startswith('mainline-'):  # happens in unstable orthancteam/orthanc images
            orthancVersion = 'mainline'
            
        self.assertEqual('Orthanc %s - PS 3.15-2008 Table E.1-1' % orthancVersion, tags2008['0012,0063'])
        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'])
        self.assertEqual(tagsDefault['0012,0063'], tags2023b['0012,0063'])

        self.assertEqual(len(tags2021b), len(tags2023b))
        self.assertNotEqual(tags2021b, tags2023b)

        for t in [ tags2008, tags2017c, tags2021b, tags2023b, tagsDefault ]:
            self.assertTrue(t['0010,0010'].startswith('Anonymized'))
            self.assertEqual('1.2.840.10008.5.1.4.1.1.4', t['0008,0016'])
            self.assertEqual(36, len(t['0010,0020']))  # Length of a UUID
            self.assertEqual('YES', t['0012,0062'])

        for t in [ tags2008 ]:
            self.assertEqual('20200101', t['0008,0020'])
            
        for t in [ tags2017c, tags2021b, tags2023b, tagsDefault ]:
            self.assertEqual('', t['0008,0020'])  # Study Date, anonymized between 2008 and 2017c
        
        for t in [ tags2008, tags2017c ]:
            self.assertEqual('HELLO^C', t['0050,0020'])
            self.assertEqual('HELLO^D', t['3006,0002'])
            
        for t in [ tags2021b, tags2023b, tagsDefault ]:
            self.assertFalse('0050,0020' in t)    # Device Description, anonymized between 2017c and 2019c
            self.assertEqual('', t['3006,0002'])  # StructureSetLabel, anonymized between 2019c and 2021b


    def test_anonymize_relationships_6(self):
        # 2020-10-20 (Salim Kanoun): "I think I have hit an
        # anonymization issue for the tag 0008,1250. This tags is a
        # sequence containing StudyUID / Series UID of related series.
        # [After anonymization,] this tag keep a reference of the
        # original Study/Series UID.
        # https://groups.google.com/g/orthanc-users/c/T0IokiActrI/m/L9K0vfscAAAJ
        UploadInstance(_REMOTE, '2020-11-16-SalimKanounAnonymization.dcm')

        tags = DoGet(_REMOTE, '/instances/%s/tags?short' % DoGet(_REMOTE, '/instances') [0])
        self.assertEqual('1.2.840.113619.6.95.31.0.3.4.1.3175.13.6054282',
                         tags['0008,1250'][0]['0020,000d'])
        self.assertEqual('1.3.12.2.1107.5.1.4.11047.30000019111306043635400005028',
                         tags['0008,1250'][0]['0020,000e'])

        a = DoGet(_REMOTE, '/studies')
        self.assertEqual(1, len(a))
        b = DoPost(_REMOTE, '/studies/%s/anonymize' % a[0], {}) ['ID']

        c = DoGet(_REMOTE, '/studies/%s/instances' % b)
        self.assertEqual(1, len(c))
        tags = DoGet(_REMOTE, '/instances/%s/tags?short' % c[0]['ID'])

        # In Orthanc <= 1.9.3, the two tests below failed
        self.assertNotEqual('1.2.840.113619.6.95.31.0.3.4.1.3175.13.6054282',
                            tags['0008,1250'][0]['0020,000d'])
        self.assertNotEqual('1.3.12.2.1107.5.1.4.11047.30000019111306043635400005028',
                            tags['0008,1250'][0]['0020,000e'])


    def test_modify_subsequences(self):
        # New in Orthanc 1.9.4 (cf. LSD-629)
        UploadInstance(_REMOTE, 'Issue22-NoPixelData.dcm')
        studies = DoGet(_REMOTE, '/studies')
        self.assertEqual(1, len(studies))

        def GetTags(study):
            instances = DoGet(_REMOTE, '/studies/%s/instances' % study)
            self.assertEqual(1, len(instances))
            return DoGet(_REMOTE, '/instances/%s/tags?short' % instances[0]['ID'])

        tags1 = GetTags(studies[0])

        a = DoPost(_REMOTE, '/studies/%s/modify' % studies[0], {
              'Replace' : {
                  'PatientName' : 'Hello1',
                  'DimensionIndexSequence[1].DimensionDescriptionLabel' : 'Hello2',
                  'DimensionIndexSequence[*].PatientName' : 'Hello3',
                  'ReferencedImageEvidenceSequence[2].ReferencedSeriesSequence[0].ReferencedSOPSequence[0].ReferencedSOPInstanceUID' : 'Hello4',
                  'DimensionOrganizationSequence[0].DimensionOrganizationUID' : '1.2.3.4',
              },
              'Remove' : [
                  'ReferencedPerformedProcedureStepSequence',
                  'PerformedProtocolCodeSequence[0].CodeValue',
                  'SharedFunctionalGroupsSequence[*].ReferencedImageSequence[*].ReferencedSOPInstanceUID',
                  'SharedFunctionalGroupsSequence[*].ReferencedImageSequence[1].ReferencedSOPClassUID',
                  'SharedFunctionalGroupsSequence[2].ReferencedImageSequence',  # Inexistent tag
              ]
            })
        tags2 = GetTags(a['ID'])

        self.assertEqual('Anonymized1', tags1['0010,0010'])
        self.assertEqual('Hello1', tags2['0010,0010'])

        self.assertEqual('Stack ID', tags1['0020,9222'][0]['0020,9421'])
        self.assertEqual('In-Stack Position Number', tags1['0020,9222'][1]['0020,9421'])
        self.assertEqual('Stack ID', tags2['0020,9222'][0]['0020,9421'])
        self.assertEqual('Hello2', tags2['0020,9222'][1]['0020,9421'])

        for i in range(3):
            self.assertFalse('0010,0010' in tags1['0020,9222'][i])
            self.assertEqual('Hello3', tags2['0020,9222'][i]['0010,0010'])

        self.assertEqual('1.3.46.670589.11.22237.5.20.1.1.7512.2014100814064168452',
                         tags1['0008,9092'][2]['0008,1115'][0]['0008,1199'][0]['0008,1155'])
        self.assertEqual('Hello4',
                         tags2['0008,9092'][2]['0008,1115'][0]['0008,1199'][0]['0008,1155'])
        self.assertEqual(tags1['0008,9092'][1]['0008,1115'][0]['0008,1199'][0]['0008,1155'],
                         tags2['0008,9092'][1]['0008,1115'][0]['0008,1199'][0]['0008,1155'])

        self.assertTrue('0008,1111' in tags1)
        self.assertFalse('0008,1111' in tags2)
        self.assertTrue('0008,0100' in tags1['0040,0260'][0])
        self.assertFalse('0008,0100' in tags2['0040,0260'][0])

        for i in range(3):
            self.assertTrue('0008,1155' in tags1['5200,9229'][0]['0008,1140'][i])
            self.assertFalse('0008,1155' in tags2['5200,9229'][0]['0008,1140'][i])
            self.assertTrue('0008,1150' in tags1['5200,9229'][0]['0008,1140'][i])

        self.assertTrue('0008,1150' in tags2['5200,9229'][0]['0008,1140'][0])
        self.assertFalse('0008,1150' in tags2['5200,9229'][0]['0008,1140'][1])
        self.assertTrue('0008,1150' in tags2['5200,9229'][0]['0008,1140'][2])

        self.assertEqual('1.3.46.670589.11.22237.5.0.11272.2014100816243076000',
                         tags1['0020,9221'][0]['0020,9164'])
        self.assertEqual('1.2.3.4', tags2['0020,9221'][0]['0020,9164'])

        a = DoPost(_REMOTE, '/studies/%s/anonymize' % studies[0], {
              'Replace' : {
                  'DimensionIndexSequence[1].DimensionDescriptionLabel' : 'Hello1',
                  'DimensionOrganizationSequence[0].DimensionOrganizationUID' : '1.2.3.4',
              },
              'Remove' : [
                  'SharedFunctionalGroupsSequence[*].ReferencedImageSequence[*].ReferencedSOPInstanceUID',  # 5200,9229
              ],
              'Keep' : [
                  'ReferencedImageEvidenceSequence',  # 0008,9092
                  'DimensionIndexSequence',  # 0020,9222
                  'PerFrameFunctionalGroupsSequence[*].2005,140f[*].SOPInstanceUID',  # 5200,9230
                  '(5200,9230)[*].2005,140f[*].(0008,0023)',  # Compatibility with Orthanc 1.9.4
                  '(5200,9230)[*].2005,140f[*].(0008,0033)',  # Compatibility with Orthanc 1.9.4
              ],
              'DicomVersion' : '2021b',
              'KeepPrivateTags' : True  # Compatibility with Orthanc 1.9.4
            })
        tags3 = GetTags(a['ID'])

        # UIDs
        for i in [ '0008,0018',
                   '0010,0020',
                   '0008,0018',
                   '0010,0020' ]:
            self.assertNotEqual(tags1[i], tags3[i])

        self.assertNotEqual(tags1['0020,9221'][0]['0020,9164'],
                            tags3['0020,9221'][0]['0020,9164'])

        self.assertNotEqual(tags1['5200,9229'][0]['2005,140e'][0]['0008,0014'],
                            tags3['5200,9229'][0]['2005,140e'][0]['0008,0014'])

        # http://dicom.nema.org/medical/dicom/current/output/chtml/part15/chapter_E.html#table_E.1-1
        # Removals (X)
        for i in [ '0008,0021',
                   '0008,002a',
                   '0008,0031',
                   '0008,1030',
                   '0008,103e',
                   '0008,1111',
                   '0010,21c0',
                   '0040,0006',
                   '0040,0241',
                   '0040,0244',
                   '0040,0245',
                   '0040,0250',
                   '0040,0251',
                   '0040,0253',
                   '0040,0254',
                   '0040,0555',
        ]:
            self.assertTrue(i in tags1)
            self.assertFalse(i in tags3)

        # Clearings (Z)
        for i in [ '0008,0020',
                   '0008,0023',
                   '0008,0030',
                   '0008,0033' ]:
            self.assertNotEqual('', tags1[i])
            self.assertEqual('', tags3[i])

        # Replace
        self.assertEqual('In-Stack Position Number', tags1['0020,9222'][1]['0020,9421'])
        self.assertEqual('Hello1', tags3['0020,9222'][1]['0020,9421'])        
        self.assertEqual('1.2.3.4', tags3['0020,9221'][0]['0020,9164'])
        
        # "Keep" on DimensionIndexSequence
        for i in range(3):
            self.assertEqual(tags1['0020,9222'][i]['0020,9164'],
                             tags3['0020,9222'][i]['0020,9164'])

        # "Keep" on ReferencedImageEvidenceSequence
        self.assertEqual(json.dumps(tags1['0008,9092']),
                         json.dumps(tags3['0008,9092']))

        # "Keep" on PerFrameFunctionalGroupsSequence
        self.assertEqual(json.dumps(tags1['5200,9230']),
                         json.dumps(tags3['5200,9230']))

        # "Remove" on SharedFunctionalGroupsSequence
        for i in range(3):
            self.assertTrue('0008,1155' in tags1['5200,9229'][0]['0008,1140'][i])
            self.assertFalse('0008,1155' in tags3['5200,9229'][0]['0008,1140'][i])


    def test_bulk_modify(self):
        # New in Orthanc 1.9.4

        def GetModified(lst, resourceType, expectedCount = None):
            m = map(lambda x: x['ID'], filter(lambda x: x['Type'] == resourceType, lst['Resources']))
            if expectedCount != None:
                self.assertEqual(expectedCount, len(m))
            return m
        
        instance = UploadInstance(_REMOTE, 'DummyCT.dcm') ['ID']
        series = DoGet(_REMOTE, '/series') [0]
        study = DoGet(_REMOTE, '/studies') [0]
        patient = DoGet(_REMOTE, '/patients') [0]

        a = DoPost(_REMOTE, '/tools/bulk-modify', {
            'Resources' : [ instance ]
            })

        self.assertNotEqual(instance, GetModified(a, 'Instance', 1) [0])
        self.assertEqual(series, GetModified(a, 'Series', 1) [0])
        self.assertEqual(study, GetModified(a, 'Study', 1) [0])
        self.assertEqual(patient, GetModified(a, 'Patient', 1) [0])

        b = DoPost(_REMOTE, '/tools/bulk-anonymize', {
            'Resources' : [ instance ]
            })

        self.assertNotEqual(instance, GetModified(b, 'Instance', 1) [0])
        self.assertNotEqual(series, GetModified(b, 'Series', 1) [0])
        self.assertNotEqual(study, GetModified(b, 'Study', 1) [0])
        self.assertNotEqual(patient, GetModified(b, 'Patient', 1) [0])
        
        self.assertEqual(3, len(DoGet(_REMOTE, '/instances')))
        self.assertEqual(2, len(DoGet(_REMOTE, '/series')))
        self.assertEqual(2, len(DoGet(_REMOTE, '/studies')))
        self.assertEqual(2, len(DoGet(_REMOTE, '/patients')))

        DoPost(_REMOTE, '/tools/bulk-delete', {
            'Resources' : GetModified(b, 'Patient', 1) + GetModified(a, 'Instance', 1)
            })
        
        knee1 = UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm') ['ID']
        knee2 = UploadInstance(_REMOTE, 'Knee/T2/IM-0001-0001.dcm') ['ID']
        brainix = UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm') ['ID']
        
        self.assertEqual(4, len(DoGet(_REMOTE, '/instances')))
        self.assertEqual(4, len(DoGet(_REMOTE, '/series')))
        self.assertEqual(3, len(DoGet(_REMOTE, '/studies')))
        self.assertEqual(3, len(DoGet(_REMOTE, '/patients')))

        a = DoPost(_REMOTE, '/tools/bulk-modify', {
            'Resources' : [ knee1, brainix ]
            })

        self.assertEqual(6, len(DoGet(_REMOTE, '/instances')))
        self.assertEqual(4, len(DoGet(_REMOTE, '/series')))
        self.assertEqual(3, len(DoGet(_REMOTE, '/studies')))
        self.assertEqual(3, len(DoGet(_REMOTE, '/patients')))

        for i in GetModified(a, 'Instance', 2):
            self.assertTrue(not i in [ instance, knee1, knee2, brainix ])
            self.assertTrue(DoGet(_REMOTE, '/instances/%s/metadata/ModifiedFrom' % i) in [ knee1, brainix ])

        b = GetModified(a, 'Series', 2)
        self.assertTrue(DoGet(_REMOTE, '/instances/%s/series' % knee1) ['ID'] in b)
        self.assertTrue(DoGet(_REMOTE, '/instances/%s/series' % brainix) ['ID'] in b)
        self.assertFalse(DoGet(_REMOTE, '/instances/%s/series' % knee2) ['ID'] in b)
        self.assertFalse(DoGet(_REMOTE, '/instances/%s/series' % instance) ['ID'] in b)
        
        b = GetModified(a, 'Study', 2)
        self.assertTrue(DoGet(_REMOTE, '/instances/%s/study' % knee1) ['ID'] in b)
        self.assertTrue(DoGet(_REMOTE, '/instances/%s/study' % brainix) ['ID'] in b)
        self.assertTrue(DoGet(_REMOTE, '/instances/%s/study' % knee2) ['ID'] in b)
        self.assertFalse(DoGet(_REMOTE, '/instances/%s/study' % instance) ['ID'] in b)

        b = GetModified(a, 'Patient', 2)
        self.assertTrue(DoGet(_REMOTE, '/instances/%s/patient' % knee1) ['ID'] in b)
        self.assertTrue(DoGet(_REMOTE, '/instances/%s/patient' % brainix) ['ID'] in b)
        self.assertTrue(DoGet(_REMOTE, '/instances/%s/patient' % knee2) ['ID'] in b)
        self.assertFalse(DoGet(_REMOTE, '/instances/%s/patient' % instance) ['ID'] in b)
        
        DoPost(_REMOTE, '/tools/bulk-delete', {
            'Resources' : GetModified(a, 'Instance', 2)
        })

        sourceInstances = DoGet(_REMOTE, '/instances')
        sourceSeries = DoGet(_REMOTE, '/series')
        sourceStudies = DoGet(_REMOTE, '/studies')
        sourcePatients = DoGet(_REMOTE, '/patients')
        self.assertEqual(4, len(sourceInstances))
        self.assertEqual(4, len(sourceSeries))
        self.assertEqual(3, len(sourceStudies))
        self.assertEqual(3, len(sourcePatients))

        a = DoPost(_REMOTE, '/tools/bulk-anonymize', {
            'Resources' : [ knee1, brainix ]
            })

        self.assertEqual(6, len(DoGet(_REMOTE, '/instances')))
        self.assertEqual(6, len(DoGet(_REMOTE, '/series')))
        self.assertEqual(5, len(DoGet(_REMOTE, '/studies')))
        self.assertEqual(5, len(DoGet(_REMOTE, '/patients')))

        for i in GetModified(a, 'Instance', 2):
            self.assertFalse(i in sourceInstances)
            self.assertTrue(DoGet(_REMOTE, '/instances/%s/metadata/AnonymizedFrom' % i) in [ knee1, brainix ])

        for i in GetModified(a, 'Series', 2):
            self.assertFalse(i in sourceSeries)
            self.assertTrue(DoGet(_REMOTE, '/series/%s/metadata/AnonymizedFrom' % i) in sourceSeries)

        for i in GetModified(a, 'Study', 2):
            self.assertFalse(i in sourceStudies)
            self.assertTrue(DoGet(_REMOTE, '/studies/%s/metadata/AnonymizedFrom' % i) in sourceStudies)

        for i in GetModified(a, 'Patient', 2):
            self.assertFalse(i in sourcePatients)
            self.assertTrue(DoGet(_REMOTE, '/patients/%s/metadata/AnonymizedFrom' % i) in sourcePatients)

        DoPost(_REMOTE, '/tools/bulk-delete', {
            'Resources' : GetModified(a, 'Patient', 2)
        })

        self.assertEqual(4, len(DoGet(_REMOTE, '/instances')))
        self.assertEqual(4, len(DoGet(_REMOTE, '/series')))
        self.assertEqual(3, len(DoGet(_REMOTE, '/studies')))
        self.assertEqual(3, len(DoGet(_REMOTE, '/patients')))

        DoPost(_REMOTE, '/tools/bulk-delete', {
            'Resources' : [ instance,
                            DoGet(_REMOTE, '/instances/%s/patient' % knee1) ['ID'],
                            DoGet(_REMOTE, '/instances/%s/series' % brainix) ['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_dicom_to_json_format(self):
        # Test new output formats for DICOM tags introduced in 1.9.4
        instance = UploadInstance(_REMOTE, 'DummyCT.dcm') ['ID']
        patient = DoGet(_REMOTE, '/instances/%s/patient' % instance) ['ID']
        study = DoGet(_REMOTE, '/instances/%s/study' % instance) ['ID']
        series = DoGet(_REMOTE, '/instances/%s/series' % instance) ['ID']
        
        self.assertEqual('KNIX', DoGet(_REMOTE, '/instances/%s/tags' % instance) ['0010,0010']['Value'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/instances/%s/tags?simplify' % instance) ['PatientName'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/instances/%s/tags?short' % instance) ['0010,0010'])
        self.assertEqual('PatientName', DoGet(_REMOTE, '/instances/%s/tags?full' % instance) ['0010,0010']['Name'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/instances/%s/tags?full' % instance) ['0010,0010']['Value'])
        self.assertEqual('String', DoGet(_REMOTE, '/instances/%s/tags?full' % instance) ['0010,0010']['Type'])

        # Test "GetInstanceHeader()" in "OrthancRestResources.cpp"
        self.assertEqual('1.2.840.10008.1.2.4.70', DoGet(_REMOTE, '/instances/%s/header' % instance) ['0002,0010']['Value'])
        self.assertEqual('1.2.840.10008.1.2.4.70', DoGet(_REMOTE, '/instances/%s/header?simplify' % instance) ['TransferSyntaxUID'])
        self.assertEqual('1.2.840.10008.1.2.4.70', DoGet(_REMOTE, '/instances/%s/header?short' % instance) ['0002,0010'])
        self.assertEqual('TransferSyntaxUID', DoGet(_REMOTE, '/instances/%s/header?full' % instance) ['0002,0010']['Name'])
        self.assertEqual('1.2.840.10008.1.2.4.70', DoGet(_REMOTE, '/instances/%s/header?full' % instance) ['0002,0010']['Value'])
        self.assertEqual('String', DoGet(_REMOTE, '/instances/%s/header?full' % instance) ['0002,0010']['Type'])

        # Test "ListResources()" in "OrthancRestResources.cpp"
        self.assertEqual('KNIX', DoGet(_REMOTE, '/patients?expand') [0]['MainDicomTags']['PatientName'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/patients?expand&short') [0]['MainDicomTags']['0010,0010'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/patients?expand&full') [0]['MainDicomTags']['0010,0010']['Value'])
        self.assertEqual('PatientName', DoGet(_REMOTE, '/patients?expand&full') [0]['MainDicomTags']['0010,0010']['Name'])

        self.assertEqual('KNIX', DoGet(_REMOTE, '/studies?expand') [0]['PatientMainDicomTags']['PatientName'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/studies?expand&short') [0]['PatientMainDicomTags']['0010,0010'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/studies?expand&full') [0]['PatientMainDicomTags']['0010,0010']['Value'])
        self.assertEqual('PatientName', DoGet(_REMOTE, '/studies?expand&full') [0]['PatientMainDicomTags']['0010,0010']['Name'])

        self.assertEqual('20070101', DoGet(_REMOTE, '/studies?expand') [0]['MainDicomTags']['StudyDate'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/studies?expand&short') [0]['MainDicomTags']['0008,0020'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/studies?expand&full') [0]['MainDicomTags']['0008,0020']['Value'])
        self.assertEqual('StudyDate', DoGet(_REMOTE, '/studies?expand&full') [0]['MainDicomTags']['0008,0020']['Name'])

        self.assertEqual('20070101', DoGet(_REMOTE, '/series?expand') [0]['MainDicomTags']['SeriesDate'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/series?expand&short') [0]['MainDicomTags']['0008,0021'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/series?expand&full') [0]['MainDicomTags']['0008,0021']['Value'])
        self.assertEqual('SeriesDate', DoGet(_REMOTE, '/series?expand&full') [0]['MainDicomTags']['0008,0021']['Name'])

        self.assertEqual('20070101', DoGet(_REMOTE, '/instances?expand') [0]['MainDicomTags']['InstanceCreationDate'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/instances?expand&short') [0]['MainDicomTags']['0008,0012'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/instances?expand&full') [0]['MainDicomTags']['0008,0012']['Value'])
        self.assertEqual('InstanceCreationDate', DoGet(_REMOTE, '/instances?expand&full') [0]['MainDicomTags']['0008,0012']['Name'])

        # Test "GetSingleResource()" in "OrthancRestResources.cpp"
        self.assertEqual('KNIX', DoGet(_REMOTE, '/patients/%s' % patient) ['MainDicomTags']['PatientName'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/patients/%s?short' % patient) ['MainDicomTags']['0010,0010'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/patients/%s?full' % patient) ['MainDicomTags']['0010,0010']['Value'])
        self.assertEqual('PatientName', DoGet(_REMOTE, '/patients/%s?full' % patient) ['MainDicomTags']['0010,0010']['Name'])

        self.assertEqual('KNIX', DoGet(_REMOTE, '/studies/%s' % study) ['PatientMainDicomTags']['PatientName'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/studies/%s?short' % study) ['PatientMainDicomTags']['0010,0010'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/studies/%s?full' % study) ['PatientMainDicomTags']['0010,0010']['Value'])
        self.assertEqual('PatientName', DoGet(_REMOTE, '/studies/%s?full' % study) ['PatientMainDicomTags']['0010,0010']['Name'])

        self.assertEqual('20070101', DoGet(_REMOTE, '/studies/%s' % study) ['MainDicomTags']['StudyDate'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/studies/%s?short' % study) ['MainDicomTags']['0008,0020'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/studies/%s?full' % study) ['MainDicomTags']['0008,0020']['Value'])
        self.assertEqual('StudyDate', DoGet(_REMOTE, '/studies/%s?full' % study) ['MainDicomTags']['0008,0020']['Name'])

        self.assertEqual('20070101', DoGet(_REMOTE, '/series/%s' % series) ['MainDicomTags']['SeriesDate'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/series/%s?short' % series) ['MainDicomTags']['0008,0021'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/series/%s?full' % series) ['MainDicomTags']['0008,0021']['Value'])
        self.assertEqual('SeriesDate', DoGet(_REMOTE, '/series/%s?full' % series) ['MainDicomTags']['0008,0021']['Name'])

        self.assertEqual('20070101', DoGet(_REMOTE, '/instances/%s' % instance) ['MainDicomTags']['InstanceCreationDate'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/instances/%s?short' % instance) ['MainDicomTags']['0008,0012'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/instances/%s?full' % instance) ['MainDicomTags']['0008,0012']['Value'])
        self.assertEqual('InstanceCreationDate', DoGet(_REMOTE, '/instances/%s?full' % instance) ['MainDicomTags']['0008,0012']['Name'])

        # Test "Find()" in "OrthancRestResources.cpp"
        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Study', 'Query' : {}, 'Expand' : True })
        self.assertEqual('20070101', a[0]['MainDicomTags']['StudyDate'])
        self.assertEqual('KNIX', a[0]['PatientMainDicomTags']['PatientName'])

        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Study', 'Query' : {}, 'Expand' : True, 'Short' : True })
        self.assertEqual('20070101', a[0]['MainDicomTags']['0008,0020'])
        self.assertEqual('KNIX', a[0]['PatientMainDicomTags']['0010,0010'])

        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Study', 'Query' : {}, 'Expand' : True, 'Full' : True })
        self.assertEqual('20070101', a[0]['MainDicomTags']['0008,0020']['Value'])
        self.assertEqual('KNIX', a[0]['PatientMainDicomTags']['0010,0010']['Value'])
        self.assertEqual('StudyDate', a[0]['MainDicomTags']['0008,0020']['Name'])
        self.assertEqual('PatientName', a[0]['PatientMainDicomTags']['0010,0010']['Name'])

        # Test "GetChildResources()" in "OrthancRestResources.cpp"
        self.assertEqual('20070101', DoGet(_REMOTE, '/patients/%s/studies' % patient) [0]['MainDicomTags']['StudyDate'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/patients/%s/studies?short' % patient) [0]['MainDicomTags']['0008,0020'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/patients/%s/studies?full' % patient) [0]['MainDicomTags']['0008,0020']['Value'])
        self.assertEqual('StudyDate', DoGet(_REMOTE, '/patients/%s/studies?full' % patient) [0]['MainDicomTags']['0008,0020']['Name'])

        self.assertEqual('20070101', DoGet(_REMOTE, '/patients/%s/series' % patient) [0]['MainDicomTags']['SeriesDate'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/patients/%s/series?short' % patient) [0]['MainDicomTags']['0008,0021'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/patients/%s/series?full' % patient) [0]['MainDicomTags']['0008,0021']['Value'])
        self.assertEqual('SeriesDate', DoGet(_REMOTE, '/patients/%s/series?full' % patient) [0]['MainDicomTags']['0008,0021']['Name'])

        self.assertEqual('20070101', DoGet(_REMOTE, '/patients/%s/instances' % patient) [0]['MainDicomTags']['InstanceCreationDate'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/patients/%s/instances?short' % patient) [0]['MainDicomTags']['0008,0012'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/patients/%s/instances?full' % patient) [0]['MainDicomTags']['0008,0012']['Value'])
        self.assertEqual('InstanceCreationDate', DoGet(_REMOTE, '/patients/%s/instances?full' % patient) [0]['MainDicomTags']['0008,0012']['Name'])

        self.assertEqual('20070101', DoGet(_REMOTE, '/studies/%s/series' % study) [0]['MainDicomTags']['SeriesDate'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/studies/%s/series?short' % study) [0]['MainDicomTags']['0008,0021'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/studies/%s/series?full' % study) [0]['MainDicomTags']['0008,0021']['Value'])
        self.assertEqual('SeriesDate', DoGet(_REMOTE, '/studies/%s/series?full' % study) [0]['MainDicomTags']['0008,0021']['Name'])

        self.assertEqual('20070101', DoGet(_REMOTE, '/studies/%s/instances' % study) [0]['MainDicomTags']['InstanceCreationDate'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/studies/%s/instances?short' % study) [0]['MainDicomTags']['0008,0012'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/studies/%s/instances?full' % study) [0]['MainDicomTags']['0008,0012']['Value'])
        self.assertEqual('InstanceCreationDate', DoGet(_REMOTE, '/studies/%s/instances?full' % study) [0]['MainDicomTags']['0008,0012']['Name'])

        self.assertEqual('20070101', DoGet(_REMOTE, '/series/%s/instances' % series) [0]['MainDicomTags']['InstanceCreationDate'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/series/%s/instances?short' % series) [0]['MainDicomTags']['0008,0012'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/series/%s/instances?full' % series) [0]['MainDicomTags']['0008,0012']['Value'])
        self.assertEqual('InstanceCreationDate', DoGet(_REMOTE, '/series/%s/instances?full' % series) [0]['MainDicomTags']['0008,0012']['Name'])

        # Test "GetParentResource()" in "OrthancRestResources.cpp"
        self.assertEqual('KNIX', DoGet(_REMOTE, '/instances/%s/patient' % instance) ['MainDicomTags']['PatientName'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/instances/%s/patient?short' % instance) ['MainDicomTags']['0010,0010'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/instances/%s/patient?full' % instance) ['MainDicomTags']['0010,0010']['Value'])
        self.assertEqual('PatientName', DoGet(_REMOTE, '/instances/%s/patient?full' % instance) ['MainDicomTags']['0010,0010']['Name'])

        self.assertEqual('20070101', DoGet(_REMOTE, '/instances/%s/study' % instance) ['MainDicomTags']['StudyDate'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/instances/%s/study?short' % instance) ['MainDicomTags']['0008,0020'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/instances/%s/study?full' % instance) ['MainDicomTags']['0008,0020']['Value'])
        self.assertEqual('StudyDate', DoGet(_REMOTE, '/instances/%s/study?full' % instance) ['MainDicomTags']['0008,0020']['Name'])

        self.assertEqual('20070101', DoGet(_REMOTE, '/instances/%s/series' % instance) ['MainDicomTags']['SeriesDate'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/instances/%s/series?short' % instance) ['MainDicomTags']['0008,0021'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/instances/%s/series?full' % instance) ['MainDicomTags']['0008,0021']['Value'])
        self.assertEqual('SeriesDate', DoGet(_REMOTE, '/instances/%s/series?full' % instance) ['MainDicomTags']['0008,0021']['Name'])

        self.assertEqual('KNIX', DoGet(_REMOTE, '/series/%s/patient' % series) ['MainDicomTags']['PatientName'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/series/%s/patient?short' % series) ['MainDicomTags']['0010,0010'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/series/%s/patient?full' % series) ['MainDicomTags']['0010,0010']['Value'])
        self.assertEqual('PatientName', DoGet(_REMOTE, '/series/%s/patient?full' % series) ['MainDicomTags']['0010,0010']['Name'])

        self.assertEqual('20070101', DoGet(_REMOTE, '/series/%s/study' % series) ['MainDicomTags']['StudyDate'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/series/%s/study?short' % series) ['MainDicomTags']['0008,0020'])
        self.assertEqual('20070101', DoGet(_REMOTE, '/series/%s/study?full' % series) ['MainDicomTags']['0008,0020']['Value'])
        self.assertEqual('StudyDate', DoGet(_REMOTE, '/series/%s/study?full' % series) ['MainDicomTags']['0008,0020']['Name'])

        self.assertEqual('KNIX', DoGet(_REMOTE, '/studies/%s/patient' % study) ['MainDicomTags']['PatientName'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/studies/%s/patient?short' % study) ['MainDicomTags']['0010,0010'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/studies/%s/patient?full' % study) ['MainDicomTags']['0010,0010']['Value'])
        self.assertEqual('PatientName', DoGet(_REMOTE, '/studies/%s/patient?full' % study) ['MainDicomTags']['0010,0010']['Name'])

        # Test "GetChildInstancesTags()" in "OrthancRestResources.cpp"
        self.assertEqual('KNIX', DoGet(_REMOTE, '/patients/%s/instances-tags?simplify' % patient) [instance]['PatientName'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/patients/%s/instances-tags?short' % patient) [instance]['0010,0010'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/patients/%s/instances-tags' % patient) [instance]['0010,0010']['Value'])
        self.assertEqual('PatientName', DoGet(_REMOTE, '/patients/%s/instances-tags' % patient) [instance]['0010,0010']['Name'])
        
        self.assertEqual('KNIX', DoGet(_REMOTE, '/studies/%s/instances-tags?simplify' % study) [instance]['PatientName'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/studies/%s/instances-tags?short' % study) [instance]['0010,0010'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/studies/%s/instances-tags' % study) [instance]['0010,0010']['Value'])
        self.assertEqual('PatientName', DoGet(_REMOTE, '/studies/%s/instances-tags' % study) [instance]['0010,0010']['Name'])
        
        self.assertEqual('KNIX', DoGet(_REMOTE, '/series/%s/instances-tags?simplify' % series) [instance]['PatientName'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/series/%s/instances-tags?short' % series) [instance]['0010,0010'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/series/%s/instances-tags' % series) [instance]['0010,0010']['Value'])
        self.assertEqual('PatientName', DoGet(_REMOTE, '/series/%s/instances-tags' % series) [instance]['0010,0010']['Name'])

        # Test "GetSharedTags()" in "OrthancRestResources.cpp"
        self.assertEqual('KNIX', DoGet(_REMOTE, '/patients/%s/shared-tags?simplify' % patient) ['PatientName'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/patients/%s/shared-tags?short' % patient) ['0010,0010'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/patients/%s/shared-tags' % patient) ['0010,0010']['Value'])
        self.assertEqual('PatientName', DoGet(_REMOTE, '/patients/%s/shared-tags' % patient) ['0010,0010']['Name'])
        
        self.assertEqual('KNIX', DoGet(_REMOTE, '/studies/%s/shared-tags?simplify' % study) ['PatientName'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/studies/%s/shared-tags?short' % study) ['0010,0010'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/studies/%s/shared-tags' % study) ['0010,0010']['Value'])
        self.assertEqual('PatientName', DoGet(_REMOTE, '/studies/%s/shared-tags' % study) ['0010,0010']['Name'])
        
        self.assertEqual('KNIX', DoGet(_REMOTE, '/series/%s/shared-tags?simplify' % series) ['PatientName'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/series/%s/shared-tags?short' % series) ['0010,0010'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/series/%s/shared-tags' % series) ['0010,0010']['Value'])
        self.assertEqual('PatientName', DoGet(_REMOTE, '/series/%s/shared-tags' % series) ['0010,0010']['Name'])

        # Test "GetModule()" in "OrthancRestResources.cpp"
        self.assertEqual('KNIX', DoGet(_REMOTE, '/patients/%s/module' % patient) ['0010,0010']['Value'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/patients/%s/module?simplify' % patient) ['PatientName'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/patients/%s/module?short' % patient) ['0010,0010'])

        self.assertEqual('KNIX', DoGet(_REMOTE, '/studies/%s/module-patient' % study) ['0010,0010']['Value'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/studies/%s/module-patient?simplify' % study) ['PatientName'])
        self.assertEqual('KNIX', DoGet(_REMOTE, '/studies/%s/module-patient?short' % study) ['0010,0010'])

        self.assertEqual('Knee (R)', DoGet(_REMOTE, '/studies/%s/module' % study) ['0008,1030']['Value'])
        self.assertEqual('Knee (R)', DoGet(_REMOTE, '/studies/%s/module?simplify' % study) ['StudyDescription'])
        self.assertEqual('Knee (R)', DoGet(_REMOTE, '/studies/%s/module?short' % study) ['0008,1030'])

        self.assertEqual('AX.  FSE PD', DoGet(_REMOTE, '/series/%s/module' % series) ['0008,103e']['Value'])
        self.assertEqual('AX.  FSE PD', DoGet(_REMOTE, '/series/%s/module?simplify' % series) ['SeriesDescription'])
        self.assertEqual('AX.  FSE PD', DoGet(_REMOTE, '/series/%s/module?short' % series) ['0008,103e'])

        self.assertEqual('1.2.840.113619.2.176.2025.1499492.7040.1171286242.109',
                         DoGet(_REMOTE, '/instances/%s/module' % instance) ['0008,0018']['Value'])
        self.assertEqual('1.2.840.113619.2.176.2025.1499492.7040.1171286242.109',
                         DoGet(_REMOTE, '/instances/%s/module?simplify' % instance) ['SOPInstanceUID'])
        self.assertEqual('1.2.840.113619.2.176.2025.1499492.7040.1171286242.109',
                         DoGet(_REMOTE, '/instances/%s/module?short' % instance) ['0008,0018'])

        # Test "ListQueryAnswers()" in "OrthancRestModalities.cpp"
        a = DoPost(_REMOTE, '/modalities/self/query', { 'Level' : 'Study',
                                                        'Query' : { 'PatientID' : '*' }}) ['ID']

        self.assertEqual(1, len(DoGet(_REMOTE, '/queries/%s/answers' % a)))
        self.assertEqual('ozp00SjY2xG', DoGet(_REMOTE, '/queries/%s/answers?expand' % a) [0]['0010,0020']['Value'])
        self.assertEqual('PatientID', DoGet(_REMOTE, '/queries/%s/answers?expand' % a) [0]['0010,0020']['Name'])
        self.assertEqual('ozp00SjY2xG', DoGet(_REMOTE, '/queries/%s/answers?expand&simplify' % a) [0]['PatientID'])
        self.assertEqual('ozp00SjY2xG', DoGet(_REMOTE, '/queries/%s/answers?expand&short' % a) [0]['0010,0020'])

        # Test "GetQueryOneAnswer()" in "OrthancRestModalities.cpp"
        self.assertEqual('ozp00SjY2xG', DoGet(_REMOTE, '/queries/%s/answers/0/content' % a) ['0010,0020']['Value'])
        self.assertEqual('PatientID', DoGet(_REMOTE, '/queries/%s/answers/0/content' % a) ['0010,0020']['Name'])
        self.assertEqual('ozp00SjY2xG', DoGet(_REMOTE, '/queries/%s/answers/0/content?simplify' % a) ['PatientID'])
        self.assertEqual('ozp00SjY2xG', DoGet(_REMOTE, '/queries/%s/answers/0/content?short' % a) ['0010,0020'])
        
        # Test "GetQueryArguments()" in "OrthancRestModalities.cpp"
        self.assertEqual('*', DoGet(_REMOTE, '/queries/%s/query' % a) ['0010,0020']['Value'])
        self.assertEqual('PatientID', DoGet(_REMOTE, '/queries/%s/query' % a) ['0010,0020']['Name'])
        self.assertEqual('*', DoGet(_REMOTE, '/queries/%s/query?simplify' % a) ['PatientID'])
        self.assertEqual('*', DoGet(_REMOTE, '/queries/%s/query?short' % a) ['0010,0020'])
        
        # Test "BulkContent()" in "OrthancRestResources.cpp"
        a = DoPost(_REMOTE, '/tools/bulk-content', { 'Resources' : [ patient, study, series, instance ] })
        self.assertEqual(4, len(a))
        self.assertEqual('ozp00SjY2xG', a[0]['MainDicomTags']['PatientID'])
        self.assertEqual('Knee (R)', a[1]['MainDicomTags']['StudyDescription'])
        self.assertEqual('KNIX', a[1]['PatientMainDicomTags']['PatientName'])
        self.assertEqual('AX.  FSE PD', a[2]['MainDicomTags']['SeriesDescription'])
        self.assertEqual('1.2.840.113619.2.176.2025.1499492.7040.1171286242.109',
                         a[3]['MainDicomTags']['SOPInstanceUID'])

        a = DoPost(_REMOTE, '/tools/bulk-content', { 'Resources' : [ patient, study, series, instance ],
                                                     'Short': True })
        self.assertEqual(4, len(a))
        self.assertEqual('ozp00SjY2xG', a[0]['MainDicomTags']['0010,0020'])
        self.assertEqual('Knee (R)', a[1]['MainDicomTags']['0008,1030'])
        self.assertEqual('KNIX', a[1]['PatientMainDicomTags']['0010,0010'])
        self.assertEqual('AX.  FSE PD', a[2]['MainDicomTags']['0008,103e'])
        self.assertEqual('1.2.840.113619.2.176.2025.1499492.7040.1171286242.109',
                         a[3]['MainDicomTags']['0008,0018'])
        
        a = DoPost(_REMOTE, '/tools/bulk-content', { 'Resources' : [ patient, study, series, instance ],
                                                     'Full': True })
        self.assertEqual(4, len(a))
        self.assertEqual('ozp00SjY2xG', a[0]['MainDicomTags']['0010,0020']['Value'])
        self.assertEqual('PatientID', a[0]['MainDicomTags']['0010,0020']['Name'])
        self.assertEqual('Knee (R)', a[1]['MainDicomTags']['0008,1030']['Value'])
        self.assertEqual('StudyDescription', a[1]['MainDicomTags']['0008,1030']['Name'])
        self.assertEqual('KNIX', a[1]['PatientMainDicomTags']['0010,0010']['Value'])
        self.assertEqual('PatientName', a[1]['PatientMainDicomTags']['0010,0010']['Name'])
        self.assertEqual('AX.  FSE PD', a[2]['MainDicomTags']['0008,103e']['Value'])
        self.assertEqual('SeriesDescription', a[2]['MainDicomTags']['0008,103e']['Name'])
        self.assertEqual('1.2.840.113619.2.176.2025.1499492.7040.1171286242.109',
                         a[3]['MainDicomTags']['0008,0018']['Value'])
        self.assertEqual('SOPInstanceUID', a[3]['MainDicomTags']['0008,0018']['Name'])
        

    def test_bulk_content(self):
        # New in Orthanc 1.9.4
        knee1 = UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm') ['ID']
        knee2 = UploadInstance(_REMOTE, 'Knee/T2/IM-0001-0001.dcm') ['ID']
        brainix = UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm') ['ID']

        brainixHierarchy = [
            DoGet(_REMOTE, '/instances/%s/patient' % brainix) ['ID'],
            DoGet(_REMOTE, '/instances/%s/study' % brainix) ['ID'],
            DoGet(_REMOTE, '/instances/%s/series' % brainix) ['ID'],
            brainix,
        ]
        
        a = DoPost(_REMOTE, '/tools/bulk-content', { 'Resources' : brainixHierarchy })
        self.assertEqual(4, len(a))
        b = map(lambda x: x['ID'], a)
        for i in range(4):
            self.assertEqual(brainixHierarchy[i], b[i])
            self.assertTrue('Metadata' in a[i])

        for (level, index) in [
                ('Patient', 0),
                ('Study', 1),
                ('Series', 2),
                ('Instance', 3),
                ]:
            a = DoPost(_REMOTE, '/tools/bulk-content', { 'Resources' : brainixHierarchy,
                                                         'Level' : level })
            self.assertEqual(1, len(a))
            self.assertEqual(level, a[0]['Type'])
            self.assertEqual(brainixHierarchy[index], a[0]['ID'])
            self.assertTrue('Metadata' in a[0])
        
            a = DoPost(_REMOTE, '/tools/bulk-content', { 'Resources' : [ brainix ],
                                                         'Level' : level,
                                                         'Metadata' : False })
            self.assertEqual(1, len(a))
            self.assertEqual(level, a[0]['Type'])
            self.assertEqual(brainixHierarchy[index], a[0]['ID'])
            self.assertFalse('Metadata' in a[0])

        a = DoPost(_REMOTE, '/tools/bulk-content', { 'Resources' : [ knee1, knee2, brainix ] })
        self.assertEqual(3, len(a))
        for item in a:
            self.assertEqual('Instance', item['Type'])
        b = map(lambda x: x['ID'], a)
        self.assertTrue(knee1 in b)
        self.assertTrue(knee2 in b)
        self.assertTrue(brainix in b)

        a = DoPost(_REMOTE, '/tools/bulk-content', { 'Resources' : [ knee1, knee2 ],
                                                     'Level' : 'Series' })
        self.assertEqual(2, len(a))
        for item in a:
            self.assertEqual('Series', item['Type'])
        b = map(lambda x: x['ID'], a)
        self.assertTrue(DoGet(_REMOTE, '/instances/%s' % knee1) ['ParentSeries'] in b)
        self.assertTrue(DoGet(_REMOTE, '/instances/%s' % knee2) ['ParentSeries'] in b)

        a = DoPost(_REMOTE, '/tools/bulk-content', { 'Resources' : [ knee1, knee2 ],
                                                     'Level' : 'Study',
                                                     'Metadata' : False })
        self.assertEqual(1, len(a))
        self.assertEqual(DoGet(_REMOTE, '/instances/%s/study' % knee1) ['ID'], a[0]['ID'])
        self.assertEqual('Study', a[0]['Type'])
        self.assertEqual('KNEE', a[0]['PatientMainDicomTags']['PatientName'])
        self.assertFalse('Metadata' in a[0])

        a = DoPost(_REMOTE, '/tools/bulk-content', { 'Resources' : [ knee1, knee2 ],
                                                     'Level' : 'Patient',
                                                     'Metadata' : True })
        self.assertEqual(1, len(a))
        self.assertEqual(DoGet(_REMOTE, '/instances/%s/patient' % knee1) ['ID'], a[0]['ID'])
        self.assertEqual('Patient', a[0]['Type'])
        self.assertEqual('KNEE', a[0]['MainDicomTags']['PatientName'])
        self.assertTrue('Metadata' in a[0])
        if IsOrthancVersionAbove(_REMOTE, 1, 11, 0):
            self.assertEqual(2, len(a[0]['Metadata']))
            self.assertTrue('MainDicomTagsSignature' in a[0]['Metadata'])
        else:
            self.assertEqual(1, len(a[0]['Metadata']))
            self.assertTrue('LastUpdate' in a[0]['Metadata'])

        for level in [ 'Instance', 'Series', 'Study', 'Patient' ]:
            a = DoPost(_REMOTE, '/tools/bulk-content', { 'Resources' : [ knee1, brainix ],
                                                         'Level' : level })
            self.assertEqual(2, len(a))
            for item in a:
                self.assertEqual(level, item['Type'])
            b = map(lambda x: x['ID'], a)
            if level == 'Instance':
                self.assertTrue(knee1 in b)
                self.assertTrue(brainix in b)
            else:
                self.assertTrue(DoGet(_REMOTE, '/instances/%s/%s' % (knee1, level.lower())) ['ID'] in b)
                self.assertTrue(DoGet(_REMOTE, '/instances/%s/%s' % (brainix, level.lower())) ['ID'] in b)


    def test_split_instances(self):
        # New in 1.9.4
        knee1 = UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm') ['ID']
        knee2 = UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0002.dcm') ['ID']
        study = '0a9b3153-2512774b-2d9580de-1fc3dcf6-3bd83918'
        series = '6de73705-c4e65c1b-9d9ea1b5-cabcd8e7-f15e4285'

        self.assertEqual(1, len(DoGet(_REMOTE, '/patients')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/studies')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/series')))

        instances = DoGet(_REMOTE, '/instances')
        self.assertEqual(2, len(instances))
        self.assertEqual('1', DoGet(_REMOTE, '/instances/%s/tags?simplify' % knee1) ['InstanceNumber'])
        self.assertEqual('2', DoGet(_REMOTE, '/instances/%s/tags?simplify' % knee2) ['InstanceNumber'])
        for i in [ knee1, knee2 ]:
            self.assertEqual(series, DoGet(_REMOTE, '/instances/%s/series' % i) ['ID'])
            self.assertEqual(study, DoGet(_REMOTE, '/instances/%s/study' % i) ['ID'])
            
        self.assertRaises(Exception, lambda: DoPost(_REMOTE, '/studies/%s/split' % study, {
            'KeepSource' : False
        }))  # Neither "Instances", nor "Series"

        self.assertRaises(Exception, lambda: DoPost(_REMOTE, '/studies/%s/split' % study, {
            'KeepSource' : False,
            'Instances' : [ ],
            'Series' : [ ]
        }))  # Empty "Instances" and "Series"

        self.assertRaises(Exception, lambda: DoPost(_REMOTE, '/studies/%s/split' % study, {
            'Instances' : [ 'nope' ],
            'KeepSource' : False
        }))

        self.assertRaises(Exception, lambda: DoPost(_REMOTE, '/studies/%s/split' % study, {
            'Series' : [ 'nope' ],
            'KeepSource' : False
        }))

        result = DoPost(_REMOTE, '/studies/%s/split' % study, {
            'Instances' : [ knee1 ],
            'KeepSource' : False
        })

        self.assertEqual(1, len(DoGet(_REMOTE, '/patients')))
        self.assertEqual(2, len(DoGet(_REMOTE, '/studies')))
        self.assertEqual(2, len(DoGet(_REMOTE, '/series')))

        instances = DoGet(_REMOTE, '/instances')
        self.assertEqual(2, len(instances))

        self.assertFalse(knee1 in instances)
        self.assertTrue(knee2 in instances)
        instances.remove(knee2)
        self.assertEqual(series, DoGet(_REMOTE, '/instances/%s/series' % knee2) ['ID'])
        self.assertEqual(study, DoGet(_REMOTE, '/instances/%s/study' % knee2) ['ID'])
        self.assertNotEqual(series, DoGet(_REMOTE, '/instances/%s/series' % instances[0]) ['ID'])
        self.assertNotEqual(study, DoGet(_REMOTE, '/instances/%s/study' % instances[0]) ['ID'])
        self.assertEqual('1', DoGet(_REMOTE, '/instances/%s/tags?simplify' % instances[0]) ['InstanceNumber'])


    def test_merge_instances(self):
        # New in Orthanc 1.9.4
        knee = UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm') ['ID']
        brainix = UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm') ['ID']
        brainixStudy = DoGet(_REMOTE, '/instances/%s/study' % brainix) ['ID']

        self.assertEqual(2, len(DoGet(_REMOTE, '/patients')))
        self.assertEqual(2, len(DoGet(_REMOTE, '/studies')))
        self.assertEqual(2, len(DoGet(_REMOTE, '/series')))
        self.assertEqual(2, len(DoGet(_REMOTE, '/instances')))

        instances = DoGet(_REMOTE, '/instances')
        self.assertEqual(2, len(instances))
        self.assertTrue(brainix in instances)
        self.assertTrue(knee in instances)

        result = DoPost(_REMOTE, '/studies/%s/merge' % brainixStudy, {
            'Resources' : [ knee ]
        })
        
        self.assertEqual(1, len(DoGet(_REMOTE, '/patients')))
        self.assertEqual(1, len(DoGet(_REMOTE, '/studies')))
        self.assertEqual(2, len(DoGet(_REMOTE, '/series')))
        self.assertEqual(brainixStudy, DoGet(_REMOTE, '/studies')[0])

        instances = DoGet(_REMOTE, '/instances')
        self.assertEqual(2, len(instances))
        self.assertTrue(brainix in instances)
        self.assertFalse(knee in instances)


    def test_query_retrieve_format(self):
        # New in Orthanc 1.9.5
        # https://groups.google.com/g/orthanc-users/c/1KC4d-0K8s0/m/hfYYz1-tAgAJ
        i = UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm') ['ID']
        study = DoGet(_REMOTE, '/instances/%s/study' % i) ['MainDicomTags']['StudyInstanceUID']

        a = DoPost(_REMOTE, '/modalities/self/query', {
            'Level' : 'Study',
            'Query' : {}
        })

        b = DoGet(_REMOTE, a['Path'] + '/answers')
        self.assertEqual(1, len(b))
        self.assertEqual('0', b[0])
        
        b = DoGet(_REMOTE, a['Path'] + '/answers?expand')
        self.assertEqual(1, len(b))
        self.assertEqual(6, len(b[0]))
        self.assertEqual('ISO_IR 100', b[0]['0008,0005']['Value'])
        self.assertEqual('SpecificCharacterSet', b[0]['0008,0005']['Name'])
        self.assertEqual('A10003245599', b[0]['0008,0050']['Value'])
        self.assertEqual('AccessionNumber', b[0]['0008,0050']['Name'])
        self.assertEqual('STUDY', b[0]['0008,0052']['Value'])
        self.assertEqual('QueryRetrieveLevel', b[0]['0008,0052']['Name'])
        self.assertEqual('ORTHANC', b[0]['0008,0054']['Value'])
        self.assertEqual('RetrieveAETitle', b[0]['0008,0054']['Name'])
        self.assertEqual('887', b[0]['0010,0020']['Value'])
        self.assertEqual('PatientID', b[0]['0010,0020']['Name'])
        self.assertEqual('2.16.840.1.113669.632.20.121711.10000160881', b[0]['0020,000d']['Value'])
        self.assertEqual('StudyInstanceUID', b[0]['0020,000d']['Name'])

        for (key, value) in b[0].items():
            self.assertEqual('String', value['Type'])

        self.assertEqual(json.dumps(b[0]),
                         json.dumps(DoGet(_REMOTE, a['Path'] + '/answers/0/content')))

        # What is below this point didn't work on Orthanc <= 1.9.3
        
        b = DoGet(_REMOTE, a['Path'] + '/answers?expand&short')
        self.assertEqual(1, len(b))
        self.assertEqual(6, len(b[0]))
        self.assertEqual('ISO_IR 100', b[0]['0008,0005'])
        self.assertEqual('A10003245599', b[0]['0008,0050'])
        self.assertEqual('STUDY', b[0]['0008,0052'])
        self.assertEqual('ORTHANC', b[0]['0008,0054'])
        self.assertEqual('887', b[0]['0010,0020'])
        self.assertEqual('2.16.840.1.113669.632.20.121711.10000160881', b[0]['0020,000d'])
        self.assertEqual(json.dumps(b[0]),
                         json.dumps(DoGet(_REMOTE, a['Path'] + '/answers/0/content?short')))
        
        b = DoGet(_REMOTE, a['Path'] + '/answers?expand&simplify')
        self.assertEqual(1, len(b))
        self.assertEqual(6, len(b[0]))
        self.assertEqual('ISO_IR 100', b[0]['SpecificCharacterSet'])
        self.assertEqual('A10003245599', b[0]['AccessionNumber'])
        self.assertEqual('STUDY', b[0]['QueryRetrieveLevel'])
        self.assertEqual('ORTHANC', b[0]['RetrieveAETitle'])
        self.assertEqual('887', b[0]['PatientID'])
        self.assertEqual('2.16.840.1.113669.632.20.121711.10000160881', b[0]['StudyInstanceUID'])
        self.assertEqual(json.dumps(b[0]),
                         json.dumps(DoGet(_REMOTE, a['Path'] + '/answers/0/content?simplify')))

        b = DoPost(_REMOTE, '/queries/%s/retrieve' % a['ID'], {})
        self.assertEqual('REST API', b['Description'])
        self.assertEqual('ORTHANC', b['LocalAet'])
        self.assertEqual('ORTHANC', b['RemoteAet'])
        self.assertEqual(1, len(b['Query']))
        self.assertEqual(4, len(b['Query'][0]))
        self.assertEqual('A10003245599', b['Query'][0]['0008,0050'])
        self.assertEqual('STUDY', b['Query'][0]['0008,0052'])
        self.assertEqual('887', b['Query'][0]['0010,0020'])
        self.assertEqual('2.16.840.1.113669.632.20.121711.10000160881', b['Query'][0]['0020,000d'])
        
        # What is below this point didn't work on Orthanc <= 1.9.4

        b = DoPost(_REMOTE, '/queries/%s/retrieve' % a['ID'], { 'Full' : True })
        self.assertEqual('REST API', b['Description'])
        self.assertEqual('ORTHANC', b['LocalAet'])
        self.assertEqual('ORTHANC', b['RemoteAet'])
        self.assertEqual(1, len(b['Query']))
        self.assertEqual(4, len(b['Query'][0]))
        self.assertEqual('A10003245599', b['Query'][0]['0008,0050']['Value'])
        self.assertEqual('STUDY', b['Query'][0]['0008,0052']['Value'])
        self.assertEqual('887', b['Query'][0]['0010,0020']['Value'])
        self.assertEqual('2.16.840.1.113669.632.20.121711.10000160881', b['Query'][0]['0020,000d']['Value'])
        self.assertEqual('AccessionNumber', b['Query'][0]['0008,0050']['Name'])
        self.assertEqual('QueryRetrieveLevel', b['Query'][0]['0008,0052']['Name'])
        self.assertEqual('PatientID', b['Query'][0]['0010,0020']['Name'])
        self.assertEqual('StudyInstanceUID', b['Query'][0]['0020,000d']['Name'])

        b = DoPost(_REMOTE, '/queries/%s/retrieve' % a['ID'], { 'Simplify' : True })
        self.assertEqual('REST API', b['Description'])
        self.assertEqual('ORTHANC', b['LocalAet'])
        self.assertEqual('ORTHANC', b['RemoteAet'])
        self.assertEqual(1, len(b['Query']))
        self.assertEqual(4, len(b['Query'][0]))
        self.assertEqual('A10003245599', b['Query'][0]['AccessionNumber'])
        self.assertEqual('STUDY', b['Query'][0]['QueryRetrieveLevel'])
        self.assertEqual('887', b['Query'][0]['PatientID'])
        self.assertEqual('2.16.840.1.113669.632.20.121711.10000160881', b['Query'][0]['StudyInstanceUID'])


    def test_anonymize_nested(self):
        # New in Orthanc 1.9.5

        tags = {
            'MappingResourceIdentificationSequence' : [
                {
                    # Test "DicomModification::RelationshipsVisitor::GetDefaultAction()"
                    '0009,1002' : 'ABCD',  # Private tag not registered in dictionary
                    '0016,0071' : '-12',  # "GPS Latitude" whose VR is DS in "removals_"
                    '0034,0005' : '13',  # VR is OB, and in "clearings_" (only in DCMTK 3.6.2)

                    # Test "DicomModification::RelationshipsVisitor::VisitString()"
                    'StudyDescription' : 'Hello',  # Removed
                    'StudyDate' : '20210705',  # Cleared
                    '0009,1001' : '-1234',  # Private tag whose VR is DS

                    # Test anonymization of nested sequences
                    'ReferencedStudySequence' : [
                        {
                            'PatientID' : 'HELLO'
                        }
                    ],

                    # Non-anonymized tags
                    'CodeMeaning' : 'MEANING1',
                    'EquivalentCodeSequence' : [
                        {
                            'CodeMeaning' : 'MEANING2',
                        }
                    ],
                }
            ],
        }
        
        a = DoPost(_REMOTE, '/tools/create-dicom',
                   json.dumps({
                       'Tags' : tags,
                       'PrivateCreator' : 'Lunit',
                   })) ['ID']

        study = DoGet(_REMOTE, '/instances/%s/study' % a) ['ID']
        b = DoPost(_REMOTE, '/studies/%s/anonymize' % study, {}) ['ID']
        c = DoGet(_REMOTE, '/studies/%s/instances' % b)
        self.assertEqual(1, len(c))

        tags1 = DoGet(_REMOTE, '/instances/%s/tags?short' % a)
        tags2 = DoGet(_REMOTE, '/instances/%s/tags?short' % c[0]['ID'])

        # Only "StudyDate" must be present in
        # "MappingResourceIdentificationSequence" after anonymization
        self.assertEqual(1, len(tags1['0008,0124']))
        self.assertEqual(1, len(tags2['0008,0124']))
        self.assertEqual(9, len(tags1['0008,0124'][0]))
        self.assertEqual(3, len(tags2['0008,0124'][0]))
        self.assertEqual('', tags2['0008,0124'][0]['0008,0020'])
        self.assertEqual('MEANING1', tags2['0008,0124'][0]['0008,0104'])
        self.assertEqual('MEANING2', tags2['0008,0124'][0]['0008,0121'][0]['0008,0104'])

        self.assertTrue('0008,1110' in tags1['0008,0124'][0])
        self.assertFalse('0008,1110' in tags2['0008,0124'][0])


    def test_issue_200(self):
        # https://groups.google.com/g/orthanc-users/c/9CTLsL-JqDw/m/2I0xgyYHBAAJ
        # https://bugs.orthanc-server.com/show_bug.cgi?id=200
        self.assertEqual(0, len(DoGet(_REMOTE, '/changes') ['Changes']))
        self.assertEqual(0, len(DoGet(_REMOTE, '/changes?last') ['Changes']))
        u = UploadInstance(_REMOTE, 'DummyCT.dcm') ['ID']

        for change in DoGet(_REMOTE, '/changes') ['Changes']:
            self.assertTrue(re.match('[0-9]{8}T[0-9]{6}', change['Date']))
            self.assertTrue(re.match('[0-9a-z]{8}-[0-9a-z]{8}-[0-9a-z]{8}-[0-9a-z]{8}', change['ID']))

        last = DoGet(_REMOTE, '/changes?last') ['Changes']
        self.assertEqual(1, len(last))
        self.assertTrue(re.match('[0-9]{8}T[0-9]{6}', last[0]['Date']))
        self.assertTrue(re.match('[0-9a-z]{8}-[0-9a-z]{8}-[0-9a-z]{8}-[0-9a-z]{8}', last[0]['ID']))

        self.assertEqual(0, len(DoGet(_REMOTE, '/exports') ['Exports']))
        self.assertEqual(0, len(DoGet(_REMOTE, '/exports?last') ['Exports']))
        DoPost(_REMOTE, '/modalities/self/store', [ u ])

        for change in DoGet(_REMOTE, '/exports') ['Exports']:
            self.assertTrue(re.match('[0-9]{8}T[0-9]{6}', change['Date']))
            self.assertTrue(re.match('[0-9a-z]{8}-[0-9a-z]{8}-[0-9a-z]{8}-[0-9a-z]{8}', change['ID']))

        last = DoGet(_REMOTE, '/exports?last') ['Exports']
        self.assertEqual(1, len(last))
        self.assertEqual('ozp00SjY2xG', last[0]['PatientID'])
        self.assertEqual('self', last[0]['RemoteModality'])
        self.assertEqual('Instance', last[0]['ResourceType'])
        self.assertEqual('/instances/%s' % last[0]['ID'], last[0]['Path'])
        self.assertEqual('1.2.840.113619.2.176.2025.1499492.7391.1171285944.390', last[0]['StudyInstanceUID'])
        self.assertEqual('1.2.840.113619.2.176.2025.1499492.7391.1171285944.394', last[0]['SeriesInstanceUID'])
        self.assertEqual('1.2.840.113619.2.176.2025.1499492.7040.1171286242.109', last[0]['SOPInstanceUID'])
        self.assertTrue(re.match('[0-9]{8}T[0-9]{6}', last[0]['Date']))
        self.assertTrue(re.match('[0-9a-z]{8}-[0-9a-z]{8}-[0-9a-z]{8}-[0-9a-z]{8}', last[0]['ID']))


    def test_upload_dicomdir_archive(self):
        # This test fails on Orthanc <= 1.9.6
        # https://groups.google.com/g/orthanc-users/c/sgBU89o4nhU/m/kbRAYiQUAAAJ

        # Create a ZIP archive with a DICOMDIR
        instance = UploadInstance(_REMOTE, 'DummyCT.dcm') ['ID']
        study = DoGet(_REMOTE, '/instances/%s/study' % instance) ['ID']
        media = DoGet(_REMOTE, '/studies/%s/media' % study)
        DoDelete(_REMOTE, '/instances/%s' % instance)
        
        result = DoPost(_REMOTE, '/instances', media)
        self.assertEqual(1, len(result))
        self.assertEqual(instance, result[0]['ID'])
        self.assertEqual('Success', result[0]['Status'])


    def test_modify_keep_source(self):
        # https://groups.google.com/g/orthanc-users/c/1lvlBTs2WUY/m/HmYsc2CPBQAJ
        instance = UploadInstance(_REMOTE, 'DummyCT.dcm') ['ID']
        study = DoGet(_REMOTE, '/instances/%s/study' % instance) ['ID']
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))

        a = DoPost(_REMOTE, '/studies/%s/anonymize' % study, {}) ['ID']
        self.assertEqual(2, len(DoGet(_REMOTE, '/instances')))
        DoDelete(_REMOTE, '/studies/%s' % a)
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))

        a = DoPost(_REMOTE, '/studies/%s/anonymize' % study, { 'KeepSource' : True }) ['ID']
        self.assertEqual(2, len(DoGet(_REMOTE, '/instances')))
        DoDelete(_REMOTE, '/studies/%s' % a)
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))

        a = DoPost(_REMOTE, '/studies/%s/anonymize' % study, { 'KeepSource' : False }) ['ID']
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))
        DoDelete(_REMOTE, '/studies/%s' % a)
        self.assertEqual(0, len(DoGet(_REMOTE, '/instances')))

        UploadInstance(_REMOTE, 'DummyCT.dcm')
        a = DoPost(_REMOTE, '/studies/%s/modify' % study, { 'Replace' : { } }) ['ID']
        self.assertEqual(2, len(DoGet(_REMOTE, '/instances')))
        DoDelete(_REMOTE, '/studies/%s' % a)
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))

        a = DoPost(_REMOTE, '/studies/%s/modify' % study, { 'KeepSource' : True }) ['ID']
        self.assertEqual(2, len(DoGet(_REMOTE, '/instances')))
        DoDelete(_REMOTE, '/studies/%s' % a)
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))

        a = DoPost(_REMOTE, '/studies/%s/modify' % study, { 'KeepSource' : False }) ['ID']
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))
        DoDelete(_REMOTE, '/studies/%s' % a)
        self.assertEqual(0, len(DoGet(_REMOTE, '/instances')))

        def GetStudy(a):
            b = filter(lambda x: x['Type'] == 'Study', a['Resources'])
            if len(b) == 1:
                return b[0]['ID']
            else:
                raise Exception()

        UploadInstance(_REMOTE, 'DummyCT.dcm')
        a = GetStudy(DoPost(_REMOTE, '/tools/bulk-anonymize', { 'Resources' : [ study ]}))
        self.assertEqual(2, len(DoGet(_REMOTE, '/instances')))
        DoDelete(_REMOTE, '/studies/%s' % a)
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))

        a = GetStudy(DoPost(_REMOTE, '/tools/bulk-anonymize', { 'Resources' : [ study ], 'KeepSource' : True}))
        self.assertEqual(2, len(DoGet(_REMOTE, '/instances')))
        DoDelete(_REMOTE, '/studies/%s' % a)
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))

        a = GetStudy(DoPost(_REMOTE, '/tools/bulk-anonymize', { 'Resources' : [ study ], 'KeepSource' : False}))
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))
        DoDelete(_REMOTE, '/studies/%s' % a)
        self.assertEqual(0, len(DoGet(_REMOTE, '/instances')))

        UploadInstance(_REMOTE, 'DummyCT.dcm')
        a = GetStudy(DoPost(_REMOTE, '/tools/bulk-modify', { 'Resources' : [ study ], 'Replace' : { }}))
        self.assertEqual(2, len(DoGet(_REMOTE, '/instances')))
        DoDelete(_REMOTE, '/studies/%s' % a)
        # No more studies, because "bulk-modify" was not given a
        # level, so the modified instance belongs to the same study as
        # the original instance
        self.assertEqual(0, len(DoGet(_REMOTE, '/instances')))

        # The following fails on Orthanc <= 1.9.6 because "Level" was
        # introduced in 1.9.7

        UploadInstance(_REMOTE, 'DummyCT.dcm')
        a = GetStudy(DoPost(_REMOTE, '/tools/bulk-modify', { 'Level' : 'Study', 'Resources' : [ study ], 'Replace' : { }}))
        self.assertEqual(2, len(DoGet(_REMOTE, '/instances')))
        DoDelete(_REMOTE, '/studies/%s' % a)
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))

        a = GetStudy(DoPost(_REMOTE, '/tools/bulk-modify', { 'Level' : 'Study', 'Resources' : [ study ], 'KeepSource' : True}))
        self.assertEqual(2, len(DoGet(_REMOTE, '/instances')))
        DoDelete(_REMOTE, '/studies/%s' % a)
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))

        a = GetStudy(DoPost(_REMOTE, '/tools/bulk-modify', { 'Level' : 'Study', 'Resources' : [ study ], 'KeepSource' : False}))
        self.assertEqual(1, len(DoGet(_REMOTE, '/instances')))
        DoDelete(_REMOTE, '/studies/%s' % a)
        self.assertEqual(0, len(DoGet(_REMOTE, '/instances')))


    def test_multiframe_windowing(self):
        # Fixed in Orthanc 1.9.7
        a = UploadInstance(_REMOTE, 'MultiframeWindowing.dcm') ['ID']

        im = GetImage(_REMOTE, '/instances/%s/frames/0/rendered?window-center=127&window-width=256' % a)
        self.assertEqual(0x00, im.getpixel((0, 0)))
        self.assertEqual(0x10, im.getpixel((1, 0)))
        self.assertEqual(0x20, im.getpixel((0, 1)))
        self.assertEqual(0x30, im.getpixel((1, 1)))

        # Center the window on value "16 == 0x10", thus it has the
        # mid-level value (i.e. 127)
        im = GetImage(_REMOTE, '/instances/%s/frames/0/rendered?window-center=16&window-width=128' % a)
        self.assertEqual(127 - 2 * 16, im.getpixel((0, 0)))
        self.assertEqual(127, im.getpixel((1, 0)))
        self.assertEqual(127 + 2 * 16, im.getpixel((0, 1)))
        self.assertEqual(127 + 2 * 32, im.getpixel((1, 1)))

        # Window center and window width are burned in FrameVOILUTSequence for frame 0
        im = GetImage(_REMOTE, '/instances/%s/frames/0/rendered' % a)
        self.assertEqual(127 - 2 * 16, im.getpixel((0, 0)))
        self.assertEqual(127, im.getpixel((1, 0)))
        self.assertEqual(127 + 2 * 16, im.getpixel((0, 1)))
        self.assertEqual(127 + 2 * 32, im.getpixel((1, 1)))

        im = GetImage(_REMOTE, '/instances/%s/frames/1/rendered?window-center=127&window-width=256' % a)
        self.assertEqual(100, im.getpixel((0, 0)))
        self.assertEqual(116, im.getpixel((1, 0)))
        self.assertEqual(132, im.getpixel((0, 1)))
        self.assertEqual(148, im.getpixel((1, 1)))

        im = GetImage(_REMOTE, '/instances/%s/frames/2/rendered?window-center=127&window-width=256' % a)
        self.assertEqual(0, im.getpixel((0, 0)))
        self.assertEqual(32, im.getpixel((1, 0)))
        self.assertEqual(64, im.getpixel((0, 1)))
        self.assertEqual(96, im.getpixel((1, 1)))

        im = GetImage(_REMOTE, '/instances/%s/frames/3/rendered?window-center=127&window-width=256' % a)
        self.assertEqual(100, im.getpixel((0, 0)))
        self.assertEqual(132, im.getpixel((1, 0)))
        self.assertEqual(164, im.getpixel((0, 1)))
        self.assertEqual(196, im.getpixel((1, 1)))

        im = GetImage(_REMOTE, '/instances/%s/frames/0/rendered?window-center=16&window-width=128' % a)
        self.assertEqual(127 - 2 * 16, im.getpixel((0, 0)))
        self.assertEqual(127, im.getpixel((1, 0)))
        self.assertEqual(127 + 2 * 16, im.getpixel((0, 1)))
        self.assertEqual(127 + 2 * 32, im.getpixel((1, 1)))


    def test_dicom_seg(self):
        # This test fails in Orthanc <= 1.9.7
        a = UploadInstance(_REMOTE, 'DicomSeg.dcm') ['ID']
        
        self.assertEqual(96, len(DoGet(_REMOTE, '/instances/%s/frames' % a)))
        self.assertRaises(Exception, lambda: DoGet(_REMOTE, '/instances/%s/frames/96/preview' % a))

        nonEmptyFrames = [ 11, 12, 13, 14, 15, 16, 39, 40, 42, 43, 44, 45,
                           46, 47, 48, 49, 75, 76, 77, 78, 79, 80, 81 ]
        
        for i in range(96):
            im = GetImage(_REMOTE, '/instances/%s/frames/%d/preview' % (a, i))
            self.assertEqual('L', im.mode)
            self.assertEqual(256, im.size[0])
            self.assertEqual(256, im.size[1])

            e = im.getextrema()
            self.assertEqual(0, e[0])

            if e[1] == 0:
                self.assertFalse(i in nonEmptyFrames)
            else:
                self.assertTrue(i in nonEmptyFrames)

        im1 = GetImage(_REMOTE, '/instances/%s/frames/44/preview' % a)

        # Generated by "dcm2pnm +F 45 DicomSeg.dcm DicomSeg-Frame45.pgm"
        im2 = Image.open(GetDatabasePath('DicomSeg-Frame45.pgm'))
        im2 = im2.point(lambda p: 255 if p == 128 else 0)

        self.assertTrue(ImageChops.difference(im1, im2).getbbox() is None)


    def test_numpy(self):
        # New in Orthanc 1.10.0
        a = UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm')['ID']
        UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0002.dcm')
        b = UploadInstance(_REMOTE, 'DicomSeg.dcm') ['ID']
        d = UploadInstance(_REMOTE, 'Issue124.dcm') ['ID']

        c = numpy.load(io.BytesIO(DoGet(_REMOTE, '/instances/%s/frames/0/numpy' % a)))
        self.assertFalse(isinstance(c, numpy.lib.npyio.NpzFile))
        self.assertEqual(numpy.float32, c.dtype)
        self.assertEqual((288, 288, 1), c.shape)
        self.assertAlmostEqual(0, c.min())
        self.assertAlmostEqual(536, c.max())

        c = numpy.load(io.BytesIO(DoGet(_REMOTE, '/instances/%s/frames/0/numpy?rescale=0' % a)))
        self.assertFalse(isinstance(c, numpy.lib.npyio.NpzFile))
        self.assertEqual(numpy.uint16, c.dtype)
        self.assertEqual((288, 288, 1), c.shape)
        self.assertEqual(0, c.min())
        self.assertEqual(536, c.max())

        c = numpy.load(io.BytesIO(DoGet(_REMOTE, '/instances/%s/numpy?rescale=1&compress=false' % a)))
        self.assertFalse(isinstance(c, numpy.lib.npyio.NpzFile))
        self.assertEqual(numpy.float32, c.dtype)
        self.assertEqual((1, 288, 288, 1), c.shape)

        series = DoGet(_REMOTE, '/instances/%s/series' % a)['ID']
        c = numpy.load(io.BytesIO(DoGet(_REMOTE, '/series/%s/numpy?rescale=true&compress=0' % series)))
        self.assertFalse(isinstance(c, numpy.lib.npyio.NpzFile))
        self.assertEqual(numpy.float32, c.dtype)
        self.assertEqual((2, 288, 288, 1), c.shape)

        series = DoGet(_REMOTE, '/instances/%s/series' % a)['ID']
        c = numpy.load(io.BytesIO(DoGet(_REMOTE, '/series/%s/numpy?rescale=1' % series)))
        self.assertFalse(isinstance(c, numpy.lib.npyio.NpzFile))
        self.assertEqual(numpy.float32, c.dtype)
        self.assertEqual((2, 288, 288, 1), c.shape)

        c = numpy.load(io.BytesIO(DoGet(_REMOTE, '/instances/%s/numpy?compress' % a)))
        self.assertTrue(isinstance(c, numpy.lib.npyio.NpzFile))
        self.assertEqual(1, len(c.files))
        self.assertEqual(numpy.float32, c['arr_0'].dtype)
        self.assertEqual((1, 288, 288, 1), c['arr_0'].shape)

        c = numpy.load(io.BytesIO(DoGet(_REMOTE, '/instances/%s/frames/0/numpy' % b)))
        self.assertFalse(isinstance(c, numpy.lib.npyio.NpzFile))
        self.assertEqual(numpy.float32, c.dtype)
        self.assertEqual((256, 256, 1), c.shape)
        self.assertAlmostEqual(0, c.min())
        self.assertAlmostEqual(0, c.max())

        c = numpy.load(io.BytesIO(DoGet(_REMOTE, '/instances/%s/frames/14/numpy' % b)))
        self.assertFalse(isinstance(c, numpy.lib.npyio.NpzFile))
        self.assertEqual(numpy.float32, c.dtype)
        self.assertEqual((256, 256, 1), c.shape)
        self.assertAlmostEqual(0, c.min())
        self.assertAlmostEqual(255, c.max())

        c = numpy.load(io.BytesIO(DoGet(_REMOTE, '/instances/%s/numpy' % b)))
        self.assertFalse(isinstance(c, numpy.lib.npyio.NpzFile))
        self.assertEqual(numpy.float32, c.dtype)
        self.assertEqual((96, 256, 256, 1), c.shape)

        series = DoGet(_REMOTE, '/instances/%s/series' % b)['ID']
        c = numpy.load(io.BytesIO(DoGet(_REMOTE, '/series/%s/numpy' % series)))
        self.assertFalse(isinstance(c, numpy.lib.npyio.NpzFile))
        self.assertEqual(numpy.float32, c.dtype)
        self.assertEqual((96, 256, 256, 1), c.shape)

        c = numpy.load(io.BytesIO(DoGet(_REMOTE, '/instances/%s/frames/0/numpy' % d)))
        self.assertFalse(isinstance(c, numpy.lib.npyio.NpzFile))
        self.assertEqual(numpy.float32, c.dtype)
        self.assertEqual((512, 512, 1), c.shape)
        self.assertAlmostEqual(-3024, c.min())  # RescaleIntercept equals -1024 in this image
        self.assertAlmostEqual(2374, c.max())

        c = numpy.load(io.BytesIO(DoGet(_REMOTE, '/instances/%s/frames/0/numpy?rescale=0' % d)))
        self.assertFalse(isinstance(c, numpy.lib.npyio.NpzFile))
        self.assertEqual(numpy.int16, c.dtype)
        self.assertEqual((512, 512, 1), c.shape)
        self.assertEqual(-2000, c.min())
        self.assertEqual(3398, c.max())


    def test_find_patient_name_with_brackets_and_star(self):
        u = UploadInstance(_REMOTE, 'Beaufix/IM-0001-0001.dcm')['ID']

        modified = DoPost(_REMOTE, '/instances/%s/modify' % u, json.dumps({
            "Replace" : {
                "PatientName" : "MyName[*]",
                "PatientID": "test_brackets"
                },
            "Force": True
            }),
            'application/json')

        m = DoPost(_REMOTE, '/instances', modified, 'application/dicom')['ID']

        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
                                             'Query' : { 'PatientName' : 'MyName[*]' }})
        self.assertEqual(1, len(a))

        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Patient',
                                             'Query' : { 'PatientName' : 'MyName[*]' }})
        self.assertEqual(1, len(a))

        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Study',
                                             'Query' : { 'PatientName' : 'MyName*' }})
        self.assertEqual(1, len(a))

        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Patient',
                                             'Query' : { 'PatientName' : 'MyName*' }})
        self.assertEqual(1, len(a))

    def test_find_patient_name_with_brackets_only(self):
        u = UploadInstance(_REMOTE, 'Beaufix/IM-0001-0001.dcm')['ID']

        modified = DoPost(_REMOTE, '/instances/%s/modify' % u, json.dumps({
            "Replace" : {
                "PatientName" : "MyName2[]",
                "PatientID": "test_brackets2"
                },
            "Force": True
            }),
            'application/json')

        m = DoPost(_REMOTE, '/instances', modified, 'application/dicom')['ID']

        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
                                             'Query' : { 'PatientName' : 'MyName2[*]' }})
        self.assertEqual(1, len(a))

        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Patient',
                                             'Query' : { 'PatientName' : 'MyName2[*]' }})
        self.assertEqual(1, len(a))

        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Study',
                                             'Query' : { 'PatientName' : 'MyName2*' }})
        self.assertEqual(1, len(a))

        a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Patient',
                                             'Query' : { 'PatientName' : 'MyName2*' }})
        self.assertEqual(1, len(a))


    def test_rest_find_requested_tags(self):
        if IsOrthancVersionAbove(_REMOTE, 1, 11, 1):  # RequestedTags introduced in 1.11.0 but Sequences allowed since 1.11.1

            # Upload instances
            for i in range(2):
                UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-000%d.dcm' % (i + 1))

            # Patient level
            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Patient',
                                                'CaseSensitive' : False,
                                                'Query' : { 'PatientName' : 'BRAINIX' },
                                                'RequestedTags' : [ 'PatientName', 'PatientID', 'PatientSex', 'PatientBirthDate'],
                                                'Expand': True
                                                })
            self.assertEqual(1, len(a))
            self.assertIn('PatientName', a[0]['RequestedTags'])
            self.assertIn('PatientID', a[0]['RequestedTags'])
            self.assertIn('PatientSex', a[0]['RequestedTags'])
            self.assertIn('PatientBirthDate', a[0]['RequestedTags'])

            self.assertEqual('BRAINIX', a[0]['RequestedTags']['PatientName'])
            self.assertEqual('5Yp0E', a[0]['RequestedTags']['PatientID'])
            self.assertEqual('0000', a[0]['RequestedTags']['PatientSex'])
            self.assertEqual('19490301', a[0]['RequestedTags']['PatientBirthDate'])

            # Study level, request patient tags too
            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Study',
                                                'CaseSensitive' : False,
                                                'Query' : { 'PatientName' : 'BRAINIX' },
                                                'RequestedTags' : [ 'PatientName', 'StudyInstanceUID'],
                                                'Expand': True
                                                })
            self.assertEqual(1, len(a))
            self.assertIn('PatientName', a[0]['RequestedTags'])
            self.assertIn('StudyInstanceUID', a[0]['RequestedTags'])

            self.assertEqual('BRAINIX', a[0]['RequestedTags']['PatientName'])
            self.assertEqual('2.16.840.1.113669.632.20.1211.10000357775', a[0]['RequestedTags']['StudyInstanceUID'])


            # Series level, request patient and study tags too
            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
                                                'CaseSensitive' : False,
                                                'Query' : { 'PatientName' : 'BRAINIX' },
                                                'RequestedTags' : [ 'PatientName', 'StudyInstanceUID', 'SeriesInstanceUID', 'RequestAttributesSequence'],
                                                'Expand': True
                                                })
            self.assertEqual(1, len(a))
            self.assertIn('PatientName', a[0]['RequestedTags'])
            self.assertIn('StudyInstanceUID', a[0]['RequestedTags'])
            self.assertIn('SeriesInstanceUID', a[0]['RequestedTags'])
            self.assertIn('RequestAttributesSequence', a[0]['RequestedTags'])

            self.assertEqual('BRAINIX', a[0]['RequestedTags']['PatientName'])
            self.assertEqual('2.16.840.1.113669.632.20.1211.10000357775', a[0]['RequestedTags']['StudyInstanceUID'])
            self.assertEqual('1.3.46.670589.11.0.0.11.4.2.0.8743.5.5396.2006120114285654497', a[0]['RequestedTags']['SeriesInstanceUID'])
            self.assertEqual('A10029316690', a[0]['RequestedTags']['RequestAttributesSequence'][0]['RequestedProcedureID'])


            # Instance level, request patient, study and series tags too, include tags that are not part of the main dicom tags
            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
                                                'CaseSensitive' : False,
                                                'Query' : { 'PatientName' : 'BRAINIX' },
                                                'RequestedTags' : [ 'PatientName', 'StudyInstanceUID', 'SeriesInstanceUID', 'SOPInstanceUID', 'PhotometricInterpretation', 'RequestAttributesSequence'],
                                                'Expand': True
                                                })
            self.assertEqual(1, len(a))
            self.assertIn('PatientName', a[0]['RequestedTags'])
            self.assertIn('StudyInstanceUID', a[0]['RequestedTags'])
            self.assertIn('SeriesInstanceUID', a[0]['RequestedTags'])
            self.assertIn('PhotometricInterpretation', a[0]['RequestedTags'])
            self.assertIn('RequestAttributesSequence', a[0]['RequestedTags'])

            self.assertEqual('BRAINIX', a[0]['RequestedTags']['PatientName'])
            self.assertEqual('2.16.840.1.113669.632.20.1211.10000357775', a[0]['RequestedTags']['StudyInstanceUID'])
            self.assertEqual('1.3.46.670589.11.0.0.11.4.2.0.8743.5.5396.2006120114285654497', a[0]['RequestedTags']['SeriesInstanceUID'])
            self.assertEqual('MONOCHROME2', a[0]['RequestedTags']['PhotometricInterpretation'])
            self.assertEqual('A10029316690', a[0]['RequestedTags']['RequestAttributesSequence'][0]['RequestedProcedureID'])


    def test_rest_find_requested_tags_computed_tags(self):
        if IsOrthancVersionAbove(_REMOTE, 1, 11, 0):
            # Upload instances
            for i in range(2):
                UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-000%d.dcm' % (i + 1))


            # Patient level
            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Patient',
                                                'CaseSensitive' : False,
                                                'Query' : { 'PatientName' : 'BRAINIX' },
                                                'RequestedTags' : [ 'PatientName', 'NumberOfPatientRelatedStudies', 'NumberOfPatientRelatedSeries', 'NumberOfPatientRelatedInstances'],
                                                'Expand': True
                                                })
            self.assertEqual(1, len(a))

            self.assertEqual('BRAINIX', a[0]['RequestedTags']['PatientName'])
            self.assertEqual('1', a[0]['RequestedTags']['NumberOfPatientRelatedStudies'])
            self.assertEqual('1', a[0]['RequestedTags']['NumberOfPatientRelatedSeries'])
            self.assertEqual('2', a[0]['RequestedTags']['NumberOfPatientRelatedInstances'])

            # Study level
            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Study',
                                                'CaseSensitive' : False,
                                                'Query' : { 'PatientName' : 'BRAINIX' },
                                                'RequestedTags' : [ 'PatientName', 'StudyInstanceUID', 'ModalitiesInStudy', 'SOPClassesInStudy', 'NumberOfStudyRelatedInstances', 'NumberOfStudyRelatedSeries'],
                                                'Expand': True
                                                })
            self.assertEqual(1, len(a))

            self.assertEqual('BRAINIX', a[0]['RequestedTags']['PatientName'])
            self.assertEqual('2.16.840.1.113669.632.20.1211.10000357775', a[0]['RequestedTags']['StudyInstanceUID'])
            self.assertEqual('MR', a[0]['RequestedTags']['ModalitiesInStudy'])
            self.assertEqual('1.2.840.10008.5.1.4.1.1.4', a[0]['RequestedTags']['SOPClassesInStudy'])
            self.assertEqual('2', a[0]['RequestedTags']['NumberOfStudyRelatedInstances'])
            self.assertEqual('1', a[0]['RequestedTags']['NumberOfStudyRelatedSeries'])

            # Series level
            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
                                                'CaseSensitive' : False,
                                                'Query' : { 'PatientName' : 'BRAINIX' },
                                                'RequestedTags' : [ 'PatientName', 'StudyInstanceUID', 'NumberOfSeriesRelatedInstances'],
                                                'Expand': True
                                                })
            self.assertEqual(1, len(a))

            self.assertEqual('BRAINIX', a[0]['RequestedTags']['PatientName'])
            self.assertEqual('2.16.840.1.113669.632.20.1211.10000357775', a[0]['RequestedTags']['StudyInstanceUID'])
            self.assertEqual('2', a[0]['RequestedTags']['NumberOfSeriesRelatedInstances'])

            # Instance level
            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Instance',
                                                'CaseSensitive' : False,
                                                'Query' : { 'PatientName' : 'BRAINIX' },
                                                'RequestedTags' : [ 'PatientName', 'StudyInstanceUID', 'SOPInstanceUID', 'InstanceAvailability'],
                                                'Expand': True
                                                })
            self.assertEqual(2, len(a))

            self.assertEqual('BRAINIX', a[0]['RequestedTags']['PatientName'])
            self.assertEqual('2.16.840.1.113669.632.20.1211.10000357775', a[0]['RequestedTags']['StudyInstanceUID'])
            self.assertEqual('ONLINE', a[0]['RequestedTags']['InstanceAvailability'])

    def test_list_resources_requested_tags(self):

        if IsOrthancVersionAbove(_REMOTE, 1, 11, 0):
            instance = UploadInstance(_REMOTE, 'DummyCT.dcm') ['ID']
            patient = DoGet(_REMOTE, '/instances/%s/patient' % instance) ['ID']
            study = DoGet(_REMOTE, '/instances/%s/study' % instance) ['ID']

            # list series and request tags that are not in the default main dicom tags
            a = DoGet(_REMOTE, '/studies/%s/series?expand&simplify&requestedTags=PatientName;Modality;SeriesInstanceUID;MRAcquisitionType' % study)

            self.assertEqual('2D', a[0]['RequestedTags']['MRAcquisitionType'])
            self.assertEqual('MR', a[0]['RequestedTags']['Modality'])
            self.assertEqual('KNIX', a[0]['RequestedTags']['PatientName'])
            self.assertEqual('1.2.840.113619.2.176.2025.1499492.7391.1171285944.394', a[0]['RequestedTags']['SeriesInstanceUID'])

            # list studies and request patient and studies tags
            a = DoGet(_REMOTE, '/patients/%s/studies?expand&simplify&requestedTags=PatientName;StudyInstanceUID' % patient)

            self.assertEqual('1.2.840.113619.2.176.2025.1499492.7391.1171285944.390', a[0]['RequestedTags']['StudyInstanceUID'])
            self.assertEqual('KNIX', a[0]['RequestedTags']['PatientName'])


            # list instances and request patient, studies and series tags including tags that are not in main dicom tags
            a = DoGet(_REMOTE, '/patients/%s/instances?expand&simplify&requestedTags=PatientName;StudyInstanceUID;SeriesInstanceUID;SOPInstanceUID;Rows;Columns;InstanceAvailability' % patient)

            self.assertEqual('1.2.840.113619.2.176.2025.1499492.7391.1171285944.390', a[0]['RequestedTags']['StudyInstanceUID'])
            self.assertEqual('1.2.840.113619.2.176.2025.1499492.7391.1171285944.394', a[0]['RequestedTags']['SeriesInstanceUID'])
            self.assertEqual('1.2.840.113619.2.176.2025.1499492.7040.1171286242.109', a[0]['RequestedTags']['SOPInstanceUID'])
            self.assertEqual('KNIX', a[0]['RequestedTags']['PatientName'])
            self.assertEqual('512', a[0]['RequestedTags']['Rows'])
            self.assertEqual('512', a[0]['RequestedTags']['Columns'])
            self.assertEqual('ONLINE', a[0]['RequestedTags']['InstanceAvailability'])


    def test_list_resources_requested_tags_study_computed_tags(self):

        if IsOrthancVersionAbove(_REMOTE, 1, 11, 0):
            UploadInstance(_REMOTE, 'Comunix/Pet/IM-0001-0001.dcm')  # make sure there are 2 different SOPClassUID in the DB

            instance = UploadInstance(_REMOTE, 'DummyCT.dcm') ['ID']
            patient = DoGet(_REMOTE, '/instances/%s/patient' % instance) ['ID']
            study = DoGet(_REMOTE, '/instances/%s/study' % instance) ['ID']

            # list studies and request patient and studies tags, including ModalitiesInStudy
            a = DoGet(_REMOTE, '/patients/%s/studies?expand&simplify&requestedTags=PatientName;StudyInstanceUID;ModalitiesInStudy;SOPClassesInStudy;NumberOfStudyRelatedInstances;NumberOfStudyRelatedSeries' % patient)

            self.assertEqual('1.2.840.113619.2.176.2025.1499492.7391.1171285944.390', a[0]['RequestedTags']['StudyInstanceUID'])
            self.assertEqual('KNIX', a[0]['RequestedTags']['PatientName'])
            self.assertEqual('MR', a[0]['RequestedTags']['ModalitiesInStudy'])
            self.assertEqual('1.2.840.10008.5.1.4.1.1.4', a[0]['RequestedTags']['SOPClassesInStudy'])
            self.assertEqual('1', a[0]['RequestedTags']['NumberOfStudyRelatedInstances'])
            self.assertEqual('1', a[0]['RequestedTags']['NumberOfStudyRelatedSeries'])

            a = DoGet(_REMOTE, '/studies/%s?expand&simplify&requestedTags=ModalitiesInStudy;NumberOfStudyRelatedInstances;NumberOfStudyRelatedSeries;SOPClassesInStudy' % study)            
            self.assertEqual('1.2.840.10008.5.1.4.1.1.4', a['RequestedTags']['SOPClassesInStudy'])


    def test_list_resources_requested_tags_series_computed_tags(self):

        if IsOrthancVersionAbove(_REMOTE, 1, 11, 0):
            instance = UploadInstance(_REMOTE, 'DummyCT.dcm') ['ID']
            patient = DoGet(_REMOTE, '/instances/%s/patient' % instance) ['ID']
            study = DoGet(_REMOTE, '/instances/%s/study' % instance) ['ID']

            # list studies and request patient and studies tags, including ModalitiesInStudy
            a = DoGet(_REMOTE, '/studies/%s/series?expand&simplify&requestedTags=PatientName;SeriesInstanceUID;NumberOfSeriesRelatedInstances' % study)

            self.assertEqual('1.2.840.113619.2.176.2025.1499492.7391.1171285944.394', a[0]['RequestedTags']['SeriesInstanceUID'])
            self.assertEqual('KNIX', a[0]['RequestedTags']['PatientName'])
            self.assertEqual('1', a[0]['RequestedTags']['NumberOfSeriesRelatedInstances'])

    def test_dicomweb_jpeg2k_implicit(self):
        # This is a file encoded using 1.2.840.10008.1.2.4.90 transfer
        # syntax, in which most DICOM tags have the "UN" value
        # representation. Support introduced in Orthanc 1.10.1.
        # https://groups.google.com/g/orthanc-users/c/86fobx3ZyoM/m/KBG17un6AQAJ
        a = UploadInstance(_REMOTE, '2022-03-08-RicSmi.dcm') ['ID']
        b = DoGet(_REMOTE, '/instances/%s/file' % a,
                  headers = { 'Accept' : 'application/dicom+json' })
        self.assertEqual(b['0020000D']['Value'][0], '1.2.276.0.7230010.3.1.2.2358427580.3460.1646695830.793')
        self.assertEqual(b['0020000E']['Value'][0], '1.2.276.0.7230010.3.1.3.2358427580.3460.1646695830.794')
    
    def test_create_png16RBGA(self):
        with open(GetDatabasePath('Png16RBGATest.png'), 'rb') as f:
            png = f.read()

        i = DoPost(_REMOTE, '/tools/create-dicom',
                   json.dumps({
                    'PatientName' : 'Jodogne',
                    'Modality' : 'CT',
                    'SOPClassUID' : '1.2.840.10008.5.1.4.1.1.1',
                    'PixelData' : 'data:image/png;base64,' + base64.b64encode(png)
                    }))

        self.assertEqual('Jodogne', DoGet(_REMOTE, '/instances/%s/content/PatientName' % i['ID']).strip())
        self.assertEqual('CT', DoGet(_REMOTE, '/instances/%s/content/Modality' % i['ID']).strip())

        png = GetImage(_REMOTE, '/instances/%s/preview' % i['ID'])
        self.assertEqual((32, 32), png.size)

        png = GetImage(_REMOTE, '/instances/%s/rendered' % i['ID'])
        self.assertEqual((32, 32), png.size)

        j = DoGet(_REMOTE, i['Path'])
        self.assertEqual('Instance', j['Type'])
        self.assertEqual(j['ID'], i['ID'])

    def test_storescu_custom_host_ip_port(self):
        if IsOrthancVersionAbove(_REMOTE, 1, 11, 3):
            DropOrthanc(_LOCAL)
            DropOrthanc(_REMOTE)        

            a = UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')

            # upload to self -> orthanctest shall not receive any content
            DoPost(_REMOTE, '/modalities/self/store', {  
                'Resources' : [ a['ID']]
            })
            self.assertEqual(0, len(DoGet(_LOCAL, '/instances')))

            # upload to self by overriding it with config from orthanctest -> orthanctest shall receive the content
            c = DoGet(_REMOTE, '/modalities/orthanctest/configuration')
            DoPost(_REMOTE, '/modalities/self/store', {  
                'LocalAet' : 'YOP',
                'CalledAet' : c['AET'],
                'Port' : c['Port'],
                'Host' : c['Host'],
                'Resources' : [ a['ID']]
            })

            self.assertEqual(1, len(DoGet(_LOCAL, '/instances')))

            DropOrthanc(_REMOTE)        
            DropOrthanc(_LOCAL)        

    def test_rle_planar_configuration(self):
        if IsOrthancVersionAbove(_REMOTE, 1, 11, 2):
            # https://groups.google.com/g/orthanc-users/c/CSVWfRasSR0/m/y1XDRXVnAgAJ
            a = UploadInstance(_REMOTE, '2022-11-14-RLEPlanarConfiguration.dcm') ['ID']
            uri = '/instances/%s/preview' % a
            im = GetImage(_REMOTE, uri)
            self.assertEqual('RGB', im.mode)
            self.assertEqual(1475, im.size[0])
            self.assertEqual(1475, im.size[1])
            self.assertEqual('c684b0050dc2523041240bf2d26dc85e', ComputeMD5(DoGet(_REMOTE, uri)))

        if IsOrthancVersionAbove(_REMOTE, 1, 12, 1):
            a = UploadInstance(_REMOTE, '2023-04-21-RLEPlanarConfigurationYBR_FULL.dcm') ['ID']
            uri = '/instances/%s/preview' % a
            im = GetImage(_REMOTE, uri)
            self.assertEqual('RGB', im.mode)
            self.assertEqual(1260, im.size[0])
            self.assertEqual(910, im.size[1])
            self.assertEqual('07a3ea7ea08d54362f744cc5945e8743', ComputeMD5(DoGet(_REMOTE, uri)))


    def test_rest_api_write_to_file_system(self):
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 0):
            a = UploadInstance(_REMOTE, '2022-11-14-RLEPlanarConfiguration.dcm') ['ID']
            self.assertRaises(Exception, lambda: DoPost(_REMOTE, '/instances/%s/export' % a, '/tmp/test.dcm'))

    def test_overwrite_generates_stable_study(self):

        # This test makes sure there are no regression wrt StableStudy when uploading instances in Orthanc
        # The current behaviour (tested from 1.5.7 to 1.12.0) is
        # If you upload 2 instances with a delay > StableAge, you get 2 StableStudy events and they are both listed in /changes
        # If you upload twice the same instance with a delay > StableAge, you get 2 StableStudy events but only the last one is listed in /changes, the first one is deleted
        # If you upload an instance and a modified version of this instance with a delay > StableAge, you get 2 StableStudy events but only the last one is listed in /changes, the first one is deleted


        def GetAllStableStudyChangesIds(studyId, timeout):
            # try to be as fast as possible -> stop as soon as we've found a StableStudy event that appeared after we started monitoring
            fromSeq = DoGet(_REMOTE, '/changes')["Last"]

            endTime = time.time() + timeout
            newStableStudyFound = False
            while not newStableStudyFound and time.time() < endTime:
                time.sleep(0.1)
                changes = DoGet(_REMOTE, '/changes')
                stableStudyChangesIds = []

                for change in changes["Changes"]:
                    if change["ChangeType"] == "StableStudy" and studyId == change["ID"]:
                        stableStudyChangesIds.append(change["Seq"])
                        if change["Seq"] > fromSeq:
                            newStableStudyFound = True

            return stableStudyChangesIds

        if True:
            DropOrthanc(_REMOTE)        
            upload1 = UploadInstance(_REMOTE, 'Knix/Loc/IM-0001-0002.dcm')
            # StableAge is set to 1, expect a StableStudy within 4 seconds
            changes1 = GetAllStableStudyChangesIds(upload1["ParentStudy"], 4)
            self.assertEqual(1, len(changes1))

            # upload the same instance again and check a new change has been generated with a new id, the first change has been deleted
            upload1b = UploadInstance(_REMOTE, 'Knix/Loc/IM-0001-0002.dcm')
            changes1b = GetAllStableStudyChangesIds(upload1b["ParentStudy"], 4)
            self.assertEqual(1, len(changes1b))
            self.assertNotEqual(changes1[0], changes1b[0])

        if True:
            DropOrthanc(_REMOTE)        
            upload1 = UploadInstance(_REMOTE, 'Knix/Loc/IM-0001-0002.dcm')
            # StableAge is set to 1, expect a StableStudy within 4 seconds
            changes1 = GetAllStableStudyChangesIds(upload1["ParentStudy"], 4)
            self.assertEqual(1, len(changes1))

            # reupload a modified instance in the same study and check a new change has been generated with a new id, the first change has been deleted
            modified = DoPost(_REMOTE, '/instances/%s/modify' % upload1["ID"],
                            json.dumps({
                                "Replace" : {
                                    "InstitutionName" : "hello",
                                    "SOPInstanceUID": "1.2.840.113619.2.176.2025.1499492.7040.1171286241.705"
                                    },
                                "Force": True
                                }),
                                'application/json')
            upload1b = DoPost(_REMOTE, '/instances', modified, 'application/dicom')
            changes1b = GetAllStableStudyChangesIds(upload1b["ParentStudy"], 4)
            self.assertEqual(upload1["ParentStudy"], upload1b["ParentStudy"])
            self.assertEqual(1, len(changes1b))
            self.assertNotEqual(changes1[0], changes1b[0])


        if True:
            DropOrthanc(_REMOTE)        
            upload1 = UploadInstance(_REMOTE, 'Knix/Loc/IM-0001-0002.dcm')
            # StableAge is set to 1, expect a StableStudy within 4 seconds
            changes1 = GetAllStableStudyChangesIds(upload1["ParentStudy"], 4)
            self.assertEqual(1, len(changes1))

            # upload a new instance in the same study and check a second StableStudy change has been generated with a new id
            upload2 = UploadInstance(_REMOTE, 'Knix/Loc/IM-0001-0003.dcm')
            changes2 = GetAllStableStudyChangesIds(upload2["ParentStudy"], 4)
            self.assertEqual(upload1["ParentStudy"], upload2["ParentStudy"])
            self.assertEqual(2, len(changes2))
            self.assertEqual(changes1[0], changes2[0])

    def test_labels(self):
        def CheckAllLabels(expected):
            actual = DoGet(_REMOTE, '/tools/labels')
            self.assertEqual(len(actual), len(expected))
            for i in expected:
                self.assertTrue(i in actual)
            for i in actual:
                self.assertTrue(i in expected)

        if (IsOrthancVersionAbove(_REMOTE, 1, 12, 0) and
            DoGet(_REMOTE, '/system') ['HasLabels']):
            u = UploadInstance(_REMOTE, 'DummyCT.dcm')['ID']
            patient = DoGet(_REMOTE, '/instances/%s/patient' % u) ['ID']
            study = DoGet(_REMOTE, '/instances/%s/study' % u) ['ID']
            series = DoGet(_REMOTE, '/instances/%s/series' % u) ['ID']

            for base in [ '/instances/%s' % u,
                          '/series/%s' % series,
                          '/studies/%s' % study,
                          '/patients/%s' % patient ]:

                # no tags by default
                self.assertEqual(0, len(DoGet(_REMOTE, base) ['Labels']))
                CheckAllLabels([])
                
                # 404 if requesting a tag that does apply for a resource
                self.assertRaises(Exception, lambda: DoGet(_REMOTE, '%s/labels/hello' % base))
                
                # delete a non existing tag does not generate an error
                self.assertEqual('', DoDelete(_REMOTE, '%s/labels/hello' % base))
                self.assertEqual(0, len(DoGet(_REMOTE, base) ['Labels']))
                
                # Not an alphanumeric label -> 400
                self.assertRaises(Exception, lambda: DoPut(_REMOTE, '%s/labels/@' % base))

                # add a tag
                self.assertEqual('', DoPut(_REMOTE, '%s/labels/hello' % base))
                self.assertEqual(1, len(DoGet(_REMOTE, base) ['Labels']))
                self.assertEqual('hello', DoGet(_REMOTE, base) ['Labels'][0])
                CheckAllLabels([ 'hello' ])

                # double tagging does not generate any error
                self.assertEqual('', DoPut(_REMOTE, '%s/labels/hello' % base))
                self.assertEqual('', DoGet(_REMOTE, '%s/labels/hello' % base))
                self.assertEqual(1, len(DoGet(_REMOTE, base) ['Labels']))
                self.assertEqual('hello', DoGet(_REMOTE, base) ['Labels'][0])

                # add a second tag
                self.assertEqual('', DoPut(_REMOTE, '%s/labels/world' % base))
                self.assertEqual('', DoGet(_REMOTE, '%s/labels/world' % base))
                self.assertEqual('', DoGet(_REMOTE, '%s/labels/hello' % base))
                self.assertEqual(2, len(DoGet(_REMOTE, base) ['Labels']))
                self.assertIn(DoGet(_REMOTE, base) ['Labels'][0], ['hello', 'world'])
                self.assertIn(DoGet(_REMOTE, base) ['Labels'][1], ['hello', 'world'])
                CheckAllLabels([ 'hello', 'world' ])

                # delete the first tag
                self.assertEqual('', DoDelete(_REMOTE, '%s/labels/hello' % base))
                self.assertEqual(1, len(DoGet(_REMOTE, base) ['Labels']))
                self.assertEqual('world', DoGet(_REMOTE, base) ['Labels'][0])
                self.assertRaises(Exception, lambda: DoGet(_REMOTE, '%s/labels/hello' % base))
                CheckAllLabels([ 'world' ])

                # delete the second tag
                self.assertEqual('', DoDelete(_REMOTE, '%s/labels/world' % base))
                self.assertEqual(0, len(DoGet(_REMOTE, base) ['Labels']))
                self.assertRaises(Exception, lambda: DoGet(_REMOTE, '%s/labels/world' % base))
                CheckAllLabels([ ])

                # test all valid chars
                VALID = r'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'
                self.assertEqual('', DoPut(_REMOTE, '%s/labels/%s' % (base, VALID)))
                CheckAllLabels([ VALID ])
                DoDelete(_REMOTE, '%s/labels/%s' % (base, VALID))
                CheckAllLabels([ ])

        else:
            print("Your database backend doesn't support labels")

    def test_find_labels(self):
        def Execute(labels, constraint, query = { }, level='Instance'):
            return DoPost(_REMOTE, '/tools/find', { 'Level' : level,
                                                    'Query' : query,
                                                    'Labels' : labels,
                                                    'LabelsConstraint' : constraint, })
        
        if (IsOrthancVersionAbove(_REMOTE, 1, 12, 0) and
            DoGet(_REMOTE, '/system') ['HasLabels']):
            u = UploadInstance(_REMOTE, 'DummyCT.dcm')
            studyId = u["ParentStudy"]
            seriesId = u["ParentSeries"]
            patientId = u["ParentPatient"]
            u = u["ID"]
            self.assertEqual(1, len(Execute([ 'a' ], 'None')))

            # The instance has no label
            self.assertEqual(1, len(Execute([], 'All')))
            self.assertEqual(1, len(Execute([], 'Any')))
            self.assertEqual(1, len(Execute([], 'None')))
            self.assertEqual(0, len(Execute([ 'a' ], 'All')))
            self.assertEqual(0, len(Execute([ 'a' ], 'Any')))
            self.assertEqual(1, len(Execute([ 'a' ], 'None')))
            self.assertEqual(0, len(Execute([ 'b' ], 'All')))
            self.assertEqual(0, len(Execute([ 'b' ], 'Any')))
            self.assertEqual(1, len(Execute([ 'b' ], 'None')))
            self.assertEqual(0, len(Execute([ 'a', 'b' ], 'All')))
            self.assertEqual(0, len(Execute([ 'a', 'b' ], 'Any')))
            self.assertEqual(1, len(Execute([ 'a', 'b' ], 'None')))

            DoPut(_REMOTE, '/instances/%s/labels/a' % u)
            # The instance has label "a"
            self.assertEqual(1, len(Execute([], 'All')))
            self.assertEqual(1, len(Execute([], 'Any')))
            self.assertEqual(1, len(Execute([], 'None')))
            self.assertEqual(1, len(Execute([ 'a' ], 'All')))
            self.assertEqual(1, len(Execute([ 'a' ], 'Any')))
            self.assertEqual(0, len(Execute([ 'a' ], 'None')))
            self.assertEqual(0, len(Execute([ 'b' ], 'All')))
            self.assertEqual(0, len(Execute([ 'b' ], 'Any')))
            self.assertEqual(1, len(Execute([ 'b' ], 'None')))
            self.assertEqual(0, len(Execute([ 'a', 'b' ], 'All')))
            self.assertEqual(1, len(Execute([ 'a', 'b' ], 'Any')))
            self.assertEqual(0, len(Execute([ 'a', 'b' ], 'None')))

            self.assertEqual(0, len(Execute([ 'a' ], 'All', { 'PatientID' : 'nope' })))
            self.assertEqual(1, len(Execute([ 'a' ], 'All', { 'PatientID' : '' })))
            self.assertEqual(0, len(Execute([ 'a' ], 'All', { 'StudyInstanceUID' : 'nope' })))
            self.assertEqual(1, len(Execute([ 'a' ], 'All', { 'StudyInstanceUID' : '' })))
            self.assertEqual(0, len(Execute([ 'a' ], 'All', { 'SeriesInstanceUID' : 'nope' })))
            self.assertEqual(1, len(Execute([ 'a' ], 'All', { 'SeriesInstanceUID' : '' })))
            self.assertEqual(0, len(Execute([ 'a' ], 'All', { 'SOPInstanceUID' : 'nope' })))
            self.assertEqual(1, len(Execute([ 'a' ], 'All', { 'SOPInstanceUID' : '' })))
            
            self.assertEqual(1, len(Execute([ 'a' ], 'All', { 'PatientID' : 'ozp00SjY2xG' })))
        
            self.assertEqual(1, len(Execute([ 'a' ], 'All', { 'StudyInstanceUID' : '1.2.840.113619.2.176.2025.1499492.7391.1171285944.390' })))
            self.assertEqual(1, len(Execute([ 'a' ], 'All', { 'SeriesInstanceUID' : '1.2.840.113619.2.176.2025.1499492.7391.1171285944.394' })))
            self.assertEqual(1, len(Execute([ 'a' ], 'All', { 'SOPInstanceUID' : '1.2.840.113619.2.176.2025.1499492.7040.1171286242.109' })))
            self.assertEqual(1, len(Execute([ 'a' ], 'All', {
                'PatientID' : 'ozp00SjY2xG',
                'StudyInstanceUID' : '1.2.840.113619.2.176.2025.1499492.7391.1171285944.390',
                'SeriesInstanceUID' : '1.2.840.113619.2.176.2025.1499492.7391.1171285944.394',
                'SOPInstanceUID' : '1.2.840.113619.2.176.2025.1499492.7040.1171286242.109',
            })))

            self.assertEqual(0, len(Execute([ 'b' ], 'All', { 'PatientID' : 'nope' })))
            self.assertEqual(0, len(Execute([ 'b' ], 'All', { 'PatientID' : '' })))
            self.assertEqual(0, len(Execute([ 'b' ], 'All', { 'StudyInstanceUID' : 'nope' })))
            self.assertEqual(0, len(Execute([ 'b' ], 'All', { 'StudyInstanceUID' : '' })))
            self.assertEqual(0, len(Execute([ 'b' ], 'All', { 'SeriesInstanceUID' : 'nope' })))
            self.assertEqual(0, len(Execute([ 'b' ], 'All', { 'SeriesInstanceUID' : '' })))
            self.assertEqual(0, len(Execute([ 'b' ], 'All', { 'SOPInstanceUID' : 'nope' })))
            self.assertEqual(0, len(Execute([ 'b' ], 'All', { 'SOPInstanceUID' : '' })))
            
            self.assertEqual(0, len(Execute([ 'b' ], 'All', { 'PatientID' : 'ozp00SjY2xG' })))
            self.assertEqual(0, len(Execute([ 'b' ], 'All', { 'StudyInstanceUID' : '1.2.840.113619.2.176.2025.1499492.7391.1171285944.390' })))
            self.assertEqual(0, len(Execute([ 'b' ], 'All', { 'SeriesInstanceUID' : '1.2.840.113619.2.176.2025.1499492.7391.1171285944.394' })))
            self.assertEqual(0, len(Execute([ 'b' ], 'All', { 'SOPInstanceUID' : '1.2.840.113619.2.176.2025.1499492.7040.1171286242.109' })))
            self.assertEqual(0, len(Execute([ 'b' ], 'All', {
                'PatientID' : 'ozp00SjY2xG',
                'StudyInstanceUID' : '1.2.840.113619.2.176.2025.1499492.7391.1171285944.390',
                'SeriesInstanceUID' : '1.2.840.113619.2.176.2025.1499492.7391.1171285944.394',
                'SOPInstanceUID' : '1.2.840.113619.2.176.2025.1499492.7040.1171286242.109',
            })))

            DoPut(_REMOTE, '/instances/%s/labels/b' % u)
            # The instance has labels "a" and "b"
            self.assertEqual(1, len(Execute([], 'All')))
            self.assertEqual(1, len(Execute([], 'Any')))
            self.assertEqual(1, len(Execute([], 'None')))
            self.assertEqual(1, len(Execute([ 'a' ], 'All')))
            self.assertEqual(1, len(Execute([ 'a' ], 'Any')))
            self.assertEqual(0, len(Execute([ 'a' ], 'None')))
            self.assertEqual(1, len(Execute([ 'b' ], 'All')))
            self.assertEqual(1, len(Execute([ 'b' ], 'Any')))
            self.assertEqual(0, len(Execute([ 'b' ], 'None')))
            self.assertEqual(1, len(Execute([ 'a', 'b' ], 'All')))
            self.assertEqual(1, len(Execute([ 'a', 'b' ], 'Any')))
            self.assertEqual(0, len(Execute([ 'a', 'b' ], 'None')))
            self.assertEqual(0, len(Execute([ 'b', 'c' ], 'All')))
            self.assertEqual(1, len(Execute([ 'b', 'c' ], 'Any')))
            self.assertEqual(0, len(Execute([ 'b', 'c' ], 'None')))
            self.assertEqual(0, len(Execute([ 'c', 'd' ], 'All')))
            self.assertEqual(0, len(Execute([ 'c', 'd' ], 'Any')))
            self.assertEqual(1, len(Execute([ 'c', 'c' ], 'None')))
            
            DoDelete(_REMOTE, '/instances/%s/labels/a' % u)
            # The instance has label "b"
            self.assertEqual(1, len(Execute([], 'All')))
            self.assertEqual(1, len(Execute([], 'Any')))
            self.assertEqual(1, len(Execute([], 'None')))
            self.assertEqual(0, len(Execute([ 'a' ], 'All')))
            self.assertEqual(0, len(Execute([ 'a' ], 'Any')))
            self.assertEqual(1, len(Execute([ 'a' ], 'None')))
            self.assertEqual(1, len(Execute([ 'b' ], 'All')))
            self.assertEqual(1, len(Execute([ 'b' ], 'Any')))
            self.assertEqual(0, len(Execute([ 'b' ], 'None')))
            self.assertEqual(0, len(Execute([ 'a', 'b' ], 'All')))
            self.assertEqual(1, len(Execute([ 'a', 'b' ], 'Any')))
            self.assertEqual(0, len(Execute([ 'a', 'b' ], 'None')))

            DoDelete(_REMOTE, '/instances/%s/labels/b' % u)
            # The instance has no more label
            self.assertEqual(1, len(Execute([], 'All')))
            self.assertEqual(1, len(Execute([], 'Any')))
            self.assertEqual(1, len(Execute([], 'None')))
            self.assertEqual(0, len(Execute([ 'a' ], 'All')))
            self.assertEqual(0, len(Execute([ 'a' ], 'Any')))
            self.assertEqual(1, len(Execute([ 'a' ], 'None')))
            self.assertEqual(0, len(Execute([ 'b' ], 'All')))
            self.assertEqual(0, len(Execute([ 'b' ], 'Any')))
            self.assertEqual(1, len(Execute([ 'b' ], 'None')))
            self.assertEqual(0, len(Execute([ 'a', 'b' ], 'All')))
            self.assertEqual(0, len(Execute([ 'a', 'b' ], 'Any')))
            self.assertEqual(1, len(Execute([ 'a', 'b' ], 'None')))


            # tests at series levels (make sure to test only with series levels and with multiple levels)
            DoPut(_REMOTE, '/series/%s/labels/b' % seriesId)
            self.assertEqual(1, len(Execute([ 'a' ], 'None', {
                'SeriesInstanceUID' : '1.2.840.113619.2.176.2025.1499492.7391.1171285944.394'
            }, 'Series')))
            self.assertEqual(0, len(Execute([ 'a' ], 'Any', {
                'SeriesInstanceUID' : '1.2.840.113619.2.176.2025.1499492.7391.1171285944.394'
            }, 'Series')))
            self.assertEqual(1, len(Execute([ 'b' ], 'Any', {
                'SeriesInstanceUID' : '1.2.840.113619.2.176.2025.1499492.7391.1171285944.394'
            }, 'Series')))
            self.assertEqual(1, len(Execute([ 'b' ], 'All', {
                'SeriesInstanceUID' : '1.2.840.113619.2.176.2025.1499492.7391.1171285944.394'
            }, 'Series')))
            self.assertEqual(1, len(Execute([ 'a' ], 'None', {
               'PatientID' : 'ozp00SjY2xG',
            }, 'Series')))
            self.assertEqual(0, len(Execute([ 'a' ], 'Any', {
               'PatientID' : 'ozp00SjY2xG',
            }, 'Series')))
            self.assertEqual(1, len(Execute([ 'b' ], 'Any', {
               'PatientID' : 'ozp00SjY2xG',
            }, 'Series')))
            self.assertEqual(1, len(Execute([ 'b' ], 'All', {
               'PatientID' : 'ozp00SjY2xG',
            }, 'Series')))

            # tests at study levels (make sure to test only with study levels and with multiple levels)
            DoPut(_REMOTE, '/studies/%s/labels/b' % studyId)
            self.assertEqual(1, len(Execute([ 'a' ], 'None', {
                'StudyInstanceUID' : '1.2.840.113619.2.176.2025.1499492.7391.1171285944.390'
            }, 'Study')))
            self.assertEqual(0, len(Execute([ 'a' ], 'Any', {
                'StudyInstanceUID' : '1.2.840.113619.2.176.2025.1499492.7391.1171285944.390'
            }, 'Study')))
            self.assertEqual(1, len(Execute([ 'b' ], 'Any', {
                'StudyInstanceUID' : '1.2.840.113619.2.176.2025.1499492.7391.1171285944.390'
            }, 'Study')))
            self.assertEqual(1, len(Execute([ 'b' ], 'All', {
                'StudyInstanceUID' : '1.2.840.113619.2.176.2025.1499492.7391.1171285944.390'
            }, 'Study')))
            self.assertEqual(1, len(Execute([ 'a' ], 'None', {
               'PatientID' : 'ozp00SjY2xG',
            }, 'Study')))
            self.assertEqual(0, len(Execute([ 'a' ], 'Any', {
               'PatientID' : 'ozp00SjY2xG',
            }, 'Study')))
            self.assertEqual(1, len(Execute([ 'b' ], 'Any', {
               'PatientID' : 'ozp00SjY2xG',
            }, 'Study')))
            self.assertEqual(1, len(Execute([ 'b' ], 'All', {
               'PatientID' : 'ozp00SjY2xG',
            }, 'Study')))

            # tests at patient levels
            DoPut(_REMOTE, '/patients/%s/labels/b' % patientId)
            self.assertEqual(1, len(Execute([ 'a' ], 'None', {
               'PatientID' : 'ozp00SjY2xG',
            }, 'Patient')))
            self.assertEqual(0, len(Execute([ 'a' ], 'Any', {
               'PatientID' : 'ozp00SjY2xG',
            }, 'Patient')))
            self.assertEqual(1, len(Execute([ 'b' ], 'Any', {
               'PatientID' : 'ozp00SjY2xG',
            }, 'Patient')))
            self.assertEqual(1, len(Execute([ 'b' ], 'All', {
               'PatientID' : 'ozp00SjY2xG',
            }, 'Patient')))

        else:
            print("Your database backend doesn't support labels")


    def test_numeric_metadata(self):
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 0):
            instance = UploadInstance(_REMOTE, 'DummyCT.dcm')['ID']
            study = DoGet(_REMOTE, '/instances/%s/study' % instance)['ID']

            m = DoGet(_REMOTE, '/studies/%s/metadata' % study)
            self.assertEqual(2, len(m))
            self.assertTrue('LastUpdate' in m)
            self.assertTrue('MainDicomTagsSignature' in m)
            lastUpdate = DoGet(_REMOTE, '/studies/%s/metadata/%s' % (study, 'LastUpdate'))
            signature = DoGet(_REMOTE, '/studies/%s/metadata/%s' % (study, 'MainDicomTagsSignature'))
            
            m = DoGet(_REMOTE, '/studies/%s/metadata?numeric' % study)
            self.assertEqual(2, len(m))
            self.assertTrue(7 in m)   # MetadataType_LastUpdate
            self.assertTrue(15 in m)  # MetadataType_MainDicomTagsSignature
            self.assertEqual(lastUpdate, DoGet(_REMOTE, '/studies/%s/metadata/%d' % (study, 7)))
            self.assertEqual(signature, DoGet(_REMOTE, '/studies/%s/metadata/%d' % (study, 15)))

            m = DoGet(_REMOTE, '/studies/%s/metadata?expand' % study)
            self.assertEqual(2, len(m))
            self.assertTrue('LastUpdate' in m)
            self.assertTrue('MainDicomTagsSignature' in m)
            self.assertEqual(lastUpdate, m['LastUpdate'])
            self.assertEqual(signature, m['MainDicomTagsSignature'])
            
            m = DoGet(_REMOTE, '/studies/%s/metadata?expand&numeric' % study)
            self.assertEqual(2, len(m))
            self.assertTrue('7' in m)
            self.assertTrue('15' in m)
            self.assertEqual(lastUpdate, m['7'])
            self.assertEqual(signature, m['15'])

    def test_expand(self):
        # test new expand options introduced in 1.12.2
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 2):
            r = UploadInstance(_REMOTE, 'DummyCT.dcm')
            instanceId = r['ID']
            seriesId = r['ParentSeries']
            studyId = r['ParentStudy']

            self.assertEqual(DoGet(_REMOTE, '/instances?expand'), DoGet(_REMOTE, '/instances?expand=true'))
            self.assertEqual(DoGet(_REMOTE, '/instances'), DoGet(_REMOTE, '/instances?expand=false'))

            self.assertEqual(DoGet(_REMOTE, '/series?expand'), DoGet(_REMOTE, '/series?expand=true'))
            self.assertEqual(DoGet(_REMOTE, '/series'), DoGet(_REMOTE, '/series?expand=false'))

            self.assertEqual(DoGet(_REMOTE, '/studies?expand'), DoGet(_REMOTE, '/studies?expand=true'))
            self.assertEqual(DoGet(_REMOTE, '/studies'), DoGet(_REMOTE, '/studies?expand=false'))

            r = DoGet(_REMOTE, '/studies/%s/instances?expand=true' % studyId)
            self.assertEqual(1, len(r))
            self.assertIn('MainDicomTags', r[0])

            r = DoGet(_REMOTE, '/studies/%s/instances?expand=false' % studyId)
            self.assertEqual(1, len(r))
            self.assertIn('66a662ce-7430e543-bad44d47-0dc5a943-ec7a538d', r[0])

            self.assertEqual(DoGet(_REMOTE, '/studies/%s/instances?expand' % studyId), DoGet(_REMOTE, '/studies/%s/instances?expand=true' % studyId))
            self.assertEqual(DoGet(_REMOTE, '/studies/%s/instances' % studyId), DoGet(_REMOTE, '/studies/%s/instances?expand=true' % studyId))

            r = DoGet(_REMOTE, '/studies/%s/series?expand=true' % studyId)
            self.assertEqual(1, len(r))
            self.assertIn('MainDicomTags', r[0])

            r = DoGet(_REMOTE, '/studies/%s/series?expand=false' % studyId)
            self.assertEqual(1, len(r))
            self.assertIn('f2635388-f01d497a-15f7c06b-ad7dba06-c4c599fe', r[0])

            self.assertEqual(DoGet(_REMOTE, '/studies/%s/series?expand' % studyId), DoGet(_REMOTE, '/studies/%s/series?expand=true' % studyId))
            self.assertEqual(DoGet(_REMOTE, '/studies/%s/series' % studyId), DoGet(_REMOTE, '/studies/%s/series?expand=true' % studyId))



    def test_add_attachment_to_non_existing_resource(self):
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 1): # till 1.12.0, it returned a 200
            (headers, body) = DoPutRaw(_REMOTE, '/instances/11111111-11111111-11111111-11111111-11111111/attachments/1025', 'hello')
            self.assertEqual('404', headers['status'])

    def test_delete_updates_parents_last_update_metadata(self):
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 1):
            i = UploadInstance(_REMOTE, 'Beaufix/IM-0001-0001.dcm')
            j = UploadInstance(_REMOTE, 'Beaufix/IM-0001-0002.dcm')
            
            #instanceLastUpdate1 = DoGet(_REMOTE, '/instances/%s/metadata/LastUpdate' % i['ID'])
            seriesLastUpdate1 = DoGet(_REMOTE, '/series/%s/metadata/LastUpdate' % i['ParentSeries'])
            studyLastUpdate1 = DoGet(_REMOTE, '/studies/%s/metadata/LastUpdate' % i['ParentStudy'])
            patientLastUpdate1 = DoGet(_REMOTE, '/patients/%s/metadata/LastUpdate' % i['ParentPatient'])
            
            time.sleep(1.01)
            DoDelete(_REMOTE, '/instances/%s' % j['ID'])

            #instanceLastUpdate2 = DoGet(_REMOTE, '/instances/%s/metadata/LastUpdate' % i['ID'])
            seriesLastUpdate2 = DoGet(_REMOTE, '/series/%s/metadata/LastUpdate' % i['ParentSeries'])
            studyLastUpdate2 = DoGet(_REMOTE, '/studies/%s/metadata/LastUpdate' % i['ParentStudy'])
            patientLastUpdate2 = DoGet(_REMOTE, '/patients/%s/metadata/LastUpdate' % i['ParentPatient'])

            #self.assertEqual(instanceLastUpdate1, instanceLastUpdate2)
            self.assertNotEqual(seriesLastUpdate1, seriesLastUpdate2)
            self.assertNotEqual(studyLastUpdate1, studyLastUpdate2)
            self.assertNotEqual(patientLastUpdate1, patientLastUpdate2)

    def test_pixel_data_vr(self):
        def Check(path, hasPixelData, hasMetadata, expectedVR):
            i = UploadInstance(_REMOTE, path) ['ID']
            m = DoGet(_REMOTE, '/instances/%s/metadata?expand' % i)
            if hasMetadata:
                self.assertTrue('PixelDataVR' in m)
                self.assertEqual(expectedVR, m['PixelDataVR'])
            else:
                self.assertFalse('PixelDataVR' in m)

            if hasPixelData:
                self.assertTrue('PixelDataOffset' in m)
                j = DoGet(_REMOTE, '/instances/%s/file?expand' % i, headers = {
                    'Accept': 'application/dicom+json'
                    })
                self.assertEqual(expectedVR, j['7FE00010']['vr'])

        if IsOrthancVersionAbove(_REMOTE, 1, 12, 1):
            # File without pixel data
            Check('MarekLatin2.dcm', False, False, None)

            # Those files are badly formatted, and should be 'OB'
            # according to the DICOM standard => medata is present
            Check('Issue143.dcm', True, True, 'OW')              # Little Endian Explicit, 8bpp
            Check('KarstenHilbertRF.dcm', True, True, 'OW')      # Little Endian Explicit, 8bpp
            Check('PilatesArgenturGEUltrasoundOsiriX.dcm', True, True, 'OW')  # Little Endian Explicit, 8bpp

            # Those files are formatted as expected
            Check('ColorTestMalaterre.dcm', True, False, 'OW')   # Implicit Little Endian, 8bpp
            Check('Issue94.dcm', True, False, 'OW')              # Implicit Little Endian, 16bpp
            Check('TransferSyntaxes/1.2.840.10008.1.2.1.dcm', True, False, 'OB') # Explicit Little Endian, 8bpp
            Check('Phenix/IM-0001-0001.dcm', True, False, 'OW')  # Explicit Little Endian, 16bpp
            Check('TransferSyntaxes/1.2.840.10008.1.2.2.dcm', True, False, 'OB') # Explicit Big Endian, 8bpp
            Check('TransferSyntaxes/1.2.840.10008.1.2.4.50.dcm', True, False, 'OB')  # JPEG
            Check('Knee/T1/IM-0001-0001.dcm', True, False, 'OB') # JPEG2k

    def test_encapsulate_stl(self):
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 1):
            stl = b'Hello, world'

            i = DoPost(_REMOTE, '/tools/create-dicom', json.dumps({
                'Content' : 'data:model/stl;base64,%s' % base64.b64encode(stl).decode(),
                'Force' : True,
                'Tags' : {
                    'PatientName' : 'Jodogne'
                }
            })) ['ID']

            tags = DoGet(_REMOTE, '/instances/%s/tags?simplify' % i)
            self.assertEqual('Jodogne', tags['PatientName'])
            self.assertEqual('M3D', tags['Modality'])
            self.assertEqual('model/stl', tags['MIMETypeOfEncapsulatedDocument'])
            self.assertEqual('1.2.840.10008.5.1.4.1.1.104.3', tags['SOPClassUID'])

            i = DoPost(_REMOTE, '/tools/create-dicom', json.dumps({
                'Content' : 'data:model/mtl;base64,%s' % base64.b64encode(stl).decode(),
                'Tags' : {}
            })) ['ID']

            tags = DoGet(_REMOTE, '/instances/%s/tags?simplify' % i)
            self.assertFalse('PatientName' in tags)
            self.assertEqual('M3D', tags['Modality'])
            self.assertEqual('model/mtl', tags['MIMETypeOfEncapsulatedDocument'])
            self.assertEqual('1.2.840.10008.5.1.4.1.1.104.5', tags['SOPClassUID'])

            i = DoPost(_REMOTE, '/tools/create-dicom', json.dumps({
                'Content' : 'data:model/obj;base64,%s' % base64.b64encode(stl).decode(),
                'Tags' : {}
            })) ['ID']

            tags = DoGet(_REMOTE, '/instances/%s/tags?simplify' % i)
            self.assertFalse('PatientName' in tags)
            self.assertEqual('M3D', tags['Modality'])
            self.assertEqual('model/obj', tags['MIMETypeOfEncapsulatedDocument'])
            self.assertEqual('1.2.840.10008.5.1.4.1.1.104.4', tags['SOPClassUID'])


    def test_error_codes_content_type(self):

        # from 1.12.2, check that a ContentType header is included in errors with an error description (ex: 404)
        (headers, body) = DoGetRaw(_REMOTE, '/rnm94%3Cscript%3Ealert(1)%3C/script%3Ejdtkc/explorer.html')
        self.assertEqual('404', headers['status'])

        if IsOrthancVersionAbove(_REMOTE, 1, 12, 2):
            self.assertEqual('text/plain', headers['content-type'])

        (headers, body) = DoPutRaw(_REMOTE, '/system', 'hello')
        self.assertEqual('405', headers['status'])
        # when there is no body, there is no content-type
        self.assertNotIn('content-type', headers)

        # responses with bodies contain x-content-type-options
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 2):
            (headers, body) = DoGetRaw(_REMOTE, '/system')
            self.assertIn('nosniff', headers['x-content-type-options'])

    def test_modify_with_labels(self):

        if DoGet(_REMOTE, '/system')['ApiVersion'] < 23 or not DoGet(_REMOTE, '/system')['HasLabels']:
            return

        def UploadAndLabel(testId):
            DropOrthanc(_REMOTE)
            
            u = UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-0001.dcm')
            studyId = u["ParentStudy"]
            seriesId = u["ParentSeries"]
            patientId = u["ParentPatient"]
            instanceId = u["ID"]

            if testId == 2: # multi instance study
                UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-0002.dcm')
                UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm')
                UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0002.dcm')

            # add a label to the study before modification
            DoPut(_REMOTE, '/patients/%s/labels/label-patient' % patientId)
            DoPut(_REMOTE, '/studies/%s/labels/label-study' % studyId)
            DoPut(_REMOTE, '/series/%s/labels/label-series' % seriesId)
            DoPut(_REMOTE, '/instances/%s/labels/label-instance' % instanceId)

            originalPatient = DoGet(_REMOTE, '/patients/%s' % patientId)
            self.assertEqual(1, len(originalPatient["Labels"]))
            self.assertIn('label-patient', originalPatient["Labels"])

            originalStudy = DoGet(_REMOTE, '/studies/%s' % studyId)
            self.assertEqual(1, len(originalStudy["Labels"]))
            self.assertIn('label-study', originalStudy["Labels"])

            originalSeries = DoGet(_REMOTE, '/series/%s' % seriesId)
            self.assertEqual(1, len(originalSeries["Labels"]))
            self.assertIn('label-series', originalSeries["Labels"])

            originalInstance = DoGet(_REMOTE, '/instances/%s' % instanceId)
            self.assertEqual(1, len(originalInstance["Labels"]))
            self.assertIn('label-instance', originalInstance["Labels"])

            return originalPatient, originalStudy, originalSeries, originalInstance


        for testId in range(1, 2): #test with a single instance study and a multi instance study

            originalPatient, originalStudy, originalSeries, originalInstance = UploadAndLabel(testId)

            # modify a study in place with no label field in the payload (default behavior before 1.12.3)
            DoPost(_REMOTE, '/studies/%s/modify' % originalStudy['ID'], {
                    'Keep' : ['StudyInstanceUID', 'SeriesInstanceUID', 'SOPInstanceUID'],
                    'Replace' : {
                        'PatientName': 'modified'
                    },
                    'Force': True
                })

            # with no options, all resources lose their labels during the modification
            modifiedStudy = DoGet(_REMOTE, '/studies/%s' % originalStudy['ID'])
            self.assertEqual(0, len(modifiedStudy["Labels"]))
            self.assertEqual('modified', modifiedStudy["PatientMainDicomTags"]["PatientName"])

        if IsOrthancVersionAbove(_REMOTE, 1, 12, 3) and DoGet(_REMOTE, '/system')['ApiVersion'] >= 23 and DoGet(_REMOTE, '/system')['HasLabels']:
            for testId in range(1, 2): #test with a single instance study and a multi instance study

                originalPatient, originalStudy, originalSeries, originalInstance = UploadAndLabel(testId)

                # modify a study in place with no label field in the payload (default behavior before 1.12.3)
                DoPost(_REMOTE, '/studies/%s/modify' % originalStudy['ID'], {
                        'Keep' : ['StudyInstanceUID', 'SeriesInstanceUID', 'SOPInstanceUID'],
                        'Replace' : {
                            'PatientName': 'modified2'
                        },
                        'Force': True,
                        'KeepLabels': True
                    })

                # now, each resource level shall have kept its labels

                modifiedInstance = DoGet(_REMOTE, '/instances/%s' % originalInstance['ID'])
                self.assertEqual(1, len(modifiedInstance["Labels"]))
                self.assertIn('label-instance', modifiedInstance["Labels"])

                modifiedSeries = DoGet(_REMOTE, '/series/%s' % originalSeries['ID'])
                self.assertEqual(1, len(modifiedSeries["Labels"]))
                self.assertIn('label-series', modifiedSeries["Labels"])

                modifiedStudy = DoGet(_REMOTE, '/studies/%s' % originalStudy['ID'])
                self.assertEqual(1, len(modifiedStudy["Labels"]))
                self.assertIn('label-study', modifiedStudy["Labels"])
                self.assertEqual('modified2', modifiedStudy["PatientMainDicomTags"]["PatientName"])

                modifiedPatient = DoGet(_REMOTE, '/patients/%s' % originalPatient['ID'])
                self.assertEqual(1, len(modifiedPatient["Labels"]))
                self.assertIn('label-patient', modifiedPatient["Labels"])

    def test_findscu_group_length(self):
        UploadInstance(_REMOTE, 'Comunix/Ct/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Comunix/Pet/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Comunix/Pet/IM-0001-0002.dcm')

        i = CallFindScu([ '-k', '0008,0052=PATIENT', '-k', '0008,0000=22' ])  # GE like C-Find that includes group-length
        # 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']))
            # if IsOrthancVersionAbove(_REMOTE, 1, 12, 5):
            #     self.assertEqual(8, len(instance['MainDicomTags']))  # since we have added SOPClassUID
            # else:
            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)

        # this is equivalent to calling /dicom-web/series?PatientID=*
        # when there are no StudyInstanceUID specified, the DICOM-Web standard says we should retrieve all Series and Study tags
        # which includes e.g. NumberOfStudyRelatedInstances (0020,1208) that is a computed tag at study level
        c = GetStorageAccessesCount(_REMOTE)
        a = DoPost(_REMOTE, '/tools/find', {
            "Query": { "PatientID": "*"},
            "Level": "Series",
            "Expand": True,
            "RequestedTags": [
                "0008,0020",
                "0008,0030",
                "0008,0050",
                "0008,0056",
                "0008,0060",
                "0008,0061",
                "0008,0090",
                "0008,0201",
                "0008,103e",
                "0010,0010",
                "0010,0020",
                "0010,0030",
                "0010,0040",
                "0020,000d",
                "0020,000e",
                "0020,0010",
                "0020,0011",
                "0020,1206",
                "0020,1208",
                "0020,1209",
                "0040,0244",
                "0040,0245",
                "0040,0275"
            ]
        })        
        # Up to now, no versions of Orthanc ever returned this value but we keep the test for later (let's wait for someone to comlain !)
        self.assertNotIn("NumberOfStudyRelatedInstances", a[0]["RequestedTags"])
        if HasExtendedFind(_REMOTE):
            self.assertEqual(c, GetStorageAccessesCount(_REMOTE))  # the disk shall not have been accessed

        c = GetStorageAccessesCount(_REMOTE)
        a = DoPost(_REMOTE, '/tools/find', {
            "Query": { "PatientID": "*"},
            "Level": "Study",
            "Expand": True,
            "RequestedTags": [
                "SOPClassesInStudy",
                "NumberOfStudyRelatedInstances"
            ]
        })        
        # Up to now, no versions of Orthanc ever returned this value but we keep the test for later (let's wait for someone to comlain !)
        self.assertIn("SOPClassesInStudy", a[0]["RequestedTags"])
        self.assertIn("NumberOfStudyRelatedInstances", a[0]["RequestedTags"])
        if HasExtendedFind(_REMOTE):
            self.assertEqual(c, GetStorageAccessesCount(_REMOTE))  # the disk shall not have been accessed



    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'

        if IsOrthancVersionAbove(_REMOTE, 1, 12, 4):     # the old syntax is still required for the upgrade/downgrade PG tests
            a = DoGet(_REMOTE, '/instances/%s?requested-tags=0008,0056' % instance)
        else:
            a = DoGet(_REMOTE, '/instances/%s?requestedTags=0008,0056' % instance)
        
        self.assertEqual(1, len(a['RequestedTags']))
        self.assertEqual('ONLINE', a['RequestedTags']['InstanceAvailability'])

        if IsOrthancVersionAbove(_REMOTE, 1, 12, 4):
            a = DoGet(_REMOTE, '/series/%s?requested-tags=0020,1209' % series)
        else:
            a = DoGet(_REMOTE, '/series/%s?requestedTags=0020,1209' % series)
        self.assertEqual(1, len(a['RequestedTags']))
        self.assertEqual(2, int(a['RequestedTags']['NumberOfSeriesRelatedInstances']))

        if IsOrthancVersionAbove(_REMOTE, 1, 12, 4):
            a = DoGet(_REMOTE, '/studies/%s?requested-tags=0008,0061;0008,0062;0020,1206;0020,1208' % study)
        else:
            a = DoGet(_REMOTE, '/studies/%s?requestedTags=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']))

        if IsOrthancVersionAbove(_REMOTE, 1, 12, 4):
            a = DoGet(_REMOTE, '/patients/%s?requested-tags=0020,1200;0020,1202;0020,1204' % patient)
        else:
            a = DoGet(_REMOTE, '/studies/%s?requestedTags=0020,1200;0020,1202;0020,1204' % study)
        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'])


    def test_extended_find_order_by(self):
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 5) and HasExtendedFind(_REMOTE):
            # Upload 12 instances
            for i in range(3):
                r = UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-000%d.dcm' % (i + 1))
                DoPut(_REMOTE, '/instances/%s/metadata/1234' % r['ID'], '%f' % (10.0 + 0.1 * i))
                r = UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-000%d.dcm' % (i + 1))
                DoPut(_REMOTE, '/instances/%s/metadata/1234' % r['ID'], '%f' % (20.0 + 0.1 * i))
                r = UploadInstance(_REMOTE, 'Knee/T1/IM-0001-000%d.dcm' % (i + 1))
                DoPut(_REMOTE, '/instances/%s/metadata/1234' % r['ID'], '%f' % (30.0 + 0.1 * i))
                r = UploadInstance(_REMOTE, 'Knee/T2/IM-0001-000%d.dcm' % (i + 1))
                DoPut(_REMOTE, '/instances/%s/metadata/1234' % r['ID'], '%f' % (40.0 + 0.1 * i))

            kneeT2SeriesId = 'bbf7a453-0d34251a-03663b55-46bb31b9-ffd74c59'
            kneeT1SeriesId = '6de73705-c4e65c1b-9d9ea1b5-cabcd8e7-f15e4285'
            brainixFlairSeriesId = '1e2c125c-411b8e86-3f4fe68e-a7584dd3-c6da78f0'
            brainixEpiSeriesId = '2ac1316d-3e432022-62eabff2-c59f5475-9b1ac3f8'
            DoPut(_REMOTE, '/series/%s/metadata/my-metadata' % kneeT2SeriesId, 'kneeT2')
            DoPut(_REMOTE, '/series/%s/metadata/my-metadata' % kneeT1SeriesId, 'kneeT1')
            DoPut(_REMOTE, '/series/%s/metadata/my-metadata' % brainixFlairSeriesId, 'brainixFlair')
            DoPut(_REMOTE, '/series/%s/metadata/my-metadata' % brainixEpiSeriesId, 'brainixEpi')

            # order by resource tag
            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Study',
                                                'Expand': True,
                                                'Query' : { 
                                                    'PatientName' : '*'
                                                },
                                                'OrderBy' : [
                                                    {
                                                        'Type': 'DicomTag',
                                                        'Key': 'PatientName',
                                                        'Direction': 'ASC'
                                                    }
                                                ]
                                                })
            self.assertEqual(2, len(a))
            self.assertEqual("BRAINIX", a[0]['PatientMainDicomTags']['PatientName'])

            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Study',
                                                'Expand': True,
                                                'Query' : { 
                                                    'PatientName' : '*'
                                                },
                                                'OrderBy' : [
                                                    {
                                                        'Type': 'DicomTag',
                                                        'Key': 'PatientName',
                                                        'Direction': 'DESC'
                                                    }
                                                ]
                                                })

            self.assertEqual("BRAINIX", a[1]['PatientMainDicomTags']['PatientName'])

            # order by parent tag
            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
                                                'Expand': False,
                                                'Query' : { 
                                                    'SeriesDescription' : '*'
                                                },
                                                'OrderBy' : [
                                                    {
                                                        'Type': 'DicomTag',
                                                        'Key': 'StudyDate',
                                                        'Direction': 'ASC'
                                                    }
                                                ]
                                                })
            # knee StudyDate = 20080819
            # brainix StudyDate = 20061201
            self.assertEqual(4, len(a))
            self.assertTrue(a[0] == brainixEpiSeriesId or a[0] == brainixFlairSeriesId)
            self.assertTrue(a[3] == kneeT1SeriesId or a[3] == kneeT2SeriesId)

            # order by parent tag and resource tag
            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
                                                'Expand': False,
                                                'Query' : { 
                                                    'SeriesDescription' : '*'
                                                },
                                                'OrderBy' : [
                                                    {
                                                        'Type': 'DicomTag',
                                                        'Key': 'StudyDate',
                                                        'Direction': 'ASC'
                                                    },
                                                    {
                                                        'Type': 'DicomTag',
                                                        'Key': 'SeriesTime',
                                                        'Direction': 'ASC'
                                                    }
                                                ]
                                                })
            # knee StudyDate = 20080819
            # brainix StudyDate = 20061201
            self.assertEqual(4, len(a))
            self.assertEqual(brainixFlairSeriesId, a[0])
            self.assertEqual(brainixEpiSeriesId, a[1])
            self.assertEqual(kneeT1SeriesId, a[2])
            self.assertEqual(kneeT2SeriesId, a[3])

            # order by grandparent tag and resource tag
            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
                                                'Expand': False,
                                                'Query' : { 
                                                    'SeriesDescription' : '*'
                                                },
                                                'OrderBy' : [
                                                    {
                                                        'Type': 'DicomTag',
                                                        'Key': 'PatientBirthDate',
                                                        'Direction': 'ASC'
                                                    },
                                                    {
                                                        'Type': 'DicomTag',
                                                        'Key': 'SeriesTime',
                                                        'Direction': 'ASC'
                                                    }
                                                ]
                                                })
            # knee PatientBirthDate = 20080822
            # brainix PatientBirthDate = 19490301
            self.assertEqual(4, len(a))
            self.assertEqual(brainixFlairSeriesId, a[0])
            self.assertEqual(brainixEpiSeriesId, a[1])
            self.assertEqual(kneeT1SeriesId, a[2])
            self.assertEqual(kneeT2SeriesId, a[3])

            # order by grandgrandparent tag and resource tag
            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Instance',
                                                'Expand': True,
                                                'Query' : { 
                                                },
                                                'OrderBy' : [
                                                    {
                                                        'Type': 'DicomTag',
                                                        'Key': 'PatientBirthDate',
                                                        'Direction': 'ASC'
                                                    },
                                                    {
                                                        'Type': 'DicomTagAsInt',
                                                        'Key': 'InstanceNumber',
                                                        'Direction': 'ASC'
                                                    },
                                                    {
                                                        'Type': 'DicomTag',
                                                        'Key': 'SeriesTime',
                                                        'Direction': 'ASC'
                                                    }
                                                ],
                                                'RequestedTags' : ['PatientBirthDate', 'InstanceNumber', 'SeriesTime']
                                                })
            self.assertEqual(12, len(a))
            for i in range(1, len(a)-1):
                self.assertTrue(a[i-1]['RequestedTags']['PatientBirthDate'] <= a[i]['RequestedTags']['PatientBirthDate'])
                if a[i-1]['RequestedTags']['PatientBirthDate'] == a[i]['RequestedTags']['PatientBirthDate']:
                    self.assertTrue(int(a[i-1]['RequestedTags']['InstanceNumber']) <= int(a[i]['RequestedTags']['InstanceNumber']))
                    if a[i-1]['RequestedTags']['InstanceNumber'] == a[i]['RequestedTags']['InstanceNumber']:
                        self.assertTrue(a[i-1]['RequestedTags']['SeriesTime'] <= a[i]['RequestedTags']['SeriesTime'])    

            # order by grandgrandparent tag and resource tag (2)
            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Instance',
                                                'Expand': True,
                                                'Query' : { 
                                                },
                                                'OrderBy' : [
                                                    {
                                                        'Type': 'DicomTagAsInt',
                                                        'Key': 'InstanceNumber',
                                                        'Direction': 'DESC'
                                                    },
                                                    {
                                                        'Type': 'DicomTag',
                                                        'Key': 'PatientBirthDate',
                                                        'Direction': 'ASC'
                                                    },
                                                    {
                                                        'Type': 'DicomTag',
                                                        'Key': 'SeriesTime',
                                                        'Direction': 'ASC'
                                                    }
                                                ],
                                                'RequestedTags' : ['InstanceNumber', 'PatientBirthDate', 'SeriesTime' ]
                                                })
            self.assertEqual(12, len(a))
            for i in range(1, len(a)-1):
                self.assertTrue(int(a[i-1]['RequestedTags']['InstanceNumber']) >= int(a[i]['RequestedTags']['InstanceNumber']))
                if a[i-1]['RequestedTags']['InstanceNumber'] == a[i]['RequestedTags']['InstanceNumber']:
                    self.assertTrue(a[i-1]['RequestedTags']['PatientBirthDate'] <= a[i]['RequestedTags']['PatientBirthDate'])
                    if a[i-1]['RequestedTags']['PatientBirthDate'] == a[i]['RequestedTags']['PatientBirthDate']:
                        self.assertTrue(a[i-1]['RequestedTags']['SeriesTime'] <= a[i]['RequestedTags']['SeriesTime'])    

            # order by resource tag on a tag that is missing in one of the resources -> it should be listed
            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
                                                 'Expand': False,
                                                 'Query' : { 
                                                },
                                                
                                                'OrderBy' : [
                                                    {
                                                        'Type': 'DicomTag',
                                                        'Key': 'BodyPartExamined',  # in Knee but not in Brainix  => Brainix is last because NULL are pushed at the end
                                                        'Direction': 'ASC'
                                                    }
                                                ]
                                                })
            self.assertTrue(a[0] == kneeT1SeriesId or a[0] == kneeT2SeriesId)
            self.assertTrue(a[3] == brainixEpiSeriesId or a[3] == brainixFlairSeriesId)

            # order by metadata
            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
                                                 'Query' : { 
                                                    'SeriesDescription' : '*'
                                                },
                                                'OrderBy' : [
                                                    {
                                                        'Type': 'Metadata',
                                                        'Key': 'my-metadata',
                                                        'Direction': 'ASC'
                                                    }
                                                ]
                                                })
            self.assertEqual(brainixEpiSeriesId, a[0])
            self.assertEqual(brainixFlairSeriesId, a[1])
            self.assertEqual(kneeT1SeriesId, a[2])
            self.assertEqual(kneeT2SeriesId, a[3])

            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
                                                 'Query' : { 
                                                    'SeriesDescription' : '*'
                                                },
                                                'OrderBy' : [
                                                    {
                                                        'Type': 'Metadata',
                                                        'Key': 'my-metadata',
                                                        'Direction': 'DESC'
                                                    }
                                                ]
                                                })
            self.assertEqual(brainixEpiSeriesId, a[3])
            self.assertEqual(brainixFlairSeriesId, a[2])
            self.assertEqual(kneeT1SeriesId, a[1])
            self.assertEqual(kneeT2SeriesId, a[0])

            # combined ordering (DicomTag + metadata)
            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
                                                 'Query' : { 
                                                    'SeriesDescription' : '*'
                                                },
                                                'OrderBy' : [
                                                    {
                                                        'Type': 'DicomTag',
                                                        'Key': 'PatientName',
                                                        'Direction': 'ASC'
                                                    },
                                                    {
                                                        'Type': 'Metadata',
                                                        'Key': 'my-metadata',
                                                        'Direction': 'DESC'
                                                    }
                                                ]
                                                })
            self.assertEqual(brainixFlairSeriesId, a[0])
            self.assertEqual(brainixEpiSeriesId, a[1])
            self.assertEqual(kneeT2SeriesId, a[2])
            self.assertEqual(kneeT1SeriesId, a[3])


            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Instance',
                                                'ResponseContent': ['Metadata', 'RequestedTags'],
                                                'Query' : { 
                                                },
                                                'OrderBy' : [
                                                    {
                                                        'Type': 'MetadataAsFloat',
                                                        'Key': '1234',
                                                        'Direction': 'DESC'
                                                    }
                                                ],
                                                'RequestedTags' : ['SeriesDescription']
                                                })
            self.assertEqual(12, len(a))
            for i in range(0, 2):
                self.assertEqual("T2W_TSE", a[i]['RequestedTags']['SeriesDescription'])
            self.assertAlmostEqual(40.2, float(a[0]['Metadata']['1234']))
            self.assertAlmostEqual(40.0, float(a[2]['Metadata']['1234']))

            for i in range(3, 5):
                self.assertEqual("T1W_aTSE", a[i]['RequestedTags']['SeriesDescription'])

            for i in range(6, 8):
                self.assertEqual("T2W/FE-EPI", a[i]['RequestedTags']['SeriesDescription'])

            for i in range(9, 11):
                self.assertEqual("sT2W/FLAIR", a[i]['RequestedTags']['SeriesDescription'])


    def test_extended_find_parent(self):
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 5) and HasExtendedFind(_REMOTE):
            # Upload 12 instances
            for i in range(3):
                UploadInstance(_REMOTE, 'Knee/T1/IM-0001-000%d.dcm' % (i + 1))
                UploadInstance(_REMOTE, 'Knee/T2/IM-0001-000%d.dcm' % (i + 1))
                UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-000%d.dcm' % (i + 1))
                UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-000%d.dcm' % (i + 1))

            kneeT2SeriesId = 'bbf7a453-0d34251a-03663b55-46bb31b9-ffd74c59'
            kneeT1SeriesId = '6de73705-c4e65c1b-9d9ea1b5-cabcd8e7-f15e4285'
            kneeStudyId = '0a9b3153-2512774b-2d9580de-1fc3dcf6-3bd83918'
            kneePatientId = 'ca29faea-b6a0e17f-067743a1-8b778011-a48b2a17'

            # retrieve only the series from a study
            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
                                                 'Query' : { 
                                                    'SeriesDescription' : 'T*'
                                                },
                                                'ParentStudy' : kneeStudyId
                                                })

            self.assertEqual(2, len(a))
            self.assertTrue(a[0] == kneeT1SeriesId or a[0] == kneeT2SeriesId)

            # retrieve only the series from a patient
            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Series',
                                                 'Query' : { 
                                                    'SeriesDescription' : 'T*'
                                                },
                                                'ParentPatient' : kneePatientId
                                                })

            self.assertEqual(2, len(a))
            self.assertTrue(a[0] == kneeT1SeriesId or a[0] == kneeT2SeriesId)

            # retrieve only the instances from a patient
            a = DoPost(_REMOTE, '/tools/find', { 'Level' : 'Instance',
                                                 'Query' : { 
                                                    'SeriesDescription' : 'T*'
                                                },
                                                'ParentPatient' : kneePatientId
                                                })

            self.assertEqual(6, len(a))

            # same query in count-resources
            a = DoPost(_REMOTE, '/tools/count-resources', { 'Level' : 'Instance',
                                                 'Query' : { 
                                                    'SeriesDescription' : 'T*'
                                                },
                                                'ParentPatient' : kneePatientId
                                                })

            self.assertEqual(6, a["Count"])


    def test_extended_find_filter_metadata(self):
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 5) and HasExtendedFind(_REMOTE):
            # Upload 12 instances
            for i in range(3):
                UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-000%d.dcm' % (i + 1))
                UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-000%d.dcm' % (i + 1))
                UploadInstance(_REMOTE, 'Knee/T1/IM-0001-000%d.dcm' % (i + 1))
                UploadInstance(_REMOTE, 'Knee/T2/IM-0001-000%d.dcm' % (i + 1))

            kneeT2SeriesId = 'bbf7a453-0d34251a-03663b55-46bb31b9-ffd74c59'
            kneeT1SeriesId = '6de73705-c4e65c1b-9d9ea1b5-cabcd8e7-f15e4285'
            brainixFlairSeriesId = '1e2c125c-411b8e86-3f4fe68e-a7584dd3-c6da78f0'
            brainixEpiSeriesId = '2ac1316d-3e432022-62eabff2-c59f5475-9b1ac3f8'
            DoPut(_REMOTE, '/series/%s/metadata/my-metadata' % kneeT2SeriesId, 'kneeT2')
            DoPut(_REMOTE, '/series/%s/metadata/my-metadata' % kneeT1SeriesId, 'kneeT1')
            DoPut(_REMOTE, '/series/%s/metadata/my-metadata' % brainixFlairSeriesId, 'brainixFlair')
            DoPut(_REMOTE, '/series/%s/metadata/my-metadata' % brainixEpiSeriesId, 'brainixEpi')

            # filter on metadata
            q = {
                'Level' : 'Series',
                'Query' : { 
                    'SeriesDescription' : 'T*'
                },
                'MetadataQuery' : {
                    'my-metadata': '*2*'
                }
            }
            a = DoPost(_REMOTE, '/tools/find', q)

            self.assertEqual(1, len(a))
            self.assertEqual(kneeT2SeriesId, a[0])

            a = DoPost(_REMOTE, '/tools/count-resources', q)
            self.assertEqual(1, a["Count"])



    def test_extended_find_expand(self):
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 5):
            UploadInstance(_REMOTE, 'Knee/T2/IM-0001-0001.dcm')

            a = DoPost(_REMOTE, '/tools/find', {    'Level' : 'Series',
                                                    'Query' : { 
                                                        'SeriesDescription' : 'T*'
                                                    },
                                                    'Expand': True,
                                                    'RequestedTags': ['StudyDate']
                                                    })

            # backward compat for Expand = True
            self.assertIn('ExpectedNumberOfInstances', a[0])
            self.assertIn('ID', a[0])
            self.assertIn('Instances', a[0])
            self.assertIn('Labels', a[0])
            self.assertIn('LastUpdate', a[0])
            self.assertIn('MainDicomTags', a[0])
            self.assertIn('ParentStudy', a[0])
            self.assertIn('RequestedTags', a[0])
            self.assertIn('Status', a[0])
            self.assertIn('Type', a[0])
            self.assertIn('IsStable', a[0])
            self.assertNotIn('Attachments', a[0])
            self.assertNotIn('Metadata', a[0])


            a = DoPost(_REMOTE, '/tools/find', {    'Level' : 'Series',
                                                    'Query' : { 
                                                        'SeriesDescription' : 'T*'
                                                    },
                                                    'ResponseContent': ["MainDicomTags"],
                                                    'RequestedTags': ['StudyDate']
                                                    })

            self.assertIn('ID', a[0])            # the ID is always in the response
            self.assertIn('Type', a[0])          # the Type is always in the response
            self.assertIn('RequestedTags', a[0]) # the RequestedTags are always in the response as soon as you have requested them
            self.assertIn('MainDicomTags', a[0])
            self.assertNotIn('ExpectedNumberOfInstances', a[0])
            self.assertNotIn('Instances', a[0])
            self.assertNotIn('Labels', a[0])
            self.assertNotIn('LastUpdate', a[0])
            self.assertNotIn('ParentStudy', a[0])
            self.assertNotIn('Status', a[0])
            self.assertNotIn('IsStable', a[0])
            self.assertNotIn('Attachments', a[0])
            self.assertNotIn('Metadata', a[0])


            a = DoPost(_REMOTE, '/tools/find', {    'Level' : 'Series',
                                                    'Query' : { 
                                                        'SeriesDescription' : 'T*'
                                                    },
                                                    'ResponseContent': ["MainDicomTags", "Children", "Parent", "IsStable", "Status", "Labels", "Metadata"],
                                                    'RequestedTags': ['StudyDate']
                                                    })

            self.assertIn('ID', a[0])            # the ID is always in the response
            self.assertIn('Type', a[0])          # the Type is always in the response
            self.assertIn('RequestedTags', a[0]) # the RequestedTags are always in the response as soon as you have requested them
            self.assertIn('MainDicomTags', a[0])
            self.assertIn('Metadata', a[0])
            self.assertIn('LastUpdate', a[0]['Metadata'])
            self.assertIn('Instances', a[0])
            self.assertIn('Labels', a[0])
            self.assertIn('ParentStudy', a[0])
            self.assertIn('Status', a[0])
            self.assertIn('IsStable', a[0])
            self.assertNotIn('Attachments', a[0])


            a = DoPost(_REMOTE, '/tools/find', {    'Level' : 'Instances',
                                                    'Query' : { 
                                                        'SeriesDescription' : 'T*'
                                                    },
                                                    'Expand': True,
                                                    'RequestedTags': ['StudyDate']
                                                    })

            # backward compat for Expand = True at instance level
            self.assertIn('ID', a[0])            # the ID is always in the response
            self.assertIn('Type', a[0])          # the Type is always in the response
            self.assertIn('RequestedTags', a[0]) # the RequestedTags are always in the response as soon as you have requested them
            self.assertIn('FileSize', a[0])
            self.assertIn('FileUuid', a[0])
            self.assertIn('IndexInSeries', a[0])
            self.assertIn('ParentSeries', a[0])
            self.assertIn('Labels', a[0])
            self.assertNotIn('Attachments', a[0])
            self.assertNotIn('Metadata', a[0])

            a = DoPost(_REMOTE, '/tools/find', {    'Level' : 'Instances',
                                                    'Query' : { 
                                                        'SeriesDescription' : 'T*'
                                                    },
                                                    'ResponseContent' : ['Attachments'],
                                                    'RequestedTags': ['StudyDate']
                                                    })

            self.assertIn('ID', a[0])            # the ID is always in the response
            self.assertIn('Type', a[0])          # the Type is always in the response
            self.assertIn('RequestedTags', a[0]) # the RequestedTags are always in the response as soon as you have requested them
            self.assertIn('Attachments', a[0])
            self.assertIn('Uuid', a[0]['Attachments'][0])
            self.assertIn('UncompressedSize', a[0]['Attachments'][0])


            # 'internal check': make sure we get the SOPClassUID even when we do not request the Metadata
            a = DoPost(_REMOTE, '/tools/find', {    'Level' : 'Instances',
                                                    'Query' : { 
                                                        'SeriesDescription' : 'T*'
                                                    },
                                                    'ResponseContent' : [],
                                                    'RequestedTags': ['SOPClassUID']
                                                    })

            self.assertIn('ID', a[0])            # the ID is always in the response
            self.assertIn('Type', a[0])          # the Type is always in the response
            self.assertIn('RequestedTags', a[0]) # the RequestedTags are always in the response as soon as you have requested them
            self.assertIn('SOPClassUID', a[0]['RequestedTags'])


    def test_extended_find_full(self):
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 5) and HasExtendedFind(_REMOTE):
            # Upload 12 instances
            for i in range(3):
                UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-000%d.dcm' % (i + 1))
                UploadInstance(_REMOTE, 'Brainix/Epi/IM-0001-000%d.dcm' % (i + 1))
                UploadInstance(_REMOTE, 'Knee/T1/IM-0001-000%d.dcm' % (i + 1))
                UploadInstance(_REMOTE, 'Knee/T2/IM-0001-000%d.dcm' % (i + 1))

            kneeT2SeriesId = 'bbf7a453-0d34251a-03663b55-46bb31b9-ffd74c59'
            kneeT1SeriesId = '6de73705-c4e65c1b-9d9ea1b5-cabcd8e7-f15e4285'
            brainixFlairSeriesId = '1e2c125c-411b8e86-3f4fe68e-a7584dd3-c6da78f0'
            brainixEpiSeriesId = '2ac1316d-3e432022-62eabff2-c59f5475-9b1ac3f8'
            kneeStudyId = '0a9b3153-2512774b-2d9580de-1fc3dcf6-3bd83918'
            kneePatientId = 'ca29faea-b6a0e17f-067743a1-8b778011-a48b2a17'
            DoPut(_REMOTE, '/series/%s/metadata/my-metadata' % kneeT2SeriesId, 'kneeT2')
            DoPut(_REMOTE, '/series/%s/metadata/my-metadata' % kneeT1SeriesId, 'kneeT1')
            DoPut(_REMOTE, '/series/%s/metadata/my-metadata' % brainixFlairSeriesId, 'brainixFlair')
            DoPut(_REMOTE, '/series/%s/metadata/my-metadata' % brainixEpiSeriesId, 'brainixEpi')

            a = DoPost(_REMOTE, '/tools/find', {    'Level' : 'Series',
                                                    'Query' : { 
                                                        'PatientName' : '*'
                                                    },
                                                    'RequestedTags': ['StudyDate'],
                                                    'MetadataQuery' : {
                                                        'my-metadata': "*nee*"
                                                    },
                                                    'OrderBy' : [
                                                        {
                                                            'Type': 'DicomTag',
                                                            'Key': 'SeriesDescription',
                                                            'Direction': 'ASC'
                                                        },
                                                        {
                                                            'Type': 'Metadata',
                                                            'Key': 'my-metadata',
                                                            'Direction': 'DESC'
                                                        }
                                                    ],
                                                    'ParentPatient': kneePatientId,
                                                    'ResponseContent' : ['Parent', 'Children', 'MainDicomTags', 'Metadata']
                                                    })

            self.assertEqual(2, len(a))
            self.assertEqual(kneeT1SeriesId, a[0]['ID'])
            self.assertEqual(kneeT2SeriesId, a[1]['ID'])
            self.assertEqual(kneeStudyId, a[0]['ParentStudy'])
            self.assertEqual(3, len(a[0]['Instances']))
            self.assertEqual('', a[0]['Metadata']['RemoteAET'])

    def test_pagination_and_limit_find_results(self):
        # LimitFindInstances is set to 20
        # LimitFindResults is set to 10

        # Upload 27 instances from KNIX
        UploadFolder(_REMOTE, 'Knix/Loc')

        # Upload 13 other series
        UploadInstance(_REMOTE, 'DummyCT.dcm')
        UploadInstance(_REMOTE, 'Phenix/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Implicit-vr-us-palette.dcm')
        UploadInstance(_REMOTE, 'Multiframe.dcm')
        UploadInstance(_REMOTE, 'Brainix/Flair/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Knee/T1/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Knee/T2/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'PrivateTags.dcm')
        UploadInstance(_REMOTE, 'PrivateMDNTags.dcm')
        UploadInstance(_REMOTE, 'Comunix/Ct/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Comunix/Pet/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Beaufix/IM-0001-0001.dcm')
        UploadInstance(_REMOTE, 'Encodings/Lena-ascii.dcm')

        self.assertEqual(14, len(DoGet(_REMOTE, '/series')))


        knixInstancesNoLimit = DoPost(_REMOTE, '/tools/find', {    
                                                'Level' : 'Instances',
                                                'Query' : { 
                                                    'PatientName' : 'KNIX'
                                                },
                                                'Expand': False
                                                })

        # pprint.pprint(knixInstancesNoLimit)
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 5) and HasExtendedFind(_REMOTE):
            self.assertEqual(20, len(knixInstancesNoLimit))
        else:
            self.assertEqual(21, len(knixInstancesNoLimit))

        knixInstancesSince5Limit20 = DoPost(_REMOTE, '/tools/find', {    
                                                'Level' : 'Instances',
                                                'Query' : { 
                                                    'PatientName' : 'KNIX'
                                                },
                                                'Expand': False,
                                                'Since': 5,
                                                'Limit': 20
                                                })
        # pprint.pprint(knixInstancesSince5Limit20)
        
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 5):
            self.assertEqual(20, len(knixInstancesSince5Limit20))  # Orthanc actually returns LimitFindInstances + 1 resources
            # the first 5 from previous call shall not be in this answer
            for i in range(0, 5):
                self.assertNotIn(knixInstancesNoLimit[i], knixInstancesSince5Limit20)
            # the last 4 from last call shall not be in the first answer
            for i in range(16, 20):
                self.assertNotIn(knixInstancesSince5Limit20[i], knixInstancesNoLimit)

        # request more instances than LimitFindInstances
        knixInstancesSince0Limit23 = DoPost(_REMOTE, '/tools/find', {    
                                                'Level' : 'Instances',
                                                'Query' : { 
                                                    'PatientName' : 'KNIX'
                                                },
                                                'Expand': False,
                                                'Since': 0,
                                                'Limit': 23
                                                })
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 5) and HasExtendedFind(_REMOTE):
            self.assertEqual(20, len(knixInstancesSince0Limit23))

        seriesNoLimit = DoPost(_REMOTE, '/tools/find', {    
                                                'Level' : 'Series',
                                                'Query' : { 
                                                    'PatientName' : '*'
                                                },
                                                'Expand': False
                                                })

        # pprint.pprint(seriesNoLimit)
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 5) and HasExtendedFind(_REMOTE):
            self.assertEqual(10, len(seriesNoLimit))
        else:
            self.assertEqual(11, len(seriesNoLimit))

        seriesSince8Limit6 = DoPost(_REMOTE, '/tools/find', {    
                                                'Level' : 'Series',
                                                'Query' : { 
                                                    'PatientName' : '*'
                                                },
                                                'Expand': False,
                                                'Since': 8,
                                                'Limit': 6
                                                })

        # pprint.pprint(seriesSince8Limit6)
        if IsOrthancVersionAbove(_REMOTE, 1, 12, 5) and HasExtendedFind(_REMOTE): # TODO: remove HasExtendedFind once find-refactoring branch has been merged and supported by all DB plugins !!!
            self.assertEqual(6, len(seriesSince8Limit6))

            # the first 7 from previous call shall not be in this answer
            for i in range(0, 7):
                self.assertNotIn(seriesNoLimit[i], seriesSince8Limit6)
            # the last 3 from last call shall not be in the first answer
            for i in range(3, 5):
                self.assertNotIn(seriesSince8Limit6[i], seriesNoLimit)

        if IsOrthancVersionAbove(_REMOTE, 1, 12, 5) and HasExtendedFind(_REMOTE):
            # query by a tag that is not in the DB (there are 27 instances from Knix/Loc + 10 instances from other series that satisfies this criteria)
            a = DoPost(_REMOTE, '/tools/find', {    
                                                    'Level' : 'Instances',
                                                    'Query' : { 
                                                        'PhotometricInterpretation' : 'MONOCHROME*'
                                                    },
                                                    'Expand': True,
                                                    'OrderBy' : [
                                                            {
                                                                'Type': 'DicomTag',
                                                                'Key': 'InstanceNumber',
                                                                'Direction': 'ASC'
                                                            }
                                                    ]})

            # pprint.pprint(a)
            # print(len(a))
            # TODO: we should have something in the response that notifies us that the response is not "complete"
            # TODO: we should receive an error if we try to use "since" in this kind of search ?
            self.assertEqual(17, len(a))   # the fast DB filtering returns 20 instances -> only 17 of them meet the criteria but this is not really correct !!!

        if IsOrthancVersionAbove(_REMOTE, 1, 12, 5) and HasExtendedFind(_REMOTE):
            # make sur an error is returned when using Since or Limit when querying a tag that is not in DB
            self.assertRaises(Exception, lambda: DoPost(_REMOTE, '/tools/find', {'Level' : 'Instances',
                                                    'Query' : { 
                                                        'PhotometricInterpretation' : 'MONOCHROME*'
                                                    },
                                                    'Since': 2
                                                    }))

            # make sur an error is returned when using Since or Limit when querying a tag that is not in DB
            self.assertRaises(Exception, lambda: DoPost(_REMOTE, '/tools/find', {'Level' : 'Instances',
                                                    'Query' : { 
                                                        'PhotometricInterpretation' : 'MONOCHROME*'
                                                    },
                                                    'Limit': 10
                                                    }))


    def test_attachment_range(self):
        def TestData(path):
            (resp, content) = DoGetRaw(_REMOTE, '/instances/%s/attachments/dicom/%s' % (i, path))
            self.assertFalse('content-range' in resp)
            self.assertEqual(200, resp.status)
            self.assertEqual(2472, len(content))
            self.assertEqual('2472', resp['content-length'])
            self.assertEqual('application/dicom', resp['content-type'])

            (resp, content) = DoGetRaw(_REMOTE, '/instances/%s/attachments/dicom/%s' % (i, path), headers = { 'Range' : 'bytes=128-131' })
            self.assertTrue('content-range' in resp)
            self.assertEqual(206, resp.status)
            self.assertEqual(4, len(content))
            self.assertEqual('D', content[0])
            self.assertEqual('I', content[1])
            self.assertEqual('C', content[2])
            self.assertEqual('M', content[3])
            self.assertEqual('4', resp['content-length'])
            self.assertEqual('application/octet-stream', resp['content-type'])
            self.assertEqual('bytes 128-131/2472', resp['content-range'])

            (resp, content) = DoGetRaw(_REMOTE, '/instances/%s/attachments/dicom/%s' % (i, path), headers = { 'Range' : 'bytes=-' })
            self.assertTrue('content-range' in resp)
            self.assertEqual(206, resp.status)
            self.assertEqual(2472, len(content))
            self.assertEqual('2472', resp['content-length'])
            self.assertEqual('application/octet-stream', resp['content-type'])
            self.assertEqual('bytes 0-2471/2472', resp['content-range'])

            (resp, content) = DoGetRaw(_REMOTE, '/instances/%s/attachments/dicom/%s' % (i, path), headers = { 'Range' : 'bytes=128-' })
            self.assertTrue('content-range' in resp)
            self.assertEqual(206, resp.status)
            self.assertEqual(2344, len(content))
            self.assertEqual('D', content[0])
            self.assertEqual('I', content[1])
            self.assertEqual('C', content[2])
            self.assertEqual('M', content[3])
            self.assertEqual('2344', resp['content-length'])
            self.assertEqual('application/octet-stream', resp['content-type'])
            self.assertEqual('bytes 128-2471/2472', resp['content-range'])

            (resp, content) = DoGetRaw(_REMOTE, '/instances/%s/attachments/dicom/%s' % (i, path), headers = { 'Range' : 'bytes=-131' })
            self.assertTrue('content-range' in resp)
            self.assertEqual(206, resp.status)
            self.assertEqual(132, len(content))
            self.assertEqual('D', content[-4])
            self.assertEqual('I', content[-3])
            self.assertEqual('C', content[-2])
            self.assertEqual('M', content[-1])
            self.assertEqual('132', resp['content-length'])
            self.assertEqual('application/octet-stream', resp['content-type'])
            self.assertEqual('bytes 0-131/2472', resp['content-range'])

        if IsOrthancVersionAbove(_REMOTE, 1, 12, 5):
            i = UploadInstance(_REMOTE, 'DummyCT.dcm') ['ID']

            DoPost(_REMOTE, '/instances/%s/attachments/dicom/uncompress' % i)
            TestData('data')
            TestData('compressed-data')

            DoPost(_REMOTE, '/instances/%s/attachments/dicom/compress' % i)
            TestData('data')

            (resp, compressed) = DoGetRaw(_REMOTE, '/instances/%s/attachments/dicom/compressed-data' % i)
            self.assertFalse('content-range' in resp)
            self.assertEqual(200, resp.status)
            self.assertTrue(len(compressed) < 2000)
            self.assertEqual(len(compressed), int(resp['content-length']))
            self.assertEqual('application/octet-stream', resp['content-type'])

            (resp, content) = DoGetRaw(_REMOTE, '/instances/%s/attachments/dicom/compressed-data' % i, headers = { 'Range' : 'bytes=-' })
            self.assertTrue('content-range' in resp)
            self.assertEqual(206, resp.status)
            self.assertEqual(compressed, content)
            self.assertEqual(len(compressed), int(resp['content-length']))
            self.assertEqual('application/octet-stream', resp['content-type'])
            self.assertEqual('bytes 0-%d/%d' % (len(compressed) - 1, len(compressed)), resp['content-range'])

            (resp, content) = DoGetRaw(_REMOTE, '/instances/%s/attachments/dicom/compressed-data' % i, headers = { 'Range' : 'bytes=10-' })
            self.assertTrue('content-range' in resp)
            self.assertEqual(206, resp.status)
            self.assertEqual(compressed[10:], content)
            self.assertEqual(len(compressed) - 10, int(resp['content-length']))
            self.assertEqual('application/octet-stream', resp['content-type'])
            self.assertEqual('bytes 10-%d/%d' % (len(compressed) - 1, len(compressed)), resp['content-range'])

            (resp, content) = DoGetRaw(_REMOTE, '/instances/%s/attachments/dicom/compressed-data' % i, headers = { 'Range' : 'bytes=-20' })
            self.assertTrue('content-range' in resp)
            self.assertEqual(206, resp.status)
            self.assertEqual(compressed[0:21], content)
            self.assertEqual(21, int(resp['content-length']))
            self.assertEqual('application/octet-stream', resp['content-type'])
            self.assertEqual('bytes 0-20/%d' % len(compressed), resp['content-range'])

            (resp, content) = DoGetRaw(_REMOTE, '/instances/%s/attachments/dicom/compressed-data' % i, headers = { 'Range' : 'bytes=10-20' })
            self.assertTrue('content-range' in resp)
            self.assertEqual(206, resp.status)
            self.assertEqual(compressed[10:21], content)
            self.assertEqual(11, int(resp['content-length']))
            self.assertEqual('application/octet-stream', resp['content-type'])
            self.assertEqual('bytes 10-20/%d' % len(compressed), resp['content-range'])