From dd78494325b2468432c57d9569ae6ddf5014f9e6 Mon Sep 17 00:00:00 2001 From: Jason Kulatunga Date: Thu, 22 Feb 2024 17:06:19 -0800 Subject: [PATCH] generate IPS bundle and composition adding test when generating summary. --- backend/pkg/database/gorm_repository_query.go | 12 +- .../pkg/database/gorm_repository_summary.go | 104 +++++++++++++-- .../database/gorm_repository_summary_test.go | 122 ++++++++++++++++++ backend/pkg/database/interface.go | 2 +- backend/pkg/models/resource_base.go | 3 + backend/pkg/web/handler/summary.go | 42 +++++- 6 files changed, 265 insertions(+), 20 deletions(-) create mode 100644 backend/pkg/database/gorm_repository_summary_test.go diff --git a/backend/pkg/database/gorm_repository_query.go b/backend/pkg/database/gorm_repository_query.go index f329f1bb..0b69a085 100644 --- a/backend/pkg/database/gorm_repository_query.go +++ b/backend/pkg/database/gorm_repository_query.go @@ -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 } } diff --git a/backend/pkg/database/gorm_repository_summary.go b/backend/pkg/database/gorm_repository_summary.go index de284bf0..74df41ef 100644 --- a/backend/pkg/database/gorm_repository_summary.go +++ b/backend/pkg/database/gorm_repository_summary.go @@ -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 "" } diff --git a/backend/pkg/database/gorm_repository_summary_test.go b/backend/pkg/database/gorm_repository_summary_test.go new file mode 100644 index 00000000..8a809b08 --- /dev/null +++ b/backend/pkg/database/gorm_repository_summary_test.go @@ -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) + +} diff --git a/backend/pkg/database/interface.go b/backend/pkg/database/interface.go index 27bed9d1..6291e254 100644 --- a/backend/pkg/database/interface.go +++ b/backend/pkg/database/interface.go @@ -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) diff --git a/backend/pkg/models/resource_base.go b/backend/pkg/models/resource_base.go index a0f3c75a..7195a9cb 100644 --- a/backend/pkg/models/resource_base.go +++ b/backend/pkg/models/resource_base.go @@ -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 diff --git a/backend/pkg/web/handler/summary.go b/backend/pkg/web/handler/summary.go index 9f37e350..b4a844c3 100644 --- a/backend/pkg/web/handler/summary.go +++ b/backend/pkg/web/handler/summary.go @@ -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 + //} }