summaryrefslogtreecommitdiff
path: root/postgresqleu/confsponsor/scanning.py
blob: e519ad09d133838caed367e6649462ad976ebf59 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
from django.shortcuts import render, get_object_or_404
from django.http import HttpResponse, HttpResponseRedirect, Http404
from django.views.decorators.csrf import csrf_exempt
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.db import transaction
from django.conf import settings

import csv
import json
import re

from postgresqleu.util.random import generate_random_token
from postgresqleu.util.qr import generate_base64_qr
from postgresqleu.util.db import exec_to_dict, exec_to_keyed_scalar
from postgresqleu.util.decorators import global_login_exempt
from postgresqleu.confreg.models import ConferenceRegistration
from postgresqleu.confreg.util import send_conference_mail, get_conference_or_404, render_conference_response

from .views import _get_sponsor_and_admin, get_authenticated_conference
from .models import SponsorScanner, ScannedAttendee
from .models import SponsorClaimedBenefit
from .benefitclasses import get_benefit_id


def testcode(request):
    return render(request, 'confsponsor/scanning_testcode.html', {
        'qrtest': generate_base64_qr('{}/t/at/TESTTESTTESTTEST/'.format(settings.SITEBASE), 2, 150),
    })


# Sponsor dashboard for badge scanning
@transaction.atomic
def sponsor_scanning(request, sponsorid):
    sponsor, is_admin = _get_sponsor_and_admin(sponsorid, request, False)

    if not sponsor.conference.askbadgescan:
        return HttpResponse("Badge scanning questions are not enabled on this conference", status=403)

    if not SponsorClaimedBenefit.objects.filter(sponsor=sponsor,
                                                benefit__benefit_class=get_benefit_id('badgescanning.BadgeScanning'),
                                                declined=False,
                                                confirmed=True).exists():
        return HttpResponse("Badge scanning not a claimed benefit for this sponsor", status=403)

    if request.method == 'POST':
        if request.POST.get('what', '') == 'add':
            if not request.POST.get('email', ''):
                messages.warning(request, "Cannot add empty address")
                return HttpResponseRedirect(".")
            try:
                reg = ConferenceRegistration.objects.get(conference=sponsor.conference, email=request.POST.get('email').lower())
                if not reg.payconfirmedat:
                    messages.error(request, "Attendee is not confirmed")
                    return HttpResponseRedirect(".")
                if reg.canceledat:
                    messages.error(request, "Attendee registration is canceled")
                    return HttpResponseRedirect(".")
                if sponsor.sponsorscanner_set.filter(scanner=reg).exists():
                    messages.warning(request, "Attendee already registered as a scanner")
                    return HttpResponseRedirect(".")
                if reg.attendee is None:
                    messages.warning(request, "Attendee does not have a connected account and must connect one before they can become a scanner")
                    return HttpResponseRedirect(".")
                scanner = SponsorScanner(sponsor=sponsor, scanner=reg, token=generate_random_token())
                scanner.save()
                sponsor.sponsorscanner_set.add(scanner)
                return HttpResponseRedirect(".")
            except ConferenceRegistration.DoesNotExist:
                messages.error(request, "Attendee not found")
                return HttpResponseRedirect(".")
        elif request.POST.get('what', '') == 'del':
            # There should only be one remove-<something>
            for k in request.POST.keys():
                if k.startswith('remove-'):
                    rid = k[len('remove-'):]
                    try:
                        scanner = SponsorScanner.objects.get(sponsor=sponsor, pk=rid)
                        n = scanner.scanner.fullname
                        if scanner.scanner.scanned_attendees.filter(sponsor=sponsor).exists():
                            messages.warning(request, "Attendee {0} has scanned badges already, cannot be removed".format(n))
                        else:
                            scanner.delete()
                            messages.info(request, "Attendee {0} removed from scanning".format(n))
                    except SponsorScanner.DoesNotExist:
                        messages.error(request, "Attendee not found")
                    return HttpResponseRedirect(".")
                elif k.startswith('email-'):
                    rid = k[len('email-'):]
                    try:
                        scanner = SponsorScanner.objects.get(sponsor=sponsor, pk=rid)
                        send_conference_mail(
                            sponsor.conference,
                            scanner.scanner.email,
                            "Attendee badge scanning",
                            "confsponsor/mail/badge_scanning_intro.txt",
                            {
                                'conference': sponsor.conference,
                                'sponsor': sponsor,
                                'scanner': scanner,
                            },
                            sender=sponsor.conference.sponsoraddr,
                            receivername=scanner.scanner.fullname,
                        )
                        messages.info(request, "Instructions email sent to {0}".format(scanner.scanner.fullname))
                    except SponsorScanner.DoesNotExist:
                        messages.error(request, "Attendee not found")
                    return HttpResponseRedirect(".")
            else:
                messages.error(request, "Invalid form submit")
                return HttpResponseRedirect(".")
        elif request.POST.get('what', '') == 'delscan':
            # There should only be one delete-scan-<id>
            for k in request.POST.keys():
                if k.startswith('delete-scan-'):
                    scid = int(k[len('delete-scan-'):])
                    try:
                        scan = ScannedAttendee.objects.get(sponsor=sponsor, pk=scid)
                        scan.delete()
                    except ScannedAttendee.DoesNotExist:
                        messages.error(request, "Scan has already been removed or permission denied")
                    break
            else:
                messages.error(request, "Invalid form submit")
            return HttpResponseRedirect(".")
        else:
            # Unknown form, so just return
            return HttpResponseRedirect(".")

    scanned = ScannedAttendee.objects.select_related('attendee', 'scannedby', 'attendee__country').filter(sponsor=sponsor)

    return render(request, 'confsponsor/sponsor_scanning.html', {
        'scanners': sponsor.sponsorscanner_set.all(),
        'scanned': scanned,
    })


def sponsor_scanning_download(request, sponsorid):
    sponsor, is_admin = _get_sponsor_and_admin(sponsorid, request, False)

    if not sponsor.conference.askbadgescan:
        return HttpResponse("Badge scanning questions are not enabled on this conference", status=403)

    if not SponsorClaimedBenefit.objects.filter(sponsor=sponsor,
                                                benefit__benefit_class=get_benefit_id('badgescanning.BadgeScanning'),
                                                declined=False,
                                                confirmed=True).exists():
        return HttpResponse("Badge scanning not a claimed benefit for this sponsor", status=403)

    scanned = ScannedAttendee.objects.select_related('attendee', 'scannedby', 'attendee__country').filter(sponsor=sponsor)

    response = HttpResponse(content_type='text/csv; charset=utf8')
    response['Content-Disposition'] = 'attachment;filename=scanned_users.csv'
    c = csv.writer(response)
    c.writerow(['Attendee name', 'Attendee country', 'Attendee company', 'Attendee email', 'Scanned at', 'Scanned by', 'Scan note'])
    for s in scanned:
        c.writerow([s.attendee.fullname, s.attendee.country, s.attendee.company, s.attendee.email, s.scannedat, s.scannedby.fullname, s.note])

    return response


# Render the scanning app from the scanner token
def _sponsor_scanning_page(request, scanner, extra=None):
    c = {
        'scanner': scanner,
        'sponsor': scanner.sponsor,
        'conference': scanner.sponsor.conference,
        'title': 'badge scan',
        'doing': 'Scan badge',
        'scanwhat': 'badge',
        'scannertype': 'Sponsor',
        'storebutton': 'Scan',
        'expectedtype': 'at',
        'hasnote': True,
        'scanfields': [
            ["name", "Name"],
            ["company", "Company"],
            ["country", "Country"],
            ["email", "E-mail"],
        ],
        'tokentype': 'at',
    }
    if extra:
        c.update(extra)

    return render(request, 'confreg/scanner_app.html', c)


def scanning_page(request, scannertoken):
    try:
        scanner = SponsorScanner.objects.select_related('sponsor', 'sponsor__conference').get(token=scannertoken)
    except SponsorScanner.DoesNotExist:
        raise Http404("Not found")

    return _sponsor_scanning_page(request, scanner)


# Landing page with instructions for how to start the scanning app
@login_required
def landing(request, urlname):
    conference = get_conference_or_404(urlname)
    try:
        reg = ConferenceRegistration.objects.get(conference=conference, attendee=request.user, payconfirmedat__isnull=False, canceledat__isnull=True)
    except ConferenceRegistration.DoesNotExist:
        raise Http404("You are not registered for this conference")

    # If we have a token, use that to identify which sponsor is being represented.
    # If we don't have a token, get all scanner setups for this user. If that is just
    # one sponsor, send the user directly there. If it's >1, show the page to select
    # which sponsor to represent, which will redirect to a token-included URL.
    scanners = SponsorScanner.objects.filter(sponsor__conference=conference, scanner=reg)
    if 'token' in request.GET:
        scanners = scanners.filter(token=request.GET['token'])

    scanners = list(scanners)
    if len(scanners) == 0:
        raise Http404("You are not registered as a scanner for any sponsor at this conference")
    elif len(scanners) > 1:
        return render_conference_response(request, conference, 'reg', 'confsponsor/scanner_selectsponsor.html', {
            'conference': conference,
            'scanners': scanners,
        })
    else:
        scanner = scanners[0]

    link = '{}/events/sponsor/scanning/{}/'.format(settings.SITEBASE, scanner.token)
    return render_conference_response(request, conference, 'reg', 'confsponsor/scanner_landing.html', {
        'conference': conference,
        'sponsor': scanner.sponsor,
        'scannerlink': link,
        'qrlink': generate_base64_qr(link, 5, 200),
        'qrtest': generate_base64_qr('{}/t/at/TESTTESTTESTTEST/'.format(settings.SITEBASE), 2, 150),
    })


# Called from confreg/checkin.py when directly scanning a token without using the app
class SponsorScannerHandler:
    def __init__(self, sponsorscanner):
        self.scanner = sponsorscanner

    def launch(self, request, scanned_token):
        return _sponsor_scanning_page(request, self.scanner, {
            'singletoken': scanned_token,
            'basehref': '{}/events/sponsor/scanning/{}/'.format(settings.SITEBASE, self.scanner.token),
        })

    @property
    def title(self):
        return 'Sponsor: {}'.format(self.scanner.sponsor.displayname)


def _json_response(reg, status, existingnote='', message=''):
    return HttpResponse(json.dumps({
        'reg': {
            'name': reg.fullname,
            'company': reg.company,
            'country': reg.country and reg.country.printable_name or '',
            'email': reg.email,
            'note': existingnote,
            'token': reg.publictoken,
        },
        'message': message,
        'showfields': False,
    }), content_type='application/json', status=status)


_tokenmatcher = re.compile('^{}/t/at/([^/]+)/$'.format(settings.SITEBASE))


def _get_scanned_attendee(sponsor, token):
    try:
        attendee = ConferenceRegistration.objects.get(conference=sponsor.conference, publictoken=token)
    except ConferenceRegistration.DoesNotExist:
        return HttpResponse("Attendee not found", status=404)

    if not attendee.badgescan:
        return HttpResponse("Attendee has not authorized badge scanning", status=403)

    if attendee.canceledat:
        return HttpResponse("Attendee registration is canceled", status=403)

    return attendee


@csrf_exempt
@global_login_exempt
def scanning_api(request, scannertoken, what):
    try:
        scanner = SponsorScanner.objects.select_related('sponsor', 'sponsor__conference', 'scanner', 'scanner__attendee').get(token=scannertoken)
    except SponsorScanner.DoesNotExist:
        raise Http404("Not found")

    sponsor = scanner.sponsor

    if request.method in ('GET', 'POST'):
        if what == 'status':
            # Request for status is handled separately, everything else is a scan
            return HttpResponse(json.dumps({
                'scanner': scanner.scanner.attendee.username,
                'name': '{} for {}'.format(scanner.scanner.fullname, scanner.sponsor.displayname),
                'sponsorname': scanner.sponsor.displayname,
                'confname': scanner.sponsor.conference.conferencename,
                'active': True,  # As soon as badges are available they can be scanned.
                'admin': False,  # There are no "admins" in badge scanning
                'activestatus': '',
            }), content_type='application/json')
        elif what == 'lookup':
            token = request.GET.get('lookup')
            m = _tokenmatcher.match(token)
            if m:
                token = m.group(1)
            else:
                raise Http404()

            with transaction.atomic():
                r = _get_scanned_attendee(sponsor, token)
                if isinstance(r, HttpResponse):
                    return r

                # Mark the badge as scanned already on search. The POST later can change the note,
                # but we record it regardless
                scan, created = ScannedAttendee.objects.get_or_create(sponsor=sponsor, scannedby=scanner.scanner, attendee=r)

                if not created and scan.firstscan:
                    # An already existing entry which was flagged as first. That likely means that someone forgot the "save" button on the previous
                    # scan. So we set it to no-longer-first, so we get the update information on the next try.
                    scan.firstscan = False
                    scan.save(update_fields=['firstscan'])

                return _json_response(r, 200, scan.note, 'Attendee {} scan stored successfully.'.format(r.fullname))
        elif request.method == 'POST' and what == 'store':
            with transaction.atomic():
                # Accept both full URL version of token and just the key part
                m = _tokenmatcher.match(request.POST['token'])
                if m:
                    token = m.group(1)
                else:
                    token = request.POST['token']
                r = _get_scanned_attendee(sponsor, token)
                if isinstance(r, HttpResponse):
                    return r

                scan, created = ScannedAttendee.objects.get_or_create(sponsor=sponsor, scannedby=scanner.scanner, attendee=r, defaults={'note': request.POST.get('note')})
                if created:
                    # This would normally never happen anymore as we create the record on search. Only if someone deletes it in between.
                    return _json_response(attendee, 201)
                else:
                    update = []
                    isfirst = scan.firstscan

                    if scan.note != request.POST.get('note'):
                        scan.note = request.POST.get('note')
                        update.append('note')

                    if scan.firstscan:
                        scan.firstscan = False
                        update.append('firstscan')
                    if update:
                        scan.save(update_fields=update)
                    return _json_response(
                        r,
                        201 if isfirst else 208,
                        scan.note,
                        'Attendee {} scan stored successfully.'.format(r.fullname) if isfirst else 'Attendee {} has already been stored.{}'.format(
                            r.fullname,
                            'The note has been updated.' if 'note' in update else '',
                        ),
                    )
        else:
            raise Http404()
    else:
        return HttpResponse("Invalid method", status=400)


def admin_scan_status(request, confurlname):
    conference = get_authenticated_conference(request, confurlname)

    if not conference.askbadgescan:
        return HttpResponse("Badge scanning not active")

    uniquebysponsor = exec_to_keyed_scalar("""
SELECT
  sp.id AS sponsorid,
  count(DISTINCT sa.attendee_id) AS num
FROM confsponsor_sponsorscanner sc
INNER JOIN confsponsor_sponsor sp ON sc.sponsor_id=sp.id
LEFT JOIN confsponsor_scannedattendee sa ON sa.sponsor_id=sp.id
WHERE sp.conference_id=%(confid)s
GROUP BY sp.id""", {
        'confid': conference.id,
    })

    uniquebyscanner = exec_to_dict("""
SELECT
  sp.id AS sponsorid,
  sp.name AS sponsorname,
  r.email,
  count(DISTINCT sa.attendee_id) AS num
FROM confsponsor_sponsorscanner sc
INNER JOIN confsponsor_sponsor sp ON sc.sponsor_id=sp.id
INNER JOIN confsponsor_sponsorshiplevel l ON sp.level_id=l.id
INNER JOIN confreg_conferenceregistration r ON r.id=sc.scanner_id
LEFT JOIN confsponsor_scannedattendee sa ON sa.sponsor_id=sp.id AND sa.scannedby_id=r.id
WHERE sp.conference_id=%(confid)s
GROUP BY sp.id, sp.name, l.id, r.email
ORDER BY l.levelcost DESC, l.levelname, sp.name, r.email
""", {
        'confid': conference.id,
    })

    return render(request, 'confsponsor/admin_scanstatus.html', {
        'conference': conference,
        'uniquebysponsor': uniquebysponsor,
        'scans': uniquebyscanner,
        'breadcrumbs': (('/events/sponsor/admin/{0}/'.format(conference.urlname), 'Sponsors'),),
    })