2016-12-03 18:51:33 +01:00
|
|
|
# Webhooks for external integrations.
|
2017-11-16 00:43:10 +01:00
|
|
|
import time
|
2020-06-13 05:24:42 +02:00
|
|
|
from typing import Any, Dict, Optional, Sequence, Tuple
|
2017-11-16 00:43:10 +01:00
|
|
|
|
|
|
|
from django.http import HttpRequest, HttpResponse
|
|
|
|
|
2020-08-20 00:32:15 +02:00
|
|
|
from zerver.decorator import webhook_view
|
2020-08-19 22:26:38 +02:00
|
|
|
from zerver.lib.exceptions import UnsupportedWebhookEventType
|
2017-10-31 04:25:48 +01:00
|
|
|
from zerver.lib.request import REQ, has_request_variables
|
2019-02-02 23:53:55 +01:00
|
|
|
from zerver.lib.response import json_success
|
2018-12-06 18:40:43 +01:00
|
|
|
from zerver.lib.timestamp import timestamp_to_datetime
|
2020-08-19 22:14:40 +02:00
|
|
|
from zerver.lib.webhooks.common import check_send_webhook_message
|
2017-05-02 01:00:50 +02:00
|
|
|
from zerver.models import UserProfile
|
2016-12-03 18:51:33 +01:00
|
|
|
|
2020-01-14 22:06:24 +01:00
|
|
|
|
2018-12-15 23:14:09 +01:00
|
|
|
class SuppressedEvent(Exception):
|
2018-12-06 19:17:51 +01:00
|
|
|
pass
|
|
|
|
|
2018-12-15 23:14:09 +01:00
|
|
|
class NotImplementedEventType(SuppressedEvent):
|
2018-12-06 09:06:23 +01:00
|
|
|
pass
|
|
|
|
|
2020-08-20 00:32:15 +02:00
|
|
|
@webhook_view('Stripe')
|
2016-12-03 18:51:33 +01:00
|
|
|
@has_request_variables
|
2017-12-04 11:57:04 +01:00
|
|
|
def api_stripe_webhook(request: HttpRequest, user_profile: UserProfile,
|
|
|
|
payload: Dict[str, Any]=REQ(argument_type='body'),
|
2018-05-10 19:34:01 +02:00
|
|
|
stream: str=REQ(default='test')) -> HttpResponse:
|
2018-12-06 19:17:51 +01:00
|
|
|
try:
|
2018-12-13 19:22:19 +01:00
|
|
|
topic, body = topic_and_body(payload)
|
2018-12-06 09:06:23 +01:00
|
|
|
except SuppressedEvent: # nocoverage
|
2018-12-15 23:14:09 +01:00
|
|
|
return json_success()
|
2018-12-13 19:22:19 +01:00
|
|
|
check_send_webhook_message(request, user_profile, topic, body)
|
2018-12-06 19:17:51 +01:00
|
|
|
return json_success()
|
|
|
|
|
|
|
|
def topic_and_body(payload: Dict[str, Any]) -> Tuple[str, str]:
|
2018-11-21 01:39:37 +01:00
|
|
|
event_type = payload["type"] # invoice.created, customer.subscription.created, etc
|
|
|
|
if len(event_type.split('.')) == 3:
|
|
|
|
category, resource, event = event_type.split('.')
|
|
|
|
else:
|
|
|
|
resource, event = event_type.split('.')
|
|
|
|
category = resource
|
|
|
|
|
|
|
|
object_ = payload["data"]["object"] # The full, updated Stripe object
|
|
|
|
|
|
|
|
# Set the topic to the customer_id when we can
|
|
|
|
topic = ''
|
|
|
|
customer_id = object_.get("customer", None)
|
|
|
|
if customer_id is not None:
|
|
|
|
# Running into the 60 character topic limit.
|
|
|
|
# topic = '[{}](https://dashboard.stripe.com/customers/{})' % (customer_id, customer_id)
|
|
|
|
topic = customer_id
|
2016-12-23 17:21:51 +01:00
|
|
|
body = None
|
2016-12-23 17:00:32 +01:00
|
|
|
|
2020-06-13 05:24:42 +02:00
|
|
|
def update_string(blacklist: Sequence[str] = []) -> str:
|
2018-12-06 09:06:23 +01:00
|
|
|
assert('previous_attributes' in payload['data'])
|
|
|
|
previous_attributes = payload['data']['previous_attributes']
|
|
|
|
for attribute in blacklist:
|
|
|
|
previous_attributes.pop(attribute, None)
|
|
|
|
if not previous_attributes: # nocoverage
|
|
|
|
raise SuppressedEvent()
|
|
|
|
return ''.join('\n* ' + attribute.replace('_', ' ').capitalize() +
|
2018-12-06 18:40:43 +01:00
|
|
|
' is now ' + stringify(object_[attribute])
|
2018-12-06 09:06:23 +01:00
|
|
|
for attribute in sorted(previous_attributes.keys()))
|
|
|
|
|
2020-06-13 05:24:42 +02:00
|
|
|
def default_body(update_blacklist: Sequence[str] = []) -> str:
|
2018-12-06 09:06:23 +01:00
|
|
|
body = '{resource} {verbed}'.format(
|
2018-11-21 01:39:37 +01:00
|
|
|
resource=linkified_id(object_['id']), verbed=event.replace('_', ' '))
|
2018-12-06 09:06:23 +01:00
|
|
|
if event == 'updated':
|
|
|
|
return body + update_string(blacklist=update_blacklist)
|
|
|
|
return body
|
2018-11-21 01:39:37 +01:00
|
|
|
|
|
|
|
if category == 'account': # nocoverage
|
2018-12-06 09:06:23 +01:00
|
|
|
if resource == 'account':
|
|
|
|
if event == 'updated':
|
2019-04-04 22:54:07 +02:00
|
|
|
if 'previous_attributes' not in payload['data']:
|
|
|
|
raise SuppressedEvent()
|
2018-12-06 09:06:23 +01:00
|
|
|
topic = "account updates"
|
|
|
|
body = update_string()
|
2017-08-24 17:31:04 +02:00
|
|
|
else:
|
2018-11-21 01:39:37 +01:00
|
|
|
# Part of Stripe Connect
|
2018-12-06 19:17:51 +01:00
|
|
|
raise NotImplementedEventType()
|
2018-11-21 01:39:37 +01:00
|
|
|
if category == 'application_fee': # nocoverage
|
|
|
|
# Part of Stripe Connect
|
2018-12-06 19:17:51 +01:00
|
|
|
raise NotImplementedEventType()
|
2018-11-21 01:39:37 +01:00
|
|
|
if category == 'balance': # nocoverage
|
|
|
|
# Not that interesting to most businesses, I think
|
2018-12-06 19:17:51 +01:00
|
|
|
raise NotImplementedEventType()
|
2018-11-21 01:39:37 +01:00
|
|
|
if category == 'charge':
|
|
|
|
if resource == 'charge':
|
2018-12-02 09:21:09 +01:00
|
|
|
if not topic: # only in legacy fixtures
|
2018-11-21 01:39:37 +01:00
|
|
|
topic = 'charges'
|
|
|
|
body = "{resource} for {amount} {verbed}".format(
|
|
|
|
resource=linkified_id(object_['id']),
|
|
|
|
amount=amount_string(object_['amount'], object_['currency']), verbed=event)
|
|
|
|
if object_['failure_code']: # nocoverage
|
|
|
|
body += '. Failure code: {}'.format(object_['failure_code'])
|
|
|
|
if resource == 'dispute':
|
|
|
|
topic = 'disputes'
|
|
|
|
body = default_body() + '. Current status: {status}.'.format(
|
|
|
|
status=object_['status'].replace('_', ' '))
|
2020-05-14 09:42:22 +02:00
|
|
|
if resource == 'refund':
|
2018-11-21 01:39:37 +01:00
|
|
|
topic = 'refunds'
|
2020-05-14 09:42:22 +02:00
|
|
|
body = 'A {resource} for a {charge} of {amount} {currency} was updated.'.format(
|
2018-11-21 01:39:37 +01:00
|
|
|
resource=linkified_id(object_['id'], lower=True),
|
2020-05-14 09:42:22 +02:00
|
|
|
charge=linkified_id(object_['charge'], lower=True),
|
|
|
|
amount=object_['amount'],
|
python: Use trailing commas consistently.
Automatically generated by the following script, based on the output
of lint with flake8-comma:
import re
import sys
last_filename = None
last_row = None
lines = []
for msg in sys.stdin:
m = re.match(
r"\x1b\[35mflake8 \|\x1b\[0m \x1b\[1;31m(.+):(\d+):(\d+): (\w+)", msg
)
if m:
filename, row_str, col_str, err = m.groups()
row, col = int(row_str), int(col_str)
if filename == last_filename:
assert last_row != row
else:
if last_filename is not None:
with open(last_filename, "w") as f:
f.writelines(lines)
with open(filename) as f:
lines = f.readlines()
last_filename = filename
last_row = row
line = lines[row - 1]
if err in ["C812", "C815"]:
lines[row - 1] = line[: col - 1] + "," + line[col - 1 :]
elif err in ["C819"]:
assert line[col - 2] == ","
lines[row - 1] = line[: col - 2] + line[col - 1 :].lstrip(" ")
if last_filename is not None:
with open(last_filename, "w") as f:
f.writelines(lines)
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-10 05:23:40 +02:00
|
|
|
currency=object_['currency'].upper(),
|
2020-05-14 09:42:22 +02:00
|
|
|
)
|
2018-11-21 01:39:37 +01:00
|
|
|
if category == 'checkout_beta': # nocoverage
|
|
|
|
# Not sure what this is
|
2018-12-06 19:17:51 +01:00
|
|
|
raise NotImplementedEventType()
|
2018-11-21 01:39:37 +01:00
|
|
|
if category == 'coupon': # nocoverage
|
|
|
|
# Not something that likely happens programmatically
|
2018-12-06 19:17:51 +01:00
|
|
|
raise NotImplementedEventType()
|
2018-11-21 01:39:37 +01:00
|
|
|
if category == 'customer':
|
|
|
|
if resource == 'customer':
|
|
|
|
# Running into the 60 character topic limit.
|
|
|
|
# topic = '[{}](https://dashboard.stripe.com/customers/{})' % (object_['id'], object_['id'])
|
|
|
|
topic = object_['id']
|
2019-02-04 21:34:32 +01:00
|
|
|
body = default_body(update_blacklist=['delinquent', 'currency', 'default_source'])
|
2018-11-21 01:39:37 +01:00
|
|
|
if event == 'created':
|
|
|
|
if object_['email']:
|
|
|
|
body += '\nEmail: {}'.format(object_['email'])
|
|
|
|
if object_['metadata']: # nocoverage
|
|
|
|
for key, value in object_['metadata'].items():
|
2020-06-09 00:25:09 +02:00
|
|
|
body += f'\n{key}: {value}'
|
2018-11-28 22:11:08 +01:00
|
|
|
if resource == 'discount':
|
|
|
|
body = 'Discount {verbed} ([{coupon_name}]({coupon_url})).'.format(
|
|
|
|
verbed=event.replace('_', ' '),
|
|
|
|
coupon_name=object_['coupon']['name'],
|
python: Use trailing commas consistently.
Automatically generated by the following script, based on the output
of lint with flake8-comma:
import re
import sys
last_filename = None
last_row = None
lines = []
for msg in sys.stdin:
m = re.match(
r"\x1b\[35mflake8 \|\x1b\[0m \x1b\[1;31m(.+):(\d+):(\d+): (\w+)", msg
)
if m:
filename, row_str, col_str, err = m.groups()
row, col = int(row_str), int(col_str)
if filename == last_filename:
assert last_row != row
else:
if last_filename is not None:
with open(last_filename, "w") as f:
f.writelines(lines)
with open(filename) as f:
lines = f.readlines()
last_filename = filename
last_row = row
line = lines[row - 1]
if err in ["C812", "C815"]:
lines[row - 1] = line[: col - 1] + "," + line[col - 1 :]
elif err in ["C819"]:
assert line[col - 2] == ","
lines[row - 1] = line[: col - 2] + line[col - 1 :].lstrip(" ")
if last_filename is not None:
with open(last_filename, "w") as f:
f.writelines(lines)
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-10 05:23:40 +02:00
|
|
|
coupon_url='https://dashboard.stripe.com/{}/{}'.format('coupons', object_['coupon']['id']),
|
2018-11-28 22:11:08 +01:00
|
|
|
)
|
2018-11-21 01:39:37 +01:00
|
|
|
if resource == 'source': # nocoverage
|
|
|
|
body = default_body()
|
|
|
|
if resource == 'subscription':
|
|
|
|
body = default_body()
|
|
|
|
if event == 'trial_will_end':
|
2017-08-24 17:31:04 +02:00
|
|
|
DAY = 60 * 60 * 24 # seconds in a day
|
2018-11-21 01:39:37 +01:00
|
|
|
# Basically always three: https://stripe.com/docs/api/python#event_types
|
|
|
|
body += ' in {days} days'.format(
|
|
|
|
days=int((object_["trial_end"] - time.time() + DAY//2) // DAY))
|
|
|
|
if event == 'created':
|
|
|
|
if object_['plan']:
|
2018-12-06 20:10:27 +01:00
|
|
|
body += '\nPlan: [{plan_nickname}](https://dashboard.stripe.com/plans/{plan_id})'.format(
|
|
|
|
plan_nickname=object_['plan']['nickname'], plan_id=object_['plan']['id'])
|
2018-11-21 01:39:37 +01:00
|
|
|
if object_['quantity']:
|
|
|
|
body += '\nQuantity: {}'.format(object_['quantity'])
|
|
|
|
if 'billing' in object_: # nocoverage
|
2019-02-04 21:34:32 +01:00
|
|
|
body += '\nBilling method: {}'.format(object_['billing'].replace('_', ' '))
|
2018-11-21 01:39:37 +01:00
|
|
|
if category == 'file': # nocoverage
|
|
|
|
topic = 'files'
|
|
|
|
body = default_body() + ' ({purpose}). \nTitle: {title}'.format(
|
|
|
|
purpose=object_['purpose'].replace('_', ' '), title=object_['title'])
|
2019-02-04 21:18:47 +01:00
|
|
|
if category == 'invoice':
|
|
|
|
if event == 'upcoming': # nocoverage
|
|
|
|
body = 'Upcoming invoice created'
|
2019-06-09 13:43:58 +02:00
|
|
|
elif (event == 'updated' and
|
|
|
|
payload['data']['previous_attributes'].get('paid', None) is False and
|
|
|
|
object_['paid'] is True and
|
|
|
|
object_["amount_paid"] != 0 and
|
|
|
|
object_["amount_remaining"] == 0):
|
|
|
|
# We are taking advantage of logical AND short circuiting here since we need the else
|
|
|
|
# statement below.
|
|
|
|
object_id = object_['id']
|
2020-06-09 00:25:09 +02:00
|
|
|
invoice_link = f'https://dashboard.stripe.com/invoices/{object_id}'
|
|
|
|
body = f'[Invoice]({invoice_link}) is now paid'
|
2019-02-04 21:18:47 +01:00
|
|
|
else:
|
2019-06-09 13:43:58 +02:00
|
|
|
body = default_body(update_blacklist=['lines', 'description', 'number', 'finalized_at',
|
|
|
|
'status_transitions', 'payment_intent'])
|
2020-06-03 05:29:16 +02:00
|
|
|
if event == 'created':
|
2019-02-04 21:18:47 +01:00
|
|
|
# Could potentially add link to invoice PDF here
|
2020-06-03 05:29:16 +02:00
|
|
|
body += ' ({reason})\nTotal: {total}\nAmount due: {due}'.format(
|
2019-02-04 21:18:47 +01:00
|
|
|
reason=object_['billing_reason'].replace('_', ' '),
|
|
|
|
total=amount_string(object_['total'], object_['currency']),
|
|
|
|
due=amount_string(object_['amount_due'], object_['currency']))
|
2019-02-05 23:08:43 +01:00
|
|
|
if category == 'invoiceitem':
|
2019-06-09 19:20:10 +02:00
|
|
|
body = default_body(update_blacklist=['description', 'invoice'])
|
2018-11-21 01:39:37 +01:00
|
|
|
if event == 'created':
|
|
|
|
body += ' for {amount}'.format(amount=amount_string(object_['amount'], object_['currency']))
|
|
|
|
if category.startswith('issuing'): # nocoverage
|
|
|
|
# Not implemented
|
2018-12-06 19:17:51 +01:00
|
|
|
raise NotImplementedEventType()
|
2018-11-21 01:39:37 +01:00
|
|
|
if category.startswith('order'): # nocoverage
|
|
|
|
# Not implemented
|
2018-12-06 19:17:51 +01:00
|
|
|
raise NotImplementedEventType()
|
2019-02-04 21:34:32 +01:00
|
|
|
if category in ['payment_intent', 'payout', 'plan', 'product', 'recipient',
|
2018-11-21 01:39:37 +01:00
|
|
|
'reporting', 'review', 'sigma', 'sku', 'source', 'subscription_schedule',
|
|
|
|
'topup', 'transfer']: # nocoverage
|
|
|
|
# Not implemented. In theory doing something like
|
|
|
|
# body = default_body()
|
|
|
|
# may not be hard for some of these
|
2018-12-06 19:17:51 +01:00
|
|
|
raise NotImplementedEventType()
|
2016-12-23 17:21:51 +01:00
|
|
|
|
|
|
|
if body is None:
|
2020-08-19 22:26:38 +02:00
|
|
|
raise UnsupportedWebhookEventType('Stripe', event_type)
|
2018-12-06 19:17:51 +01:00
|
|
|
return (topic, body)
|
2016-12-03 18:51:33 +01:00
|
|
|
|
2018-11-21 01:39:37 +01:00
|
|
|
def amount_string(amount: int, currency: str) -> str:
|
2017-10-27 02:31:10 +02:00
|
|
|
zero_decimal_currencies = ["bif", "djf", "jpy", "krw", "pyg", "vnd", "xaf",
|
|
|
|
"xpf", "clp", "gnf", "kmf", "mga", "rwf", "vuv", "xof"]
|
2016-12-03 18:51:33 +01:00
|
|
|
if currency in zero_decimal_currencies:
|
2018-11-21 01:39:37 +01:00
|
|
|
decimal_amount = str(amount) # nocoverage
|
2016-12-03 18:51:33 +01:00
|
|
|
else:
|
2020-06-09 00:25:09 +02:00
|
|
|
decimal_amount = f'{float(amount) * 0.01:.02f}'
|
2018-11-21 01:39:37 +01:00
|
|
|
|
|
|
|
if currency == 'usd': # nocoverage
|
|
|
|
return '$' + decimal_amount
|
2020-06-09 00:25:09 +02:00
|
|
|
return decimal_amount + f' {currency.upper()}'
|
2018-11-21 01:39:37 +01:00
|
|
|
|
|
|
|
def linkified_id(object_id: str, lower: bool=False) -> str:
|
python: Convert assignment type annotations to Python 3.6 style.
This commit was split by tabbott; this piece covers the vast majority
of files in Zulip, but excludes scripts/, tools/, and puppet/ to help
ensure we at least show the right error messages for Xenial systems.
We can likely further refine the remaining pieces with some testing.
Generated by com2ann, with whitespace fixes and various manual fixes
for runtime issues:
- invoiced_through: Optional[LicenseLedger] = models.ForeignKey(
+ invoiced_through: Optional["LicenseLedger"] = models.ForeignKey(
-_apns_client: Optional[APNsClient] = None
+_apns_client: Optional["APNsClient"] = None
- notifications_stream: Optional[Stream] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
- signup_notifications_stream: Optional[Stream] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
+ notifications_stream: Optional["Stream"] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
+ signup_notifications_stream: Optional["Stream"] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
- author: Optional[UserProfile] = models.ForeignKey('UserProfile', blank=True, null=True, on_delete=CASCADE)
+ author: Optional["UserProfile"] = models.ForeignKey('UserProfile', blank=True, null=True, on_delete=CASCADE)
- bot_owner: Optional[UserProfile] = models.ForeignKey('self', null=True, on_delete=models.SET_NULL)
+ bot_owner: Optional["UserProfile"] = models.ForeignKey('self', null=True, on_delete=models.SET_NULL)
- default_sending_stream: Optional[Stream] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
- default_events_register_stream: Optional[Stream] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
+ default_sending_stream: Optional["Stream"] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
+ default_events_register_stream: Optional["Stream"] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
-descriptors_by_handler_id: Dict[int, ClientDescriptor] = {}
+descriptors_by_handler_id: Dict[int, "ClientDescriptor"] = {}
-worker_classes: Dict[str, Type[QueueProcessingWorker]] = {}
-queues: Dict[str, Dict[str, Type[QueueProcessingWorker]]] = {}
+worker_classes: Dict[str, Type["QueueProcessingWorker"]] = {}
+queues: Dict[str, Dict[str, Type["QueueProcessingWorker"]]] = {}
-AUTH_LDAP_REVERSE_EMAIL_SEARCH: Optional[LDAPSearch] = None
+AUTH_LDAP_REVERSE_EMAIL_SEARCH: Optional["LDAPSearch"] = None
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-22 01:09:50 +02:00
|
|
|
names_and_urls: Dict[str, Tuple[str, Optional[str]]] = {
|
2018-11-21 01:39:37 +01:00
|
|
|
# Core resources
|
|
|
|
'ch': ('Charge', 'charges'),
|
|
|
|
'cus': ('Customer', 'customers'),
|
|
|
|
'dp': ('Dispute', 'disputes'),
|
|
|
|
'file': ('File', 'files'),
|
|
|
|
'link': ('File link', 'file_links'),
|
|
|
|
'pi': ('Payment intent', 'payment_intents'),
|
|
|
|
'po': ('Payout', 'payouts'),
|
|
|
|
'prod': ('Product', 'products'),
|
|
|
|
're': ('Refund', 'refunds'),
|
|
|
|
'tok': ('Token', 'tokens'),
|
|
|
|
|
|
|
|
# Payment methods
|
|
|
|
# payment methods have URL prefixes like /customers/cus_id/sources
|
|
|
|
'ba': ('Bank account', None),
|
|
|
|
'card': ('Card', None),
|
|
|
|
'src': ('Source', None),
|
|
|
|
|
|
|
|
# Billing
|
|
|
|
# coupons have a configurable id, but the URL prefix is /coupons
|
|
|
|
# discounts don't have a URL, I think
|
|
|
|
'in': ('Invoice', 'invoices'),
|
|
|
|
'ii': ('Invoice item', 'invoiceitems'),
|
|
|
|
# products are covered in core resources
|
|
|
|
# plans have a configurable id, though by default they are created with this pattern
|
|
|
|
# 'plan': ('Plan', 'plans'),
|
|
|
|
'sub': ('Subscription', 'subscriptions'),
|
|
|
|
'si': ('Subscription item', 'subscription_items'),
|
|
|
|
# I think usage records have URL prefixes like /subscription_items/si_id/usage_record_summaries
|
|
|
|
'mbur': ('Usage record', None),
|
|
|
|
|
2018-12-02 09:21:09 +01:00
|
|
|
# Undocumented :|
|
|
|
|
'py': ('Payment', 'payments'),
|
2020-08-11 01:47:44 +02:00
|
|
|
'pyr': ('Refund', 'refunds'), # Pseudo refunds. Not fully tested.
|
2018-12-02 09:21:09 +01:00
|
|
|
|
2018-11-21 01:39:37 +01:00
|
|
|
# Connect, Fraud, Orders, etc not implemented
|
python: Convert assignment type annotations to Python 3.6 style.
This commit was split by tabbott; this piece covers the vast majority
of files in Zulip, but excludes scripts/, tools/, and puppet/ to help
ensure we at least show the right error messages for Xenial systems.
We can likely further refine the remaining pieces with some testing.
Generated by com2ann, with whitespace fixes and various manual fixes
for runtime issues:
- invoiced_through: Optional[LicenseLedger] = models.ForeignKey(
+ invoiced_through: Optional["LicenseLedger"] = models.ForeignKey(
-_apns_client: Optional[APNsClient] = None
+_apns_client: Optional["APNsClient"] = None
- notifications_stream: Optional[Stream] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
- signup_notifications_stream: Optional[Stream] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
+ notifications_stream: Optional["Stream"] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
+ signup_notifications_stream: Optional["Stream"] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
- author: Optional[UserProfile] = models.ForeignKey('UserProfile', blank=True, null=True, on_delete=CASCADE)
+ author: Optional["UserProfile"] = models.ForeignKey('UserProfile', blank=True, null=True, on_delete=CASCADE)
- bot_owner: Optional[UserProfile] = models.ForeignKey('self', null=True, on_delete=models.SET_NULL)
+ bot_owner: Optional["UserProfile"] = models.ForeignKey('self', null=True, on_delete=models.SET_NULL)
- default_sending_stream: Optional[Stream] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
- default_events_register_stream: Optional[Stream] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
+ default_sending_stream: Optional["Stream"] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
+ default_events_register_stream: Optional["Stream"] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
-descriptors_by_handler_id: Dict[int, ClientDescriptor] = {}
+descriptors_by_handler_id: Dict[int, "ClientDescriptor"] = {}
-worker_classes: Dict[str, Type[QueueProcessingWorker]] = {}
-queues: Dict[str, Dict[str, Type[QueueProcessingWorker]]] = {}
+worker_classes: Dict[str, Type["QueueProcessingWorker"]] = {}
+queues: Dict[str, Dict[str, Type["QueueProcessingWorker"]]] = {}
-AUTH_LDAP_REVERSE_EMAIL_SEARCH: Optional[LDAPSearch] = None
+AUTH_LDAP_REVERSE_EMAIL_SEARCH: Optional["LDAPSearch"] = None
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-22 01:09:50 +02:00
|
|
|
}
|
2018-11-21 01:39:37 +01:00
|
|
|
name, url_prefix = names_and_urls[object_id.split('_')[0]]
|
|
|
|
if lower: # nocoverage
|
|
|
|
name = name.lower()
|
|
|
|
if url_prefix is None: # nocoverage
|
|
|
|
return name
|
2020-06-09 00:25:09 +02:00
|
|
|
return f'[{name}](https://dashboard.stripe.com/{url_prefix}/{object_id})'
|
2018-12-06 18:40:43 +01:00
|
|
|
|
|
|
|
def stringify(value: Any) -> str:
|
|
|
|
if isinstance(value, int) and value > 1500000000 and value < 2000000000:
|
|
|
|
return timestamp_to_datetime(value).strftime('%b %d, %Y, %H:%M:%S %Z')
|
|
|
|
return str(value)
|