Browse Source

fixes #58: import attendees using Eventbrite's API

Davide Alberani 4 years ago
parent
commit
836223395e

+ 1 - 1
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

+ 2 - 1
angular_app/import-persons.html

@@ -7,7 +7,7 @@
         <div class="panel-body">
             <form name="ebAPIForm" class="well">
                 <div class="form-group">
-                    <label for="eb-api-key">{{'API key' | translate}}</label>
+                    <label for="eb-api-key">{{'OAuth token' | translate}}</label>
                     <input ng-model="ebAPIkey" id="eb-api-key" type="password" ng-required="true">
                 </div>
                 <div class="form-group">
@@ -16,6 +16,7 @@
                 </div>
 
                 <input type="checkbox" ng-model="createNewEvent"> {{'create a new event' | translate}}
+                <br />
 
                 <div class="form-group" ng-disabled="createNewEvent">
                     <label for="forEvent">{{'Associate tickets to this event' | translate}}</label>

+ 7 - 3
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)})
             });
         };
 

+ 1 - 5
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);
                 }
             }
         });

+ 2 - 1
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
 

+ 24 - 0
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).

+ 41 - 10
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)
 

+ 1 - 1
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",

+ 5 - 2
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]

+ 116 - 0
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):