generate IPS bundle and composition

adding test when generating summary.
This commit is contained in:
Jason Kulatunga 2024-02-22 17:06:19 -08:00
parent c3184fa8c0
commit dd78494325
No known key found for this signature in database
6 changed files with 265 additions and 20 deletions

View File

@ -67,9 +67,15 @@ func (gr *GormRepository) QueryResources(ctx context.Context, query models.Query
return results, clientResp.Error
} else {
results := []models.ResourceBase{}
clientResp := sqlQuery.Find(&results)
return results, clientResp.Error
//find the associated Gorm Model for this query
queryModelSlice, err := databaseModel.NewFhirResourceModelSliceByType(query.From)
if err != nil {
return nil, err
}
clientResp := sqlQuery.Find(&queryModelSlice)
return queryModelSlice, clientResp.Error
}
}

View File

@ -6,16 +6,23 @@ import (
"fmt"
"github.com/fastenhealth/fasten-onprem/backend/pkg"
"github.com/fastenhealth/fasten-onprem/backend/pkg/models"
"github.com/fastenhealth/fasten-onprem/backend/pkg/models/database"
"github.com/fastenhealth/fasten-onprem/backend/pkg/utils/ips"
"github.com/fastenhealth/gofhir-models/fhir401"
"github.com/google/uuid"
"log"
"time"
)
func (gr *GormRepository) GetInternationalPatientSummaryBundle(ctx context.Context) (interface{}, error) {
func (gr *GormRepository) GetInternationalPatientSummaryBundle(ctx context.Context) (interface{}, interface{}, error) {
summaryTime := time.Now()
timestamp := summaryTime.Format(time.RFC3339)
narrativeEngine, err := ips.NewNarrative()
if err != nil {
return nil, nil, fmt.Errorf("error creating narrative engine: %w", err)
}
//Algorithm to create the IPS bundle
// 1. Generate the IPS Section Lists (GetInternationalPatientSummarySectionResources)
// - Process each resource, generating a Markdown narrative in the text field for each resource
@ -34,15 +41,15 @@ func (gr *GormRepository) GetInternationalPatientSummaryBundle(ctx context.Conte
//Step 1. Generate the IPS Section Lists
summarySectionResources, err := gr.GetInternationalPatientSummarySectionResources(ctx)
if err != nil {
return nil, err
return nil, nil, err
}
//Step 2. Create the Composition Section
compositionSections := []fhir401.CompositionSection{}
for sectionType, sectionResources := range summarySectionResources {
compositionSection, err := generateIPSCompositionSection(sectionType, sectionResources)
compositionSection, err := generateIPSCompositionSection(narrativeEngine, sectionType, sectionResources)
if err != nil {
return nil, err
return nil, nil, err
}
compositionSections = append(compositionSections, *compositionSection)
}
@ -115,6 +122,7 @@ func (gr *GormRepository) GetInternationalPatientSummaryBundle(ctx context.Conte
},
},
}
ipsComposition.Section = compositionSections
// Step 6. Create the IPS Bundle
bundleUUID := uuid.New().String()
@ -129,7 +137,7 @@ func (gr *GormRepository) GetInternationalPatientSummaryBundle(ctx context.Conte
// Add the Composition to the bundle
ipsCompositionJson, err := json.Marshal(ipsComposition)
if err != nil {
return nil, err
return nil, nil, err
}
ipsBundle.Entry = append(ipsBundle.Entry, fhir401.BundleEntry{
Resource: json.RawMessage(ipsCompositionJson),
@ -142,28 +150,28 @@ func (gr *GormRepository) GetInternationalPatientSummaryBundle(ctx context.Conte
for _, sectionResources := range summarySectionResources {
for _, resource := range sectionResources {
ipsBundle.Entry = append(ipsBundle.Entry, fhir401.BundleEntry{
Resource: json.RawMessage(resource.ResourceRaw),
Resource: json.RawMessage(resource.GetResourceRaw()),
})
}
}
return ipsBundle, nil
return ipsBundle, ipsComposition, nil
}
// GetInternationalPatientSummary will generate an IPS bundle, which can then be used to generate a IPS QR code, PDF or JSON bundle
// The IPS bundle will contain a summary of all the data in the system, including a list of all sources, and the main Patient
// See: https://github.com/fastenhealth/fasten-onprem/issues/170
// See: https://github.com/jddamore/fhir-ips-server/blob/main/docs/Summary_Creation_Steps.md
func (gr *GormRepository) GetInternationalPatientSummarySectionResources(ctx context.Context) (map[pkg.IPSSections][]models.ResourceBase, error) {
func (gr *GormRepository) GetInternationalPatientSummarySectionResources(ctx context.Context) (map[pkg.IPSSections][]database.IFhirResourceModel, error) {
summarySectionResources := map[pkg.IPSSections][]models.ResourceBase{}
summarySectionResources := map[pkg.IPSSections][]database.IFhirResourceModel{}
// generate queries for each IPS Section
for ndx, _ := range pkg.IPSSectionsList {
sectionName := pkg.IPSSectionsList[ndx]
//initialize the section
summarySectionResources[sectionName] = []models.ResourceBase{}
summarySectionResources[sectionName] = []database.IFhirResourceModel{}
queries, err := generateIPSSectionQueries(sectionName)
if err != nil {
@ -176,7 +184,7 @@ func (gr *GormRepository) GetInternationalPatientSummarySectionResources(ctx con
return nil, err
}
resultsList := results.([]models.ResourceBase)
resultsList := convertUnknownInterfaceToFhirSlice(results)
//TODO: generate resource narrative
summarySectionResources[sectionName] = append(summarySectionResources[sectionName], resultsList...)
@ -186,7 +194,7 @@ func (gr *GormRepository) GetInternationalPatientSummarySectionResources(ctx con
return summarySectionResources, nil
}
func generateIPSCompositionSection(sectionType pkg.IPSSections, resources []models.ResourceBase) (*fhir401.CompositionSection, error) {
func generateIPSCompositionSection(narrativeEngine *ips.Narrative, sectionType pkg.IPSSections, resources []database.IFhirResourceModel) (*fhir401.CompositionSection, error) {
sectionTitle, sectionCode, err := generateIPSSectionHeaderInfo(sectionType)
if err != nil {
return nil, err
@ -210,7 +218,7 @@ func generateIPSCompositionSection(sectionType pkg.IPSSections, resources []mode
section.Entry = []fhir401.Reference{}
for _, resource := range resources {
reference := fhir401.Reference{
Reference: stringPtr(fmt.Sprintf("%s/%s", resource.SourceResourceType, resource.SourceID)),
Reference: stringPtr(fmt.Sprintf("%s/%s", resource.GetSourceResourceType(), resource.GetSourceResourceID())),
}
if err != nil {
return nil, err
@ -219,9 +227,17 @@ func generateIPSCompositionSection(sectionType pkg.IPSSections, resources []mode
}
//TODO: Add the section narrative summary
rendered, err := narrativeEngine.RenderSection(
sectionType,
resources,
)
if err != nil {
return nil, fmt.Errorf("error rendering narrative for section %s: %w", sectionType, err)
}
section.Text = &fhir401.Narrative{
Status: fhir401.NarrativeStatusGenerated,
Div: "PLACEHOLDER NARRATIVE SUMMARY FOR SECTION",
Div: rendered,
}
}
@ -543,6 +559,66 @@ func generateIPSSectionHeaderInfo(sectionType pkg.IPSSections) (string, fhir401.
}
// QueryResources returns an interface{} which is actually a slice of the appropriate FHIR resource type
// we use this function to "cast" the results to a slice of the IFhirResourceModel interface (so we can use the same code to handle the results)
// TODO: there has to be a better way to do this :/
func convertUnknownInterfaceToFhirSlice(unknown interface{}) []database.IFhirResourceModel {
results := []database.IFhirResourceModel{}
switch fhirSlice := unknown.(type) {
case []database.FhirAllergyIntolerance:
for ndx, _ := range fhirSlice {
results = append(results, &fhirSlice[ndx])
}
case []database.FhirCarePlan:
for ndx, _ := range fhirSlice {
results = append(results, &fhirSlice[ndx])
}
case []database.FhirCondition:
for ndx, _ := range fhirSlice {
results = append(results, &fhirSlice[ndx])
}
case []database.FhirDevice:
for ndx, _ := range fhirSlice {
results = append(results, &fhirSlice[ndx])
}
case []database.FhirDiagnosticReport:
for ndx, _ := range fhirSlice {
results = append(results, &fhirSlice[ndx])
}
case []database.FhirEncounter:
for ndx, _ := range fhirSlice {
results = append(results, &fhirSlice[ndx])
}
case []database.FhirImmunization:
for ndx, _ := range fhirSlice {
results = append(results, &fhirSlice[ndx])
}
case []database.FhirMedicationRequest:
for ndx, _ := range fhirSlice {
results = append(results, &fhirSlice[ndx])
}
case []database.FhirMedicationStatement:
for ndx, _ := range fhirSlice {
results = append(results, &fhirSlice[ndx])
}
case []database.FhirObservation:
for ndx, _ := range fhirSlice {
results = append(results, &fhirSlice[ndx])
}
case []database.FhirPatient:
for ndx, _ := range fhirSlice {
results = append(results, &fhirSlice[ndx])
}
case []database.FhirProcedure:
for ndx, _ := range fhirSlice {
results = append(results, &fhirSlice[ndx])
}
}
return results
}
func generateIPSSectionNarrative(sectionType pkg.IPSSections, resources []models.ResourceBase) string {
return ""
}

View File

@ -0,0 +1,122 @@
package database
import (
"context"
"fmt"
"github.com/fastenhealth/fasten-onprem/backend/pkg"
mock_config "github.com/fastenhealth/fasten-onprem/backend/pkg/config/mock"
"github.com/fastenhealth/fasten-onprem/backend/pkg/event_bus"
"github.com/fastenhealth/fasten-onprem/backend/pkg/models"
sourceFactory "github.com/fastenhealth/fasten-sources/clients/factory"
sourcePkg "github.com/fastenhealth/fasten-sources/pkg"
"github.com/fastenhealth/gofhir-models/fhir401"
"github.com/golang/mock/gomock"
"github.com/google/uuid"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"io/ioutil"
"log"
"os"
"testing"
)
// Define the suite, and absorb the built-in basic suite
// functionality from testify - including a T() method which
// returns the current testing context
type RepositorySummaryTestSuite struct {
suite.Suite
MockCtrl *gomock.Controller
TestDatabase *os.File
}
// BeforeTest has a function to be executed right before the test starts and receives the suite and test names as input
func (suite *RepositorySummaryTestSuite) BeforeTest(suiteName, testName string) {
suite.MockCtrl = gomock.NewController(suite.T())
dbFile, err := ioutil.TempFile("", fmt.Sprintf("%s.*.db", testName))
if err != nil {
log.Fatal(err)
}
suite.TestDatabase = dbFile
}
// AfterTest has a function to be executed right after the test finishes and receives the suite and test names as input
func (suite *RepositorySummaryTestSuite) AfterTest(suiteName, testName string) {
suite.MockCtrl.Finish()
os.Remove(suite.TestDatabase.Name())
os.Remove(suite.TestDatabase.Name() + "-shm")
os.Remove(suite.TestDatabase.Name() + "-wal")
}
// In order for 'go test' to run this suite, we need to create
// a normal test function and pass our suite to suite.Run
func TestRepositorySummaryTestSuiteSuite(t *testing.T) {
suite.Run(t, new(RepositorySummaryTestSuite))
}
func (suite *RepositorySummaryTestSuite) TestGetInternationalPatientSummaryBundle() {
//setup
fakeConfig := mock_config.NewMockInterface(suite.MockCtrl)
fakeConfig.EXPECT().GetString("database.location").Return(suite.TestDatabase.Name()).AnyTimes()
fakeConfig.EXPECT().GetString("database.type").Return("sqlite").AnyTimes()
fakeConfig.EXPECT().IsSet("database.encryption.key").Return(false).AnyTimes()
fakeConfig.EXPECT().GetString("log.level").Return("INFO").AnyTimes()
dbRepo, err := NewRepository(fakeConfig, logrus.WithField("test", suite.T().Name()), event_bus.NewNoopEventBusServer())
require.NoError(suite.T(), err)
userModel := &models.User{
Username: "test_username",
Password: "testpassword",
Email: "test@test.com",
}
err = dbRepo.CreateUser(context.Background(), userModel)
require.NoError(suite.T(), err)
require.NotEmpty(suite.T(), userModel.ID)
require.NotEqual(suite.T(), uuid.Nil, userModel.ID)
authContext := context.WithValue(context.Background(), pkg.ContextKeyTypeAuthUsername, "test_username")
testSourceCredential := models.SourceCredential{
ModelBase: models.ModelBase{
ID: uuid.New(),
},
UserID: userModel.ID,
Patient: uuid.New().String(),
PlatformType: sourcePkg.PlatformTypeManual,
}
err = dbRepo.CreateSource(authContext, &testSourceCredential)
require.NoError(suite.T(), err)
bundleFile, err := os.Open("./testdata/Abraham100_Heller342_262b819a-5193-404a-9787-b7f599358035.json")
require.NoError(suite.T(), err)
testLogger := logrus.WithFields(logrus.Fields{
"type": "test",
})
manualClient, err := sourceFactory.GetSourceClient(sourcePkg.FastenLighthouseEnvSandbox, authContext, testLogger, &testSourceCredential)
summary, err := manualClient.SyncAllBundle(dbRepo, bundleFile, sourcePkg.FhirVersion401)
require.NoError(suite.T(), err)
require.Equal(suite.T(), 198, summary.TotalResources)
require.Equal(suite.T(), 234, len(summary.UpdatedResources))
//test
bundle, composition, err := dbRepo.GetInternationalPatientSummaryBundle(authContext)
require.NoError(suite.T(), err)
//case bundle and composition
fhirBundle := bundle.(*fhir401.Bundle)
fhirComposition := composition.(*fhir401.Composition)
require.NotNil(suite.T(), fhirBundle)
require.NotNil(suite.T(), fhirComposition)
require.Equal(suite.T(), 211, len(fhirBundle.Entry))
require.Equal(suite.T(), 14, len(fhirComposition.Section))
//require.Equal(suite.T(), "", fhirComposition.Section[0].Title)
//require.Equal(suite.T(), "", fhirComposition.Section[0].Text.Div)
}

View File

@ -21,7 +21,7 @@ type DatabaseRepository interface {
//get a count of every resource type
GetSummary(ctx context.Context) (*models.Summary, error)
GetInternationalPatientSummaryBundle(ctx context.Context) (interface{}, error)
GetInternationalPatientSummaryBundle(ctx context.Context) (interface{}, interface{}, error)
GetResourceByResourceTypeAndId(context.Context, string, string) (*models.ResourceBase, error)
GetResourceBySourceId(context.Context, string, string) (*models.ResourceBase, error)

View File

@ -33,6 +33,9 @@ func (s *ResourceBase) SetSortDate(sortDate *time.Time) {
func (s *ResourceBase) SetResourceRaw(resourceRaw datatypes.JSON) {
s.ResourceRaw = resourceRaw
}
func (s *ResourceBase) GetResourceRaw() datatypes.JSON {
return s.ResourceRaw
}
func (s *ResourceBase) SetSourceUri(sourceUri *string) {
s.SourceUri = sourceUri

View File

@ -1,8 +1,11 @@
package handler
import (
_ "embed"
"github.com/fastenhealth/fasten-onprem/backend/pkg"
"github.com/fastenhealth/fasten-onprem/backend/pkg/database"
"github.com/fastenhealth/fasten-onprem/backend/pkg/utils/ips"
"github.com/fastenhealth/gofhir-models/fhir401"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"net/http"
@ -25,11 +28,46 @@ func GetIPSSummary(c *gin.Context) {
logger := c.MustGet(pkg.ContextKeyTypeLogger).(*logrus.Entry)
databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository)
summary, err := databaseRepo.GetInternationalPatientSummaryBundle(c)
ipsBundle, ipsComposititon, err := databaseRepo.GetInternationalPatientSummaryBundle(c)
if err != nil {
logger.Errorln("An error occurred while retrieving IPS summary", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": summary})
if c.Query("format") == "" {
c.JSON(http.StatusOK, gin.H{"success": true, "data": ipsBundle})
return
}
narrative, err := ips.NewNarrative()
if err != nil {
logger.Errorln("An error occurred while parsing template", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
return
}
composititon := ipsComposititon.(*fhir401.Composition)
if c.Query("format") == "html" {
logger.Debugln("Rendering HTML")
//create string writer
content, err := narrative.RenderTemplate("index.gohtml", ips.NarrativeTemplateData{Composition: composititon})
if err != nil {
logger.Errorln("An error occurred while executing template", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
return
}
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, content)
return
}
//} else if c.Query("format") == "pdf" {
//
// c.Header("Content-Disposition", "attachment; filename=ips_summary.pdf")
// c.Header("Content-Type", "application/pdf")
// c.String(http.StatusOK, b.String())
// return
//}
}