grzctl 0.1.0__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.
- grzctl/__init__.py +5 -0
- grzctl/cli.py +113 -0
- grzctl/commands/__init__.py +3 -0
- grzctl/commands/archive.py +43 -0
- grzctl/commands/clean.py +77 -0
- grzctl/commands/consent.py +116 -0
- grzctl/commands/db.py +371 -0
- grzctl/commands/decrypt.py +45 -0
- grzctl/commands/download.py +46 -0
- grzctl/commands/list_submissions.py +50 -0
- grzctl/commands/pruefbericht.py +181 -0
- grzctl/models/__init__.py +0 -0
- grzctl/models/config.py +34 -0
- grzctl/models/db.py +37 -0
- grzctl/models/pruefbericht.py +28 -0
- grzctl/py.typed +0 -0
- grzctl-0.1.0.dist-info/METADATA +32 -0
- grzctl-0.1.0.dist-info/RECORD +20 -0
- grzctl-0.1.0.dist-info/WHEEL +4 -0
- grzctl-0.1.0.dist-info/entry_points.txt +2 -0
grzctl/__init__.py
ADDED
grzctl/cli.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI module for handling command-line interface operations for GRZ administrators.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
PACKAGE_ROOT = "grzctl"
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import logging.config
|
|
9
|
+
from importlib.metadata import version
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
import grz_pydantic_models.submission.metadata
|
|
13
|
+
from grz_cli.commands.encrypt import encrypt
|
|
14
|
+
from grz_cli.commands.submit import submit
|
|
15
|
+
from grz_cli.commands.upload import upload
|
|
16
|
+
from grz_cli.commands.validate import validate
|
|
17
|
+
from grz_common.logging_setup import add_filelogger
|
|
18
|
+
|
|
19
|
+
from .commands.archive import archive
|
|
20
|
+
from .commands.clean import clean
|
|
21
|
+
from .commands.consent import consent
|
|
22
|
+
from .commands.db import db
|
|
23
|
+
from .commands.decrypt import decrypt
|
|
24
|
+
from .commands.download import download
|
|
25
|
+
from .commands.list_submissions import list_submissions
|
|
26
|
+
from .commands.pruefbericht import pruefbericht
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger(PACKAGE_ROOT + ".cli")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OrderedGroup(click.Group):
|
|
32
|
+
"""
|
|
33
|
+
A click Group that keeps track of the order in which commands are added.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def list_commands(self, ctx):
|
|
37
|
+
"""Return the list of commands in the order they were added."""
|
|
38
|
+
return list(self.commands.keys())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_cli():
|
|
42
|
+
"""
|
|
43
|
+
Factory for building the CLI application.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
@click.group(
|
|
47
|
+
cls=OrderedGroup,
|
|
48
|
+
help="GRZ Control CLI for GRZ administrators.",
|
|
49
|
+
)
|
|
50
|
+
@click.version_option(
|
|
51
|
+
version=version("grzctl"),
|
|
52
|
+
prog_name="grzctl",
|
|
53
|
+
message=f"%(prog)s v%(version)s (metadata schema versions: {', '.join(grz_pydantic_models.submission.metadata.get_supported_versions())})",
|
|
54
|
+
)
|
|
55
|
+
@click.option("--log-file", metavar="FILE", type=str, help="Path to log file")
|
|
56
|
+
@click.option(
|
|
57
|
+
"--log-level",
|
|
58
|
+
default="INFO",
|
|
59
|
+
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
|
|
60
|
+
help="Set the log level (default: INFO)",
|
|
61
|
+
)
|
|
62
|
+
def cli(log_file: str | None = None, log_level: str = "INFO"):
|
|
63
|
+
"""
|
|
64
|
+
Command-line interface function for setting up logging.
|
|
65
|
+
|
|
66
|
+
:param log_file: Path to the log file. If provided, a file logger will be added.
|
|
67
|
+
:param log_level: Log level for the logger. It should be one of the following:
|
|
68
|
+
DEBUG, INFO, WARNING, ERROR, CRITICAL.
|
|
69
|
+
"""
|
|
70
|
+
if log_file:
|
|
71
|
+
add_filelogger(
|
|
72
|
+
log_file,
|
|
73
|
+
log_level.upper(),
|
|
74
|
+
) # Add file logger
|
|
75
|
+
|
|
76
|
+
# show only time and log level in STDOUT
|
|
77
|
+
logging.basicConfig(
|
|
78
|
+
format="%(asctime)s - %(levelname)s - %(message)s",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# set the log level for this package
|
|
82
|
+
logging.getLogger(PACKAGE_ROOT).setLevel(log_level.upper())
|
|
83
|
+
|
|
84
|
+
log.debug("Logging setup complete.")
|
|
85
|
+
|
|
86
|
+
# For convenience, include grz-cli commands as well.
|
|
87
|
+
cli.add_command(validate)
|
|
88
|
+
cli.add_command(encrypt)
|
|
89
|
+
cli.add_command(upload)
|
|
90
|
+
cli.add_command(submit)
|
|
91
|
+
|
|
92
|
+
cli.add_command(list_submissions, name="list")
|
|
93
|
+
cli.add_command(download)
|
|
94
|
+
cli.add_command(decrypt)
|
|
95
|
+
cli.add_command(archive)
|
|
96
|
+
cli.add_command(clean)
|
|
97
|
+
cli.add_command(consent)
|
|
98
|
+
cli.add_command(pruefbericht)
|
|
99
|
+
cli.add_command(db)
|
|
100
|
+
|
|
101
|
+
return cli
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main():
|
|
105
|
+
"""
|
|
106
|
+
Main entry point for the CLI application.
|
|
107
|
+
"""
|
|
108
|
+
cli = build_cli()
|
|
109
|
+
cli()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
main()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Command for archiving a submission."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from grz_common.workers.worker import Worker
|
|
8
|
+
|
|
9
|
+
from ..models.config import ArchiveConfig
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
from grz_common.cli import config_file, submission_dir, threads
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.command()
|
|
17
|
+
@submission_dir
|
|
18
|
+
@config_file
|
|
19
|
+
@threads
|
|
20
|
+
def archive(
|
|
21
|
+
submission_dir,
|
|
22
|
+
config_file,
|
|
23
|
+
threads,
|
|
24
|
+
):
|
|
25
|
+
"""
|
|
26
|
+
Archive a submission within a GRZ/GDC.
|
|
27
|
+
"""
|
|
28
|
+
config = ArchiveConfig.from_path(config_file)
|
|
29
|
+
|
|
30
|
+
log.info("Starting archival...")
|
|
31
|
+
|
|
32
|
+
submission_dir = Path(submission_dir)
|
|
33
|
+
|
|
34
|
+
worker_inst = Worker(
|
|
35
|
+
metadata_dir=submission_dir / "metadata",
|
|
36
|
+
files_dir=submission_dir / "files",
|
|
37
|
+
log_dir=submission_dir / "logs",
|
|
38
|
+
encrypted_files_dir=submission_dir / "encrypted_files",
|
|
39
|
+
threads=threads,
|
|
40
|
+
)
|
|
41
|
+
worker_inst.archive(config.s3)
|
|
42
|
+
|
|
43
|
+
log.info("Archival finished!")
|
grzctl/commands/clean.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Command for cleaning a submission from the S3 inbox."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from grz_common.cli import config_file, submission_id
|
|
8
|
+
from grz_common.transfer import init_s3_resource
|
|
9
|
+
|
|
10
|
+
from ..models.config import CleanConfig
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command()
|
|
16
|
+
@submission_id
|
|
17
|
+
@config_file
|
|
18
|
+
@click.option("--yes-i-really-mean-it", is_flag=True)
|
|
19
|
+
def clean(submission_id, config_file, yes_i_really_mean_it: bool):
|
|
20
|
+
"""
|
|
21
|
+
Remove all files of a submission from the S3 inbox.
|
|
22
|
+
"""
|
|
23
|
+
config = CleanConfig.from_path(config_file)
|
|
24
|
+
bucket_name = config.s3.bucket
|
|
25
|
+
|
|
26
|
+
if yes_i_really_mean_it or click.confirm(
|
|
27
|
+
f"Are you SURE you want to delete the submission from the inbox bucket ({bucket_name})?",
|
|
28
|
+
default=False,
|
|
29
|
+
show_default=True,
|
|
30
|
+
):
|
|
31
|
+
prefix = submission_id
|
|
32
|
+
prefix = prefix + "/" if not prefix.endswith("/") else prefix
|
|
33
|
+
|
|
34
|
+
resource = init_s3_resource(config.s3)
|
|
35
|
+
bucket = resource.Bucket(bucket_name)
|
|
36
|
+
log.info(f"Deleting {prefix} from {bucket_name} …")
|
|
37
|
+
|
|
38
|
+
responses = bucket.objects.filter(Prefix=prefix).delete()
|
|
39
|
+
if not responses:
|
|
40
|
+
sys.exit(f"No objects with prefix '{prefix}' in bucket '{bucket_name}' found for deletion.")
|
|
41
|
+
|
|
42
|
+
successfully_deleted_keys = []
|
|
43
|
+
errors_encountered = []
|
|
44
|
+
objects_found = 0
|
|
45
|
+
|
|
46
|
+
# responses is a list of dicts reporting the result of the deletion API call
|
|
47
|
+
# because the API does things in batches
|
|
48
|
+
for response in responses:
|
|
49
|
+
deleted_batch = response.get("Deleted", [])
|
|
50
|
+
for deleted_obj in deleted_batch:
|
|
51
|
+
key = deleted_obj.get("Key")
|
|
52
|
+
if key:
|
|
53
|
+
successfully_deleted_keys.append(key)
|
|
54
|
+
|
|
55
|
+
errors_batch = response.get("Errors", [])
|
|
56
|
+
for error_obj in errors_batch:
|
|
57
|
+
errors_encountered.append(
|
|
58
|
+
{
|
|
59
|
+
"Key": error_obj.get("Key", "N/A"),
|
|
60
|
+
"Code": error_obj.get("Code", "N/A"),
|
|
61
|
+
"Message": error_obj.get("Message", "N/A"),
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
objects_found += len(deleted_batch) + len(errors_batch)
|
|
66
|
+
|
|
67
|
+
log.info(f"Total objects attempted to delete: {objects_found}")
|
|
68
|
+
log.info(f"Successfully deleted: {len(successfully_deleted_keys)} objects.")
|
|
69
|
+
log.info(f"Failed to delete: {len(errors_encountered)} objects.")
|
|
70
|
+
|
|
71
|
+
for error in errors_encountered:
|
|
72
|
+
log.error(f" - Key: {error['Key']}, Code: {error['Code']}, Message: {error['Message']}")
|
|
73
|
+
|
|
74
|
+
if errors_encountered:
|
|
75
|
+
sys.exit(f"Errors encountered while deleting objects from bucket '{bucket_name}'. See log for details.")
|
|
76
|
+
|
|
77
|
+
log.info(f"Deleted '{prefix}' from '{bucket_name}'.")
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Command for determining whether a submission is consented for research."""
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
import typing
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
import rich.console
|
|
13
|
+
import rich.table
|
|
14
|
+
import rich.text
|
|
15
|
+
from grz_common.cli import output_json, show_details, submission_dir
|
|
16
|
+
from grz_common.workers.submission import GrzSubmissionMetadata, SubmissionMetadata
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
MDAT_WISSENSCHAFTLICH_NUTZEN_EU_DSGVO_NIVEAU = "2.16.840.1.113883.3.1937.777.24.5.3.8"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FhirProvision(enum.StrEnum):
|
|
24
|
+
"""Possible FHIR Provision options."""
|
|
25
|
+
|
|
26
|
+
PERMIT = "permit"
|
|
27
|
+
DENY = "deny"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@click.command()
|
|
31
|
+
@submission_dir
|
|
32
|
+
@output_json
|
|
33
|
+
@show_details
|
|
34
|
+
def consent(submission_dir, output_json, show_details):
|
|
35
|
+
"""
|
|
36
|
+
Check if a submission is consented for research.
|
|
37
|
+
|
|
38
|
+
Returns 'true' if consented, 'false' if not.
|
|
39
|
+
A submission is considered consented if all donors have consented for research, that is
|
|
40
|
+
the FHIR MII IG Consent profiles all have a "permit" provision for code 2.16.840.1.113883.3.1937.777.24.5.3.8
|
|
41
|
+
"""
|
|
42
|
+
metadata = SubmissionMetadata(Path(submission_dir) / "metadata" / "metadata.json").content
|
|
43
|
+
|
|
44
|
+
consents = _gather_consent_information(metadata)
|
|
45
|
+
overall_consent = _submission_has_research_consent(consents)
|
|
46
|
+
|
|
47
|
+
match output_json, show_details:
|
|
48
|
+
case True, True:
|
|
49
|
+
json.dump(consents, sys.stdout)
|
|
50
|
+
case True, False:
|
|
51
|
+
json.dump(overall_consent, sys.stdout)
|
|
52
|
+
case False, True:
|
|
53
|
+
_print_rich_table(consents)
|
|
54
|
+
case False, False:
|
|
55
|
+
click.echo(str(overall_consent).lower())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _submission_has_research_consent(consents):
|
|
59
|
+
return all(consents.values())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _print_rich_table(consents: dict[str, bool]):
|
|
63
|
+
console = rich.console.Console()
|
|
64
|
+
table = rich.table.Table()
|
|
65
|
+
table.add_column("Donor", no_wrap=True)
|
|
66
|
+
table.add_column("Research Consent", no_wrap=True)
|
|
67
|
+
for donor_pseudonym, consent_value in consents.items():
|
|
68
|
+
research_consent = rich.text.Text(
|
|
69
|
+
"True" if consent_value else "False",
|
|
70
|
+
style="green" if consent_value else "red",
|
|
71
|
+
)
|
|
72
|
+
table.add_row(
|
|
73
|
+
donor_pseudonym,
|
|
74
|
+
research_consent,
|
|
75
|
+
)
|
|
76
|
+
console.print(table)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _gather_consent_information(metadata: GrzSubmissionMetadata) -> dict[str, bool]:
|
|
80
|
+
consents = {donor.donor_pseudonym: False for donor in metadata.donors}
|
|
81
|
+
for donor in metadata.donors:
|
|
82
|
+
for research_consent in donor.research_consents:
|
|
83
|
+
mii_consent = research_consent.scope
|
|
84
|
+
if isinstance(mii_consent, str):
|
|
85
|
+
mii_consent = json.loads(mii_consent)
|
|
86
|
+
mii_consent = typing.cast(dict[str, Any], mii_consent)
|
|
87
|
+
|
|
88
|
+
if top_level_provision := mii_consent.get("provision"):
|
|
89
|
+
if top_level_provision.get("type") != FhirProvision.DENY:
|
|
90
|
+
sys.exit(
|
|
91
|
+
f"The root provision type must be deny, not {top_level_provision.get('type')}, "
|
|
92
|
+
f"since the profile follows an opt-in consent scheme. "
|
|
93
|
+
f"Explicit opt-in consents must be made via nested provisions."
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
nested_provisions = top_level_provision.get("provision")
|
|
97
|
+
consents[donor.donor_pseudonym] = _check_nested_provisions(nested_provisions)
|
|
98
|
+
|
|
99
|
+
return consents
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _check_nested_provisions(provisions: list[dict[str, Any]]) -> bool:
|
|
103
|
+
for provision in provisions:
|
|
104
|
+
if provision.get("type") == FhirProvision.PERMIT:
|
|
105
|
+
for codeable_concept in provision.get("code", []):
|
|
106
|
+
for coding in codeable_concept.get("coding", []):
|
|
107
|
+
code = coding.get("code")
|
|
108
|
+
if isinstance(code, str):
|
|
109
|
+
if code == MDAT_WISSENSCHAFTLICH_NUTZEN_EU_DSGVO_NIVEAU:
|
|
110
|
+
return True
|
|
111
|
+
elif isinstance(code, dict):
|
|
112
|
+
if (value := code.get("value")) and value == MDAT_WISSENSCHAFTLICH_NUTZEN_EU_DSGVO_NIVEAU:
|
|
113
|
+
return True
|
|
114
|
+
else:
|
|
115
|
+
raise ValueError(code, f"Expected str or dict, got {type(code)}")
|
|
116
|
+
return False
|
grzctl/commands/db.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Command for managing a submission database"""
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
import traceback
|
|
8
|
+
from collections import namedtuple
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
import rich.console
|
|
13
|
+
import rich.table
|
|
14
|
+
from grz_common.cli import config_file, output_json
|
|
15
|
+
from grz_db import (
|
|
16
|
+
Author,
|
|
17
|
+
DatabaseConfigurationError,
|
|
18
|
+
DuplicateSubmissionError,
|
|
19
|
+
DuplicateTanGError,
|
|
20
|
+
Submission,
|
|
21
|
+
SubmissionDb,
|
|
22
|
+
SubmissionNotFoundError,
|
|
23
|
+
SubmissionStateEnum,
|
|
24
|
+
SubmissionStateLog,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from ..models.config import DbConfig
|
|
28
|
+
|
|
29
|
+
console = rich.console.Console()
|
|
30
|
+
log = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
DATABASE_URL = "sqlite:///test.sqlite"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_submission_db_instance(db_url: str | None, author: Author | None = None) -> SubmissionDb:
|
|
36
|
+
"""Creates and returns an instance of SubmissionDb."""
|
|
37
|
+
db_url = db_url or DATABASE_URL
|
|
38
|
+
return SubmissionDb(db_url=db_url, author=author)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@click.group(help="Database operations")
|
|
42
|
+
@config_file
|
|
43
|
+
@click.pass_context
|
|
44
|
+
def db(ctx: click.Context, config_file: str):
|
|
45
|
+
"""Database operations"""
|
|
46
|
+
config = DbConfig.from_path(config_file)
|
|
47
|
+
db_config = config.db
|
|
48
|
+
if not db_config:
|
|
49
|
+
raise ValueError("DB config not found")
|
|
50
|
+
author_name = db_config.author.name
|
|
51
|
+
|
|
52
|
+
if path := db_config.author.private_key_path:
|
|
53
|
+
with open(path, "rb") as f:
|
|
54
|
+
private_key_bytes = f.read()
|
|
55
|
+
elif key := db_config.author.private_key:
|
|
56
|
+
private_key_bytes = key.encode("utf-8")
|
|
57
|
+
else:
|
|
58
|
+
raise ValueError("Either private_key or private_key_path must be provided.")
|
|
59
|
+
|
|
60
|
+
from cryptography.hazmat.primitives.serialization import load_ssh_public_key
|
|
61
|
+
|
|
62
|
+
log.info("Reading known public keys")
|
|
63
|
+
KnownKeyEntry = namedtuple("KnownKeyEntry", ["key_format", "public_key_base64", "author_name"])
|
|
64
|
+
with open(db_config.known_public_keys) as f:
|
|
65
|
+
public_key_list = list(map(lambda v: KnownKeyEntry(*v), map(lambda s: s.strip().split(), f.readlines())))
|
|
66
|
+
public_keys = {
|
|
67
|
+
author: load_ssh_public_key(f"{fmt}\t{key}\t{author}".encode()) for fmt, key, author in public_key_list
|
|
68
|
+
}
|
|
69
|
+
for author in public_keys:
|
|
70
|
+
log.debug(f"Found public key for {author}")
|
|
71
|
+
|
|
72
|
+
author = Author(name=author_name, private_key_bytes=private_key_bytes)
|
|
73
|
+
ctx.obj = {"author": author, "public_keys": public_keys, "db_url": db_config.database_url}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@db.group()
|
|
77
|
+
@click.pass_context
|
|
78
|
+
def submission(ctx: click.Context):
|
|
79
|
+
"""Submission operations"""
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@db.command()
|
|
84
|
+
@click.pass_context
|
|
85
|
+
def init(ctx: click.Context):
|
|
86
|
+
"""Initializes or upgrades the database schema using Alembic."""
|
|
87
|
+
db = ctx.obj["db_url"]
|
|
88
|
+
submission_db = get_submission_db_instance(db, author=ctx.obj["author"])
|
|
89
|
+
console.print(f"[cyan]Initializing database {db}[/cyan]")
|
|
90
|
+
submission_db.initialize_schema()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@db.command()
|
|
94
|
+
@click.option("--revision", default="head", help="Alembic revision to upgrade to (default: 'head').")
|
|
95
|
+
@click.option(
|
|
96
|
+
"--alembic-ini",
|
|
97
|
+
type=click.Path(exists=True, dir_okay=False, resolve_path=True),
|
|
98
|
+
default="alembic.ini",
|
|
99
|
+
help="Override path to alembic.ini file.",
|
|
100
|
+
)
|
|
101
|
+
@click.pass_context
|
|
102
|
+
def upgrade(
|
|
103
|
+
ctx: click.Context,
|
|
104
|
+
revision: str,
|
|
105
|
+
alembic_ini: str,
|
|
106
|
+
):
|
|
107
|
+
"""
|
|
108
|
+
Upgrades the database schema using Alembic.
|
|
109
|
+
"""
|
|
110
|
+
db = ctx.obj["db_url"]
|
|
111
|
+
submission_db = get_submission_db_instance(db, author=ctx.obj["author"])
|
|
112
|
+
|
|
113
|
+
console.print(f"[cyan]Using alembic configuration: {alembic_ini}[/cyan]")
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
console.print(f"[cyan]Attempting to upgrade database to revision: {revision}...[/cyan]")
|
|
117
|
+
_ = submission_db.upgrade_schema(
|
|
118
|
+
alembic_ini_path=alembic_ini,
|
|
119
|
+
revision=revision,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
except (DatabaseConfigurationError, RuntimeError) as e:
|
|
123
|
+
console.print(f"[red]Error during schema initialization: {e}[/red]")
|
|
124
|
+
if isinstance(e, RuntimeError):
|
|
125
|
+
console.print(
|
|
126
|
+
"[yellow]Ensure your database is running and accessible, and alembic.ini is configured correctly.[/yellow]"
|
|
127
|
+
)
|
|
128
|
+
console.print(
|
|
129
|
+
"[yellow]You might need to create an initial migration if this is the first time: 'alembic revision -m \"initial\" --autogenerate'[/yellow]"
|
|
130
|
+
)
|
|
131
|
+
raise click.ClickException(str(e)) from e
|
|
132
|
+
except Exception as e:
|
|
133
|
+
console.print(f"[red]An unexpected error occurred during 'db init': {type(e).__name__} - {e}[/red]")
|
|
134
|
+
raise click.ClickException(str(e)) from e
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@db.command("list")
|
|
138
|
+
@output_json
|
|
139
|
+
@click.pass_context
|
|
140
|
+
def list_submissions(ctx: click.Context, output_json: bool = False):
|
|
141
|
+
"""Lists all submissions in the database with their latest state."""
|
|
142
|
+
db = ctx.obj["db_url"]
|
|
143
|
+
db_service = get_submission_db_instance(db)
|
|
144
|
+
submissions = db_service.list_submissions()
|
|
145
|
+
|
|
146
|
+
if not submissions:
|
|
147
|
+
console.print("[yellow]No submissions found in the database.[/yellow]")
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
table = rich.table.Table(title="All Submissions")
|
|
151
|
+
table.add_column("ID", style="dim", width=12)
|
|
152
|
+
table.add_column("tanG", style="cyan")
|
|
153
|
+
table.add_column("Pseudonym", style="magenta")
|
|
154
|
+
table.add_column("Latest State", style="green")
|
|
155
|
+
table.add_column("Last State Timestamp (UTC)", style="yellow")
|
|
156
|
+
table.add_column("Data Steward")
|
|
157
|
+
table.add_column("Signature Status")
|
|
158
|
+
|
|
159
|
+
submission_dicts = []
|
|
160
|
+
|
|
161
|
+
for submission in submissions:
|
|
162
|
+
latest_state_obj: SubmissionStateLog | None = None
|
|
163
|
+
if submission.states:
|
|
164
|
+
latest_state_obj = max(submission.states, key=lambda s: s.timestamp)
|
|
165
|
+
|
|
166
|
+
latest_state_str = "N/A"
|
|
167
|
+
latest_timestamp_str = "N/A"
|
|
168
|
+
author_name_str = "N/A"
|
|
169
|
+
signature_status = SignatureStatus.UNKNOWN
|
|
170
|
+
|
|
171
|
+
if latest_state_obj:
|
|
172
|
+
latest_state_str = latest_state_obj.state.value
|
|
173
|
+
latest_state_str = (
|
|
174
|
+
f"[red]{latest_state_str}[/red]" if latest_state_str == SubmissionStateEnum.ERROR else latest_state_str
|
|
175
|
+
)
|
|
176
|
+
latest_timestamp_str = latest_state_obj.timestamp.isoformat()
|
|
177
|
+
author_name_str = latest_state_obj.author_name
|
|
178
|
+
|
|
179
|
+
author_public_key = ctx.obj["public_keys"].get(author_name_str)
|
|
180
|
+
signature_status = _verify_signature(author_public_key, latest_state_obj)
|
|
181
|
+
|
|
182
|
+
if output_json:
|
|
183
|
+
submission_dict = _build_submission_dict_from(latest_state_obj, submission, signature_status)
|
|
184
|
+
submission_dicts.append(submission_dict)
|
|
185
|
+
else:
|
|
186
|
+
table.add_row(
|
|
187
|
+
submission.id,
|
|
188
|
+
submission.tan_g if submission.tan_g is not None else "N/A",
|
|
189
|
+
submission.pseudonym if submission.pseudonym is not None else "N/A",
|
|
190
|
+
latest_state_str,
|
|
191
|
+
latest_timestamp_str,
|
|
192
|
+
author_name_str,
|
|
193
|
+
signature_status.rich_display(),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if output_json:
|
|
197
|
+
json.dump(submission_dicts, sys.stdout)
|
|
198
|
+
else:
|
|
199
|
+
console.print(table)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class SignatureStatus(enum.StrEnum):
|
|
203
|
+
"""Enum for signature status."""
|
|
204
|
+
|
|
205
|
+
VERIFIED = "Verified"
|
|
206
|
+
FAILED = "Failed"
|
|
207
|
+
ERROR = "Error"
|
|
208
|
+
UNKNOWN = "Unknown"
|
|
209
|
+
|
|
210
|
+
def rich_display(self) -> str:
|
|
211
|
+
"""Displays the signature status in rich format."""
|
|
212
|
+
match self:
|
|
213
|
+
case "Verified":
|
|
214
|
+
return "[green]Verified[/green]"
|
|
215
|
+
case "Failed":
|
|
216
|
+
return "[red]Failed[/red]"
|
|
217
|
+
case "Error":
|
|
218
|
+
return "[red]Error[/red]"
|
|
219
|
+
case "Unknown" | _:
|
|
220
|
+
return "[yellow]Unknown[/yellow]"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _verify_signature(author_public_key, latest_state_obj: SubmissionStateLog) -> SignatureStatus:
|
|
224
|
+
signature_status = SignatureStatus.UNKNOWN
|
|
225
|
+
if author_public_key:
|
|
226
|
+
try:
|
|
227
|
+
if latest_state_obj.verify(author_public_key):
|
|
228
|
+
signature_status = SignatureStatus.VERIFIED
|
|
229
|
+
else:
|
|
230
|
+
signature_status = SignatureStatus.FAILED
|
|
231
|
+
except Exception as e:
|
|
232
|
+
signature_status = SignatureStatus.ERROR
|
|
233
|
+
log.error(e)
|
|
234
|
+
return signature_status
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _build_submission_dict_from(
|
|
238
|
+
latest_state_obj: SubmissionStateLog | None, submission: Submission, signature_status: SignatureStatus
|
|
239
|
+
) -> dict[str, Any]:
|
|
240
|
+
submission_dict: dict[str, Any] = {
|
|
241
|
+
"id": submission.id,
|
|
242
|
+
"tan_g": submission.tan_g,
|
|
243
|
+
"pseudonym": submission.pseudonym,
|
|
244
|
+
"latest_state": None,
|
|
245
|
+
}
|
|
246
|
+
if latest_state_obj:
|
|
247
|
+
submission_dict["latest_state"] = {
|
|
248
|
+
"state": latest_state_obj.state.value,
|
|
249
|
+
"timestamp": latest_state_obj.timestamp.isoformat(),
|
|
250
|
+
"data": latest_state_obj.data,
|
|
251
|
+
"data_steward": latest_state_obj.author_name,
|
|
252
|
+
"data_steward_signature": signature_status,
|
|
253
|
+
}
|
|
254
|
+
return submission_dict
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@submission.command()
|
|
258
|
+
@click.argument("submission_id", type=str)
|
|
259
|
+
@click.option("--tan-g", "tan_g", type=str, default=None, help="The tanG for the submission.")
|
|
260
|
+
@click.option("--pseudonym", type=str, default=None, help="The pseudonym for the submission.")
|
|
261
|
+
@click.pass_context
|
|
262
|
+
def add(ctx: click.Context, submission_id: str, tan_g: str | None, pseudonym: str | None):
|
|
263
|
+
"""
|
|
264
|
+
Add a submission to the database.
|
|
265
|
+
"""
|
|
266
|
+
db = ctx.obj["db_url"]
|
|
267
|
+
db_service = get_submission_db_instance(db)
|
|
268
|
+
try:
|
|
269
|
+
db_submission = db_service.add_submission(submission_id, tan_g, pseudonym)
|
|
270
|
+
console.print(f"[green]Submission '{db_submission.id}' added successfully.[/green]")
|
|
271
|
+
console.print(f" tanG: {db_submission.tan_g}, Pseudonym: {db_submission.pseudonym}")
|
|
272
|
+
except (DuplicateSubmissionError, DuplicateTanGError) as e:
|
|
273
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
274
|
+
raise click.Abort() from e
|
|
275
|
+
except Exception as e:
|
|
276
|
+
console.print(f"[red]An unexpected error occurred: {e}[/red]")
|
|
277
|
+
raise click.ClickException(f"Failed to add submission: {e}") from e
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@submission.command()
|
|
281
|
+
@click.argument("submission_id", type=str)
|
|
282
|
+
@click.argument("state_str", metavar="STATE", type=click.Choice(SubmissionStateEnum.list(), case_sensitive=False))
|
|
283
|
+
@click.option("--data", "data_json", type=str, default=None, help='Additional JSON data (e.g., \'{"k":"v"}\').')
|
|
284
|
+
@click.pass_context
|
|
285
|
+
def update(ctx: click.Context, submission_id: str, state_str: str, data_json: str | None):
|
|
286
|
+
"""Update a submission to the given state. Optionally accepts additional JSON data to associate with the log entry."""
|
|
287
|
+
db = ctx.obj["db_url"]
|
|
288
|
+
db_service = get_submission_db_instance(db, author=ctx.obj["author"])
|
|
289
|
+
try:
|
|
290
|
+
state_enum = SubmissionStateEnum(state_str)
|
|
291
|
+
except ValueError as e:
|
|
292
|
+
console.print(f"[red]Error: Invalid state value '{state_str}'.[/red]")
|
|
293
|
+
raise click.Abort() from e
|
|
294
|
+
|
|
295
|
+
parsed_data = None
|
|
296
|
+
if data_json:
|
|
297
|
+
try:
|
|
298
|
+
parsed_data = json.loads(data_json)
|
|
299
|
+
except json.JSONDecodeError as e:
|
|
300
|
+
console.print(f"[red]Error: Invalid JSON string for --data: {data_json}[/red]")
|
|
301
|
+
raise click.Abort() from e
|
|
302
|
+
try:
|
|
303
|
+
new_state_log = db_service.update_submission_state(submission_id, state_enum, parsed_data)
|
|
304
|
+
console.print(
|
|
305
|
+
f"[green]Submission '{submission_id}' updated to state '{new_state_log.state.value}'. Log ID: {new_state_log.id}[/green]"
|
|
306
|
+
)
|
|
307
|
+
if new_state_log.data:
|
|
308
|
+
console.print(f" Data: {new_state_log.data}")
|
|
309
|
+
|
|
310
|
+
if state_enum == SubmissionStateEnum.REPORTED:
|
|
311
|
+
updated_submission = db_service.get_submission(submission_id)
|
|
312
|
+
if updated_submission:
|
|
313
|
+
console.print(f" Submission tanG is now: {updated_submission.tan_g}")
|
|
314
|
+
|
|
315
|
+
except SubmissionNotFoundError as e:
|
|
316
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
317
|
+
console.print(f"You might need to add it first: grz-cli db add-submission {submission_id}")
|
|
318
|
+
raise click.Abort() from e
|
|
319
|
+
except Exception as e:
|
|
320
|
+
console.print(f"[red]An unexpected error occurred: {e}[/red]")
|
|
321
|
+
traceback.print_exc()
|
|
322
|
+
raise click.ClickException(f"Failed to update submission state: {e}") from e
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@submission.command("show")
|
|
326
|
+
@click.argument("submission_id", type=str)
|
|
327
|
+
@click.pass_context
|
|
328
|
+
def show(ctx: click.Context, submission_id: str):
|
|
329
|
+
"""
|
|
330
|
+
Show details of a submission.
|
|
331
|
+
"""
|
|
332
|
+
db = ctx.obj["db_url"]
|
|
333
|
+
db_service = get_submission_db_instance(db)
|
|
334
|
+
submission = db_service.get_submission(submission_id)
|
|
335
|
+
if not submission:
|
|
336
|
+
console.print(f"[red]Error: Submission with ID '{submission_id}' not found.[/red]")
|
|
337
|
+
raise click.Abort()
|
|
338
|
+
|
|
339
|
+
console.print(f"\n[bold blue]Submission Details for ID: {submission.id}[/bold blue]")
|
|
340
|
+
console.print(f" tanG: {submission.tan_g if submission.tan_g is not None else 'N/A'}")
|
|
341
|
+
console.print(f" Pseudonym: {submission.pseudonym if submission.pseudonym is not None else 'N/A'}")
|
|
342
|
+
|
|
343
|
+
if submission.states:
|
|
344
|
+
table = rich.table.Table(title=f"State History for Submission {submission.id}")
|
|
345
|
+
table.add_column("Log ID", style="dim", width=12)
|
|
346
|
+
table.add_column("Timestamp (UTC)", style="yellow")
|
|
347
|
+
table.add_column("State", style="green")
|
|
348
|
+
table.add_column("Data", style="cyan", overflow="ellipsis")
|
|
349
|
+
table.add_column("Data Steward", style="magenta")
|
|
350
|
+
table.add_column("Signature Status")
|
|
351
|
+
|
|
352
|
+
sorted_states = sorted(submission.states, key=lambda s: s.timestamp)
|
|
353
|
+
for state_log in sorted_states:
|
|
354
|
+
data_str = json.dumps(state_log.data) if state_log.data else ""
|
|
355
|
+
state = state_log.state.value
|
|
356
|
+
state_str = f"[red]{state}[/red]" if state == SubmissionStateEnum.ERROR else state
|
|
357
|
+
data_steward_str = state_log.author_name
|
|
358
|
+
author_public_key = ctx.obj["public_keys"].get(data_steward_str)
|
|
359
|
+
signature_status_str = _verify_signature(author_public_key, state_log).rich_display()
|
|
360
|
+
|
|
361
|
+
table.add_row(
|
|
362
|
+
str(state_log.id),
|
|
363
|
+
state_log.timestamp.isoformat(),
|
|
364
|
+
state_str,
|
|
365
|
+
data_str,
|
|
366
|
+
data_steward_str,
|
|
367
|
+
signature_status_str,
|
|
368
|
+
)
|
|
369
|
+
console.print(table)
|
|
370
|
+
else:
|
|
371
|
+
console.print("[yellow]No state history found for this submission.[/yellow]")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Command for decrypting a submission."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from grz_common.cli import config_file, force, submission_dir
|
|
9
|
+
from grz_common.workers.worker import Worker
|
|
10
|
+
|
|
11
|
+
from ..models.config import DecryptConfig
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.command()
|
|
17
|
+
@submission_dir
|
|
18
|
+
@config_file
|
|
19
|
+
@force
|
|
20
|
+
def decrypt(submission_dir, config_file, force):
|
|
21
|
+
"""
|
|
22
|
+
Decrypt a submission.
|
|
23
|
+
|
|
24
|
+
Decrypting a submission requires the _private_ key of the original recipient.
|
|
25
|
+
"""
|
|
26
|
+
config = DecryptConfig.from_path(config_file)
|
|
27
|
+
|
|
28
|
+
grz_privkey_path = config.keys.grz_private_key_path
|
|
29
|
+
if not grz_privkey_path:
|
|
30
|
+
log.error("GRZ private key path is required for decryption.")
|
|
31
|
+
sys.exit(1)
|
|
32
|
+
|
|
33
|
+
log.info("Starting decryption...")
|
|
34
|
+
|
|
35
|
+
submission_dir = Path(submission_dir)
|
|
36
|
+
|
|
37
|
+
worker_inst = Worker(
|
|
38
|
+
metadata_dir=submission_dir / "metadata",
|
|
39
|
+
files_dir=submission_dir / "files",
|
|
40
|
+
log_dir=submission_dir / "logs",
|
|
41
|
+
encrypted_files_dir=submission_dir / "encrypted_files",
|
|
42
|
+
)
|
|
43
|
+
worker_inst.decrypt(grz_privkey_path, force=force)
|
|
44
|
+
|
|
45
|
+
log.info("Decryption successful!")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Command for downloading a submission."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from grz_common.cli import config_file, force, output_dir, submission_id, threads
|
|
8
|
+
from grz_common.workers.worker import Worker
|
|
9
|
+
|
|
10
|
+
from ..models.config import DownloadConfig
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command()
|
|
16
|
+
@submission_id
|
|
17
|
+
@output_dir
|
|
18
|
+
@config_file
|
|
19
|
+
@threads
|
|
20
|
+
@force
|
|
21
|
+
def download(submission_id, output_dir, config_file, threads, force):
|
|
22
|
+
"""
|
|
23
|
+
Download a submission from a GRZ.
|
|
24
|
+
|
|
25
|
+
Downloaded metadata is stored within the `metadata` sub-folder of the submission output directory.
|
|
26
|
+
Downloaded files are stored within the `encrypted_files` sub-folder of the submission output directory.
|
|
27
|
+
"""
|
|
28
|
+
config = DownloadConfig.from_path(config_file)
|
|
29
|
+
|
|
30
|
+
log.info("Starting download...")
|
|
31
|
+
|
|
32
|
+
submission_dir_path = Path(output_dir)
|
|
33
|
+
if not submission_dir_path.is_dir():
|
|
34
|
+
log.debug("Creating submission directory %s", submission_dir_path)
|
|
35
|
+
submission_dir_path.mkdir(mode=0o770, parents=False, exist_ok=False)
|
|
36
|
+
|
|
37
|
+
worker_inst = Worker(
|
|
38
|
+
metadata_dir=submission_dir_path / "metadata",
|
|
39
|
+
files_dir=submission_dir_path / "files",
|
|
40
|
+
log_dir=submission_dir_path / "logs",
|
|
41
|
+
encrypted_files_dir=submission_dir_path / "encrypted_files",
|
|
42
|
+
threads=threads,
|
|
43
|
+
)
|
|
44
|
+
worker_inst.download(config.s3, submission_id, force=force)
|
|
45
|
+
|
|
46
|
+
log.info("Download finished!")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Command for listing submissions."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import rich.console
|
|
9
|
+
import rich.table
|
|
10
|
+
import rich.text
|
|
11
|
+
from grz_common.cli import config_file, output_json
|
|
12
|
+
from grz_common.workers.download import query_submissions
|
|
13
|
+
from pydantic_core import to_jsonable_python
|
|
14
|
+
|
|
15
|
+
from ..models.config import ListConfig
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.command()
|
|
21
|
+
@config_file
|
|
22
|
+
@output_json
|
|
23
|
+
def list_submissions(config_file, output_json):
|
|
24
|
+
"""
|
|
25
|
+
List submissions within an inbox from oldest to newest.
|
|
26
|
+
"""
|
|
27
|
+
config = ListConfig.from_path(config_file)
|
|
28
|
+
submissions = query_submissions(config.s3)
|
|
29
|
+
|
|
30
|
+
if output_json:
|
|
31
|
+
json.dump(to_jsonable_python(submissions), sys.stdout)
|
|
32
|
+
else:
|
|
33
|
+
console = rich.console.Console()
|
|
34
|
+
table = rich.table.Table()
|
|
35
|
+
table.add_column("ID", no_wrap=True)
|
|
36
|
+
table.add_column("Status", no_wrap=True)
|
|
37
|
+
table.add_column("Oldest Upload", overflow="fold")
|
|
38
|
+
table.add_column("Newest Upload", overflow="fold")
|
|
39
|
+
for submission in submissions:
|
|
40
|
+
status_text = rich.text.Text(
|
|
41
|
+
"Complete" if submission.complete else "Incomplete",
|
|
42
|
+
style="green" if submission.complete else "yellow",
|
|
43
|
+
)
|
|
44
|
+
table.add_row(
|
|
45
|
+
submission.submission_id,
|
|
46
|
+
status_text,
|
|
47
|
+
submission.oldest_upload.astimezone().strftime("%Y-%m-%d %H:%M:%S"),
|
|
48
|
+
submission.newest_upload.astimezone().strftime("%Y-%m-%d %H:%M:%S"),
|
|
49
|
+
)
|
|
50
|
+
console.print(table)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Command for submitting Prüfberichte."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import requests
|
|
10
|
+
from grz_common.cli import config_file, output_json, submission_dir
|
|
11
|
+
from grz_common.workers.submission import Submission
|
|
12
|
+
|
|
13
|
+
# pyrefly: ignore
|
|
14
|
+
from grz_pydantic_models.pruefbericht import LibraryType, Pruefbericht, SubmittedCase
|
|
15
|
+
|
|
16
|
+
# pyrefly: ignore
|
|
17
|
+
from grz_pydantic_models.submission.metadata.v1 import (
|
|
18
|
+
GenomicStudySubtype,
|
|
19
|
+
GrzSubmissionMetadata,
|
|
20
|
+
Relation,
|
|
21
|
+
SequenceSubtype,
|
|
22
|
+
)
|
|
23
|
+
from pydantic_core import to_jsonable_python
|
|
24
|
+
|
|
25
|
+
from ..models.config import PruefberichtConfig
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_new_token(auth_url: str, client_id: str, client_secret: str) -> tuple[str, datetime.datetime]:
|
|
31
|
+
log.info("Refreshing access token...")
|
|
32
|
+
|
|
33
|
+
response = requests.post(
|
|
34
|
+
auth_url,
|
|
35
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
36
|
+
data={"grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret},
|
|
37
|
+
timeout=60,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if response.status_code != requests.codes.ok:
|
|
41
|
+
log.error("There was a problem refreshing the access token")
|
|
42
|
+
response.raise_for_status()
|
|
43
|
+
|
|
44
|
+
response_json = response.json()
|
|
45
|
+
token = response_json["access_token"]
|
|
46
|
+
expires_in = response_json["expires_in"]
|
|
47
|
+
# take off a second to provide at least a minimal safety margin
|
|
48
|
+
expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in - 1)
|
|
49
|
+
|
|
50
|
+
log.info("Successfully obtained a new access token.")
|
|
51
|
+
return token, expires_at
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _submit_pruefbericht(base_url: str, token: str, pruefbericht: Pruefbericht):
|
|
55
|
+
log.info("Submitting Prüfbericht...")
|
|
56
|
+
|
|
57
|
+
response = requests.post(
|
|
58
|
+
base_url.rstrip("/") + "/upload",
|
|
59
|
+
headers={"Authorization": f"bearer {token}"},
|
|
60
|
+
json=to_jsonable_python(pruefbericht),
|
|
61
|
+
timeout=60,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if response.status_code != requests.codes.ok:
|
|
65
|
+
log.warning("There was a problem submitting the Prüfbericht.")
|
|
66
|
+
response.raise_for_status()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _get_library_type(metadata: GrzSubmissionMetadata) -> LibraryType:
|
|
70
|
+
index_patients = [donor for donor in metadata.donors if donor.relation == Relation.index_]
|
|
71
|
+
if len(index_patients) != 1:
|
|
72
|
+
# TODO handle in grz-pydantic-model validation?
|
|
73
|
+
raise ValueError("Multiple index patients in one submission")
|
|
74
|
+
index_patient = index_patients[0]
|
|
75
|
+
|
|
76
|
+
# assume somatic is tumor in context of genomDE
|
|
77
|
+
data_tumor = filter(lambda datum: datum.sequence_subtype == SequenceSubtype.somatic, index_patient.lab_data)
|
|
78
|
+
data_germline = filter(lambda datum: datum.sequence_subtype == SequenceSubtype.germline, index_patient.lab_data)
|
|
79
|
+
|
|
80
|
+
unique_tumor_library_types = {datum.library_type for datum in data_tumor}
|
|
81
|
+
if len(unique_tumor_library_types) > 1:
|
|
82
|
+
raise ValueError("Multiple different library types detected for tumor data")
|
|
83
|
+
|
|
84
|
+
unique_germline_library_types = {datum.library_type for datum in data_germline}
|
|
85
|
+
if len(unique_germline_library_types) > 1:
|
|
86
|
+
raise ValueError("Multiple different library types detected for germline data")
|
|
87
|
+
|
|
88
|
+
match metadata.submission.genomic_study_subtype:
|
|
89
|
+
case GenomicStudySubtype.tumor_only:
|
|
90
|
+
if not unique_tumor_library_types:
|
|
91
|
+
raise ValueError("No somatic library types detected in tumor submission")
|
|
92
|
+
library_type = LibraryType(str(next(iter(unique_tumor_library_types))))
|
|
93
|
+
case GenomicStudySubtype.germline_only:
|
|
94
|
+
if not unique_germline_library_types:
|
|
95
|
+
raise ValueError("No germline library types detected in germline submission")
|
|
96
|
+
library_type = LibraryType(str(next(iter(unique_germline_library_types))))
|
|
97
|
+
case GenomicStudySubtype.tumor_germline:
|
|
98
|
+
unique_library_types = unique_tumor_library_types | unique_germline_library_types
|
|
99
|
+
if len(unique_library_types) != 1:
|
|
100
|
+
# TODO: is there a better solution to this?
|
|
101
|
+
raise ValueError("None or multiple different library types detected for tumor+germline sample")
|
|
102
|
+
library_type = LibraryType(str(next(iter(unique_library_types))))
|
|
103
|
+
case _:
|
|
104
|
+
raise ValueError(f"Unknown genomic study subtype: {metadata.submission.genomic_study_subtype}")
|
|
105
|
+
|
|
106
|
+
return library_type
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@click.command()
|
|
110
|
+
@config_file
|
|
111
|
+
@submission_dir
|
|
112
|
+
@output_json
|
|
113
|
+
@click.option("--fail/--pass", "failed", help="Fail an otherwise valid submission (e.g. failed internal QC)")
|
|
114
|
+
@click.option(
|
|
115
|
+
"--token", help="Access token to try instead of requesting a new one.", envvar="GRZ_PRUEFBERICHT_ACCESS_TOKEN"
|
|
116
|
+
)
|
|
117
|
+
def pruefbericht(config_file, submission_dir, output_json, failed, token):
|
|
118
|
+
"""
|
|
119
|
+
Submit a Prüfbericht to BfArM.
|
|
120
|
+
"""
|
|
121
|
+
config = PruefberichtConfig.from_path(config_file)
|
|
122
|
+
|
|
123
|
+
if config.pruefbericht.authorization_url is None:
|
|
124
|
+
raise ValueError("pruefbericht.auth_url must be provided to submit Prüfberichte")
|
|
125
|
+
if config.pruefbericht.client_id is None:
|
|
126
|
+
raise ValueError("pruefbericht.client_id must be provided to submit Prüfberichte")
|
|
127
|
+
if config.pruefbericht.client_secret is None:
|
|
128
|
+
raise ValueError("pruefbericht.client_secret must be provided to submit Prüfberichte")
|
|
129
|
+
|
|
130
|
+
submission = Submission(metadata_dir=f"{submission_dir}/metadata", files_dir=f"{submission_dir}/files")
|
|
131
|
+
|
|
132
|
+
metadata = submission.metadata.content
|
|
133
|
+
pruefbericht = Pruefbericht(
|
|
134
|
+
SubmittedCase=SubmittedCase(
|
|
135
|
+
submissionDate=metadata.submission.submission_date,
|
|
136
|
+
submissionType=metadata.submission.submission_type,
|
|
137
|
+
tan=metadata.submission.tan_g,
|
|
138
|
+
submitterId=metadata.submission.submitter_id,
|
|
139
|
+
dataNodeId=metadata.submission.genomic_data_center_id,
|
|
140
|
+
diseaseType=metadata.submission.disease_type,
|
|
141
|
+
dataCategory="genomic",
|
|
142
|
+
libraryType=_get_library_type(metadata),
|
|
143
|
+
coverageType=metadata.submission.coverage_type,
|
|
144
|
+
dataQualityCheckPassed=not failed,
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if token:
|
|
149
|
+
# replace newlines in token if accidentally present from pasting
|
|
150
|
+
token = token.replace("\n", "")
|
|
151
|
+
expiry = None
|
|
152
|
+
else:
|
|
153
|
+
token, expiry = _get_new_token(
|
|
154
|
+
auth_url=str(config.pruefbericht.authorization_url),
|
|
155
|
+
client_id=config.pruefbericht.client_id,
|
|
156
|
+
client_secret=config.pruefbericht.client_secret,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
_submit_pruefbericht(base_url=str(config.pruefbericht.api_base_url), token=token, pruefbericht=pruefbericht)
|
|
161
|
+
except requests.HTTPError as error:
|
|
162
|
+
if error.response.status_code == requests.codes.unauthorized:
|
|
163
|
+
# get a new token and try again
|
|
164
|
+
log.warning("Provided token has expired. Attempting to refresh.")
|
|
165
|
+
token, expiry = _get_new_token(
|
|
166
|
+
auth_url=str(config.pruefbericht.authorization_url),
|
|
167
|
+
client_id=config.pruefbericht.client_id,
|
|
168
|
+
client_secret=config.pruefbericht.client_secret,
|
|
169
|
+
)
|
|
170
|
+
_submit_pruefbericht(base_url=str(config.pruefbericht.api_base_url), token=token, pruefbericht=pruefbericht)
|
|
171
|
+
else:
|
|
172
|
+
log.error("Encountered an irrecoverable error while submitting the Prüfbericht!")
|
|
173
|
+
raise error
|
|
174
|
+
|
|
175
|
+
log.info("Prüfbericht submitted successfully.")
|
|
176
|
+
|
|
177
|
+
if output_json and expiry:
|
|
178
|
+
json.dump({"token": token, "expires": expiry.isoformat()}, sys.stdout)
|
|
179
|
+
elif expiry:
|
|
180
|
+
log.info(f"New token expires at {expiry.isoformat()}")
|
|
181
|
+
print(token)
|
|
File without changes
|
grzctl/models/config.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from grz_common.models.base import IgnoringBaseSettings
|
|
2
|
+
from grz_common.models.keys import KeyConfigModel
|
|
3
|
+
from grz_common.models.s3 import S3ConfigModel
|
|
4
|
+
|
|
5
|
+
from .db import DbModel
|
|
6
|
+
from .pruefbericht import PruefberichtModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ArchiveConfig(S3ConfigModel):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DownloadConfig(S3ConfigModel):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DecryptConfig(KeyConfigModel):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CleanConfig(S3ConfigModel, KeyConfigModel):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PruefberichtConfig(IgnoringBaseSettings):
|
|
26
|
+
pruefbericht: PruefberichtModel
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DbConfig(IgnoringBaseSettings):
|
|
30
|
+
db: DbModel
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ListConfig(S3ConfigModel):
|
|
34
|
+
pass
|
grzctl/models/db.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from typing import Annotated, Self
|
|
2
|
+
|
|
3
|
+
from grz_common.models.base import IgnoringBaseSettings
|
|
4
|
+
from pydantic import Field, FilePath, model_validator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Author(IgnoringBaseSettings):
|
|
8
|
+
name: str
|
|
9
|
+
"""Name of the author"""
|
|
10
|
+
|
|
11
|
+
private_key: str | None = None
|
|
12
|
+
"""Author's private key (needed to sign DB modifications)."""
|
|
13
|
+
|
|
14
|
+
private_key_path: FilePath | None = None
|
|
15
|
+
"""Path to the author's private key (needed to sign DB modifications)."""
|
|
16
|
+
|
|
17
|
+
@model_validator(mode="after")
|
|
18
|
+
def validate_private_key(self) -> Self:
|
|
19
|
+
if self.private_key is not None and self.private_key_path is not None:
|
|
20
|
+
raise ValueError("Only one of private_key or private_key_path must be set.")
|
|
21
|
+
return self
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DbModel(IgnoringBaseSettings):
|
|
25
|
+
"""Submission database related configuration."""
|
|
26
|
+
|
|
27
|
+
database_url: Annotated[str, Field(examples=["sqlite:///submission.sqlite"])]
|
|
28
|
+
"""URL to a database."""
|
|
29
|
+
|
|
30
|
+
author: Author
|
|
31
|
+
"""Author information for submission database."""
|
|
32
|
+
|
|
33
|
+
known_public_keys: FilePath | str = "~/.config/grz-cli/known_public_keys"
|
|
34
|
+
"""
|
|
35
|
+
File listing public keys. Used for DB verification.
|
|
36
|
+
|
|
37
|
+
Format: key_format public_key_base64 author_name"""
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from grz_common.models.base import IgnoringBaseModel
|
|
4
|
+
from pydantic import AnyHttpUrl, UrlConstraints
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PruefberichtModel(IgnoringBaseModel):
|
|
8
|
+
authorization_url: Annotated[AnyHttpUrl, UrlConstraints(allowed_schemes=["https"], host_required=True)] | None = (
|
|
9
|
+
None
|
|
10
|
+
)
|
|
11
|
+
"""
|
|
12
|
+
URL from which to request a new Prüfbericht submission token
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
client_id: str | None = None
|
|
16
|
+
"""
|
|
17
|
+
Client ID used to obtain new Prüfbericht submission tokens
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
client_secret: str | None = None
|
|
21
|
+
"""
|
|
22
|
+
Client secret used to obtain new Prüfbericht submission tokens
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
api_base_url: Annotated[AnyHttpUrl, UrlConstraints(allowed_schemes=["https"], host_required=True)] | None = None
|
|
26
|
+
"""
|
|
27
|
+
Base URL to BfArM Submission (Prüfbericht) API
|
|
28
|
+
"""
|
grzctl/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: grzctl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Control CLI for GRZ administrators.
|
|
5
|
+
Project-URL: Homepage, https://github.com/BfArM-MVH/grz-tools
|
|
6
|
+
Project-URL: Repository, https://github.com/BfArM-MVH/grz-tools
|
|
7
|
+
Project-URL: Documentation, https://github.com/BfArM-MVH/grz-tools/tree/main/packages/grzctl
|
|
8
|
+
Project-URL: Issues, https://github.com/BfArM-MVH/grz-tools/issues
|
|
9
|
+
Author-email: Koray Kirli <koraykirli@gmail.com>, Mathias Lesche <mathias.lesche@tu-dresden.de>, "Florian R. Hölzlwimmer" <git.ich@frhoelzlwimmer.de>, Till Hartmann <till.hartmann@bih-charite.de>, Thomas Sell <thomas.sell@bih-charite.de>, Travis Wrightsman <travis.wrightsman@uni-tuebingen.de>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
Keywords: GDC,GRZ,S3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Requires-Python: >=3.12
|
|
15
|
+
Requires-Dist: boto3<2,>=1.36
|
|
16
|
+
Requires-Dist: click<9,>=8.1.7
|
|
17
|
+
Requires-Dist: crypt4gh<2,>=1.7
|
|
18
|
+
Requires-Dist: grz-common
|
|
19
|
+
Requires-Dist: grz-db
|
|
20
|
+
Requires-Dist: grz-pydantic-models>=1.3.0
|
|
21
|
+
Requires-Dist: jsonschema<5,>=4.23.0
|
|
22
|
+
Requires-Dist: platformdirs<5,>=4.3.6
|
|
23
|
+
Requires-Dist: pydantic-settings<2.10,>=2.9.0
|
|
24
|
+
Requires-Dist: pydantic<2.10,>=2.9.2
|
|
25
|
+
Requires-Dist: pysam==0.23.*
|
|
26
|
+
Requires-Dist: pyyaml<7,>=6.0.2
|
|
27
|
+
Requires-Dist: requests<3,>=2.32.3
|
|
28
|
+
Requires-Dist: rich==13.*
|
|
29
|
+
Requires-Dist: tqdm<5,>=4.66.5
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
GRZ internal tooling.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
grzctl/__init__.py,sha256=XdIBbKjf_hXVHi3q1dIdbAdTpDaheUuEzFnIQYn3G3Q,71
|
|
2
|
+
grzctl/cli.py,sha256=qX5EcQbVMn9SMADVnQWoNPKOJtJKbw09mFqtU9d_Y1A,3289
|
|
3
|
+
grzctl/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
grzctl/commands/__init__.py,sha256=_ZczLaPd0w1uEgRbSNe1J7w1zW1ct424pM9fp6qTw0g,48
|
|
5
|
+
grzctl/commands/archive.py,sha256=MT8XUHPWdRtniRzdgJPpEP2ja1OBevvKNf-w3oJuRz4,926
|
|
6
|
+
grzctl/commands/clean.py,sha256=3TMYc2p8O6INMFi0-2g_oJ-kjSCe5nIAX48LD6OUzq8,2806
|
|
7
|
+
grzctl/commands/consent.py,sha256=z9TirrWVmKzbcX0ONyPKU8ZNT5aznA1JmABN2TSGXxQ,4280
|
|
8
|
+
grzctl/commands/db.py,sha256=e947DKbwA-oVCzd_Jh9hC_kJF2vO7Ba1SAibSBnzUy8,14166
|
|
9
|
+
grzctl/commands/decrypt.py,sha256=dK6FhR_06JLdrkBfq11U2eBzbeJrUGjpC9wCNF4c9d8,1159
|
|
10
|
+
grzctl/commands/download.py,sha256=J7x1fx4zzXLtKv65oEmliCap5SlvU_gXEW_1AtVQcso,1419
|
|
11
|
+
grzctl/commands/list_submissions.py,sha256=bbTOza6ij8tOAw-sWv-lHBfDN-nS_8IaIdl7eZiNUFw,1565
|
|
12
|
+
grzctl/commands/pruefbericht.py,sha256=ksN-MSAQxbPPrTOxK5UkyZZi7M59iaotAcW-la8n56k,7542
|
|
13
|
+
grzctl/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
grzctl/models/config.py,sha256=aK5vIA_lREy9t6nTBiyPtqV4oj55HB8zJsw5AQXKXKo,612
|
|
15
|
+
grzctl/models/db.py,sha256=DCImTKjxIjzOCR38EDePO73GTNsrTGrIL8bh5RySYGc,1199
|
|
16
|
+
grzctl/models/pruefbericht.py,sha256=qeOlqxlehFV7QS23byQGOJc6sNFLkOtT_yKLC9PYmr4,819
|
|
17
|
+
grzctl-0.1.0.dist-info/METADATA,sha256=gUfUokVthxJZNPHarmoCJ2GYPWkQdncZBWffoInzIBo,1396
|
|
18
|
+
grzctl-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
19
|
+
grzctl-0.1.0.dist-info/entry_points.txt,sha256=jL-UHdg_9PxXMA_g_B4atB3XSw62-H3qZsjV-HNHV5g,43
|
|
20
|
+
grzctl-0.1.0.dist-info/RECORD,,
|