mirror of
https://github.com/pretix/pretix.git
synced 2026-05-04 15:04:03 +00:00
Web-based check-in interface (#1985)
This commit is contained in:
22
src/pretix/plugins/webcheckin/__init__.py
Normal file
22
src/pretix/plugins/webcheckin/__init__.py
Normal 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'
|
||||
27
src/pretix/plugins/webcheckin/signals.py
Normal file
27
src/pretix/plugins/webcheckin/signals.py
Normal 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'),
|
||||
}]
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
8
src/pretix/plugins/webcheckin/urls.py
Normal file
8
src/pretix/plugins/webcheckin/urls.py
Normal 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'),
|
||||
]
|
||||
20
src/pretix/plugins/webcheckin/views.py
Normal file
20
src/pretix/plugins/webcheckin/views.py
Normal 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
|
||||
Reference in New Issue
Block a user