def prepare(self, data): if self.request['session']['role'] != 'admin': if data.get('external_ticket_url'): raise JsonErrors.HTTPForbidden( message='external_ticket_url may only be set by admins') elif data.get('external_donation_url'): raise JsonErrors.HTTPForbidden( message='external_donation_url may only be set by admins') timezone: TzInfo = data.pop('timezone', None) if timezone: data['timezone'] = str(timezone) date = data.pop('date', None) if date: dt, duration = prepare_event_start(date['dt'], date['dur'], timezone) data.update( start_ts=dt, duration=duration, ) loc = data.pop('location', None) if loc: data.update( location_name=loc['name'], location_lat=loc['lat'], location_lng=loc['lng'], ) return data
async def host_signup(request): signin_method = request.match_info['site'] model, siw_method = SIGNUP_MODELS[signin_method] m: GrecaptchaModel = await parse_request(request, model) await check_grecaptcha(m, get_ip(request), app=request.app) details = await siw_method(m, app=request.app) company_id = request['company_id'] conn = request['conn'] r = await conn.fetchrow( 'SELECT role, status FROM users WHERE email=$1 AND company=$2', details['email'], company_id) existing_role = None if r: existing_role, status = r if existing_role != 'guest': raise JsonErrors.HTTP470(status='existing-user') if status == 'suspended': raise JsonErrors.HTTP470(message='user suspended') user_id = await request['conn'].fetchval_b( """ INSERT INTO users (:values__names) VALUES :values ON CONFLICT (company, email) DO UPDATE SET role=EXCLUDED.role RETURNING id """, values=Values( company=company_id, role='host', status='active' if signin_method in {'facebook', 'google'} else 'pending', email=details['email'].lower(), first_name=details.get('first_name'), last_name=details.get('last_name'), )) session = await new_session(request) session.update({ 'user_id': user_id, 'user_role': 'host', 'last_active': int(time()) }) await record_action(request, user_id, ActionTypes.host_signup, existing_user=bool(existing_role), signin_method=signin_method) await request.app['email_actor'].send_account_created(user_id) json_str = await request['conn'].fetchval(GET_USER_SQL, company_id, user_id) return raw_json_response(json_str)
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 check_permissions(self): await check_session(self.request, 'admin', 'host') await _check_event_permissions(self.request, check_upcoming=True) user_status = await self.conn.fetchrow( 'SELECT status FROM users WHERE id=$1', self.session['user_id']) if self.session['role'] != 'admin' and user_status != 'active': raise JsonErrors.HTTPForbidden(message='Host not active')
async def check_email(m: EmailModel, app): if await validate_email(m.email, app.loop): return m.dict() else: raise JsonErrors.HTTP470( status='invalid', message=f'"{m.email}" doesn\'t look like an active email address.')
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)
def prepare(self, data): if data.get('external_ticket_url') and self.request['session']['role'] != 'admin': raise JsonErrors.HTTPForbidden(message='external_ticket_url may only be set by admins') date = data.pop('date', None) timezone: TzInfo = data.pop('timezone', None) if timezone: data['timezone'] = str(timezone) if date: dt: datetime = timezone.localize(date['dt'].replace(tzinfo=None)) duration: Optional[int] = date['dur'] if duration: duration = timedelta(seconds=duration) else: dt = datetime(dt.year, dt.month, dt.day) data.update( start_ts=dt, duration=duration, ) loc = data.pop('location', None) if loc: data.update( location_name=loc['name'], location_lat=loc['lat'], location_lng=loc['lng'], ) return data
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_image_upload(request): co_id = request['company_id'] don_opt_id = int(request.match_info['pk']) r = await request['conn'].fetchrow( """ SELECT co.slug, cat.slug, d.image FROM donation_options AS d JOIN categories AS cat ON d.category = cat.id JOIN companies AS co ON cat.company = co.id WHERE d.id = $1 AND cat.company = $2 """, don_opt_id, co_id, ) if not r: raise JsonErrors.HTTPNotFound(message='donation option not found') co_slug, cat_slug, old_image = r content = await request_image(request, expected_size=IMAGE_SIZE) upload_path = Path(co_slug) / cat_slug / str(don_opt_id) image_url = await upload_other( content, upload_path=upload_path, settings=request.app['settings'], req_size=IMAGE_SIZE, thumb=True, ) await request['conn'].execute('UPDATE donation_options SET image=$1 WHERE id=$2', image_url, don_opt_id) if old_image: await delete_image(old_image, request.app['settings']) return json_response(status='success')
async def user_tickets(request): user_id = int(request.match_info['pk']) if request['session']['role'] != 'admin' and user_id != request['session']['user_id']: raise JsonErrors.HTTPForbidden(message='wrong user') json_str = await request['conn'].fetchval(user_tickets_sql, user_id) return raw_json_response(json_str)
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 execute(self, m: Model): action_id = int(self.request.match_info['action_id']) v = await self.conn.execute( 'update actions set extra=extra || $3 where id=$1 and user_id=$2', action_id, self.session['user_id'], json.dumps({'gift_aid': m.dict()}) ) if v != 'UPDATE 1': raise JsonErrors.HTTPNotFound(message='action not found')
async def category_public(request): conn: BuildPgConnection = request['conn'] company_id = request['company_id'] category_slug = request.match_info['category'] json_str = await conn.fetchval(CATEGORY_PUBLIC_SQL, company_id, category_slug) if not json_str: raise JsonErrors.HTTPNotFound(message='category not found') return raw_json_response(json_str)
async def _check_event_permissions(request, check_upcoming=False): event_id = int(request.match_info['id']) r = await request['conn'].fetchrow( """ SELECT host, start_ts FROM events AS e JOIN categories AS cat ON e.category = cat.id WHERE e.id=$1 AND cat.company=$2 """, event_id, request['company_id']) if not r: raise JsonErrors.HTTPNotFound(message='event not found') host_id, start_ts = r if request['session']['role'] != 'admin': if host_id != request['session']['user_id']: raise JsonErrors.HTTPForbidden(message='user is not the host of this event') if check_upcoming and start_ts < datetime.utcnow().replace(tzinfo=timezone.utc): raise JsonErrors.HTTPForbidden(message="you can't modify past events") return event_id
async def _get_cat_img_path(request): cat_id = int(request.match_info['cat_id']) conn: BuildPgConnection = request['conn'] try: co_slug, cat_slug = await conn.fetchrow(CAT_IMAGE_SQL, request['company_id'], cat_id) except TypeError: raise JsonErrors.HTTPNotFound(message='category not found') else: return Path(co_slug) / cat_slug / 'option'
async def event_tickets(request): event_id = int(request.match_info['id']) if request['session']['user_role'] == 'host': host_id = await request['conn'].fetchval('SELECT host FROM events WHERE id=$1', event_id) if host_id != request['session']['user_id']: raise JsonErrors.HTTPForbidden(message='use is not the host of this event') json_str = await request['conn'].fetchval(event_ticket_sql, event_id, request['company_id']) return raw_json_response(json_str)
async def check_item_permissions(self, pk): v = await self.conn.fetchval_b( ':query', query=await self.check_item_permissions_query(pk), print_=self.print_queries, ) if not v: raise JsonErrors.HTTPNotFound( message=f'{self.meta["single_title"]} not found')
async def event_public(request): conn: BuildPgConnection = request['conn'] company_id = request['company_id'] category_slug = request.match_info['category'] event_slug = request.match_info['event'] json_str = await conn.fetchval(event_sql, company_id, category_slug, event_slug) if not json_str: raise JsonErrors.HTTPNotFound(message='event not found') return raw_json_response(json_str)
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 conflict_exc(self, exc: UniqueViolationError): columns = re.search(r'\((.+?)\)', exc.as_dict()['detail']).group(1).split(', ') return JsonErrors.HTTPConflict( message='Conflict', details=[{ 'loc': [col], 'msg': f'This value conflicts with an existing "{col}", try something else.', 'type': 'value_error.conflict', } for col in columns if col in self.model.__fields__])
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 clear_email_def(request): trigger = get_trigger(request) r = await request['conn'].execute( 'DELETE FROM email_definitions WHERE trigger=$1 AND company=$2', trigger, request['company_id'], ) if r == 'DELETE 1': return json_response(status='ok') else: raise JsonErrors.HTTPNotFound( message=f'email definition with trigger "{trigger}" not found')
async def switch_user_status(request): user_id = int(request.match_info['pk']) status = await request['conn'].fetchval( 'SELECT status FROM users WHERE id=$1 AND company=$2', user_id, request['company_id'], ) if not status: raise JsonErrors.HTTPNotFound(message='user not found') new_status = 'suspended' if status == 'active' else 'active' await request['conn'].execute('UPDATE users SET status=$1 WHERE id=$2', new_status, user_id) return json_response(new_status=new_status)
async def check_permissions(self): await check_session(self.request, 'admin') v = await self.conn.fetchval_b( """ SELECT 1 FROM events AS e JOIN categories AS c on e.category = c.id WHERE e.id=:id AND c.company=:company """, id=int(self.request.match_info['id']), company=self.request['company_id'] ) if not v: raise JsonErrors.HTTPNotFound(message='Event not found')
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 check_event_sig(request): company_id = request['company_id'] category_slug = request.match_info['category'] event_slug = request.match_info['event'] r = await request['conn'].fetchrow(event_id_public_sql, company_id, category_slug, event_slug) # so we always do the hashing even for an event that does exist to avoid timing attack, probably over kill if r: event_id, event_is_public = r else: event_id, event_is_public = 0, False if not event_is_public: url_sig = request.match_info.get('sig') if not url_sig: raise JsonErrors.HTTPNotFound(message='event not found') sig = hmac.new(request.app['settings'].auth_key.encode(), f'/{category_slug}/{event_slug}/'.encode(), digestmod=hashlib.md5).hexdigest() if not compare_digest(url_sig, sig): raise JsonErrors.HTTPNotFound(message='event not found') return event_id
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')