Skip to content

Commit

Permalink
Merge branch 'main' into filter-data-by-selected-locations
Browse files Browse the repository at this point in the history
Signed-off-by: Elly Kitoto <[email protected]>
  • Loading branch information
ellykits committed Jun 11, 2024
2 parents d425b90 + 31be373 commit c49cc44
Show file tree
Hide file tree
Showing 33 changed files with 889 additions and 188 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added
- Added a new class (PdfGenerator) for generating PDF documents from HTML content using Android's WebView and PrintManager
- Introduced a new class (HtmlPopulator) to populate HTML templates with data from a Questionnaire Response

## [1.1.0] - 2024-02-15

Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ import org.hl7.fhir.r4.model.ResourceType
import org.jetbrains.annotations.VisibleForTesting
import org.json.JSONObject
import org.smartregister.fhircore.engine.BuildConfig
import org.smartregister.fhircore.engine.OpenSrpApplication
import org.smartregister.fhircore.engine.configuration.app.ConfigService
import org.smartregister.fhircore.engine.configuration.profile.ProfileConfiguration
import org.smartregister.fhircore.engine.configuration.register.RegisterConfiguration
Expand Down Expand Up @@ -92,18 +91,17 @@ constructor(
val configService: ConfigService,
val json: Json,
@ApplicationContext val context: Context,
private var openSrpApplication: OpenSrpApplication?,
) {

@Inject lateinit var knowledgeManager: KnowledgeManager

val configsJsonMap = mutableMapOf<String, String>()
val configCacheMap = mutableMapOf<String, Configuration>()
val localizationHelper: LocalizationHelper by lazy { LocalizationHelper(this) }
private val supportedFileExtensions = listOf("json", "properties")
private var _isNonProxy = BuildConfig.IS_NON_PROXY_APK
private val fhirContext = FhirContext.forR4Cached()

@Inject lateinit var knowledgeManager: KnowledgeManager

private val authConfiguration = configService.provideAuthConfiguration()
private val jsonParser = fhirContext.newJsonParser()

/**
Expand Down Expand Up @@ -405,8 +403,7 @@ constructor(
* Type'?_id='comma,separated,list,of,ids'
*/
@Throws(UnknownHostException::class, HttpException::class)
suspend fun fetchNonWorkflowConfigResources(isInitialLogin: Boolean = true) {
// Reset configurations before loading new ones
suspend fun fetchNonWorkflowConfigResources() {
configCacheMap.clear()
sharedPreferencesHelper.read(SharedPreferenceKey.APP_ID.name, null)?.let { appId ->
val parsedAppId = appId.substringBefore(TYPE_REFERENCE_DELIMITER).trim()
Expand Down Expand Up @@ -638,7 +635,7 @@ constructor(
this.apply {
url =
url
?: """${openSrpApplication?.getFhirServerHost()?.toString()?.trimEnd { it == '/' }}/${this.referenceValue()}"""
?: """${authConfiguration.fhirServerBaseUrl.trimEnd { it == '/' }}/${this.referenceValue()}"""
}

fun writeToFile(resource: Resource): File {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,8 @@ data class ApplicationConfiguration(
SettingsOptions.INSIGHTS,
),
val logGpsLocation: List<LocationLogOptions> = emptyList(),
val usePractitionerAssignedLocationOnSync: Boolean =
true, // TODO This defaults to scheduling periodic sync, otherwise use sync location ids from
// location selector
val launcherType: LauncherType = LauncherType.REGISTER,
val usePractitionerAssignedLocationOnSync: Boolean = true,
val navigationStartDestination: LauncherType = LauncherType.REGISTER,
) : Configuration()

enum class SyncStrategy {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,6 @@ interface ConfigService {
return tags
}

fun provideConfigurationSyncPageSize(): String

/**
* Provide a list of custom search parameters.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@

package org.smartregister.fhircore.engine.di

import android.content.Context
import ca.uhn.fhir.context.FhirContext
import ca.uhn.fhir.parser.IParser
import com.google.gson.Gson
Expand All @@ -25,8 +24,8 @@ import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFact
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.net.URL
import java.util.TimeZone
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
Expand All @@ -40,7 +39,6 @@ import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import okhttp3.logging.HttpLoggingInterceptor
import org.smartregister.fhircore.engine.BuildConfig
import org.smartregister.fhircore.engine.OpenSrpApplication
import org.smartregister.fhircore.engine.configuration.app.ConfigService
import org.smartregister.fhircore.engine.data.remote.auth.KeycloakService
import org.smartregister.fhircore.engine.data.remote.auth.OAuthService
Expand Down Expand Up @@ -83,7 +81,7 @@ class NetworkModule {
fun provideOkHttpClient(
tokenAuthenticator: TokenAuthenticator,
sharedPreferencesHelper: SharedPreferencesHelper,
openSrpApplication: OpenSrpApplication?,
configService: ConfigService,
) =
OkHttpClient.Builder()
.addInterceptor(
Expand All @@ -92,15 +90,11 @@ class NetworkModule {
var request = chain.request()
val requestPath = request.url.encodedPath.substring(1)
val resourcePath = if (!_isNonProxy) requestPath.replace("fhir/", "") else requestPath
val host = URL(configService.provideAuthConfiguration().fhirServerBaseUrl).host

openSrpApplication?.let {
if (
(request.url.host == it.getFhirServerHost()?.host) &&
CUSTOM_ENDPOINTS.contains(resourcePath)
) {
val newUrl = request.url.newBuilder().encodedPath("/$resourcePath").build()
request = request.newBuilder().url(newUrl).build()
}
if (request.url.host == host && CUSTOM_ENDPOINTS.contains(resourcePath)) {
val newUrl = request.url.newBuilder().encodedPath("/$resourcePath").build()
request = request.newBuilder().url(newUrl).build()
}

chain.proceed(request)
Expand Down Expand Up @@ -231,11 +225,6 @@ class NetworkModule {
fun provideFhirResourceService(@RegularRetrofit retrofit: Retrofit): FhirResourceService =
retrofit.create(FhirResourceService::class.java)

@Provides
@Singleton
fun provideFHIRBaseURL(@ApplicationContext context: Context): OpenSrpApplication? =
if (context is OpenSrpApplication) context else null

companion object {
const val TIMEOUT_DURATION = 120L
const val AUTHORIZATION = "Authorization"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/*
* Copyright 2021-2024 Ona Systems, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.smartregister.fhircore.engine.pdf

import java.util.regex.Matcher
import java.util.regex.Pattern
import org.hl7.fhir.r4.model.BaseDateTimeType
import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.smartregister.fhircore.engine.util.extension.allItems
import org.smartregister.fhircore.engine.util.extension.formatDate
import org.smartregister.fhircore.engine.util.extension.makeItReadable
import org.smartregister.fhircore.engine.util.extension.valueToString

/**
* HtmlPopulator class is responsible for processing an HTML template by replacing custom tags with
* data from a QuestionnaireResponse. The class uses various regex patterns to find and replace
* custom tags such as @is-not-empty, @answer-as-list, @answer, @submitted-date, and @contains.
*
* @property questionnaireResponse The QuestionnaireResponse object containing data for replacement.
*/
class HtmlPopulator(
private val questionnaireResponse: QuestionnaireResponse,
) {

// Map to store questionnaire response items keyed by their linkId
private val questionnaireResponseItemMap =
questionnaireResponse.allItems.associateBy(
keySelector = { it.linkId },
valueTransform = { it.answer },
)

/**
* Populates the provided HTML template with data from the QuestionnaireResponse.
*
* After a tag got replaced, the current index will be used twice, adding an increment will skip a
* character right after the current index.
*
* @param rawHtml The raw HTML template containing custom tags to be replaced.
* @return The populated HTML with all custom tags replaced by corresponding data.
*/
fun populateHtml(rawHtml: String): String {
val html = StringBuilder(rawHtml)
var i = 0
while (i < html.length) {
when {
html.startsWith("@is-not-empty", i) -> {
val matcher = isNotEmptyPattern.matcher(html.substring(i))
if (matcher.find()) processIsNotEmpty(i, html, matcher) else i++
}
html.startsWith("@answer-as-list", i) -> {
val matcher = answerAsListPattern.matcher(html.substring(i))
if (matcher.find()) processAnswerAsList(i, html, matcher) else i++
}
html.startsWith("@answer", i) -> {
val matcher = answerPattern.matcher(html.substring(i))
if (matcher.find()) processAnswer(i, html, matcher) else i++
}
html.startsWith("@submitted-date", i) -> {
val matcher = submittedDatePattern.matcher(html.substring(i))
if (matcher.find()) processSubmittedDate(i, html, matcher) else i++
}
html.startsWith("@contains", i) -> {
val matcher = containsPattern.matcher(html.substring(i))
if (matcher.find()) processContains(i, html, matcher) else i++
}
else -> i++
}
}
return html.toString()
}

/**
* Processes the @is-not-empty tag by checking if the specified linkId has an answer. Replaces the
* tag with the content if the answer exists, otherwise removes the tag.
*
* @param i The starting index of the tag in the HTML.
* @param html The StringBuilder containing the HTML.
* @param matcher The Matcher object for the regex pattern.
*/
private fun processIsNotEmpty(i: Int, html: StringBuilder, matcher: Matcher) {
val linkId = matcher.group(1)
val content = matcher.group(2) ?: ""
val doesAnswerExist = questionnaireResponseItemMap.getOrDefault(linkId, listOf()).isNotEmpty()
if (doesAnswerExist) {
html.replace(i, matcher.end() + i, content)
// Start index is the index of '@' symbol, End index is the index after the ')' symbol.
// For example: @is-not-empty('link')Text@is-not-empty('link')
// The args we put the the replace function: The Start index is 0, the '@' symbol. The
// End index is the index after the ')' symbol.
// Note: The ones that are going to be replaced are from the Start index which is an '@' of
// the first tag, until the index before the End index which is an ')' of the second tag.
} else {
html.replace(i, matcher.end() + i, "")
}
}

/**
* Processes the @answer-as-list tag by replacing it with a list of answers for the specified
* linkId.
*
* @param i The starting index of the tag in the HTML.
* @param html The StringBuilder containing the HTML.
* @param matcher The Matcher object for the regex pattern.
*/
private fun processAnswerAsList(i: Int, html: StringBuilder, matcher: Matcher) {
val linkId = matcher.group(1)
val answerAsList =
questionnaireResponseItemMap.getOrDefault(linkId, listOf()).joinToString(separator = "") {
answer ->
"<li>${answer.value.valueToString()}</li>"
}
html.replace(i, matcher.end() + i, answerAsList)
}

/**
* Processes the @answer tag by replacing it with the answer for the specified linkId.
*
* @param i The starting index of the tag in the HTML.
* @param html The StringBuilder containing the HTML.
* @param matcher The Matcher object for the regex pattern.
*/
private fun processAnswer(i: Int, html: StringBuilder, matcher: Matcher) {
val linkId = matcher.group(1)
val dateFormat = matcher.group(2)
val answer =
questionnaireResponseItemMap.getOrDefault(linkId, listOf()).joinToString { answer ->
if (dateFormat == null) {
answer.value.valueToString()
} else answer.value.valueToString(dateFormat)
}
html.replace(i, matcher.end() + i, answer)
}

/**
* Processes the @submitted-date tag by replacing it with the formatted date.
*
* @param i The starting index of the tag in the HTML.
* @param html The StringBuilder containing the HTML.
* @param matcher The Matcher object for the regex pattern.
*/
private fun processSubmittedDate(i: Int, html: StringBuilder, matcher: Matcher) {
val dateFormat = matcher.group(1)
val date =
if (dateFormat == null) {
questionnaireResponse.meta.lastUpdated.formatDate()
} else {
questionnaireResponse.meta.lastUpdated.formatDate(dateFormat)
}
html.replace(i, matcher.end() + i, date)
}

/**
* Processes the @contains tag by checking if the specified linkId contains the indicator.
* Replaces the tag with the content if the indicator is found, otherwise removes the tag.
*
* @param i The starting index of the tag in the HTML.
* @param html The StringBuilder containing the HTML.
* @param matcher The Matcher object for the regex pattern.
*/
private fun processContains(i: Int, html: StringBuilder, matcher: Matcher) {
val linkId = matcher.group(1)
val indicator = matcher.group(2) ?: ""
val content = matcher.group(3) ?: ""
val doesAnswerExist =
questionnaireResponseItemMap.getOrDefault(linkId, listOf()).any {
when {
it.hasValueCoding() -> it.valueCoding.code == indicator
it.hasValueStringType() -> it.valueStringType.value.contains(indicator)
it.hasValueIntegerType() -> it.valueIntegerType.value == indicator.toInt()
it.hasValueDecimalType() -> it.valueDecimalType.value == indicator.toBigDecimal()
it.hasValueBooleanType() -> it.valueBooleanType.value == indicator.toBoolean()
it.hasValueQuantity() ->
"${it.valueQuantity.value.toPlainString()} ${it.valueQuantity.unit}" == indicator
it.hasValueDateType() || it.hasValueDateTimeType() ->
(it.value as BaseDateTimeType).value.makeItReadable() == indicator
else -> false
}
}
if (doesAnswerExist) {
html.replace(i, matcher.end() + i, content)
} else {
html.replace(i, matcher.end() + i, "")
}
}

companion object {
// Compile regex patterns for different tags
private val isNotEmptyPattern =
Pattern.compile("@is-not-empty\\('([^']+)'\\)((?s).*?)@is-not-empty\\('\\1'\\)")
private val answerAsListPattern = Pattern.compile("@answer-as-list\\('([^']+)'\\)")
private val answerPattern = Pattern.compile("@answer\\('([^']+)'(?:,'([^']+)')?\\)")
private val submittedDatePattern = Pattern.compile("@submitted-date(?:\\('([^']+)'\\))?")
private val containsPattern =
Pattern.compile("@contains\\('([^']+)','([^']+)'\\)((?s).*?)@contains\\('\\1'\\)")
}
}
Loading

0 comments on commit c49cc44

Please sign in to comment.