Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Upload DICOM images with FTPS #226

Merged
merged 45 commits into from
Jan 23, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
05ff091
Add dummy ftp server
stefpiatek Jan 12, 2024
412326d
Move FTP server to core tests
stefpiatek Jan 12, 2024
7448861
Move FTP server to core tests
stefpiatek Jan 12, 2024
f9ab780
Run core tests from pytest directly
stefpiatek Jan 12, 2024
9dfbe38
Fix typo
stefpiatek Jan 12, 2024
442640b
Spin up docker containers from pytest for core
stefpiatek Jan 12, 2024
d2165ab
Specify `platform` for FTP server Docker
milanmlft Jan 15, 2024
0443faa
Merge branch 'main' into stef/image-upload-sftp
milanmlft Jan 15, 2024
1e64846
Add basic FTP upload functionality
milanmlft Jan 15, 2024
3b28bbd
Rename `file_upload.py` -> `upload.py`
milanmlft Jan 15, 2024
387f177
Switch to different docker image for ftp server
milanmlft Jan 15, 2024
33d4ad5
Generate new SSL certificates for ftp server
milanmlft Jan 15, 2024
7ce2105
Refactor `upload_file()` to return the remote directory and output fi…
milanmlft Jan 15, 2024
34ed7cc
Add basic test for ftp file upload
milanmlft Jan 15, 2024
b0de033
Added interaction with PIXL db and download upload functionality
ruaridhg Jan 16, 2024
8efa95c
Get to point where connections have to be encrypted
stefpiatek Jan 19, 2024
7fb161d
pair switch
stefpiatek Jan 19, 2024
8d626cf
gitignore pixl_core test files and folders created
ruaridhg Jan 19, 2024
7856a56
FTP_TLS in production and FTP for testing
ruaridhg Jan 19, 2024
6131a5d
Use FTP_TLS for testing (#230)
stefpiatek Jan 19, 2024
32079bb
Remove FTP uploaded data after tests
milanmlft Jan 22, 2024
469729b
Test DICOM image uploading with FTPS
milanmlft Jan 22, 2024
567081e
remove timezone info from db
ruaridhg Jan 22, 2024
f40e1ce
Added exception for image already exported
ruaridhg Jan 22, 2024
422fcb2
Tear down data sub dirs after testing
ruaridhg Jan 22, 2024
ff01769
Fix directory teardown in `mounted_data()` fixture`
milanmlft Jan 22, 2024
edb96c7
Revert "Fix directory teardown in `mounted_data()` fixture`"
milanmlft Jan 22, 2024
6f6b449
Make FTP server mounted data directory writable on GHA
milanmlft Jan 22, 2024
d6a04d3
Fix my fix
milanmlft Jan 22, 2024
9f1c67d
😫
milanmlft Jan 22, 2024
c10c843
Maybe this will work?
milanmlft Jan 22, 2024
9b1983d
🤞
milanmlft Jan 22, 2024
aa38ce2
😤
milanmlft Jan 22, 2024
8413c80
Clean up, fix ruff errors
milanmlft Jan 22, 2024
15c8a83
Merge branch 'main' into stef/image-upload-sftp
milanmlft Jan 23, 2024
c5cf602
Remove return from upload_dicom_image()
milanmlft Jan 23, 2024
5d52bc9
Remove return from `upload_content()`, part 2
milanmlft Jan 23, 2024
a0ab2a8
Remove return from `upload_content()`, part 3
milanmlft Jan 23, 2024
9401ea3
Refactor `upload_dicom_image()`, remove `upload_content()`
milanmlft Jan 23, 2024
7c4bac9
Revert scope for `mounted_data()` fixture
milanmlft Jan 23, 2024
b8ff97e
Set return type for `run_containers()` fixture
milanmlft Jan 23, 2024
8e0622c
Renaming and adding docstrings to public module functions
milanmlft Jan 23, 2024
c5a94d3
Extract `_query_existing_image` method
milanmlft Jan 23, 2024
0e62386
Extract global variables in orthanc plugins
milanmlft Jan 23, 2024
93ce4da
Log the `resourceID`
milanmlft Jan 23, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions orthanc/orthanc-anon/plugin/pixl.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

import requests
import yaml
from core import upload
from decouple import config
from pydicom import dcmread

Expand Down Expand Up @@ -156,6 +157,34 @@ def SendViaStow(resourceId):
orthanc.LogError("Failed to send via STOW")


def SendViaFTPS(resourceId):
"""
Makes a POST API call to upload the resource to a dicom-web server
using orthanc credentials as authorisation
"""
ORTHANC_USERNAME = config("ORTHANC_USERNAME")
ORTHANC_PASSWORD = config("ORTHANC_PASSWORD")

milanmlft marked this conversation as resolved.
Show resolved Hide resolved
orthanc_url = "http://localhost:8042"

# Query orthanc-anon for the study
query = f"{orthanc_url}/studies/{resourceId}/archive"
try:
response_study = requests.get(
query, verify=False, auth=(ORTHANC_USERNAME, ORTHANC_PASSWORD)
)
if response_study.status_code != 200:
raise RuntimeError(f"Could not download archive of resource '{resourceId}'")
except requests.exceptions.RequestException:
orthanc.LogError(f"Failed to query'{resourceId}'")

# get the zip content
zip_content = response_study.content
logging.info("Downloaded data: %s", zip_content)
milanmlft marked this conversation as resolved.
Show resolved Hide resolved

upload.upload_content(zip_content, resourceId)
milanmlft marked this conversation as resolved.
Show resolved Hide resolved


def ShouldAutoRoute():
"""
Checks whether ORTHANC_AUTOROUTE_ANON_TO_AZURE environment variable is
Expand Down
80 changes: 80 additions & 0 deletions pixl_core/src/core/_database.py
milanmlft marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Copyright (c) University College London Hospitals NHS Foundation Trust
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Interaction with the PIXL database."""
from typing import TYPE_CHECKING

from decouple import config
from sqlalchemy import URL, create_engine
from sqlalchemy.orm import sessionmaker

from core.database import Extract, Image

if TYPE_CHECKING:
from datetime import datetime

url = URL.create(
drivername="postgresql+psycopg2",
username=config("PIXL_DB_USER", default="None"),
password=config("PIXL_DB_PASSWORD", default="None"),
host=config("PIXL_DB_HOST", default="None"),
port=config("PIXL_DB_PORT", default=1),
database=config("PIXL_DB_NAME", default="None"),
)

engine = create_engine(url)


def get_project_slug_from_db(hashed_value: str) -> Image:
PixlSession = sessionmaker(engine)
with PixlSession() as pixl_session, pixl_session.begin():
existing_image = (
pixl_session.query(Image)
.filter(
Image.hashed_identifier == hashed_value,
)
.one()
)
milanmlft marked this conversation as resolved.
Show resolved Hide resolved

existing_extract = (
pixl_session.query(Extract)
.filter(
Extract.extract_id == existing_image.extract_id,
)
.one()
)

return existing_extract.slug


def update_exported_at_and_save(hashed_value: str, date_time: datetime) -> Image:
PixlSession = sessionmaker(engine)
with PixlSession() as pixl_session, pixl_session.begin():
existing_image = (
pixl_session.query(Image)
.filter(
Image.hashed_identifier == hashed_value,
)
.one()
)
existing_image.exported_at = date_time
pixl_session.add(existing_image)

updated_image = (
pixl_session.query(Image)
.filter(Image.hashed_identifier == hashed_value, Image.exported_at == date_time)
.one_or_none()
)

return updated_image
24 changes: 18 additions & 6 deletions pixl_core/src/core/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,47 @@
import ftplib
import logging
import os
from datetime import datetime
from ftplib import FTP_TLS
from pathlib import Path

from core._database import get_project_slug_from_db, update_exported_at_and_save

logger = logging.getLogger(__name__)

# Make a DSHUploader class that takes a project slug and study pseudonymised id?


def upload_file(local_file_path: Path) -> str:
"""Upload local file to hardcoded directory in ftp server."""
output_filename = local_file_path.name
# Store the file using a binary handler
with local_file_path.open("rb") as file_content:
upload_content(file_content, output_filename)


def upload_content(content: bytes, pseudo_anon_id: str) -> str:
milanmlft marked this conversation as resolved.
Show resolved Hide resolved
"""Upload local file to hardcoded directory in ftp server."""
ftp = _connect_to_ftp()

# Create the remote directory if it doesn't exist
# TODO: rename destination to {project-slug}/{study-pseduonymised-id}.zip
remote_directory = get_project_slug_from_db(pseudo_anon_id)

remote_directory = "new-extract"
_create_and_set_as_cwd(ftp, remote_directory)
output_filename = pseudo_anon_id + ".zip"

output_filename = local_file_path.name
# Store the file using a binary handler
with local_file_path.open("rb") as local_file:
command = f"STOR {output_filename}"
logger.info("Running %s", command)
ftp.storbinary(command, local_file)
command = f"STOR {output_filename}"
logger.info("Running %s", command)
ftp.storbinary(command, content)

# Close the FTP connection
ftp.quit()
logger.info("Done!")

update_exported_at_and_save(datetime.now)
milanmlft marked this conversation as resolved.
Show resolved Hide resolved

return f"{remote_directory} / {output_filename}"


Expand Down
Loading