# HG changeset patch # User Sebastien Jodogne # Date 1623854644 -7200 # Node ID 45bce660ce3a5c0a12febfe31e69a9ce892e634a # Parent e68edf92e5ccfb3a9d8f318cdccd1e47448178d9 added routes for bulk anonymization/modification diff -r e68edf92e5cc -r 45bce660ce3a NEWS --- a/NEWS Fri Jun 11 10:48:28 2021 +0200 +++ b/NEWS Wed Jun 16 16:44:04 2021 +0200 @@ -11,6 +11,9 @@ -------- * API version upgraded to 13 +* New routes: + - "/tools/bulk-anonymize" to anonymize groups of multiple, unrelated resources at once + - "/tools/bulk-modify" to modify groups of multiple, unrelated resources at once * ZIP archive/media generated in synchronous mode are now streamed by default * "Replace" tags in "/modify" and "/anonymize" now supports value representation AT * "/jobs/..." has new field "ErrorDetails" to help identify the cause of an error diff -r e68edf92e5cc -r 45bce660ce3a OrthancFramework/Sources/DicomParsing/DicomModification.cpp --- a/OrthancFramework/Sources/DicomParsing/DicomModification.cpp Fri Jun 11 10:48:28 2021 +0200 +++ b/OrthancFramework/Sources/DicomParsing/DicomModification.cpp Wed Jun 16 16:44:04 2021 +0200 @@ -155,10 +155,21 @@ if (that_.uids_.find(tag) != that_.uids_.end() && !IsManuallyModified(tag)) { - // This is a first-level UID tag that must be anonymized - assert(vr == ValueRepresentation_UniqueIdentifier || - vr == ValueRepresentation_NotSupported /* for older versions of DCMTK */); - newValue = that_.MapDicomIdentifier(value, ResourceType_Instance); + if (tag == DICOM_TAG_PATIENT_ID || + tag == DICOM_TAG_PATIENT_NAME) + { + assert(vr == ValueRepresentation_LongString || + vr == ValueRepresentation_PersonName); + newValue = that_.MapDicomIdentifier(value, ResourceType_Patient); + } + else + { + // This is a first-level UID tag that must be anonymized + assert(vr == ValueRepresentation_UniqueIdentifier || + vr == ValueRepresentation_NotSupported /* for older versions of DCMTK */); + newValue = that_.MapDicomIdentifier(value, ResourceType_Instance); + } + return Action_Replace; } else @@ -202,27 +213,35 @@ } else if (that_.uids_.find(tag) != that_.uids_.end()) { - assert(vr == ValueRepresentation_UniqueIdentifier || - vr == ValueRepresentation_NotSupported /* for older versions of DCMTK */); - - if (parentTags.size() == 2 && - parentTags[0] == DICOM_TAG_REFERENCED_FRAME_OF_REFERENCE_SEQUENCE && - parentTags[1] == DICOM_TAG_RT_REFERENCED_STUDY_SEQUENCE && - tag == DICOM_TAG_REFERENCED_SOP_INSTANCE_UID) + if (tag == DICOM_TAG_PATIENT_ID || + tag == DICOM_TAG_PATIENT_NAME) { - /** - * In RT-STRUCT, this ReferencedSOPInstanceUID is actually - * referencing a StudyInstanceUID !! (observed in many - * data sets including: - * https://wiki.cancerimagingarchive.net/display/Public/Lung+CT+Segmentation+Challenge+2017) - * Tested in "test_anonymize_relationships_5". Introduced - * in: https://hg.orthanc-server.com/orthanc/rev/3513 - **/ - newValue = that_.MapDicomIdentifier(value, ResourceType_Study); + newValue = that_.MapDicomIdentifier(value, ResourceType_Patient); } else { - newValue = that_.MapDicomIdentifier(value, ResourceType_Instance); + assert(vr == ValueRepresentation_UniqueIdentifier || + vr == ValueRepresentation_NotSupported /* for older versions of DCMTK */); + + if (parentTags.size() == 2 && + parentTags[0] == DICOM_TAG_REFERENCED_FRAME_OF_REFERENCE_SEQUENCE && + parentTags[1] == DICOM_TAG_RT_REFERENCED_STUDY_SEQUENCE && + tag == DICOM_TAG_REFERENCED_SOP_INSTANCE_UID) + { + /** + * In RT-STRUCT, this ReferencedSOPInstanceUID is actually + * referencing a StudyInstanceUID !! (observed in many + * data sets including: + * https://wiki.cancerimagingarchive.net/display/Public/Lung+CT+Segmentation+Challenge+2017) + * Tested in "test_anonymize_relationships_5". Introduced + * in: https://hg.orthanc-server.com/orthanc/rev/3513 + **/ + newValue = that_.MapDicomIdentifier(value, ResourceType_Study); + } + else + { + newValue = that_.MapDicomIdentifier(value, ResourceType_Instance); + } } return Action_Replace; @@ -619,6 +638,10 @@ **/ uids_.clear(); + // (*) "PatientID" and "PatientName" are handled as UIDs since Orthanc 1.9.4 + uids_.insert(DICOM_TAG_PATIENT_ID); + uids_.insert(DICOM_TAG_PATIENT_NAME); + SetupUidsFromOrthancInternal(uids_, removals_, DicomTag(0x0008, 0x0014)); // Instance Creator UID <= from SetupAnonymization2008() SetupUidsFromOrthancInternal(uids_, removals_, DicomTag(0x0008, 0x1155)); // Referenced SOP Instance UID <= from VisitString() + RemoveRelationships() SetupUidsFromOrthancInternal(uids_, removals_, DicomTag(0x0020, 0x0052)); // Frame of Reference UID <= from VisitString() + RemoveRelationships() @@ -772,16 +795,29 @@ ReplaceInternal(DicomTag(0x0012, 0x0062), "YES"); // (*) Choose a random patient name and ID - std::string patientId = FromDcmtkBridge::GenerateUniqueIdentifier(ResourceType_Patient); - ReplaceInternal(DICOM_TAG_PATIENT_ID, patientId); - ReplaceInternal(DICOM_TAG_PATIENT_NAME, patientId); + uids_.insert(DICOM_TAG_PATIENT_ID); + uids_.insert(DICOM_TAG_PATIENT_NAME); // Sanity check for (SetOfTags::const_iterator it = uids_.begin(); it != uids_.end(); ++it) { ValueRepresentation vr = FromDcmtkBridge::LookupValueRepresentation(*it); - if (vr != ValueRepresentation_UniqueIdentifier && - vr != ValueRepresentation_NotSupported /* for older versions of DCMTK */) + if (*it == DICOM_TAG_PATIENT_ID) + { + if (vr != ValueRepresentation_LongString) + { + throw OrthancException(ErrorCode_InternalError); + } + } + else if (*it == DICOM_TAG_PATIENT_NAME) + { + if (vr != ValueRepresentation_PersonName) + { + throw OrthancException(ErrorCode_InternalError); + } + } + else if (vr != ValueRepresentation_UniqueIdentifier && + vr != ValueRepresentation_NotSupported /* for older versions of DCMTK */) { throw OrthancException(ErrorCode_InternalError); } @@ -805,7 +841,10 @@ // Sanity checks at the patient level - if (level_ == ResourceType_Patient && !IsReplaced(DICOM_TAG_PATIENT_ID)) + bool isReplacedPatientId = (IsReplaced(DICOM_TAG_PATIENT_ID) || + uids_.find(DICOM_TAG_PATIENT_ID) != uids_.end()); + + if (level_ == ResourceType_Patient && !isReplacedPatientId) { throw OrthancException(ErrorCode_BadRequest, "When modifying a patient, her PatientID is required to be modified"); @@ -834,7 +873,7 @@ // Sanity checks at the study level - if (level_ == ResourceType_Study && IsReplaced(DICOM_TAG_PATIENT_ID)) + if (level_ == ResourceType_Study && isReplacedPatientId) { throw OrthancException(ErrorCode_BadRequest, "When modifying a study, the parent PatientID cannot be manually modified"); @@ -857,7 +896,7 @@ // Sanity checks at the series level - if (level_ == ResourceType_Series && IsReplaced(DICOM_TAG_PATIENT_ID)) + if (level_ == ResourceType_Series && isReplacedPatientId) { throw OrthancException(ErrorCode_BadRequest, "When modifying a series, the parent PatientID cannot be manually modified"); @@ -880,7 +919,7 @@ // Sanity checks at the instance level - if (level_ == ResourceType_Instance && IsReplaced(DICOM_TAG_PATIENT_ID)) + if (level_ == ResourceType_Instance && isReplacedPatientId) { throw OrthancException(ErrorCode_BadRequest, "When modifying an instance, the parent PatientID cannot be manually modified"); @@ -1203,7 +1242,7 @@ } - void DicomModification::ParseAnonymizationRequest(bool& patientNameReplaced, + void DicomModification::ParseAnonymizationRequest(bool& patientNameOverridden, const Json::Value& request) { if (!request.isObject()) @@ -1230,8 +1269,6 @@ SetupAnonymization(version); - std::string patientName = GetReplacementAsString(DICOM_TAG_PATIENT_NAME); - if (GetBooleanValue("KeepPrivateTags", request, false)) { SetRemovePrivateTags(false); @@ -1252,9 +1289,8 @@ ParseListOfTags(*this, request["Keep"], TagOperation_Keep, force); } - patientNameReplaced = (IsReplaced(DICOM_TAG_PATIENT_NAME) && - GetReplacement(DICOM_TAG_PATIENT_NAME) == patientName); - + patientNameOverridden = (uids_.find(DICOM_TAG_PATIENT_NAME) == uids_.end()); + // New in Orthanc 1.6.0 if (request.isMember("PrivateCreator")) { diff -r e68edf92e5cc -r 45bce660ce3a OrthancFramework/Sources/DicomParsing/DicomModification.h --- a/OrthancFramework/Sources/DicomParsing/DicomModification.h Fri Jun 11 10:48:28 2021 +0200 +++ b/OrthancFramework/Sources/DicomParsing/DicomModification.h Wed Jun 16 16:44:04 2021 +0200 @@ -235,7 +235,9 @@ void ParseModifyRequest(const Json::Value& request); - void ParseAnonymizationRequest(bool& patientNameReplaced, + // "patientNameOverridden" is set to "true" iff. the PatientName + // (0010,0010) tag is manually replaced, removed, cleared or kept + void ParseAnonymizationRequest(bool& patientNameOverridden /* out */, const Json::Value& request); void SetDicomIdentifierGenerator(IDicomIdentifierGenerator& generator); diff -r e68edf92e5cc -r 45bce660ce3a OrthancFramework/UnitTestsSources/FromDcmtkTests.cpp --- a/OrthancFramework/UnitTestsSources/FromDcmtkTests.cpp Fri Jun 11 10:48:28 2021 +0200 +++ b/OrthancFramework/UnitTestsSources/FromDcmtkTests.cpp Wed Jun 16 16:44:04 2021 +0200 @@ -2430,7 +2430,7 @@ { Json::Value c = Json::objectValue; c["CodeValue"] = "122403"; - c["0010,0010"] = "WORLD"; // Patient name + c["0008,103e"] = "WORLD"; // Series description b.append(c); } @@ -2446,6 +2446,7 @@ static const char* CODE_VALUE = "0008,0100"; static const char* PATIENT_ID = "0010,0020"; static const char* PATIENT_NAME = "0010,0010"; + static const char* SERIES_DESCRIPTION = "0008,103e"; static const char* PURPOSE_CODE_SEQ = "0040,a170"; static const char* REF_IM_SEQ = "0008,1140"; static const char* REF_SOP_CLASS = "0008,1150"; @@ -2525,7 +2526,7 @@ Json::Value vv; dicom->DatasetToJson(vv, DicomToJsonFormat_Short, DicomToJsonFlags_None, 0); - ASSERT_EQ("WORLD", vv[REL_SERIES_SEQ][0][PURPOSE_CODE_SEQ][0][PATIENT_NAME].asString()); + ASSERT_EQ("WORLD", vv[REL_SERIES_SEQ][0][PURPOSE_CODE_SEQ][0][SERIES_DESCRIPTION].asString()); ASSERT_FALSE(vv[REL_SERIES_SEQ][0][PURPOSE_CODE_SEQ][0].isMember(CODE_VALUE)); } @@ -2685,7 +2686,7 @@ ASSERT_NE("1.2.840.113619.2.176.2025.1499492.7040.1171286241.719", vv1[REF_IM_SEQ][0][REF_SOP_INSTANCE].asString()); ASSERT_NE("1.2.840.113619.2.176.2025.1499492.7040.1171286241.726", vv1[REF_IM_SEQ][1][REF_SOP_INSTANCE].asString()); ASSERT_NE("1.2.840.113704.1.111.7016.1342451220.40", vv1[REL_SERIES_SEQ][0][STUDY_INSTANCE_UID].asString()); - ASSERT_EQ("WORLD", vv1[REL_SERIES_SEQ][0][PURPOSE_CODE_SEQ][0][PATIENT_NAME].asString()); + ASSERT_EQ("WORLD", vv1[REL_SERIES_SEQ][0][PURPOSE_CODE_SEQ][0][SERIES_DESCRIPTION].asString()); } { diff -r e68edf92e5cc -r 45bce660ce3a OrthancServer/Sources/OrthancRestApi/OrthancRestAnonymizeModify.cpp --- a/OrthancServer/Sources/OrthancRestApi/OrthancRestAnonymizeModify.cpp Fri Jun 11 10:48:28 2021 +0200 +++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestAnonymizeModify.cpp Wed Jun 16 16:44:04 2021 +0200 @@ -146,12 +146,12 @@ if (call.ParseJsonRequest(request) && request.isObject()) { - bool patientNameReplaced; - target.ParseAnonymizationRequest(patientNameReplaced, request); + bool patientNameOverridden; + target.ParseAnonymizationRequest(patientNameOverridden, request); - if (patientNameReplaced) + if (!patientNameOverridden) { - // Overwrite the random Patient's Name by one that is more + // Override the random Patient's Name by one that is more // user-friendly (provided none was specified by the user) target.Replace(DICOM_TAG_PATIENT_NAME, GeneratePatientName(OrthancRestApi::GetContext(call)), true); } @@ -209,6 +209,27 @@ } + static ResourceType DetectModifyLevel(const DicomModification& modification) + { + if (modification.IsReplaced(DICOM_TAG_PATIENT_ID)) + { + return ResourceType_Patient; + } + else if (modification.IsReplaced(DICOM_TAG_STUDY_INSTANCE_UID)) + { + return ResourceType_Study; + } + else if (modification.IsReplaced(DICOM_TAG_SERIES_INSTANCE_UID)) + { + return ResourceType_Series; + } + else + { + return ResourceType_Instance; + } + } + + static void ModifyInstance(RestApiPostCall& call) { if (call.IsDocumentation()) @@ -230,22 +251,7 @@ Json::Value request; ParseModifyRequest(request, modification, call); - if (modification.IsReplaced(DICOM_TAG_PATIENT_ID)) - { - modification.SetLevel(ResourceType_Patient); - } - else if (modification.IsReplaced(DICOM_TAG_STUDY_INSTANCE_UID)) - { - modification.SetLevel(ResourceType_Study); - } - else if (modification.IsReplaced(DICOM_TAG_SERIES_INSTANCE_UID)) - { - modification.SetLevel(ResourceType_Series); - } - else - { - modification.SetLevel(ResourceType_Instance); - } + modification.SetLevel(DetectModifyLevel(modification)); static const char* TRANSCODE = "Transcode"; if (request.isMember(TRANSCODE)) @@ -311,13 +317,23 @@ bool isAnonymization, RestApiPostCall& call, const Json::Value& body, - ResourceType level) + ResourceType outputLevel /* unused for multiple resources */, + bool isSingleResource, + const std::set& resources) { ServerContext& context = OrthancRestApi::GetContext(call); std::unique_ptr job(new ResourceModificationJob(context)); - job->SetModification(modification.release(), level, isAnonymization); + if (isSingleResource) // This notably configures the output format + { + job->SetSingleResourceModification(modification.release(), outputLevel, isAnonymization); + } + else + { + job->SetMultipleResourcesModification(modification.release(), isAnonymization); + } + job->SetOrigin(call); SetKeepSource(*job, body); @@ -326,8 +342,13 @@ { job->SetTranscode(SerializationToolbox::ReadString(body, TRANSCODE)); } + + for (std::set::const_iterator + it = resources.begin(); it != resources.end(); ++it) + { + context.AddChildInstances(*job, *it); + } - context.AddChildInstances(*job, call.GetUriComponent("id", "")); job->AddTrailingStep(); OrthancRestApi::GetApi(call).SubmitCommandsJob @@ -335,6 +356,35 @@ } + static void SubmitModificationJob(std::unique_ptr& modification, + bool isAnonymization, + RestApiPostCall& call, + const Json::Value& body, + ResourceType outputLevel) + { + // This was the only flavor in Orthanc <= 1.9.3 + std::set resources; + resources.insert(call.GetUriComponent("id", "")); + + SubmitModificationJob(modification, isAnonymization, call, body, outputLevel, + true /* single resource */, resources); + } + + + static void SubmitBulkJob(std::unique_ptr& modification, + bool isAnonymization, + RestApiPostCall& call, + const Json::Value& body) + { + std::set resources; + SerializationToolbox::ReadSetOfStrings(resources, body, "Resources"); + + SubmitModificationJob(modification, isAnonymization, + call, body, ResourceType_Instance /* arbitrary value, unused */, + false /* multiple resources */, resources); + } + + template static void ModifyResource(RestApiPostCall& call) { @@ -360,12 +410,41 @@ ParseModifyRequest(body, *modification, call); modification->SetLevel(resourceType); - + SubmitModificationJob(modification, false /* not an anonymization */, call, body, resourceType); } + // New in Orthanc 1.9.4 + static void BulkModify(RestApiPostCall& call) + { + if (call.IsDocumentation()) + { + OrthancRestApi::DocumentSubmitCommandsJob(call); + DocumentModifyOptions(call); + call.GetDocumentation() + .SetTag("System") + .SetSummary("Modify a set of instances") + .SetRequestField("Resources", RestApiCallDocumentation::Type_JsonListOfStrings, + "List of the Orthanc identifiers of the patients/studies/series/instances of interest.", false) + .SetDescription("Start a job that will modify all the DICOM patients, studies, series or instances " + "whose identifiers are provided in the `Resources` field.") + .AddAnswerType(MimeType_Json, "The list of all the resources that have been altered by this modification"); + return; + } + + std::unique_ptr modification(new DicomModification); + + Json::Value body; + ParseModifyRequest(body, *modification, call); + + modification->SetLevel(DetectModifyLevel(*modification)); + + SubmitBulkJob(modification, false /* not an anonymization */, call, body); + } + + template static void AnonymizeResource(RestApiPostCall& call) { @@ -395,6 +474,33 @@ } + // New in Orthanc 1.9.4 + static void BulkAnonymize(RestApiPostCall& call) + { + if (call.IsDocumentation()) + { + OrthancRestApi::DocumentSubmitCommandsJob(call); + DocumentAnonymizationOptions(call); + call.GetDocumentation() + .SetTag("System") + .SetSummary("Anonymize a set of instances") + .SetRequestField("Resources", RestApiCallDocumentation::Type_JsonListOfStrings, + "List of the Orthanc identifiers of the patients/studies/series/instances of interest.", false) + .SetDescription("Start a job that will anonymize all the DICOM patients, studies, series or instances " + "whose identifiers are provided in the `Resources` field.") + .AddAnswerType(MimeType_Json, "The list of all the resources that have been created by this anonymization"); + return; + } + + std::unique_ptr modification(new DicomModification); + + Json::Value body; + ParseAnonymizationRequest(body, *modification, call); + + SubmitBulkJob(modification, true /* anonymization */, call, body); + } + + static void StoreCreatedInstance(std::string& id /* out */, RestApiPostCall& call, ParsedDicomFile& dicom, @@ -1054,11 +1160,13 @@ Register("/series/{id}/modify", ModifyResource); Register("/studies/{id}/modify", ModifyResource); Register("/patients/{id}/modify", ModifyResource); + Register("/tools/bulk-modify", BulkModify); Register("/instances/{id}/anonymize", AnonymizeInstance); Register("/series/{id}/anonymize", AnonymizeResource); Register("/studies/{id}/anonymize", AnonymizeResource); Register("/patients/{id}/anonymize", AnonymizeResource); + Register("/tools/bulk-anonymize", BulkAnonymize); Register("/tools/create-dicom", CreateDicom); diff -r e68edf92e5cc -r 45bce660ce3a OrthancServer/Sources/ServerJobs/ResourceModificationJob.cpp --- a/OrthancServer/Sources/ServerJobs/ResourceModificationJob.cpp Fri Jun 11 10:48:28 2021 +0200 +++ b/OrthancServer/Sources/ServerJobs/ResourceModificationJob.cpp Wed Jun 16 16:44:04 2021 +0200 @@ -44,7 +44,16 @@ namespace Orthanc { - class ResourceModificationJob::Output : public boost::noncopyable + static void FormatResource(Json::Value& target, + ResourceType level, + const std::string& id) + { + target["Type"] = EnumerationToString(level); + target["ID"] = id; + target["Path"] = GetBasePath(level, id); + } + + class ResourceModificationJob::SingleOutput : public IOutput { private: ResourceType level_; @@ -53,7 +62,7 @@ std::string patientId_; public: - explicit Output(ResourceType level) : + explicit SingleOutput(ResourceType level) : level_(level), isFirst_(true) { @@ -65,13 +74,7 @@ } } - ResourceType GetLevel() const - { - return level_; - } - - - void Update(DicomInstanceHasher& hasher) + virtual void Update(DicomInstanceHasher& hasher) ORTHANC_OVERRIDE { if (isFirst_) { @@ -98,40 +101,78 @@ } } + virtual void Format(Json::Value& target) const ORTHANC_OVERRIDE + { + assert(target.type() == Json::objectValue); - bool Format(Json::Value& target) - { - if (isFirst_) + if (!isFirst_) { - return false; - } - else - { - target = Json::objectValue; - target["Type"] = EnumerationToString(level_); - target["ID"] = id_; - target["Path"] = GetBasePath(level_, id_); + FormatResource(target, level_, id_); target["PatientID"] = patientId_; - return true; } } - - bool GetIdentifier(std::string& id) + virtual bool IsSingleResource() const ORTHANC_OVERRIDE { - if (isFirst_) - { - return false; - } - else - { - id = id_; - return true; - } + return true; + } + + ResourceType GetLevel() const + { + return level_; } }; + class ResourceModificationJob::MultipleOutputs : public IOutput + { + private: + static void FormatResources(Json::Value& target, + ResourceType level, + const std::set& resources) + { + assert(target.type() == Json::arrayValue); + + for (std::set::const_iterator + it = resources.begin(); it != resources.end(); ++it) + { + Json::Value item = Json::objectValue; + FormatResource(item, level, *it); + target.append(item); + } + } + + std::set instances_; + std::set series_; + std::set studies_; + std::set patients_; + + public: + virtual void Update(DicomInstanceHasher& hasher) ORTHANC_OVERRIDE + { + instances_.insert(hasher.HashInstance()); + series_.insert(hasher.HashSeries()); + studies_.insert(hasher.HashStudy()); + patients_.insert(hasher.HashPatient()); + } + + virtual void Format(Json::Value& target) const ORTHANC_OVERRIDE + { + assert(target.type() == Json::objectValue); + Json::Value resources = Json::arrayValue; + FormatResources(resources, ResourceType_Instance, instances_); + FormatResources(resources, ResourceType_Series, series_); + FormatResources(resources, ResourceType_Study, studies_); + FormatResources(resources, ResourceType_Patient, patients_); + target["Resources"] = resources; + } + + virtual bool IsSingleResource() const ORTHANC_OVERRIDE + { + return false; + } + }; + bool ResourceModificationJob::HandleInstance(const std::string& instance) @@ -271,7 +312,6 @@ ResourceModificationJob::ResourceModificationJob(ServerContext& context) : CleaningInstancesJob(context, true /* by default, keep source */), - modification_(new DicomModification), isAnonymization_(false), transcode_(false), transferSyntax_(DicomTransferSyntax_LittleEndianExplicit) // dummy initialization @@ -279,9 +319,9 @@ } - void ResourceModificationJob::SetModification(DicomModification* modification, - ResourceType level, - bool isAnonymization) + void ResourceModificationJob::SetSingleResourceModification(DicomModification* modification, + ResourceType outputLevel, + bool isAnonymization) { if (modification == NULL) { @@ -294,7 +334,27 @@ else { modification_.reset(modification); - output_.reset(new Output(level)); + output_.reset(new SingleOutput(outputLevel)); + isAnonymization_ = isAnonymization; + } + } + + + void ResourceModificationJob::SetMultipleResourcesModification(DicomModification* modification, + bool isAnonymization) + { + if (modification == NULL) + { + throw OrthancException(ErrorCode_NullPointer); + } + else if (IsStarted()) + { + throw OrthancException(ErrorCode_BadSequenceOfCalls); + } + else + { + modification_.reset(modification); + output_.reset(new MultipleOutputs); isAnonymization_ = isAnonymization; } } @@ -387,6 +447,37 @@ } + bool ResourceModificationJob::IsSingleResourceModification() const + { + if (modification_.get() == NULL) + { + assert(output_.get() == NULL); + throw OrthancException(ErrorCode_BadSequenceOfCalls); + } + else + { + assert(output_.get() != NULL); + return output_->IsSingleResource(); + } + } + + + ResourceType ResourceModificationJob::GetOutputLevel() const + { + if (IsSingleResourceModification()) + { + assert(modification_.get() != NULL && + output_.get() != NULL); + return dynamic_cast(*output_).GetLevel(); + } + else + { + // Not applicable if multiple resources + throw OrthancException(ErrorCode_BadSequenceOfCalls); + } + } + + void ResourceModificationJob::GetPublicContent(Json::Value& value) { CleaningInstancesJob::GetPublicContent(value); @@ -409,6 +500,8 @@ static const char* ORIGIN = "Origin"; static const char* IS_ANONYMIZATION = "IsAnonymization"; static const char* TRANSCODE = "Transcode"; + static const char* OUTPUT_LEVEL = "OutputLevel"; + static const char* IS_SINGLE_RESOURCE = "IsSingleResource"; ResourceModificationJob::ResourceModificationJob(ServerContext& context, @@ -418,9 +511,7 @@ { assert(serialized.type() == Json::objectValue); - isAnonymization_ = SerializationToolbox::ReadBoolean(serialized, IS_ANONYMIZATION); origin_ = DicomInstanceOrigin(serialized[ORIGIN]); - modification_.reset(new DicomModification(serialized[MODIFICATION])); if (serialized.isMember(TRANSCODE)) { @@ -430,11 +521,62 @@ { transcode_ = false; } + + bool isSingleResource; + if (serialized.isMember(IS_SINGLE_RESOURCE)) + { + isSingleResource = SerializationToolbox::ReadBoolean(serialized, IS_SINGLE_RESOURCE); + } + else + { + isSingleResource = true; // Backward compatibility with Orthanc <= 1.9.3 + } + + bool isAnonymization = SerializationToolbox::ReadBoolean(serialized, IS_ANONYMIZATION); + std::unique_ptr modification(new DicomModification(serialized[MODIFICATION])); + + if (isSingleResource) + { + ResourceType outputLevel; + + if (serialized.isMember(OUTPUT_LEVEL)) + { + // New in Orthanc 1.9.4. This fixes an *incorrect* behavior in + // Orthanc <= 1.9.3, in which "outputLevel" would be set to + // "modification->GetLevel()" + outputLevel = StringToResourceType(SerializationToolbox::ReadString(serialized, OUTPUT_LEVEL).c_str()); + } + else + { + // Use the buggy convention from Orthanc <= 1.9.3 (which is + // the only thing we have at hand) + outputLevel = modification->GetLevel(); + + if (outputLevel == ResourceType_Instance) + { + // This should never happen, but as "SingleOutput" doesn't + // support instance-level anonymization, don't take any risk + // and choose an arbitrary output level + outputLevel = ResourceType_Patient; + } + } + + SetSingleResourceModification(modification.release(), outputLevel, isAnonymization); + } + else + { + // New in Orthanc 1.9.4 + SetMultipleResourcesModification(modification.release(), isAnonymization); + } } bool ResourceModificationJob::Serialize(Json::Value& value) { - if (!CleaningInstancesJob::Serialize(value)) + if (modification_.get() == NULL) + { + throw OrthancException(ErrorCode_BadSequenceOfCalls); + } + else if (!CleaningInstancesJob::Serialize(value)) { return false; } @@ -455,6 +597,13 @@ modification_->Serialize(tmp); value[MODIFICATION] = tmp; + // New in Orthanc 1.9.4 + value[IS_SINGLE_RESOURCE] = IsSingleResourceModification(); + if (IsSingleResourceModification()) + { + value[OUTPUT_LEVEL] = EnumerationToString(GetOutputLevel()); + } + return true; } } diff -r e68edf92e5cc -r 45bce660ce3a OrthancServer/Sources/ServerJobs/ResourceModificationJob.h --- a/OrthancServer/Sources/ServerJobs/ResourceModificationJob.h Fri Jun 11 10:48:28 2021 +0200 +++ b/OrthancServer/Sources/ServerJobs/ResourceModificationJob.h Wed Jun 16 16:44:04 2021 +0200 @@ -44,10 +44,25 @@ class ResourceModificationJob : public CleaningInstancesJob { private: - class Output; + class IOutput : public boost::noncopyable + { + public: + virtual ~IOutput() + { + } + + virtual void Update(DicomInstanceHasher& hasher) = 0; + + virtual void Format(Json::Value& target) const = 0; + + virtual bool IsSingleResource() const = 0; + }; + + class SingleOutput; + class MultipleOutputs; std::unique_ptr modification_; - boost::shared_ptr output_; + boost::shared_ptr output_; bool isAnonymization_; DicomInstanceOrigin origin_; bool transcode_; @@ -62,9 +77,14 @@ ResourceModificationJob(ServerContext& context, const Json::Value& serialized); - void SetModification(DicomModification* modification, // Takes ownership - ResourceType level, - bool isAnonymization); + // NB: The "outputLevel" only controls the output format, and + // might *not* be the same as "modification->GetLevel()" + void SetSingleResourceModification(DicomModification* modification, // Takes ownership + ResourceType outputLevel, + bool isAnonymization); + + void SetMultipleResourcesModification(DicomModification* modification, // Takes ownership + bool isAnonymization); void SetOrigin(const DicomInstanceOrigin& origin); @@ -95,6 +115,11 @@ void ClearTranscode(); + bool IsSingleResourceModification() const; + + // Only possible if "IsSingleResourceModification()" + ResourceType GetOutputLevel() const; + virtual void Stop(JobStopReason reason) ORTHANC_OVERRIDE { } diff -r e68edf92e5cc -r 45bce660ce3a OrthancServer/UnitTestsSources/ServerJobsTests.cpp --- a/OrthancServer/UnitTestsSources/ServerJobsTests.cpp Fri Jun 11 10:48:28 2021 +0200 +++ b/OrthancServer/UnitTestsSources/ServerJobsTests.cpp Wed Jun 16 16:44:04 2021 +0200 @@ -819,11 +819,16 @@ { std::unique_ptr modification(new DicomModification); - modification->SetupAnonymization(DicomVersion_2008); + modification->SetupAnonymization(DicomVersion_2008); + modification->SetLevel(ResourceType_Series); ResourceModificationJob job(GetContext()); - job.SetModification(modification.release(), ResourceType_Patient, true); + ASSERT_THROW(job.IsSingleResourceModification(), OrthancException); + job.SetSingleResourceModification(modification.release(), ResourceType_Patient, true); job.SetOrigin(DicomInstanceOrigin::FromLua()); + ASSERT_TRUE(job.IsAnonymization()); + ASSERT_TRUE(job.IsSingleResourceModification()); + ASSERT_EQ(ResourceType_Patient, job.GetOutputLevel()); job.AddTrailingStep(); // Necessary since 1.7.0 ASSERT_TRUE(CheckIdempotentSetOfInstances(unserializer, job)); @@ -840,12 +845,32 @@ ASSERT_THROW(tmp.GetTransferSyntax(), OrthancException); ASSERT_EQ(RequestOrigin_Lua, tmp.GetOrigin().GetRequestOrigin()); ASSERT_TRUE(tmp.GetModification().IsRemoved(DICOM_TAG_STUDY_DESCRIPTION)); + ASSERT_TRUE(tmp.IsSingleResourceModification()); + ASSERT_EQ(ResourceType_Patient, tmp.GetOutputLevel()); + ASSERT_EQ(ResourceType_Series, tmp.GetModification().GetLevel()); + } + + { + // Backward compatibility with Orthanc 1.9.3 + ASSERT_TRUE(s.isMember("OutputLevel")); + ASSERT_TRUE(s.isMember("IsSingleResource")); + s.removeMember("OutputLevel"); + s.removeMember("IsSingleResource"); + + std::unique_ptr job; + job.reset(unserializer.UnserializeJob(s)); + + ResourceModificationJob& tmp = dynamic_cast(*job); + ASSERT_TRUE(tmp.IsSingleResourceModification()); + ASSERT_EQ(ResourceType_Series, tmp.GetOutputLevel()); // old, incorrect behavior + ASSERT_EQ(ResourceType_Series, tmp.GetModification().GetLevel()); } { ResourceModificationJob job(GetContext()); ASSERT_THROW(job.SetTranscode("nope"), OrthancException); job.SetTranscode(DicomTransferSyntax_JPEGProcess1); + job.SetSingleResourceModification(new DicomModification, ResourceType_Study, false); job.AddTrailingStep(); // Necessary since 1.7.0 ASSERT_TRUE(CheckIdempotentSetOfInstances(unserializer, job)); @@ -861,6 +886,72 @@ ASSERT_TRUE(tmp.IsTranscode()); ASSERT_EQ(DicomTransferSyntax_JPEGProcess1, tmp.GetTransferSyntax()); ASSERT_EQ(RequestOrigin_Unknown, tmp.GetOrigin().GetRequestOrigin()); + ASSERT_TRUE(tmp.IsSingleResourceModification()); + ASSERT_EQ(ResourceType_Study, tmp.GetOutputLevel()); + ASSERT_EQ(ResourceType_Instance, tmp.GetModification().GetLevel()); + } + + { + ResourceModificationJob job(GetContext()); + job.SetMultipleResourcesModification(new DicomModification, true); + job.AddInstance("toto"); + job.AddInstance("tutu"); + job.AddTrailingStep(); // Necessary since 1.7.0 + ASSERT_TRUE(CheckIdempotentSetOfInstances(unserializer, job)); + ASSERT_TRUE(job.Serialize(s)); + } + + { + std::unique_ptr job; + job.reset(unserializer.UnserializeJob(s)); + + ResourceModificationJob& tmp = dynamic_cast(*job); + + std::set instances; + for (size_t i = 0; i < tmp.GetInstancesCount(); i++) + { + instances.insert(tmp.GetInstance(i)); + } + + ASSERT_EQ(2u, instances.size()); + ASSERT_TRUE(instances.find("toto") != instances.end()); + ASSERT_TRUE(instances.find("tutu") != instances.end()); + + ASSERT_TRUE(tmp.IsAnonymization()); + ASSERT_FALSE(tmp.IsSingleResourceModification()); + ASSERT_THROW(tmp.GetOutputLevel(), OrthancException); + ASSERT_EQ(ResourceType_Instance, tmp.GetModification().GetLevel()); + } + + { + // Test behavior on broken serialization + ASSERT_FALSE(s.isMember("OutputLevel")); + ASSERT_TRUE(s.isMember("IsSingleResource")); + s.removeMember("IsSingleResource"); + + { + std::unique_ptr job; + job.reset(unserializer.UnserializeJob(s)); + + ResourceModificationJob& tmp = dynamic_cast(*job); + ASSERT_TRUE(tmp.IsAnonymization()); + ASSERT_TRUE(tmp.IsSingleResourceModification()); + ASSERT_EQ(ResourceType_Patient, tmp.GetOutputLevel()); + ASSERT_EQ(ResourceType_Instance, tmp.GetModification().GetLevel()); + } + + s["Modification"]["Level"] = "Series"; + + { + std::unique_ptr job; + job.reset(unserializer.UnserializeJob(s)); + + ResourceModificationJob& tmp = dynamic_cast(*job); + ASSERT_TRUE(tmp.IsAnonymization()); + ASSERT_TRUE(tmp.IsSingleResourceModification()); + ASSERT_EQ(ResourceType_Series, tmp.GetOutputLevel()); + ASSERT_EQ(ResourceType_Series, tmp.GetModification().GetLevel()); + } } // SplitStudyJob