Skip to content

Commit cf7a6aa

Browse files
committed
OpenConceptLab/ocl_issues#22 | Retired concepts and/or mappings are now excluded from exports
1 parent a29abc6 commit cf7a6aa

File tree

8 files changed

+162
-29
lines changed

8 files changed

+162
-29
lines changed

core/collections/views.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
from core.collections.utils import is_version_specified
3434
from core.common.constants import (
3535
HEAD, RELEASED_PARAM, PROCESSING_PARAM, OK_MESSAGE,
36-
ACCESS_TYPE_NONE)
36+
ACCESS_TYPE_NONE, INCLUDE_RETIRED_PARAM)
3737
from core.common.mixins import (
3838
ConceptDictionaryCreateMixin, ListWithHeadersMixin, ConceptDictionaryUpdateMixin,
3939
ConceptContainerExportMixin,
@@ -578,7 +578,8 @@ class CollectionVersionExportView(ConceptContainerExportMixin, CollectionVersion
578578
def handle_export_version(self):
579579
version = self.get_object()
580580
try:
581-
export_collection.delay(version.id)
581+
include_retired = parse_boolean_query_param(self.request, INCLUDE_RETIRED_PARAM, "True")
582+
export_collection.delay(version.id, include_retired)
582583
return status.HTTP_202_ACCEPTED
583584
except AlreadyQueued:
584585
return status.HTTP_409_CONFLICT

core/common/mixins.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@
1515
from rest_framework.mixins import ListModelMixin, CreateModelMixin
1616
from rest_framework.response import Response
1717

18-
from core.common.constants import HEAD, ACCESS_TYPE_EDIT, ACCESS_TYPE_VIEW, ACCESS_TYPE_NONE, INCLUDE_FACETS, \
18+
from core.common.constants import HEAD, ACCESS_TYPE_EDIT, ACCESS_TYPE_VIEW, ACCESS_TYPE_NONE, INCLUDE_FACETS, INCLUDE_RETIRED_PARAM, \
1919
LIST_DEFAULT_LIMIT, HTTP_COMPRESS_HEADER, CSV_DEFAULT_LIMIT, FACETS_ONLY, NOT_FOUND, \
2020
MUST_SPECIFY_EXTRA_PARAM_IN_BODY
2121
from core.common.permissions import HasPrivateAccess, HasOwnership, CanViewConceptDictionary
2222
from core.common.services import S3
23-
from .utils import write_csv_to_s3, get_csv_from_s3, get_query_params_from_url_string, compact_dict_by_values
23+
from .utils import write_csv_to_s3, get_csv_from_s3, get_query_params_from_url_string, compact_dict_by_values, parse_boolean_query_param
2424

2525
logger = logging.getLogger('oclapi')
2626

@@ -542,16 +542,8 @@ def get_object(self):
542542
return instance
543543

544544
def get(self, request, *args, **kwargs): # pylint: disable=unused-argument
545-
version = self.get_object()
546-
logger.debug(
547-
'Export requested for %s version %s - Requesting AWS-S3 key', self.entity.lower(), version.version
548-
)
549-
if version.is_head and not request.user.is_staff:
550-
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED)
551-
552-
if version.has_export():
553-
export_url = version.get_export_url()
554545

546+
def export_response(export_url):
555547
no_redirect = request.query_params.get('noRedirect', False) in ['true', 'True', True]
556548
if no_redirect:
557549
return Response(dict(url=export_url), status=status.HTTP_200_OK)
@@ -567,6 +559,24 @@ def get(self, request, *args, **kwargs): # pylint: disable=unused-argument
567559
response['Last-Updated-Timezone'] = settings.TIME_ZONE_PLACE
568560
return response
569561

562+
version = self.get_object()
563+
include_retired = parse_boolean_query_param(request, INCLUDE_RETIRED_PARAM, "True")
564+
logger.debug(
565+
'Export requested for %s version %s - Requesting AWS-S3 key', self.entity.lower(), version.version
566+
)
567+
568+
if version.is_head and not request.user.is_staff:
569+
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED)
570+
571+
if include_retired:
572+
if version.has_export():
573+
export_url = version.get_export_url()
574+
return export_response(export_url)
575+
else:
576+
if version.has_unretired_export():
577+
export_url = version.get_unretired_export_url()
578+
return export_response(export_url)
579+
570580
if version.is_exporting:
571581
return Response(status=status.HTTP_208_ALREADY_REPORTED)
572582

core/common/models.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,11 @@ def is_exporting(self):
722722
def export_path(self):
723723
last_update = self.last_child_update.strftime('%Y%m%d%H%M%S')
724724
return self.generic_export_path(suffix=f"{last_update}.zip")
725+
726+
@cached_property
727+
def exclude_retired_export_path(self):
728+
last_update = self.last_child_update.strftime('%Y%m%d%H%M%S')
729+
return self.generic_export_path(suffix=f"{last_update}_unretired.zip")
725730

726731
def generic_export_path(self, suffix='*'):
727732
path = f"{self.parent_resource}/{self.mnemonic}_{self.version}."
@@ -733,9 +738,15 @@ def generic_export_path(self, suffix='*'):
733738
def get_export_url(self):
734739
return S3.url_for(self.export_path)
735740

741+
def get_unretired_export_url(self):
742+
return S3.url_for(self.exclude_retired_export_path)
743+
736744
def has_export(self):
737745
return S3.exists(self.export_path)
738746

747+
def has_unretired_export(self):
748+
return S3.exists(self.exclude_retired_export_path)
749+
739750

740751
class CelerySignalProcessor(RealTimeSignalProcessor):
741752
def handle_save(self, sender, instance, **kwargs):

core/common/tasks.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def delete_source(source_id):
6060

6161

6262
@app.task(base=QueueOnce, bind=True)
63-
def export_source(self, version_id):
63+
def export_source(self, version_id, include_retired=True):
6464
from core.sources.models import Source
6565
logger.info('Finding source version...')
6666

@@ -75,14 +75,14 @@ def export_source(self, version_id):
7575
version.add_processing(self.request.id)
7676
try:
7777
logger.info('Found source version %s. Beginning export...', version.version)
78-
write_export_file(version, 'source', 'core.sources.serializers.SourceVersionExportSerializer', logger)
78+
write_export_file(version, include_retired, 'source', 'core.sources.serializers.SourceVersionExportSerializer', logger)
7979
logger.info('Export complete!')
8080
finally:
8181
version.remove_processing(self.request.id)
8282

8383

8484
@app.task(base=QueueOnce, bind=True)
85-
def export_collection(self, version_id):
85+
def export_collection(self, version_id, include_retired=True):
8686
from core.collections.models import Collection
8787
logger.info('Finding collection version...')
8888

@@ -98,7 +98,7 @@ def export_collection(self, version_id):
9898
try:
9999
logger.info('Found collection version %s. Beginning export...', version.version)
100100
write_export_file(
101-
version, 'collection', 'core.collections.serializers.CollectionVersionExportSerializer', logger
101+
version, include_retired, 'collection', 'core.collections.serializers.CollectionVersionExportSerializer', logger
102102
)
103103
logger.info('Export complete!')
104104
finally:

core/common/utils.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ def get_class(kls):
191191

192192

193193
def write_export_file(
194-
version, resource_type, resource_serializer_type, logger
194+
version, include_retired, resource_type, resource_serializer_type, logger
195195
): # pylint: disable=too-many-statements,too-many-locals,too-many-branches
196196
from core.concepts.models import Concept
197197
from core.mappings.models import Mapping
@@ -207,6 +207,13 @@ def write_export_file(
207207
logger.info('Done serializing attributes.')
208208

209209
batch_size = 100
210+
concepts_qs = version.concepts
211+
mappings_qs = version.mappings
212+
213+
if not include_retired:
214+
concepts_qs = concepts_qs.filter(retired=False)
215+
mappings_qs = mappings_qs.filter(retired=False)
216+
210217
is_collection = resource_type == 'collection'
211218

212219
if is_collection:
@@ -328,7 +335,7 @@ def write_export_file(
328335
logger.info(file_path)
329336
logger.info('Done compressing. Uploading...')
330337

331-
s3_key = version.export_path
338+
s3_key = version.export_path if include_retired else version.exclude_retired_export_path
332339
S3.upload_file(
333340
key=s3_key, file_path=file_path, binary=True, metadata=dict(ContentType='application/zip'),
334341
headers={'content-type': 'application/zip'}

core/integration_tests/tests_collections.py

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -857,29 +857,29 @@ def test_post_303(self, s3_exists_mock):
857857
def test_post_202(self, s3_exists_mock, export_collection_mock):
858858
s3_exists_mock.return_value = False
859859
response = self.client.post(
860-
self.collection_v1.uri + 'export/',
860+
self.collection_v1.uri + 'export/?includeRetired=False',
861861
HTTP_AUTHORIZATION='Token ' + self.token,
862862
format='json'
863863
)
864864

865865
self.assertEqual(response.status_code, 202)
866866
s3_exists_mock.assert_called_once_with(f"username/coll_v1.{self.v1_updated_at}.zip")
867-
export_collection_mock.delay.assert_called_once_with(self.collection_v1.id)
867+
export_collection_mock.delay.assert_called_once_with(self.collection_v1.id, False)
868868

869869
@patch('core.collections.views.export_collection')
870870
@patch('core.common.services.S3.exists')
871871
def test_post_409(self, s3_exists_mock, export_collection_mock):
872872
s3_exists_mock.return_value = False
873873
export_collection_mock.delay.side_effect = AlreadyQueued('already-queued')
874874
response = self.client.post(
875-
self.collection_v1.uri + 'export/',
875+
self.collection_v1.uri + 'export/?includeRetired=False',
876876
HTTP_AUTHORIZATION='Token ' + self.token,
877877
format='json'
878878
)
879879

880880
self.assertEqual(response.status_code, 409)
881881
s3_exists_mock.assert_called_once_with(f"username/coll_v1.{self.v1_updated_at}.zip")
882-
export_collection_mock.delay.assert_called_once_with(self.collection_v1.id)
882+
export_collection_mock.delay.assert_called_once_with(self.collection_v1.id, False)
883883

884884

885885
class CollectionVersionListViewTest(OCLAPITestCase):
@@ -999,6 +999,62 @@ def test_export_collection(self, s3_mock): # pylint: disable=too-many-locals
999999
import shutil
10001000
shutil.rmtree(latest_temp_dir)
10011001

1002+
@patch('core.common.utils.S3')
1003+
def test_unretired_export_collection(self, s3_mock): # pylint: disable=too-many-locals
1004+
s3_mock.url_for = Mock(return_value='https://s3-url')
1005+
s3_mock.upload_file = Mock()
1006+
source = OrganizationSourceFactory()
1007+
concept1 = ConceptFactory(parent=source)
1008+
concept2 = ConceptFactory(parent=source)
1009+
mapping = MappingFactory(from_concept=concept2, to_concept=concept1, parent=source)
1010+
collection = OrganizationCollectionFactory()
1011+
collection.add_references([concept1.uri, concept2.uri, mapping.uri])
1012+
collection.refresh_from_db()
1013+
1014+
export_collection(collection.id, include_retired=False) # pylint: disable=no-value-for-parameter
1015+
1016+
latest_temp_dir = get_latest_dir_in_path('/tmp/')
1017+
zipped_file = zipfile.ZipFile(latest_temp_dir + '/export.zip')
1018+
exported_data = json.loads(zipped_file.read('export.json').decode('utf-8'))
1019+
1020+
self.assertEqual(
1021+
exported_data,
1022+
{**CollectionVersionExportSerializer(collection).data, 'concepts': ANY, 'mappings': ANY, 'references': ANY}
1023+
)
1024+
1025+
exported_concepts = exported_data['concepts']
1026+
expected_concepts = ConceptVersionExportSerializer(
1027+
[concept2.get_latest_version(), concept1.get_latest_version()], many=True
1028+
).data
1029+
1030+
self.assertEqual(len(exported_concepts), 2)
1031+
self.assertIn(expected_concepts[0], exported_concepts)
1032+
self.assertIn(expected_concepts[1], exported_concepts)
1033+
1034+
exported_mappings = exported_data['mappings']
1035+
expected_mappings = MappingDetailSerializer([mapping.get_latest_version()], many=True).data
1036+
1037+
self.assertEqual(len(exported_mappings), 1)
1038+
self.assertEqual(expected_mappings, exported_mappings)
1039+
1040+
exported_references = exported_data['references']
1041+
expected_references = CollectionReferenceSerializer(collection.references.all(), many=True).data
1042+
1043+
self.assertEqual(len(exported_references), 3)
1044+
self.assertIn(exported_references[0], expected_references)
1045+
self.assertIn(exported_references[1], expected_references)
1046+
self.assertIn(exported_references[2], expected_references)
1047+
1048+
s3_upload_key = collection.exclude_retired_export_path
1049+
s3_mock.upload_file.assert_called_once_with(
1050+
key=s3_upload_key, file_path=latest_temp_dir + '/export.zip', binary=True,
1051+
metadata={'ContentType': 'application/zip'}, headers={'content-type': 'application/zip'}
1052+
)
1053+
s3_mock.url_for.assert_called_once_with(s3_upload_key)
1054+
1055+
import shutil
1056+
shutil.rmtree(latest_temp_dir)
1057+
10021058

10031059
class CollectionConceptsViewTest(OCLAPITestCase):
10041060
def setUp(self):

core/integration_tests/tests_sources.py

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -704,29 +704,29 @@ def test_post_303(self, s3_exists_mock):
704704
def test_post_202(self, s3_exists_mock, export_source_mock):
705705
s3_exists_mock.return_value = False
706706
response = self.client.post(
707-
self.source_v1.uri + 'export/',
707+
self.source_v1.uri + 'export/?includeRetired=False',
708708
HTTP_AUTHORIZATION='Token ' + self.token,
709709
format='json'
710710
)
711711

712712
self.assertEqual(response.status_code, 202)
713713
s3_exists_mock.assert_called_once_with(f"username/source1_v1.{self.v1_updated_at}.zip")
714-
export_source_mock.delay.assert_called_once_with(self.source_v1.id)
714+
export_source_mock.delay.assert_called_once_with(self.source_v1.id, False)
715715

716716
@patch('core.sources.views.export_source')
717717
@patch('core.common.services.S3.exists')
718718
def test_post_409(self, s3_exists_mock, export_source_mock):
719719
s3_exists_mock.return_value = False
720720
export_source_mock.delay.side_effect = AlreadyQueued('already-queued')
721721
response = self.client.post(
722-
self.source_v1.uri + 'export/',
722+
self.source_v1.uri + 'export/?includeRetired=False',
723723
HTTP_AUTHORIZATION='Token ' + self.token,
724724
format='json'
725725
)
726726

727727
self.assertEqual(response.status_code, 409)
728728
s3_exists_mock.assert_called_once_with(f"username/source1_v1.{self.v1_updated_at}.zip")
729-
export_source_mock.delay.assert_called_once_with(self.source_v1.id)
729+
export_source_mock.delay.assert_called_once_with(self.source_v1.id, False)
730730

731731

732732
class ExportSourceTaskTest(OCLAPITestCase):
@@ -777,6 +777,53 @@ def test_export_source(self, s3_mock): # pylint: disable=too-many-locals
777777
import shutil
778778
shutil.rmtree(latest_temp_dir)
779779

780+
@patch('core.common.utils.S3')
781+
def test_unretired_export_source(self, s3_mock): # pylint: disable=too-many-locals
782+
s3_mock.url_for = Mock(return_value='https://s3-url')
783+
s3_mock.upload_file = Mock()
784+
source = OrganizationSourceFactory()
785+
concept1 = ConceptFactory(parent=source)
786+
concept2 = ConceptFactory(parent=source)
787+
mapping = MappingFactory(from_concept=concept2, to_concept=concept1, parent=source)
788+
789+
source_v1 = OrganizationSourceFactory(mnemonic=source.mnemonic, organization=source.organization, version='v1')
790+
concept1.sources.add(source_v1)
791+
concept2.sources.add(source_v1)
792+
mapping.sources.add(source_v1)
793+
794+
export_source(source_v1.id, include_retired=False) # pylint: disable=no-value-for-parameter
795+
796+
latest_temp_dir = get_latest_dir_in_path('/tmp/')
797+
zipped_file = zipfile.ZipFile(latest_temp_dir + '/export.zip')
798+
exported_data = json.loads(zipped_file.read('export.json').decode('utf-8'))
799+
800+
self.assertEqual(
801+
exported_data, {**SourceVersionExportSerializer(source_v1).data, 'concepts': ANY, 'mappings': ANY}
802+
)
803+
804+
exported_concepts = exported_data['concepts']
805+
expected_concepts = ConceptVersionExportSerializer([concept2, concept1], many=True).data
806+
807+
self.assertEqual(len(exported_concepts), 2)
808+
self.assertIn(expected_concepts[0], exported_concepts)
809+
self.assertIn(expected_concepts[1], exported_concepts)
810+
811+
exported_mappings = exported_data['mappings']
812+
expected_mappings = MappingDetailSerializer([mapping], many=True).data
813+
814+
self.assertEqual(len(exported_mappings), 1)
815+
self.assertEqual(expected_mappings, exported_mappings)
816+
817+
s3_upload_key = source_v1.exclude_retired_export_path
818+
s3_mock.upload_file.assert_called_once_with(
819+
key=s3_upload_key, file_path=latest_temp_dir + '/export.zip', binary=True,
820+
metadata={'ContentType': 'application/zip'}, headers={'content-type': 'application/zip'}
821+
)
822+
s3_mock.url_for.assert_called_once_with(s3_upload_key)
823+
824+
import shutil
825+
shutil.rmtree(latest_temp_dir)
826+
780827

781828
class SourceLogoViewTest(OCLAPITestCase):
782829
def setUp(self):

core/sources/views.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from rest_framework.response import Response
1515

1616
from core.client_configs.views import ResourceClientConfigsView
17-
from core.common.constants import HEAD, RELEASED_PARAM, PROCESSING_PARAM, ACCESS_TYPE_NONE
17+
from core.common.constants import HEAD, RELEASED_PARAM, PROCESSING_PARAM, ACCESS_TYPE_NONE, INCLUDE_RETIRED_PARAM
1818
from core.common.mixins import ListWithHeadersMixin, ConceptDictionaryCreateMixin, ConceptDictionaryUpdateMixin, \
1919
ConceptContainerExportMixin, ConceptContainerProcessingMixin, ConceptContainerExtraRetrieveUpdateDestroyView
2020
from core.common.permissions import CanViewConceptDictionary, CanEditConceptDictionary, HasAccessToVersionedObject, \
@@ -392,7 +392,8 @@ class SourceVersionExportView(ConceptContainerExportMixin, SourceVersionBaseView
392392
def handle_export_version(self):
393393
version = self.get_object()
394394
try:
395-
export_source.delay(version.id)
395+
include_retired = parse_boolean_query_param(self.request, INCLUDE_RETIRED_PARAM, "True")
396+
export_source.delay(version.id, include_retired)
396397
return status.HTTP_202_ACCEPTED
397398
except AlreadyQueued:
398399
return status.HTTP_409_CONFLICT

0 commit comments

Comments
 (0)