Access to VistA Imaging System

This commit is contained in:
Jiang Yio 2023-08-10 18:30:12 -04:00
parent 83c9f11c73
commit 3aa6a64f36
5 changed files with 292 additions and 0 deletions

View File

@ -49,6 +49,7 @@
import RoutePatientReports from './RoutePatientReports.vue';
import RoutePatientDocuments from './RoutePatientDocuments.vue';
import RoutePatientConsults from './RoutePatientConsults.vue';
import RoutePatientImaging from './RoutePatientImaging.vue';
import RoutePlanner from './RoutePlanner.vue';
import RouteRecall from './RouteRecall.vue';
import RouteInbox from './RouteInbox.vue';
@ -100,6 +101,8 @@
{ path: 'document/:tiu_da', component: RoutePatientDocuments },
{ path: 'consult', component: RoutePatientConsults },
{ path: 'consult/:ien', component: RoutePatientConsults },
{ path: 'imaging', component: RoutePatientImaging },
{ path: 'imaging/:ien', component: RoutePatientImaging },
] },
{ path: '/planner', component: RoutePlanner },
{ path: '/recall', component: RouteRecall },

View File

@ -61,6 +61,7 @@
{ name: 'Reports', href: '/patient/' + this.patient_dfn + '/reports' },
{ name: 'Documents', href: '/patient/' + this.patient_dfn + '/document' },
{ name: 'Consults', href: '/patient/' + this.patient_dfn + '/consult' },
{ name: 'Imaging', href: '/patient/' + this.patient_dfn + '/imaging' },
]
} : null;
}

View File

@ -0,0 +1,228 @@
<template>
<Subtitle value="Imaging" />
<Subtitle :value="patient_info.name" />
<div class="row">
<div class="selector col-12" :class="{ 'col-xl-4': selection_data }">
<div class="card mb-3 shadow">
<div class="card-header">{{resultset.length > 0 ? resultset.length : 'No'}} record{{resultset.length == 1 ? '' : 's'}}</div>
<ul class="scroller list-group list-group-flush" :class="{ 'list-skinny': selection_data }" ref="scroller">
<router-link v-for="item in resultset" :to="'/patient/' + patient_dfn + '/imaging/' + item.Info.IEN" replace custom v-slot="{ navigate, href }">
<li :key="item" class="record list-group-item" :class="{ 'active': selection == item.Info.IEN }" :title="'Site: ' + item['Site'] + '\nNote Title: ' + item['Note Title~~W0'] + '\nProc DT: ' + item['Proc DT~S1'] + '\nProcedure: ' + item['Procedure'] + '\n# Img: ' + item['# Img~S2'] + '\nShort Desc: ' + item['Short Desc'] + '\nPkg: ' + item['Pkg'] + '\nClass: ' + item['Class'] + '\nType: ' + item['Type'] + '\nSpecialty: ' + item['Specialty'] + '\nOrigin: ' + item['Origin'] + '\nCap Dt: ' + item['Cap Dt~S1~W0'] + '\nCap by: ' + item['Cap by~~W0'] + '\nImage ID: ' + item['Image ID~S2~W0'] + '\nCreation Date: ' + item.Info['Document Date']" @click="navigate">
<div class="row">
<div class="cell col-4"><router-link :to="href" replace>{{item['Proc DT~S1']}}</router-link></div>
<div class="cell col-7">{{doctitledesc(item)}}</div>
<div class="cell col-1">#{{item['# Img~S2']}}</div>
<div class="cell col-3">{{doctypespec(item)}}</div>
<div class="cell col-3">{{docclspkgproc(item)}}</div>
<div class="cell col-3">{{item['Site']}} {{item['Origin']}}</div>
</div>
</li>
</router-link>
</ul>
</div>
</div>
<div v-if="selection_data" class="col-12 col-xl-8">
<div class="card mb-3 shadow">
<div class="card-header d-flex justify-content-between align-items-center">
<span>{{doctitledesc(selection_data) || 'Image'}} #{{selection}}</span>
<router-link class="close" :to="'/patient/' + patient_dfn + '/imaging'" replace></router-link>
</div>
<div class="detail card-body" ref="detail">
<p v-if="selection_info">{{selection_info}}</p>
<ul v-if="(selection_images) && (selection_images.length > 0)" class="list-group list-group-flush">
<li v-for="info in selection_images" class="list-group-item"><router-link :to="'/v1/vista/' + client.cid + '/imaging/' + info['Image Path'].replace(/\\/g, '/').replace(/^\/+/, '') + '?view'" target="_blank">{{info['Short Desc']}}</router-link></li>
</ul>
</div>
</div>
</div>
</div>
</template>
<style scoped>
div.selector {
position: sticky;
top: 1.15rem;
z-index: 1;
}
ul.scroller.list-skinny {
max-height: 25vh;
overflow-y: auto;
}
li.record {
cursor: default;
border-top: 1px solid #dee2e6;
padding: 0.25rem 0.75rem;
scroll-margin-top: 3.6875rem;
}
li.record:nth-child(even) {
background-color: rgba(0, 0, 0, 0.05);
}
ul.scroller.list-skinny li.record {
scroll-margin-top: 0;
}
li.record a {
color: inherit;
}
li.record.active {
color: #fff;
background-color: #0d6efd;
}
li.bottom {
overflow-anchor: none;
}
div.cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
a.close {
cursor: default;
text-decoration: none;
}
div.detail {
scroll-margin-top: calc(3.6875rem + 2.5625rem + 25vh);
font-family: monospace;
white-space: pre-wrap;
}
@media (min-width: 1200px) {
div.selector {
position: static;
}
ul.scroller.list-skinny {
max-height: 75vh;
}
div.detail {
max-height: 75vh;
scroll-margin-top: 0;
overflow-y: auto;
}
}
</style>
<script>
import { debounce, strptime_vista } from './util.mjs';
import Subtitle from './Subtitle.vue';
const SZ_WINDOW = 100;
export default {
components: {
Subtitle
},
props: {
client: Object,
sensitive: Boolean,
patient_dfn: String,
patient_info: Object
},
data() {
return {
dfn: null,
has_more: '',
is_loading: false,
resultset: [],
selection: null,
selection_info: null,
selection_images: null
};
},
computed: {
selection_data() {
var selection = this.selection, resultset = this.resultset;
if((selection) && (resultset) && (resultset.length > 0)) return resultset.find(item => item['Image ID~S2~W0'] == selection);
}
},
watch: {
'$route.params.ien': {
async handler(value) {
this.selection = value;
}, immediate: true
}
},
methods: {
strptime_vista,
datestring(date) {
return date.toLocaleDateString('sv-SE');
},
datetimestring(date) {
return date.toLocaleDateString('sv-SE') + ' ' + date.toLocaleTimeString('en-GB');
},
docclspkgproc(item) {
var res = [], cls = item['Class'], pkg = item['Pkg'], proc = item['Procedure'];
if(cls) res.push(cls);
if((pkg) && (pkg != 'NONE') && (pkg != cls)) res.push(pkg);
if((proc) && (proc != cls) && (proc != pkg)) res.push(proc);
return res.join(' • ');
},
doctypespec(item) {
var res = [], type = item['Type'], spec = item['Specialty'];
if(type) res.push(type);
if(spec) res.push(spec);
return res.join(' • ');
},
doctitledesc(item) {
var title = item['Note Title~~W0'].replace(/^\s+|\s+$/g, ''), desc = item['Short Desc'].replace(/^\s+|\s+$/g, '');
if(title) {
if((desc == title) || (desc == '+' + title)) return title;
else return title + ' • ' + desc;
} else return desc;
},
doctitle(doc) {
if(doc) {
var m = doc.match(/^Orderable Item:\s*(.*)$/m);
if(m) return m[1];
}
}
},
created() {
this.$watch(
() => (this.client, this.patient_dfn, {}),
debounce(async () => {
if((this.client) && (this.patient_dfn)) this.resultset = await this.client.MAG4_IMAGE_LIST('E', '', '', '', ['IXCLASS^^CLIN^CLIN/ADMIN^ADMIN/CLIN', 'IDFN^^' + this.patient_dfn]);
else this.resultset = [];
}, 500),
{ immediate: true }
);
this.$watch(
() => (this.client, this.selection, {}),
async function() {
try {
this.selection_info = (this.client) && (this.selection) ? await this.client.MAG4_GET_IMAGE_INFO(this.selection) : null;
} catch(ex) {
this.selection_info = null;
console.warn(ex);
}
try {
this.selection_images = (this.client) && (this.selection) && (this.selection_data) ? (this.selection_data['# Img~S2'] == '1' ? [await this.client.MAGG_IMAGE_INFO(this.selection, '1')] : await this.client.MAGG_GROUP_IMAGES(this.selection, '1')) : null;
} catch(ex) {
this.selection_images = null;
console.warn(ex);
}
if(this.$refs.scroller) {
if(this.selection_data) { // scroll to selected item
await this.$nextTick();
var active = this.$refs.scroller.querySelectorAll(':scope > .active');
if(active.length > 0) (Element.prototype.scrollIntoViewIfNeeded || Element.prototype.scrollIntoView).call(active[0]);
if(this.$refs.detail) { // scroll to top of detail panel
this.$refs.detail.scrollIntoView();
this.$refs.detail.scrollTop = 0;
}
} else { // scroll to topmost item
var offset = this.$refs.scroller.getBoundingClientRect().top;
for(var children = this.$refs.scroller.children, count = children.length, i = 0; i < count; ++i) if(children[i].getBoundingClientRect().top >= offset) {
await this.$nextTick();
var behavior = document.documentElement.style.scrollBehavior;
document.documentElement.style.scrollBehavior = 'auto'; // inhibit Bootstrap smooth scrolling
children[i].scrollIntoView();
document.documentElement.style.scrollBehavior = behavior;
break;
}
}
}
},
{ immediate: true }
);
}
};
</script>

View File

@ -50,6 +50,37 @@ export const d_parse_array = data => data !== '' ? data : [];
export const d_parse_authinfo = data => data ? { duz: data[0] != '0' ? data[0] : null, device_lock: data[1] != '0', change_verify: data[2] != '0', message: data[3], reserved: data[4], greeting_lines: data[5], greeting: data.slice(6), success: (data[0] != '0') && (data[2] == '0') } : { success: false }
export const d_parse_imagelist = data => {
var descriptor = d_split1(data[0], '^', 'success', 'filter', 'more'), res;
if(descriptor.success == '1') {
var headers = d_split1(data[1], '^');
res = data.slice(2).map(function(row) {
row = row.split('|');
var values = headers.reduce((acc, val, idx) => (acc[val] = acc[idx], acc), row[0].split('^'))
values.Info = d_split1(row[1], '^', 'IEN', 'Image Path', 'Abstract Path', 'Short Desc', 'Procedure Time', 'Object Type', 'Procedure', 'Display Date', 'Pointer', 'Abs Type', 'Availability', 'DICOM Series', 'DICOM Image', 'Count', 'Site IEN', 'Site', 'Error', 'BIGPath', 'Patient DFN', 'Patient Name', 'Image Class', 'Cap Dt', 'Document Date', 'Group IEN', 'Group Ch1', 'RPC Server', 'RPC Port', 'Controlled Image', 'Viewable Status', 'Status', 'Image Annotated', 'Image TIU Note Completed', 'Annotation Operation Status', 'Annotation Operation Status Description', 'Package');
values.Info['Group Ch1'] = values.Info['Group Ch1'] ? d_split1(':', 'IEN', 'Type') : null
return values;
});
res.descriptor = descriptor;
} else {
res = data.slice(1);
res.descriptor = descriptor;
}
return res;
};
export const d_parse_imageinfo = data => {
var res = d_split1(data[0], '^', 'Code', 'IEN', 'Image Path', 'Abstract Path', 'Short Desc', 'Procedure Time', 'Object Type', 'Procedure', 'Display Date', 'Pointer', 'Abs Type', 'Availability', 'DICOM Series', 'DICOM Image', 'Count', 'Site IEN', 'Site', 'Error', 'BIGPath', 'Patient DFN', 'Patient Name', 'Image Class', 'Cap Dt', 'Document Date', 'Group IEN', 'Group Ch1', 'RPC Server', 'RPC Port', 'Controlled Image', 'Viewable Status', 'Status', 'Image Annotated', 'Image TIU Note Completed', 'Annotation Operation Status', 'Annotation Operation Status Description', 'Package');
res.Patient = d_split1(data[1], '^', 'IEN', 'name');
return res;
};
export const d_parse_imagegroup = data => {
var res = d_split(data.slice(1), '^', 'B2', 'IEN', 'Image Path', 'Abstract Path', 'Short Desc', 'Procedure Time', 'Object Type', 'Procedure', 'Display Date', 'Pointer', 'Abs Type', 'Availability', 'DICOM Series', 'DICOM Image', 'Count', 'Site IEN', 'Site', 'Error', 'BIGPath', 'Patient DFN', 'Patient Name', 'Image Class', 'Cap Dt', 'Document Date', 'Group IEN', 'Group Ch1', 'RPC Server', 'RPC Port', 'Controlled Image', 'Viewable Status', 'Status', 'Image Annotated', 'Image TIU Note Completed', 'Annotation Operation Status', 'Annotation Operation Status Description', 'Package');
res.Result = d_split1(data[0], '^', 'code', 'count');
return res;
};
export const d_parse_orderdialogs = (data, columns=['IEN', 'windowFormId', 'displayGroupId', 'type', 'displayText']) => data.map(function(row) {
row = row.split('^');
row = [...row[0].split(';'), row[1]];
@ -344,6 +375,11 @@ export function Client(cid, secret) {
this.ORWPCE_NOTEVSTR = aflow((...args) => this.call({ method: 'ORWPCE_NOTEVSTR', context: ['OR CPRS GUI CHART'], ttl: 86400, stale: true }, ...args), d_log, d_unwrap);
this.ORWPCE_DELETE = aflow((...args) => this.call({ method: 'ORWPCE_DELETE', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
this.MAG4_IMAGE_LIST = memoized(aflow((...args) => this.call({ method: 'MAG4_IMAGE_LIST', context: ['MAG WINDOWS'], ttl: 60, stale: false }, ...args), d_log, d_unwrap, d_parse_array, d_parse_imagelist));
this.MAG4_GET_IMAGE_INFO = memoized(aflow((...args) => this.call({ method: 'MAG4_GET_IMAGE_INFO', context: ['MAG WINDOWS'], ttl: 60, stale: false }, ...args), d_log, d_unwrap, d_parse_text));
this.MAGG_IMAGE_INFO = memoized(aflow((...args) => this.call({ method: 'MAGG_IMAGE_INFO', context: ['MAG WINDOWS'], ttl: 60, stale: false }, ...args), d_log, d_unwrap, d_parse_array, d_parse_imageinfo));
this.MAGG_GROUP_IMAGES = memoized(aflow((...args) => this.call({ method: 'MAGG_GROUP_IMAGES', context: ['MAG WINDOWS'], ttl: 60, stale: false }, ...args), d_log, d_unwrap, d_parse_array, d_parse_imagegroup));
this.ORWCV_VST = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWCV_VST', ...args), d_log, d_unwrap, d_parse_array, f_split('^', 'apptinfo', 'datetime', 'location', 'status')));
this.ORWU_NEWPERS = memoized(aflow((...args) => this.call({ method: 'ORWU_NEWPERS', context: ['OR CPRS GUI CHART'], ttl: 86400, stale: true }, ...args), d_log, d_unwrap, f_split('^', 'DUZ', 'name', 'description')));

24
main.py
View File

@ -1,5 +1,6 @@
#!/usr/bin/env python3
import os
import json
import secrets
import string
@ -160,6 +161,29 @@ def application():
logger.exception(request.url)
return jsonify_error(ex, id=request.json.get('id'))
@app.get('/v1/vista/<cid>/imaging/<path:path>')
def cb_imaging(cid, path):
if 'view' in request.args:
adapter = 'view.' + path.rsplit('.', 1)[1].lower() + '.html'
if os.path.isfile('./htdocs/adapter/' + adapter):
return send_from_directory('./htdocs/adapter', adapter)
client = clients[cid]
frag = path.replace('\\', '/').strip('/').split('/')
winshare = '\\\\' + '\\'.join(frag[:2]).upper()
for item in client.MAG_GET_NETLOC('ALL', context=['MAG WINDOWS']):
if item.split('^')[1] == winshare:
break
else:
raise PermissionError(path)
try:
open('//' + '/'.join(frag)).close()
except PermissionError as ex:
credentials = client.MAGGUSER2(context=['MAG WINDOWS'])[2].split('^')
import subprocess, XWBHash
subprocess.run(['net', 'use', winshare, '/d'])
subprocess.run(['net', 'use', winshare, '/user:' + credentials[0], XWBHash.decrypt(credentials[1])])
return send_from_directory('//' + '/'.join(frag[:2]), '/'.join(frag[2:]), as_attachment=('dl' in request.args))
@app.get('/<path:path>')
def cb_static(path):
return send_from_directory('./htdocs', path if '.' in path.rsplit('/', 1)[-1] else 'index.html')