Browse Source

fixes #144: begin and end time for ticket sales

Davide Alberani 7 years ago
parent
commit
f87f87607a
7 changed files with 169 additions and 45 deletions
  1. 2 0
      README.md
  2. 45 13
      angular_app/event-edit.html
  3. 15 4
      angular_app/events-list.html
  4. 6 6
      angular_app/js/services.js
  5. 9 4
      docs/DEVELOPMENT.md
  6. 83 18
      eventman_server.py
  7. 9 0
      utils.py

+ 2 - 0
README.md

@@ -12,6 +12,7 @@ Main features:
 - no registration is required to join/leave an event
 - 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
 - RESTful interface that can be called by third-party applications (see the https://github.com/raspibo/event_man/ repository for a simple script that checks people in using a barcode/QR-code reader)
 - ability to run triggers to respond to an event (e.g. when a person is marked as attending to an event)
@@ -50,6 +51,7 @@ Be sure to have a running MongoDB server, locally. If you want to install the de
     sudo python get-pip.py
     sudo pip install tornado # version 4.2 or later
     sudo pip install pymongo # version 3.2.2 or later
+    sudo pip install python-dateutil
     sudo pip install pycups # only needed if you want to print labels
     git clone https://github.com/raspibo/eventman
     cd eventman

+ 45 - 13
angular_app/event-edit.html

@@ -9,7 +9,7 @@
                     <span class="fa fa-ticket vcenter"></span>
                     {{'Tickets' | translate}}
                 </button>
-                &nbsp;<button ng-if="event._id && hasPermission('event:tickets-all|create')" ng-click="$state.go('event.ticket.new', {id: event._id})" class="btn btn-success">
+                &nbsp;<button ng-if="event._id && hasPermission('event:tickets-all|create')" ng-click="$state.go('event.ticket.new', {id: event._id})" ng-class="{btn: true, 'btn-success': true, disabled: event.no_tickets_for_sale}">
                     <span class="fa fa-user-plus vcenter"></span>
                     {{'Join this event' | translate}}
                 </button>
@@ -44,33 +44,33 @@
 
                     <div class="input-group top5 well form-horizontal" ng-controller="DatetimePickerCtrl">
                         <div class="form-group">
-                            <label for="begin-date" class="col-sm-3 control-label">{{'begin date:' | translate}}</label>
-                            <div id="begin-date" class="input-group col-sm-9">
-                                <input type="text" class="form-control" datepicker-popup="dd-MMMM-yyyy" ng-model="event['begin-date']" is-open="opened" />
+                            <label for="begin-date" class="col-sm-6 control-label">{{'begin date:' | translate}}</label>
+                            <div id="begin-date" class="input-group col-sm-6">
+                                <input type="text" class="form-control" datepicker-popup="dd-MMMM-yyyy" ng-model="event.begin_date" is-open="opened" />
                                 <span class="input-group-btn">
                                     <button type="button" class="btn btn-default" ng-click="open($event)"><i class="fa fa-calendar"></i></button>
                                 </span>
                             </div>
                         </div>
                         <div class="form-group">
-                            <label for="begin-time" class="col-sm-3 control-label">{{'begin time:' | translate}}</label>
-                            <timepicker id="begin-time" class="input-group" ng-model="event['begin-time']" show-meridian="false"></timepicker>
+                            <label for="begin-time" class="col-sm-6 control-label">{{'begin time:' | translate}}</label>
+                            <timepicker id="begin-time" class="input-group" ng-model="event.begin_time" show-meridian="false"></timepicker>
                         </div>
                     </div>
 
                     <div class="input-group top5 well form-horizontal" ng-controller="DatetimePickerCtrl">
                         <div class="form-group">
-                            <label for="end-date" class="col-sm-3 control-label">{{'End date:' | translate}}</label>
-                            <div id="end-date" class="input-group col-sm-9">
-                                <input type="text" class="form-control" datepicker-popup="dd-MMMM-yyyy" ng-model="event['end-date']" is-open="opened" />
+                            <label for="end-date" class="col-sm-6 control-label">{{'end date:' | translate}}</label>
+                            <div id="end-date" class="input-group col-sm-6">
+                                <input type="text" class="form-control" datepicker-popup="dd-MMMM-yyyy" ng-model="event.end_date" is-open="opened" />
                                 <span class="input-group-btn">
                                     <button type="button" class="btn btn-default" ng-click="open($event)"><i class="fa fa-calendar"></i></button>
                                 </span>
                             </div>
                         </div>
                         <div class="form-group">
-                            <label for="end-time" class="col-sm-3 control-label">{{'End time:' | translate}}</label>
-                            <timepicker id="end-time" class="input-group" ng-model="event['end-time']" show-meridian="false"></timepicker>
+                            <label for="end-time" class="col-sm-6 control-label">{{'end time:' | translate}}</label>
+                            <timepicker id="end-time" class="input-group" ng-model="event.end_time" show-meridian="false"></timepicker>
                         </div>
                     </div>
 
@@ -83,15 +83,47 @@
                         <input type="text" class="form-control" placeholder="{{'Used to share persons amongst multiple events. Must be hard to guess (if empty, will be autogenerated)' | translate}}" ng-model="event.group_id">
                     </div>
 
-                    <div class="panel panel-default table-striped top5">
+                    <div class="panel panel-default table-striped top30">
                         <div class="panel-heading">
-                            <h1>{{'Ticket limits'}}</h1>
+                            <h3>{{'Ticket limits'}}</h3>
                         </div>
                         <div class="panel-body">
                             <div class="input-group input-group top5">
                                 <span class="input-group-addon min100">{{'Number of tickets' | translate}}</span>
                                 <input type="number" min="0" class="form-control" placeholder="{{'Number of tickets (0 or empty means unlimited)' | translate}}" ng-model="event.number_of_tickets">
                             </div>
+
+                            <div class="input-group top5 well form-horizontal" ng-controller="DatetimePickerCtrl">
+                                <div class="form-group">
+                                    <label for="sales-begin-date" class="col-sm-6 control-label">{{'ticket sales begin date:' | translate}}</label>
+                                    <div id="sales-begin-date" class="input-group col-sm-6">
+                                        <input type="text" class="form-control" datepicker-popup="dd-MMMM-yyyy" ng-model="event.ticket_sales_begin_date" is-open="opened" />
+                                        <span class="input-group-btn">
+                                            <button type="button" class="btn btn-default" ng-click="open($event)"><i class="fa fa-calendar"></i></button>
+                                        </span>
+                                    </div>
+                                </div>
+                                <div class="form-group">
+                                    <label for="sales-begin-time" class="col-sm-6 control-label">{{'ticket sales begin time:' | translate}}</label>
+                                    <timepicker id="sales-begin-time" class="input-group" ng-model="event.ticket_sales_begin_time" show-meridian="false"></timepicker>
+                                </div>
+                            </div>
+
+                            <div class="input-group top5 well form-horizontal" ng-controller="DatetimePickerCtrl">
+                                <div class="form-group">
+                                    <label for="sales-end-date" class="col-sm-6 control-label">{{'ticket sales end date:' | translate}}</label>
+                                    <div id="sales-end-date" class="input-group col-sm-6">
+                                        <input type="text" class="form-control" datepicker-popup="dd-MMMM-yyyy" ng-model="event.ticket_sales_end_date" is-open="opened" />
+                                        <span class="input-group-btn">
+                                            <button type="button" class="btn btn-default" ng-click="open($event)"><i class="fa fa-calendar"></i></button>
+                                        </span>
+                                    </div>
+                                </div>
+                                <div class="form-group">
+                                    <label for="sales-end-time" class="col-sm-6 control-label">{{'ticket sales end time:' | translate}}</label>
+                                    <timepicker id="sales-end-time" class="input-group" ng-model="event.ticket_sales_end_time" show-meridian="false"></timepicker>
+                                </div>
+                            </div>
                         </div>
                     </div>
 

+ 15 - 4
angular_app/events-list.html

@@ -47,19 +47,30 @@
                             </span>
                             <div ng-if="event.tagline"><em>{{event.tagline}}</em></div>
                             <div ng-if="event.summary"><em>{{event.summary}}</em></div>
-                            <div ng-if="event['begin-date'] || event['end-date'] || event.where" class="top5">
+                            <div ng-if="event.begin_date || event.end_date || event.where" class="top5">
                                 <div ng-if="event.where">{{event.where}}</div>
-                                <span ng-if="event['begin-date']">{{'Begins:' | translate}} {{event['begin-date'] | date:'fullDate'}} {{event['begin-time'] | date:'HH:mm'}}<br/></span>
-                                <span ng-if="event['end-date']">{{'Ends:' | translate}} {{event['end-date'] | date:'fullDate' }} {{event['end-time'] | date:'HH:mm'}}</span>
+                                <span ng-if="event.begin_date">{{'Begins:' | translate}} {{event.begin_date | date:'fullDate'}} {{event.begin_time | date:'HH:mm'}}<br/></span>
+                                <span ng-if="event.end_date">{{'Ends:' | translate}} {{event.end_date | date:'fullDate' }} {{event.end_time | date:'HH:mm'}}</span>
                             </div>
                         </td>
                         <td ng-if="hasPermission('event:tickets-all|read')" class="hcenter">
                             <p><span ng-init="attendeesNr = ((event.tickets || []) | attendeesFilter).length">{{attendeesNr}}</span> / {{event.tickets_sold || 0}} ({{((attendeesNr / (event.tickets_sold || 0) * 100) || 0).toFixed()}}%)</p>
                         </td>
                         <td>
-                            <div ng-if="hasPermission('event:tickets-all|create')" class="top5 hcenter"><button ng-click="$state.go('event.ticket.new', {id: event._id})" class="min150 btn btn-success" type="button" title="{{'Join this event' | translate}}"><span class="fa fa-user-plus vcenter"></span> {{'Join this event' | translate}}</button></div>
+                            <div ng-if="hasPermission('event:tickets-all|create')" class="top5 hcenter"><button ng-click="$state.go('event.ticket.new', {id: event._id})" ng-class="{min150: true, btn: true, 'btn-success': true, disabled: event.no_tickets_for_sale}" type="button" title="{{'Join this event' | translate}}"><span class="fa fa-user-plus vcenter"></span> {{'Join this event' | translate}}</button></div>
                             <div ng-if="hasPermission('ticket|update')" class="top5 hcenter"><button ng-click="$state.go('event.tickets', {id: event._id})" class="min150 btn btn-primary" type="button" title="{{'Manage tickets' | translate}}"><span class="fa fa-ticket"></span> {{'Manage tickets' | translate}}</button></div>
                             <div class="top5 hcenter" ng-if="event.number_of_tickets">{{event.number_of_tickets}} {{'tickets' | translate}}, {{event.number_of_tickets - (event.tickets_sold || 0)}} {{'still available' | translate}}</div>
+                            <div class="top5 hcenter" ng-if="event.ticket_sales_begin_date || event.ticket_sales_end_date || event.ticket_sales_begin_time || event.ticket_sales_end_time">
+                                <strong>{{'Tickets for sale:' | translate}}</strong>
+                                <span ng-if="event.ticket_sales_begin_date || event.ticket_sales_begin_time">
+                                    <br />
+                                    {{'from' | translate}}<span ng-if="event.ticket_sales_begin_date"> {{event.ticket_sales_begin_date | date:'fullDate'}}</span><span ng-if="event.ticket_sales_begin_time"> {{event.ticket_sales_begin_time | date:'HH:mm'}}</span>
+                                </span>
+                                <span ng-if="event.ticket_sales_end_date || event.ticket_sales_end_time">
+                                    <br />
+                                    {{'until' | translate}}<span ng-if="event.ticket_sales_end_date"> {{event.ticket_sales_end_date | date:'fullDate'}}</span><span ng-if="event.ticket_sales_end_time"> {{event.ticket_sales_end_time | date:'HH:mm'}}</span>
+                                </span>
+                            </div>
                         </td>
                         <td ng-if="hasPermission('event|update') || hasPermission('event|delete')">
                             <div ng-if="hasPermission('event|update')" class="top5 hcenter"><button ng-click="$state.go('event.edit', {id: event._id})" type="button" class="min150 btn btn-warning" title="{{'Edit event' | translate}}"><span class="fa fa-cog"></span> {{'Edit event' | translate}}</button></div>

+ 6 - 6
angular_app/js/services.js

@@ -6,12 +6,12 @@ var eventManServices = angular.module('eventManServices', ['ngResource']);
 
 /* Modify, in place, an object to convert datetime. */
 function convert_dates(obj) {
-    if (obj['begin-date']) {
-        obj['begin-date'] = obj['begin_date'] = obj['begin-date'].getTime();
-    }
-    if (obj['end-date']) {
-        obj['end-date'] = obj['end_date'] = obj['end-date'].getTime();
-    }
+    angular.forEach(['begin_date', 'end_date', 'ticket_sales_begin_date', 'ticket_sales_end_date'], function(key, key_idx) {
+        if (!obj[key]) {
+            return;
+        }
+        obj[key] = obj[key].getTime();
+    });
     return obj;
 }
 

+ 9 - 4
docs/DEVELOPMENT.md

@@ -133,14 +133,19 @@ Stores information about events and tickets.
 Main field:
 
 - title
-- begin-data
-- begin-time
-- end-date
-- end-time
+- begin\_date
+- begin\_time
+- end\_date
+- end\_time
 - summary
 - description
 - where
 - group\_id
+- number\_of\_tickets
+- ticket\_sales\_begin\_date
+- ticket\_sales\_begin\_time
+- ticket\_sales\_end\_date
+- ticket\_sales\_end\_time
 - tickets - a list of information about tickets (each entry is a ticket)
   - tickets.$.\_id
   - tickets.$.ticket\_id

+ 83 - 18
eventman_server.py

@@ -26,6 +26,8 @@ import string
 import random
 import logging
 import datetime
+import dateutil.tz
+import dateutil.parser
 
 import tornado.httpserver
 import tornado.ioloop
@@ -589,19 +591,26 @@ class EventsHandler(CollectionHandler):
     document = 'event'
     collection = 'events'
 
-    def filter_get(self, output):
-        if 'tickets' in output:
-            output['tickets_sold'] = len([t for t in output['tickets'] if not t.get('cancelled')])
+    def _mangle_event(self, event):
+        # Some in-place changes to an event
+        if 'tickets' in event:
+            event['tickets_sold'] = len([t for t in event['tickets'] if not t.get('cancelled')])
+            event['no_tickets_for_sale'] = False
+            try:
+                self._check_sales_datetime(event)
+                self._check_number_of_tickets(event)
+            except InputException:
+                event['no_tickets_for_sale'] = True
             if not self.has_permission('tickets-all|read'):
-                output['tickets'] = []
-        return output
+                event['tickets'] = []
+        return event
+
+    def filter_get(self, output):
+        return self._mangle_event(output)
 
     def filter_get_all(self, output):
         for event in output.get('events') or []:
-            if 'tickets' in event:
-                event['tickets_sold'] = len([t for t in event['tickets'] if not t.get('cancelled')])
-                if not self.has_permission('tickets-all|read'):
-                    event['tickets'] = []
+            self._mangle_event(event)
         return output
 
     def filter_input_post(self, data):
@@ -661,13 +670,70 @@ class EventsHandler(CollectionHandler):
         tickets = self._filter_results(event.get('tickets') or [], self.arguments)
         return {'tickets': tickets}
 
+    def _check_number_of_tickets(self, event):
+        if self.has_permission('admin|all'):
+            return
+        number_of_tickets = event.get('number_of_tickets')
+        if number_of_tickets is None:
+            return
+        try:
+            number_of_tickets = int(number_of_tickets)
+        except ValueError:
+            return
+        tickets = event.get('tickets') or []
+        tickets = [t for t in tickets if not t.get('cancelled')]
+        if len(tickets) >= event['number_of_tickets']:
+            raise InputException('no more tickets available')
+
+    def _check_sales_datetime(self, event):
+        if self.has_permission('admin|all'):
+            return
+        begin_date = event.get('ticket_sales_begin_date')
+        begin_time = event.get('ticket_sales_begin_time')
+        end_date = event.get('ticket_sales_end_date')
+        end_time = event.get('ticket_sales_end_time')
+        utc = dateutil.tz.tzutc()
+        is_dst = time.daylight and time.localtime().tm_isdst > 0
+        utc_offset = - (time.altzone if is_dst else time.timezone)
+        if begin_date is None:
+            begin_date = datetime.datetime.now(tz=utc).replace(hour=0, minute=0, second=0, microsecond=0)
+        else:
+            begin_date = dateutil.parser.parse(begin_date)
+            # Compensate UTC and DST offset, that otherwise would be added 2 times (one for date, one for time)
+            begin_date = begin_date + datetime.timedelta(seconds=utc_offset)
+        if begin_time is None:
+            begin_time_h = 0
+            begin_time_m = 0
+        else:
+            begin_time = dateutil.parser.parse(begin_time)
+            begin_time_h = begin_time.hour
+            begin_time_m = begin_time.minute
+        now = datetime.datetime.now(tz=utc)
+        begin_datetime = begin_date + datetime.timedelta(hours=begin_time_h, minutes=begin_time_m)
+        if now < begin_datetime:
+            raise InputException('ticket sales not yet started')
+
+        if end_date is None:
+            end_date = datetime.datetime.today().replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=utc)
+        else:
+            end_date = dateutil.parser.parse(end_date)
+            end_date = end_date + datetime.timedelta(seconds=utc_offset)
+        if end_time is None:
+            end_time = end_date
+            end_time_h = 23
+            end_time_m = 59
+        else:
+            end_time = dateutil.parser.parse(end_time, yearfirst=True)
+            end_time_h = end_time.hour
+            end_time_m = end_time.minute
+        end_datetime = end_date + datetime.timedelta(hours=end_time_h, minutes=end_time_m+1)
+        if now > end_datetime:
+            raise InputException('ticket sales has ended')
+
     def handle_post_tickets(self, id_, resource_id, data):
         event = self.db.query('events', {'_id': id_})[0]
-        if 'number_of_tickets' in event:
-            tickets = event.get('tickets') or []
-            tickets = [t for t in tickets if not t.get('cancelled')]
-            if len(tickets) >= event['number_of_tickets']:
-                raise InputException('no more tickets available')
+        self._check_sales_datetime(event)
+        self._check_number_of_tickets(event)
         uuid, arguments = self.uuid_arguments
         self._clean_dict(data)
         data['seq'] = self.get_next_seq('event_%s_tickets' % id_)
@@ -710,14 +776,13 @@ class EventsHandler(CollectionHandler):
             current_event = current_event[0]
         else:
             current_event = {}
+        self._check_sales_datetime(current_event)
         tickets = current_event.get('tickets') or []
         old_ticket_data = self._get_ticket_data(ticket_query, tickets)
 
-        # We updating the "cancelled" status of a ticket; check if we still have a ticket available
+        # We have changed the "cancelled" status of a ticket to False; check if we still have a ticket available
         if 'number_of_tickets' in current_event and old_ticket_data.get('cancelled') and not data.get('cancelled'):
-            active_tickets = [t for t in tickets if not t.get('cancelled')]
-            if len(active_tickets) >= current_event['number_of_tickets']:
-                raise InputException('no more tickets available')
+            self._check_number_of_tickets(current_event)
 
         merged, doc = self.db.update('events', query,
                 data, updateList='tickets', create=False)

+ 9 - 0
utils.py

@@ -77,6 +77,15 @@ def csvParse(csvStr, remap=None, merge=None):
 
 
 def hash_password(password, salt=None):
+    """Hash a password.
+
+    :param password: the cleartext password
+    :type password: str
+    :param salt: the optional salt (randomly generated, if None)
+    :type salt: str
+
+    :returns: the hashed password
+    :rtype: str"""
     if salt is None:
         salt_pool = string.ascii_letters + string.digits
         salt = ''.join(random.choice(salt_pool) for x in xrange(32))