Web-based check-in interface (#1985)

This commit is contained in:
Raphael Michel
2021-03-30 09:34:11 +02:00
committed by GitHub
parent b06cded172
commit 92a50cb2d1
56 changed files with 3578 additions and 58 deletions

View File

@@ -0,0 +1,22 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
from pretix import __version__ as version
class WebCheckinApp(AppConfig):
name = 'pretix.plugins.webcheckin'
verbose_name = _("Web-based check-in")
class PretixPluginMeta:
name = _("Web-based check-in")
author = _("the pretix team")
version = version
category = "FEATURE"
description = _("This plugin allows you to perform check-in actions in your browser.")
def ready(self):
from . import signals # NOQA
default_app_config = 'pretix.plugins.webcheckin.WebCheckinApp'

View File

@@ -0,0 +1,27 @@
from django.dispatch import receiver
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from pretix.control.signals import nav_event
@receiver(nav_event, dispatch_uid='webcheckin_nav_event')
def navbar_entry(sender, request, **kwargs):
url = request.resolver_match
if not request.user.has_event_permission(request.organizer, request.event, ('can_change_orders', 'can_checkin_orders'), request=request):
return []
return [{
'label': mark_safe(_('Web Check-in') + ' <span class="label label-success">beta</span>'),
'url': reverse('plugins:webcheckin:index', kwargs={
'event': request.event.slug,
'organizer': request.organizer.slug,
}),
'parent': reverse('control:event.orders.checkinlists', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'external': True,
'icon': 'check-square-o',
'active': url.namespace == 'plugins:webcheckin' and url.url_name.startswith('index'),
}]

View File

@@ -0,0 +1,517 @@
<template>
<div id="app">
<div class="container">
<h1>
{{ $root.event_name }}
</h1>
<checkinlist-select v-if="!checkinlist" @selected="selectList($event)"></checkinlist-select>
<input v-if="checkinlist" v-model="query" ref="input" :placeholder="$root.strings['input.placeholder']" @keyup="inputKeyup" class="form-control scan-input">
<div v-if="checkResult !== null" class="panel panel-primary check-result">
<div class="panel-heading">
<a class="pull-right" @click.prevent="clear" href="#" tabindex="-1">
<span class="fa fa-close"></span>
</a>
<h3 class="panel-title">
{{ $root.strings['check.headline'] }}
</h3>
</div>
<div v-if="checkLoading" class="panel-body text-center">
<span class="fa fa-4x fa-cog fa-spin loading-icon"></span>
</div>
<div v-else-if="checkError" class="panel-body text-center">
{{ checkError }}
</div>
<div :class="'check-result-status check-result-' + checkResultColor">
{{ checkResultText }}
</div>
<div class="panel-body" v-if="checkResult.position">
<div class="details">
<h4>{{ checkResult.position.order }}-{{ checkResult.position.positionid }} {{ checkResult.position.attendee_name }}</h4>
<span>{{ checkResultItemvar }}</span>
<span v-if="checkResult.position.seat"><br>{{ checkResult.position.seat.name }}</span>
</div>
</div>
<div class="attention" v-if="checkResult && checkResult.require_attention">
<span class="fa fa-warning"></span>
{{ $root.strings['check.attention'] }}
</div>
</div>
<div v-else-if="searchResults !== null" class="panel panel-primary search-results">
<div class="panel-heading">
<a class="pull-right" @click.prevent="clear" href="#" tabindex="-1">
<span class="fa fa-close"></span>
</a>
<h3 class="panel-title">
{{ $root.strings['results.headline'] }}
</h3>
</div>
<ul class="list-group">
<searchresult-item ref="result" v-if="searchResults" v-for="p in searchResults" :position="p" :key="p.id" @selected="selectResult($event)"></searchresult-item>
<li v-if="!searchResults.length && !searchLoading" class="list-group-item text-center">
{{ $root.strings['results.none'] }}
</li>
<li v-if="searchLoading" class="list-group-item text-center">
<span class="fa fa-4x fa-cog fa-spin loading-icon"></span>
</li>
<li v-else-if="searchError" class="list-group-item text-center">
{{ searchError }}
</li>
<a v-else-if="searchNextUrl" class="list-group-item text-center" href="#" @click.prevent="searchNext">
{{ $root.strings['pagination.next'] }}
</a>
</ul>
</div>
<div v-else-if="checkinlist">
<div class="panel panel-default">
<div class="panel-body meta">
<div class="row settings">
<div class="col-sm-6">
<div>
<span :class="'fa fa-sign-' + (type === 'exit' ? 'out' : 'in')"></span>
{{ $root.strings['scantype.' + type] }}<br>
<button @click="switchType" class="btn btn-default"><span class="fa fa-refresh"></span> {{ $root.strings['scantype.switch'] }}</button>
</div>
</div>
<div class="col-sm-6">
<div v-if="checkinlist">
{{ checkinlist.name }}<br>
{{ subevent }}<br v-if="subevent">
<button @click="switchList" type="button" class="btn btn-default">{{ $root.strings['checkinlist.switch'] }}</button>
</div>
</div>
</div>
<div v-if="status" class="row status">
<div class="col-sm-4">
<span class="statistic">{{ status.checkin_count }}</span>
{{ $root.strings['status.checkin'] }}
</div>
<div class="col-sm-4">
<span class="statistic">{{ status.position_count }}</span>
{{ $root.strings['status.position'] }}
</div>
<div class="col-sm-4">
<div class="pull-right">
<button @click="fetchStatus" class="btn btn-default"><span :class="'fa fa-refresh' + (statusLoading ? ' fa-spin': '')"></span></button>
</div>
<span class="statistic">{{ status.inside_count }}</span>
{{ $root.strings['status.inside'] }}
</div>
</div>
</div>
</div>
</div>
</div>
<div :class="'modal modal-unpaid fade' + (showUnpaidModal ? ' in' : '')" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content" v-if="checkResult && checkResult.position">
<div class="modal-header">
<button type="button" class="close" @click="showUnpaidModal = false">
<span class="fa fa-close"></span>
</button>
<h4 class="modal-title">
{{ $root.strings['modal.unpaid.head'] }}
</h4>
</div>
<div class="modal-body">
<p>
{{ $root.strings['modal.unpaid.text'] }}
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary pull-right" @click="check(checkResult.position.secret, true, false, false)">
{{ $root.strings['modal.continue'] }}
</button>
<button type="button" class="btn btn-default" @click="showUnpaidModal = false">
{{ $root.strings['modal.cancel'] }}
</button>
</div>
</div>
</div>
</div>
<form :class="'modal modal-questions fade' + (showQuestionsModal ? ' in' : '')" tabindex="-1" role="dialog" ref="questionsModal">
<div class="modal-dialog" role="document">
<div class="modal-content" v-if="checkResult && checkResult.questions">
<div class="modal-header">
<button type="button" class="close" @click="showQuestionsModal = false">
<span class="fa fa-close"></span>
</button>
<h4 class="modal-title">
{{ $root.strings['modal.questions'] }}
</h4>
</div>
<div class="modal-body">
<div :class="q.type === 'M' ? '' : (q.type === 'B' ? 'checkbox' : 'form-group')" v-for="q in checkResult.questions">
<label :for="'q_' + q.id" v-if="q.type !== 'B'">
{{ q.question }}
{{ q.required ? ' *' : '' }}
</label>
<textarea v-if="q.type === 'T'" v-model="answers[q.id.toString()]" :id="'q_' + q.id" class="form-control" :required="q.required"></textarea>
<input v-else-if="q.type === 'N'" type="number" v-model="answers[q.id.toString()]" :id="'q_' + q.id" class="form-control" :required="q.required">
<datefield v-else-if="q.type === 'D'" v-model="answers[q.id.toString()]" :id="'q_' + q.id" :required="q.required"></datefield>
<timefield v-else-if="q.type === 'H'" v-model="answers[q.id.toString()]" :id="'q_' + q.id" :required="q.required"></timefield>
<datetimefield v-else-if="q.type === 'W'" v-model="answers[q.id.toString()]" :id="'q_' + q.id" :required="q.required"></datetimefield>
<select v-else-if="q.type === 'C'" v-model="answers[q.id.toString()]" :id="'q_' + q.id" class="form-control" :required="q.required">
<option v-if="!q.required"></option>
<option v-for="op in q.options" :value="op.id.toString()">{{ op.answer }}</option>
</select>
<div v-else-if="q.type === 'F'"><em>file input not supported</em></div>
<div v-else-if="q.type === 'M'">
<div class="checkbox" v-for="op in q.options">
<label>
<input type="checkbox" :checked="answers[q.id.toString()] && answers[q.id.toString()].split(',').includes(op.id.toString)" @input="answerSetM(q.id.toString(), op.id.toString(), $event.target.checked)">
{{ op.answer }}
</label>
</div>
</div>
<label v-else-if="q.type === 'B'">
<input type="checkbox" :checked="answers[q.id.toString()] === 'true'" @input="answers[q.id.toString()] = $event.target.checked.toString()" :required="q.required">
{{ q.question }}
{{ q.required ? ' *' : '' }}
</label>
<select v-else-if="q.type === 'CC'" v-model="answers[q.id.toString()]" :id="'q_' + q.id" class="form-control" :required="q.required">
<option v-if="!q.required"></option>
<option v-for="op in countries" :value="op.key">{{ op.value }}</option>
</select>
<input v-else v-model="answers[q.id.toString()]" :id="'q_' + q.id" class="form-control" :required="q.required">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary pull-right" @click="check(checkResult.position.secret, true, true)">
{{ $root.strings['modal.continue'] }}
</button>
<button type="button" class="btn btn-default" @click="showQuestionsModal = false">
{{ $root.strings['modal.cancel'] }}
</button>
</div>
</div>
</div>
</form>
</div>
</template>
<script>
export default {
components: {
CheckinlistSelect: CheckinlistSelect.default,
SearchresultItem: SearchresultItem.default,
Datetimefield: Datetimefield.default,
Timefield: Timefield.default,
Datefield: Datefield.default,
},
data() {
return {
type: 'entry',
query: '',
searchLoading: false,
searchResults: null,
searchNextUrl: null,
searchError: null,
status: null,
statusLoading: 0,
statusInterval: null,
checkLoading: false,
checkError: null,
checkResult: null,
checkinlist: null,
clearTimeout: null,
showUnpaidModal: false,
showQuestionsModal: false,
answers: {},
}
},
mounted() {
window.addEventListener('focus', this.globalKeydown)
document.addEventListener("visibilitychange", this.globalKeydown)
document.addEventListener('keydown', this.globalKeydown)
this.statusInterval = window.setInterval(this.fetchStatus, 120 * 1000)
},
destroyed() {
window.removeEventListener('focus', this.globalKeydown)
document.removeEventListener("visibilitychange", this.globalKeydown)
document.removeEventListener('keydown', this.globalKeydown)
window.clearInterval(this.statusInterval)
window.clearInterval(this.clearTimeout)
},
computed: {
countries() {
return JSON.parse(document.querySelector("#countries").innerHTML);
},
subevent() {
if (!this.checkinlist) return ''
if (!this.checkinlist.subevent) return ''
const name = i18nstring_localize(this.checkinlist.subevent.name)
const date = moment.utc(this.checkinlist.subevent.date_from).tz(this.$root.timezone).format(this.$root.datetime_format)
return `${name} · ${date}`
},
checkResultItemvar() {
if (!this.checkResult) return ''
if (this.checkResult.position.variation) {
return `${i18nstring_localize(this.checkResult.position.item.name)} ${i18nstring_localize(this.checkResult.position.variation.value)}`
}
return i18nstring_localize(this.checkResult.position.item.name)
},
checkResultText () {
if (!this.checkResult) return ''
if (this.checkResult.status === 'ok') {
return this.$root.strings['result.ok']
} else if (this.checkResult.status === 'incomplete') {
return this.$root.strings['result.questions']
} else {
return this.$root.strings['result.' + this.checkResult.reason]
}
},
checkResultColor () {
if (!this.checkResult) return ''
if (this.checkResult.status === 'ok') {
return "green";
} else if (this.checkResult.status === 'incomplete') {
return "purple";
} else {
if (this.checkResult.reason === 'already_redeemed') return "orange";
return "red";
}
},
},
methods: {
selectResult(res) {
this.check(res.id, false, false, false)
},
answerSetM(qid, opid, checked) {
let arr = this.answers[qid] ? this.answers[qid].split(',') : [];
if (checked && !arr.includes(opid)) {
arr.push(opid)
} else if (!checked) {
arr = arr.filter(o => opid !== o)
}
this.answers[qid] = arr.join(',')
},
clear() {
this.query = ''
this.searchLoading = false
this.searchResults = null
this.searchNextUrl = null
this.searchError = null
this.checkLoading = false
this.checkError = null
this.checkResult = null
this.showUnpaidModal = false
this.showQuestionsModal = false
this.answers = {}
},
check(id, ignoreUnpaid, keepAnswers, fallbackToSearch) {
if (!keepAnswers) {
this.answers = {}
} else if (this.showQuestionsModal) {
if (!this.$refs.questionsModal.reportValidity()) {
return
}
}
this.showUnpaidModal = false
this.showQuestionsModal = false
this.checkLoading = true
this.checkError = null
this.checkResult = {}
window.clearInterval(this.clearTimeout)
fetch(this.$root.api.lists + this.checkinlist.id + '/positions/' + encodeURIComponent(id) + '/redeem/?expand=item&expand=variation', {
method: 'POST',
headers: {
'X-CSRFToken': document.querySelector("input[name=csrfmiddlewaretoken]").value,
'Content-Type': 'application/json'
},
body: JSON.stringify({
questions_supported: true,
canceled_supported: true,
ignore_unpaid: ignoreUnpaid || false,
type: this.type,
answers: this.answers,
})
})
.then(response => {
if (response.status === 404) {
return {
status: 'error',
reason: 'invalid',
}
}
if (!response.ok && response.status != 400) {
throw new Error("HTTP status " + response.status);
}
return response.json()
})
.then(data => {
this.checkLoading = false
this.checkResult = data
if (this.checkinlist.include_pending && data.status === 'error' && data.reason === 'unpaid') {
this.showUnpaidModal = true
this.$nextTick(() => {
document.querySelector(".modal-unpaid .btn-primary").focus()
})
} else if (data.status === 'incomplete') {
this.showQuestionsModal = true
for (const q of this.checkResult.questions) {
if (!this.answers[q.id.toString()]) {
this.answers[q.id.toString()] = ""
}
q.question = i18nstring_localize(q.question)
for (const o of q.options) {
o.answer = i18nstring_localize(o.answer)
}
}
this.$nextTick(() => {
document.querySelector(".modal-questions input, .modal-questions select, .modal-questions textarea").focus()
})
} else if (data.status === 'error' && data.reason === 'invalid' && fallbackToSearch) {
this.startSearch(false)
} else {
this.clearTimeout = window.setTimeout(this.clear, 1000 * 20)
this.fetchStatus()
}
})
.catch(reason => {
this.checkLoading = false
this.checkResult = {}
this.checkError = reason.toString()
this.clearTimeout = window.setTimeout(this.clear, 1000 * 20)
})
},
globalKeydown(e) {
if (document.activeElement.classList.contains('searchresult') && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
if (e.key === 'ArrowDown') {
document.activeElement.nextElementSibling.focus()
e.preventDefault()
return true
}
if (e.key === 'ArrowUp') {
document.activeElement.previousElementSibling.focus()
e.preventDefault()
return true
}
}
if (document.activeElement.nodeName.toLowerCase() !== 'input' && document.activeElement.nodeName.toLowerCase() !== 'textarea') {
if (e.key && e.key.match(/^[a-z0-9A-Z+/=<>#]$/)) {
this.query = ''
this.refocus()
}
}
},
refocus() {
this.$nextTick(() => {
this.$refs.input.focus()
})
},
inputKeyup(e) {
if (e.key === "Enter") {
this.startSearch(true)
} else if (this.query === '') {
this.clear()
}
},
startSearch(fallbackToScan) {
if (this.query.length >= 32 && fallbackToScan) {
// likely a secret, not a search result
this.check(this.query, false, false, true)
return
}
this.checkResult = null
this.searchLoading = true
this.searchError = null
this.searchResults = []
this.answers = {}
window.clearInterval(this.clearTimeout)
fetch(this.$root.api.lists + this.checkinlist.id + '/positions/?ignore_status=true&expand=subevent&expand=item&expand=variation&check_rules=true&search=' + encodeURIComponent(this.query))
.then(response => response.json())
.then(data => {
this.searchLoading = false
if (data.results) {
this.searchResults = data.results
this.searchNextUrl = data.next
if (data.results.length) {
if (data.results[0].secret === this.query) {
this.$nextTick(() => {
this.$refs.result[0].$refs.a.click()
})
} else {
this.$nextTick(() => {
this.$refs.result[0].$refs.a.focus()
})
}
} else {
this.$nextTick(() => {
this.$refs.input.blur()
})
}
} else {
this.searchError = data
}
this.clearTimeout = window.setTimeout(this.clear, 1000 * 20)
})
.catch(reason => {
this.searchLoading = false
this.searchResults = []
this.searchError = reason
this.clearTimeout = window.setTimeout(this.clear, 1000 * 20)
})
},
searchNext() {
this.searchLoading = true
this.searchError = null
window.clearInterval(this.clearTimeout)
fetch(this.searchNextUrl)
.then(response => response.json())
.then(data => {
this.searchLoading = false
if (data.results) {
this.searchResults.push(...data.results)
this.searchNextUrl = data.next
} else {
this.searchError = data
}
this.clearTimeout = window.setTimeout(this.clear, 1000 * 20)
})
.catch(reason => {
this.searchLoading = false
this.searchError = reason
this.clearTimeout = window.setTimeout(this.clear, 1000 * 20)
})
},
switchType() {
this.type = this.type === 'exit' ? 'entry' : 'exit'
this.refocus()
},
switchList() {
location.hash = ''
this.checkinlist = null
},
fetchStatus() {
this.statusLoading++
fetch(this.$root.api.lists + this.checkinlist.id + '/status/')
.then(response => response.json())
.then(data => {
this.statusLoading--
this.status = data
})
.catch(reason => {
this.statusLoading--
})
},
selectList(list) {
this.checkinlist = list
location.hash = '#' + list.id
this.refocus()
this.fetchStatus()
}
}
}
</script>

View File

@@ -0,0 +1,28 @@
<template>
<a class="list-group-item" href="#" @click.prevent="$emit('selected', list)">
<div class="row">
<div class="col-md-6">
{{ list.name }}
</div>
<div class="col-md-6 text-muted">
{{ subevent }}
</div>
</div>
</a>
</template>
<script>
export default {
components: {},
props: {
list: Object
},
computed: {
subevent () {
if (!this.list.subevent) return '';
const name = i18nstring_localize(this.list.subevent.name)
const date = moment.utc(this.list.subevent.date_from).tz(this.$root.timezone).format(this.$root.datetime_format)
return `${name} · ${date}`
}
},
}
</script>

View File

@@ -0,0 +1,101 @@
<template>
<div class="panel panel-primary checkinlist-select">
<div class="panel-heading">
<h3 class="panel-title">
{{ $root.strings['checkinlist.select'] }}
</h3>
</div>
<ul class="list-group">
<checkinlist-item v-if="lists" v-for="l in lists" :list="l" :key="l.id" @selected="$emit('selected', l)"></checkinlist-item>
<li v-if="loading" class="list-group-item text-center">
<span class="fa fa-4x fa-cog fa-spin loading-icon"></span>
</li>
<li v-else-if="error" class="list-group-item text-center">
{{ error }}
</li>
<a v-else-if="next_url" class="list-group-item text-center" href="#" @click.prevent="loadNext">
{{ $root.strings['pagination.next'] }}
</a>
</ul>
</div>
</template>
<script>
export default {
components: {
CheckinlistItem: CheckinlistItem.default,
},
data() {
return {
loading: false,
error: null,
lists: null,
next_url: null,
}
},
// TODO: pagination
mounted() {
this.load()
},
methods: {
load() {
this.loading = true
const cutoff = moment().subtract(8, 'hours').toISOString()
if (location.hash) {
fetch(this.$root.api.lists + location.hash.substr(1) + '/' + '?expand=subevent')
.then(response => response.json())
.then(data => {
this.loading = false
if (data.id) {
this.$emit('selected', data)
} else {
location.hash = ''
this.load()
}
})
.catch(reason => {
location.hash = ''
this.load()
})
return
}
fetch(this.$root.api.lists + '?exclude=checkin_count&exclude=position_count&expand=subevent&ends_after=' + cutoff)
.then(response => response.json())
.then(data => {
this.loading = false
if (data.results) {
this.lists = data.results
this.next_url = data.next
} else if (data.results === 0) {
this.error = this.$root.strings['checkinlist.none']
} else {
this.error = data
}
})
.catch(reason => {
this.loading = false
this.error = reason
})
},
loadNext() {
this.loading = true
fetch(this.next_url)
.then(response => response.json())
.then(data => {
this.loading = false
if (data.results) {
this.lists.push(...data.results)
this.next_url = data.next
} else if (data.results === 0) {
this.error = this.$root.strings['checkinlist.none']
} else {
this.error = data
}
})
.catch(reason => {
this.loading = false
this.error = reason
})
},
},
}
</script>

View File

@@ -0,0 +1,54 @@
<template>
<input class="form-control" :required="required">
</template>
<script>
export default {
props: ["required", "value"],
mounted: function () {
var vm = this;
var multiple = this.multiple;
$(this.$el)
.datetimepicker(this.opts())
.trigger("change")
.on("dp.change", function (e) {
vm.$emit("input", $(this).data('DateTimePicker').date().format("YYYY-MM-DD"));
});
if (!vm.value) {
$(this.$el).data("DateTimePicker").viewDate(moment().hour(0).minute(0).second(0).millisecond(0));
} else {
$(this.$el).data("DateTimePicker").date(moment(vm.value));
}
},
methods: {
opts: function () {
return {
format: $("body").attr("data-dateformat"),
locale: $("body").attr("data-datetimelocale"),
useCurrent: false,
showClear: this.required,
icons: {
time: 'fa fa-clock-o',
date: 'fa fa-calendar',
up: 'fa fa-chevron-up',
down: 'fa fa-chevron-down',
previous: 'fa fa-chevron-left',
next: 'fa fa-chevron-right',
today: 'fa fa-screenshot',
clear: 'fa fa-trash',
close: 'fa fa-remove'
}
};
}
},
watch: {
value: function (val) {
$(this.$el).data('DateTimePicker').date(moment(val));
},
},
destroyed: function () {
$(this.$el)
.off()
.datetimepicker("destroy");
}
}
</script>

View File

@@ -0,0 +1,55 @@
<template>
<input class="form-control" :required="required">
</template>
<script>
export default {
props: ["required", "value"],
mounted: function () {
var vm = this;
var multiple = this.multiple;
$(this.$el)
.datetimepicker(this.opts())
.trigger("change")
.on("dp.change", function (e) {
vm.$emit("input", $(this).data('DateTimePicker').date().toISOString());
});
if (!vm.value) {
$(this.$el).data("DateTimePicker").viewDate(moment().hour(0).minute(0).second(0).millisecond(0));
} else {
$(this.$el).data("DateTimePicker").date(moment(vm.value));
}
},
methods: {
opts: function () {
return {
format: $("body").attr("data-datetimeformat"),
locale: $("body").attr("data-datetimelocale"),
timeZone: $("body").attr("data-timezone"),
useCurrent: false,
showClear: this.required,
icons: {
time: 'fa fa-clock-o',
date: 'fa fa-calendar',
up: 'fa fa-chevron-up',
down: 'fa fa-chevron-down',
previous: 'fa fa-chevron-left',
next: 'fa fa-chevron-right',
today: 'fa fa-screenshot',
clear: 'fa fa-trash',
close: 'fa fa-remove'
}
};
}
},
watch: {
value: function (val) {
$(this.$el).data('DateTimePicker').date(moment(val));
},
},
destroyed: function () {
$(this.$el)
.off()
.datetimepicker("destroy");
}
}
</script>

View File

@@ -0,0 +1,46 @@
<template>
<a class="list-group-item searchresult" href="#" @click.prevent="$emit('selected', position)" ref="a">
<div class="details">
<h4>{{ position.order }}-{{ position.positionid }} {{ position.attendee_name }}</h4>
<span>{{ itemvar }}<br></span>
<span v-if="subevent">{{ subevent }}<br></span>
<div class="secret">{{ position.secret }}</div>
</div>
<div :class="`status status-${status}`">
<span v-if="position.require_attention"><span class="fa fa-warning"></span><br></span>
{{ $root.strings[`status.${status}`] }}
</div>
</a>
</template>
<script>
export default {
components: {},
props: {
position: Object
},
computed: {
status() {
if (this.position.checkins.length) return 'redeemed';
return this.position.order__status
},
itemvar() {
if (this.position.variation) {
return `${i18nstring_localize(this.position.item.name)} ${i18nstring_localize(this.position.variation.value)}`
}
return i18nstring_localize(this.position.item.name)
},
subevent() {
if (!this.position.subevent) return ''
const name = i18nstring_localize(this.position.subevent.name)
const date = moment.utc(this.position.subevent.date_from).tz(this.$root.timezone).format(this.$root.datetime_format)
return `${name} · ${date}`
},
},
}
// secret
// status
// order code
// name
// seat
// require attention
</script>

View File

@@ -0,0 +1,54 @@
<template>
<input class="form-control" :required="required">
</template>
<script>
export default {
props: ["required", "value"],
mounted: function () {
var vm = this;
var multiple = this.multiple;
$(this.$el)
.datetimepicker(this.opts())
.trigger("change")
.on("dp.change", function (e) {
vm.$emit("input", $(this).data('DateTimePicker').date().format("HH:mm:ss"));
});
if (!vm.value) {
$(this.$el).data("DateTimePicker").viewDate(moment().hour(0).minute(0).second(0).millisecond(0));
} else {
$(this.$el).data("DateTimePicker").date(moment(vm.value));
}
},
methods: {
opts: function () {
return {
format: $("body").attr("data-timeformat"),
locale: $("body").attr("data-datetimelocale"),
useCurrent: false,
showClear: this.required,
icons: {
time: 'fa fa-clock-o',
date: 'fa fa-calendar',
up: 'fa fa-chevron-up',
down: 'fa fa-chevron-down',
previous: 'fa fa-chevron-left',
next: 'fa fa-chevron-right',
today: 'fa fa-screenshot',
clear: 'fa fa-trash',
close: 'fa fa-remove'
}
};
}
},
watch: {
value: function (val) {
$(this.$el).data('DateTimePicker').date(moment(val));
},
},
destroyed: function () {
$(this.$el)
.off()
.datetimepicker("destroy");
}
}
</script>

View File

@@ -0,0 +1,71 @@
/*global gettext, Vue, App*/
function gettext(msgid) {
if (typeof django !== 'undefined' && typeof django.gettext !== 'undefined') {
return django.gettext(msgid);
}
return msgid;
}
function ngettext(singular, plural, count) {
if (typeof django !== 'undefined' && typeof django.ngettext !== 'undefined') {
return django.ngettext(singular, plural, count);
}
return plural;
}
moment.locale(document.body.attributes['data-datetimelocale'].value)
window.vapp = new Vue({
components: {
App: App.default
},
render: function (h) {
return h('App')
},
data: {
api: {
lists: document.querySelector('#app').attributes['data-api-lists'].value,
},
strings: {
'checkinlist.select': gettext('Select a check-in list'),
'checkinlist.none': gettext('No active check-in lists found.'),
'checkinlist.switch': gettext('Switch check-in list'),
'results.headline': gettext('Search results'),
'results.none': gettext('No tickets found'),
'check.headline': gettext('Check-in result'),
'check.attention': gettext('This ticket requires special attention'),
'scantype.switch': gettext('Switch direction'),
'scantype.entry': gettext('Entry'),
'scantype.exit': gettext('Exit'),
'input.placeholder': gettext('Scan a ticket or search and press return…'),
'pagination.next': gettext('Load more'),
'status.p': gettext('Valid'),
'status.n': gettext('Unpaid'),
'status.c': gettext('Canceled'),
'status.e': gettext('Canceled'),
'status.redeemed': gettext('Redeemed'),
'modal.cancel': gettext('Cancel'),
'modal.continue': gettext('Continue'),
'modal.unpaid.head': gettext('Ticket not paid'),
'modal.unpaid.text': gettext('This ticket is not yet paid. Do you want to continue anyways?'),
'modal.questions': gettext('Additional information required'),
'result.ok': gettext('Valid ticket'),
'result.exit': gettext('Exit recorded'),
'result.already_redeemed': gettext('Ticket already used'),
'result.questions': gettext('Information required'),
'result.invalid': gettext('Invalid ticket'),
'result.product': gettext('Invalid product'),
'result.unpaid': gettext('Ticket not paid'),
'result.rules': gettext('Entry not allowed'),
'result.revoked': gettext('Ticket code revoked/changed'),
'result.canceled': gettext('Order canceled'),
'status.checkin': gettext('Checked-in Tickets'),
'status.position': gettext('Valid Tickets'),
'status.inside': gettext('Currently inside'),
},
event_name: document.querySelector('#app').attributes['data-event-name'].value,
timezone: document.body.attributes['data-timezone'].value,
datetime_format: document.body.attributes['data-datetimeformat'].value,
},
el: '#app'
})

View File

@@ -0,0 +1,153 @@
@import "pretixbase/scss/_variables.scss";
@import "bootstrap/scss/_bootstrap.scss";
@import "pretixbase/scss/_theme.scss";
@import "fontawesome/scss/font-awesome.scss";
@import "datetimepicker/_bootstrap-datetimepicker.scss";
body {
background: #FBF7FC;
}
.container {
padding-top: 20px;
padding-bottom: 20px;
}
.loading-icon {
color: $brand-primary;
-webkit-animation: fa-spin 8s infinite linear;
animation: fa-spin 8s infinite linear;
}
.meta {
padding: 20px;
text-align: center;
.settings {
font-size: 32px;
}
color: $text-muted;
.fa-sign-out {
color: $brand-warning;
}
.status {
padding-top: 20px;
color: $text-muted;
.statistic {
display: block;
font-size: 30px;
}
}
}
.scan-input {
margin-bottom: 20px;
}
a.searchresult {
display: flex;
flex-direction: row;
min-height: 96px;
padding: 0;
color: $text-color;
&:focus {
background: $gray-lightest;
}
h4 {
margin: 0 0 5px;
}
.details {
flex: auto 1 1;
padding: 10px;
}
.status {
flex: 128px 0 0;
font-weight: bold;
color: white;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
font-size: 140%;
&.status-p {
background: $brand-success;
}
&.status-c, &.status-e, &.status-n {
background: $brand-danger;
}
&.status-redeemed {
background: $brand-warning;
}
}
.secret {
word-break: break-word;
color: $text-muted;
}
}
.check-result-status {
height: 30vh;
max-height: 200px;
font-size: 35px;
text-transform: uppercase;
font-weight: bold;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
color: white;
&.check-result-red {
background: $brand-danger;
}
&.check-result-green {
background: $brand-success;
}
&.check-result-orange {
background: $brand-warning;
}
&.check-result-purple {
background: $brand-primary;
}
}
.attention {
padding: 10px;
text-align: center;
animation: blinking 1s infinite;
font-weight: bold;
font-size: 16px;
}
.modal {
background: rgba(0, 0, 0, 0.7);
}
.modal.fade.in {
display: block;
overflow: auto;
}
@-webkit-keyframes blinking {
0%, 49% {
background-color: $brand-primary;
color: white;
}
50%, 100% {
background-color: $brand-warning;
color: $brand-primary;
}
}
.panel-primary .panel-heading a {
color: white;
}
.modal-footer .btn-primary.pull-right {
margin-left: 10px;
}

View File

@@ -0,0 +1,51 @@
{% load compress %}
{% load static %}
{% load i18n %}
{% load hijack_tags %}
{% load statici18n %}
{% load eventurl %}
{% load escapejson %}
<!DOCTYPE html>
<html>
<head>
<title>{{ request.event.name }} :: {% trans "Check-in" %} :: {{ settings.PRETIX_INSTANCE_NAME }}</title>
{% compress css %}
<link rel="stylesheet" type="text/x-scss" href="{% static "pretixplugins/webcheckin/scss/main.scss" %}"/>
{% endcompress %}
{% if DEBUG %}
<script type="text/javascript" src="{% url 'javascript-catalog' lang=request.LANGUAGE_CODE %}" async></script>
{% else %}
<script src="{% statici18n request.LANGUAGE_CODE %}"></script>
{% endif %}
{{ html_head|safe }}
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="{% static "pretixbase/img/favicon.ico" %}">
</head>
<body data-datetimeformat="{{ js_datetime_format }}" data-timeformat="{{ js_time_format }}"
data-dateformat="{{ js_date_format }}" data-datetimelocale="{{ js_locale }}"
data-pretixlocale="{{ request.LANGUAGE_CODE }}" data-timezone="{{ request.event.settings.timezone }}">
<div
data-api-lists="{% url "api-v1:checkinlist-list" event=request.event.slug organizer=request.organizer.slug %}"
data-event-name="{{ request.event.name }}"
id="app"></div>
{# TODO: use vue.min.js #}
{% compress js %}
<script type="text/javascript" src="{% static "pretixbase/js/i18nstring.js" %}"></script>
<script type="text/javascript" src="{% static "moment/moment-with-locales.js" %}"></script>
<script type="text/javascript" src="{% static "moment/moment-timezone-with-data-1970-2030.js" %}"></script>
<script type="text/javascript" src="{% static "jquery/js/jquery-2.1.1.min.js" %}"></script>
<script type="text/javascript" src="{% static "datetimepicker/bootstrap-datetimepicker.js" %}"></script>
<script type="text/javascript" src="{% static "vuejs/vue.js" %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/checkinlist-item.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/checkinlist-select.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/searchresult-item.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/datetimefield.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/datefield.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/timefield.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/app.vue' %}"></script>
<script type="text/javascript" src="{% static "pretixplugins/webcheckin/main.js" %}"></script>
{% endcompress %}
<script type="application/json" id="countries">{{ countries|escapejson_dumps }}</script>
{% csrf_token %}
</body>
</html>

View File

@@ -0,0 +1,8 @@
from django.conf.urls import url
from .views import IndexView
urlpatterns = [
url(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/webcheckin/$',
IndexView.as_view(), name='index'),
]

View File

@@ -0,0 +1,20 @@
from django.views.generic import TemplateView
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.helpers.countries import CachedCountries
class IndexView(EventPermissionRequiredMixin, TemplateView):
permission = ('can_change_orders', 'can_checkin_orders')
template_name = 'pretixplugins/webcheckin/index.html'
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['countries'] = [
{
'key': key,
'value': name
}
for key, name in CachedCountries()
]
return ctx