def utility_processor(): SALES_STATE = get_sales_state() SITE_STATE = get_site_state() return dict( SALES_STATE=SALES_STATE, SITE_STATE=SITE_STATE, CURRENCIES=CURRENCIES, CURRENCY_SYMBOLS=CURRENCY_SYMBOLS, external_url=external_url, feature_enabled=feature_enabled, get_user_currency=get_user_currency, year=datetime.utcnow().year, )
def utility_processor(): SALES_STATE = get_sales_state() SITE_STATE = get_site_state() if app.config.get('DEBUG'): SITE_STATE = request.args.get("site_state", SITE_STATE) SALES_STATE = request.args.get("sales_state", SALES_STATE) return dict(SALES_STATE=SALES_STATE, SITE_STATE=SITE_STATE, CURRENCIES=CURRENCIES, CURRENCY_SYMBOLS=CURRENCY_SYMBOLS, external_url=external_url, feature_enabled=feature_enabled)
def utility_processor(): SALES_STATE = get_sales_state() SITE_STATE = get_site_state() if app.config.get('DEBUG'): SITE_STATE = request.args.get("site_state", SITE_STATE) SALES_STATE = request.args.get("sales_state", SALES_STATE) return dict( SALES_STATE=SALES_STATE, SITE_STATE=SITE_STATE, CURRENCIES=CURRENCIES, CURRENCY_SYMBOLS=CURRENCY_SYMBOLS, external_url=external_url, feature_enabled=feature_enabled )
def utility_processor(): SALES_STATE = get_sales_state() SITE_STATE = get_site_state() if app.config.get("DEBUG"): SITE_STATE = request.args.get("site_state", SITE_STATE) SALES_STATE = request.args.get("sales_state", SALES_STATE) basket_count = len(session.get("basket_purchase_ids", [])) return dict( SALES_STATE=SALES_STATE, SITE_STATE=SITE_STATE, CURRENCIES=CURRENCIES, CURRENCY_SYMBOLS=CURRENCY_SYMBOLS, external_url=external_url, feature_enabled=feature_enabled, basket_count=basket_count, )
def utility_processor(): SALES_STATE = get_sales_state() SITE_STATE = get_site_state() if app.config.get('DEBUG'): SITE_STATE = request.args.get("site_state", SITE_STATE) SALES_STATE = request.args.get("sales_state", SALES_STATE) basket_count = len(session.get('basket_purchase_ids', [])) return dict( SALES_STATE=SALES_STATE, SITE_STATE=SITE_STATE, CURRENCIES=CURRENCIES, CURRENCY_SYMBOLS=CURRENCY_SYMBOLS, external_url=external_url, feature_enabled=feature_enabled, basket_count=basket_count, )
def main(flow=None): if flow is None: flow = "main" # For now, use the flow name as a view name. This might change. view = ProductView.get_by_name(flow) if not view: abort(404) if view.cfp_accepted_only and current_user.is_anonymous: return redirect(url_for("users.login", next=url_for(".main", flow=flow))) if not view.is_accessible(current_user, session.get("ticket_token")): abort(404) sales_state = get_sales_state() if sales_state in ["unavailable", "sold-out"]: # For the main entry point, we assume people want admissions tickets, # but we still need to sell people parking tickets, tents or tickets # from tokens until the final cutoff (sales-ended). if flow != "main": sales_state = "available" if app.config.get("DEBUG"): sales_state = request.args.get("sales_state", sales_state) if sales_state in {"available", "unavailable"}: pass elif not current_user.is_anonymous and current_user.has_permission("admin"): pass else: return render_template("tickets/cutoff.html") tiers = OrderedDict() products = ( ProductViewProduct.query.filter_by(view_id=view.id) .join(ProductViewProduct.product) .with_entities(Product) .order_by(ProductViewProduct.order) .options(joinedload(Product.price_tiers).joinedload(PriceTier.prices)) ) for product in products: pts = [tier for tier in product.price_tiers if tier.active] if len(pts) > 1: app.logger.error( "Multiple active PriceTiers found for %s. Excluding product.", product ) continue pt = pts[0] tiers[pt.id] = pt basket = Basket.from_session(current_user, get_user_currency()) form = TicketAmountsForm() """ For consistency and to avoid surprises, we try to ensure a few things here: - if the user successfully submits a form with no errors, their basket is updated - if they don't, the basket is left untouched - the basket is updated to match what was submitted, even if they added tickets in another tab - if they already have tickets in their basket, only reserve the extra tickets as necessary - don't unreserve surplus tickets until the payment is created - if the user hasn't submitted anything, we use their current reserved ticket counts - if the user has reserved tickets from exhausted tiers on this view, we still show them - if the user has reserved tickets from other views, don't show and don't mess with them - this means the user can combine tickets from multiple views into a single basket - show the sold-out/unavailable states only when the user doesn't have reserved tickets We currently don't deal with multiple price tiers being available around the same time. Reserved tickets from a previous tier should be cancelled before activating a new one. """ if request.method != "POST": # Empty form - populate products for pt_id, tier in tiers.items(): form.tiers.append_entry() f = form.tiers[-1] f.tier_id.data = pt_id f.amount.data = basket.get(tier, 0) # Whether submitted or not, update the allowed amounts before validating capacity_gone = False for f in form.tiers: pt_id = f.tier_id.data tier = tiers[pt_id] f._tier = tier # If they've already got reserved tickets, let them keep them user_limit = max(tier.user_limit(), basket.get(tier, 0)) if f.amount.data and f.amount.data > user_limit: capacity_gone = True values = range(user_limit + 1) f.amount.values = values f._any = any(values) available = True if sales_state == "unavailable": if not any(p.product in products for p in basket.purchases): # If they have any reservations, they bypass the unavailable state. # This means someone can use another view to get access to this one # again. I'm not sure what to do about this. It usually won't matter. available = False if form.validate_on_submit(): if form.buy_tickets.data or form.buy_hire.data or form.buy_other.data: if form.currency_code.data != get_user_currency(): set_user_currency(form.currency_code.data) # Commit so we don't lose the currency change if an error occurs db.session.commit() # Reload the basket because set_user_currency has changed it under us basket = Basket.from_session(current_user, get_user_currency()) for f in form.tiers: pt = f._tier if f.amount.data != basket.get(pt, 0): app.logger.info( "Adding %s %s tickets to basket", f.amount.data, pt.name ) basket[pt] = f.amount.data if not available: app.logger.warn("User has no reservations, enforcing unavailable state") basket.save_to_session() return redirect(url_for("tickets.main", flow=flow)) if not any(basket.values()): empty_baskets.inc() if view.type == "tickets": flash("Please select at least one ticket to buy.") elif view.type == "hire": flash("Please select at least one item to hire.") else: flash("Please select at least one item to buy.") basket.save_to_session() return redirect(url_for("tickets.main", flow=flow)) app.logger.info("Basket %s", basket) try: basket.create_purchases() basket.ensure_purchase_capacity() db.session.commit() except CapacityException as e: # Damn, capacity's gone since we created the purchases # Redirect back to show what's currently in the basket db.session.rollback() no_capacity.inc() app.logger.warn("Limit exceeded creating tickets: %s", e) flash( "We're very sorry, but there is not enough capacity available to " "allocate these tickets. You may be able to try again with a smaller amount." ) return redirect(url_for("tickets.main", flow=flow)) basket.save_to_session() if basket.total != 0: # Send the user off to pay return redirect(url_for("tickets.pay", flow=flow)) # Otherwise, the user is trying to buy free tickets. # They must be authenticated for this. if not current_user.is_authenticated: app.logger.warn("User is not authenticated, sending to login") flash("You must be logged in to buy additional free tickets") return redirect( url_for("users.login", next=url_for("tickets.main", flow=flow)) ) # We sell under-12 tickets to non-CfP users, to enforce capacity. # We don't let people order an under-12 ticket on its own. # However, CfP users need to be able to buy day and parking tickets. admissions_tickets = current_user.get_owned_tickets(type="admission_ticket") if not any(admissions_tickets) and not view.cfp_accepted_only: app.logger.warn( "User trying to buy free add-ons without an admission ticket" ) flash( "You must have an admissions ticket to buy additional free tickets" ) return redirect(url_for("tickets.main", flow=flow)) basket.user = current_user basket.check_out_free() db.session.commit() Basket.clear_from_session() msg = Message( "Your EMF ticket order", sender=app.config["TICKETS_EMAIL"], recipients=[current_user.email], ) already_emailed = set_tickets_emailed(current_user) msg.body = render_template( "emails/tickets-ordered-email-free.txt", user=current_user, basket=basket, already_emailed=already_emailed, ) if feature_enabled("ISSUE_TICKETS"): attach_tickets(msg, current_user) mail.send(msg) if len(basket.purchases) == 1: flash("Your ticket has been confirmed") else: flash("Your tickets have been confirmed") return redirect(url_for("users.purchases")) if request.method == "POST" and form.set_currency.data: if form.set_currency.validate(form): app.logger.info("Updating currency to %s only", form.set_currency.data) set_user_currency(form.set_currency.data) db.session.commit() for field in form: field.errors = [] if capacity_gone: no_capacity.inc() flash( "We're sorry, but there is not enough capacity available to " "allocate these tickets. You may be able to try again with a smaller amount." ) form.currency_code.data = get_user_currency() return render_template( "tickets/choose.html", form=form, flow=flow, view=view, available=available )
def choose(flow=None): token = session.get('ticket_token') sales_state = get_sales_state() if flow is None: admissions = True elif flow == 'other': admissions = False else: abort(404) if sales_state in ['unavailable', 'sold-out']: # For the main entry point, we assume people want admissions tickets, # but we still need to sell people e.g. parking tickets or tents until # the final cutoff (sales-ended). if not admissions: sales_state = 'available' # Allow people with valid discount tokens to buy tickets elif token is not None and TicketType.get_types_for_token(token): sales_state = 'available' if app.config.get('DEBUG'): sales_state = request.args.get("sales_state", sales_state) if sales_state == 'available': pass elif not current_user.is_anonymous() and current_user.has_permission( 'admin'): pass else: return render_template("tickets-cutoff.html") form = TicketAmountsForm() # If this is the main page, exclude tents and other paraphernalia. # For the non-admissions page, only exclude actual admissions tickets. # This means both pages show parking and caravan tickets. if admissions: tts = TicketType.query.filter(~TicketType.admits.in_(['other'])) else: tts = TicketType.query.filter(~TicketType.admits.in_(['full', 'kid'])) tts = tts.order_by(TicketType.order).all() limits = dict((tt.id, tt.user_limit(current_user, token)) for tt in tts) if request.method != 'POST': # Empty form - populate ticket types for tt in tts: form.types.append_entry() form.types[-1].type_id.data = tt.id tts = {tt.id: tt for tt in tts} for f in form.types: t_id = f.type_id.data f._type = tts[t_id] values = range(limits[t_id] + 1) f.amount.values = values f._any = any(values) if form.validate_on_submit(): if form.buy.data or form.buy_other.data: set_user_currency(form.currency_code.data) basket = [] for f in form.types: if f.amount.data: tt = f._type app.logger.info('Adding %s %s tickets to basket', f.amount.data, tt.name) basket += [tt.id] * f.amount.data if basket: session['basket'] = basket return redirect(url_for('tickets.pay', flow=flow)) elif admissions: flash("Please select at least one ticket to buy.") else: flash("Please select at least one item to buy.") if request.method == 'POST' and form.set_currency.data: if form.set_currency.validate(form): app.logger.info("Updating currency to %s only", form.set_currency.data) set_user_currency(form.set_currency.data) for field in form: field.errors = [] form.currency_code.data = get_user_currency() return render_template("tickets-choose.html", form=form, admissions=admissions)
def choose(flow=None): token = session.get('ticket_token') sales_state = get_sales_state() if flow is None: admissions = True elif flow == 'other': admissions = False else: abort(404) if sales_state in ['unavailable', 'sold-out']: # For the main entry point, we assume people want admissions tickets, # but we still need to sell people e.g. parking tickets or tents until # the final cutoff (sales-ended). if not admissions: sales_state = 'available' # Allow people with valid discount tokens to buy tickets elif token is not None and TicketType.get_types_for_token(token): sales_state = 'available' if app.config.get('DEBUG'): sales_state = request.args.get("sales_state", sales_state) if sales_state == 'available': pass elif not current_user.is_anonymous() and current_user.has_permission('admin'): pass else: return render_template("tickets-cutoff.html") form = TicketAmountsForm() # If this is the main page, exclude tents and other paraphernalia. # For the non-admissions page, only exclude actual admissions tickets. # This means both pages show parking and caravan tickets. if admissions: tts = TicketType.query.filter(~TicketType.admits.in_(['other'])) else: tts = TicketType.query.filter(~TicketType.admits.in_(['full', 'kid'])) tts = tts.order_by(TicketType.order).all() limits = dict((tt.id, tt.user_limit(current_user, token)) for tt in tts) if request.method != 'POST': # Empty form - populate ticket types for tt in tts: form.types.append_entry() form.types[-1].type_id.data = tt.id tts = {tt.id: tt for tt in tts} for f in form.types: t_id = f.type_id.data f._type = tts[t_id] values = range(limits[t_id] + 1) f.amount.values = values f._any = any(values) if form.validate_on_submit(): if form.buy.data or form.buy_other.data: set_user_currency(form.currency_code.data) basket = [] for f in form.types: if f.amount.data: tt = f._type app.logger.info('Adding %s %s tickets to basket', f.amount.data, tt.name) basket += [tt.id] * f.amount.data if basket: session['basket'] = basket return redirect(url_for('tickets.pay', flow=flow)) elif admissions: flash("Please select at least one ticket to buy.") else: flash("Please select at least one item to buy.") if request.method == 'POST' and form.set_currency.data: if form.set_currency.validate(form): app.logger.info("Updating currency to %s only", form.set_currency.data) set_user_currency(form.set_currency.data) for field in form: field.errors = [] form.currency_code.data = get_user_currency() return render_template("tickets-choose.html", form=form, admissions=admissions)
def main(flow="main"): """The main tickets page. This lets the user choose which tickets to buy, creates a basket for them and then adds the tickets to their basket. At this point tickets are reserved, and the user is passed on to `/tickets/pay` to enter their user details and choose a payment method. The `flow` parameter dictates which ProductView to display on this page, allowing us to have different categories of items on sale, for example tickets on one page, and t-shirts on a separate page. """ # Fetch the ProductView and determine if this user is allowed to view it. view = get_product_view(flow) if view.cfp_accepted_only and current_user.is_anonymous: return redirect( url_for("users.login", next=url_for(".main", flow=flow))) if not view.is_accessible(current_user, session.get("ticket_voucher")): # User isn't allowed to see this ProductView, either because it's # CfP-restricted or because they don't have an active voucher. abort(404) # The sales state controls whether admission tickets are on sale. sales_state = get_sales_state() if sales_state in {"unavailable", "sold-out"}: # For the main entry point, we assume people want admissions tickets, # but we still need to sell people parking tickets, tents or tickets # from vouchers until the final cutoff (sales-ended). if flow != "main": sales_state = "available" if app.config.get("DEBUG"): sales_state = request.args.get("sales_state", sales_state) if sales_state in {"available", "unavailable"}: # Tickets are on sale, or they're unavailable but we're still showing prices. pass elif not current_user.is_anonymous and current_user.has_permission( "admin"): # Admins always have access pass else: # User is prevented from buying by the sales state. return render_template("tickets/cutoff.html") # OK, looks like we can try and sell the user some stuff. products = products_for_view(view) form = TicketAmountsForm(products) basket = Basket.from_session(current_user, get_user_currency()) if request.method != "POST": # Empty form - populate products with any amounts already in basket form.populate(basket) # Validate the capacity in the form, setting the maximum limits where available. if not form.ensure_capacity(basket): # We're not able to provide the number of tickets the user has selected. no_capacity.inc() flash( "We're sorry, but there weren't enough tickets remaining to give " "you all the tickets you requested. We've reserved as many as we can for you." ) available = True if sales_state == "unavailable": # If the user has any reservations, they bypass the unavailable state. # This means someone can use another view to get access to this one # again. I'm not sure what to do about this. It usually won't matter. available = any(p.product in products for p in basket.purchases) if form.validate_on_submit() and (form.buy_tickets.data or form.buy_hire.data or form.buy_other.data): # User has selected some tickets to buy. if not available: # Tickets are out :( app.logger.warn( "User has no reservations, enforcing unavailable state") basket.save_to_session() return redirect(url_for("tickets.main", flow=flow)) return handle_ticket_selection(form, view, flow, basket) if request.method == "POST" and form.set_currency.data: # User has changed their currency but they don't have javascript enabled, # so a page reload has been caused. if form.set_currency.validate(form): app.logger.info("Updating currency to %s (no-JS path)", form.set_currency.data) set_user_currency(form.set_currency.data) db.session.commit() for field in form: field.errors = [] form.currency_code.data = get_user_currency() return render_template("tickets/choose.html", form=form, flow=flow, view=view, available=available)
def main(flow=None): if flow is None: flow = 'main' # For now, use the flow name as a view name. This might change. view = ProductView.get_by_name(flow) if not view: abort(404) if view.cfp_accepted_only and current_user.is_anonymous: return redirect(url_for('users.login', next=url_for('.main', flow=flow))) if not view.is_accessible(current_user, session.get('ticket_token')): abort(404) sales_state = get_sales_state() if sales_state in ['unavailable', 'sold-out']: # For the main entry point, we assume people want admissions tickets, # but we still need to sell people parking tickets, tents or tickets # from tokens until the final cutoff (sales-ended). if flow != 'main': sales_state = 'available' if app.config.get('DEBUG'): sales_state = request.args.get("sales_state", sales_state) if sales_state in {'available', 'unavailable'}: pass elif not current_user.is_anonymous and current_user.has_permission('admin'): pass else: return render_template("tickets-cutoff.html") tiers = OrderedDict() products = ProductViewProduct.query.filter_by(view_id=view.id) \ .join(ProductViewProduct.product) \ .with_entities(Product) \ .order_by(ProductViewProduct.order) \ .options(joinedload(Product.price_tiers) .joinedload(PriceTier.prices) ) for product in products: pts = [tier for tier in product.price_tiers if tier.active] if len(pts) > 1: app.logger.error("Multiple active PriceTiers found for %s. Excluding product.", product) continue pt = pts[0] tiers[pt.id] = pt basket = Basket.from_session(current_user, get_user_currency()) form = TicketAmountsForm() """ For consistency and to avoid surprises, we try to ensure a few things here: - if the user successfully submits a form with no errors, their basket is updated - if they don't, the basket is left untouched - the basket is updated to match what was submitted, even if they added tickets in another tab - if they already have tickets in their basket, only reserve the extra tickets as necessary - don't unreserve surplus tickets until the payment is created - if the user hasn't submitted anything, we use their current reserved ticket counts - if the user has reserved tickets from exhausted tiers on this view, we still show them - if the user has reserved tickets from other views, don't show and don't mess with them - this means the user can combine tickets from multiple views into a single basket - show the sold-out/unavailable states only when the user doesn't have reserved tickets We currently don't deal with multiple price tiers being available around the same time. Reserved tickets from a previous tier should be cancelled before activating a new one. """ if request.method != 'POST': # Empty form - populate products for pt_id, tier in tiers.items(): form.tiers.append_entry() f = form.tiers[-1] f.tier_id.data = pt_id f.amount.data = basket.get(tier, 0) # Whether submitted or not, update the allowed amounts before validating capacity_gone = False for f in form.tiers: pt_id = f.tier_id.data tier = tiers[pt_id] f._tier = tier # If they've already got reserved tickets, let them keep them user_limit = max(tier.user_limit(), basket.get(tier, 0)) if f.amount.data and f.amount.data > user_limit: capacity_gone = True values = range(user_limit + 1) f.amount.values = values f._any = any(values) available = True if sales_state == 'unavailable': if not any(p.product in products for p in basket.purchases): # If they have any reservations, they bypass the unavailable state. # This means someone can use another view to get access to this one # again. I'm not sure what to do about this. It usually won't matter. available = False if form.validate_on_submit(): if form.buy_tickets.data or form.buy_hire.data or form.buy_other.data: if form.currency_code.data != get_user_currency(): set_user_currency(form.currency_code.data) # Commit so we don't lose the currency change if an error occurs db.session.commit() # Reload the basket because set_user_currency has changed it under us basket = Basket.from_session(current_user, get_user_currency()) for f in form.tiers: pt = f._tier if f.amount.data != basket.get(pt, 0): app.logger.info('Adding %s %s tickets to basket', f.amount.data, pt.name) basket[pt] = f.amount.data if not available: app.logger.warn('User has no reservations, enforcing unavailable state') basket.save_to_session() return redirect(url_for('tickets.main', flow=flow)) if not any(basket.values()): empty_baskets.inc() if view.type == 'tickets': flash("Please select at least one ticket to buy.") elif view.type == 'hire': flash("Please select at least one item to hire.") else: flash("Please select at least one item to buy.") basket.save_to_session() return redirect(url_for('tickets.main', flow=flow)) app.logger.info('Basket %s', basket) try: basket.create_purchases() basket.ensure_purchase_capacity() db.session.commit() except CapacityException as e: # Damn, capacity's gone since we created the purchases # Redirect back to show what's currently in the basket db.session.rollback() no_capacity.inc() app.logger.warn('Limit exceeded creating tickets: %s', e) flash("We're very sorry, but there is not enough capacity available to " "allocate these tickets. You may be able to try again with a smaller amount.") return redirect(url_for("tickets.main", flow=flow)) basket.save_to_session() if basket.total != 0: # Send the user off to pay return redirect(url_for('tickets.pay', flow=flow)) # Otherwise, the user is trying to buy free tickets. # They must be authenticated for this. if not current_user.is_authenticated: app.logger.warn("User is not authenticated, sending to login") flash("You must be logged in to buy additional free tickets") return redirect(url_for('users.login', next=url_for('tickets.main', flow=flow))) # We sell under-12 tickets to non-CfP users, to enforce capacity. # We don't let people order an under-12 ticket on its own. # However, CfP users need to be able to buy day and parking tickets. admissions_tickets = current_user.get_owned_tickets(type='admission_ticket') if not any(admissions_tickets) and not view.cfp_accepted_only: app.logger.warn("User trying to buy free add-ons without an admission ticket") flash("You must have an admissions ticket to buy additional free tickets") return redirect(url_for("tickets.main", flow=flow)) basket.user = current_user basket.check_out_free() db.session.commit() Basket.clear_from_session() msg = Message("Your EMF ticket order", sender=app.config['TICKETS_EMAIL'], recipients=[current_user.email]) already_emailed = set_tickets_emailed(current_user) msg.body = render_template("emails/tickets-ordered-email-free.txt", user=current_user, basket=basket, already_emailed=already_emailed) if feature_enabled('ISSUE_TICKETS'): attach_tickets(msg, current_user) mail.send(msg) if len(basket.purchases) == 1: flash("Your ticket has been confirmed") else: flash("Your tickets have been confirmed") return redirect(url_for('users.purchases')) if request.method == 'POST' and form.set_currency.data: if form.set_currency.validate(form): app.logger.info("Updating currency to %s only", form.set_currency.data) set_user_currency(form.set_currency.data) db.session.commit() for field in form: field.errors = [] if capacity_gone: no_capacity.inc() flash("We're sorry, but there is not enough capacity available to " "allocate these tickets. You may be able to try again with a smaller amount.") form.currency_code.data = get_user_currency() return render_template("tickets-choose.html", form=form, flow=flow, view=view, available=available)