Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
e7e6630
Reorder AsyncCheckProvider
robbi5 May 5, 2026
3f22c7d
Draft: support multiple tickets/orderpositions as candidates for the …
robbi5 May 7, 2026
f6b2d85
Draft: error handling for multiple tickets/orderpositions as candidates
robbi5 May 7, 2026
b68e668
Draft: tests for reusable medium offline checkin
robbi5 May 11, 2026
5991c75
Fix sync of order positions linked to reusable medium
robbi5 May 11, 2026
31689a1
Fix matching of order position to reusable medium
robbi5 May 12, 2026
ee9feb3
Reject resuable media that are not active
robbi5 May 12, 2026
6ed320e
Map reusable medium expiry to OffsetDateTime
robbi5 May 12, 2026
6c41a35
Reject reusable media that are expired
robbi5 May 12, 2026
9018e0b
Rework test for two tickets with non overlapping times in different e…
robbi5 May 12, 2026
b7d0d58
Offer closest valid ticket for the error message if none of the ticke…
robbi5 May 13, 2026
5e8a530
Add support for already exchanged ticket error
robbi5 May 20, 2026
deee903
Add server side identfier to MediaPolicy enum
robbi5 May 21, 2026
c339726
Return checkin type EXCHANGE_REQUIRED for server responding with stat…
robbi5 May 21, 2026
c18d584
Rename .java to .kt
robbi5 May 26, 2026
192542a
Enable lookup of MediaPolicy and ResuableMediaType by server side id…
robbi5 May 26, 2026
9305b30
Add API calls for loading and linking resuable media
robbi5 May 26, 2026
7416e21
Handle exchange required in AsyncCheckProvider too, gate already_exch…
robbi5 May 28, 2026
a2c37ca
Add support for reusable media exchange to checkinrpc
robbi5 May 30, 2026
3ed7ca2
Add media exchange parameters to FakePretixApi too
robbi5 May 30, 2026
03f76ae
Use exchange_ prefix and singular for api properties
robbi5 Jun 9, 2026
571ad7b
Add new error types for reusable medium exchange
robbi5 Jun 9, 2026
a54d6ba
Fix ambiguous null ReusableMediaType
robbi5 Jun 9, 2026
fa21a82
Remove exchange_link_action, server knows best
robbi5 Jun 9, 2026
a192d9e
Add MediaPolicy.APPEND and MediaPolicy.APPEND_OR_NEW
robbi5 Jun 11, 2026
89f3678
Don't try to support media exchange while offline
robbi5 Jun 11, 2026
5c98686
Remove loadMedium and linkMedium from API again
robbi5 Jun 11, 2026
969add0
Fix null in ReusableMedium.expires database rows
robbi5 Jun 11, 2026
aa4e657
Fix matching of order position to reusable medium for selectByLinkedO…
robbi5 Jun 11, 2026
6eb45f8
Add mediumUsed
raphaelm Jun 11, 2026
423757b
Add EXCHANGE_REQUIRED_OFFLINE and store failed medium exchange
robbi5 Jun 11, 2026
978bcaa
Fix failing test, status code is now invalid
robbi5 Jun 11, 2026
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 @@ -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<Long>, secret: String, datetime: String?, force: Boolean, nonce: String?, answers: List<Answer>?, ignore_unpaid: Boolean, pdf_data: Boolean, type: String?, source_type: String?, callTimeout: Long? = null, questions_supported: Boolean = true): ApiResponse {
open fun redeem(
lists: List<Long>,
secret: String,
datetime: String?,
force: Boolean,
nonce: String?,
answers: List<Answer>?,
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)
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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.")
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, EXCHANGE_REQUIRED_OFFLINE
}

var type: Type? = null
Expand All @@ -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
Expand Down Expand Up @@ -157,7 +162,20 @@ interface TicketCheckProvider {
class StatusResult(var eventName: String?, var totalTickets: Int, var alreadyScanned: Int, var currentlyInside: Int?, var items: List<StatusResultItem>?) {
}

fun check(eventsAndCheckinLists: Map<String, Long>, ticketid: String, source_type: String, answers: List<Answer>?, ignore_unpaid: Boolean, with_badge_data: Boolean, type: CheckInType, nonce: String? = null, allowQuestions: Boolean = true): CheckResult
fun check(
eventsAndCheckinLists: Map<String, Long>,
ticketid: String,
source_type: String,
answers: List<Answer>?,
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<String, Long>, ticketid: String): CheckResult
@Throws(CheckException::class)
fun search(eventsAndCheckinLists: Map<String, Long>, query: String, page: Int): List<SearchResult>
Expand Down
Original file line number Diff line number Diff line change
@@ -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]
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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]
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package eu.pretix.libpretixsync.models

import java.time.OffsetDateTime

data class ReusableMedium(
val id: Long,
val serverId: Long?,
val active: Boolean,
val customerId: Long?,
val expires: String?,
val expires: OffsetDateTime?,
val identifier: String?,
val linkedGiftCardId: Long?,
val type: String?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
package eu.pretix.libpretixsync.models.db

import eu.pretix.libpretixsync.sqldelight.ReusableMedium
import eu.pretix.libpretixsync.sqldelight.SafeOffsetDateTimeMapper
import org.json.JSONObject
import eu.pretix.libpretixsync.models.ReusableMedium as ReusableMediumModel

fun ReusableMedium.toModel(): ReusableMediumModel {
val json = JSONObject(this.json_data!!)

return ReusableMediumModel(
id = this.id,
serverId = this.server_id!!,
active = this.active,
customerId = this.customer_id,
expires = this.expires,
expires = SafeOffsetDateTimeMapper.decode(json, "expires"),
identifier = this.identifier,
linkedGiftCardId = this.linked_giftcard_id,
type = this.type,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ import app.cash.sqldelight.db.QueryResult
import eu.pretix.libpretixsync.api.ApiException
import eu.pretix.libpretixsync.api.PretixApi
import eu.pretix.libpretixsync.api.ResourceNotModified
import eu.pretix.libpretixsync.sqldelight.Migrations
import eu.pretix.libpretixsync.sqldelight.ResourceSyncStatus
import eu.pretix.libpretixsync.sqldelight.ReusableMedium
import eu.pretix.libpretixsync.sqldelight.SyncDatabase
import eu.pretix.libpretixsync.sync.SyncManager.ProgressFeedback
import eu.pretix.libpretixsync.utils.JSONUtils
import org.joda.time.format.ISODateTimeFormat
import org.json.JSONException
import org.json.JSONObject
import java.io.UnsupportedEncodingException
Expand Down Expand Up @@ -62,11 +65,17 @@ class ReusableMediaSyncAdapter(
}

override fun insert(jsonobj: JSONObject) {
val expires = if (!jsonobj.isNull("expires")) {
ISODateTimeFormat.dateTimeParser().parseDateTime(jsonobj.getString("expires")).toDate()
} else {
null
}

val rmId = db.reusableMediumQueries.transactionWithResult {
db.reusableMediumQueries.insert(
active = jsonobj.getBoolean("active"),
customer_id = jsonobj.optLong("customer"),
expires = jsonobj.optString("expires"),
expires = expires,
identifier = jsonobj.getString("identifier"),
json_data = jsonobj.toString(),
linked_giftcard_id = jsonobj.optLong("linked_giftcard"),
Expand All @@ -88,10 +97,16 @@ class ReusableMediaSyncAdapter(
}
.toSet()

val expires = if (!jsonobj.isNull("expires")) {
ISODateTimeFormat.dateTimeParser().parseDateTime(jsonobj.getString("expires")).toDate()
} else {
null
}

db.reusableMediumQueries.updateFromJson(
active = jsonobj.getBoolean("active"),
customer_id = jsonobj.optLong("customer"),
expires = jsonobj.optString("expires"),
expires = expires,
identifier = jsonobj.getString("identifier"),
json_data = jsonobj.toString(),
linked_giftcard_id = jsonobj.optLong("linked_giftcard"),
Expand All @@ -117,9 +132,9 @@ class ReusableMediaSyncAdapter(
}

val newIds = if (orderpositionids.isNotEmpty()) {
db.orderPositionQueries.selectByReusableMediumId(
reusablemedium_id = rmId,
).executeAsList().map { it.id }.toSet()
orderpositionids.mapNotNull {
db.orderPositionQueries.selectByServerId(it).executeAsOneOrNull()?.id
}.toSet()
} else {
emptySet()
}
Expand Down Expand Up @@ -293,4 +308,21 @@ class ReusableMediaSyncAdapter(
return d
}

@Throws(JSONException::class)
fun standaloneRefreshFromJSON(data: JSONObject) {
val known = db.reusableMediumQueries.selectByServerId(data.getLong("id")).executeAsOneOrNull()

// Store object
data.put("__libpretixsync_dbversion", Migrations.CURRENT_VERSION)
data.put("__libpretixsync_syncCycleId", syncCycleId)
if (known == null) {
insert(data)
} else {
val old = JSONObject(known.json_data!!)
if (!JSONUtils.similar(data, old)) {
update(known, data)
}
}
}

}
Loading
Loading