das-cli 1.0.6__py3-none-any.whl → 1.2.4__py3-none-any.whl
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.
- das/ai/plugins/dasai.py +11 -2
- das/cli.py +130 -6
- das/common/api.py +20 -0
- das/common/config.py +6 -0
- das/managers/digital_objects_manager.py +84 -0
- das/managers/download_manager.py +43 -1
- das/managers/entries_manager.py +37 -1
- das/managers/search_manager.py +17 -2
- das/services/digital_objects.py +142 -0
- das/services/downloads.py +19 -2
- das/services/entries.py +29 -3
- das_cli-1.2.4.dist-info/METADATA +1076 -0
- das_cli-1.2.4.dist-info/RECORD +32 -0
- {das_cli-1.0.6.dist-info → das_cli-1.2.4.dist-info}/WHEEL +1 -1
- das_cli-1.0.6.dist-info/METADATA +0 -408
- das_cli-1.0.6.dist-info/RECORD +0 -30
- {das_cli-1.0.6.dist-info → das_cli-1.2.4.dist-info}/entry_points.txt +0 -0
- {das_cli-1.0.6.dist-info → das_cli-1.2.4.dist-info}/licenses/LICENSE +0 -0
- {das_cli-1.0.6.dist-info → das_cli-1.2.4.dist-info}/top_level.txt +0 -0
das/ai/plugins/dasai.py
CHANGED
|
@@ -3,6 +3,7 @@ import logging
|
|
|
3
3
|
from dotenv import load_dotenv
|
|
4
4
|
import asyncio
|
|
5
5
|
from semantic_kernel import Kernel
|
|
6
|
+
from openai import OpenAI
|
|
6
7
|
from semantic_kernel.connectors.ai.open_ai import (
|
|
7
8
|
OpenAIChatCompletion,
|
|
8
9
|
)
|
|
@@ -22,9 +23,17 @@ class DasAI:
|
|
|
22
23
|
api_key = os.getenv("OPENAI_API_KEY") or load_openai_api_key()
|
|
23
24
|
if not api_key:
|
|
24
25
|
raise ValueError("OpenAI API key is not configured.")
|
|
25
|
-
self.openai_chat_completion = OpenAIChatCompletion(ai_model_id="
|
|
26
|
+
self.openai_chat_completion = OpenAIChatCompletion(ai_model_id=os.getenv("OPEN_AI_MODEL_ID"), api_key=api_key)
|
|
26
27
|
self.kernel.add_service(self.openai_chat_completion)
|
|
27
|
-
self.kernel.add_plugin(GetEntryByCodePlugin(), plugin_name="get_entry_by_code")
|
|
28
|
+
self.kernel.add_plugin(GetEntryByCodePlugin(), plugin_name="get_entry_by_code")
|
|
29
|
+
# self.__load_available_models()
|
|
30
|
+
|
|
31
|
+
def __load_available_models(self):
|
|
32
|
+
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY") or load_openai_api_key())
|
|
33
|
+
models = client.models.list()
|
|
34
|
+
for model in models:
|
|
35
|
+
print(model.id)
|
|
36
|
+
|
|
28
37
|
|
|
29
38
|
async def main(self):
|
|
30
39
|
history = ChatHistory()
|
das/cli.py
CHANGED
|
@@ -14,6 +14,7 @@ from das.app import Das
|
|
|
14
14
|
from das.managers.download_manager import DownloadManager
|
|
15
15
|
from das.managers.entries_manager import EntryManager
|
|
16
16
|
from das.managers.search_manager import SearchManager
|
|
17
|
+
from das.managers.digital_objects_manager import DigitalObjectsManager
|
|
17
18
|
from das.common.file_utils import load_file_based_on_extension, parse_data_string
|
|
18
19
|
from das.ai.plugins.dasai import DasAI
|
|
19
20
|
|
|
@@ -51,6 +52,7 @@ class DasCLI:
|
|
|
51
52
|
self.entry_manager = None
|
|
52
53
|
self.search_manager = None
|
|
53
54
|
self.download_manager = None
|
|
55
|
+
self.digital_objects_manager = None
|
|
54
56
|
self.das_ai = None
|
|
55
57
|
|
|
56
58
|
def get_client(self):
|
|
@@ -65,6 +67,7 @@ class DasCLI:
|
|
|
65
67
|
self.entry_manager = EntryManager()
|
|
66
68
|
self.search_manager = SearchManager()
|
|
67
69
|
self.download_manager = DownloadManager()
|
|
70
|
+
self.digital_objects_manager = DigitalObjectsManager()
|
|
68
71
|
# Set SSL verification based on saved config
|
|
69
72
|
if not self.client:
|
|
70
73
|
self.client = Das(self.api_url)
|
|
@@ -80,6 +83,7 @@ class DasCLI:
|
|
|
80
83
|
pass_das_context = click.make_pass_decorator(DasCLI, ensure=True)
|
|
81
84
|
|
|
82
85
|
@click.group()
|
|
86
|
+
@click.version_option(package_name="das-cli")
|
|
83
87
|
@click.pass_context
|
|
84
88
|
def cli(ctx):
|
|
85
89
|
"""DAS Python CLI - Data Archive System client tool"""
|
|
@@ -397,14 +401,67 @@ def update_entry(das_ctx, attribute, code=None, file_path=None, data=None):
|
|
|
397
401
|
click.secho(f"Error: {e}", fg="red")
|
|
398
402
|
|
|
399
403
|
@entry.command("delete")
|
|
400
|
-
@click.
|
|
404
|
+
@click.option('--code', default=None, help='Entry code')
|
|
405
|
+
@click.option('--id', default=None, help='Entry ID')
|
|
406
|
+
@click.option('--force', is_flag=True, help='Skip confirmation prompt')
|
|
401
407
|
@pass_das_context
|
|
402
|
-
def delete_entry(das_ctx, code):
|
|
403
|
-
"""Delete entry by its code"""
|
|
404
|
-
|
|
408
|
+
def delete_entry(das_ctx, code, id, force):
|
|
409
|
+
"""Delete an entry by its code or ID"""
|
|
410
|
+
if not code and not id:
|
|
411
|
+
raise click.UsageError("Please provide either an entry code or ID")
|
|
412
|
+
|
|
413
|
+
# Ensure client and entry_manager are initialized
|
|
414
|
+
das_ctx.get_client()
|
|
415
|
+
|
|
416
|
+
identifier = code or id
|
|
417
|
+
id_label = "code" if code else "ID"
|
|
418
|
+
|
|
419
|
+
if not force:
|
|
420
|
+
if not click.confirm(f"Are you sure you want to delete entry with {id_label} '{identifier}'?"):
|
|
421
|
+
click.echo("Operation cancelled.")
|
|
422
|
+
return
|
|
423
|
+
|
|
405
424
|
try:
|
|
406
|
-
|
|
407
|
-
click.secho(f"✓ Entry '{
|
|
425
|
+
das_ctx.entry_manager.delete(id=id, code=code)
|
|
426
|
+
click.secho(f"✓ Entry with {id_label} '{identifier}' deleted!", fg="green")
|
|
427
|
+
except Exception as e:
|
|
428
|
+
click.secho(f"Error: {e}", fg="red")
|
|
429
|
+
|
|
430
|
+
@entry.command("link-digital-objects")
|
|
431
|
+
@click.option('--entry-code', required=True, help='Entry code to link/unlink digital objects to')
|
|
432
|
+
@click.option('--digital-object-code', '-d', multiple=True, required=True, help='Digital object code. Use multiple times for multiple objects.')
|
|
433
|
+
@click.option('--unlink', is_flag=True, help='Unlink specified digital objects from the entry instead of linking')
|
|
434
|
+
@pass_das_context
|
|
435
|
+
def link_digital_objects(das_ctx, entry_code, digital_object_code, unlink):
|
|
436
|
+
"""Link or unlink existing digital objects to an entry by their codes.
|
|
437
|
+
|
|
438
|
+
Examples:
|
|
439
|
+
|
|
440
|
+
\b
|
|
441
|
+
# Link two digital objects to an entry
|
|
442
|
+
das entry link-digital-objects --entry-code ENT001 -d DO001 -d DO002
|
|
443
|
+
|
|
444
|
+
\b
|
|
445
|
+
# Unlink a digital object from an entry
|
|
446
|
+
das entry link-digital-objects --entry-code ENT001 -d DO003 --unlink
|
|
447
|
+
"""
|
|
448
|
+
try:
|
|
449
|
+
das_ctx.get_client()
|
|
450
|
+
codes = list(digital_object_code)
|
|
451
|
+
if not codes:
|
|
452
|
+
raise click.UsageError("Please provide at least one --digital-object-code")
|
|
453
|
+
|
|
454
|
+
success = das_ctx.digital_objects_manager.link_existing_digital_objects(
|
|
455
|
+
entry_code=entry_code,
|
|
456
|
+
digital_object_code_list=codes,
|
|
457
|
+
is_unlink=unlink,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
if success:
|
|
461
|
+
action = "unlinked" if unlink else "linked"
|
|
462
|
+
click.secho(f"✓ Successfully {action} {len(codes)} digital object(s) for entry '{entry_code}'", fg="green")
|
|
463
|
+
else:
|
|
464
|
+
click.secho("Operation did not report success.", fg="yellow")
|
|
408
465
|
except Exception as e:
|
|
409
466
|
click.secho(f"Error: {e}", fg="red")
|
|
410
467
|
|
|
@@ -509,6 +566,41 @@ def create_entry(das_ctx, attribute, file_path=None, data=None):
|
|
|
509
566
|
except Exception as e:
|
|
510
567
|
click.secho(f"Error: {e}", fg="red")
|
|
511
568
|
|
|
569
|
+
@entry.command("upload-digital-object")
|
|
570
|
+
@click.option('--entry-code', required=True, help='Entry code to attach the digital object to')
|
|
571
|
+
@click.option('--type', 'digital_object_type', required=True, help='Digital object type name (e.g., Dataset, File, Image)')
|
|
572
|
+
@click.option('--description', 'file_description', default='', help='Description for the uploaded file')
|
|
573
|
+
@click.argument('file_path', required=True)
|
|
574
|
+
@pass_das_context
|
|
575
|
+
def upload_digital_object(das_ctx, entry_code, digital_object_type, file_description, file_path):
|
|
576
|
+
"""Upload a file as a digital object and link it to an entry.
|
|
577
|
+
|
|
578
|
+
Examples:
|
|
579
|
+
|
|
580
|
+
\b
|
|
581
|
+
# Upload a dataset file and link to an entry
|
|
582
|
+
das entry upload-digital-object --entry-code ENT001 --type Dataset --description "CTD raw" c:\\data\\ctd.zip
|
|
583
|
+
"""
|
|
584
|
+
try:
|
|
585
|
+
# Ensure services are initialized
|
|
586
|
+
das_ctx.get_client()
|
|
587
|
+
|
|
588
|
+
# Perform upload and link
|
|
589
|
+
digital_object_id = das_ctx.digital_objects_manager.upload_digital_object(
|
|
590
|
+
entry_code=entry_code,
|
|
591
|
+
file_description=file_description,
|
|
592
|
+
digital_object_type=digital_object_type,
|
|
593
|
+
file_path=file_path,
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
if digital_object_id:
|
|
597
|
+
click.secho("✓ Digital object uploaded and linked successfully!", fg="green")
|
|
598
|
+
click.echo(f"Digital Object ID: {digital_object_id}")
|
|
599
|
+
else:
|
|
600
|
+
click.secho("Upload completed but no ID was returned.", fg="yellow")
|
|
601
|
+
except Exception as e:
|
|
602
|
+
click.secho(f"Error: {e}", fg="red")
|
|
603
|
+
|
|
512
604
|
@entry.command("get")
|
|
513
605
|
@click.option('--code', default=None, help='Entry code')
|
|
514
606
|
@click.option('--id', type=int, default=None, help='Entry ID')
|
|
@@ -915,6 +1007,38 @@ def create_download_request(das_ctx, name, entry, file, from_file, output_format
|
|
|
915
1007
|
except Exception as e:
|
|
916
1008
|
click.secho(f"Error: {e}", fg="red")
|
|
917
1009
|
|
|
1010
|
+
@download.command("files")
|
|
1011
|
+
@click.argument('request_id', required=True)
|
|
1012
|
+
@click.option('--out', 'output_path', required=False, default='.', help='Output file path or directory (defaults to current directory)')
|
|
1013
|
+
@click.option('--force', is_flag=True, help='Overwrite existing file if present')
|
|
1014
|
+
@pass_das_context
|
|
1015
|
+
def download_files(das_ctx, request_id, output_path, force):
|
|
1016
|
+
"""
|
|
1017
|
+
Download the completed bundle for a download request and save it to disk.
|
|
1018
|
+
|
|
1019
|
+
Examples:
|
|
1020
|
+
|
|
1021
|
+
\b
|
|
1022
|
+
# Save into current directory with server-provided filename
|
|
1023
|
+
das download files 6b0e68e6-00cd-43a7-9c51-d56c9c091123
|
|
1024
|
+
|
|
1025
|
+
\b
|
|
1026
|
+
# Save to a specific folder
|
|
1027
|
+
das download files 6b0e68e6-00cd-43a7-9c51-d56c9c091123 --out C:\\Downloads
|
|
1028
|
+
|
|
1029
|
+
\b
|
|
1030
|
+
# Save to an explicit filename, overwriting if exists
|
|
1031
|
+
das download files 6b0e68e6-00cd-43a7-9c51-d56c9c091123 --out C:\\Downloads\\bundle.zip --force
|
|
1032
|
+
"""
|
|
1033
|
+
try:
|
|
1034
|
+
das_ctx.get_client()
|
|
1035
|
+
saved_path = das_ctx.download_manager.save_download(request_id=request_id, output_path=output_path, overwrite=force)
|
|
1036
|
+
click.secho(f"✓ Download saved to: {saved_path}", fg="green")
|
|
1037
|
+
except FileExistsError as e:
|
|
1038
|
+
click.secho(str(e), fg="yellow")
|
|
1039
|
+
except Exception as e:
|
|
1040
|
+
click.secho(f"Error: {e}", fg="red")
|
|
1041
|
+
|
|
918
1042
|
@download.command("delete-request")
|
|
919
1043
|
@click.argument('request_id', required=True)
|
|
920
1044
|
@pass_das_context
|
das/common/api.py
CHANGED
|
@@ -27,6 +27,26 @@ def get_data(url, headers=None, params=None):
|
|
|
27
27
|
print(f"Error fetching API data: {e}")
|
|
28
28
|
return {"error": str(e)}
|
|
29
29
|
|
|
30
|
+
def get_binary_response(url, headers=None, params=None, stream=True):
|
|
31
|
+
"""
|
|
32
|
+
Perform a GET request expected to return binary content.
|
|
33
|
+
|
|
34
|
+
Returns the raw requests.Response so callers can inspect headers
|
|
35
|
+
and stream content to disk.
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
response = requests.get(
|
|
39
|
+
url,
|
|
40
|
+
headers=headers,
|
|
41
|
+
params=params,
|
|
42
|
+
verify=load_verify_ssl(),
|
|
43
|
+
stream=stream,
|
|
44
|
+
)
|
|
45
|
+
response.raise_for_status()
|
|
46
|
+
return response
|
|
47
|
+
except requests.RequestException as e:
|
|
48
|
+
return {"error": str(e)}
|
|
49
|
+
|
|
30
50
|
def post_data(url, headers=None, data=None):
|
|
31
51
|
"""
|
|
32
52
|
Send data to a REST API endpoint.
|
das/common/config.py
CHANGED
|
@@ -81,12 +81,18 @@ def load_verify_ssl() -> bool:
|
|
|
81
81
|
|
|
82
82
|
if os.getenv("VERIFY_SSL") is not None:
|
|
83
83
|
VERIFY_SSL = os.getenv("VERIFY_SSL") == "True"
|
|
84
|
+
if not VERIFY_SSL:
|
|
85
|
+
print("SSL certificate verification is disabled")
|
|
84
86
|
return VERIFY_SSL
|
|
85
87
|
|
|
86
88
|
verify = config.get("verify_ssl")
|
|
87
89
|
if verify is not None:
|
|
88
90
|
VERIFY_SSL = verify
|
|
91
|
+
if not VERIFY_SSL:
|
|
92
|
+
print("SSL certificate verification is disabled")
|
|
89
93
|
return verify
|
|
94
|
+
else:
|
|
95
|
+
raise ValueError("SSL certificate verification is not set")
|
|
90
96
|
except Exception:
|
|
91
97
|
pass
|
|
92
98
|
return VERIFY_SSL
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from das.common.config import load_api_url
|
|
4
|
+
from das.services.search import SearchService
|
|
5
|
+
from das.services.entries import EntriesService
|
|
6
|
+
from das.services.digital_objects import DigitalObjectsService
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
|
9
|
+
|
|
10
|
+
class DigitalObjectsManager:
|
|
11
|
+
"""Manager for digital objects."""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
base_url = load_api_url()
|
|
15
|
+
if base_url is None or base_url == "":
|
|
16
|
+
raise ValueError(f"Base URL is required - {self.__class__.__name__}")
|
|
17
|
+
|
|
18
|
+
self.__attribute_id_digital_object_type = 5;
|
|
19
|
+
self.digital_objects_service = DigitalObjectsService(base_url)
|
|
20
|
+
self.entry_service = EntriesService(base_url)
|
|
21
|
+
self.search_service = SearchService(base_url)
|
|
22
|
+
|
|
23
|
+
def link_existing_digital_objects(
|
|
24
|
+
self, entry_code: str, digital_object_code_list: list[str], is_unlink: bool = False
|
|
25
|
+
) -> bool:
|
|
26
|
+
"""Attach or detach (unlink) digital objects to an entry using codes."""
|
|
27
|
+
entry_response = self.entry_service.get_entry(entry_code)
|
|
28
|
+
|
|
29
|
+
if entry_response is None:
|
|
30
|
+
raise ValueError(f"Entry with code '{entry_code}' not found")
|
|
31
|
+
|
|
32
|
+
entry_payload = entry_response.get("entry")
|
|
33
|
+
if entry_payload is None:
|
|
34
|
+
raise ValueError(f"Entry with code '{entry_code}' not found")
|
|
35
|
+
|
|
36
|
+
digital_object_id_list: list[str] = []
|
|
37
|
+
|
|
38
|
+
for code in digital_object_code_list:
|
|
39
|
+
do_response = self.entry_service.get_entry(code)
|
|
40
|
+
do_entry = do_response.get("entry") if do_response else None
|
|
41
|
+
if do_entry is None:
|
|
42
|
+
raise ValueError(f"Digital object with code '{code}' not found")
|
|
43
|
+
digital_object_id_list.append(do_entry.get("id"))
|
|
44
|
+
|
|
45
|
+
result = self.digital_objects_service.link_existing_digital_objects(
|
|
46
|
+
attribute_id=entry_response.get("attributeId"),
|
|
47
|
+
entry_id=entry_payload.get("id"),
|
|
48
|
+
digital_object_id_list=digital_object_id_list,
|
|
49
|
+
is_unlink=is_unlink,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
def upload_digital_object(self, entry_code: str, file_description: str, digital_object_type: str, file_path: str):
|
|
55
|
+
"""Upload a digital object to the digital object service."""
|
|
56
|
+
response = self.search_service.search_entries(
|
|
57
|
+
queryString=f"displayname({digital_object_type})",
|
|
58
|
+
attributeId=self.__attribute_id_digital_object_type,
|
|
59
|
+
maxResultCount=1,
|
|
60
|
+
skipCount=0
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
entry_response = self.entry_service.get_entry(entry_code)
|
|
64
|
+
if entry_response is None:
|
|
65
|
+
raise ValueError(f"Entry with code '{entry_code}' not found")
|
|
66
|
+
|
|
67
|
+
if response.get('totalCount', 0) == 0:
|
|
68
|
+
raise ValueError(f"Digital object type '{digital_object_type}' not found")
|
|
69
|
+
|
|
70
|
+
digital_object_type_id = response.get('items', [])[0].get('entry').get('id')
|
|
71
|
+
digital_object_id = self.digital_objects_service.upload_digital_object(file_description, digital_object_type_id, file_path)
|
|
72
|
+
|
|
73
|
+
self.digital_objects_service.link_existing_digital_objects(
|
|
74
|
+
attribute_id=entry_response.get('attributeId'),
|
|
75
|
+
entry_id=entry_response.get('entry').get('id'),
|
|
76
|
+
digital_object_id_list=[digital_object_id]
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return digital_object_id
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
digital_objects_manager = DigitalObjectsManager()
|
|
84
|
+
digital_objects_manager.upload_digital_object(entry_code="zb.b.f7", file_description="test", digital_object_type="Dataset", file_path="my_new_file.txt")
|
das/managers/download_manager.py
CHANGED
|
@@ -90,4 +90,46 @@ class DownloadManager:
|
|
|
90
90
|
|
|
91
91
|
def get_my_requests(self):
|
|
92
92
|
"""Get all download requests for the current user."""
|
|
93
|
-
return self.download_request_service.get_my_requests()
|
|
93
|
+
return self.download_request_service.get_my_requests()
|
|
94
|
+
|
|
95
|
+
def download_files(self, request_id: str):
|
|
96
|
+
"""Return streaming response for files of a download request."""
|
|
97
|
+
return self.download_request_service.download_files(request_id)
|
|
98
|
+
|
|
99
|
+
def save_download(self, request_id: str, output_path: str, overwrite: bool = False) -> str:
|
|
100
|
+
"""
|
|
101
|
+
Download and save the request bundle to disk.
|
|
102
|
+
|
|
103
|
+
Returns the path to the saved file.
|
|
104
|
+
"""
|
|
105
|
+
import os
|
|
106
|
+
|
|
107
|
+
resp = self.download_files(request_id)
|
|
108
|
+
# If an error structure was returned from lower layer
|
|
109
|
+
if isinstance(resp, dict) and resp.get('error'):
|
|
110
|
+
raise ValueError(resp['error'])
|
|
111
|
+
|
|
112
|
+
# Determine filename from headers if available
|
|
113
|
+
filename = f"download_{request_id}.zip"
|
|
114
|
+
try:
|
|
115
|
+
cd = resp.headers.get('Content-Disposition') if hasattr(resp, 'headers') else None
|
|
116
|
+
if cd and 'filename=' in cd:
|
|
117
|
+
filename = cd.split('filename=')[-1].strip('"')
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
# If output_path is a directory, join with filename
|
|
122
|
+
target_path = output_path
|
|
123
|
+
if os.path.isdir(output_path) or output_path.endswith(os.path.sep):
|
|
124
|
+
target_path = os.path.join(output_path, filename)
|
|
125
|
+
|
|
126
|
+
if os.path.exists(target_path) and not overwrite:
|
|
127
|
+
raise FileExistsError(f"File already exists: {target_path}. Use overwrite to replace.")
|
|
128
|
+
|
|
129
|
+
# Stream to disk
|
|
130
|
+
with open(target_path, 'wb') as f:
|
|
131
|
+
for chunk in resp.iter_content(chunk_size=8192):
|
|
132
|
+
if chunk:
|
|
133
|
+
f.write(chunk)
|
|
134
|
+
|
|
135
|
+
return target_path
|
das/managers/entries_manager.py
CHANGED
|
@@ -23,6 +23,23 @@ class EntryManager:
|
|
|
23
23
|
self.attribute_service = AttributesService(base_url)
|
|
24
24
|
self.user_service = UsersService(base_url)
|
|
25
25
|
|
|
26
|
+
def delete(self, id: str = None, code: str = None) -> bool:
|
|
27
|
+
"""Delete an entry by its id or code."""
|
|
28
|
+
if not id and not code:
|
|
29
|
+
raise ValueError("Entry ID or code is required")
|
|
30
|
+
if id:
|
|
31
|
+
if self.entry_service.delete_by_id(id=id) is True:
|
|
32
|
+
return True
|
|
33
|
+
else:
|
|
34
|
+
return False
|
|
35
|
+
elif code:
|
|
36
|
+
if self.entry_service.delete(code=code) is True:
|
|
37
|
+
return True
|
|
38
|
+
else:
|
|
39
|
+
return False
|
|
40
|
+
else:
|
|
41
|
+
raise ValueError("Entry ID or code is required")
|
|
42
|
+
|
|
26
43
|
def get_entry(self, entry_id: str):
|
|
27
44
|
"""Get entry details by ID"""
|
|
28
45
|
if not entry_id:
|
|
@@ -222,6 +239,9 @@ class EntryManager:
|
|
|
222
239
|
raise ValueError(f"Invalid existing entry response: {existing_entry_response}")
|
|
223
240
|
|
|
224
241
|
attribute_id = existing_entry_response.get("attributeId")
|
|
242
|
+
|
|
243
|
+
# make all keys lowercase
|
|
244
|
+
entry = {k.lower(): v for k, v in entry.items()}
|
|
225
245
|
|
|
226
246
|
if not attribute_id:
|
|
227
247
|
raise ValueError("Attribute ID is missing in the existing entry")
|
|
@@ -244,6 +264,10 @@ class EntryManager:
|
|
|
244
264
|
|
|
245
265
|
if field_name in entry:
|
|
246
266
|
updated_entry[column_name] = self.__get_value(field, entry[field_name])
|
|
267
|
+
elif column_name in entry:
|
|
268
|
+
updated_entry[column_name] = self.__get_value(field, entry[column_name])
|
|
269
|
+
else:
|
|
270
|
+
updated_entry[column_name] = None
|
|
247
271
|
|
|
248
272
|
return self.entry_service.update(attribute_id=attribute_id, entry=updated_entry)
|
|
249
273
|
|
|
@@ -276,7 +300,7 @@ class EntryManager:
|
|
|
276
300
|
if (datasource is not None and isinstance(datasource, dict) and "attributeid" in datasource):
|
|
277
301
|
attribute_id = datasource.get("attributeid")
|
|
278
302
|
except json.JSONDecodeError:
|
|
279
|
-
raise ValueError(f"Invalid customdata JSON: {field.get('customdata')}")
|
|
303
|
+
raise ValueError(f"Invalid customdata JSON: {field.get('customdata')}")
|
|
280
304
|
|
|
281
305
|
search_params = {
|
|
282
306
|
"attributeId": attribute_id,
|
|
@@ -285,6 +309,14 @@ class EntryManager:
|
|
|
285
309
|
"skipCount": 0
|
|
286
310
|
}
|
|
287
311
|
|
|
312
|
+
# checks if source is GUID, if so, than we search by id
|
|
313
|
+
if self.is_guid(source):
|
|
314
|
+
search_params['queryString'] = f"id({source});"
|
|
315
|
+
search_response = self.search_service.search_entries(**search_params)
|
|
316
|
+
else:
|
|
317
|
+
search_params['queryString'] = f"displayname({source});"
|
|
318
|
+
search_response = self.search_service.search_entries(**search_params)
|
|
319
|
+
|
|
288
320
|
search_response = self.search_service.search_entries(**search_params)
|
|
289
321
|
|
|
290
322
|
if search_response.get('totalCount', 0) == 0:
|
|
@@ -303,6 +335,10 @@ class EntryManager:
|
|
|
303
335
|
return json.dumps([result])
|
|
304
336
|
else:
|
|
305
337
|
return source
|
|
338
|
+
|
|
339
|
+
def is_guid(self, source: str) -> bool:
|
|
340
|
+
"""Helper method to check if a string is a GUID."""
|
|
341
|
+
return len(source) == 36 and source.count('-') == 4
|
|
306
342
|
|
|
307
343
|
def __get_field_value(self, entry_raw, field):
|
|
308
344
|
"""Helper method to safely get field value from entry_raw."""
|
das/managers/search_manager.py
CHANGED
|
@@ -3,7 +3,6 @@ from das.services.attributes import AttributesService
|
|
|
3
3
|
from das.services.entry_fields import EntryFieldsService
|
|
4
4
|
from das.services.search import SearchService
|
|
5
5
|
|
|
6
|
-
|
|
7
6
|
class SearchManager:
|
|
8
7
|
def __init__(self):
|
|
9
8
|
base_url = load_api_url()
|
|
@@ -59,6 +58,22 @@ class SearchManager:
|
|
|
59
58
|
"sorting": self.__convert_sorting(entry_fields, sort_by, sort_order)
|
|
60
59
|
}
|
|
61
60
|
results = self.search_service.search_entries(**search_params)
|
|
62
|
-
|
|
61
|
+
|
|
62
|
+
# Build user-friendly items list while preserving totalCount
|
|
63
|
+
friendly_items = []
|
|
64
|
+
for result in results.get('items', []):
|
|
65
|
+
entry = result.get('entry', {}) if isinstance(result, dict) else {}
|
|
66
|
+
friendly_item = {}
|
|
67
|
+
for field in entry_fields:
|
|
68
|
+
display_name = field.get('displayName')
|
|
69
|
+
column_name = field.get('column')
|
|
70
|
+
if display_name and column_name:
|
|
71
|
+
friendly_item[display_name] = entry.get(column_name)
|
|
72
|
+
friendly_items.append(friendly_item)
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
'items': friendly_items,
|
|
76
|
+
'totalCount': results.get('totalCount', len(friendly_items))
|
|
77
|
+
}
|
|
63
78
|
except Exception as e:
|
|
64
79
|
raise ValueError(f"Search failed: {str(e)}")
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from math import ceil
|
|
4
|
+
from os.path import exists
|
|
5
|
+
import json
|
|
6
|
+
from base64 import b64encode
|
|
7
|
+
from das.common.api import post_data
|
|
8
|
+
from das.common.config import load_token, load_verify_ssl
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import math
|
|
11
|
+
import uuid
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
CHUNK_SIZE = 1000000 # 1MB
|
|
15
|
+
class DigitalObjectsService:
|
|
16
|
+
def __init__(self, base_url):
|
|
17
|
+
self.base_url = f"{base_url}/api/services/app/DigitalObject"
|
|
18
|
+
# Common possible upload endpoints observed across deployments
|
|
19
|
+
self.upload_digital_object_url = f"{base_url}/File/UploadDigitalObject"
|
|
20
|
+
|
|
21
|
+
def link_existing_digital_objects(self, attribute_id: int, entry_id: str, digital_object_id_list: list[str], is_unlink: bool = False):
|
|
22
|
+
"""Link existing digital objects to an entry."""
|
|
23
|
+
token = load_token()
|
|
24
|
+
|
|
25
|
+
if token is None or token == "":
|
|
26
|
+
raise ValueError("Authorization token is required")
|
|
27
|
+
|
|
28
|
+
headers = {
|
|
29
|
+
"Authorization": f"Bearer {token}",
|
|
30
|
+
"Content-Type": "application/json",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
payload = {
|
|
34
|
+
"attributeId": attribute_id,
|
|
35
|
+
"attributeValueId": entry_id,
|
|
36
|
+
"digitalObjects": [],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for digital_object_id in digital_object_id_list:
|
|
40
|
+
payload["digitalObjects"].append(
|
|
41
|
+
{
|
|
42
|
+
"attributeId": attribute_id,
|
|
43
|
+
"attributeValueId": entry_id,
|
|
44
|
+
"digitalObjectId": digital_object_id,
|
|
45
|
+
"isDeleted": is_unlink,
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
response = post_data(
|
|
50
|
+
f"{self.base_url}/LinkExistingDigitalObject", data=payload, headers=headers
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return response.get("success")
|
|
54
|
+
|
|
55
|
+
# This is our chunk reader. This is what gets the next chunk of data ready to send.
|
|
56
|
+
def __read_in_chunks(self, file_object, chunk_size):
|
|
57
|
+
while True:
|
|
58
|
+
data = file_object.read(chunk_size)
|
|
59
|
+
if not data:
|
|
60
|
+
break
|
|
61
|
+
yield data
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def upload_digital_object(self, file_description: str, digital_object_type_id: str, file_path: str):
|
|
65
|
+
|
|
66
|
+
if not exists(file_path):
|
|
67
|
+
raise ValueError(f"File '{file_path}' does not exist")
|
|
68
|
+
|
|
69
|
+
head, tail = os.path.split(file_path)
|
|
70
|
+
|
|
71
|
+
metadata = {
|
|
72
|
+
"fileName": tail,
|
|
73
|
+
"fileSize": os.path.getsize(file_path),
|
|
74
|
+
"description": file_description,
|
|
75
|
+
"digitalObjectTypeId": digital_object_type_id,
|
|
76
|
+
"id": str(uuid.uuid4()).lower(),
|
|
77
|
+
"description": file_description,
|
|
78
|
+
"totalCount": ceil(os.path.getsize(file_path) / CHUNK_SIZE),
|
|
79
|
+
"index": 0,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
binary_file = open(file_path, "rb")
|
|
83
|
+
index = 0
|
|
84
|
+
offset = 0
|
|
85
|
+
digital_object_id = None
|
|
86
|
+
headers = {}
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
for chunk in self.__read_in_chunks(binary_file, CHUNK_SIZE):
|
|
90
|
+
offset = index + len(chunk)
|
|
91
|
+
headers['Content-Range'] = 'bytes %s-%s/%s' % (index, offset - 1, metadata.get('fileSize'))
|
|
92
|
+
index = offset
|
|
93
|
+
json_metadata = json.dumps(metadata)
|
|
94
|
+
base654_bytes = b64encode(json_metadata.encode('utf-8')).decode('ascii')
|
|
95
|
+
headers['metadata'] = base654_bytes
|
|
96
|
+
|
|
97
|
+
r = self.upload_file(chunk, metadata, headers)
|
|
98
|
+
|
|
99
|
+
if r.get('result', None) is None:
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
digital_object_id = r.get('result').get('id')
|
|
103
|
+
metadata['index'] = index + 1
|
|
104
|
+
|
|
105
|
+
binary_file.close()
|
|
106
|
+
|
|
107
|
+
except Exception as e:
|
|
108
|
+
raise ValueError(f"Error uploading file '{file_path}': {str(e)}")
|
|
109
|
+
finally:
|
|
110
|
+
binary_file.close()
|
|
111
|
+
|
|
112
|
+
return digital_object_id
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def upload_file(self, file, body, headers):
|
|
117
|
+
"""Upload a file to the digital object service."""
|
|
118
|
+
token = load_token()
|
|
119
|
+
headers.update({
|
|
120
|
+
"Accept": "application/json",
|
|
121
|
+
"Authorization": f"Bearer {token}",
|
|
122
|
+
# Do NOT set Content-Type here when sending files; requests will set proper multipart boundary
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
files = {
|
|
126
|
+
"file": ("chunk", file, "application/octet-stream"),
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
response = requests.post(self.upload_digital_object_url, headers=headers, files=files, verify=load_verify_ssl())
|
|
131
|
+
response.raise_for_status()
|
|
132
|
+
if response.status_code == 200:
|
|
133
|
+
return response.json()
|
|
134
|
+
else:
|
|
135
|
+
raise ValueError(f"Error uploading file: {response.status_code} - {response.text}")
|
|
136
|
+
except requests.RequestException as e:
|
|
137
|
+
raise ValueError(f"Error uploading file: {str(e)}")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
|
das/services/downloads.py
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
from das.common.api import post_data, get_data
|
|
1
|
+
from das.common.api import post_data, get_data, get_binary_response
|
|
2
2
|
from das.common.config import load_token
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
class DownloadRequestService:
|
|
6
6
|
def __init__(self, base_url):
|
|
7
7
|
self.base_url = f"{base_url}/api/services/app/DownloadRequest"
|
|
8
|
+
self.download_files_url = f"{base_url}/File/DownloadRequestSet"
|
|
8
9
|
|
|
9
10
|
def create(self, request_data: list[dict]):
|
|
10
11
|
"""Create a new download request."""
|
|
@@ -81,4 +82,20 @@ class DownloadRequestService:
|
|
|
81
82
|
error_msg = None
|
|
82
83
|
if isinstance(response, dict):
|
|
83
84
|
error_msg = response.get('error') or response.get('message')
|
|
84
|
-
raise ValueError(error_msg or 'Failed to fetch download requests')
|
|
85
|
+
raise ValueError(error_msg or 'Failed to fetch download requests')
|
|
86
|
+
|
|
87
|
+
def download_files(self, request_id: str):
|
|
88
|
+
"""Return a streaming HTTP response for the download bundle of a request."""
|
|
89
|
+
token = load_token()
|
|
90
|
+
|
|
91
|
+
if (token is None or token == ""):
|
|
92
|
+
raise ValueError("Authorization token is required")
|
|
93
|
+
|
|
94
|
+
headers = {
|
|
95
|
+
"Authorization": f"Bearer {token}"
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
url = f"{self.download_files_url}?requestId={request_id}"
|
|
99
|
+
|
|
100
|
+
response = get_binary_response(url, headers=headers, params=None, stream=True)
|
|
101
|
+
return response
|