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)
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
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)
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)
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')
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')
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
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')
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')
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')
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)
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')
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)
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', ))
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)
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')
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)
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, }
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')
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, }
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', )