From 836223395ef1e728040b3e2b6dbcdfc18abf7d44 Mon Sep 17 00:00:00 2001 From: Davide Alberani Date: Sun, 10 Sep 2017 16:06:32 +0200 Subject: [PATCH] fixes #58: import attendees using Eventbrite's API --- README.md | 2 +- angular_app/import-persons.html | 3 +- angular_app/js/controllers.js | 10 ++- angular_app/js/services.js | 6 +- docs/DEVELOPMENT.md | 3 +- docs/Eventbrite_import.md | 24 +++++++ eventman_server.py | 51 +++++++++++--- static/i18n/it_IT.json | 2 +- tools/qrcode_reader.ini | 7 +- utils.py | 116 ++++++++++++++++++++++++++++++++ 10 files changed, 200 insertions(+), 24 deletions(-) create mode 100644 docs/Eventbrite_import.md diff --git a/README.md b/README.md index e90b215..dc77de6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Main features: - quickly mark a registered person as an attendee - easy way to add a new ticket, if it's already known from a previous event or if it's a completely new ticket - set maximum number of tickets and begin/end date for sales -- can import Eventbrite CSV export files +- can import from Eventbrite using API and CSV file; see the docs/Eventbrite\_import.md file - RESTful interface that can be called by third-party applications - ability to run triggers to respond to an event (e.g. when a person is marked as attending to an event) - can run on HTTPS diff --git a/angular_app/import-persons.html b/angular_app/import-persons.html index 6d1891f..bd71297 100644 --- a/angular_app/import-persons.html +++ b/angular_app/import-persons.html @@ -7,7 +7,7 @@
- +
@@ -16,6 +16,7 @@
{{'create a new event' | translate}} +
diff --git a/angular_app/js/controllers.js b/angular_app/js/controllers.js index 713fda0..9544bb8 100644 --- a/angular_app/js/controllers.js +++ b/angular_app/js/controllers.js @@ -826,8 +826,8 @@ eventManControllers.controller('UsersCtrl', ['$scope', '$rootScope', '$state', ' ); -eventManControllers.controller('FileUploadCtrl', ['$scope', '$log', '$upload', 'EbAPI', 'Event', - function ($scope, $log, $upload, EbAPI, Event) { +eventManControllers.controller('FileUploadCtrl', ['$scope', '$log', '$upload', 'EbAPI', 'Event', 'toaster', + function ($scope, $log, $upload, EbAPI, Event, toaster) { $scope.file = null; $scope.progress = 0; $scope.progressbarType = 'warning'; @@ -848,7 +848,11 @@ eventManControllers.controller('FileUploadCtrl', ['$scope', '$log', '$upload', ' create: $scope.createNewEvent, eventID: $scope.ebEventID, targetEvent: $scope.targetEvent, - apiKey: $scope.ebAPIkey + oauthToken: $scope.ebAPIkey + }, function(data) { + console.log(data); + toaster.pop({type: 'info', title: 'imported tickets', + body: 'total: ' + data.total + ' errors: ' + (data.total - data.valid)}) }); }; diff --git a/angular_app/js/services.js b/angular_app/js/services.js index a51e11f..ffa7ef8 100644 --- a/angular_app/js/services.js +++ b/angular_app/js/services.js @@ -203,11 +203,7 @@ eventManServices.factory('EbAPI', ['$resource', '$rootScope', interceptor: {responseError: $rootScope.errorHandler}, isArray: false, transformResponse: function(data, headers) { - data = angular.fromJson(data); - if (data.error) { - return data; - } - return data.info || {}; + return angular.fromJson(data); } } }); diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index b41b854..0d31f0d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -58,7 +58,8 @@ The paths used to communicate with the Tornado web server: - /users/:user\_id PUT - update an existing user - /settings GET - settings to customize the GUI (logo, extra columns for events and tickets lists) - /info GET - information about the current user -- /ebcsvpersons POST - csv file upload to import persons +- /ebcsvpersons POST - csv file upload to import tickets +- /ebapi POST - import tickets (and optionally a complete event) using Eventbrite API - /login POST - log a user in - /logout GET - when visited, the user is logged out diff --git a/docs/Eventbrite_import.md b/docs/Eventbrite_import.md new file mode 100644 index 0000000..a06936f --- /dev/null +++ b/docs/Eventbrite_import.md @@ -0,0 +1,24 @@ +# Eventbrite import + +It's possible to import attendees from Eventbrite using both an exported CSV file or the Eventbrite's API. + +## API import + +On the Evenbrite site, go to the "Account Settings" -> "Developer" -> "App management" page. + +Create a new app. Click on the "Show Client Secret and OAuth Token" link and copy "Your personal OAuth token". + +Now go to the Eventbrite web page of your event, and copy the "eid" field of the URL. + +In the EventMan "Import tickets" page, set the copied OAuth token and Event ID; it's also possible to select an existing event that will receive the new attendees, or create a brand new event with the information from Eventbrite. + + +## CSV import + +On the Evenbrite site, go to the "manage" page of your event and then go to the "Manage Attendees" -> "Orders" page. + +From there, use the "Export to CSV" feature to get the CSV file. + +Load this file in the EventMan "Import tickets" page, select an existing event that will receive the new attendees and click the "Import" button. + +If you select this import method, please beware that you probably also want to edit the tools/qrcode_reader.ini configuration file (see the comment about the limit\_field setting). diff --git a/eventman_server.py b/eventman_server.py index 16096cc..d6fe8fb 100755 --- a/eventman_server.py +++ b/eventman_server.py @@ -436,8 +436,11 @@ class CollectionHandler(BaseHandler): @gen.coroutine @authenticated - def post(self, id_=None, resource=None, resource_id=None, **kwargs): - data = escape.json_decode(self.request.body or '{}') + def post(self, id_=None, resource=None, resource_id=None, _rawData=None, **kwargs): + if _rawData: + data = _rawData + else: + data = escape.json_decode(self.request.body or '{}') self._clean_dict(data) method = self.request.method.lower() crud_method = 'create' if method == 'post' else 'update' @@ -768,7 +771,7 @@ class EventsHandler(CollectionHandler): if now > end_datetime: raise InputException('ticket sales has ended') - def handle_post_tickets(self, id_, resource_id, data): + def handle_post_tickets(self, id_, resource_id, data, _skipTriggers=False): event = self.db.query('events', {'_id': id_})[0] self._check_sales_datetime(event) self._check_number_of_tickets(event) @@ -784,7 +787,7 @@ class EventsHandler(CollectionHandler): {'tickets': data}, operation='appendUnique', create=False) - if doc: + if doc and not _skipTriggers: self.send_ws_message('event/%s/tickets/updates' % id_, json.dumps(ret)) ticket = self._get_ticket_data(ticket_id, doc.get('tickets') or []) env = dict(ticket) @@ -1000,12 +1003,41 @@ class EbAPIImportHandler(BaseHandler): @gen.coroutine @authenticated def post(self, *args, **kwargs): - reply = {} + reply = dict(total=0, valid=0, merged=0, new_in_event=0) data = escape.json_decode(self.request.body or '{}') - apiKey = data.get('apiKey') - targetEvent = data.get('targetEvent') + oauthToken = data.get('oauthToken') eventID = data.get('eventID') - create = data.get('create') + targetEventID = data.get('targetEvent') + create = True + try: + create = self.tobool(data.get('create')) + except: + pass + try: + eb_info = utils.ebAPIFetch(oauthToken=oauthToken, eventID=eventID) + except Exception as e: + return self.build_error('Error using Eventbrite API: %s' % e) + if 'event' not in eb_info or 'attendees' not in eb_info: + return self.build_error('Missing information from Eventbrite API') + event_handler = EventsHandler(self.application, self.request) + event_handler.db = self.db + event_handler.logger = self.logger + event_handler.authentication = self.authentication + if create: + event_handler.post(_rawData=eb_info['event']) + event_in_db = self.db.query('events', {'eb_event_id': eb_info['event']['eb_event_id']}) + if not event_in_db: + return self.build_error('Unable to create a new event') + targetEventID = event_in_db[0]['_id'] + for ticket in eb_info.get('attendees') or []: + reply['total'] += 1 + ticket['event_id'] = targetEventID + try: + event_handler.handle_post_tickets(targetEventID, None, ticket, _skipTriggers=True) + except Exception as e: + self.logger.warn('failed to add a ticket: ' % e) + reply['new_in_event'] += 1 + reply['valid'] += 1 self.write(reply) @@ -1067,7 +1099,6 @@ class EbCSVImportPersonsHandler(BaseHandler): if not event_details: return self.build_error('invalid event') all_persons = set() - #[x.get('email') for x in (event_details[0].get('tickets') or []) if x.get('email')]) for ticket in (event_details[0].get('tickets') or []): all_persons.add('%s_%s_%s' % (ticket.get('name'), ticket.get('surname'), ticket.get('email'))) for fieldname, contents in self.request.files.items(): @@ -1088,7 +1119,7 @@ class EbCSVImportPersonsHandler(BaseHandler): if duplicate_check in all_persons: continue all_persons.add(duplicate_check) - event_handler.handle_post_tickets(event_id, None, person) + event_handler.handle_post_tickets(event_id, None, person, _skipTriggers=True) reply['new_in_event'] += 1 self.write(reply) diff --git a/static/i18n/it_IT.json b/static/i18n/it_IT.json index 8ea8997..53e4df1 100644 --- a/static/i18n/it_IT.json +++ b/static/i18n/it_IT.json @@ -59,7 +59,7 @@ "Delete event": "Cancella l'evento", "Delete all tickets in event": "Cancella tutti i ticket dell'evento", "Import tickets with Eventbrite API": "Importa i biglietti tramite le API di Eventbrite", - "API key": "API key", + "OAuth token": "OAuth token", "Eventbrite Event ID": "Eventbrite Event ID", "create a new event": "crea un nuovo evento", "Associate tickets to this event": "Associa i biglietti a questo evento", diff --git a/tools/qrcode_reader.ini b/tools/qrcode_reader.ini index a541eaa..b30cd12 100644 --- a/tools/qrcode_reader.ini +++ b/tools/qrcode_reader.ini @@ -13,11 +13,14 @@ ca = # in the 'event' section you have to specify the ID of the event, # the name of the field used to search for tickets and - optionally - # the number of chars in the field value that will be considered -# for the match (limit_field) +# for the match (limit_field). +# Leave limit_field commented if you're managing data imported +# using the Eventbrite API; if the data was imported from an +# Eventbrite CSV, set it to 9. [event] id = 1492099112_2612922-3896-9zwsccuvguz91jtw9y6lwvkud11ba7wt field = order_nr -limit_field = 9 +#limit_field = 9 # the 'actions' section key: value pairs are used in the PUT method. [actions] diff --git a/utils.py b/utils.py index 49f29df..05d3e23 100644 --- a/utils.py +++ b/utils.py @@ -18,6 +18,7 @@ limitations under the License. """ import csv +import copy import json import string import random @@ -96,6 +97,121 @@ def hash_password(password, salt=None): return '$%s$%s' % (salt, hash_.hexdigest()) +has_eventbrite_sdk = False +try: + from eventbrite import Eventbrite + has_eventbrite_sdk = True +except ImportError: + Eventbrite = object + + +class CustomEventbrite(Eventbrite): + """Custom methods to override official SDK limitations; code take from Yuval Hager; see: + https://github.com/eventbrite/eventbrite-sdk-python/issues/18 + + This class should be removed onces the official SDK supports pagination. + """ + def custom_get_event_attendees(self, event_id, status=None, changed_since=None, page=1): + data = {} + if status: + data['status'] = status + if changed_since: + data['changed_since'] = changed_since + data['page'] = page + return self.get("/events/{0}/attendees/".format(event_id), data=data) + + def get_all_event_attendees(self, event_id, status=None, changed_since=None): + page = 1 + attendees = [] + while True: + r = self.custom_get_event_attendees(event_id, status, changed_since, page=page) + attendees.extend(r['attendees']) + if r['pagination']['page_count'] <= page: + break + page += 1 + return attendees + + +KEYS_REMAP = { + ('capacity', 'number_of_tickets'), + ('changed', 'updated_at', lambda x: x.replace('T', ' ').replace('Z', '')), + ('created', 'created_at', lambda x: x.replace('T', ' ').replace('Z', '')), + ('description', 'description', lambda x: x.get('text', '')) +} + +def reworkObj(obj, kind='event'): + """Rename and fix some key in the data from the Eventbrite API.""" + for remap in KEYS_REMAP: + transf = lambda x: x + if len(remap) == 2: + old, new = remap + else: + old, new, transf = remap + if old in obj: + obj[new] = transf(obj[old]) + if old != new: + del obj[old] + if kind == 'event': + if 'name' in obj: + obj['title'] = obj.get('name', {}).get('text') or '' + del obj['name'] + if 'start' in obj: + t = obj['start'].get('utc') or '' + obj['begin_date'] = obj['begin_time'] = t.replace('T', ' ').replace('Z', '') + if 'end' in obj: + t = obj['end'].get('utc') or '' + obj['end_date'] = obj['end_time'] = t.replace('T', ' ').replace('Z', '') + else: + profile = obj.get('profile') or {} + complete_name = profile['name'] + obj['surname'] = profile.get('last_name') or '' + obj['name'] = profile.get('first_name') or '' + if not (obj['surname'] and obj['name']): + obj['surname'] = complete_name + obj['email'] = profile.get('email') or '' + return obj + + +def expandBarcodes(attendees): + """Generate an attendee for each barcode in the Eventbrite API data.""" + for attendee in attendees: + barcodes = attendee.get('barcodes') or [] + if not barcodes: + yield attendee + barcodes = [b.get('barcode') for b in barcodes if b.get('barcode')] + for code in barcodes: + att_copy = copy.deepcopy(attendee) + att_copy['order_nr'] = code + yield att_copy + + +def ebAPIFetch(oauthToken, eventID): + """Fetch an event, complete with all attendees using Eventbrite API. + + :param oauthToken: Eventbrite API key + :type oauthToken: str + :param eventID: Eventbrite ID of the even to be fetched + :type eventID: str + + :returns: information about the event and all its attendees + :rtype: dict + """ + if not has_eventbrite_sdk: + raise Exception('unable to import eventbrite module') + eb = CustomEventbrite(oauthToken) + event = eb.get_event(eventID) + eb_attendees = eb.get_all_event_attendees(eventID) + event = reworkObj(event, kind='event') + attendees = [] + for eb_attendee in eb_attendees: + reworkObj(eb_attendee, kind='attendee') + attendees.append(eb_attendee) + event['eb_event_id'] = eventID + attendees = list(expandBarcodes(attendees)) + info = {'event': event, 'attendees': attendees} + return info + + class ImprovedEncoder(json.JSONEncoder): """Enhance the default JSON encoder to serialize datetime and ObjectId instances.""" def default(self, o):