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
):
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
))
39 class StatsBase(object):
40 def __init__(self
, conf
, title
, headline
, template
, **kwargs
):
42 Base class for statistics
44 Represents a stats "box" with a title, headline and some content derived
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
56 super(StatsBase
, self
).__init
__(**kwargs
)
59 self
.headline
= headline
60 self
.template
= template
63 def show_currency_info(self
):
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.
73 def registrants(self
):
74 return self
._conf
.getRegistrantsList()
77 def registration_form(self
):
78 return self
._conf
.getRegistrationForm()
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
88 :returns: str -- the currency abbreviation
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):
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
102 The table below indicates the valid types and the format of the data
103 specific to the type.
105 +--------------------+-------------------------------------------------+
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 |
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 | | `—` (use `Cell(type='str')` for an empty |
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
137 # Provide sensible data defaults for specific types
141 elif type == 'progress-stacked':
143 elif type == 'progress':
145 elif type == 'currency':
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
159 macro
= cell_macros
[self
.type]
161 macro
= cell_macros
['default']
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`
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
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
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
()
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.
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
)),
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
))
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
)
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
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
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
)
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:
275 table
['rows'].extend(('sub-row', self
._get
_sub
_row
(detail
, num_regs
) + self
._get
_billing
_details
(detail
))
276 for detail
in item_details
)
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
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
:
309 # Only paid or unpaid accommodation so we move the sub row up as the
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
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
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
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
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
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
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.
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
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
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]),
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():
551 return (len(self
.registrants
), users_limit
, float(len(self
.registrants
)) / users_limit
)
553 def __nonzero__(self
):
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
:
571 capacity
= max(d
.capacity
for d
in acco_details
)
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
:
580 if not details
.capacity
:
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
597 regs
=len(acco_details
),
598 capacity
=next((detail
.getAccommodationType().getPlacesLimit() for detail
in acco_details
), 0),
600 cancelled
=any(detail
.isCancelled() for detail
in acco_details
),
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
):
611 regs
=len(acco_details
),
612 capacity
=next((a
.getAccommodationType().getPlacesLimit() for a
in acco_details
), 0),
614 cancelled
=any(a
.isCancelled() for a
in acco_details
)
617 def _billed_data_from_item(self
, acco
):
619 capacity
=acco
.getPlacesLimit(),
621 cancelled
=acco
.isCancelled(),
622 price
=acco
.getPrice()
625 def _not_billed_data_from_item(self
, acco
):
627 capacity
=acco
.getPlacesLimit(),
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"))])
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
):
657 Cell(type='str', data
=' ' + acco_name
, classes
=['cancelled-item'] if cancel_reason
else [],
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
):
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
):
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
698 regs
=len(ses_details
),
700 cancelled
=any(detail
.isCancelled() for detail
in ses_details
),
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"))])
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 [],
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")
770 def selection_type(self
):
771 return self
._form
.getSelectionTypeId()
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)
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
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),
808 cancelled
=any(detail
.isCancelled() for detail
in evt_details
),
809 cancel_reason
=next((detail
.getCancelledReason() for detail
in evt_details
if detail
.getCancelledReason()),
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
):
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),
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
):
837 capacity
=event
.getPlacesLimit(),
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
):
847 capacity
=event
.getPlacesLimit(),
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
:
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"))
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 [],
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
901 billing_cells
[0] = Cell(colspan
=2)
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
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
__())