aipscan 0.9.0.dev10__tar.gz → 0.9.0rc2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/.gitignore +1 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/database_helpers.py +22 -6
- aipscan-0.9.0rc2/AIPscan/Aggregator/downloads.py +24 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/forms.py +9 -2
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/mets_parse_helpers.py +23 -19
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/task_helpers.py +3 -3
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/tasks.py +63 -43
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/templates/edit_storage_service.html +8 -2
- aipscan-0.9.0rc2/AIPscan/Aggregator/tests/fixtures/legacy_mets/README.md +23 -0
- aipscan-0.9.0rc2/AIPscan/Aggregator/tests/fixtures/legacy_mets/production-aip-mets-file.xml +833 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/tests/fixtures/package_json/packages.json +1 -1
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/tests/test_database_helpers.py +162 -31
- aipscan-0.9.0rc2/AIPscan/Aggregator/tests/test_fetch_cli.py +107 -0
- aipscan-0.9.0rc2/AIPscan/Aggregator/tests/test_forms.py +42 -0
- aipscan-0.9.0rc2/AIPscan/Aggregator/tests/test_import_failures.py +142 -0
- aipscan-0.9.0rc2/AIPscan/Aggregator/tests/test_mets.py +118 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/tests/test_task_helpers.py +17 -9
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/tests/test_tasks.py +152 -1
- aipscan-0.9.0rc2/AIPscan/Aggregator/tests/test_views.py +290 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/views.py +30 -15
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Data/data.py +11 -26
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Data/report_data.py +90 -28
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Data/report_data_typesense.py +54 -66
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Data/tests/test_aip_contents.py +50 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Data/tests/test_format_versions_count.py +2 -2
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Data/tests/test_formats_count.py +2 -2
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/report_aip_contents.py +7 -3
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/report_formats_count.py +15 -5
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/report_ingest_log.py +3 -1
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/report_storage_locations.py +1 -1
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/templates/file.html +1 -1
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/templates/report_aips_by_puid.html +2 -2
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/templates/report_format_versions_count.html +1 -1
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/templates/report_formats_count.html +1 -1
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/templates/report_ingest_log_tabular.html +2 -2
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/templates/report_largest_aips.html +1 -1
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/templates/report_storage_locations_usage_over_time.html +1 -1
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/templates/reports.html +3 -3
- aipscan-0.9.0rc2/AIPscan/Reporter/tests/test_aip_contents.py +90 -0
- aipscan-0.9.0rc2/AIPscan/Reporter/tests/test_formats_count.py +205 -0
- aipscan-0.9.0rc2/AIPscan/Reporter/tests/test_indexed_metadata.py +98 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/tests/test_ingest_log.py +5 -2
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/tests/test_largest_aips.py +19 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/tests/test_views.py +19 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/views.py +4 -2
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/__init__.py +18 -5
- aipscan-0.9.0rc2/AIPscan/celery.py +46 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/config.py +21 -24
- aipscan-0.9.0rc2/AIPscan/conftest.py +572 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/helpers.py +1 -1
- aipscan-0.9.0rc2/AIPscan/migrations/README +1 -0
- aipscan-0.9.0rc2/AIPscan/migrations/alembic.ini +50 -0
- aipscan-0.9.0rc2/AIPscan/migrations/env.py +130 -0
- aipscan-0.9.0rc2/AIPscan/migrations/script.py.mako +24 -0
- aipscan-0.9.0rc2/AIPscan/migrations/versions/c605f2284613_initial_migration.py +299 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/models.py +21 -41
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/static/css/custom.css +3 -2
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/static/js/aggregator/storage_service.js +25 -8
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/static/js/reporter/plot_formats_count.js +2 -2
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/static/js/reporter/reports.js +34 -34
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/test_helpers.py +72 -15
- aipscan-0.9.0rc2/AIPscan/tests/test_app.py +46 -0
- aipscan-0.9.0rc2/AIPscan/tests/test_celery_config.py +55 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/tests/test_cli.py +8 -26
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/tests/test_typesense_helpers.py +51 -8
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/typesense_helpers.py +39 -8
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/typesense_test_helpers.py +15 -5
- aipscan-0.9.0rc2/PKG-INFO +123 -0
- aipscan-0.9.0rc2/README.md +87 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/build_config/base.config.js +8 -10
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/build_config/chart_formats_count.config.js +8 -10
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/build_config/entry/shared/base_shared.js +0 -2
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/build_config/plot_formats_count.config.js +10 -12
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/build_config/report_base.config.js +8 -10
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/package-lock.json +746 -1128
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/package.json +6 -7
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/pyproject.toml +25 -11
- aipscan-0.9.0.dev10/AIPscan/Aggregator/tests/test_mets.py +0 -54
- aipscan-0.9.0.dev10/AIPscan/Aggregator/tests/test_views.py +0 -142
- aipscan-0.9.0.dev10/AIPscan/Reporter/tests/test_aip_contents.py +0 -23
- aipscan-0.9.0.dev10/AIPscan/Reporter/tests/test_formats_count.py +0 -92
- aipscan-0.9.0.dev10/AIPscan/celery.py +0 -27
- aipscan-0.9.0.dev10/AIPscan/conftest.py +0 -609
- aipscan-0.9.0.dev10/PKG-INFO +0 -381
- aipscan-0.9.0.dev10/README.md +0 -342
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/API/__init__.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/API/fields.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/API/namespace_data.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/API/namespace_infos.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/API/namespace_report_data.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/API/views.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/__init__.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/celery_helpers.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/templates/confirm.html +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/templates/result_message.html +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/templates/storage_service.html +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/templates/storage_services.html +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/tests/__init__.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/tests/fixtures/features_mets/features-mets-added-agents.xml +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/tests/fixtures/features_mets/features-mets.xml +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/tests/fixtures/images_mets/images.xml +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/tests/fixtures/iso_mets/iso_mets.xml +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/tests/fixtures/original_name_mets/dataverse_example.xml +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/tests/fixtures/original_name_mets/document-empty-dirs.xml +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/tests/test_types.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/types.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Data/__init__.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Data/fields.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Data/tests/__init__.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Data/tests/test_agents_transfers.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Data/tests/test_aips_by_format.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Data/tests/test_aips_by_puid.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Data/tests/test_largest_aips.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Data/tests/test_largest_files.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Data/tests/test_preservation_derivatives.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Data/tests/test_storage_locations.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Home/__init__.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Home/views.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/__init__.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/database_helpers.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/helpers.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/report_aips_by_format.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/report_aips_by_puid.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/report_format_versions_count.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/report_largest_aips.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/report_largest_files.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/report_preservation_derivatives.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/request_params.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/templates/aip.html +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/templates/aips.html +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/templates/chart_formats_count.html +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/templates/plot_formats_count.html +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/templates/report_aip_contents.html +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/templates/report_aips_by_format.html +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/templates/report_buttons.html +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/templates/report_buttons_print_only.html +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/templates/report_ingest_log_gantt.html +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/templates/report_largest_files.html +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/templates/report_preservation_derivatives.html +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/templates/report_storage_locations.html +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/tests/__init__.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/tests/fixtures/storage_location_usage_cumulative_aips.csv +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/tests/fixtures/storage_location_usage_cumulative_files.csv +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/tests/fixtures/storage_location_usage_cumulative_size.csv +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/tests/fixtures/storage_location_usage_differential_aips.csv +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/tests/fixtures/storage_location_usage_differential_files.csv +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/tests/fixtures/storage_location_usage_differential_size.csv +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/tests/test_aips_by_file_format.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/tests/test_aips_by_puid.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/tests/test_format_versions_count.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/tests/test_helpers.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/tests/test_largest_files.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/tests/test_preservation_derivatives.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/tests/test_storage_locations.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Reporter/tests/test_update_dates.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/User/__init__.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/User/forms.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/User/views.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/cli.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/decorators.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/navbar.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/run.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/static/css/style_ingest_log.css +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/static/favicon.ico +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/static/js/report.js +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/static/js/reporter/aips.js +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/static/js/reporter/chart_formats_count.js +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/templates/base.html +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/templates/error/404.html +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/templates/partials/navbar.html +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/templates/report_base.html +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/tests/__init__.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/tests/test_helpers.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/tests/test_models.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/tests/test_typsense_test_helpers.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/worker.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/LICENSE +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/build.py +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/build_config/entry/base.entry.js +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/build_config/entry/chart_formats_count.entry.js +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/build_config/entry/plot_formats_count.entry.js +0 -0
- {aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/build_config/entry/report_base.entry.js +0 -0
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
database.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import os
|
|
6
6
|
|
|
7
7
|
from celery.utils.log import get_task_logger
|
|
8
|
+
from dateutil.parser import parse
|
|
8
9
|
from lxml import etree
|
|
9
10
|
|
|
10
11
|
from AIPscan import db
|
|
11
12
|
from AIPscan.Aggregator import tasks
|
|
13
|
+
from AIPscan.Aggregator.downloads import get_download_root
|
|
12
14
|
from AIPscan.Aggregator.task_helpers import _tz_neutral_date
|
|
13
15
|
from AIPscan.Aggregator.task_helpers import get_storage_service_api_url
|
|
14
16
|
from AIPscan.models import AIP
|
|
@@ -247,6 +249,16 @@ def create_or_update_pipeline(origin_pipeline, storage_service):
|
|
|
247
249
|
return pipeline
|
|
248
250
|
|
|
249
251
|
|
|
252
|
+
def _optional_file_date(value):
|
|
253
|
+
"""Keep absent or unusable PREMIS file dates unknown."""
|
|
254
|
+
if value is None or value == () or value == "":
|
|
255
|
+
return None
|
|
256
|
+
try:
|
|
257
|
+
return parse(value).replace(tzinfo=None, microsecond=0)
|
|
258
|
+
except ValueError, TypeError, OverflowError:
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
250
262
|
def _get_file_properties(fs_entry):
|
|
251
263
|
"""Retrieve file properties from FSEntry
|
|
252
264
|
|
|
@@ -270,14 +282,17 @@ def _get_file_properties(fs_entry):
|
|
|
270
282
|
|
|
271
283
|
try:
|
|
272
284
|
for premis_object in fs_entry.get_premis_objects():
|
|
273
|
-
|
|
285
|
+
size = premis_object.size
|
|
286
|
+
file_info["size"] = None if size == () else size
|
|
274
287
|
key_alias = premis_object.format_registry_key
|
|
275
|
-
file_info["date_created"] =
|
|
288
|
+
file_info["date_created"] = _optional_file_date(
|
|
276
289
|
premis_object.date_created_by_application
|
|
277
290
|
)
|
|
278
291
|
if not isinstance(key_alias, tuple):
|
|
279
292
|
file_info["puid"] = key_alias
|
|
280
|
-
|
|
293
|
+
name_alias = premis_object.format_name
|
|
294
|
+
if not isinstance(name_alias, tuple):
|
|
295
|
+
file_info["file_format"] = name_alias
|
|
281
296
|
version_alias = premis_object.format_version
|
|
282
297
|
if not isinstance(version_alias, tuple):
|
|
283
298
|
file_info["format_version"] = version_alias
|
|
@@ -406,13 +421,15 @@ def process_aip_data(aip, mets):
|
|
|
406
421
|
|
|
407
422
|
|
|
408
423
|
def create_fetch_job(datetime_obj_start, timestamp_str, storage_server_id):
|
|
424
|
+
download_root = get_download_root()
|
|
425
|
+
download_directory = os.path.join(download_root, timestamp_str, "")
|
|
409
426
|
fetch_job = FetchJob(
|
|
410
427
|
total_packages=None,
|
|
411
428
|
total_deleted_aips=None,
|
|
412
429
|
total_aips=None,
|
|
413
430
|
download_start=datetime_obj_start,
|
|
414
431
|
download_end=None,
|
|
415
|
-
download_directory=
|
|
432
|
+
download_directory=download_directory,
|
|
416
433
|
storage_service_id=storage_server_id,
|
|
417
434
|
)
|
|
418
435
|
db.session.add(fetch_job)
|
|
@@ -453,7 +470,6 @@ def update_fetch_job(fetch_job_id, processed_packages, total_packages_count):
|
|
|
453
470
|
obj.total_sips = total_sips
|
|
454
471
|
obj.total_replicas = total_replicas
|
|
455
472
|
obj.total_deleted_aips = total_deleted_aips
|
|
456
|
-
obj.download_end = datetime.now().replace(microsecond=0)
|
|
457
473
|
db.session.commit()
|
|
458
474
|
|
|
459
475
|
return obj
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Helpers for working with Aggregator download directories."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from importlib import resources
|
|
5
|
+
|
|
6
|
+
from flask import current_app
|
|
7
|
+
|
|
8
|
+
DEFAULT_DOWNLOAD_ROOT = os.fspath(resources.files(__package__).joinpath("downloads"))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_download_root(app=None) -> str:
|
|
12
|
+
"""Return the base directory used to stage download artifacts."""
|
|
13
|
+
if app is not None:
|
|
14
|
+
return app.config.get("AGGREGATOR_DOWNLOAD_ROOT", DEFAULT_DOWNLOAD_ROOT)
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
application = current_app._get_current_object()
|
|
18
|
+
except RuntimeError:
|
|
19
|
+
env_override = os.getenv("AGGREGATOR_DOWNLOAD_ROOT")
|
|
20
|
+
if env_override:
|
|
21
|
+
return env_override
|
|
22
|
+
return DEFAULT_DOWNLOAD_ROOT
|
|
23
|
+
|
|
24
|
+
return application.config.get("AGGREGATOR_DOWNLOAD_ROOT", DEFAULT_DOWNLOAD_ROOT)
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
from flask_wtf import FlaskForm
|
|
2
2
|
from wtforms import BooleanField
|
|
3
|
+
from wtforms import IntegerField
|
|
3
4
|
from wtforms import StringField
|
|
4
5
|
from wtforms.validators import DataRequired
|
|
6
|
+
from wtforms.validators import InputRequired
|
|
7
|
+
from wtforms.validators import NumberRange
|
|
5
8
|
from wtforms.widgets import PasswordInput
|
|
6
9
|
|
|
7
10
|
|
|
@@ -12,6 +15,10 @@ class StorageServiceForm(FlaskForm):
|
|
|
12
15
|
api_key = StringField(
|
|
13
16
|
"API key", validators=[DataRequired()], widget=PasswordInput(hide_value=False)
|
|
14
17
|
)
|
|
15
|
-
download_limit =
|
|
16
|
-
|
|
18
|
+
download_limit = IntegerField(
|
|
19
|
+
"Download limit", default=20, validators=[InputRequired(), NumberRange(min=1)]
|
|
20
|
+
)
|
|
21
|
+
download_offset = IntegerField(
|
|
22
|
+
"Download offset", default=0, validators=[InputRequired(), NumberRange(min=0)]
|
|
23
|
+
)
|
|
17
24
|
default = BooleanField("Default")
|
|
@@ -40,17 +40,14 @@ def parse_mets_with_metsrw(mets_file):
|
|
|
40
40
|
return mets
|
|
41
41
|
|
|
42
42
|
|
|
43
|
-
def get_aip_original_name(mets):
|
|
44
|
-
"""Retrieve
|
|
43
|
+
def get_aip_original_name(mets, package_uuid):
|
|
44
|
+
"""Retrieve the package name from PREMIS or its physical directory label.
|
|
45
45
|
|
|
46
46
|
If the original name cannot be reliably retrieved from the METS file
|
|
47
47
|
a METSError exception is returned to be handled by the caller as
|
|
48
48
|
desired.
|
|
49
49
|
"""
|
|
50
50
|
|
|
51
|
-
# Negated as we're going to want to remove this length of values.
|
|
52
|
-
NAMESUFFIX = -len("-00000000-0000-0000-0000-000000000000")
|
|
53
|
-
|
|
54
51
|
# The transfer directory prefix is a directory prefix that can also
|
|
55
52
|
# exist in a dmdSec intellectual entity and we want to identify and
|
|
56
53
|
# ignore those.
|
|
@@ -66,16 +63,22 @@ def get_aip_original_name(mets):
|
|
|
66
63
|
full_name = dmd_element.find(
|
|
67
64
|
ELEM_ORIGINAL_NAME_PATTERN, namespaces=NAMESPACES
|
|
68
65
|
)
|
|
69
|
-
if full_name is
|
|
66
|
+
if full_name is None or not full_name.text:
|
|
67
|
+
continue
|
|
68
|
+
if full_name.text.startswith(TRANSFER_DIR_PREFIX):
|
|
70
69
|
# We don't want this value, it will usually represent an
|
|
71
70
|
# directory entity.
|
|
72
71
|
continue
|
|
73
|
-
|
|
74
|
-
original_name = full_name.text[:NAMESUFFIX]
|
|
75
|
-
except AttributeError:
|
|
76
|
-
continue
|
|
72
|
+
original_name = full_name.text
|
|
77
73
|
|
|
78
|
-
#
|
|
74
|
+
# Older Archivematica METS only identify the package in the physical
|
|
75
|
+
# structure map. METSRW represents its top-level divs as parentless entries.
|
|
76
|
+
if not original_name:
|
|
77
|
+
roots = [entry for entry in mets.all_files() if entry.parent is None]
|
|
78
|
+
if len(roots) == 1 and roots[0].type == "Directory":
|
|
79
|
+
original_name = roots[0].label or ""
|
|
80
|
+
|
|
81
|
+
original_name = original_name.removesuffix(f"-{package_uuid}")
|
|
79
82
|
if original_name == "":
|
|
80
83
|
raise METSError("Cannot locate transfer name in METS")
|
|
81
84
|
|
|
@@ -94,14 +97,15 @@ def download_mets(
|
|
|
94
97
|
numbered_subdir = create_numbered_subdirs(timestamp, package_list_no)
|
|
95
98
|
download_file = os.path.join(numbered_subdir, f"METS.{package_uuid}.xml")
|
|
96
99
|
|
|
97
|
-
mets_response = requests.get(
|
|
98
|
-
get_mets_url(storage_service, package_uuid, relative_path_to_mets),
|
|
99
|
-
stream=True,
|
|
100
|
-
)
|
|
101
|
-
|
|
102
100
|
try:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
with requests.get(
|
|
102
|
+
get_mets_url(storage_service, package_uuid, relative_path_to_mets),
|
|
103
|
+
stream=True,
|
|
104
|
+
timeout=(10, 120),
|
|
105
|
+
) as mets_response:
|
|
106
|
+
mets_response.raise_for_status()
|
|
107
|
+
mets_hash = stream_write_and_hash(mets_response, download_file)
|
|
108
|
+
except requests.RequestException:
|
|
109
|
+
raise METSError(f"Cannot download METS for AIP {package_uuid}") from None
|
|
106
110
|
|
|
107
111
|
return download_file, mets_hash
|
|
@@ -9,6 +9,7 @@ from datetime import datetime
|
|
|
9
9
|
from dateutil.parser import ParserError
|
|
10
10
|
from dateutil.parser import parse
|
|
11
11
|
|
|
12
|
+
from AIPscan.Aggregator.downloads import get_download_root
|
|
12
13
|
from AIPscan.Aggregator.types import StorageServicePackage
|
|
13
14
|
|
|
14
15
|
|
|
@@ -27,7 +28,7 @@ def get_packages_directory(timestamp):
|
|
|
27
28
|
"""Create a path which we will use to store packages downloaded from
|
|
28
29
|
the storage service plus other metadata.
|
|
29
30
|
"""
|
|
30
|
-
return os.path.join(
|
|
31
|
+
return os.path.join(get_download_root(), timestamp, "packages")
|
|
31
32
|
|
|
32
33
|
|
|
33
34
|
def parse_package_list_file(filepath, logger=None, remove_after_parsing=False):
|
|
@@ -129,12 +130,11 @@ def create_numbered_subdirs(timestamp, package_list_number):
|
|
|
129
130
|
"""Check for the existence and create a container folder for our
|
|
130
131
|
METS files as required.
|
|
131
132
|
"""
|
|
132
|
-
AGGREGATOR_DOWNLOADS = os.path.join("AIPscan", "Aggregator", "downloads")
|
|
133
133
|
METS_FOLDER = "mets"
|
|
134
134
|
|
|
135
135
|
# Create a package list numbered subdirectory if it doesn't exist.
|
|
136
136
|
numbered_subdir = os.path.join(
|
|
137
|
-
|
|
137
|
+
get_download_root(), timestamp, METS_FOLDER, str(package_list_number)
|
|
138
138
|
)
|
|
139
139
|
if not os.path.exists(numbered_subdir):
|
|
140
140
|
os.makedirs(numbered_subdir)
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
3
|
import shutil
|
|
4
|
+
import time
|
|
4
5
|
from datetime import datetime
|
|
5
6
|
|
|
6
7
|
import requests
|
|
7
8
|
from celery.utils.log import get_task_logger
|
|
9
|
+
from flask import current_app
|
|
8
10
|
|
|
9
11
|
from AIPscan import db
|
|
10
12
|
from AIPscan import typesense_helpers
|
|
@@ -127,15 +129,25 @@ def workflow_coordinator(
|
|
|
127
129
|
# Wait for package lists task to finish downloading all package
|
|
128
130
|
# lists.
|
|
129
131
|
task = package_lists_request.AsyncResult(package_lists_task.id, app=celery)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
132
|
+
idle_timeout = current_app.config["PACKAGE_LIST_IDLE_TIMEOUT_SECONDS"]
|
|
133
|
+
deadline = time.monotonic() + idle_timeout
|
|
134
|
+
completed_lists = 0
|
|
135
|
+
while task.state not in {"SUCCESS", "FAILURE"}:
|
|
136
|
+
now = time.monotonic()
|
|
137
|
+
info = task.info
|
|
138
|
+
progress = info if isinstance(info, dict) else {}
|
|
139
|
+
if progress.get("completedPackageLists", 0) > completed_lists:
|
|
140
|
+
completed_lists = progress["completedPackageLists"]
|
|
141
|
+
deadline = now + idle_timeout
|
|
142
|
+
if now >= deadline:
|
|
143
|
+
raise TaskError(
|
|
144
|
+
f"Package listing made no progress for {idle_timeout} seconds"
|
|
145
|
+
)
|
|
146
|
+
time.sleep(0.2)
|
|
147
|
+
if task.state == "FAILURE":
|
|
148
|
+
raise TaskError("Package listing failed; check the worker logs")
|
|
137
149
|
|
|
138
|
-
total_package_lists =
|
|
150
|
+
total_package_lists = task.info["totalPackageLists"]
|
|
139
151
|
|
|
140
152
|
all_packages = []
|
|
141
153
|
for package_list_no in range(1, total_package_lists + 1):
|
|
@@ -152,7 +164,7 @@ def workflow_coordinator(
|
|
|
152
164
|
|
|
153
165
|
all_packages = all_packages + packages
|
|
154
166
|
|
|
155
|
-
total_packages_count =
|
|
167
|
+
total_packages_count = task.info["totalPackages"]
|
|
156
168
|
|
|
157
169
|
obj = database_helpers.update_fetch_job(
|
|
158
170
|
fetch_job_id, all_packages, total_packages_count
|
|
@@ -165,7 +177,11 @@ def make_request(request_url, request_url_without_api_key):
|
|
|
165
177
|
"""Make our request to the storage service and return a valid
|
|
166
178
|
response to our caller or raise a TaskError for celery.
|
|
167
179
|
"""
|
|
168
|
-
|
|
180
|
+
try:
|
|
181
|
+
response = requests.get(request_url, timeout=(10, 120))
|
|
182
|
+
except requests.RequestException:
|
|
183
|
+
# Request exceptions may contain the URL, including its API key.
|
|
184
|
+
raise TaskError(f"Cannot connect to: {request_url_without_api_key}") from None
|
|
169
185
|
if response.status_code != requests.codes.ok:
|
|
170
186
|
err = f"Check the URL and API details, cannot connect to: `{request_url_without_api_key}`"
|
|
171
187
|
logger.error(err)
|
|
@@ -225,7 +241,8 @@ def package_lists_request(self, storage_service_id, timestamp, packages_director
|
|
|
225
241
|
self.update_state(
|
|
226
242
|
state=IN_PROGRESS,
|
|
227
243
|
meta={
|
|
228
|
-
"
|
|
244
|
+
"completedPackageLists": packages_count,
|
|
245
|
+
"message": f"Total packages: {total_packages} Total package lists: {total_package_lists}",
|
|
229
246
|
},
|
|
230
247
|
)
|
|
231
248
|
|
|
@@ -292,8 +309,6 @@ def get_mets(
|
|
|
292
309
|
This function relies on being able to use mets-reader-writer which
|
|
293
310
|
is the primary object we will be passing about.
|
|
294
311
|
|
|
295
|
-
TODO: Log METS errors.
|
|
296
|
-
|
|
297
312
|
The "customlogger" argument allows an external logger to be specified when
|
|
298
313
|
the task's logic is executed, using the task's "apply" method, by an
|
|
299
314
|
external application like a batch script.
|
|
@@ -305,13 +320,16 @@ def get_mets(
|
|
|
305
320
|
|
|
306
321
|
# Download METS file
|
|
307
322
|
storage_service = db.session.get(StorageService, storage_service_id)
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
323
|
+
try:
|
|
324
|
+
download_file, mets_hash = download_mets(
|
|
325
|
+
storage_service,
|
|
326
|
+
package_uuid,
|
|
327
|
+
relative_path_to_mets,
|
|
328
|
+
timestamp_str,
|
|
329
|
+
package_list_no,
|
|
330
|
+
)
|
|
331
|
+
except METSError as err:
|
|
332
|
+
raise TaskError(str(err)) from err
|
|
315
333
|
mets_name = os.path.basename(download_file)
|
|
316
334
|
|
|
317
335
|
# If METS file's hash matches an existing value, this is a duplicate of an
|
|
@@ -331,12 +349,11 @@ def get_mets(
|
|
|
331
349
|
|
|
332
350
|
try:
|
|
333
351
|
mets = parse_mets_with_metsrw(download_file)
|
|
334
|
-
except METSError:
|
|
335
|
-
|
|
336
|
-
return
|
|
352
|
+
except METSError as err:
|
|
353
|
+
raise TaskError(f"Cannot parse METS for AIP {package_uuid}: {err}") from err
|
|
337
354
|
|
|
338
355
|
try:
|
|
339
|
-
original_name = get_aip_original_name(mets)
|
|
356
|
+
original_name = get_aip_original_name(mets, package_uuid)
|
|
340
357
|
except METSError:
|
|
341
358
|
# Some other error with the METS file that we might want to
|
|
342
359
|
# log and act upon.
|
|
@@ -344,26 +361,29 @@ def get_mets(
|
|
|
344
361
|
|
|
345
362
|
# Delete records of any previous versions of this AIP, which will shortly
|
|
346
363
|
# be replaced by new records from the updated METS.
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
aip = database_helpers.create_aip_object(
|
|
355
|
-
package_uuid=package_uuid,
|
|
356
|
-
transfer_name=original_name,
|
|
357
|
-
create_date=mets.createdate,
|
|
358
|
-
mets_sha256=mets_hash,
|
|
359
|
-
size=aip_size,
|
|
360
|
-
storage_service_id=storage_service_id,
|
|
361
|
-
storage_location_id=storage_location_id,
|
|
362
|
-
fetch_job_id=fetch_job_id,
|
|
363
|
-
origin_pipeline_id=origin_pipeline_id,
|
|
364
|
-
)
|
|
364
|
+
try:
|
|
365
|
+
previous_aips = AIP.query.filter_by(uuid=package_uuid).all()
|
|
366
|
+
for previous_aip in previous_aips:
|
|
367
|
+
tasklogger.info(
|
|
368
|
+
f"Deleting record for AIP {package_uuid} to replace from newer METS"
|
|
369
|
+
)
|
|
370
|
+
database_helpers.delete_aip_object(previous_aip)
|
|
365
371
|
|
|
366
|
-
|
|
372
|
+
aip = database_helpers.create_aip_object(
|
|
373
|
+
package_uuid=package_uuid,
|
|
374
|
+
transfer_name=original_name,
|
|
375
|
+
create_date=mets.createdate,
|
|
376
|
+
mets_sha256=mets_hash,
|
|
377
|
+
size=aip_size,
|
|
378
|
+
storage_service_id=storage_service_id,
|
|
379
|
+
storage_location_id=storage_location_id,
|
|
380
|
+
fetch_job_id=fetch_job_id,
|
|
381
|
+
origin_pipeline_id=origin_pipeline_id,
|
|
382
|
+
)
|
|
383
|
+
database_helpers.process_aip_data(aip, mets)
|
|
384
|
+
except Exception as err:
|
|
385
|
+
db.session.rollback()
|
|
386
|
+
raise TaskError(f"Cannot import METS for AIP {package_uuid}") from err
|
|
367
387
|
|
|
368
388
|
# Delete downloaded METS file.
|
|
369
389
|
try:
|
{aipscan-0.9.0.dev10 → aipscan-0.9.0rc2}/AIPscan/Aggregator/templates/edit_storage_service.html
RENAMED
|
@@ -23,8 +23,14 @@
|
|
|
23
23
|
{% for error in form.api_key.errors %}
|
|
24
24
|
<span style="color: red;">[{{ error }}]</span>
|
|
25
25
|
{% endfor %}</td></tr>
|
|
26
|
-
<tr><td><strong>{{ form.download_limit.label }}</strong></td><td>{{ form.download_limit(size=48) }}
|
|
27
|
-
|
|
26
|
+
<tr><td><strong>{{ form.download_limit.label }}</strong></td><td>{{ form.download_limit(size=48) }}
|
|
27
|
+
{% for error in form.download_limit.errors %}
|
|
28
|
+
<span style="color: red;">[{{ error }}]</span>
|
|
29
|
+
{% endfor %}</td></tr>
|
|
30
|
+
<tr><td><strong>{{ form.download_offset.label }}</strong></td><td>{{ form.download_offset(size=48) }}
|
|
31
|
+
{% for error in form.download_offset.errors %}
|
|
32
|
+
<span style="color: red;">[{{ error }}]</span>
|
|
33
|
+
{% endfor %}</td></tr>
|
|
28
34
|
<tr><td><strong>{{ form.default.label }}</strong></td><td>{{ form.default() }}</td></tr>
|
|
29
35
|
</table>
|
|
30
36
|
<button type="submit" class="btn btn-success">Save</button>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Legacy METS fixture
|
|
2
|
+
|
|
3
|
+
`production-aip-mets-file.xml` is copied unchanged from
|
|
4
|
+
[mets-reader-writer] at revision `0affd1133b3fee11c1ee911eb445ba8504c9be1c`.
|
|
5
|
+
Its introducing commit, [1a1fcc93], describes it as generated by an
|
|
6
|
+
Archivematica QA deployment. The document identifies Archivematica 1.6 and
|
|
7
|
+
uses PREMIS 2.2. Its original file, `abc.txt`, has size and format metadata
|
|
8
|
+
but no `dateCreatedByApplication`.
|
|
9
|
+
|
|
10
|
+
The upstream suite uses this fixture for METS XMLSchema and Archivematica
|
|
11
|
+
Schematron validation. AIPscan's import tests vary only the original file's
|
|
12
|
+
optional creation date in memory; the fixture on disk remains unchanged.
|
|
13
|
+
|
|
14
|
+
AIP name tests also use this fixture to recover the package name from the
|
|
15
|
+
physical structure map when no PREMIS package name is available. They vary
|
|
16
|
+
the root directory label in memory to exercise UUID suffix handling.
|
|
17
|
+
|
|
18
|
+
The Dagger scenario packages this METS unchanged and also creates a variant
|
|
19
|
+
with the original file's format identification removed. It verifies both
|
|
20
|
+
packages through Storage Service, Celery, CSV exports, and browser charts.
|
|
21
|
+
|
|
22
|
+
[mets-reader-writer]: https://github.com/artefactual-labs/mets-reader-writer/blob/0affd1133b3fee11c1ee911eb445ba8504c9be1c/fixtures/production-aip-mets-file.xml
|
|
23
|
+
[1a1fcc93]: https://github.com/artefactual-labs/mets-reader-writer/commit/1a1fcc93e73237e1a7c252b416dc4b6e443e9185
|