diff --git a/CHANGES.rst b/CHANGES.rst index 0180688d..36f536ee 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -21,6 +21,7 @@ Changelog **Fixed** +- #181 Patient historic results are not displayed - #172 Fix sporadical errors when contacts do not have a valid email address - #168 Cannot create Patient inside Client (content type not allowed) - #162 Unable to search by Client Patient ID in Sample Add form diff --git a/bika/health/browser/patient/historicresults.pt b/bika/health/browser/patient/historicresults.pt index 40908a06..973b4a86 100644 --- a/bika/health/browser/patient/historicresults.pt +++ b/bika/health/browser/patient/historicresults.pt @@ -5,11 +5,21 @@ metal:use-macro="here/main_template/macros/master" i18n:domain="senaite.health"> - - + + - .x.axis path { - display: none; - } - - .line { - fill: none; - stroke: steelblue; - stroke-width: 1.5px; - } - .chart-container { - border: 1px solid #CDCDCD; - margin: 10px 0; - border-radius: 5px; - } - .chart-options { - padding:10px; - background: #DDDDDD; - } - - - - - + -

- - -

+

+ + +

- -
- + +
+
-
-
- - -
-
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
TestUnitsRange
-   - - - - - - - -
+ + +
+
+ + +
+
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Test + +
+
+ + :  + + + + + + + + + +
-
-
-
No historic results found
-
+
+
+
No historic results found
+
- + - + diff --git a/bika/health/browser/patient/historicresults.py b/bika/health/browser/patient/historicresults.py index ab8ac457..979cc38b 100644 --- a/bika/health/browser/patient/historicresults.py +++ b/bika/health/browser/patient/historicresults.py @@ -18,20 +18,26 @@ # Copyright 2018-2019 by it's authors. # Some rights reserved, see README and LICENSE. -from Products.CMFCore.utils import getToolByName +import itertools +import json +from datetime import datetime + +from Products.ATContentTypes.utils import DT2dt from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile -from bika.lims.browser import BrowserView + from bika.health import bikaMessageFactory as _ -from zope.interface import implements -from plone.app.layout.globals.interfaces import IViewView -from Products.CMFPlone.i18nl10n import ulocalized_time -import plone -import json +from bika.lims import api +from bika.lims import to_utf8 +from bika.lims.api import to_date +from bika.lims.api.analysis import get_formatted_interval +from bika.lims.browser import BrowserView +from bika.lims.catalog import CATALOG_ANALYSIS_REQUEST_LISTING +from bika.lims.utils import format_supsub class HistoricResultsView(BrowserView): - implements(IViewView) - + """Historic Results View + """ template = ViewPageTemplateFile("historicresults.pt") def __init__(self, context, request): @@ -88,76 +94,74 @@ def get_historicresults(patient): rows = {} dates = [] - uid = patient.UID() - states = ['verified', 'published'] # Retrieve the AR IDs for the current patient - bc = getToolByName(patient, 'bika_catalog') - ars = [ar.id for ar - in bc(portal_type='AnalysisRequest', review_state=states) - if 'Patient' in ar.getObject().Schema() - and ar.getObject().Schema().getField('Patient').get(ar.getObject()) - and ar.getObject().Schema().getField('Patient').get(ar.getObject()).UID() == uid] - - # Retrieve all the analyses, sorted by ResultCaptureDate DESC - bc = getToolByName(patient, 'bika_analysis_catalog') - analyses = [an.getObject() for an - in bc(portal_type='Analysis', - getRequestID=ars, - sort_on='getResultCaptureDate', - sort_order='reverse')] + query = {"portal_type": "AnalysisRequest", + "getPatientUID": api.get_uid(patient), + "review_state": ["verified", "published"], + "sort_on": "getDateSampled", + "sort_order": "descending"} + brains = api.search(query, CATALOG_ANALYSIS_REQUEST_LISTING) + samples = map(api.get_object, brains) + + # Retrieve all analyses + analyses = map(lambda samp: samp.objectValues("Analysis"), samples) + analyses = list(itertools.chain.from_iterable(analyses)) # Build the dictionary of rows for analysis in analyses: - ar = analysis.aq_parent - sampletype = ar.getSampleType() - row = rows.get(sampletype.UID()) if sampletype.UID() in rows.keys() \ - else {'object': sampletype, 'analyses': {}} - anrow = row.get('analyses') + sample = analysis.aq_parent + sample_type = sample.getSampleType() + row = { + "object": sample_type, + "analyses": {}, + } + sample_type_uid = api.get_uid(sample_type) + if sample_type_uid in rows: + row = rows.get(sample_type_uid) + + anrow = row.get("analyses") service_uid = analysis.getServiceUID() - asdict = anrow.get(service_uid, {'object': analysis, - 'title': analysis.Title(), - 'keyword': analysis.getKeyword(), - 'units': analysis.getUnit()}) + asdict = { + "object": analysis, + "title": api.get_title(analysis), + "keyword": to_utf8(analysis.getKeyword()), + } + if service_uid in anrow: + asdict = anrow.get(service_uid) + + if not anrow.get("units", None): + asdict.update({ + "units": format_supsub(to_utf8(analysis.getUnit())) + }) + date = analysis.getResultCaptureDate() or analysis.created() - date = ulocalized_time(date, 1, None, patient, 'bika') + date_time = DT2dt(to_date(date)).replace(tzinfo=None) + date_time = datetime.strftime(date_time, "%Y-%m-%d %H:%M") + # If more than one analysis of the same type has been # performed in the same datetime, get only the last one - if date not in asdict.keys(): - asdict[date] = {'object': analysis, - 'result': analysis.getResult(), - 'formattedresult': analysis.getFormattedResult()} + if date_time not in asdict.keys(): + asdict[date_time] = { + "object": analysis, + "result": analysis.getResult(), + "formattedresult": analysis.getFormattedResult() + } # Get the specs # Only the specs applied to the last analysis for that # sample type will be taken into consideration. # We assume specs from previous analyses are obsolete. - if 'specs' not in asdict.keys(): - spec = analysis.getAnalysisSpecs() - spec = spec.getResultsRangeDict() if spec else {} - specs = spec.get(analysis.getKeyword(), {}) - if not specs.get('rangecomment', ''): - if specs.get('min', '') and specs.get('max', ''): - specs['rangecomment'] = '%s - %s' % \ - (specs.get('min'), specs.get('max')) - elif specs.get('min', ''): - specs['rangecomment'] = '> %s' % specs.get('min') - elif specs.get('max', ''): - specs['rangecomment'] = '< %s' % specs.get('max') - - if specs.get('error', '0') != '0' \ - and specs.get('rangecomment', ''): - specs['rangecomment'] = ('%s (%s' % - (specs.get('rangecomment'), - specs.get('error'))) + '%)' - asdict['specs'] = specs - - if date not in dates: - dates.append(date) + if "specs" not in asdict.keys(): + specs = analysis.getResultsRange() + asdict["specs"] = get_formatted_interval(specs, "") + + if date_time not in dates: + dates.append(date_time) + anrow[service_uid] = asdict row['analyses'] = anrow - rows[sampletype.UID()] = row + rows[sample_type_uid] = row dates.sort(reverse=False) - return dates, rows @@ -174,11 +178,12 @@ def __call__(self): dates, data = get_historicresults(self.context) datatable = [] for andate in dates: - datarow = {'date': ulocalized_time( - andate, 1, None, self.context, 'bika')} + datarow = {'date': andate} for row in data.itervalues(): for anrow in row['analyses'].itervalues(): serie = anrow['title'] + if "result" not in anrow.get(andate, {}): + continue datarow[serie] = anrow.get(andate, {}).get('result', '') datatable.append(datarow) return json.dumps(datatable) diff --git a/bika/health/static/js/bika.health.historicresults.js b/bika/health/static/js/bika.health.historicresults.js new file mode 100644 index 00000000..c00702d5 --- /dev/null +++ b/bika/health/static/js/bika.health.historicresults.js @@ -0,0 +1,218 @@ +jQuery(function($){ + $(document).ready(function(){ + + // Update the chart when interpolation value changes + $('div.chart-container #interpolation').change(function(e) { + loadChart($(this).val()); + }); + + // By default, use "linear" interpolation + loadChart("linear"); + + function loadChart(interpolation) { + if ($("#chart svg").length > 0) { + $("#chart").css("height", $("#chart").innerHeight()); + $("#chart").css("width", $("#chart").innerWidth()); + } + + $("#chart").html(""); + + d3.json("historicjson", function(error, data) { + + // Do not display the chart if less than two results + if (error || data.length < 2) { + $(".chart-container").hide(); + return; + } + + $(".chart-container").show(); + + var margin = {top: 20, right: 120, bottom: 30, left: 50}, + width = $('#chart').innerWidth() - margin.left - margin.right, + height = 250 - margin.top - margin.bottom; + + var parse_date = d3.time.format("%Y-%m-%d %H:%M").parse; + + var color = d3.scale.category10(); + + var x = d3.time.scale() + .range([0, width]); + + var y = d3.scale.linear() + .range([height, 0]); + + var xAxis = d3.svg.axis() + .scale(x) + .orient("bottom") + .tickSize(0); + + var yAxis = d3.svg.axis() + .scale(y) + .orient("left") + .tickSize(0); + + var line = d3.svg.line() + .interpolate(interpolation) + .x(function(d) { return x(d.date); }) + .y(function(d) { return y(d.result); }); + + $('#chart').append(''); + var svg = d3.select("svg") + .attr("width", width + margin.left + margin.right) + .attr("height", height + margin.top + margin.bottom) + .append("g") + .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); + + // Extract the keys for series (data series might be unbalanced) + var keys = d3.set() + data.forEach(function(d) { + var row_keys = d3.keys(d); + row_keys.forEach(function(key) { + if (key !== "date") { + keys.add(key); + } + }); + }); + color.domain(keys.values()); + + // Apply valid format to date (x-axis) + data.forEach(function(d) { + d.date = parse_date(d.date); + }); + + var series = color.domain().map(function(name) { + return { + name: name, + values: data.map(function(d) { + return {date: d.date, result: +d[name]}; + }) + }; + }); + + x.domain(d3.extent(data, function(d) { return d.date; })); + y.domain([ + d3.min(series, function(c) { + return d3.min(c.values, function(v) { + return (typeof v === 'undefined') ? "" : v.result; + }); + }), + d3.max(series, function(c) { + return d3.max(c.values, function(v) { + return (typeof v === 'undefined') ? "" : v.result; + }); + }) + ]); + + svg.append("g") + .attr("class", "x axis") + .attr("fill", "#3f3f3f") + .style("font-size", "11px") + .attr("transform", "translate(0," + height + ")") + .call(xAxis) + .append("text") + .attr("x", width) + .attr("dy", "-0.71em") + .attr("text-anchor", "end") + .text("Date captured"); + + svg.append("g") + .attr("class", "y axis") + .attr("fill", "#3f3f3f") + .style("font-size", "11px") + .call(yAxis) + .append("text") + .attr("transform", "rotate(-90)") + .attr("y", 6) + .attr("dy", ".71em") + .style("text-anchor", "end") + .text("Result"); + + var serie = svg.selectAll(".serie") + .data(series) + .enter().append("g") + .attr("class", "serie"); + + serie.append("path") + .attr("class", "line") + .attr("fill", "none") + .attr("d", function(d) { + var vals = d.values; + return line( + // Bail out empty values + vals.filter(function(value) { + return !(Number.isNaN(value.result)); + }) + ); + }) + .attr("stroke-width", "1.5px") + .style("stroke", function(d) { return color(d.name); }) + .on("mouseout", function() { + d3.select(this) + .attr("stroke-width", "1.5px"); + }) + .on("mouseover", function() { + d3.select(this) + .attr("stroke-width", "4px"); + }); + + // Place the legend for the series + serie.append("text") + .datum(function(d) { + // Get the last non empty value for this serie + var last_val = 0; + for (var i = d.values.length - 1; i >= 0; i--) { + var val = d.values[i]; + console.log(val); + if (!Number.isNaN(val.result)) { + last_val = val; + break; + } + } + return {name: d.name, value: last_val}; + }) + .attr("transform", function(d) { return "translate(" + x(d.value.date) + "," + y(d.value.result) + ")"; }) + .attr("x", 10) + .attr("dy", ".35em") + .style("font-size", "11px") + .style("fill", function(d) { return color(d.name);}) + .text(function(d) { return d.name; }); + + series.forEach(function(d) { + res = d.results; + vals = d.values; + col = color(d.name); + vals.forEach(function(v) { + // Do not create dots for empty values + if (Number.isNaN(v.result)) { + return; + } + svg.append("circle") + .attr("r", 3) + .style("fill", col) + .attr("cx", x(v.date)) + .attr("cy", y(v.result)) + .on("mouseout", function() { + last = this.parentNode.children.length; + d3.select(this) + .attr("r", 3); + d3.select(this.parentNode.children[last-1]) + .remove(); + }) + .on("mouseover", function() { + d3.select(this) + .attr("r", 6); + d3.select(this.parentNode) + .append("text") + .attr("fill", "#000000") + .style("font-size", "11px") + .attr("x", x(v.date) - 10) + .attr("y", y(v.result) - 10) + .text(v.result) + }) + }); + }); + }); + } + + }); +}); \ No newline at end of file