Пример #1
0
async def guest_signin(request):
    model, siw_method = SIGNIN_MODELS[request.match_info['site']]
    m = await parse_request(request, model)
    details = await siw_method(m, app=request.app)

    company_id = request['company_id']
    user_id, status = await request['conn'].fetchrow_b(
        CREATE_USER_SQL,
        values=Values(
            company=company_id,
            role='guest',
            email=details['email'].lower(),
            first_name=details.get('first_name'),
            last_name=details.get('last_name'),
        ))
    if status == 'suspended':
        raise JsonErrors.HTTPBadRequest(message='user suspended')

    session = await new_session(request)
    session.update({
        'user_id': user_id,
        'user_role': 'guest',
        'last_active': int(time())
    })

    await record_action(request, user_id, ActionTypes.guest_signin)

    json_str = await request['conn'].fetchval(GET_USER_SQL, company_id,
                                              user_id)
    return raw_json_response(json_str)
Пример #2
0
 async def prepare_add_data(self, data):
     role_type = data.pop('role_type')
     if role_type not in {UserRoles.host, UserRoles.admin}:
         raise JsonErrors.HTTPBadRequest(
             message='role must be either "host" or "admin".')
     data.update(role=role_type, company=self.request['company_id'])
     return data
Пример #3
0
async def donation_after_prepare(request):
    donation_option_id = int(request.match_info['don_opt_id'])
    event_id = int(request.match_info['event_id'])
    conn = request['conn']
    r = await conn.fetchrow(
        """
        SELECT opt.name, opt.amount, cat.id
        FROM donation_options AS opt
        JOIN categories AS cat ON opt.category = cat.id
        WHERE opt.id = $1 AND opt.live AND cat.company = $2
        """,
        donation_option_id,
        request['company_id'],
    )
    if not r:
        raise JsonErrors.HTTPBadRequest(message='donation option not found')

    name, amount, cat_id = r
    event = await conn.fetchval(
        'SELECT 1 FROM events WHERE id=$1 AND category=$2', event_id, cat_id)
    if not event:
        raise JsonErrors.HTTPBadRequest(
            message='event not found on the same category as donation_option')

    user_id = request['session']['user_id']
    action_id = await record_action_id(request,
                                       user_id,
                                       ActionTypes.donate_prepare,
                                       event_id=event_id,
                                       donation_option_id=donation_option_id)

    client_secret = await stripe_payment_intent(
        user_id=user_id,
        price_cents=int(amount * 100),
        description=f'donation to {name} ({donation_option_id}) after booking',
        metadata={
            'purpose': 'donate',
            'event_id': event_id,
            'reserve_action_id': action_id,
            'user_id': user_id
        },
        company_id=request['company_id'],
        idempotency_key=f'idempotency-donate-{action_id}',
        app=request.app,
        conn=conn,
    )
    return json_response(client_secret=client_secret, action_id=action_id)
Пример #4
0
    async def execute(self, m: Model):
        event_id = await _check_event_permissions(self.request)
        ticket_id = int(self.request.match_info['tid'])
        r = await self.conn.fetchrow(
            """
            select a.type, t.price, a.extra->>'charge_id'
            from tickets as t
            join actions as a on t.booked_action = a.id
            where t.event = $1 and t.id = $2 and t.status = 'booked'
            """,
            event_id,
            ticket_id,
        )
        if not r:
            raise JsonErrors.HTTPNotFound(message='Ticket not found')
        booking_type, price, charge_id = r
        if m.refund_amount is not None:
            if booking_type != ActionTypes.buy_tickets:
                raise JsonErrors.HTTPBadRequest(
                    message=
                    'Refund not possible unless ticket was bought through stripe.'
                )
            if m.refund_amount > price:
                raise JsonErrors.HTTPBadRequest(
                    message=f'Refund amount must not exceed {price:0.2f}.')

        async with self.conn.transaction():
            action_id = await record_action_id(
                self.request, self.session['user_id'],
                ActionTypes.cancel_booked_tickets)
            await self.conn.execute(
                "update tickets set status='cancelled', cancel_action=$1 where id=$2",
                action_id, ticket_id)
            await self.conn.execute('SELECT check_tickets_remaining($1, $2)',
                                    event_id, self.settings.ticket_ttl)
            if m.refund_amount is not None:
                await stripe_refund(
                    refund_charge_id=charge_id,
                    ticket_id=ticket_id,
                    amount=int(m.refund_amount * 100),
                    user_id=self.session['user_id'],
                    company_id=self.request['company_id'],
                    app=self.app,
                    conn=self.conn,
                )
        await self.app['email_actor'].send_tickets_available(event_id)
Пример #5
0
async def category_default_image(request):
    m = await parse_request(request, ImageActionModel)

    path = await _get_cat_img_path(request)
    images = await list_images(path, request.app['settings'])
    if m.image not in images:
        raise JsonErrors.HTTPBadRequest(message='image does not exist')
    cat_id = int(request.match_info['cat_id'])
    await request['conn'].execute('UPDATE categories SET image = $1 WHERE id = $2', m.image, cat_id)
    return json_response(status='success')
Пример #6
0
    async def execute(self, m: Model):
        event_id = await _check_event_permissions(self.request, check_upcoming=True)
        existing = [tt for tt in m.ticket_types if tt.id]
        deleted_with_tickets = await self.conn.fetchval(
            """
            SELECT 1
            FROM ticket_types AS tt
            JOIN tickets AS t ON tt.id = t.ticket_type
            WHERE tt.event=$1 AND NOT (tt.id=ANY($2))
            GROUP BY tt.id
            """, event_id, [tt.id for tt in existing]
        )
        if deleted_with_tickets:
            raise JsonErrors.HTTPBadRequest(message='ticket types deleted which have ticket associated with them')

        async with self.conn.transaction():
            await self.conn.fetchval(
                """
                DELETE FROM ticket_types
                WHERE ticket_types.event=$1 AND NOT (ticket_types.id=ANY($2))
                """, event_id, [tt.id for tt in existing]
            )

            for tt in existing:
                v = await self.conn.execute_b(
                    'UPDATE ticket_types SET :values WHERE id=:id AND event=:event',
                    values=SetValues(**tt.dict(exclude={'id'})),
                    id=tt.id,
                    event=event_id,
                )
                if v != 'UPDATE 1':
                    raise JsonErrors.HTTPBadRequest(message='wrong ticket updated')

            new = [tt for tt in m.ticket_types if not tt.id]
            if new:
                await self.conn.execute_b(
                    """
                    INSERT INTO ticket_types (:values__names) VALUES :values
                    """,
                    values=MultipleValues(*(Values(event=event_id, **tt.dict(exclude={'id'})) for tt in new))
                )
            await record_action(self.request, self.request['session']['user_id'], ActionTypes.edit_event,
                                event_id=event_id, subtype='edit-ticket-types')
Пример #7
0
 def where_pk(self, pk) -> Where:
     if pk < 1:
         raise JsonErrors.HTTPBadRequest(
             message='request pk must be greater than 0')
     where = self.where()
     is_pk = self.pk_ref() == pk
     if where:
         where.logic = where.logic & is_pk
     else:
         where = Where(is_pk)
     return where
Пример #8
0
async def set_event_image_existing(request):
    m = await parse_request(request, ImageModel)
    if not m.image.startswith(request.app['settings'].s3_domain):
        raise JsonErrors.HTTPBadRequest(message='image not allowed')

    await _delete_existing_image(request)

    event_id = int(request.match_info['id'])
    await request['conn'].execute('UPDATE events SET image=$1 WHERE id=$2', m.image, event_id)
    await record_action(request, request['session']['user_id'], ActionTypes.edit_event,
                        event_id=event_id, subtype='set-image-existing')
    return json_response(status='success')
Пример #9
0
async def category_delete_image(request):
    m = await parse_request(request, ImageModel)
    await _check_image_exists(request, m)
    cat_id = int(request.match_info['cat_id'])

    dft_image = await request['conn'].fetchval(
        'SELECT image FROM categories WHERE id=$1', cat_id)
    if dft_image == m.image:
        raise JsonErrors.HTTPBadRequest(
            message='default image may not be be deleted')

    await delete_image(m.image, request.app['settings'])
    return json_response(status='success')
Пример #10
0
    async def edit(self, pk) -> web.Response:
        await self.check_item_permissions(pk)
        m = await parse_request_ignore_missing(self.request, self.model)

        data = await self.prepare_edit_data(pk, m.dict(exclude_unset=True))
        if not data:
            raise JsonErrors.HTTPBadRequest(message=f'no data to save')

        try:
            await self.edit_execute(pk, **data)
        except UniqueViolationError as e:
            raise self.conflict_exc(e)
        else:
            return json_response(status='ok')
Пример #11
0
async def set_password(request):
    conn = request['conn']
    m = await parse_request(request,
                            PasswordModel,
                            headers_=HEADER_CROSS_ORIGIN)
    user_id = decrypt_json(request.app,
                           m.token.encode(),
                           ttl=3600 * 24 * 7,
                           headers_=HEADER_CROSS_ORIGIN)
    nonce = m.token[:20]

    already_used = await conn.fetchval(
        """
        SELECT 1 FROM actions
        WHERE user_id=$1 AND type='password-reset' AND now() - ts < interval '7 days' AND extra->>'nonce'=$2
        """,
        user_id,
        nonce,
    )
    if already_used:
        raise JsonErrors.HTTP470(
            message='This password reset link has already been used.',
            headers_=HEADER_CROSS_ORIGIN)

    user = await conn.fetchrow(
        'SELECT id, first_name, last_name, email, role, status, company FROM users WHERE id=$1',
        user_id)
    user = dict(user)
    if user.pop('company') != request['company_id']:
        # should not happen
        raise JsonErrors.HTTPBadRequest(
            message='company and user do not match')
    if user['status'] == 'suspended':
        raise JsonErrors.HTTP470(
            message='user suspended, password update not allowed.',
            headers_=HEADER_CROSS_ORIGIN)

    pw_hash = mk_password(m.password1, request.app['settings'])
    del m

    await conn.execute(
        "UPDATE users SET password_hash=$1, status='active' WHERE id=$2",
        pw_hash, user_id)
    await record_action(request,
                        user_id,
                        ActionTypes.password_reset,
                        nonce=nonce)
    return successful_login(user, request.app, HEADER_CROSS_ORIGIN)
Пример #12
0
async def category_add_image(request):
    try:
        p = await request.post()
    except ValueError:
        raise HTTPRequestEntityTooLarge
    image = p['image']
    content = image.file.read()
    try:
        check_size_save(content)
    except ValueError as e:
        raise JsonErrors.HTTPBadRequest(message=str(e))

    upload_path = await _get_cat_img_path(request)
    await resize_upload(content, upload_path, request.app['settings'])

    return json_response(status='success')
Пример #13
0
    async def execute(self, m: Model):
        ticket_type_id = int(self.request.match_info['tt_id'])
        user_id = self.session['user_id']

        r = await self.conn.fetchrow(
            """
            SELECT tt.price, tt.custom_amount, e.id, e.name
            FROM ticket_types tt
            JOIN events e ON tt.event = e.id
            WHERE tt.active=TRUE AND tt.mode='donation' AND tt.id=$1 AND status = 'published' AND
              external_ticket_url IS NULL AND external_donation_url IS NULL
            """,
            ticket_type_id,
        )
        if not r:
            raise JsonErrors.HTTPBadRequest(message='Ticket type not found')
        donation_amount, custom_amount_tt, event_id, event_name = r

        if custom_amount_tt:
            donation_amount = m.custom_amount

        action_id = await record_action_id(
            self.request,
            user_id,
            ActionTypes.donate_direct_prepare,
            event_id=event_id,
            ticket_type_id=ticket_type_id,
            donation_amount=float(donation_amount),
        )

        client_secret = await stripe_payment_intent(
            user_id=user_id,
            price_cents=int(donation_amount * 100),
            description=
            f'donation to {event_name} (id {event_id}, ticket type {ticket_type_id})',
            metadata={
                'purpose': 'donate-direct',
                'event_id': event_id,
                'reserve_action_id': action_id,
                'user_id': user_id,
            },
            company_id=self.request['company_id'],
            idempotency_key=f'idempotency-donate-direct-{action_id}',
            app=self.request.app,
            conn=self.conn,
        )
        return dict(client_secret=client_secret, action_id=action_id)
Пример #14
0
async def guest_signup(request):
    signin_method = request.match_info['site']
    model, siw_method = SIGNIN_MODELS[signin_method]
    m: BaseModel = await parse_request(request, model)

    siw_used = signin_method in {'facebook', 'google'}
    if not siw_used:
        await check_grecaptcha(m, request)

    details = await siw_method(m, app=request.app)

    user_email = details['email'].lower()
    user_id, status = await request['conn'].fetchrow_b(
        CREATE_USER_SQL,
        values=Values(
            company=request['company_id'],
            role='guest',
            email=user_email,
            status='active' if siw_used else 'pending',
            first_name=details.get('first_name'),
            last_name=details.get('last_name'),
        ),
    )
    if status == 'suspended':
        raise JsonErrors.HTTPBadRequest(message='user suspended')

    session = await new_session(request)
    session.update({
        'user_id': user_id,
        'role': 'guest',
        'last_active': int(time())
    })

    await record_action(request,
                        user_id,
                        ActionTypes.guest_signin,
                        signin_method=signin_method)

    return json_response(user=dict(
        id=user_id,
        first_name=details.get('first_name'),
        last_name=details.get('last_name'),
        email=user_email,
        role='guest',
    ))
Пример #15
0
 async def execute(self, m: Model):
     # no ttl since a user may try to cancel a reservation after it has expired
     res = Reservation(**decrypt_json(self.app, m.booking_token))
     async with self.conn.transaction():
         user_id = await self.conn.fetchval(
             'SELECT user_id FROM actions WHERE id=$1', res.action_id)
         v = await self.conn.execute(
             "DELETE FROM tickets WHERE reserve_action=$1 AND status='reserved'",
             res.action_id)
         if v == 'DELETE 0':
             # no tickets were deleted
             raise JsonErrors.HTTPBadRequest(message='no tickets deleted')
         await self.conn.execute('SELECT check_tickets_remaining($1, $2)',
                                 res.event_id, self.settings.ticket_ttl)
         await record_action(self.request,
                             user_id,
                             ActionTypes.cancel_reserved_tickets,
                             event_id=res.event_id)
Пример #16
0
 async def edit_execute(self, pk, **data):
     try:
         await super().edit_execute(pk, **data)
     except CheckViolationError as exc:
         if exc.constraint_name != 'ticket_limit_check':  # pragma: no branch
             raise  # pragma: no cover
         raise JsonErrors.HTTPBadRequest(
             message='Invalid Ticket Limit',
             details=[
                 {
                     'loc': ['ticket_limit'],
                     'msg': f'May not be less than the number of tickets already booked.',
                     'type': 'value_error.too_low',
                 }
             ]
         )
     else:
         await record_action(self.request, self.request['session']['user_id'], ActionTypes.edit_event,
                             event_id=pk, subtype='edit-event')
Пример #17
0
async def set_password(request):
    h = {'Access-Control-Allow-Origin': 'null'}
    conn = request['conn']
    m = await parse_request(request, PasswordModel, error_headers=h)
    data = decrypt_json(request.app, m.token, ttl=3600 * 24 * 7)
    user_id, nonce = data['user_id'], data['nonce']

    already_used = await conn.fetchval(
        """
        SELECT 1 FROM actions
        WHERE user_id=$1 AND type='password-reset' AND now() - ts < interval '7 days' AND extra->>'nonce'=$2
        """, user_id, nonce)
    if already_used:
        raise JsonErrors.HTTP470(
            message='This password reset link has already been used.')

    company_id, status = await conn.fetchrow(
        'SELECT company, status FROM users WHERE id=$1', user_id)
    if company_id != request['company_id']:
        # should not happen
        raise JsonErrors.HTTPBadRequest(
            message='company and user do not match')
    if status == 'suspended':
        raise JsonErrors.HTTP470(
            message='user suspended, password update not allowed.')

    pw_hash = mk_password(m.password1, request.app['settings'])
    del m

    await conn.execute(
        "UPDATE users SET password_hash=$1, status='active' WHERE id=$2",
        pw_hash, user_id)
    await record_action(request,
                        user_id,
                        ActionTypes.password_reset,
                        nonce=nonce)
    return json_response(status='success', headers_=h)
Пример #18
0
    async def execute(self, m: Model):  # noqa: C901 (ignore complexity)
        event_id = int(self.request.match_info['id'])
        ticket_count = len(m.tickets)

        if ticket_count > self.settings.max_tickets:
            raise JsonErrors.HTTPBadRequest(
                message='Too many tickets reserved')

        user_id = self.session['user_id']

        status, external_ticket_url, event_name, cover_costs_percentage = await self.conn.fetchrow(
            """
            SELECT e.status, e.external_ticket_url, e.name, c.cover_costs_percentage
            FROM events AS e
            JOIN categories c on e.category = c.id
            WHERE c.company=$1 AND e.id=$2
            """, self.request['company_id'], event_id)

        if status != 'published':
            raise JsonErrors.HTTPBadRequest(message='Event not published')

        if external_ticket_url is not None:
            raise JsonErrors.HTTPBadRequest(
                message='Cannot reserve ticket for an externally ticketed event'
            )

        r = await self.conn.fetchrow(
            'SELECT price FROM ticket_types WHERE event=$1 AND id=$2',
            event_id, m.ticket_type)
        if not r:
            raise JsonErrors.HTTPBadRequest(message='Ticket type not found')
        item_price, *_ = r

        if self.settings.ticket_reservation_precheck:  # should only be false during CheckViolationError tests
            tickets_remaining = await self.conn.fetchval(
                'SELECT check_tickets_remaining($1, $2)', event_id,
                self.settings.ticket_ttl)
            if tickets_remaining is not None and ticket_count > tickets_remaining:
                raise JsonErrors.HTTP470(
                    message=f'only {tickets_remaining} tickets remaining',
                    tickets_remaining=tickets_remaining)

        total_price, item_extra_donated = None, None
        if item_price:
            total_price = item_price * ticket_count
            if cover_costs_percentage and m.tickets[0].cover_costs:
                item_extra_donated = item_price * cover_costs_percentage / 100
                total_price += item_extra_donated * ticket_count

        try:
            async with self.conn.transaction():
                update_user_preferences = await self.create_users(m.tickets)

                action_id = await record_action_id(self.request,
                                                   user_id,
                                                   ActionTypes.reserve_tickets,
                                                   event_id=event_id)
                ticket_values = [
                    Values(
                        email=t.email and t.email.lower(),
                        first_name=t.first_name,
                        last_name=t.last_name,
                        extra_info=t.extra_info or None,
                    ) for t in m.tickets
                ]

                await self.conn.execute_b(
                    """
                    WITH v (email, first_name, last_name, extra_info) AS (VALUES :values)
                    INSERT INTO tickets (event, reserve_action, ticket_type, price, extra_donated, user_id,
                      first_name, last_name, extra_info)
                    SELECT :event, :reserve_action, :ticket_type, :price, :extra_donated, u.id,
                      v.first_name, v.last_name, v.extra_info FROM v
                    LEFT JOIN users AS u ON v.email=u.email AND u.company=:company_id
                    """,
                    event=event_id,
                    reserve_action=action_id,
                    ticket_type=m.ticket_type,
                    price=item_price,
                    extra_donated=item_extra_donated,
                    company_id=self.request['company_id'],
                    values=MultipleValues(*ticket_values),
                )
                await self.conn.execute(
                    'SELECT check_tickets_remaining($1, $2)', event_id,
                    self.settings.ticket_ttl)
        except CheckViolationError as exc:
            if exc.constraint_name != 'ticket_limit_check':  # pragma: no branch
                raise  # pragma: no cover
            logger.warning('CheckViolationError: %s', exc)
            raise JsonErrors.HTTPBadRequest(
                message='insufficient tickets remaining')

        res = Reservation(
            user_id=user_id,
            action_id=action_id,
            price_cent=total_price and int(total_price * 100),
            event_id=event_id,
            ticket_count=ticket_count,
            event_name=event_name,
        )
        if total_price:
            client_secret = await stripe_buy_intent(res,
                                                    self.request['company_id'],
                                                    self.app, self.conn)
        else:
            client_secret = None
        if update_user_preferences:
            # has to happen after the transactions is finished
            await self.app['donorfy_actor'].update_user(
                self.request['session']['user_id'], update_user=False)
        return {
            'booking_token':
            encrypt_json(self.app, res.dict()),
            'action_id':
            action_id,
            'ticket_count':
            ticket_count,
            'item_price':
            item_price and float(item_price),
            'extra_donated':
            item_extra_donated and float(item_extra_donated * ticket_count),
            'total_price':
            total_price and float(total_price),
            'timeout':
            int(time()) + self.settings.ticket_ttl - 30,
            'client_secret':
            client_secret,
        }
Пример #19
0
async def _check_image_exists(request, m: ImageModel):
    path = await _get_cat_img_path(request)
    images = await list_images(path, request.app['settings'])
    if m.image not in images:
        raise JsonErrors.HTTPBadRequest(message='image does not exist')
Пример #20
0
    async def execute(self, m: Model):
        event_id = int(self.request.match_info['id'])
        ticket_count = len(m.tickets)
        if ticket_count < 1:
            raise JsonErrors.HTTPBadRequest(message='at least one ticket must be purchased')

        status, event_price, event_name = await self.conn.fetchrow(
            """
            SELECT e.status, e.price, e.name
            FROM events AS e
            JOIN categories c on e.category = c.id
            WHERE c.company=$1 AND e.id=$2
            """,
            self.request['company_id'], event_id
        )
        if status != 'published':
            raise JsonErrors.HTTPBadRequest(message='Event not published')

        tickets_remaining = await self.conn.fetchval(
            'SELECT check_tickets_remaining($1, $2)',
            event_id, self.settings.ticket_ttl
        )

        if tickets_remaining is not None and ticket_count > tickets_remaining:
            raise JsonErrors.HTTP470(message=f'only {tickets_remaining} tickets remaining',
                                     tickets_remaining=tickets_remaining)

        # TODO check user isn't already booked

        try:
            async with self.conn.transaction():
                user_lookup = await self.create_users(m.tickets)

                action_id = await record_action_id(self.request, self.session['user_id'], ActionTypes.reserve_tickets)
                await self.conn.execute_b(
                    'INSERT INTO tickets (:values__names) VALUES :values',
                    values=MultipleValues(*[
                        Values(
                            event=event_id,
                            user_id=user_lookup[t.email.lower()] if t.email else None,
                            reserve_action=action_id,
                            extra=to_json_if(t.dict(include={'dietary_req', 'extra_info'})),
                        )
                        for t in m.tickets
                    ])
                )
                await self.conn.execute('SELECT check_tickets_remaining($1, $2)', event_id, self.settings.ticket_ttl)
        except CheckViolationError as e:
            logger.warning('CheckViolationError: %s', e)
            raise JsonErrors.HTTPBadRequest(message='insufficient tickets remaining')

        user = await self.conn.fetchrow(
            """
            SELECT id, full_name(first_name, last_name, email) AS name, email, role
            FROM users
            WHERE id=$1
            """,
            self.session['user_id']
        )
        # TODO needs to work when the event is free
        price_cent = int(event_price * ticket_count * 100)
        res = Reservation(
            user_id=self.session['user_id'],
            action_id=action_id,
            price_cent=price_cent,
            event_id=event_id,
            ticket_count=ticket_count,
            event_name=event_name,
        )
        return {
            'booking_token': encrypt_json(self.app, res.dict()),
            'ticket_count': ticket_count,
            'item_price_cent': int(event_price * 100),
            'total_price_cent': price_cent,
            'user': dict(user),
            'timeout': int(time()) + self.settings.ticket_ttl,
        }
Пример #21
0
    async def execute(self, m: Model):
        event_id = await _check_event_permissions(self.request,
                                                  check_upcoming=True)
        existing = [tt for tt in m.ticket_types if tt.id]
        mode = m.ticket_types[0].mode

        if not all(tt.mode == mode for tt in m.ticket_types):
            raise JsonErrors.HTTPBadRequest(
                message='all ticket types must have the same mode')

        existing_ids = [tt.id for tt in existing]
        deleted_with_tickets = await self.conn.fetchval(
            """
            SELECT 1
            FROM ticket_types AS tt
            JOIN tickets AS t ON tt.id = t.ticket_type
            WHERE tt.event=$1 AND mode=$2 AND NOT (tt.id=ANY($3))
            GROUP BY tt.id
            """,
            event_id,
            mode.value,
            existing_ids,
        )
        if deleted_with_tickets:
            raise JsonErrors.HTTPBadRequest(
                message=
                'ticket types deleted which have ticket associated with them')

        changed_type = await self.conn.fetchval(
            'select 1 from ticket_types tt where tt.event=$1 and mode!=$2 and tt.id=ANY($3)',
            event_id,
            mode.value,
            existing_ids,
        )
        if changed_type:
            raise JsonErrors.HTTPBadRequest(
                message='ticket type modes should not change')

        async with self.conn.transaction():
            await self.conn.fetchval(
                """
                DELETE FROM ticket_types
                WHERE ticket_types.event=$1
                      AND ticket_types.mode=$2
                      AND NOT ticket_types.custom_amount
                      AND NOT (ticket_types.id=ANY($3))
                """,
                event_id,
                mode.value,
                [tt.id for tt in existing],
            )

            for tt in existing:
                v = await self.conn.execute_b(
                    'UPDATE ticket_types SET :values WHERE id=:id AND event=:event AND NOT ticket_types.custom_amount',
                    values=SetValues(**tt.dict(exclude={'id'})),
                    id=tt.id,
                    event=event_id,
                )
                if v != 'UPDATE 1':
                    raise JsonErrors.HTTPBadRequest(
                        message='wrong ticket updated')

            new = [tt for tt in m.ticket_types if not tt.id]
            if new:
                await self.conn.execute_b(
                    """
                    INSERT INTO ticket_types (:values__names) VALUES :values
                    """,
                    values=MultipleValues(
                        *(Values(event=event_id, **tt.dict(exclude={'id'}))
                          for tt in new)),
                )
            await record_action(
                self.request,
                self.request['session']['user_id'],
                ActionTypes.edit_event,
                event_id=event_id,
                subtype='edit-ticket-types',
            )