From 3001324d5e9400e39dfe1a0ef087b43857422086 Mon Sep 17 00:00:00 2001 From: James Polley Date: Sat, 30 Sep 2017 01:49:37 +1000 Subject: [PATCH] git subrepo pull vendor/registrasion subrepo: subdir: "vendor/registrasion" merged: "3545a80" upstream: origin: "git@gitlab.com:tchaypo/registrasion.git" branch: "lca2018" commit: "3545a80" git-subrepo: version: "0.3.1" origin: "???" commit: "???" --- vendor/registrasion/.gitrepo | 4 +- .../registrasion/contrib/badger.py | 386 ++++++++++++++++++ .../registrasion/controllers/cart.py | 8 +- vendor/registrasion/registrasion/forms.py | 57 ++- .../registrasion/generate_badges.py | 1 + .../registrasion/models/people.py | 2 +- .../registrasion/reporting/reports.py | 10 + .../registrasion/reporting/views.py | 195 ++++++--- .../templatetags/registrasion_tags.py | 5 +- vendor/registrasion/registrasion/urls.py | 8 +- vendor/registrasion/registrasion/views.py | 166 +++++++- 11 files changed, 773 insertions(+), 69 deletions(-) create mode 100644 vendor/registrasion/registrasion/contrib/badger.py create mode 120000 vendor/registrasion/registrasion/generate_badges.py diff --git a/vendor/registrasion/.gitrepo b/vendor/registrasion/.gitrepo index c14eed14..0dfb8b08 100644 --- a/vendor/registrasion/.gitrepo +++ b/vendor/registrasion/.gitrepo @@ -6,6 +6,6 @@ [subrepo] remote = git@gitlab.com:tchaypo/registrasion.git branch = lca2018 - commit = 7cf314adae9f8de1519db63828f55a10aa09f0ee - parent = 7c5c6c02f20ddcbc6c54b3065311880fce586192 + commit = 3545a809e8e14014963c670709b6d0273c0e354a + parent = 19e4185cd9433c8f743c32dde8aee09455db3982 cmdver = 0.3.1 diff --git a/vendor/registrasion/registrasion/contrib/badger.py b/vendor/registrasion/registrasion/contrib/badger.py new file mode 100644 index 00000000..3049ccd2 --- /dev/null +++ b/vendor/registrasion/registrasion/contrib/badger.py @@ -0,0 +1,386 @@ +''' +Generate Conference Badges +========================== + +Nearly all of the code in this was written by Richard Jones for the 2016 conference. +That code relied on the user supplying the attendee data in a CSV file, which Richard's +code then processed. + +The main (and perhaps only real) difference, here, is that the attendee data are taken +directly from the database. No CSV file is required. + +This is now a library with functions / classes referenced by the generate_badges +management command, and by the tickets/badger and tickets/badge API functions. +''' +import sys +import os +import csv +from lxml import etree +import tempfile +from copy import deepcopy +import subprocess + +import pdb + +from django.core.management.base import BaseCommand + +from django.contrib.auth.models import User, Group +from pinaxcon.registrasion.models import AttendeeProfile +from registrasion.controllers.cart import CartController +from registrasion.controllers.invoice import InvoiceController +from registrasion.models import Voucher +from registrasion.models import Attendee +from registrasion.models import Product +from registrasion.models import Invoice +from symposion.speakers.models import Speaker + +# A few unicode encodings ... +GLYPH_PLUS = '+' +GLYPH_GLASS = u'\ue001' +GLYPH_DINNER = u'\ue179' +GLYPH_SPEAKER = u'\ue122' +GLYPH_SPRINTS = u'\ue254' +GLYPH_CROWN = u'\ue211' +GLYPH_SNOWMAN = u'\u2603' +GLYPH_STAR = u'\ue007' +GLYPH_FLASH = u'\ue162' +GLYPH_EDU = u'\ue233' + +# Some company names are too long to fit on the badge, so, we +# define abbreviations here. +overrides = { + "Optiver Pty. Ltd.": "Optiver", + "IRESS Market Tech": "IRESS", + "The Bureau of Meteorology": "BoM", + "Google Australia": "Google", + "Facebook Inc.": "Facebook", + "Rhapsody Solutions Pty Ltd": "Rhapsody Solutions", + "PivotNine Pty Ltd": "PivotNine", + "SEEK Ltd.": "SEEK", + "UNSW Australia": "UNSW", + "Dev Demand Co": "Dev Demand", + "Cascode Labs Pty Ltd": "Cascode Labs", + "CyberHound Pty Ltd": "CyberHound", + "Self employed Contractor": "", + "Data Processors Pty Lmt": "Data Processors", + "Bureau of Meterology": "BoM", + "Google Australia Pty Ltd": "Google", + # "NSW Rural Doctors Network": "", + "Sense of Security Pty Ltd": "Sense of Security", + "Hewlett Packard Enterprose": "HPE", + "Hewlett Packard Enterprise": "HPE", + "CISCO SYSTEMS INDIA PVT LTD": "CISCO", + "The University of Melbourne": "University of Melbourne", + "Peter MacCallum Cancer Centre": "Peter Mac", + "Commonwealth Bank of Australia": "CBA", + "VLSCI, University of Melbourne": "VLSCI", + "Australian Bureau of Meteorology": "BoM", + "Bureau of Meteorology": "BoM", + "Australian Synchrotron | ANSTO": "Australian Synchrotron", + "Bureau of Meteorology, Australia": "BoM", + "QUT Digital Media Research Centre": "QUT", + "Dyn - Dynamic Network Services Inc": "Dyn", + "The Australian National University": "ANU", + "Murdoch Childrens Research Institute": "MCRI", + "Centenary Institute, University of Sydney": "Centenary Institute", + "Synchrotron Light Source Australia Pty Ltd": "Australian Synchrotron", + "Australian Communication and Media Authority": "ACMA", + "Dept. of Education - Camden Haven High School": "Camden Haven High School", + "Australian Government - Bureau of Meteorology": "BoM", + "The Walter and Eliza Hall Institute of Medical Research": "WEHI", + "Dept. Parliamentary Services, Australian Parliamentary Library": "Dept. Parliamentary Services", +} + + +def text_size(text, prev=9999): + ''' + Calculate the length of a text string as it relates to font size. + ''' + n = len(text) + size = int(min(48, max(28, 28 + 30 * (1 - (n-8) / 11.)))) + return min(prev, size) + + +def set_text(soup, text_id, text, resize=None): + ''' + Set the text value of an element (via beautiful soup calls). + ''' + elem = soup.find(".//*[@id='%s']/{http://www.w3.org/2000/svg}tspan" % text_id) + if elem is None: + raise ValueError('could not find tag id=%s' % text_id) + elem.text = text + if resize: + style = elem.get('style') + elem.set('style', style.replace('font-size:60px', 'font-size:%dpx' % resize)) + + +def set_colour(soup, slice_id, colour): + ''' + Set colour of an element (using beautiful soup calls). + ''' + elem = soup.find(".//*[@id='%s']" % slice_id) + if elem is None: + raise ValueError('could not find tag id=%s' % slice_id) + style = elem.get('style') + elem.set('style', style.replace('fill:#316a9a', 'fill:#%s' % colour)) + +Volunteers = Group.objects.filter(name='Conference volunteers').first().user_set.all() +Organisers = Group.objects.filter(name='Conference organisers').first().user_set.all() + +def is_volunteer(attendee): + ''' + Returns True if attendee is in the Conference volunteers group. + False otherwise. + ''' + return attendee.user in Volunteers + +def is_organiser(attendee): + ''' + Returns True if attendee is in the Conference volunteers group. + False otherwise. + ''' + return attendee.user in Organisers + + +def svg_badge(soup, data, n): + ''' + Do the actual "heavy lifting" to create the badge SVG + ''' + + # Python2/3 compat ... + try: + xx = filter(None, [1, 2, None, 3])[2] + filter_None = lambda lst: filter(None, lst) + except (TypeError,): + filter_None = lambda lst: list(filter(None, lst)) + + side = 'lr'[n] + for tb in 'tb': + part = tb + side + lines = [data['firstname'], data['lastname']] + if data['promote_company']: + lines.append(data['company']) + lines.extend([data['line1'], data['line2']]) + lines = filter_None(lines)[:4] + + lines.extend('' for n in range(4-len(lines))) + prev = 9999 + for m, line in enumerate(lines): + size = text_size(line, prev) + set_text(soup, 'line-%s-%s' % (part, m), line, size) + prev = size + + lines = [] + if data['organiser']: + lines.append('Organiser') + set_colour(soup, 'colour-' + part, '319a51') + elif data['volunteer']: + lines.append('Volunteer') + set_colour(soup, 'colour-' + part, '319a51') + if data['speaker']: + lines.append('Speaker') + + special = bool(lines) + + if 'Friday Only' in data['ticket']: + # lines.append('Friday Only') + set_colour(soup, 'colour-' + part, 'a83f3f') + + if 'Contributor' in data['ticket']: + lines.append('Contributor') + elif 'Professional' in data['ticket'] and not data['organiser']: + lines.append('Professional') + elif 'Sponsor' in data['ticket'] and not data['organiser']: + lines.append('Sponsor') + elif 'Enthusiast' in data['ticket'] and not data['organiser']: + lines.append('Enthusiast') + elif data['ticket'] == 'Speaker' and not data['speaker']: + lines.append('Speaker') + elif not special: + if data['ticket']: + lines.append(data['ticket']) + elif data['friday']: + lines.append('Friday Only') + set_colour(soup, 'colour-' + part, 'a83f3f') + else: + lines.append('Tutorial Only') + set_colour(soup, 'colour-' + part, 'a83f3f') + + if data['friday'] and data['ticket'] and not data['organiser']: + lines.append('Fri, Sat and Sun') + if not data['volunteer']: + set_colour(soup, 'colour-' + part, '71319a') + + if len(lines) > 3: + raise ValueError('lines = %s' % (lines,)) + + for n in range(3 - len(lines)): + lines.insert(0, '') + for m, line in enumerate(lines): + size = text_size(line) + set_text(soup, 'tags-%s-%s' % (part, m), line, size) + + icons = [] + if data['sprints']: + icons.append(GLYPH_SPRINTS) + if data['tutorial']: + icons.append(GLYPH_EDU) + + set_text(soup, 'icons-' + part, ' '.join(icons)) + set_text(soup, 'shirt-' + side, '; '.join(data['shirts'])) + set_text(soup, 'email-' + side, data['email']) + + +def collate(options): + # If specific usernames were given on the command line, just use those. + # Otherwise, use the entire list of attendees. + users = User.objects.filter(invoice__status=Invoice.STATUS_PAID) + if options['usernames']: + users = users.filter(username__in=options['usernames']) + + # Iterate through the attendee list to generate the badges. + for n, user in enumerate(users.distinct()): + ap = user.attendee.attendeeprofilebase.attendeeprofile + data = dict() + + at_nm = ap.name.split() + if at_nm[0].lower() in 'mr dr ms mrs miss'.split(): + at_nm[0] = at_nm[0] + ' ' + at_nm[1] + del at_nm[1] + if at_nm: + data['firstname'] = at_nm[0] + data['lastname'] = ' '.join(at_nm[1:]) + else: + print ("ERROR:", ap.attendee.user, 'has no name') + continue + + data['line1'] = ap.free_text_1 + data['line2'] = ap.free_text_2 + + data['email'] = user.email + data['over18'] = ap.of_legal_age + speaker = Speaker.objects.filter(user=user).first() + if speaker is None: + data['speaker'] = False + else: + data['speaker'] = bool(speaker.proposals.filter(result__status='accepted')) + + data['paid'] = data['friday'] = data['sprints'] = data['tutorial'] = False + data['shirts'] = [] + data['ticket'] = '' + + # look over all the invoices, yes + for inv in Invoice.objects.filter(user_id=ap.attendee.user.id): + if not inv.is_paid: + continue + cart = inv.cart + if cart is None: + continue + data['paid'] = True + if cart.productitem_set.filter(product__category__name__startswith="Specialist Day").exists(): + data['friday'] = True + if cart.productitem_set.filter(product__category__name__startswith="Sprint Ticket").exists(): + data['sprints'] = True + if cart.productitem_set.filter(product__category__name__contains="Tutorial").exists(): + data['tutorial'] = True + t = cart.productitem_set.filter(product__category__name__startswith="Conference Ticket") + if t.exists(): + product = t.first().product.name + if 'SOLD OUT' not in product: + data['ticket'] = product + elif cart.productitem_set.filter(product__category__name__contains="Specialist Day Only").exists(): + data['ticket'] = 'Specialist Day Only' + + data['shirts'].extend(ts.product.name for ts in cart.productitem_set.filter( + product__category__name__startswith="T-Shirt")) + + if not data['paid']: + print ("INFO:", ap.attendee.user, 'not paid!') + continue + + if not data['ticket'] and not (data['friday'] or data['tutorial']): + print ("ERROR:", ap.attendee.user, 'no conference ticket!') + continue + + data['company'] = overrides.get(ap.company, ap.company).strip() + + data['volunteer'] = is_volunteer(ap.attendee) + data['organiser'] = is_organiser(ap.attendee) + + if 'Specialist Day Only' in data['ticket']: + data['ticket'] = 'Friday Only' + + if 'Conference Organiser' in data['ticket']: + data['ticket'] = '' + + if 'Conference Volunteer' in data['ticket']: + data['ticket'] = '' + + data['promote_company'] = ( + data['organiser'] or data['volunteer'] or data['speaker'] or + 'Sponsor' in data['ticket'] or + 'Contributor' in data['ticket'] or + 'Professional' in data['ticket'] + ) + + yield data + + +def generate_stats(options): + stats = { + 'firstname': [], + 'lastname': [], + 'company': [], + } + for badge in collate(options): + stats['firstname'].append((len(badge['firstname']), badge['firstname'])) + stats['lastname'].append((len(badge['lastname']), badge['lastname'])) + if badge['promote_company']: + stats['company'].append((len(badge['company']), badge['company'])) + + stats['firstname'].sort() + stats['lastname'].sort() + stats['company'].sort() + + for l, s in stats['firstname']: + print ('%2d %s' % (l, s)) + for l, s in stats['lastname']: + print ('%2d %s' % (l, s)) + for l, s in stats['company']: + print ('%2d %s' % (l, s)) + + +def generate_badges(options): + names = list() + + orig = etree.parse(options['template']) + tree = deepcopy(orig) + root = tree.getroot() + + for n, data in enumerate(collate(options)): + svg_badge(root, data, n % 2) + if n % 2: + name = os.path.abspath( + os.path.join(options['out_dir'], 'badge-%d.svg' % n)) + tree.write(name) + names.append(name) + tree = deepcopy(orig) + root = tree.getroot() + + if not n % 2: + name = os.path.abspath( + os.path.join(options['out_dir'], 'badge-%d.svg' % n)) + tree.write(name) + names.append(name) + + return 0 + +class InvalidTicketChoiceError(Exception): + ''' + Exception thrown when they chosen ticket isn't valid. This + happens either if the ticket choice is 0 (default: Chose a ticket), + or is greater than the index if the last ticket choice in the + dropdown list. + ''' + def __init__(self, message="Please choose a VALID ticket."): + super(InvalidTicketChoiceError, self).__init__(message,) diff --git a/vendor/registrasion/registrasion/controllers/cart.py b/vendor/registrasion/registrasion/controllers/cart.py index bb772ce0..4bd416e9 100644 --- a/vendor/registrasion/registrasion/controllers/cart.py +++ b/vendor/registrasion/registrasion/controllers/cart.py @@ -9,7 +9,7 @@ import datetime import functools import itertools -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned from django.core.exceptions import ValidationError from django.db import transaction from django.db.models import Max @@ -64,6 +64,12 @@ class CartController(object): time_last_updated=timezone.now(), reservation_duration=datetime.timedelta(), ) + except MultipleObjectsReturned: + # Get the one that looks "newest". + existing = commerce.Cart.objects.filter( + user=user, + status=commerce.Cart.STATUS_ACTIVE, + ).order_by('-time_last_updated').first() return cls(existing) def _fail_if_cart_is_not_active(self): diff --git a/vendor/registrasion/registrasion/forms.py b/vendor/registrasion/registrasion/forms.py index 3e0beaaa..879d6d5c 100644 --- a/vendor/registrasion/registrasion/forms.py +++ b/vendor/registrasion/registrasion/forms.py @@ -263,7 +263,7 @@ class _CheckboxProductsForm(_ProductsForm): def set_fields(cls, category, products): for product in products: field = forms.BooleanField( - label='%s -- %s' % (product.name, product.price), + label='%s -- $%s' % (product.name, product.price), required=False, ) cls.base_fields[cls.field_name(product)] = field @@ -521,3 +521,58 @@ class InvoiceEmailForm(InvoicesWithProductAndStatusForm): choices=ACTION_CHOICES, initial=ACTION_PREVIEW, ) + + +from registrasion.contrib.badger import InvalidTicketChoiceError + +def ticket_selection(): + return list(enumerate(['!!! NOT A VALID TICKET !!!'] + \ + [p.name for p in inventory.Product.objects.\ + filter(category__name__contains="Ticket").\ + exclude(name__contains="Organiser").order_by('id')])) + + +class TicketSelectionField(forms.ChoiceField): + + def validate(self, value): + super(TicketSelectionField, self).validate(value) + + result = int(self.to_python(value)) + if result <= 0 or result > len(list(self.choices)): + raise InvalidTicketChoiceError() + + + +class BadgeForm(forms.Form): + ''' + A form for creating one-off badges at rego desk. + ''' + required_css_class = 'label-required' + + name = forms.CharField(label="Name", max_length=60, required=True) + email = forms.EmailField(label="Email", max_length=60, required=False) + company = forms.CharField(label="Company", max_length=60, required=False) + free_text_1 = forms.CharField(label="Free Text", max_length=60, required=False) + free_text_2 = forms.CharField(label="Free Text", max_length=60, required=False) + + ticket = TicketSelectionField(label="Select a Ticket", choices=ticket_selection) + + paid = forms.BooleanField(label="Paid", required=False) + over18 = forms.BooleanField(label="Over 18", required=False) + speaker = forms.BooleanField(label="Speaker", required=False) + tutorial = forms.BooleanField(label="Tutorial Ticket", required=False) + friday = forms.BooleanField(label="Specialist Day", required=False) + sprints = forms.BooleanField(label="Sprints", required=False) + + + def is_valid(self): + valid = super(BadgeForm, self).is_valid() + + if not valid: + return valid + + if self.data['ticket'] == '0': # Invalid ticket type! + self.add_error('ticket', 'Please select a VALID ticket type.') + return False + + return True diff --git a/vendor/registrasion/registrasion/generate_badges.py b/vendor/registrasion/registrasion/generate_badges.py new file mode 120000 index 00000000..cc490dd1 --- /dev/null +++ b/vendor/registrasion/registrasion/generate_badges.py @@ -0,0 +1 @@ +../../../website/pinaxcon/registrasion/management/commands/generate_badges.py \ No newline at end of file diff --git a/vendor/registrasion/registrasion/models/people.py b/vendor/registrasion/registrasion/models/people.py index 64d0ac40..561c3bb4 100644 --- a/vendor/registrasion/registrasion/models/people.py +++ b/vendor/registrasion/registrasion/models/people.py @@ -43,7 +43,7 @@ class Attendee(models.Model): db_index=True, ) completed_registration = models.BooleanField(default=False) - guided_categories_complete = models.ManyToManyField("category") + guided_categories_complete = models.ManyToManyField("category", blank=True) class AttendeeProfileBase(models.Model): diff --git a/vendor/registrasion/registrasion/reporting/reports.py b/vendor/registrasion/registrasion/reporting/reports.py index d58219f9..f63bacff 100644 --- a/vendor/registrasion/registrasion/reporting/reports.py +++ b/vendor/registrasion/registrasion/reporting/reports.py @@ -75,6 +75,8 @@ class _ReportTemplateWrapper(object): def rows(self): return self.report.rows(self.content_type) + def count(self): + return self.report.count() class BasicReport(Report): @@ -118,6 +120,9 @@ class ListReport(BasicReport): for i, cell in enumerate(row) ] + def count(self): + return len(self._data) + class QuerysetReport(BasicReport): @@ -158,6 +163,9 @@ class QuerysetReport(BasicReport): ] + def count(self): + return self._queryset.count() + class Links(Report): def __init__(self, title, links): @@ -182,6 +190,8 @@ class Links(Report): self._linked_text(content_type, url, link_text) ] + def count(self): + return len(self._links) def report_view(title, form_type=None): ''' Decorator that converts a report view function into something that diff --git a/vendor/registrasion/registrasion/reporting/views.py b/vendor/registrasion/registrasion/reporting/views.py index fbae7906..8c38351a 100644 --- a/vendor/registrasion/registrasion/reporting/views.py +++ b/vendor/registrasion/registrasion/reporting/views.py @@ -13,10 +13,12 @@ from django.db.models import F, Q from django.db.models import Count, Max, Sum from django.db.models import Case, When, Value from django.db.models.fields.related import RelatedField +from django.db.models.fields import CharField from django.shortcuts import render from registrasion.controllers.cart import CartController from registrasion.controllers.item import ItemController +from registrasion.models import conditions from registrasion.models import commerce from registrasion.models import people from registrasion import util @@ -30,6 +32,8 @@ from .reports import ListReport from .reports import QuerysetReport from .reports import report_view +import bleach + def CURRENCY(): return models.DecimalField(decimal_places=2) @@ -240,6 +244,83 @@ def group_by_cart_status(queryset, order, values): return values +@report_view("Limits") +def limits(request, form): + ''' Shows the summary of sales against stock limits. ''' + + line_items = commerce.ProductItem.objects.filter( + cart__status=commerce.Invoice.STATUS_PAID, + ).values( + "product", "product__name", + ).annotate( + total_quantity=Sum("quantity") + ) + + quantities = collections.defaultdict(int) + for line_item in line_items.all(): + quantities[line_item['product__name']] += line_item['total_quantity'] + + limits = conditions.TimeOrStockLimitFlag.objects.all().order_by("-limit") + + headings = ["Product", "Quantity"] + + reports = [] + for limit in limits: + data = [] + total = 0 + for product in limit.products.all(): + if product.name in quantities: + total += quantities[product.name] + data.append([product.name, quantities[product.name]]) + if limit.limit: + data.append(['(TOTAL)', '%s/%s' % (total, limit.limit)]) + else: + data.append(['(TOTAL)', total]) + + description = limit.description + extras = [] + if limit.start_time: + extras.append('Starts: %s' % (limit.start_time)) + + if limit.end_time: + extras.append('Ends: %s' % (limit.end_time)) + + if extras: + description += ' (' + ', '.join(extras) + ')' + + reports.append(ListReport(description, headings, data)) + + # now get discount items + discount_items = conditions.DiscountBase.objects.select_subclasses() + + data = [] + for discount in discount_items.all(): + quantity = 0 + for item in discount.discountitem_set.filter(cart__status=2): + quantity += item.quantity + + description = discount.description + extras = [] + if getattr(discount, 'start_time', None): + extras.append('Starts: %s' % (discount.start_time)) + + if getattr(discount, 'end_time', None): + extras.append('Ends: %s' % (discount.end_time)) + + if extras: + description += ' (' + ', '.join(extras) + ')' + + if getattr(discount, 'limit', None): + data.append([description, '%s/%s' % (quantity, discount.limit)]) + else: + data.append([description, quantity]) + + headings = ["Discount", "Quantity"] + reports.append(ListReport('Discounts', headings, data)) + + return reports + + @report_view("Product status", form_type=forms.ProductAndCategoryForm) def product_status(request, form): ''' Summarises the inventory status of the given items, grouping by @@ -329,7 +410,7 @@ def product_line_items(request, form): "user", "user__attendee", "user__attendee__attendeeprofilebase" - ).order_by("issue_time") + ).order_by("issue_time").distinct() headings = [ 'Invoice', 'Invoice Date', 'Attendee', 'Qty', 'Product', 'Status' @@ -386,7 +467,7 @@ def paid_invoices_by_date(request, form): ) # Zero-value invoices will have no payments, so they're paid at issue time - zero_value_invoices = invoices.filter(value=0) + zero_value_invoices = invoices.filter(value=0).distinct() times = itertools.chain( (line["max_time"] for line in invoice_max_time), @@ -464,18 +545,19 @@ def attendee(request, form, user_id=None): if user_id is None: return attendee_list(request) - attendee = people.Attendee.objects.get(user__id=user_id) - name = attendee.attendeeprofilebase.attendee_name() - reports = [] profile_data = [] try: + attendee = people.Attendee.objects.get(user__id=user_id) + name = attendee.attendeeprofilebase.attendee_name() + profile = people.AttendeeProfileBase.objects.get_subclass( attendee=attendee ) fields = profile._meta.get_fields() except people.AttendeeProfileBase.DoesNotExist: + name = attendee.user.username fields = [] exclude = set(["attendeeprofilebase_ptr", "id"]) @@ -489,11 +571,17 @@ def attendee(request, form, user_id=None): if isinstance(field, models.ManyToManyField): value = ", ".join(str(i) for i in value.all()) + elif isinstance(field, CharField): + value = bleach.clean(value) profile_data.append((field.verbose_name, value)) cart = CartController.for_user(attendee.user) - reservation = cart.cart.reservation_duration + cart.cart.time_last_updated + try: + reservation = cart.cart.reservation_duration + cart.cart.time_last_updated + except AttributeError: # No reservation_duration set -- default to 24h + reservation = datetime.datetime.now() + datetime.timedelta(hours=24) + profile_data.append(("Current cart reserved until", reservation)) reports.append(ListReport("Profile", ["", ""], profile_data)) @@ -515,53 +603,65 @@ def attendee(request, form, user_id=None): reports.append(Links("Actions for " + name, links)) # Paid and pending products - ic = ItemController(attendee.user) - reports.append(ListReport( - "Paid Products", - ["Product", "Quantity"], - [(pq.product, pq.quantity) for pq in ic.items_purchased()], - )) - reports.append(ListReport( - "Unpaid Products", - ["Product", "Quantity"], - [(pq.product, pq.quantity) for pq in ic.items_pending()], - )) + try: + ic = ItemController(attendee.user) + reports.append(ListReport( + "Paid Products", + ["Product", "Quantity"], + [(pq.product, pq.quantity) for pq in ic.items_purchased()], + )) + reports.append(ListReport( + "Unpaid Products", + ["Product", "Quantity"], + [(pq.product, pq.quantity) for pq in ic.items_pending()], + )) + except AttributeError: + pass # Invoices - invoices = commerce.Invoice.objects.filter( - user=attendee.user, - ) - reports.append(QuerysetReport( - "Invoices", - ["id", "get_status_display", "value"], - invoices, - headings=["Invoice ID", "Status", "Value"], - link_view=views.invoice, - )) + try: + invoices = commerce.Invoice.objects.filter( + user=attendee.user, + ) + reports.append(QuerysetReport( + "Invoices", + ["id", "get_status_display", "value"], + invoices, + headings=["Invoice ID", "Status", "Value"], + link_view=views.invoice, + )) + except AttrbuteError: + pass # Credit Notes - credit_notes = commerce.CreditNote.objects.filter( - invoice__user=attendee.user, - ).select_related("invoice", "creditnoteapplication", "creditnoterefund") + try: + credit_notes = commerce.CreditNote.objects.filter( + invoice__user=attendee.user, + ).select_related("invoice", "creditnoteapplication", "creditnoterefund") - reports.append(QuerysetReport( - "Credit Notes", - ["id", "status", "value"], - credit_notes, - link_view=views.credit_note, - )) + reports.append(QuerysetReport( + "Credit Notes", + ["id", "status", "value"], + credit_notes, + link_view=views.credit_note, + )) + except AttributeError: + pass # All payments - payments = commerce.PaymentBase.objects.filter( - invoice__user=attendee.user, - ).select_related("invoice") + try: + payments = commerce.PaymentBase.objects.filter( + invoice__user=attendee.user, + ).select_related("invoice") - reports.append(QuerysetReport( - "Payments", - ["invoice__id", "id", "reference", "amount"], - payments, - link_view=views.invoice, - )) + reports.append(QuerysetReport( + "Payments", + ["invoice__id", "id", "reference", "amount"], + payments, + link_view=views.invoice, + )) + except AttributeError: + pass return reports @@ -678,6 +778,7 @@ def attendee_data(request, form, user_id=None): category = product + "__category" category_name = category + "__name" + if by_category: grouping_fields = (category, category_name) order_by = (category, ) @@ -712,7 +813,7 @@ def attendee_data(request, form, user_id=None): return None else: def display_field(value): - return value + return bleach.clean(value) status_count = lambda status: Case(When( # noqa attendee__user__cart__status=status, @@ -759,7 +860,7 @@ def attendee_data(request, form, user_id=None): if isinstance(field_type, models.ManyToManyField): return [str(i) for i in attr.all()] or "" else: - return attr + return bleach.clean(attr) headings = ["User ID", "Name", "Email", "Product", "Item Status"] headings.extend(field_names) diff --git a/vendor/registrasion/registrasion/templatetags/registrasion_tags.py b/vendor/registrasion/registrasion/templatetags/registrasion_tags.py index e87be9e8..ccebe20b 100644 --- a/vendor/registrasion/registrasion/templatetags/registrasion_tags.py +++ b/vendor/registrasion/registrasion/templatetags/registrasion_tags.py @@ -10,6 +10,8 @@ try: except ImportError: from urllib.parse import urlencode +from operator import attrgetter + register = template.Library() @@ -46,8 +48,9 @@ def missing_categories(context): for product, quantity in items: categories_held.add(product.category) - return categories_available - categories_held + missing = categories_available - categories_held + return sorted(set(i for i in missing), key=attrgetter("order")) @register.assignment_tag(takes_context=True) def available_credit(context): diff --git a/vendor/registrasion/registrasion/urls.py b/vendor/registrasion/registrasion/urls.py index 6afdd5f0..72553877 100644 --- a/vendor/registrasion/registrasion/urls.py +++ b/vendor/registrasion/registrasion/urls.py @@ -6,7 +6,7 @@ from django.conf.urls import url from .views import ( amend_registration, badge, - badges, + badger, checkout, credit_note, edit_profile, @@ -26,7 +26,8 @@ from .views import ( public = [ url(r"^amend/([0-9]+)$", amend_registration, name="amend_registration"), url(r"^badge/([0-9]+)$", badge, name="badge"), - url(r"^badges$", badges, name="badges"), + url(r"^badger/([A-Za-z0-9]+)$", badger, name="badger"), + url(r"^badger/", badger, name="badger"), url(r"^category/([0-9]+)$", product_category, name="product_category"), url(r"^checkout$", checkout, name="checkout"), url(r"^checkout/([0-9]+)$", checkout, name="checkout"), @@ -42,9 +43,9 @@ public = [ name="invoice_access"), url(r"^invoice_mailout$", invoice_mailout, name="invoice_mailout"), url(r"^profile$", edit_profile, name="attendee_edit"), - url(r"^register$", guided_registration, name="guided_registration"), url(r"^review$", review, name="review"), url(r"^voucher$", voucher_code, name="voucher_code"), + url(r"^register$", guided_registration, name="guided_registration"), url(r"^register/([0-9]+)$", guided_registration, name="guided_registration"), ] @@ -56,6 +57,7 @@ reports = [ url(r"^attendee_data/?$", rv.attendee_data, name="attendee_data"), url(r"^attendee/([0-9]*)$", rv.attendee, name="attendee"), url(r"^credit_notes/?$", rv.credit_notes, name="credit_notes"), + url(r"^limits/?$", rv.limits, name="reconciliation"), url(r"^manifest/?$", rv.manifest, name="manifest"), url( r"^product_line_items/?$", diff --git a/vendor/registrasion/registrasion/views.py b/vendor/registrasion/registrasion/views.py index e16313f2..ac78d842 100644 --- a/vendor/registrasion/registrasion/views.py +++ b/vendor/registrasion/registrasion/views.py @@ -1,5 +1,6 @@ import datetime import zipfile +import os from . import forms from . import util @@ -32,6 +33,16 @@ from django.shortcuts import redirect from django.shortcuts import render from django.template import Context, Template, loader +from lxml import etree +from copy import deepcopy + +from registrasion.forms import BadgeForm, ticket_selection +from registrasion.contrib.badger import ( + collate, + svg_badge, + InvalidTicketChoiceError + ) + _GuidedRegistrationSection = namedtuple( "GuidedRegistrationSection", @@ -544,11 +555,13 @@ def _handle_products(request, category, products, prefix): # If category is required, the user must have at least one # in an active+valid cart if category.required: - carts = commerce.Cart.objects.filter(user=request.user) + carts = commerce.Cart.objects.filter(user=request.user, + status=commerce.Cart.STATUS_ACTIVE) items = commerce.ProductItem.objects.filter( product__category=category, - cart=carts, + cart=current_cart.cart, ) + if len(items) == 0: products_form.add_error( None, @@ -1076,24 +1089,42 @@ def invoice_mailout(request): return render(request, "registrasion/invoice_mailout.html", data) +def _get_badge_template_name(): + return os.path.join(settings.PROJECT_ROOT, 'pinaxcon', 'templates', + settings.BADGER_DEFAULT_SVG) @user_passes_test(_staff_only) def badge(request, user_id): - ''' Renders a single user's badge (SVG). ''' + ''' + Renders a single user's badge (SVG). + + This does little more than call Richard Jones' collate and svg_badge + functions found in generate_badges. + ''' user_id = int(user_id) user = User.objects.get(pk=user_id) - rendered = render_badge(user) - response = HttpResponse(rendered) + # This will fail spectacularly -- will put exception handling in later ... + user_data = list(collate({'usernames': [user.username]}))[0] + orig = etree.parse(_get_badge_template_name()) + tree = deepcopy(orig) + root = tree.getroot() + + svg_badge(root, user_data, 0) + + response = HttpResponse(etree.tostring(root)) response["Content-Type"] = "image/svg+xml" response["Content-Disposition"] = 'inline; filename="badge.svg"' return response def badges(request): - ''' Either displays a form containing a list of users with badges to + ''' + *** NOT USED FOR PYCONAU 2017 MELBOURNE (I.e., not supported in badger module.) *** + + Either displays a form containing a list of users with badges to render, or returns a .zip file containing their badges. ''' category = request.GET.getlist("category", []) @@ -1128,12 +1159,121 @@ def badges(request): return render(request, "registrasion/badges.html", data) -def render_badge(user): - ''' Renders a single user's badge. ''' +def collate_from_form(form): + ''' + Does what collate does, but using form data as its input source + rather than User record. + ''' + # Build the thing we'll pass to svg_badge() later. + data = dict() - data = { - "user": user, - } + # Get the name bits ... + at_nm = form.data['name'].split() + if at_nm[0].lower() in 'mr dr ms mrs miss'.split(): + at_nm[0] = at_nm[0] + ' ' + at_nm[1] + del at_nm[1] + if at_nm: + data['firstname'] = at_nm[0] + data['lastname'] = ''.join(at_nm[1:]) + else: # Can't happen -- form validator will check for this. + pass - t = loader.get_template('registrasion/badge.svg') - return t.render(data) + # Free text -- only one line ... come on! + data['line1'] = form.data['free_text_1'] + data['line2'] = form.data['free_text_2'] + + # Email ... + data['email'] = form.data['email'] + + # Don't think we want to allow ad hoc organiser tickets ... + data['organiser'] = False + + # Punt on shirts for now ... + data['shirts'] = list() + + # Lots booleans ... + for key in ['over18', 'paid', 'friday', 'speaker', 'tutorial', + 'sprints', 'company',]: + data[key] = form.data.get(key, False) + + # This will throw InvalidTicketChoiceError if the ticket + # choice isn't found in the ticket list or is the + # "Plese select a valid tickt" choice. (I.e., they forgot + # to choose a ticket.) + data['ticket'] = ticket_selection()[int(form.data['ticket'])][1] + + data['volunteer'] = data['ticket'].find("Volunteer") >= 0 + + if 'Specialist Day Only' in data['ticket']: + data['ticket'] = 'Friday Only' + data['friday'] = True + + if 'Conference Organiser' in data['ticket']: + data['ticket'] = '' + + if 'Conference Volunteer' in data['ticket']: + data['ticket'] = '' + + data['promote_company'] = ( + data['organiser'] or data['volunteer'] or data['speaker'] or + 'Sponsor' in data['ticket'] or + 'Contributor' in data['ticket'] or + 'Professional' in data['ticket'] + ) + + return data + + +@user_passes_test(_staff_only) +def badger(request, username=None): + ''' + Renders a single user's badge from data supplied on + a form rather than from Attendee data. + + If *username* is provided in the URL, an attempt + will be made to look up this user and fill in the + badge details from the User and Attendee records. + ''' + + if username is not None: + + # We have a username. Try to populate our badge data + # from User/Attendee model. + try: + data = collate({'usernames': [username,]}).next() + except: # No matching User record (probably) ... just put up a blank form + return render(request, settings.BADGER_DEFAULT_FORM, {'form': BadgeForm}) + else: + form = BadgeForm(request.POST) + + if len(form.data) == 0: # Empty or request to put up the form. + return render(request, settings.BADGER_DEFAULT_FORM, {'form': BadgeForm}) + + try: + if form.is_valid(): + data = collate_from_form(form) + + except InvalidTicketChoiceError: + form.add_error('ticket', 'Please select a VALID ticket type!') + return render(request, settings.BADGER_DEFAULT_FORM, {'form': form}) + + except TypeError as e: + form.add_error(e.message) + return render(request, settings.BADGER_DEFAULT_FORM, {'form': form}) + + + # We should have valid data if we get this far. + # Fill in the template and return the resulting SVG object. + orig = etree.parse(_get_badge_template_name()) + tree = deepcopy(orig) + root = tree.getroot() + + # Generate the badge (svg) + svg_badge(root, data, 0) + + # Ship it back to the user... + response = HttpResponse(etree.tostring(root)) + + response["Content-Type"] = "image/svg+xml" + response["Content-Disposition"] = 'inline; filename="badge.svg"' + return response