Example #1
0
def tickets_clear(flow=None):
    basket = Basket.from_session(current_user, get_user_currency())
    basket.cancel_purchases()
    db.session.commit()

    Basket.clear_from_session()
    return redirect(url_for('tickets.main', flow=flow))
Example #2
0
def tickets_clear(flow=None):
    basket = Basket.from_session(current_user, get_user_currency())
    basket.cancel_purchases()
    db.session.commit()

    Basket.clear_from_session()
    return redirect(url_for("tickets.main", flow=flow))
Example #3
0
def handle_free_tickets(flow: str, view: ProductView, basket: Basket):
    """The user is trying to "buy" only free tickets.

    This is effectively a payment stage, handled differently
    from the rest of the flow.
    """
    # 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 = EmailMessage(
        "Your EMF ticket order",
        from_email=from_email("TICKETS_EMAIL"),
        to=[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)

    msg.send()

    if len(basket.purchases) == 1:
        flash("Your ticket has been confirmed")
    else:
        flash("Your tickets have been confirmed")

    return redirect(url_for("users.purchases"))
Example #4
0
def tickets_reserved(flow=None, currency=None):
    if current_user.is_anonymous:
        return redirect(url_for('users.login', next=url_for('.tickets_reserved', flow=flow)))

    basket = Basket(current_user, get_user_currency())
    basket.load_purchases_from_db()
    basket.save_to_session()

    if currency in CURRENCY_SYMBOLS:
        set_user_currency(currency)

    return redirect(url_for('tickets.pay', flow=flow))
Example #5
0
    def create_fake_tickets(self, user):
        if random.random() < 0.3:
            return

        if random.random() < 0.2:
            currency = "EUR"
        else:
            currency = "GBP"
        b = Basket(user, currency)

        pt = PriceTier.query.filter_by(name="full-std").one()
        b[pt] = int(round(random.uniform(1, 4)))

        if random.random() < 0.5:
            pt = PriceTier.query.filter_by(name="parking").one()
            b[pt] = 1

        b.create_purchases()
        b.ensure_purchase_capacity()

        payment_type = {
            "gc": GoCardlessPayment,
            "bank": BankPayment,
            "stripe": StripePayment,
        }.get(random_state({"gc": 0.3, "bank": 0.2, "stripe": 0.5}))

        payment = b.create_payment(payment_type)

        if random.random() < 0.8:
            payment.paid()
        else:
            payment.cancel()
Example #6
0
    def create_fixtures(self):
        self.user1_email = self.user1_email_template.format(random_string(8))
        self.user2_email = self.user2_email_template.format(random_string(8))
        self.user1 = User(self.user1_email, 'test_user1')
        self.user2 = User(self.user2_email, 'test_user2')
        self.db.session.add(self.user1)
        self.db.session.add(self.user2)

        self.group_name = self.group_template.format(random_string(8))
        self.group = ProductGroup(type='admissions', name=self.group_name)
        self.product = Product(name=self.product_name, parent=self.group)
        self.tier = PriceTier(name=self.tier_name, parent=self.product)
        self.price = Price(price_tier=self.tier, currency='GBP', price_int=666)

        self.db.session.add(self.price)
        self.db.session.commit()

        # PriceTier needs to have been committed before this
        basket = Basket(self.user1, 'GBP')
        basket[self.tier] = 1
        basket.create_purchases()
        basket.ensure_purchase_capacity()
        payment = basket.create_payment(BankPayment)
        assert len(payment.purchases) == 1
        self.db.session.commit()
Example #7
0
    def create_purchases(self, tier, count):
        basket = Basket(self.user, 'GBP')
        basket[tier] = count
        basket.create_purchases()
        basket.ensure_purchase_capacity()
        payment = basket.create_payment(BankPayment)
        assert len(payment.purchases) == count
        self.db.session.commit()

        return payment.purchases
def create_purchases(tier, count, user):
    basket = Basket(user, "GBP")
    basket[tier] = count
    basket.create_purchases()
    basket.ensure_purchase_capacity()
    payment = basket.create_payment(BankPayment)

    db.session.add(payment)
    db.session.commit()
    assert len(payment.purchases) == count
    return payment.purchases
Example #9
0
def create_purchases(tier, count, user):
    basket = Basket(user, 'GBP')
    basket[tier] = count
    basket.create_purchases()
    basket.ensure_purchase_capacity()
    payment = basket.create_payment(BankPayment)

    db.session.add(payment)
    db.session.commit()
    assert len(payment.purchases) == count
    return payment.purchases
Example #10
0
def tickets_reserved(flow=None, currency=None):
    if current_user.is_anonymous:
        return redirect(
            url_for("users.login", next=url_for(".tickets_reserved", flow=flow))
        )

    basket = Basket(current_user, get_user_currency())
    basket.load_purchases_from_db()
    basket.save_to_session()

    if currency in CURRENCY_SYMBOLS:
        set_user_currency(currency)

    return redirect(url_for("tickets.pay", flow=flow))
Example #11
0
    def create_fake_tickets(self, user):
        if random.random() < 0.3:
            return

        if random.random() < 0.2:
            currency = 'EUR'
        else:
            currency = 'GBP'
        b = Basket(user, currency)

        pt = PriceTier.query.filter_by(name='full-std').one()
        b[pt] = int(round(random.uniform(1, 4)))

        if random.random() < 0.5:
            pt = PriceTier.query.filter_by(name='parking').one()
            b[pt] = 1

        b.create_purchases()
        b.ensure_purchase_capacity()

        payment_type = {
            'gc': GoCardlessPayment,
            'bank': BankPayment,
            'stripe': StripePayment
        }.get(random_state({
            'gc': 0.3,
            'bank': 0.2,
            'stripe': 0.5
        }))

        payment = b.create_payment(payment_type)

        if random.random() < 0.8:
            payment.paid()
        else:
            payment.cancel()
Example #12
0
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)
Example #13
0
def pay(flow="main"):
    """
    The user is sent here once they've added tickets to their basket.
    This view collects users details, offers payment options, and then
    starts the correct payment flow in the payment app.
    """
    view = get_product_view(flow)

    if not view.is_accessible(current_user, session.get("ticket_voucher")):
        # It's likely the user had a voucher which has been used
        # This happens if they press the back button while at the payment stage.
        if current_user.is_authenticated:
            # Redirect user to their purchases page so they can see their
            # unpaid payment and retry it.
            return redirect(url_for("users.purchases"))
        else:
            abort(404)

    if request.form.get("change_currency") in ("GBP", "EUR"):
        currency = request.form.get("change_currency")
        app.logger.info("Updating currency to %s", currency)
        set_user_currency(currency)
        db.session.commit()

        return redirect(url_for(".pay", flow=flow))

    basket = Basket.from_session(current_user, get_user_currency())
    if not any(basket.values()):
        empty_baskets.inc()

        if current_user.is_authenticated:
            basket.load_purchases_from_db()

        if any(basket.values()):
            # We've lost the user's state, but we can still show them all
            # tickets they've reserved and let them empty their basket.
            flash(
                "Your browser doesn't seem to be storing cookies. This may break some parts of the site."
            )
            app.logger.warn(
                "Basket is empty, so showing reserved tickets (%s)",
                request.headers.get("User-Agent"),
            )

        elif current_user.is_authenticated:
            # This might happen if the user clicks back and then refresh in their browser
            app.logger.info("Empty basket, redirecting back to purchases page")
            flash("Your basket was empty. Please check your purchases below.")
            return redirect(url_for("users.purchases"))

        else:
            # This should never normally happen. The user wants to pay
            # for something, but we have no handle on them. Give up.
            app.logger.info(
                "Empty basket for anonymous user, redirecting back to choose tickets"
            )
            phrase = "item to buy"
            if view.type == "tickets":
                phrase = "ticket to buy"
            elif view.type == "hire":
                phrase = "item to hire"
            msg = Markup(
                f"""
                Please select at least one {phrase}, or <a href="{url_for("users.login")}">log in</a> to view your orders.
                """
            )
            flash(msg)
            return redirect(url_for("tickets.main", flow=flow))

    if basket.requires_shipping:
        if current_user.is_authenticated:
            shipping = current_user.shipping
        else:
            shipping = None

        form = TicketPaymentShippingForm(obj=shipping)
    else:
        form = TicketPaymentForm()

    form.flow = flow

    if current_user.is_authenticated:
        form.name.data = current_user.name
        del form.email
        if current_user.name != current_user.email and not basket.requires_shipping:
            # FIXME: is this helpful?
            del form.name

    if form.validate_on_submit():
        # Valid form submitted, process it
        return start_payment(form, basket, flow)

    form.basket_total.data = basket.total

    # Whether the user has an admission ticket in their basket or already purchased.
    # FIXME: this is rather ugly
    has_admission_ticket = any(p.product.is_adult_ticket() for p in basket.purchases)
    if current_user.is_authenticated:
        has_admission_ticket |= any(
            (
                p.product.is_adult_ticket()
                and p.state not in ("cancelled", "refunded", "reserved")
            )
            for p in current_user.owned_tickets
        )

    # Whether the user has any purchases in their basket which require an admission ticket,
    # such as parking or live-in vehicle tickets.
    requires_admission_ticket = any(
        p.parent.get_attribute("requires_admission_ticket", True) for p in basket.keys()
    )

    return render_template(
        "tickets/payment-choose.html",
        form=form,
        basket=basket,
        total=basket.total,
        flow=flow,
        view=view,
        admission_ticket_needed=requires_admission_ticket and not has_admission_ticket,
    )
Example #14
0
def logout():
    session.permanent = False
    Basket.clear_from_session()
    logout_user()
    return redirect(request.args.get("next", url_for("base.main")))
Example #15
0
 def create_basket(self, user):
     basket = Basket(user, self.currency.data or 'GBP')
     for f in self.price_tiers:
         if f.amount.data:
             basket[f._tier] = f.amount.data
     return basket
Example #16
0
def start_payment(form, basket, flow):
    if Decimal(form.basket_total.data) != Decimal(basket.total):
        # Check that the user's basket approximately matches what we told them they were paying.
        price_changed.inc()
        app.logger.warn(
            "User's basket has changed value %s -> %s",
            form.basket_total.data,
            basket.total,
        )
        flash(
            """The items you selected have changed, possibly because you had two windows open.
              Please verify that you've selected the correct items.""")
        return redirect(url_for("tickets.pay", flow=flow))

    user = current_user

    if user.is_anonymous:
        try:
            new_user = create_current_user(form.email.data, form.name.data)
        except IntegrityError as e:
            app.logger.warn("Adding user raised %r, possible double-click", e)
            return redirect(url_for("tickets.pay", flow=flow))

        user = new_user
    elif user.name == user.email:
        user.name = form.name.data

    if form.allow_promo.data:
        user.promo_opt_in = True

    if basket.requires_shipping:
        if not user.shipping:
            user.shipping = UserShipping()

        user.shipping.address_1 = form.address_1.data
        user.shipping.address_2 = form.address_2.data
        user.shipping.town = form.town.data
        user.shipping.postcode = form.postcode.data
        user.shipping.country = form.country.data

    payment_type = form.get_payment_class()

    basket.user = user
    try:
        payment = basket.create_payment(payment_type)
    except VoucherUsedError as e:
        # Voucher has been used since we last checked it at the "choose" stage.
        app.logger.exception("Voucher used at payment stage")
        flash(
            "The voucher you've used does not allow you to buy this many adult tickets. "
            "Please choose fewer tickets.")
        db.session.rollback()
        return redirect(url_for("tickets.main", flow=flow))

    basket.cancel_surplus_purchases()
    db.session.commit()

    # Remove voucher ID from session, if it exists.
    try:
        del session["ticket_voucher"]
    except KeyError:
        pass

    Basket.clear_from_session()

    if not payment:
        empty_baskets.inc()
        app.logger.warn("User tried to pay for empty basket")
        flash(
            "We're sorry, your session information has been lost. Please try ordering again."
        )
        return redirect(url_for("tickets.main", flow=flow))

    if payment_type == GoCardlessPayment:
        return gocardless_start(payment)
    elif payment_type == BankPayment:
        return transfer_start(payment)
    elif payment_type == StripePayment:
        return stripe_start(payment)
Example #17
0
def set_user_currency(currency):
    basket = Basket.from_session(current_user, get_user_currency())
    basket.set_currency(currency)
    session["currency"] = currency
Example #18
0
def logout():
    session.permanent = False
    Basket.clear_from_session()
    logout_user()
    return redirect(get_next_url(default=url_for("base.main")))
Example #19
0
def pay(flow="main"):
    """
        The user is sent here once they've added tickets to their basket.
        This view collects users details, offers payment options, and then
        starts the correct payment flow in the payment app.
    """
    view = get_product_view(flow)

    if not view.is_accessible(current_user, session.get("ticket_voucher")):
        # It's likely the user had a voucher which has been used
        # This happens if they press the back button while at the payment stage.
        if current_user.is_authenticated:
            # Redirect user to their purchases page so they can see their
            # unpaid payment and retry it.
            return redirect(url_for("users.purchases"))
        else:
            abort(404)

    if request.form.get("change_currency") in ("GBP", "EUR"):
        currency = request.form.get("change_currency")
        app.logger.info("Updating currency to %s", currency)
        set_user_currency(currency)
        db.session.commit()

        return redirect(url_for(".pay", flow=flow))

    basket = Basket.from_session(current_user, get_user_currency())
    if not any(basket.values()):
        empty_baskets.inc()

        if current_user.is_authenticated:
            basket.load_purchases_from_db()

        if any(basket.values()):
            # We've lost the user's state, but we can still show them all
            # tickets they've reserved and let them empty their basket.
            flash(
                "Your browser doesn't seem to be storing cookies. This may break some parts of the site."
            )
            app.logger.warn(
                "Basket is empty, so showing reserved tickets (%s)",
                request.headers.get("User-Agent"),
            )

        else:
            app.logger.info(
                "Basket is empty, redirecting back to choose tickets")
            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.")
            return redirect(url_for("tickets.main", flow=flow))

    if basket.requires_shipping:
        if current_user.is_authenticated:
            shipping = current_user.shipping
        else:
            shipping = None

        form = TicketPaymentShippingForm(obj=shipping)
    else:
        form = TicketPaymentForm()

    form.flow = flow

    if current_user.is_authenticated:
        form.name.data = current_user.name
        del form.email
        if current_user.name != current_user.email and not basket.requires_shipping:
            # FIXME: is this helpful?
            del form.name

    if form.validate_on_submit():
        # Valid form submitted, process it
        return start_payment(form, basket, flow)

    form.basket_total.data = basket.total

    return render_template(
        "tickets/payment-choose.html",
        form=form,
        basket=basket,
        total=basket.total,
        flow=flow,
        view=view,
    )
Example #20
0
def logout():
    session.permanent = False
    Basket.clear_from_session()
    logout_user()
    return redirect(url_for('base.main'))
Example #21
0
def set_user_currency(currency):
    basket = Basket.from_session(current_user, get_user_currency())
    basket.set_currency(currency)
    session['currency'] = currency
Example #22
0
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)
Example #23
0
def pay(flow=None):
    if flow is None:
        flow = 'main'

    view = ProductView.get_by_name(flow)
    if not view:
        abort(404)

    if view.token and session.get('ticket_token') != view.token:
        if not current_user.is_anonymous and current_user.has_permission('admin'):
            abort(404)

    if request.form.get("change_currency") in ('GBP', 'EUR'):
        set_user_currency(request.form.get("change_currency"))
        db.session.commit()

        return redirect(url_for('.pay', flow=flow))

    form = TicketPaymentForm()
    form.flow = flow

    if not current_user.is_anonymous:
        del form.email
        if current_user.name != current_user.email:
            del form.name

    basket = Basket.from_session(current_user, get_user_currency())
    if not any(basket.values()):
        empty_baskets.inc()
        if flow == 'main':
            flash("Please select at least one ticket to buy.")
        else:
            flash("Please select at least one item to buy.")
        return redirect(url_for('tickets.main'))

    if form.validate_on_submit():
        if Decimal(form.basket_total.data) != Decimal(basket.total):
            # Check that the user's basket approximately matches what we told them they were paying.
            price_changed.inc()
            app.logger.warn("User's basket has changed value %s -> %s", form.basket_total.data, basket.total)
            flash("""The tickets you selected have changed, possibly because you had two windows open.
                  Please verify that you've selected the correct tickets.""")
            return redirect(url_for('tickets.pay', flow=flow))

        user = current_user
        if user.is_anonymous:
            try:
                new_user = create_current_user(form.email.data, form.name.data)
            except IntegrityError as e:
                app.logger.warn('Adding user raised %r, possible double-click', e)
                return None

            user = new_user

        elif user.name == user.email:
            user.name = form.name.data

        if form.allow_promo.data:
            user.promo_opt_in = True

        if form.gocardless.data:
            payment_type = GoCardlessPayment
        elif form.banktransfer.data:
            payment_type = BankPayment
        elif form.stripe.data:
            payment_type = StripePayment

        basket.user = user
        payment = basket.create_payment(payment_type)
        basket.cancel_surplus_purchases()
        db.session.commit()

        Basket.clear_from_session()

        if not payment:
            empty_baskets.inc()
            app.logger.warn('User tried to pay for empty basket')
            flash("We're sorry, your session information has been lost. Please try ordering again.")
            return redirect(url_for('tickets.main', flow=flow))

        if payment_type == GoCardlessPayment:
            return gocardless_start(payment)
        elif payment_type == BankPayment:
            return transfer_start(payment)
        elif payment_type == StripePayment:
            return stripe_start(payment)

    form.basket_total.data = basket.total

    return render_template('payment-choose.html', form=form,
                           basket=basket, total=basket.total,
                           flow=flow)
Example #24
0
def pay(flow=None):
    if flow is None:
        flow = "main"

    view = ProductView.get_by_name(flow)
    if not view:
        abort(404)

    if view.token and session.get("ticket_token") != view.token:
        if not current_user.is_anonymous and current_user.has_permission("admin"):
            abort(404)

    if request.form.get("change_currency") in ("GBP", "EUR"):
        currency = request.form.get("change_currency")
        app.logger.info("Updating currency to %s", currency)
        set_user_currency(currency)
        db.session.commit()

        return redirect(url_for(".pay", flow=flow))

    basket = Basket.from_session(current_user, get_user_currency())
    if not any(basket.values()):
        empty_baskets.inc()

        if current_user.is_authenticated:
            basket.load_purchases_from_db()

        if any(basket.values()):
            # We've lost the user's state, but we can still show them all
            # tickets they've reserved and let them empty their basket.
            flash(
                "Your browser doesn't seem to be storing cookies. This may break some parts of the site."
            )
            app.logger.warn(
                "Basket is empty, so showing reserved tickets (%s)",
                request.headers.get("User-Agent"),
            )

        else:
            app.logger.info("Basket is empty, redirecting back to choose tickets")
            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.")
            return redirect(url_for("tickets.main", flow=flow))

    if basket.requires_shipping:
        if current_user.is_authenticated:
            shipping = current_user.shipping
        else:
            shipping = None

        form = TicketPaymentShippingForm(obj=shipping)

    else:
        form = TicketPaymentForm()

    form.flow = flow

    if current_user.is_authenticated:
        form.name.data = current_user.name
        del form.email
        if current_user.name != current_user.email and not basket.requires_shipping:
            # FIXME: is this helpful?
            del form.name

    if form.validate_on_submit():
        if Decimal(form.basket_total.data) != Decimal(basket.total):
            # Check that the user's basket approximately matches what we told them they were paying.
            price_changed.inc()
            app.logger.warn(
                "User's basket has changed value %s -> %s",
                form.basket_total.data,
                basket.total,
            )
            flash(
                """The items you selected have changed, possibly because you had two windows open.
                  Please verify that you've selected the correct items."""
            )
            return redirect(url_for("tickets.pay", flow=flow))

        user = current_user
        if user.is_anonymous:
            try:
                new_user = create_current_user(form.email.data, form.name.data)
            except IntegrityError as e:
                app.logger.warn("Adding user raised %r, possible double-click", e)
                return None

            user = new_user

        elif user.name == user.email:
            user.name = form.name.data

        if form.allow_promo.data:
            user.promo_opt_in = True

        if basket.requires_shipping:
            if not user.shipping:
                user.shipping = UserShipping()

            user.shipping.address_1 = form.address_1.data
            user.shipping.address_2 = form.address_2.data
            user.shipping.town = form.town.data
            user.shipping.postcode = form.postcode.data
            user.shipping.country = form.country.data

        if form.gocardless.data:
            payment_type = GoCardlessPayment
        elif form.banktransfer.data:
            payment_type = BankPayment
        elif form.stripe.data:
            payment_type = StripePayment

        basket.user = user
        payment = basket.create_payment(payment_type)
        basket.cancel_surplus_purchases()
        db.session.commit()

        Basket.clear_from_session()

        if not payment:
            empty_baskets.inc()
            app.logger.warn("User tried to pay for empty basket")
            flash(
                "We're sorry, your session information has been lost. Please try ordering again."
            )
            return redirect(url_for("tickets.main", flow=flow))

        if payment_type == GoCardlessPayment:
            return gocardless_start(payment)
        elif payment_type == BankPayment:
            return transfer_start(payment)
        elif payment_type == StripePayment:
            return stripe_start(payment)

    form.basket_total.data = basket.total

    return render_template(
        "tickets/payment-choose.html",
        form=form,
        basket=basket,
        total=basket.total,
        flow=flow,
        view=view,
    )
Example #25
0
def tickets_choose_free(user_id=None):
    free_pts = PriceTier.query.join(Product).filter(
        ~PriceTier.prices.any(Price.price_int > 0), ).order_by(
            Product.name).all()

    if user_id is None:
        form = TicketsNewUserForm()
        user = None
        new_user = True
    else:
        form = TicketsForm()
        user = User.query.get_or_404(user_id)
        new_user = False

    if request.method != 'POST':
        for pt in free_pts:
            form.price_tiers.append_entry()
            form.price_tiers[-1].tier_id.data = pt.id

    pts = {pt.id: pt for pt in free_pts}
    for f in form.price_tiers:
        f._tier = pts[f.tier_id.data]
        values = range(f._tier.personal_limit + 1)
        f.amount.values = values
        f._any = any(values)

    if form.validate_on_submit():
        if not any(f.amount.data for f in form.price_tiers):
            flash('Please choose some tickets to allocate')
            return redirect(url_for('.tickets_choose_free', user_id=user_id))

        if new_user:
            app.logger.info('Creating new user with email %s and name %s',
                            form.email.data, form.name.data)
            user = User(form.email.data, form.name.data)
            db.session.add(user)
            flash('Created account for %s' % form.email.data)

        basket = Basket(user, 'GBP')
        for f in form.price_tiers:
            if f.amount.data:
                basket[f._tier] = f.amount.data

        app.logger.info('Admin basket for %s %s', user.email, basket)

        try:
            basket.create_purchases()
            basket.ensure_purchase_capacity()
            assert basket.total == 0

        except CapacityException as e:
            db.session.rollback()
            app.logger.warn('Limit exceeded creating admin tickets: %s', e)
            return redirect(url_for('.tickets_choose_free', user_id=user_id))

        for p in basket.purchases:
            p.set_state('paid')

        app.logger.info('Allocated %s tickets to user', len(basket.purchases))
        db.session.commit()

        code = user.login_code(app.config['SECRET_KEY'])
        msg = Message('Your complimentary tickets to EMF',
                      sender=app.config['TICKETS_EMAIL'],
                      recipients=[user.email])

        msg.body = render_template('emails/tickets-free.txt',
                                   user=user,
                                   code=code,
                                   tickets=basket.purchases,
                                   new_user=new_user)

        if feature_enabled('ISSUE_TICKETS'):
            attach_tickets(msg, user)

        mail.send(msg)
        db.session.commit()

        flash('Allocated %s ticket(s)' % len(basket.purchases))
        return redirect(url_for('.tickets_choose_free'))

    if new_user:
        users = User.query.order_by(User.id).all()
    else:
        users = None

    return render_template('admin/tickets/tickets-choose-free.html',
                           form=form,
                           user=user,
                           users=users)
Example #26
0
def tickets_reserve(user_id=None):
    pts = PriceTier.query.join(Product, ProductGroup) \
                         .order_by(ProductGroup.name, Product.display_name, Product.id).all()

    if user_id is None:
        form = TicketsNewUserForm()
        user = None
        new_user = True
    else:
        form = TicketsForm()
        user = User.query.get_or_404(user_id)
        new_user = False

    if request.method != 'POST':
        for pt in pts:
            form.price_tiers.append_entry()
            form.price_tiers[-1].tier_id.data = pt.id

    pts = {pt.id: pt for pt in pts}
    for f in form.price_tiers:
        f._tier = pts[f.tier_id.data]
        # TODO: apply per-user limits
        values = range(f._tier.personal_limit + 1)
        f.amount.values = values
        f._any = any(values)

    if form.validate_on_submit():
        if new_user:
            email, name = form.email.data, form.name.data
            if not name:
                name = email

            app.logger.info('Creating new user with email %s and name %s',
                            email, name)
            user = User(email, name)
            flash('Created account for %s' % name)

            db.session.add(user)

        currency = form.currency.data

        if currency:
            basket = Basket(user, currency)
        else:
            basket = Basket(user, 'GBP')

        for f in form.price_tiers:
            if f.amount.data:
                basket[f._tier] = f.amount.data

        app.logger.info('Admin basket for %s %s', user.email, basket)

        try:
            basket.create_purchases()
            basket.ensure_purchase_capacity()

            db.session.commit()

        except CapacityException as e:
            db.session.rollback()
            app.logger.warn('Limit exceeded creating admin tickets: %s', e)
            return redirect(url_for('.tickets_reserve', user_id=user_id))

        code = user.login_code(app.config['SECRET_KEY'])
        msg = Message('Your reserved tickets to EMF',
                      sender=app.config['TICKETS_EMAIL'],
                      recipients=[user.email])

        msg.body = render_template('emails/tickets-reserved.txt',
                                   user=user,
                                   code=code,
                                   tickets=basket.purchases,
                                   new_user=new_user,
                                   currency=currency)

        mail.send(msg)
        db.session.commit()

        flash('Reserved tickets and emailed {}'.format(user.email))
        return redirect(url_for('.tickets_reserve'))

    if new_user:
        users = User.query.order_by(User.id).all()
    else:
        users = None

    return render_template('admin/tickets/tickets-reserve.html',
                           form=form,
                           pts=pts,
                           user=user,
                           users=users)
Example #27
0
def pay(flow=None):
    if flow is None:
        flow = 'main'

    view = ProductView.get_by_name(flow)
    if not view:
        abort(404)

    if view.token and session.get('ticket_token') != view.token:
        if not current_user.is_anonymous and current_user.has_permission(
                'admin'):
            abort(404)

    if request.form.get("change_currency") in ('GBP', 'EUR'):
        set_user_currency(request.form.get("change_currency"))
        db.session.commit()

        return redirect(url_for('.pay', flow=flow))

    form = TicketPaymentForm()
    form.flow = flow

    if not current_user.is_anonymous:
        del form.email
        if current_user.name != current_user.email:
            del form.name

    basket = Basket.from_session(current_user, get_user_currency())
    if not any(basket.values()):
        empty_baskets.inc()
        if flow == 'main':
            flash("Please select at least one ticket to buy.")
        else:
            flash("Please select at least one item to buy.")
        return redirect(url_for('tickets.main'))

    if form.validate_on_submit():
        if Decimal(form.basket_total.data) != Decimal(basket.total):
            # Check that the user's basket approximately matches what we told them they were paying.
            price_changed.inc()
            app.logger.warn("User's basket has changed value %s -> %s",
                            form.basket_total.data, basket.total)
            flash(
                """The tickets you selected have changed, possibly because you had two windows open.
                  Please verify that you've selected the correct tickets.""")
            return redirect(url_for('tickets.pay', flow=flow))

        user = current_user
        if user.is_anonymous:
            try:
                new_user = create_current_user(form.email.data, form.name.data)
            except IntegrityError as e:
                app.logger.warn('Adding user raised %r, possible double-click',
                                e)
                return None

            user = new_user

        elif user.name == user.email:
            user.name = form.name.data

        if form.allow_promo.data:
            user.promo_opt_in = True

        if form.gocardless.data:
            payment_type = GoCardlessPayment
        elif form.banktransfer.data:
            payment_type = BankPayment
        elif form.stripe.data:
            payment_type = StripePayment

        basket.user = user
        payment = basket.create_payment(payment_type)
        basket.cancel_surplus_purchases()
        db.session.commit()

        Basket.clear_from_session()

        if not payment:
            empty_baskets.inc()
            app.logger.warn('User tried to pay for empty basket')
            flash(
                "We're sorry, your session information has been lost. Please try ordering again."
            )
            return redirect(url_for('tickets.main', flow=flow))

        if payment_type == GoCardlessPayment:
            return gocardless_start(payment)
        elif payment_type == BankPayment:
            return transfer_start(payment)
        elif payment_type == StripePayment:
            return stripe_start(payment)

    form.basket_total.data = basket.total

    return render_template('payment-choose.html',
                           form=form,
                           basket=basket,
                           total=basket.total,
                           flow=flow)
Example #28
0
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
    )
Example #29
0
def pay(flow=None):
    if flow is None:
        flow = 'main'

    view = ProductView.get_by_name(flow)
    if not view:
        abort(404)

    if view.token and session.get('ticket_token') != view.token:
        if not current_user.is_anonymous and current_user.has_permission('admin'):
            abort(404)

    if request.form.get("change_currency") in ('GBP', 'EUR'):
        currency = request.form.get("change_currency")
        app.logger.info("Updating currency to %s", currency)
        set_user_currency(currency)
        db.session.commit()

        return redirect(url_for('.pay', flow=flow))

    basket = Basket.from_session(current_user, get_user_currency())
    if not any(basket.values()):
        empty_baskets.inc()

        if current_user.is_authenticated:
            basket.load_purchases_from_db()

        if any(basket.values()):
            # We've lost the user's state, but we can still show them all
            # tickets they've reserved and let them empty their basket.
            flash("Your browser doesn't seem to be storing cookies. This may break some parts of the site.")
            app.logger.warn("Basket is empty, so showing reserved tickets (%s)", request.headers.get('User-Agent'))

        else:
            app.logger.info("Basket is empty, redirecting back to choose tickets")
            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.")
            return redirect(url_for('tickets.main', flow=flow))

    if basket.requires_shipping:
        if current_user.is_authenticated:
            shipping = current_user.shipping
        else:
            shipping = None

        form = TicketPaymentShippingForm(obj=shipping)

    else:
        form = TicketPaymentForm()

    form.flow = flow

    if current_user.is_authenticated:
        form.name.data = current_user.name
        del form.email
        if current_user.name != current_user.email and not basket.requires_shipping:
            # FIXME: is this helpful?
            del form.name

    if form.validate_on_submit():
        if Decimal(form.basket_total.data) != Decimal(basket.total):
            # Check that the user's basket approximately matches what we told them they were paying.
            price_changed.inc()
            app.logger.warn("User's basket has changed value %s -> %s", form.basket_total.data, basket.total)
            flash("""The items you selected have changed, possibly because you had two windows open.
                  Please verify that you've selected the correct items.""")
            return redirect(url_for('tickets.pay', flow=flow))

        user = current_user
        if user.is_anonymous:
            try:
                new_user = create_current_user(form.email.data, form.name.data)
            except IntegrityError as e:
                app.logger.warn('Adding user raised %r, possible double-click', e)
                return None

            user = new_user

        elif user.name == user.email:
            user.name = form.name.data

        if form.allow_promo.data:
            user.promo_opt_in = True

        if basket.requires_shipping:
            if not user.shipping:
                user.shipping = UserShipping()

            user.shipping.address_1 = form.address_1.data
            user.shipping.address_2 = form.address_2.data
            user.shipping.town = form.town.data
            user.shipping.postcode = form.postcode.data
            user.shipping.country = form.country.data

        if form.gocardless.data:
            payment_type = GoCardlessPayment
        elif form.banktransfer.data:
            payment_type = BankPayment
        elif form.stripe.data:
            payment_type = StripePayment

        basket.user = user
        payment = basket.create_payment(payment_type)
        basket.cancel_surplus_purchases()
        db.session.commit()

        Basket.clear_from_session()

        if not payment:
            empty_baskets.inc()
            app.logger.warn('User tried to pay for empty basket')
            flash("We're sorry, your session information has been lost. Please try ordering again.")
            return redirect(url_for('tickets.main', flow=flow))

        if payment_type == GoCardlessPayment:
            return gocardless_start(payment)
        elif payment_type == BankPayment:
            return transfer_start(payment)
        elif payment_type == StripePayment:
            return stripe_start(payment)

    form.basket_total.data = basket.total

    return render_template('payment-choose.html', form=form,
                           basket=basket, total=basket.total,
                           flow=flow, view=view)
Example #30
0
def handle_ticket_selection(form, view: ProductView, flow: str,
                            basket: Basket):
    """
    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 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())

    form.add_to_basket(basket)

    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))

    # Ensure this purchase is valid for this voucher.
    voucher = Voucher.get_by_code(basket.voucher)
    if voucher and not voucher.check_capacity(basket):
        basket.save_to_session()
        if voucher.is_used:
            flash("Your voucher has been used by someone else.")
        else:
            flash(f"You can only purchase {voucher.tickets_remaining} adult "
                  "tickets with this voucher. Please select fewer tickets.")
        return redirect(url_for("tickets.main", flow=flow))

    app.logger.info("Saving basket %s", basket)

    try:
        # Convert the user's basket into a purchase.
        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))
    else:
        return handle_free_tickets(flow, view, basket)
Example #31
0
def test_create_stripe_purchase(user, app, monkeypatch):
    # Add some tickets to a basket (/tickets/choose)
    basket = Basket(user, "GBP")
    tier = PriceTier.query.filter_by(name="full-std").one_or_none()
    basket[tier] = 2

    basket.create_purchases()
    basket.ensure_purchase_capacity()
    db.session.commit()

    # This matches the intent ID in stored fixtures
    intent_id = "pi_1GUslpIcI91cWsdeheAuRsyg"

    with app.test_request_context("/tickets/pay"):
        login_user(user)
        payment = basket.create_payment(StripePayment)
        stripe_start(payment)

    assert payment.state == "new"

    with app.test_request_context(f"/pay/stripe/{payment.id}/capture"):
        login_user(user)
        # Start capture process - this creates a payment intent from fake-stripe
        stripe_capture(payment.id)

        # A payment_intent.created webhook should be generated here, but it
        # doesn't cause any action on our end so we don't simulate this.
        assert payment.intent_id == intent_id
        assert payment.state == "new"

        # User is now on the Stripe form, which captures the card details.
        # Once this is complete, payment details are sent to Stripe and the form
        # submission triggers stripe_capture_post
        stripe_capture_post(payment.id)

    assert payment.state == "charging"

    with app.test_request_context("/stripe-webhook"):
        # Stripe will now send a webhook to notify us of the payment success.
        stripe_payment_intent_updated(
            "payment_intent.succeeded",
            load_webhook_fixture("payment_intent.succeeded"))
        # A charge.succeeded webhook is also sent but we ignore it.

    assert payment.state == "paid"
    assert all(
        purchase.state == "paid" for purchase in
        payment.purchases), "Purchases should be marked as paid after payment"

    # Payment is all paid. Now we test refunding it.
    # Create a refund request for the entire payment, with £20 donation.
    refund_request = RefundRequest(payment=payment,
                                   donation=20,
                                   currency=payment.currency)
    payment.state = "refund-requested"
    db.session.add(refund_request)
    db.session.commit()

    handle_refund_request(refund_request)

    with app.test_request_context("/stripe-webhook"):
        # charge.refunded webhook. We do process this but currently we don't use it for anything.
        stripe_charge_refunded("charge.refunded",
                               load_webhook_fixture("charge.refunded"))

    # Payment should be marked as fully refunded.
    assert payment.state == "refunded"
    assert all(purchase.state == "refunded" for purchase in payment.purchases
               ), "Purchases should be marked as refunded after refund"