From bf5c621f43f2f75a8ce639c8c465b45c5c60a844 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Mon, 22 Nov 2021 07:41:52 +1100 Subject: [PATCH] update rendering to get timezones right, and add tests for that --- RELEASE_NOTES.md | 2 +- .../hl7/fhir/r5/renderers/DataRenderer.java | 89 +++++++++++++++---- .../r5/renderers/utils/RenderingContext.java | 68 ++++++++++++++ .../r5/test/NarrativeGenerationTests.java | 16 ++++ .../fhir/r5/test/NarrativeGeneratorTests.java | 50 ++++++++++- pom.xml | 2 +- 6 files changed, 207 insertions(+), 20 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 1126aab1f..e60a3f7d7 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -3,4 +3,4 @@ Validator: Other changes: * improvements to data types rendering based on new test cases (URLs, Money, Markdown) -* add locale to rendering context +* add locale to rendering context, and fix up timezone related rendering based on locale diff --git a/org.hl7.fhir.r5/src/main/java/org/hl7/fhir/r5/renderers/DataRenderer.java b/org.hl7.fhir.r5/src/main/java/org/hl7/fhir/r5/renderers/DataRenderer.java index 03fa0c3b2..b716eb5c0 100644 --- a/org.hl7.fhir.r5/src/main/java/org/hl7/fhir/r5/renderers/DataRenderer.java +++ b/org.hl7.fhir.r5/src/main/java/org/hl7/fhir/r5/renderers/DataRenderer.java @@ -3,9 +3,17 @@ package org.hl7.fhir.r5.renderers; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.math.BigDecimal; +import java.text.DateFormat; import java.text.NumberFormat; +import java.text.SimpleDateFormat; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; import java.util.Currency; import java.util.List; +import java.util.TimeZone; import org.hl7.fhir.exceptions.DefinitionException; import org.hl7.fhir.exceptions.FHIRException; @@ -38,7 +46,6 @@ import org.hl7.fhir.r5.model.HumanName; import org.hl7.fhir.r5.model.HumanName.NameUse; import org.hl7.fhir.r5.model.IdType; import org.hl7.fhir.r5.model.Identifier; -import org.hl7.fhir.r5.model.InstantType; import org.hl7.fhir.r5.model.MarkdownType; import org.hl7.fhir.r5.model.Money; import org.hl7.fhir.r5.model.Period; @@ -70,6 +77,9 @@ import org.hl7.fhir.utilities.validation.ValidationOptions; import org.hl7.fhir.utilities.xhtml.NodeType; import org.hl7.fhir.utilities.xhtml.XhtmlNode; import org.hl7.fhir.utilities.xhtml.XhtmlParser; + +import ca.uhn.fhir.model.api.TemporalPrecisionEnum; + import org.hl7.fhir.utilities.xhtml.HierarchicalTableGenerator; import org.hl7.fhir.utilities.xhtml.HierarchicalTableGenerator.Piece; @@ -379,6 +389,8 @@ public class DataRenderer extends Renderer { return displayTiming((Timing) type); } else if (type instanceof SampledData) { return displaySampledData((SampledData) type); + } else if (type.isDateTime()) { + return displayDateTime((BaseDateTimeType) type); } else if (type.isPrimitive()) { return type.primitiveValue(); } else { @@ -386,6 +398,53 @@ public class DataRenderer extends Renderer { } } + private String displayDateTime(BaseDateTimeType type) { + if (!type.hasPrimitiveValue()) { + return ""; + } + + // relevant inputs in rendering context: + // timeZone, dateTimeFormat, locale, mode + // timezone - application specified timezone to use. + // null = default to the time of the date/time itself + // dateTimeFormat - application specified format for date times + // null = default to ... depends on mode + // mode - if rendering mode is technical, format defaults to XML format + // locale - otherwise, format defaults to default for the Locale + if (isOnlyDate(type.getPrecision())) { + DateTimeFormatter fmt = context.getDateFormat(); + if (fmt == null) { + if (context.isTechnicalMode()) { + fmt = DateTimeFormatter.ISO_DATE; + } else { + fmt = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM); + } + } + + LocalDate date = LocalDate.of(type.getYear(), type.getMonth()+1, type.getDay()); + return fmt.format(date); + } + + DateTimeFormatter fmt = context.getDateTimeFormat(); + if (fmt == null) { + if (context.isTechnicalMode()) { + fmt = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + } else { + fmt = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM); + } + } + ZonedDateTime zdt = ZonedDateTime.parse(type.primitiveValue()); + ZoneId zone = context.getTimeZoneId(); + if (zone != null) { + zdt = zdt.withZoneSameInstant(zone); + } + return fmt.format(zdt); + } + + private boolean isOnlyDate(TemporalPrecisionEnum temporalPrecisionEnum) { + return temporalPrecisionEnum == TemporalPrecisionEnum.YEAR || temporalPrecisionEnum == TemporalPrecisionEnum.MONTH || temporalPrecisionEnum == TemporalPrecisionEnum.DAY; + } + public String display(BaseWrapper type) { return "to do"; } @@ -414,8 +473,8 @@ public class DataRenderer extends Renderer { } public void render(XhtmlNode x, DataType type) throws FHIRFormatError, DefinitionException, IOException { - if (type instanceof DateTimeType) { - renderDateTime(x, (DateTimeType) type); + if (type instanceof BaseDateTimeType) { + x.tx(displayDateTime((BaseDateTimeType) type)); } else if (type instanceof UriType) { renderUri(x, (UriType) type); } else if (type instanceof Annotation) { @@ -448,12 +507,8 @@ public class DataRenderer extends Renderer { renderSampledData(x, (SampledData) type); } else if (type instanceof Reference) { renderReference(x, (Reference) type); - } else if (type instanceof InstantType) { - x.tx(((InstantType) type).toHumanDisplay()); } else if (type instanceof MarkdownType) { addMarkdown(x, ((MarkdownType) type).asStringValue()); - } else if (type instanceof BaseDateTimeType) { - x.tx(((BaseDateTimeType) type).toHumanDisplay()); } else if (type.isPrimitive()) { x.tx(type.primitiveValue()); } else { @@ -473,14 +528,14 @@ public class DataRenderer extends Renderer { public void renderDateTime(XhtmlNode x, Base e) { if (e.hasPrimitiveValue()) { - x.addText(((DateTimeType) e).toHumanDisplay()); + x.addText(displayDateTime((DateTimeType) e)); } } public void renderDateTime(XhtmlNode x, String s) { if (s != null) { DateTimeType dt = new DateTimeType(s); - x.addText(dt.toHumanDisplay()); + x.addText(displayDateTime(dt)); } } @@ -1112,16 +1167,16 @@ public class DataRenderer extends Renderer { x.tx(" "+q.getLow().getUnit()); } - public static String displayPeriod(Period p) { - String s = !p.hasStart() ? "(?)" : p.getStartElement().toHumanDisplay(); + public String displayPeriod(Period p) { + String s = !p.hasStart() ? "(?)" : displayDateTime(p.getStartElement()); s = s + " --> "; - return s + (!p.hasEnd() ? "(ongoing)" : p.getEndElement().toHumanDisplay()); + return s + (!p.hasEnd() ? "(ongoing)" : displayDateTime(p.getEndElement())); } public void renderPeriod(XhtmlNode x, Period p) { - x.addText(!p.hasStart() ? "??" : p.getStartElement().toHumanDisplay()); + x.addText(!p.hasStart() ? "??" : displayDateTime(p.getStartElement())); x.tx(" --> "); - x.addText(!p.hasEnd() ? "(ongoing)" : p.getEndElement().toHumanDisplay()); + x.addText(!p.hasEnd() ? "(ongoing)" : displayDateTime(p.getEndElement())); } public void renderDataRequirement(XhtmlNode x, DataRequirement dr) throws FHIRFormatError, DefinitionException, IOException { @@ -1229,7 +1284,7 @@ public class DataRenderer extends Renderer { CommaSeparatedStringBuilder c = new CommaSeparatedStringBuilder(); for (DateTimeType p : s.getEvent()) { if (p.hasValue()) { - c.append(p.toHumanDisplay()); + c.append(displayDateTime(p)); } else if (!renderExpression(c, p)) { c.append("??"); } @@ -1240,7 +1295,7 @@ public class DataRenderer extends Renderer { if (s.hasRepeat()) { TimingRepeatComponent rep = s.getRepeat(); if (rep.hasBoundsPeriod() && rep.getBoundsPeriod().hasStart()) - b.append("Starting "+rep.getBoundsPeriod().getStartElement().toHumanDisplay()); + b.append("Starting "+displayDateTime(rep.getBoundsPeriod().getStartElement())); if (rep.hasCount()) b.append("Count "+Integer.toString(rep.getCount())+" times"); if (rep.hasDuration()) @@ -1272,7 +1327,7 @@ public class DataRenderer extends Renderer { b.append("Do "+st); } if (rep.hasBoundsPeriod() && rep.getBoundsPeriod().hasEnd()) - b.append("Until "+rep.getBoundsPeriod().getEndElement().toHumanDisplay()); + b.append("Until "+displayDateTime(rep.getBoundsPeriod().getEndElement())); } return b.toString(); } diff --git a/org.hl7.fhir.r5/src/main/java/org/hl7/fhir/r5/renderers/utils/RenderingContext.java b/org.hl7.fhir.r5/src/main/java/org/hl7/fhir/r5/renderers/utils/RenderingContext.java index f560d36d6..5e57c4b85 100644 --- a/org.hl7.fhir.r5/src/main/java/org/hl7/fhir/r5/renderers/utils/RenderingContext.java +++ b/org.hl7.fhir.r5/src/main/java/org/hl7/fhir/r5/renderers/utils/RenderingContext.java @@ -1,9 +1,14 @@ package org.hl7.fhir.r5.renderers.utils; import java.io.IOException; +import java.text.DateFormat; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.TimeZone; import org.hl7.fhir.exceptions.FHIRException; import org.hl7.fhir.exceptions.FHIRFormatError; @@ -119,6 +124,10 @@ public class RenderingContext { private FhirPublication targetVersion; private Locale locale; + private ZoneId timeZoneId; + private DateTimeFormatter dateTimeFormat; + private DateTimeFormatter dateFormat; + /** * * @param context - access to all related resources that might be needed @@ -439,4 +448,63 @@ public class RenderingContext { this.locale = locale; } + + /** + * if the timezone is null, the rendering will default to the source timezone + * in the resource + * + * Note that if you're working server side, the FHIR project recommends the use + * of the Date header so that clients know what timezone the server defaults to, + * + * There is no standard way for the server to know what the client timezone is. + * In the case where the client timezone is unknown, the timezone should be null + * + * @return the specified timezone to render in + */ + public ZoneId getTimeZoneId() { + return timeZoneId; + } + + public void setTimeZoneId(ZoneId timeZoneId) { + this.timeZoneId = timeZoneId; + } + + + /** + * In the absence of a specified format, the renderers will default to + * the FormatStyle.MEDIUM for the current locale. + * + * @return the format to use + */ + public DateTimeFormatter getDateTimeFormat() { + return this.dateTimeFormat; + } + + public void setDateTimeFormat(DateTimeFormatter dateTimeFormat) { + this.dateTimeFormat = dateTimeFormat; + } + + /** + * In the absence of a specified format, the renderers will default to + * the FormatStyle.MEDIUM for the current locale. + * + * @return the format to use + */ + public DateTimeFormatter getDateFormat() { + return this.dateFormat; + } + + public void setDateFormat(DateTimeFormatter dateFormat) { + this.dateFormat = dateFormat; + } + + public ResourceRendererMode getMode() { + return mode; + } + + public void setMode(ResourceRendererMode mode) { + this.mode = mode; + } + + } \ No newline at end of file diff --git a/org.hl7.fhir.r5/src/test/java/org/hl7/fhir/r5/test/NarrativeGenerationTests.java b/org.hl7.fhir.r5/src/test/java/org/hl7/fhir/r5/test/NarrativeGenerationTests.java index d37a0218b..b02ef7786 100644 --- a/org.hl7.fhir.r5/src/test/java/org/hl7/fhir/r5/test/NarrativeGenerationTests.java +++ b/org.hl7.fhir.r5/src/test/java/org/hl7/fhir/r5/test/NarrativeGenerationTests.java @@ -1,8 +1,13 @@ package org.hl7.fhir.r5.test; +import java.io.BufferedReader; import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.FileOutputStream; +import java.io.FileReader; import java.io.IOException; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.stream.Stream; @@ -76,12 +81,14 @@ public class NarrativeGenerationTests { private String id; private boolean header; private boolean meta; + private boolean technical; public TestDetails(Element test) { super(); id = test.getAttribute("id"); header = "true".equals(test.getAttribute("header")); meta = "true".equals(test.getAttribute("meta")); + technical = "technical".equals(test.getAttribute("mode")); } public String getId() { @@ -132,6 +139,15 @@ public class NarrativeGenerationTests { rc.setDefinitionsTarget("test.html"); rc.setTerminologyServiceOptions(TerminologyServiceOptions.defaults()); rc.setParser(new TestTypeParser()); + + // getting timezones correct (well, at least consistent, so tests pass on any computer) + rc.setLocale(new java.util.Locale("en", "AU")); + rc.setTimeZoneId(ZoneId.of("Australia/Sydney")); + rc.setDateTimeFormat(null); + rc.setDateFormat(null); + rc.setMode(test.technical ? ResourceRendererMode.TECHNICAL : ResourceRendererMode.END_USER); + + Resource source; if (TestingUtilities.findTestResource("r5", "narrative", test.getId() + ".json")) { source = (Resource) new JsonParser().parse(TestingUtilities.loadTestResourceStream("r5", "narrative", test.getId() + ".json")); diff --git a/org.hl7.fhir.r5/src/test/java/org/hl7/fhir/r5/test/NarrativeGeneratorTests.java b/org.hl7.fhir.r5/src/test/java/org/hl7/fhir/r5/test/NarrativeGeneratorTests.java index cdc8c5c6b..afd0f55fb 100644 --- a/org.hl7.fhir.r5/src/test/java/org/hl7/fhir/r5/test/NarrativeGeneratorTests.java +++ b/org.hl7.fhir.r5/src/test/java/org/hl7/fhir/r5/test/NarrativeGeneratorTests.java @@ -4,22 +4,34 @@ import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.Locale; +import java.util.TimeZone; +import org.hl7.fhir.exceptions.DefinitionException; import org.hl7.fhir.exceptions.FHIRException; +import org.hl7.fhir.exceptions.FHIRFormatError; import org.hl7.fhir.r5.formats.XmlParser; +import org.hl7.fhir.r5.model.DateTimeType; import org.hl7.fhir.r5.model.DomainResource; +import org.hl7.fhir.r5.renderers.DataRenderer; import org.hl7.fhir.r5.renderers.RendererFactory; import org.hl7.fhir.r5.renderers.utils.RenderingContext; import org.hl7.fhir.r5.renderers.utils.RenderingContext.ResourceRendererMode; import org.hl7.fhir.r5.test.utils.TestingUtilities; import org.hl7.fhir.r5.utils.EOperationOutcome; +import org.hl7.fhir.utilities.xhtml.NodeType; +import org.hl7.fhir.utilities.xhtml.XhtmlComposer; +import org.hl7.fhir.utilities.xhtml.XhtmlNode; +import org.junit.Assert; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.xmlpull.v1.XmlPullParserException; public class NarrativeGeneratorTests { - private static RenderingContext rc; @BeforeAll @@ -40,4 +52,40 @@ public class NarrativeGeneratorTests { new XmlParser().compose(s, r, true); s.close(); } + + + private void checkDateTimeRendering(String src, String lang, String country, ZoneId tz, FormatStyle fmt, ResourceRendererMode mode, String expected) throws FHIRFormatError, DefinitionException, IOException { + rc.setLocale(new java.util.Locale(lang, country)); + rc.setTimeZoneId(tz); + if (fmt == null) { + rc.setDateTimeFormat(null); + rc.setDateFormat(null); + } else { + rc.setDateTimeFormat(DateTimeFormatter.ofLocalizedDateTime(fmt)); + rc.setDateFormat(DateTimeFormatter.ofLocalizedDate(fmt)); + } + rc.setMode(mode); + + DateTimeType dt = new DateTimeType(src); + String actual = new DataRenderer(rc).display(dt); + Assert.assertEquals(expected, actual); + XhtmlNode node = new XhtmlNode(NodeType.Element, "p"); + new DataRenderer(rc).render(node, dt); + actual = new XhtmlComposer(true, false).compose(node); + Assert.assertEquals("

"+expected+"

", actual); + } + + + @Test + public void testDateTimeRendering() throws FHIRFormatError, DefinitionException, IOException { + checkDateTimeRendering("2021-11-19T14:13:12Z", "en", "AU", ZoneId.of("UTC"), null, ResourceRendererMode.TECHNICAL, "2021-11-19T14:13:12Z"); + checkDateTimeRendering("2021-11-19T14:13:12Z", "en", "AU", ZoneId.of("Australia/Sydney"), null, ResourceRendererMode.TECHNICAL, "2021-11-20T01:13:12+11:00"); + + //todo: how to change this to get localised time as well? + checkDateTimeRendering("2021-11-19T14:13:12Z", "en", "AU", ZoneId.of("UTC"), FormatStyle.MEDIUM, ResourceRendererMode.TECHNICAL, "19 Nov. 2021, 2:13:12 pm"); + checkDateTimeRendering("2021-11-19T14:13:12Z", "en", "AU", ZoneId.of("UTC"), null, ResourceRendererMode.END_USER, "19 Nov. 2021, 2:13:12 pm"); + checkDateTimeRendering("2021-11-19", "en", "AU", ZoneId.of("UTC"), null, ResourceRendererMode.END_USER, "19 Nov. 2021"); + } + + } \ No newline at end of file diff --git a/pom.xml b/pom.xml index 9ab58e071..03115f76d 100644 --- a/pom.xml +++ b/pom.xml @@ -19,7 +19,7 @@ 5.1.0 - 1.1.76 + 1.1.77 5.7.1 1.7.1 3.0.0-M5