diff --git a/libpretixsync/src/main/java/eu/pretix/libpretixsync/api/PretixApi.kt b/libpretixsync/src/main/java/eu/pretix/libpretixsync/api/PretixApi.kt index 66715694..d000dad7 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/api/PretixApi.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/api/PretixApi.kt @@ -99,7 +99,22 @@ open class PretixApi(url: String, key: String, orgaSlug: String, version: Int, h } @Throws(ApiException::class, JSONException::class) - open fun redeem(lists: List, secret: String, datetime: String?, force: Boolean, nonce: String?, answers: List?, ignore_unpaid: Boolean, pdf_data: Boolean, type: String?, source_type: String?, callTimeout: Long? = null, questions_supported: Boolean = true): ApiResponse { + open fun redeem( + lists: List, + secret: String, + datetime: String?, + force: Boolean, + nonce: String?, + answers: List?, + ignore_unpaid: Boolean, + pdf_data: Boolean, + type: String?, + source_type: String?, + callTimeout: Long? = null, + questions_supported: Boolean = true, + exchange_medium_type: String? = null, + exchange_medium_identifier: String? = null, + ): ApiResponse { val body = JSONObject() if (datetime != null) { body.put("datetime", datetime) @@ -135,6 +150,8 @@ open class PretixApi(url: String, key: String, orgaSlug: String, version: Int, h jlists.put(l) } body.put("lists", jlists) + if (exchange_medium_type != null) body.put("exchange_medium_type", exchange_medium_type) + if (exchange_medium_identifier != null) body.put("exchange_medium_identifier", exchange_medium_identifier) var pd = "?expand=answers.question" if (pdf_data) { pd += "&pdf_data=true" diff --git a/libpretixsync/src/main/java/eu/pretix/libpretixsync/api/ProxyApi.kt b/libpretixsync/src/main/java/eu/pretix/libpretixsync/api/ProxyApi.kt index 3021f0bb..800512b6 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/api/ProxyApi.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/api/ProxyApi.kt @@ -25,7 +25,9 @@ data class MultiCheckInput( // TODO: Check unused values val allowQuestions: Boolean, - val nonce: String? + val nonce: String?, + val exchange_medium_type: String?, + val exchange_medium_identifier: String?, ) data class CheckInput( diff --git a/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/AsyncCheckProvider.kt b/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/AsyncCheckProvider.kt index b37f653c..4eb5b403 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/AsyncCheckProvider.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/AsyncCheckProvider.kt @@ -8,8 +8,10 @@ import eu.pretix.libpretixsync.crypto.isValidSignature import eu.pretix.libpretixsync.crypto.readPubkeyFromPem import eu.pretix.libpretixsync.crypto.sig1.TicketProtos import eu.pretix.libpretixsync.db.Answer +import eu.pretix.libpretixsync.db.MediaPolicy import eu.pretix.libpretixsync.db.NonceGenerator import eu.pretix.libpretixsync.db.QuestionLike +import eu.pretix.libpretixsync.db.ReusableMediaType import eu.pretix.libpretixsync.models.CheckIn import eu.pretix.libpretixsync.models.Event import eu.pretix.libpretixsync.models.Order as OrderModel @@ -279,7 +281,14 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa return RSAResult(givenAnswers, requiredAnswers, shownAnswers, askQuestions) } - private fun checkOfflineWithoutData(eventsAndCheckinLists: Map, ticketid: String, type: TicketCheckProvider.CheckInType, answers: List?, nonce: String?, allowQuestions: Boolean): TicketCheckProvider.CheckResult { + private fun checkOfflineWithoutData( + eventsAndCheckinLists: Map, + ticketid: String, + type: TicketCheckProvider.CheckInType, + answers: List?, + nonce: String?, + allowQuestions: Boolean + ): TicketCheckProvider.CheckResult { val dt = now() val events = db.eventQueries.selectBySlugList(eventsAndCheckinLists.keys.toList()) .executeAsList() @@ -544,11 +553,21 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa type: TicketCheckProvider.CheckInType, nonce: String?, allowQuestions: Boolean, + exchange_medium_type: String?, + exchange_medium_identifier: String?, ): TicketCheckProvider.CheckResult { val ticketid_cleaned = cleanInput(ticketid, source_type) sentry.addBreadcrumb("provider.check", "offline check started") + if (exchange_medium_type != null || exchange_medium_identifier != null) { + return TicketCheckProvider.CheckResult( + TicketCheckProvider.CheckResult.Type.ERROR, + "Media exchange is not supported in offline mode", + offline = true + ) + } + val tickets = db.orderPositionQueries.selectBySecretAndEventSlugs( secret = ticketid_cleaned, event_slugs = eventsAndCheckinLists.keys.toList(), @@ -609,7 +628,16 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa return checkOfflineWithData(eventsAndCheckinLists, ticketid_cleaned, tickets, answers, ignore_unpaid, type, nonce = nonce, allowQuestions = allowQuestions) } - private fun checkOfflineWithData(eventsAndCheckinLists: Map, secret: String, tickets: List, answers: List?, ignore_unpaid: Boolean, type: TicketCheckProvider.CheckInType, nonce: String?, allowQuestions: Boolean): TicketCheckProvider.CheckResult { + private fun checkOfflineWithData( + eventsAndCheckinLists: Map, + secret: String, + tickets: List, + answers: List?, + ignore_unpaid: Boolean, + type: TicketCheckProvider.CheckInType, + nonce: String?, + allowQuestions: Boolean, + ): TicketCheckProvider.CheckResult { // !!! When extending this, also extend checkOfflineWithoutData !!! val dt = now() @@ -781,6 +809,28 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa return res } + val settings = db.settingsQueries.selectBySlug(eventSlug).executeAsOneOrNull()?.toModel() + val reusableMediaUsageEnforced = (settings?.json?.optBoolean("reusable_media_usage_enforced", false) == true) + + val hasLinkedReusableMedium = + db.reusableMediumQueries.selectByLinkedOrderPosition(position.positionId) + .executeAsList().isNotEmpty() + + if (positionItem.mediaPolicy != MediaPolicy.NONE && positionItem.mediaType != ReusableMediaType.NONE) { + if (!hasLinkedReusableMedium) { + res.type = TicketCheckProvider.CheckResult.Type.ERROR // EXCHANGE_REQUIRED, but not in offline mode + res.isCheckinAllowed = false + res.reasonExplanation = "This ticket needs to be exchanged, but this isn't possible while offline" + storeFailedCheckin(eventSlug, list.serverId, "exchange", position.secret!!, type, position = position.serverId, item = positionItem.serverId, variation = position.variationServerId, subevent = position.subEventServerId, nonce = nonce) + return res + } else if (reusableMediaUsageEnforced) { + res.type = TicketCheckProvider.CheckResult.Type.ALREADY_EXCHANGED + res.isCheckinAllowed = false + storeFailedCheckin(eventSlug, list.serverId, "already_exchanged", position.secret!!, type, position = position.serverId, item = positionItem.serverId, variation = position.variationServerId, subevent = position.subEventServerId, nonce = nonce) + return res + } + } + // !!! When extending this, also extend checkOfflineWithoutData !!! val rules = list.rules diff --git a/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/OnlineCheckProvider.kt b/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/OnlineCheckProvider.kt index 6e26db3c..7af71942 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/OnlineCheckProvider.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/OnlineCheckProvider.kt @@ -8,7 +8,9 @@ import eu.pretix.libpretixsync.api.PretixApi import eu.pretix.libpretixsync.api.TimeoutApiException import eu.pretix.libpretixsync.config.ConfigStore import eu.pretix.libpretixsync.db.Answer +import eu.pretix.libpretixsync.db.MediaPolicy import eu.pretix.libpretixsync.db.NonceGenerator +import eu.pretix.libpretixsync.db.ReusableMediaType import eu.pretix.libpretixsync.models.db.toModel import eu.pretix.libpretixsync.sqldelight.Question import eu.pretix.libpretixsync.sqldelight.SyncDatabase @@ -47,7 +49,9 @@ class OnlineCheckProvider( with_badge_data: Boolean, type: TicketCheckProvider.CheckInType, nonce: String?, - allowQuestions: Boolean + allowQuestions: Boolean, + exchange_medium_type: String?, + exchange_medium_identifier: String?, ): TicketCheckProvider.CheckResult { val ticketid_cleaned = cleanInput(ticketid, source_type) val nonce_cleaned = nonce ?: NonceGenerator.nextNonce() @@ -70,6 +74,8 @@ class OnlineCheckProvider( source_type, callTimeout = if (fallback != null) fallbackTimeout.toLong() else null, questions_supported = allowQuestions, + exchange_medium_type = exchange_medium_type, + exchange_medium_identifier = exchange_medium_identifier, ) } else { if (eventsAndCheckinLists.size != 1) throw CheckException("Multi-event scan not supported by server.") @@ -124,6 +130,16 @@ class OnlineCheckProvider( required_answers.add(TicketCheckProvider.QuestionAnswer(question, q.toString(), "")) } res.requiredAnswers = required_answers + } else if ("exchange" == status){ + res.type = TicketCheckProvider.CheckResult.Type.EXCHANGE_REQUIRED + try { + res.requiredMediaPolicy = + MediaPolicy.getByServerName(response.optString("media_policy")) + res.requiredMediaType = + ReusableMediaType.getByServerName(response.optString("media_type")) + } catch (_: IllegalArgumentException) { + // silently fall back to null + } } else { val reason = response.optString("reason") if ("already_redeemed" == reason) { @@ -163,6 +179,8 @@ class OnlineCheckProvider( res.isCheckinAllowed = includePending && response.has("position") && response.getJSONObject("position").optString("order__status", "n") == "n" } else if ("product" == reason) { res.type = TicketCheckProvider.CheckResult.Type.PRODUCT + } else if ("already_exchanged" == reason) { + res.type = TicketCheckProvider.CheckResult.Type.ALREADY_EXCHANGED } else { res.type = TicketCheckProvider.CheckResult.Type.ERROR } diff --git a/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/ProxyCheckProvider.kt b/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/ProxyCheckProvider.kt index 81ac73c0..232fb758 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/ProxyCheckProvider.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/ProxyCheckProvider.kt @@ -109,7 +109,9 @@ class ProxyCheckProvider(private val config: ConfigStore, httpClientFactory: Htt with_badge_data: Boolean, type: TicketCheckProvider.CheckInType, nonce: String?, - allowQuestions: Boolean + allowQuestions: Boolean, + exchange_medium_type: String?, + exchange_medium_identifier: String?, ): TicketCheckProvider.CheckResult { val answersInput = answers?.map { val questionModel = it.question as Question // TODO: Can we avoid the cast? @@ -130,6 +132,8 @@ class ProxyCheckProvider(private val config: ConfigStore, httpClientFactory: Htt type = type.name, allowQuestions = allowQuestions, nonce = nonce, + exchange_medium_type = exchange_medium_type, + exchange_medium_identifier = exchange_medium_identifier, ) return try { diff --git a/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/TicketCheckProvider.kt b/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/TicketCheckProvider.kt index d2229836..b8b08774 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/TicketCheckProvider.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/TicketCheckProvider.kt @@ -2,6 +2,8 @@ package eu.pretix.libpretixsync.check import eu.pretix.libpretixsync.SentryInterface import eu.pretix.libpretixsync.db.Answer +import eu.pretix.libpretixsync.db.MediaPolicy +import eu.pretix.libpretixsync.db.ReusableMediaType import eu.pretix.libpretixsync.models.db.toModel import eu.pretix.libpretixsync.sqldelight.Question import eu.pretix.libpretixsync.models.Question as QuestionModel @@ -57,7 +59,8 @@ interface TicketCheckProvider { class CheckResult { enum class Type { INVALID, VALID, USED, ERROR, UNPAID, BLOCKED, INVALID_TIME, CANCELED, PRODUCT, RULES, - ANSWERS_REQUIRED, AMBIGUOUS, REVOKED, UNAPPROVED + ANSWERS_REQUIRED, AMBIGUOUS, REVOKED, UNAPPROVED, ALREADY_EXCHANGED, MEDIUM_INVALID, + MEDIUM_EXISTS, EXCHANGE_REQUIRED } var type: Type? = null @@ -80,6 +83,8 @@ interface TicketCheckProvider { var position: JSONObject? = null var eventSlug: String? = null var offline: Boolean = false + var requiredMediaPolicy: MediaPolicy? = null + var requiredMediaType: ReusableMediaType? = null constructor(type: Type?, message: String?, offline: Boolean = false) { this.type = type @@ -157,7 +162,20 @@ interface TicketCheckProvider { class StatusResult(var eventName: String?, var totalTickets: Int, var alreadyScanned: Int, var currentlyInside: Int?, var items: List?) { } - fun check(eventsAndCheckinLists: Map, ticketid: String, source_type: String, answers: List?, ignore_unpaid: Boolean, with_badge_data: Boolean, type: CheckInType, nonce: String? = null, allowQuestions: Boolean = true): CheckResult + fun check( + eventsAndCheckinLists: Map, + ticketid: String, + source_type: String, + answers: List?, + ignore_unpaid: Boolean, + with_badge_data: Boolean, + type: CheckInType, + nonce: String? = null, + allowQuestions: Boolean = true, + exchange_medium_type: String? = null, + exchange_medium_identifier: String? = null, + ): CheckResult + fun check(eventsAndCheckinLists: Map, ticketid: String): CheckResult @Throws(CheckException::class) fun search(eventsAndCheckinLists: Map, query: String, page: Int): List diff --git a/libpretixsync/src/main/java/eu/pretix/libpretixsync/db/MediaPolicy.kt b/libpretixsync/src/main/java/eu/pretix/libpretixsync/db/MediaPolicy.kt index 6e034678..89158905 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/db/MediaPolicy.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/db/MediaPolicy.kt @@ -1,8 +1,15 @@ package eu.pretix.libpretixsync.db -enum class MediaPolicy { - NONE, - REUSE, - NEW, - REUSE_OR_NEW, -} +enum class MediaPolicy(val serverName: String?) { + NONE(null), + NEW("new"), + REUSE("reuse"), + REUSE_OR_NEW("reuse_or_new"), + APPEND("append"), + APPEND_OR_NEW("append_or_new"); + + companion object { + private val map = entries.associateBy(MediaPolicy::serverName) + fun getByServerName(serverName: String?) = map[serverName] + } +} \ No newline at end of file diff --git a/libpretixsync/src/main/java/eu/pretix/libpretixsync/db/ReusableMediaType.java b/libpretixsync/src/main/java/eu/pretix/libpretixsync/db/ReusableMediaType.java deleted file mode 100644 index c4377bb8..00000000 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/db/ReusableMediaType.java +++ /dev/null @@ -1,20 +0,0 @@ -package eu.pretix.libpretixsync.db; - -public enum ReusableMediaType { - NONE(null), - BARCODE("barcode"), - NFC_UID("nfc_uid"), - - NFC_MF0AES("nfc_mf0aes"), - UNSUPPORTED(null); - - public final String serverName; - - private ReusableMediaType(String serverName) { - this.serverName = serverName; - } - - public boolean isNfcBased() { - return this.serverName.startsWith("nfc_"); - } -} diff --git a/libpretixsync/src/main/java/eu/pretix/libpretixsync/db/ReusableMediaType.kt b/libpretixsync/src/main/java/eu/pretix/libpretixsync/db/ReusableMediaType.kt new file mode 100644 index 00000000..bed4d0da --- /dev/null +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/db/ReusableMediaType.kt @@ -0,0 +1,19 @@ +package eu.pretix.libpretixsync.db + +enum class ReusableMediaType(val serverName: String?) { + NONE(null), + BARCODE("barcode"), + NFC_UID("nfc_uid"), + + NFC_MF0AES("nfc_mf0aes"), + UNSUPPORTED("unsupported"); + + fun isNfcBased(): Boolean { + return this.serverName?.startsWith("nfc_") ?: false; + } + + companion object { + private val map = ReusableMediaType.entries.associateBy(ReusableMediaType::serverName) + fun getByServerName(serverName: String?) = map[serverName] + } +} diff --git a/libpretixsync/src/main/java/eu/pretix/libpretixsync/models/db/ItemExtensions.kt b/libpretixsync/src/main/java/eu/pretix/libpretixsync/models/db/ItemExtensions.kt index 045803ec..7307ed9d 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/models/db/ItemExtensions.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/models/db/ItemExtensions.kt @@ -208,9 +208,9 @@ private fun parseHasFreePrice(json: JSONObject): Boolean { private fun parseMediaPolicy(json: JSONObject): MediaPolicy { return try { val mp: String = json.optString("media_policy") ?: return MediaPolicy.NONE - if (mp == "reuse") return MediaPolicy.REUSE - if (mp == "new") return MediaPolicy.NEW - if (mp == "reuse_or_new") MediaPolicy.REUSE_OR_NEW else MediaPolicy.NONE + MediaPolicy.getByServerName(mp) ?: return MediaPolicy.NONE + } catch (_: IllegalArgumentException) { + MediaPolicy.NONE } catch (e: JSONException) { e.printStackTrace() MediaPolicy.NONE @@ -220,9 +220,7 @@ private fun parseMediaPolicy(json: JSONObject): MediaPolicy { private fun parseMediaType(json: JSONObject): ReusableMediaType { return try { val mp: String = json.optString("media_type") ?: return ReusableMediaType.NONE - if (mp == "barcode") return ReusableMediaType.BARCODE - if (mp == "nfc_uid") return ReusableMediaType.NFC_UID - if (mp == "nfc_mf0aes") ReusableMediaType.NFC_MF0AES else ReusableMediaType.UNSUPPORTED + ReusableMediaType.getByServerName(mp) ?: ReusableMediaType.UNSUPPORTED } catch (e: JSONException) { e.printStackTrace() ReusableMediaType.NONE diff --git a/libpretixsync/src/main/sqldelight/common/eu/pretix/libpretixsync/sqldelight/ReusableMedium.sq b/libpretixsync/src/main/sqldelight/common/eu/pretix/libpretixsync/sqldelight/ReusableMedium.sq index ede9a5ad..320e32c4 100644 --- a/libpretixsync/src/main/sqldelight/common/eu/pretix/libpretixsync/sqldelight/ReusableMedium.sq +++ b/libpretixsync/src/main/sqldelight/common/eu/pretix/libpretixsync/sqldelight/ReusableMedium.sq @@ -22,6 +22,12 @@ WHERE ReusableMedium.identifier = :identifier AND ReusableMedium.type = :type AND orders.event_slug IN :event_slugs; +selectByLinkedOrderPosition: +SELECT ReusableMedium.* +FROM ReusableMedium +LEFT JOIN ReusableMedium_OrderPosition ON ReusableMedium_OrderPosition.OrderPositionId = ReusableMedium.id +WHERE OrderPositionId = :order_position_id; + deleteByServerId: DELETE FROM ReusableMedium WHERE server_id = ?; diff --git a/libpretixsync/src/testFixtures/java/eu/pretix/pretixscan/scanproxy/tests/test/FakePretixApi.kt b/libpretixsync/src/testFixtures/java/eu/pretix/pretixscan/scanproxy/tests/test/FakePretixApi.kt index 284b4182..fbf2730e 100644 --- a/libpretixsync/src/testFixtures/java/eu/pretix/pretixscan/scanproxy/tests/test/FakePretixApi.kt +++ b/libpretixsync/src/testFixtures/java/eu/pretix/pretixscan/scanproxy/tests/test/FakePretixApi.kt @@ -42,7 +42,9 @@ class FakePretixApi : PretixApi("http://1.1.1.1/", "a", "demo", 1, DefaultHttpCl type: String?, source_type: String?, callTimeout: Long?, - questions_supported: Boolean + questions_supported: Boolean, + exchange_medium_type: String?, + exchange_medium_identifier: String?, ): ApiResponse { redeemRequestSecret = secret redeemRequestDatetime = datetime