Skip to content
This repository was archived by the owner on Feb 4, 2025. It is now read-only.

Coupons/fetch reports #3012

Merged
merged 10 commits into from
May 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import org.wordpress.android.fluxc.model.SiteModel
import org.wordpress.android.fluxc.store.CouponStore
import org.wordpress.android.fluxc.store.CouponStore.Companion.DEFAULT_PAGE
import org.wordpress.android.fluxc.store.WooCommerceStore
import java.util.Date
import javax.inject.Inject
import kotlin.time.Duration.Companion.days

class WooCouponsFragment : StoreSelectingFragment() {
@Inject internal lateinit var store: CouponStore
Expand All @@ -30,8 +32,12 @@ class WooCouponsFragment : StoreSelectingFragment() {
private val coroutineScope = CoroutineScope(Dispatchers.Main)
private var couponPage = DEFAULT_PAGE

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
inflater.inflate(R.layout.fragment_woo_coupons, container, false)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? =
inflater.inflate(R.layout.fragment_woo_coupons, container, false)

override fun onSiteSelected(site: SiteModel) {
viewLifecycleOwner.lifecycleScope.launchWhenStarted {
Expand Down Expand Up @@ -128,6 +134,7 @@ class WooCouponsFragment : StoreSelectingFragment() {
when {
reportResult.isError ->
prependToLog("Fetching report failed, ${reportResult.error.message}")

else -> {
val report = reportResult.model!!
val usageAmountFormatted = wooCommerceStore.formatCurrencyForDisplay(
Expand All @@ -144,6 +151,25 @@ class WooCouponsFragment : StoreSelectingFragment() {
}
}

btnFetchMostActiveCoupons.setOnClickListener {
val site = selectedSite ?: return@setOnClickListener
coroutineScope.launch {
val reportResult = store.fetchMostActiveCoupons(
site = site,
dateRange = Date().apply { time -= 30.days.inWholeMilliseconds }..Date(),
limit = 3
)
if (reportResult.isError) {
prependToLog("Fetching report failed, ${reportResult.error.message}")
} else {
prependToLog(
"Most active coupons in the last month:\n" +
reportResult.model!!.joinToString(",\n")
)
}
}
}

btnSearchCoupons.setOnClickListener {
showSingleLineDialog(activity, "Enter a search query:") { editText ->
coroutineScope.launch {
Expand Down
8 changes: 8 additions & 0 deletions example/src/main/res/layout/fragment_woo_coupons.xml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@
android:enabled="false"
android:text="Fetch Coupon Report" />

<Button
android:id="@+id/btnFetchMostActiveCoupons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:enabled="false"
android:text="Fetch most active coupons" />

<Button
android:id="@+id/btnUpdateCoupon"
android:layout_width="match_parent"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,59 +32,52 @@ enum class WooErrorType {
INVALID_RESPONSE,
AUTHORIZATION_REQUIRED,
INVALID_PARAM,
PLUGIN_NOT_ACTIVE,
API_NOT_FOUND,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was the one who introduced PLUGIN_NOT_ACTIVE during the REST API project, it was to match the value emitted by the Order REST client, but I think that name was not the right choice, the error could happen for any non-existing endpoint, be it because the plugin is not active, or any other reason, so I renamed it to make it more clear, please let me know what you think?

And this is a breaking change for WCAndroid, so let's not merge this until the WCAndroid PR is ready.

EMPTY_RESPONSE,
INVALID_COUPON,
RESOURCE_ALREADY_EXISTS
}

fun WPComGsonNetworkError.toWooError(): WooError {
val type = when (type) {
TIMEOUT -> WooErrorType.TIMEOUT
NO_CONNECTION,
SERVER_ERROR,
INVALID_SSL_CERTIFICATE,
NETWORK_ERROR -> WooErrorType.API_ERROR
PARSE_ERROR,
CENSORED,
INVALID_RESPONSE -> WooErrorType.INVALID_RESPONSE
HTTP_AUTH_ERROR,
AUTHORIZATION_REQUIRED,
NOT_AUTHENTICATED -> WooErrorType.AUTHORIZATION_REQUIRED
NOT_FOUND -> WooErrorType.INVALID_ID
UNKNOWN, null -> {
when (apiError) {
"rest_invalid_param" -> WooErrorType.INVALID_PARAM
"rest_no_route" -> WooErrorType.PLUGIN_NOT_ACTIVE
else -> WooErrorType.GENERIC_ERROR
}
fun WPComGsonNetworkError.toWooError() = WooError(
type = type.getWooErrorType(apiError),
original = type,
message = message
)

fun WPAPINetworkError.toWooError() = WooError(
type = type.getWooErrorType(errorCode),
original = type,
message = message
)

private fun GenericErrorType?.getWooErrorType(apiError: String?) = when (this) {
TIMEOUT -> WooErrorType.TIMEOUT
NO_CONNECTION,
SERVER_ERROR,
INVALID_SSL_CERTIFICATE,
NETWORK_ERROR -> WooErrorType.API_ERROR

PARSE_ERROR,
CENSORED,
INVALID_RESPONSE -> WooErrorType.INVALID_RESPONSE

HTTP_AUTH_ERROR,
AUTHORIZATION_REQUIRED,
NOT_AUTHENTICATED -> WooErrorType.AUTHORIZATION_REQUIRED

NOT_FOUND -> {
when (apiError) {
"rest_no_route" -> WooErrorType.API_NOT_FOUND
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need this specific differentiation now, the new error type will help identify when the wc-analytics endpoint is not available.

else -> WooErrorType.INVALID_ID
}
}
return WooError(type, this.type, message)
}

fun WPAPINetworkError.toWooError(): WooError {
val type = when (type) {
TIMEOUT -> WooErrorType.TIMEOUT
NO_CONNECTION,
SERVER_ERROR,
INVALID_SSL_CERTIFICATE,
NETWORK_ERROR -> WooErrorType.API_ERROR
PARSE_ERROR,
CENSORED,
INVALID_RESPONSE -> WooErrorType.INVALID_RESPONSE
HTTP_AUTH_ERROR,
AUTHORIZATION_REQUIRED,
NOT_AUTHENTICATED -> WooErrorType.AUTHORIZATION_REQUIRED
NOT_FOUND -> WooErrorType.INVALID_ID
UNKNOWN, null -> {
when (errorCode) {
"rest_invalid_param" -> WooErrorType.INVALID_PARAM
"rest_no_route" -> WooErrorType.PLUGIN_NOT_ACTIVE
"woocommerce_rest_invalid_coupon" -> WooErrorType.INVALID_COUPON
else -> WooErrorType.GENERIC_ERROR
}
UNKNOWN, null -> {
when (apiError) {
"rest_invalid_param" -> WooErrorType.INVALID_PARAM
"rest_no_route" -> WooErrorType.API_NOT_FOUND
"woocommerce_rest_invalid_coupon" -> WooErrorType.INVALID_COUPON
else -> WooErrorType.GENERIC_ERROR
}
}
return WooError(type, this.type, message)
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooErrorType.API_ERROR
import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooNetwork
import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooPayload
import org.wordpress.android.fluxc.network.rest.wpcom.wc.toWooError
import org.wordpress.android.fluxc.utils.extensions.filterNotNull
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
Expand All @@ -22,7 +23,8 @@ class CouponRestClient @Inject constructor(
site: SiteModel,
page: Int,
pageSize: Int,
searchQuery: String? = null
searchQuery: String? = null,
couponIds: List<Long> = emptyList()
): WooPayload<Array<CouponDto>> {
val url = WOOCOMMERCE.coupons.pathV3

Expand All @@ -35,6 +37,9 @@ class CouponRestClient @Inject constructor(
searchQuery?.let {
put("search", searchQuery)
}
couponIds.takeIf { it.isNotEmpty() }?.let {
put("include", it.joinToString(","))
}
},
clazz = Array<CouponDto>::class.java
)
Expand Down Expand Up @@ -141,15 +146,25 @@ class CouponRestClient @Inject constructor(
suspend fun fetchCouponsReports(
site: SiteModel,
couponsIds: LongArray = longArrayOf(),
after: Date
orderBy: CouponsReportOrderBy = CouponsReportOrderBy.CouponId,
orderDescending: Boolean = true,
page: Int = 1,
perPage: Int? = null,
before: Date? = null,
after: Date? = null
): WooPayload<List<CouponReportDto>> {
val url = WOOCOMMERCE.reports.coupons.pathV4Analytics
val dateFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ROOT)

val params = mapOf(
"after" to dateFormatter.format(after),
"orderby" to orderBy.value,
"order" to if (orderDescending) "desc" else "asc",
"page" to page.toString(),
"per_page" to perPage?.toString(),
"before" to before?.let { dateFormatter.format(it) },
"after" to after?.let { dateFormatter.format(it) },
"coupons" to couponsIds.joinToString(",")
)
).filterNotNull()

val response = wooNetwork.executeGetGsonRequest(
site = site,
Expand Down Expand Up @@ -184,6 +199,13 @@ class CouponRestClient @Inject constructor(
}
}

enum class CouponsReportOrderBy(val value: String) {
CouponId("coupon_id"),
Code("code"),
Amount("amount"),
OrdersCount("orders_count")
}

@Suppress("ComplexMethod")
private fun UpdateCouponRequest.toNetworkRequest(): Map<String, Any> {
return mutableMapOf<String, Any>().apply {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ interface CouponsDao {
@Query("SELECT * FROM Coupons WHERE localSiteId = :localSiteId ORDER BY dateCreated DESC")
fun observeCoupons(localSiteId: LocalId): Flow<List<CouponWithEmails>>

@Transaction
@Query("SELECT * FROM Coupons WHERE localSiteId = :localSiteId AND id IN (:couponIds) ORDER BY dateCreated DESC")
fun observeCoupons(localSiteId: LocalId, couponIds: List<Long>): Flow<List<CouponWithEmails>>

@Transaction
@Query("SELECT * FROM Coupons " +
"WHERE localSiteId = :localSiteId AND id IN (:couponIds) ORDER BY dateCreated DESC"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package org.wordpress.android.fluxc.store

import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import org.wordpress.android.fluxc.model.LocalOrRemoteId.RemoteId
Expand Down Expand Up @@ -44,16 +43,22 @@ class CouponStore @Inject constructor(
suspend fun fetchCoupons(
site: SiteModel,
page: Int = DEFAULT_PAGE,
pageSize: Int = DEFAULT_PAGE_SIZE
pageSize: Int = DEFAULT_PAGE_SIZE,
couponIds: List<Long> = emptyList(),
deleteOldData: Boolean = page == 1
): WooResult<Boolean> {
return coroutineEngine.withDefaultContext(API, this, "fetchCoupons") {
val response = restClient.fetchCoupons(site, page, pageSize)
val response = restClient.fetchCoupons(
site = site,
page = page,
pageSize = pageSize,
couponIds = couponIds
)
when {
response.isError -> WooResult(response.error)
response.result != null -> {
database.executeInTransaction {
// clear the table if the 1st page is requested
if (page == 1) {
if (deleteOldData) {
couponsDao.deleteAllCoupons(site.localId())
}

Expand All @@ -62,6 +67,7 @@ class CouponStore @Inject constructor(
val canLoadMore = response.result.size == pageSize
WooResult(canLoadMore)
}

else -> WooResult(WooError(GENERIC_ERROR, UNKNOWN))
}
}
Expand All @@ -87,6 +93,7 @@ class CouponStore @Inject constructor(
val canLoadMore = response.result.size == pageSize
WooResult(CouponSearchResult(coupons, canLoadMore))
}

else -> WooResult(WooError(GENERIC_ERROR, UNKNOWN))
}
}
Expand All @@ -101,6 +108,7 @@ class CouponStore @Inject constructor(
addCouponToDatabase(response.result, site)
WooResult(Unit)
}

else -> WooResult(WooError(GENERIC_ERROR, UNKNOWN))
}
}
Expand Down Expand Up @@ -149,9 +157,16 @@ class CouponStore @Inject constructor(
suspend fun getCoupon(site: SiteModel, couponId: Long) =
couponsDao.getCoupon(site.localId(), RemoteId(couponId))

@ExperimentalCoroutinesApi
fun observeCoupons(site: SiteModel): Flow<List<CouponWithEmails>> =
couponsDao.observeCoupons(site.localId()).distinctUntilChanged()
fun observeCoupons(
site: SiteModel,
couponIds: List<Long> = emptyList()
): Flow<List<CouponWithEmails>> {
return if (couponIds.isEmpty()) {
couponsDao.observeCoupons(site.localId())
} else {
couponsDao.observeCoupons(site.localId(), couponIds)
}.distinctUntilChanged()
}

suspend fun fetchCouponReport(site: SiteModel, couponId: Long): WooResult<CouponReport> =
coroutineEngine.withDefaultContext(T.API, this, "fetchCouponReport") {
Expand Down Expand Up @@ -199,7 +214,11 @@ class CouponStore @Inject constructor(
)
} else {
coroutineEngine.withDefaultContext(T.API, this, "createCoupon") {
return@withDefaultContext restClient.updateCoupon(site, couponId, updateCouponRequest)
return@withDefaultContext restClient.updateCoupon(
site,
couponId,
updateCouponRequest
)
.let { response ->
if (response.isError || response.result == null) {
WooResult(response.error)
Expand All @@ -212,6 +231,36 @@ class CouponStore @Inject constructor(
}
}

suspend fun fetchMostActiveCoupons(
site: SiteModel,
dateRange: ClosedRange<Date>,
limit: Int
): WooResult<List<CouponReport>> {
return coroutineEngine.withDefaultContext(T.API, this, "fetchMostRecentCoupons") {
val response = restClient.fetchCouponsReports(
site = site,
before = dateRange.endInclusive,
after = dateRange.start,
perPage = limit,
orderBy = CouponRestClient.CouponsReportOrderBy.OrdersCount,
)

when {
response.isError -> WooResult(response.error)
response.result != null -> {
val reports = response.result.map { it.toDataModel() }
WooResult(reports)
}

else -> WooResult(WooError(GENERIC_ERROR, UNKNOWN))
}
}
}

suspend fun getCoupons(site: SiteModel, couponIds: List<Long>): List<CouponWithEmails> {
return couponsDao.getCoupons(site.localId(), couponIds.map { RemoteId(it) })
}

data class CouponSearchResult(
val coupons: List<CouponWithEmails>,
val canLoadMore: Boolean
Expand Down
Loading