Fix datepicker arrows style on hover
[cds-indico.git] / indico / MaKaC / webinterface / rh / registration_stats.py
blob6729767bcbfe8dd4f8e1b31c2d520d0aba642e03
1 # This file is part of Indico.
2 # Copyright (C) 2002 - 2015 European Organization for Nuclear Research (CERN).
4 # Indico is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License as
6 # published by the Free Software Foundation; either version 3 of the
7 # License, or (at your option) any later version.
9 # Indico is distributed in the hope that it will be useful, but
10 # WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12 # General Public License for more details.
14 # You should have received a copy of the GNU General Public License
15 # along with Indico; if not, see <http://www.gnu.org/licenses/>.
17 from collections import defaultdict, namedtuple
18 from itertools import groupby
19 from operator import methodcaller
21 from indico.util.date_time import now_utc
22 from indico.util.i18n import _, get_current_locale
23 from MaKaC.webinterface.pages.registrants import WPRegistrationStats
24 from MaKaC.webinterface.rh.registrationFormModif import RHRegistrationFormModifBase
27 class RHRegistrationStats(RHRegistrationFormModifBase):
28 def _process(self):
29 stats = [
30 OverviewStats(self._conf),
31 AccommodationStats(self._conf),
32 SocialEventsStats(self._conf),
33 SessionStats(self._conf),
35 p = WPRegistrationStats(self, self._conf, stats=filter(None, stats))
36 return p.display()
39 class StatsBase(object):
40 def __init__(self, conf, title, headline, template, **kwargs):
41 """
42 Base class for statistics
44 Represents a stats "box" with a title, headline and some content derived
45 from a conference.
47 Each instance also holds the reference to the template used for
48 rendering. It should be a template extending
49 `events/registration/_stats_box.html`.
51 :param conf: Conference -- the conference object
52 :param title: str -- the title for the stats box
53 :param headline: str -- the headline for the stats box
54 :param template: str -- the Jinja template to render the stats box
55 """
56 super(StatsBase, self).__init__(**kwargs)
57 self._conf = conf
58 self.title = title
59 self.headline = headline
60 self.template = template
62 @property
63 def show_currency_info(self):
64 """
65 Whether to show a small label at the top right corner of the box,
66 indicating the currency used in the stats.
68 This should be overriden by children accordingly.
69 """
70 return False
72 @property
73 def registrants(self):
74 return self._conf.getRegistrantsList()
76 @property
77 def registration_form(self):
78 return self._conf.getRegistrationForm()
80 @property
81 def currency(self):
82 """
83 The currency used for payments by registrants to the conference.
85 Defaults to `CHF` if not set, which happens if e-payment was disabled
86 afterwards.
88 :returns: str -- the currency abbreviation
89 """
90 return self._conf.getRegistrationForm().getCurrency() or 'CHF'
93 class Cell(namedtuple('Cell', ['type', 'colspan', 'classes', 'qtip', 'data'])):
94 def __new__(cls, type=None, colspan=1, classes=None, qtip=None, data=None):
95 """
96 Holds the data and type of for a cell of a stats table.
98 Used by the `TableStats` class.
100 The format of the data depends on the value (`str`) passed to the type
101 argument.
102 The table below indicates the valid types and the format of the data
103 specific to the type.
105 +--------------------+-------------------------------------------------+
106 | type | data |
107 +====================+=================================================+
108 | `str` | `str` -- string value |
109 +--------------------+-------------------------------------------------+
110 | `progress` | `(int, str)` -- a tuple with the progress (a |
111 | | value between 0 and 1) and a label |
112 +--------------------+-------------------------------------------------+
113 | `progress-stacked` | `([int], str)` -- a tuple with a list of |
114 | | progresses (values which must sum up to 1) and |
115 | | a label |
116 +--------------------+-------------------------------------------------+
117 | `currency` | `float` -- numeric value |
118 +--------------------+-------------------------------------------------+
119 | `icon` | `str` -- icon name, must be a valid icon from |
120 | | `_icons.scss ` (without the `icon-` prefix) |
121 +--------------------+-------------------------------------------------+
122 | `default` / `None` | `None` -- renders a default cell with an |
123 | | `&mdash;` (use `Cell(type='str')` for an empty |
124 | | cell) |
125 +--------------------+-------------------------------------------------+
127 :param type: str -- The type of data in the cell
128 :param colspan: int -- HTML colspan value for the cell
129 :param classes: [str] -- HTML classes to apply to the cell
130 :param qtip: str -- string to set as value for the HTML `title` attribute,
131 used as content for qtip
132 :param data: The data for the cell
134 if classes is None:
135 classes = []
137 # Provide sensible data defaults for specific types
138 if data is None:
139 if type == 'str':
140 data = ''
141 elif type == 'progress-stacked':
142 data = [[0], '0']
143 elif type == 'progress':
144 data = (0, '0')
145 elif type == 'currency':
146 data = 0,
147 elif type == 'icon':
148 data = 'warning'
149 return super(Cell, cls).__new__(cls, type, colspan, classes, qtip, data)
151 def render(self, cell_macros):
153 Visitor-like function to render the cell content in Jinja.
155 The cell_macros is a mapping from the cell type to the macro rendering
158 try:
159 macro = cell_macros[self.type]
160 except KeyError:
161 macro = cell_macros['default']
162 return macro(self)
165 class DataItem(namedtuple('DataItem', ['regs', 'attendance', 'capacity', 'billable', 'cancelled', 'cancel_reason',
166 'price', 'fixed_price', 'paid', 'paid_amount', 'unpaid', 'unpaid_amount'])):
167 def __new__(cls, regs=0, attendance=0, capacity=0, billable=False, cancelled=False, cancel_reason='', price=0,
168 fixed_price=False, paid=0, paid_amount=0, unpaid=0, unpaid_amount=0):
170 Holds the aggregation of some data, intended for stats tables as a
171 aggregation from which to generate cells.
173 :param regs: int -- number of registrant
174 :param attendance: int -- number of people attending
175 :param capacity: int -- maximum number of people allowed to attend (`0`
176 if unlimited)
177 :param billable: bool -- whether the item is billable to the or not
178 :param cancelled: bool -- whether the item is canceled or not
179 :param cancel_reason: str -- reason the item has been canceled `None` if
180 it is not canceled)
181 :param price: str -- the price of the item
182 :param fixed_price: bool -- `True` if the price is per registrant,
183 `False` if accompanying guests must pay as well.
184 :param paid: int -- number of registrants who paid
185 :param paid_amount: float -- amount already paid by registrants
186 :param unpaid: int -- number of registrants who haven't paid
187 :param unpaid_amount: float -- amount not already paid by registrants
189 return super(DataItem, cls).__new__(cls, regs, attendance, capacity, billable, cancelled, cancel_reason, price,
190 fixed_price, paid, paid_amount, unpaid, unpaid_amount)
193 class TableStats(object):
194 def __init__(self, items, reg_items, **kwargs):
196 Generates a stats table.
198 Takes a list of `items` such as events, sessions, accommodations and so
199 on, as well as a list of registration objects (`reg_items`) for said
200 `items`.
202 super(TableStats, self).__init__(**kwargs)
203 self._items = {item.getId(): item for item in items}
204 self._reg_items = sorted(reg_items, key=self._key_fn)
205 for ritem in self._reg_items:
206 item = self._item_from_ritem(ritem)
207 self._items.setdefault(item.getId(), item)
208 self._data, self._show_billing_info = self._compute_data()
210 @property
211 def show_currency_info(self):
212 return self._show_billing_info
214 def _compute_data(self):
216 Aggregates and format data from `self._items` and `self._reg_items`
217 such that a table can be generated from it.
219 :returns: (dict, bool) -- the data and a boolean to indicate whether the
220 data contains billing information or not.
222 billed_items = {}
223 for k, ritems in groupby((ritem for ritem in self._reg_items if self._is_billable(ritem)), self._key_fn):
224 billed_items[k] = self._billed_data_from_ritems(k, list(ritems))
225 not_billed_items = {}
226 for k, ritems in groupby((ritem for ritem in self._reg_items if not self._is_billable(ritem)),
227 self._alt_key_fn):
228 not_billed_items[k] = self._not_billed_data_from_ritems(k, list(ritems))
229 # Add events with no registrants
230 for item in self._items.itervalues():
231 if self._is_billable(item):
232 billed_items.setdefault(self._key_fn(item), self._billed_data_from_item(item))
233 else:
234 not_billed_items.setdefault(self._alt_key_fn(item), self._not_billed_data_from_item(item))
236 data = defaultdict(list)
237 for key, item in billed_items.iteritems():
238 data[self._get_name_id_from_key(key)].append(item)
239 for key, item in not_billed_items.iteritems():
240 data[self._get_name_id_from_alt_key(key)].append(item)
242 return data, bool(billed_items)
244 def get_table(self):
246 Returns a table containing the stats for each item.
248 If an item has multiple aggregations (determined by the key functions
249 `self._key_fn` and `self._alt_key_fn`), this item will have multiple sub
250 rows, one for each aggregation of data and a header row which contains
251 an aggregation of the sub rows.
252 If an item has a single aggregation if will have a single row as a main
253 row.
255 :return: dict -- A table with a list of head cells (key: `'head'`) and
256 a list of rows (key: `'rows'`) where each row is a list of
257 cells.
259 table = defaultdict(list)
260 table['head'] = self._get_table_head()
262 for (name, id), item_details in sorted(self._data.iteritems()):
263 num_regs = sum(detail.regs for detail in item_details)
264 cancel_reason = self._cancel_reason_from_item(item_details)
266 # Header/Single row
267 table['rows'].append(('single-row' if len(item_details) == 1 else 'header-row',
268 self._get_main_row(item_details, name, num_regs, cancel_reason) +
269 self._get_billing(item_details)))
271 if len(item_details) == 1:
272 continue
274 # Optional sub-rows
275 table['rows'].extend(('sub-row', self._get_sub_row(detail, num_regs) + self._get_billing_details(detail))
276 for detail in item_details)
277 return table
279 def __nonzero__(self):
281 Used to indicate whether the stats should be displayed or not depending
282 if there is data or not.
284 return bool(self._data)
286 def _is_billable(self, item):
288 Convenience method to indicate if an item is billable.
290 This can be overridden by children.
292 :returns: bool -- `True` if the item is billable, false otherwise
294 return item.isBillable() and float(item.getPrice()) > 0
296 def _get_billing(self, item_details):
298 Returns the cells with billing information, in order, for a main row
299 (header or single row).
301 :params item_details: [DataItem] -- list of aggregation for which to
302 generate the cells.
303 :returns: [Cell] -- a list of cells with billing information in order.
305 # hide billing details if no session require payments
306 if not self._show_billing_info:
307 return []
309 # Only paid or unpaid accommodation so we move the sub row up as the
310 # header row.
311 if len(item_details) == 1:
312 return self._get_billing_details(item_details[0])
314 paid = sum(detail.paid for detail in item_details if detail.billable)
315 paid_amount = sum(detail.paid_amount for detail in item_details if detail.billable)
317 unpaid = sum(detail.unpaid for detail in item_details if detail.billable)
318 unpaid_amount = sum(detail.unpaid_amount for detail in item_details if detail.billable)
320 total = paid + unpaid
321 total_amount = paid_amount + unpaid_amount
323 progress = [[float(paid) / total, float(unpaid) / total], '{} / {}'.format(paid, total)] if total else None
325 return [Cell(), # no price for aggregation of details
326 Cell(type='progress-stacked', data=progress, classes=['paid-unpaid-progress']),
327 Cell(type='currency', data=paid_amount, classes=['paid-amount', 'stick-left']),
328 Cell(type='currency', data=unpaid_amount, classes=['unpaid-amount', 'stick-right']),
329 Cell(type='currency', data=total_amount)]
331 def _get_billing_details(self, detail):
333 Returns the cells with billing information, in order, for a sub row.
335 :params item_details: DataItem -- aggregation for which to generate the
336 cells.
337 :returns: [Cell] -- a list of cells with billing information in order.
339 if not self._show_billing_info: # hide billing details if no events require payments
340 return []
342 if not detail.billable:
343 return [Cell(type='currency', data=0),
344 Cell(), # mdash (default content) instead of a paid progress bar
345 Cell(type='currency', data=0, classes=['paid-amount', 'stick-left']),
346 Cell(type='currency', data=0, classes=['unpaid-amount', 'stick-right']),
347 Cell(type='currency', data=0)]
349 progress = [[float(detail.paid) / detail.regs, float(detail.unpaid) / detail.regs],
350 '{0.paid} / {0.regs}'.format(detail)] if detail.regs else None
352 return [
353 Cell(type='currency', data=float(detail.price)),
354 Cell(type='progress-stacked', data=progress, classes=['paid-unpaid-progress']),
355 Cell(type='currency', data=detail.paid_amount, classes=['paid-amount', 'stick-left']),
356 Cell(type='currency', data=detail.unpaid_amount, classes=['unpaid-amount', 'stick-right']),
357 Cell(type='currency', data=detail.paid_amount + detail.unpaid_amount)
360 def _item_from_ritem(self, ritem):
362 Returns an "item: of similar type as the items in `self._items` given an
363 "ritem" from `self._reg_items`.
365 Must be overridden by children accordingly based on items and reg items.
367 raise NotImplementedError
369 def _key_fn(self, item):
371 The key function used to sort and group by reg items.
373 It must include the name (title or caption) and the id of the item.
374 This should also include the price or other billing information by which
375 to aggregate.
377 :param item: the item from which to derive a key.
378 :returns: tuple -- tuple to be used as a key to sort and group items by.
380 raise NotImplementedError
382 def _alt_key_fn(self, item):
384 The alternate key function used to sort and group by reg items.
386 It must include the name (title or caption) and the id of the item.
387 This should explicitly not include the price or other billing
388 information in order to identify free reg items.
390 :param item: the item from which to derive a key.
391 :returns: tuple -- tuple to be used as a key to sort and group items by.
393 raise NotImplementedError
395 def _billed_data_from_ritems(self, key, ritems):
397 Returns a `DataItem` containing the aggregation of reg items (`ritems`)
398 which are billed to the registrants.
400 :param ritems: list -- list of reg items to be aggregated
401 :returns: DataItem -- the aggregation of the `ritems`
403 raise NotImplementedError
405 def _not_billed_data_from_ritems(self, key, ritems):
407 Returns a `DataItem` containing the aggregation of reg items (`ritems`)
408 which are not billed to the registrants.
410 :param ritems: list -- list of reg items to be aggregated
411 :returns: DataItem -- the aggregation of the `ritems`
413 raise NotImplementedError
415 def _billed_data_from_item(self, item):
417 Returns a `DataItem` containing the aggregation of an item which is
418 billed to the registrants.
420 :param item: list -- item to be aggregated
421 :returns: DataItem -- the aggregation of the `item`
423 raise NotImplementedError
425 def _not_billed_data_from_item(self, item):
427 Returns a `DataItem` containing the aggregation of an item which is not
428 billed to the registrants.
430 :param item: list -- item to be aggregated
431 :returns: DataItem -- the aggregation of the `item`
433 raise NotImplementedError
435 def _get_name_id_from_key(self, key):
437 Returns the name and id of an item from its key.
439 The key corresponds to output of `self._key_fn` for a given item.
441 :returns: (name, id) -- a tuple containing the name and id of item
443 raise NotImplementedError
445 def _get_name_id_from_alt_key(self, key):
447 Returns the name and id of an item from its key.
449 The key corresponds to output of `self._alt_key_fn` for a given item.
451 :returns: (name, id) -- a tuple containing the name and id of item
453 raise NotImplementedError
455 def _get_table_head(self):
457 Returns a list of `Cell` corresponding to the headers of a the table.
459 :returns: [Cell] -- the headers of the table.
461 raise NotImplementedError
463 def _cancel_reason_from_item(self, details):
465 Returns a cancel reason from a list of `DataItem`.
467 If any item in the list of `DataItem` has the cancelled flag set,
468 returns a first cancel_reason from the list of `DataItem`.
470 If no cancel reason is found, a default reason should be returned.
472 Returns `None` if nono of the `cancelled` flag are set.
474 :param details: [DataItem] -- a list of items w
475 :returns: str -- a cancel reason if an item has the `cancelled` flag
476 set, `None` otherwise.
478 raise NotImplementedError
480 def _get_main_row(self, item_details, item_name, num_regs, cancel_reason):
482 Returns the cells of the main (header or single) row of the table.
484 Each `item` has a main row. The row is a list of `Cell` which matches
485 the table head.
487 :param item_details: [DataItem] -- list of aggregations for the item
488 :param item_name: str -- the item's name
489 :param num_regs: int -- the number of registrants for the item
490 :param cancel_reason: str -- the cancel reason for this item if it is
491 canceled, `None` otherwise
493 :returns: [Cell] -- the list of cells constituting the row.
495 raise NotImplementedError
497 def _get_sub_row(self, details, num_regs):
499 Returns the cells of the sub row of the table.
501 An `item` cna have a sub row. The row is a list of `Cell` which matches
502 the table head.
504 :param details: DataItem -- aggregation for the item
505 :param num_regs: int -- the number of registrants for the item
507 :returns: [Cell] -- the list of cells constituting the row.
509 raise NotImplementedError
512 class OverviewStats(StatsBase):
513 def __init__(self, conf):
515 Generic stats showing an overview for the conference.
517 Shows the following:
518 - number of registrants
519 - days left to register
520 - number of countries of origins from registrants
521 - availability of the conference
522 - repartition of registrants per country of origin (for top 15
523 countries)
525 super(OverviewStats, self).__init__(conf=conf, title=_("Overview"), headline="", template='stats_overview.html')
526 self.registrants_sorted = sorted(self.registrants, key=methodcaller('getCountry')) # sort for groupby
527 self.countries, self.num_countries = self._compute_countries()
528 self.availability = self._compute_availibility()
529 self.days_left = max(0, (self.registration_form.getAllowedEndRegistrationDate() - now_utc()).days)
531 def _compute_countries(self):
532 locale = get_current_locale()
533 countries = defaultdict(int)
534 for country_code, regs in groupby(self.registrants_sorted, methodcaller('getCountry')):
535 country = locale.territories.get(country_code)
536 countries[country] += sum(1 for x in regs)
537 others = [countries.pop(None, 0), _("Others")]
538 if not countries: # no country data for any registrants
539 return [], 0
540 num_countries = len(countries)
541 # Sort by highest number of people per country then alphabetically per countries' name
542 countries = sorted(((val, name) for name, val in countries.iteritems()), key=lambda x: (-x[0], x[1]),
543 reverse=True)
544 others[0] += sum(val for val, name in countries[:-15])
545 return ([others] if others[0] else []) + countries[-15:], num_countries
547 def _compute_availibility(self):
548 users_limit = self.registration_form.getUsersLimit()
549 if not users_limit or self.registration_form.isFull():
550 return (0, 0, 0)
551 return (len(self.registrants), users_limit, float(len(self.registrants)) / users_limit)
553 def __nonzero__(self):
554 return True
557 class AccommodationStats(TableStats, StatsBase):
558 def __init__(self, conf):
559 super(AccommodationStats, self).__init__(
560 conf=conf, title=_("Accommodation"), headline="", template='stats_accomodation.html',
561 items=conf.getRegistrationForm().getAccommodationForm().getAccommodationTypesList(),
562 reg_items=(r.getAccommodation() for r in conf.getRegistrantsList()
563 if r.getAccommodation() is not None and r.getAccommodation().getAccommodationType() is not None)
565 self.has_capacity = any(detail.capacity for acco_details in self._data.itervalues()
566 for detail in acco_details if detail.capacity)
568 def _get_occupancy(self, acco_details):
569 if not self.has_capacity:
570 return []
571 capacity = max(d.capacity for d in acco_details)
572 if not capacity:
573 return [Cell()]
574 regs = sum(d.regs for d in acco_details)
575 return [Cell(type='progress', data=(float(regs) / capacity, '{} / {}'.format(regs, capacity)))]
577 def _get_occupancy_details(self, details):
578 if not self.has_capacity:
579 return []
580 if not details.capacity:
581 return [Cell()]
582 return [Cell(type='progress',
583 data=(float(details.regs) / details.capacity, '{0.regs} / {0.capacity}'.format(details)))]
585 def _item_from_ritem(self, reg_acco):
586 return reg_acco.getAccommodationType()
588 def _key_fn(self, acco):
589 return (acco.getCaption(), acco.getId(), acco.getPrice())
591 def _alt_key_fn(self, acco):
592 return (acco.getCaption(), acco.getId())
594 def _billed_data_from_ritems(self, key, acco_details):
595 name, id, price = key
596 return DataItem(
597 regs=len(acco_details),
598 capacity=next((detail.getAccommodationType().getPlacesLimit() for detail in acco_details), 0),
599 billable=True,
600 cancelled=any(detail.isCancelled() for detail in acco_details),
601 price=price,
602 paid=len([detail for detail in acco_details if detail.getRegistrant().getPayed()]),
603 paid_amount=sum(float(price) for detail in acco_details if detail.getRegistrant().getPayed()),
604 unpaid=len([detail for detail in acco_details if not detail.getRegistrant().getPayed()]),
605 unpaid_amount=sum(float(price) for detail in acco_details if not detail.getRegistrant().getPayed())
608 def _not_billed_data_from_ritems(self, key, acco_details):
609 name, id = key
610 return DataItem(
611 regs=len(acco_details),
612 capacity=next((a.getAccommodationType().getPlacesLimit() for a in acco_details), 0),
613 billable=False,
614 cancelled=any(a.isCancelled() for a in acco_details)
617 def _billed_data_from_item(self, acco):
618 return DataItem(
619 capacity=acco.getPlacesLimit(),
620 billable=True,
621 cancelled=acco.isCancelled(),
622 price=acco.getPrice()
625 def _not_billed_data_from_item(self, acco):
626 return DataItem(
627 capacity=acco.getPlacesLimit(),
628 billable=False,
629 cancelled=acco.isCancelled()
632 def _get_name_id_from_key(self, key):
633 return key[:2] # key = name, id, price, price_per_place
635 def _get_name_id_from_alt_key(self, key):
636 return key # key = name, id
638 def _get_table_head(self):
639 head = [Cell(type='str', data=_("Accomodation")), Cell(type='str', data=_("Registrants"))]
641 if self.has_capacity:
642 head.append(Cell(type='str', data=_("Occupancy")))
644 if self._show_billing_info:
645 head.extend([Cell(type='str', data=_("Price")),
646 Cell(type='str', data=_("Accommodations paid")),
647 Cell(type='str', data=_("Total paid (unpaid)"), colspan=2),
648 Cell(type='str', data=_("Total"))])
650 return head
652 def _cancel_reason_from_item(self, acco_details):
653 return _("Accommodation unavailable") if any(detail.cancelled for detail in acco_details) else None
655 def _get_main_row(self, acco_details, acco_name, num_regs, cancel_reason):
656 return [
657 Cell(type='str', data=' ' + acco_name, classes=['cancelled-item'] if cancel_reason else [],
658 qtip=cancel_reason),
659 Cell(type='progress', data=((float(num_regs) / len(self.registrants),
660 '{} / {}'.format(num_regs, len(self.registrants)))
661 if self.registrants else None))
662 ] + self._get_occupancy(acco_details)
664 def _get_sub_row(self, details, num_regs):
665 return [
666 Cell(type='str'),
667 Cell(type='progress', data=((float(details.regs) / num_regs, '{} / {}'.format(details.regs, num_regs))
668 if num_regs else None)),
669 ] + self._get_occupancy_details(details)
671 def __nonzero__(self):
672 return (self.registration_form.getAccommodationForm().isEnabled() and
673 super(AccommodationStats, self).__nonzero__())
676 class SessionStats(TableStats, StatsBase):
677 def __init__(self, conf):
678 super(SessionStats, self).__init__(
679 conf=conf, title=_("Sessions"), headline="", template='stats_sessions.html',
680 items=(reg_ses for reg_ses in conf.getRegistrationForm().getSessionsForm().getSessionList()
681 if reg_ses.getSession() is not None),
682 reg_items=(reg_ses for r in conf.getRegistrantsList() for reg_ses in r.getSessionList()
683 if reg_ses.getSession() is not None)
686 def _item_from_ritem(self, rses):
687 return rses
689 def _key_fn(self, rses):
690 return (rses.getTitle(), rses.getId(), rses.getPrice())
692 def _alt_key_fn(self, rses):
693 return (rses.getTitle(), rses.getId())
695 def _billed_data_from_ritems(self, key, ses_details):
696 name, id, price = key
697 return DataItem(
698 regs=len(ses_details),
699 billable=True,
700 cancelled=any(detail.isCancelled() for detail in ses_details),
701 price=price,
702 paid=len([detail for detail in ses_details if detail.getRegistrant().getPayed()]),
703 paid_amount=sum(float(detail.getPrice()) for detail in ses_details if detail.getRegistrant().getPayed()),
704 unpaid=len([detail for detail in ses_details if not detail.getRegistrant().getPayed()]),
705 unpaid_amount=sum(float(price) for detail in ses_details if not detail.getRegistrant().getPayed())
708 def _not_billed_data_from_ritems(self, key, ses_details):
709 return DataItem(regs=len(ses_details), billable=False,
710 cancelled=any(detail.isCancelled() for detail in ses_details))
712 def _billed_data_from_item(self, ses_detail):
713 return DataItem(billable=True, cancelled=ses_detail.isCancelled(), price=ses_detail.getPrice())
715 def _not_billed_data_from_item(self, ses_detail):
716 return DataItem(billable=False, cancelled=ses_detail.isCancelled())
718 def _get_name_id_from_key(self, key):
719 return key[:2] # key = name, id, price
721 def _get_name_id_from_alt_key(self, key):
722 return key # key = name, id
724 def _get_table_head(self):
725 head = [Cell(type='str', data=_("Sessions")), Cell(type='str', data=_("Registrants"))]
727 if self._show_billing_info:
728 head.extend([Cell(type='str', data=_("Price")),
729 Cell(type='str', data=_("Sessions paid")),
730 Cell(type='str', data=_("Total paid (unpaid)"), colspan=2),
731 Cell(type='str', data=_("Total"))])
733 return head
735 def _cancel_reason_from_item(self, ses_details):
736 return _("Session cancelled") if any(detail.cancelled for detail in ses_details) else None
738 def _get_main_row(self, ses_details, ses_name, num_regs, cancel_reason):
739 return [Cell(type='str', data=' ' + ses_name, classes=['cancelled-item'] if cancel_reason else [],
740 qtip=cancel_reason),
741 Cell(type='progress', data=((float(num_regs) / len(self.registrants),
742 '{} / {}'.format(num_regs, len(self.registrants)))
743 if self.registrants else None))]
745 def _get_sub_row(self, details, num_regs):
746 return [Cell(type='str'),
747 Cell(type='progress', data=((float(details.regs) / num_regs, '{} / {}'.format(details.regs, num_regs))
748 if num_regs else None))]
750 def __nonzero__(self):
751 return (self.registration_form.getSessionsForm().isEnabled() and
752 super(SessionStats, self).__nonzero__())
755 class SocialEventsStats(TableStats, StatsBase):
756 def __init__(self, conf):
757 super(SocialEventsStats, self).__init__(
758 conf=conf, title=None, headline=None, template='stats_social_events.html',
759 items=(e for e in conf.getRegistrationForm().getSocialEventForm().getSocialEventList() if e is not None),
760 reg_items=(re for r in conf.getRegistrantsList() for re in r.getSocialEvents() if re is not None))
761 self._form = self._conf.getRegistrationForm().getSocialEventForm()
762 # Override title and headline
763 self.title = self._form.getTitle()
764 self.headline = _("Registrants {} select {}.").format(
765 '<b>{}</b>'.format(_("must")) if self.mandatory else _("can"),
766 _("multiple events") if self.selection_type == "multiple" else _("a unique event")
769 @property
770 def selection_type(self):
771 return self._form.getSelectionTypeId()
773 @property
774 def mandatory(self):
775 return self._form.getMandatory()
777 def _compute_amount(self, event):
778 return (float(event.getPrice()) * event.getNoPlaces()) if event.isPricePerPlace() else float(event.getPrice())
780 def _get_attendance(self, evt_details):
781 capacity = next((detail.capacity for detail in evt_details if detail.capacity), 0)
782 if not capacity:
783 return Cell(type='str', data=sum(detail.attendance for detail in evt_details))
785 attendance = next((detail.attendance for detail in evt_details if detail.attendance), 0)
786 label = '{} / {}'.format(attendance, capacity)
787 qtip = '{} attendees, {} place(s) available'.format(attendance, capacity - attendance)
788 return Cell(type='progress', qtip=qtip, data=(float(attendance) / capacity, label))
790 def _item_from_ritem(self, reg_event):
791 return reg_event.getSocialEventItem()
793 def _key_fn(self, event):
794 return (event.getCaption(), event.getId(), event.getPrice(), event.isPricePerPlace())
796 def _alt_key_fn(self, event):
797 return (event.getCaption(), event.getId())
799 def _billed_data_from_ritems(self, key, evt_details):
800 name, id, price, price_per_place = key
801 return DataItem(
802 regs=len(evt_details),
803 attendance=next((detail.getSocialEventItem().getCurrentNoPlaces()
804 for detail in evt_details if detail.getSocialEventItem().getCurrentNoPlaces()), 0),
805 capacity=next((detail.getSocialEventItem().getPlacesLimit()
806 for detail in evt_details if detail.getSocialEventItem().getPlacesLimit()), 0),
807 billable=True,
808 cancelled=any(detail.isCancelled() for detail in evt_details),
809 cancel_reason=next((detail.getCancelledReason() for detail in evt_details if detail.getCancelledReason()),
810 None),
811 price=price,
812 fixed_price=not price_per_place,
813 paid=len([detail for detail in evt_details if detail.getRegistrant().getPayed()]),
814 paid_amount=sum(self._compute_amount(detail) for detail in evt_details
815 if detail.getRegistrant().getPayed()),
816 unpaid=len([detail for detail in evt_details if not detail.getRegistrant().getPayed()]),
817 unpaid_amount=sum(self._compute_amount(detail) for detail in evt_details
818 if not detail.getRegistrant().getPayed())
821 def _not_billed_data_from_ritems(self, key, evt_details):
822 return DataItem(
823 regs=len(evt_details),
824 attendance=next((detail.getSocialEventItem().getCurrentNoPlaces()
825 for detail in evt_details if detail.getSocialEventItem().getCurrentNoPlaces()), 0),
826 capacity=next((detail.getSocialEventItem().getPlacesLimit()
827 for detail in evt_details if detail.getSocialEventItem().getPlacesLimit()), 0),
828 billable=False,
829 cancelled=any(detail.isCancelled() for detail in evt_details),
830 cancel_reason=next((detail.getCancelledReason() for detail in evt_details
831 if detail.getCancelledReason()), None),
832 price=next((detail.getPrice() for detail in evt_details), 0)
835 def _billed_data_from_item(self, event):
836 return DataItem(
837 capacity=event.getPlacesLimit(),
838 billable=True,
839 cancelled=event.isCancelled(),
840 cancel_reason=event.getCancelledReason() if event.getCancelledReason() else '',
841 price=event.getPrice(),
842 fixed_price=not event.isPricePerPlace()
845 def _not_billed_data_from_item(self, event):
846 return DataItem(
847 capacity=event.getPlacesLimit(),
848 billable=False,
849 price=event.getPrice(),
850 fixed_price=not event.isPricePerPlace()
853 def _get_name_id_from_key(self, key):
854 return key[:2] # key = name, id, price, price_per_place
856 def _get_name_id_from_alt_key(self, key):
857 return key # key = name, id
859 def _get_table_head(self):
860 head = [Cell(type='str', data=_("Event")),
861 Cell(type='str', data=_("Registrants")),
862 Cell(type='str', data=_("Attendees"))]
864 if self._show_billing_info:
865 head.extend([
866 Cell(type='str', data=_("Price"), colspan=2),
867 Cell(type='str', data=_("Registrations paid")),
868 Cell(type='str', data=_("Total paid (unpaid)"), colspan=2),
869 Cell(type='str', data=_("Total"))
872 return head
874 def _cancel_reason_from_item(self, evt_details):
875 return (_("Cancelled: {}").format(next((detail.cancel_reason for detail in evt_details if detail.cancel_reason),
876 _("No reason given")))
877 if any(detail.cancelled for detail in evt_details) else None)
879 def _get_main_row(self, event_details, event_name, num_regs, cancel_reason):
880 return [Cell(type='str', data=' ' + event_name, classes=['cancelled-item'] if cancel_reason else [],
881 qtip=cancel_reason),
882 Cell(type='progress', data=(float(num_regs) / len(self.registrants), '{} / {}'.format(num_regs,
883 len(self.registrants))) if self.registrants else None),
884 self._get_attendance(event_details)]
886 def _get_sub_row(self, details, num_regs):
887 return [Cell(type='str'),
888 Cell(type='progress', data=((float(details.regs) / num_regs, '{} / {}'.format(details.regs, num_regs))
889 if num_regs else None)),
890 self._get_attendance([details])]
892 def _get_billing(self, evt_details):
893 # Only one kind of payment (currency, price and fixed price or not)
894 # or only no payment so we move the sub row up as the header row.
895 if len(evt_details) == 1:
896 return self._get_billing_details(evt_details[0])
898 billing_cells = super(SocialEventsStats, self)._get_billing(evt_details)
899 # the icon next to the price for social events takes an extra cell
900 if billing_cells:
901 billing_cells[0] = Cell(colspan=2)
903 return billing_cells
905 def _get_billing_details(self, details):
906 billing_cells = super(SocialEventsStats, self)._get_billing_details(details)
907 if not billing_cells: # no billing details
908 return billing_cells
910 # The users icon is displayed when accompanying guests must pay as well.
911 price_details = (Cell(type='str', classes=['stick-left']) if details.fixed_price else
912 Cell(type='icon', data='users', qtip='accompanying guests must pay', classes=['stick-left']))
914 return [price_details] + billing_cells
916 def __nonzero__(self):
917 return (self.registration_form.getSocialEventForm().isEnabled() and
918 super(SocialEventsStats, self).__nonzero__())