Compare commits

..

1 Commits

Author SHA1 Message Date
Tim Abbott
e5cde05710 docker: Set has_appserver=0 correctly. 2018-05-15 08:16:39 -07:00
3584 changed files with 180785 additions and 356473 deletions

View File

@@ -1,6 +0,0 @@
> 0.2%
> 0.2% in US
last 2 versions
Firefox ESR
not dead
Chrome 26 # similar to PhantomJS

View File

@@ -1,151 +1,146 @@
# See https://zulip.readthedocs.io/en/latest/testing/continuous-integration.html for
# high-level documentation on our CircleCI setup.
# See CircleCI upstream's docs on this config format: # See CircleCI upstream's docs on this config format:
# https://circleci.com/docs/2.0/language-python/ # https://circleci.com/docs/2.0/language-python/
# #
version: 2.0 version: 2
aliases:
- &create_cache_directories
run:
name: create cache directories
command: |
dirs=(/srv/zulip-{npm,venv}-cache)
sudo mkdir -p "${dirs[@]}"
sudo chown -R circleci "${dirs[@]}"
- &restore_cache_package_json
restore_cache:
keys:
- v1-npm-base.{{ .Environment.CIRCLE_JOB }}-{{ checksum "package.json" }}-{{ checksum "yarn.lock" }}
- &restore_cache_requirements
restore_cache:
keys:
- v1-venv-base.{{ .Environment.CIRCLE_JOB }}-{{ checksum "requirements/thumbor.txt" }}-{{ checksum "requirements/dev.txt" }}
- &install_dependencies
run:
name: install dependencies
command: |
sudo apt-get update
# Install moreutils so we can use `ts` and `mispipe` in the following.
sudo apt-get install -y moreutils
# CircleCI sets the following in Git config at clone time:
# url.ssh://git@github.com.insteadOf https://github.com
# This breaks the Git clones in the NVM `install.sh` we run
# in `install-node`.
# TODO: figure out why that breaks, and whether we want it.
# (Is it an optimization?)
rm -f /home/circleci/.gitconfig
# This is the main setup job for the test suite
mispipe "tools/ci/setup-backend" ts
# Cleaning caches is mostly unnecessary in Circle, because
# most builds don't get to write to the cache.
# mispipe "scripts/lib/clean-unused-caches --verbose --threshold 0" ts
- &save_cache_package_json
save_cache:
paths:
- /srv/zulip-npm-cache
key: v1-npm-base.{{ .Environment.CIRCLE_JOB }}-{{ checksum "package.json" }}-{{ checksum "yarn.lock" }}
- &save_cache_requirements
save_cache:
paths:
- /srv/zulip-venv-cache
key: v1-venv-base.{{ .Environment.CIRCLE_JOB }}-{{ checksum "requirements/thumbor.txt" }}-{{ checksum "requirements/dev.txt" }}
# TODO: in Travis we also cache ~/zulip-emoji-cache, ~/node, ~/misc
- &run_backend_tests
run:
name: run backend tests
command: |
. /srv/zulip-py3-venv/bin/activate
mispipe ./tools/ci/backend ts
- &run_frontend_tests
run:
name: run frontend tests
command: |
. /srv/zulip-py3-venv/bin/activate
mispipe ./tools/ci/frontend ts
- &upload_coverage_report
run:
name: upload coverage report
command: |
. /srv/zulip-py3-venv/bin/activate
pip install codecov && codecov \
|| echo "Error in uploading coverage reports to codecov.io."
jobs: jobs:
"xenial-backend-frontend-python3.5": "trusty-python-3.4":
docker: docker:
# This is built from tools/circleci/images/xenial/Dockerfile . # This is built from tools/circleci/images/trusty/Dockerfile .
# Xenial ships with Python 3.5. - image: gregprice/circleci:trusty-python-4.test
- image: gregprice/circleci:xenial-python-4.test
working_directory: ~/zulip working_directory: ~/zulip
steps: steps:
- checkout - checkout
- *create_cache_directories
- *restore_cache_package_json
- *restore_cache_requirements
- *install_dependencies
- *save_cache_package_json
- *save_cache_requirements
- *run_backend_tests
- *run_frontend_tests
# We only need to upload coverage reports on whichever platform
# runs the frontend tests.
- *upload_coverage_report
- store_artifacts:
path: ./var/casper/
destination: casper
- store_artifacts:
path: ../../../tmp/zulip-test-event-log/
destination: test-reports
- store_test_results:
path: ./var/xunit-test-results/casper/
"bionic-backend-python3.6":
docker:
# This is built from tools/circleci/images/bionic/Dockerfile .
# Bionic ships with Python 3.6.
- image: gregprice/circleci:bionic-python-1.test
working_directory: ~/zulip
steps:
- checkout
- *create_cache_directories
- run: - run:
name: do Bionic hack name: create cache directories
command: | command: |
# Temporary hack till `sudo service redis-server start` gets fixes in Bionic. See dirs=(/srv/zulip-{npm,venv}-cache)
# https://chat.zulip.org/#narrow/stream/3-backend/topic/Ubuntu.20bionic.20CircleCI sudo mkdir -p "${dirs[@]}"
sudo sed -i '/^bind/s/bind.*/bind 0.0.0.0/' /etc/redis/redis.conf sudo chown -R circleci "${dirs[@]}"
- *restore_cache_package_json - restore_cache:
- *restore_cache_requirements keys:
- *install_dependencies - v1-npm-base.trusty-{{ checksum "package.json" }}-{{ checksum "yarn.lock" }}
- *save_cache_package_json - restore_cache:
- *save_cache_requirements keys:
- *run_backend_tests - v1-venv-base.trusty-{{ checksum "requirements/thumbor.txt" }}-{{ checksum "requirements/dev.txt" }}
- run:
name: install dependencies
command: |
# Install moreutils so we can use `ts` and `mispipe` in the following.
sudo apt-get install -y moreutils
# CircleCI sets the following in Git config at clone time:
# url.ssh://git@github.com.insteadOf https://github.com
# This breaks the Git clones in the NVM `install.sh` we run
# in `install-node`.
# TODO: figure out why that breaks, and whether we want it.
# (Is it an optimization?)
rm -f /home/circleci/.gitconfig
# This is the main setup job for the test suite
mispipe "tools/travis/setup-backend" ts
# Cleaning caches is mostly unnecessary in Circle, because
# most builds don't get to write to the cache.
# mispipe "scripts/lib/clean-unused-caches --verbose --threshold 0" ts
- save_cache:
paths:
- /srv/zulip-npm-cache
key: v1-npm-base.trusty-{{ checksum "package.json" }}-{{ checksum "yarn.lock" }}
- save_cache:
paths:
- /srv/zulip-venv-cache
key: v1-venv-base.trusty-{{ checksum "requirements/thumbor.txt" }}-{{ checksum "requirements/dev.txt" }}
# TODO: in Travis we also cache ~/zulip-emoji-cache, ~/node, ~/misc
# The moment of truth! Run the tests.
- run:
name: run backend tests
command: |
. /srv/zulip-py3-venv/bin/activate
mispipe ./tools/travis/backend ts
- run:
name: run frontend tests
command: |
. /srv/zulip-py3-venv/bin/activate
mispipe ./tools/travis/frontend ts
- run:
name: upload coverage report
command: |
. /srv/zulip-py3-venv/bin/activate
pip install codecov && codecov \
|| echo "Error in uploading coverage reports to codecov.io."
# - store_artifacts: # TODO
# path: var/casper/
# # also /tmp/zulip-test-event-log/
# destination: test-reports
"xenial-python-3.5":
docker:
# This is built from tools/circleci/images/xenial/Dockerfile .
- image: gregprice/circleci:xenial-python-3.test
working_directory: ~/zulip
steps:
- checkout
- run:
name: create cache directories
command: |
dirs=(/srv/zulip-{npm,venv}-cache)
sudo mkdir -p "${dirs[@]}"
sudo chown -R circleci "${dirs[@]}"
- restore_cache:
keys:
- v1-npm-base.xenial-{{ checksum "package.json" }}-{{ checksum "yarn.lock" }}
- restore_cache:
keys:
- v1-venv-base.xenial-{{ checksum "requirements/thumbor.txt" }}-{{ checksum "requirements/dev.txt" }}
- run:
name: install dependencies
command: |
sudo apt-get update
sudo apt-get install -y moreutils
rm -f /home/circleci/.gitconfig
mispipe "tools/travis/setup-backend" ts
- save_cache:
paths:
- /srv/zulip-npm-cache
key: v1-npm-base.xenial-{{ checksum "package.json" }}-{{ checksum "yarn.lock" }}
- save_cache:
paths:
- /srv/zulip-venv-cache
key: v1-venv-base.xenial-{{ checksum "requirements/thumbor.txt" }}-{{ checksum "requirements/dev.txt" }}
- run:
name: run backend tests
command: |
. /srv/zulip-py3-venv/bin/activate
mispipe ./tools/travis/backend ts
- run:
name: upload coverage report
command: |
. /srv/zulip-py3-venv/bin/activate
pip install codecov && codecov \
|| echo "Error in uploading coverage reports to codecov.io."
workflows: workflows:
version: 2 version: 2
build: build:
jobs: jobs:
- "xenial-backend-frontend-python3.5" - "trusty-python-3.4"
- "bionic-backend-python3.6" - "xenial-python-3.5"

View File

@@ -5,8 +5,6 @@ coverage:
project: project:
default: default:
target: auto target: auto
# Codecov has the tendency to report a lot of false negatives, threshold: 0.50
# so we basically suppress comments completely.
threshold: 50%
base: auto base: auto
patch: off patch: off

View File

@@ -6,20 +6,14 @@ charset = utf-8
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
[*.{sh,py,pyi,js,ts,json,yml,xml,css,md,markdown,handlebars,html}] [*.{sh,py,pyi,js,json,yml,xml,css,md,markdown,handlebars,html}]
indent_style = space indent_style = space
indent_size = 4 indent_size = 4
[*.py]
max_line_length = 110
[*.{js,ts}]
max_line_length = 100
[*.{svg,rb,pp,pl}] [*.{svg,rb,pp,pl}]
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
[*.cfg] [*.{cfg}]
indent_style = space indent_style = space
indent_size = 8 indent_size = 8

View File

@@ -1,3 +1,2 @@
static/js/blueslip.js static/js/blueslip.js
static/webpack-bundles static/webpack-bundles
static/js/js_typings/zulip/index.d.ts

View File

@@ -4,217 +4,180 @@
"es6": true "es6": true
}, },
"parserOptions": { "parserOptions": {
"warnOnUnsupportedTypeScriptVersion": false,
"sourceType": "module" "sourceType": "module"
}, },
"globals": { "globals": {
"$": false, "$": false,
"ClipboardJS": false,
"Dict": false,
"FetchStatus": false,
"Filter": false,
"Handlebars": false,
"LightboxCanvas": false,
"MessageListData": false,
"MessageListView": false,
"Plotly": false,
"SockJS": false,
"Socket": false,
"Sortable": false,
"WinChan": false,
"XDate": false,
"_": false, "_": false,
"activity": false,
"admin": false,
"alert_words": false,
"alert_words_ui": false,
"attachments_ui": false,
"avatar": false,
"billing": false,
"blueslip": false,
"bot_data": false,
"bridge": false,
"buddy_data": false,
"buddy_list": false,
"channel": false,
"click_handlers": false,
"color_data": false,
"colorspace": false,
"common": false,
"components": false,
"compose": false,
"compose_actions": false,
"compose_fade": false,
"compose_pm_pill": false,
"compose_state": false,
"compose_ui": false,
"composebox_typeahead": false,
"condense": false,
"confirm_dialog": false,
"copy_and_paste": false,
"csrf_token": false,
"current_msg_list": true,
"drafts": false,
"echo": false,
"emoji": false,
"emoji_codes": false,
"emoji_picker": false,
"favicon": false,
"feature_flags": false,
"feedback_widget": false,
"fenced_code": false,
"flatpickr": false,
"floating_recipient_bar": false,
"gear_menu": false,
"hash_util": false,
"hashchange": false,
"helpers": false,
"history": false,
"home_msg_list": false,
"hotspots": false,
"i18n": false,
"info_overlay": false,
"input_pill": false,
"invite": false,
"jQuery": false, "jQuery": false,
"katex": false, "Spinner": false,
"keydown_util": false, "Handlebars": false,
"lightbox": false, "XDate": false,
"list_cursor": false, "zxcvbn": false,
"list_render": false, "SockJS": false,
"list_util": false,
"loading": false,
"localStorage": false,
"local_message": false,
"localstorage": false,
"location": false,
"markdown": false,
"marked": false, "marked": false,
"md5": false, "md5": false,
"message_edit": false,
"message_events": false,
"message_fetch": false,
"message_flags": false,
"message_list": false,
"message_live_update": false,
"message_scroll": false,
"message_store": false,
"message_util": false,
"message_viewport": false,
"moment": false, "moment": false,
"muting": false, "i18n": false,
"muting_ui": false, "LightboxCanvas": false,
"narrow": false, "bridge": false,
"narrow_state": false,
"navigate": false,
"night_mode": false,
"notifications": false,
"overlays": false,
"padded_widget": false,
"page_params": false, "page_params": false,
"panels": false, "attachments_ui": false,
"people": false, "csrf_token": false,
"pm_conversations": false, "typeahead_helper": false,
"pm_list": false,
"pointer": false,
"popovers": false,
"presence": false,
"pygments_data": false, "pygments_data": false,
"reactions": false, "popovers": false,
"realm_icon": false,
"realm_logo": false,
"realm_night_logo": false,
"recent_senders": false,
"reload": false,
"reload_state": false,
"reminder": false,
"resize": false,
"rows": false,
"rtl": false,
"run_test": false,
"schema": false,
"scroll_bar": false,
"scroll_util": false,
"search": false,
"search_pill": false,
"search_pill_widget": false,
"search_suggestion": false,
"search_util": false,
"sent_messages": false,
"server_events": false, "server_events": false,
"server_events_dispatch": false, "server_events_dispatch": false,
"settings": false, "message_scroll": false,
"settings_account": false, "keydown_util": false,
"settings_bots": false, "info_overlay": false,
"settings_display": false,
"settings_emoji": false,
"settings_exports": false,
"settings_linkifiers": false,
"settings_invites": false,
"settings_muting": false,
"settings_notifications": false,
"settings_org": false,
"settings_panel_menu": false,
"settings_profile_fields": false,
"settings_sections": false,
"settings_streams": false,
"settings_toggle": false,
"settings_ui": false,
"settings_user_groups": false,
"settings_users": false,
"starred_messages": false,
"stream_color": false,
"stream_create": false,
"stream_data": false,
"stream_edit": false,
"stream_events": false,
"stream_list": false,
"stream_muting": false,
"stream_popover": false,
"stream_sort": false,
"stream_ui_updates": false,
"StripeCheckout": false,
"submessage": false,
"subs": false,
"tab_bar": false,
"templates": false,
"tictactoe_widget": false,
"timerender": false,
"toMarkdown": false,
"todo_widget": false,
"top_left_corner": false,
"topic_data": false,
"topic_generator": false,
"topic_list": false,
"topic_zoom": false,
"transmit": false,
"tutorial": false,
"typeahead_helper": false,
"typing": false,
"typing_data": false,
"typing_events": false,
"ui": false, "ui": false,
"ui_init": false,
"ui_report": false, "ui_report": false,
"night_mode": false,
"ui_util": false, "ui_util": false,
"unread": false, "lightbox": false,
"unread_ops": false, "input_pill": false,
"unread_ui": false,
"upgrade": false,
"upload": false,
"upload_widget": false,
"user_events": false,
"user_groups": false,
"user_pill": false, "user_pill": false,
"user_search": false, "compose_pm_pill": false,
"user_status": false, "stream_color": false,
"user_status_ui": false, "people": false,
"user_groups": false,
"navigate": false,
"toMarkdown": false,
"settings_toggle": false,
"settings_account": false,
"settings_display": false,
"settings_notifications": false,
"settings_muting": false,
"settings_bots": false,
"settings_sections": false,
"settings_emoji": false,
"settings_org": false,
"settings_ui": false,
"settings_users": false,
"settings_streams": false,
"settings_filters": false,
"settings_invites": false,
"settings_user_groups": false,
"settings_profile_fields": false,
"settings": false,
"resize": false,
"loading": false,
"typing": false,
"typing_events": false,
"typing_data": false,
"typing_status": false,
"sent_messages": false,
"transmit": false,
"compose": false,
"compose_actions": false,
"compose_state": false,
"compose_fade": false,
"overlays": false,
"stream_create": false,
"stream_edit": false,
"subs": false,
"stream_muting": false,
"stream_events": false,
"timerender": false,
"message_live_update": false,
"message_edit": false,
"reload": false,
"composebox_typeahead": false,
"search": false,
"topic_list": false,
"topic_generator": false,
"gear_menu": false,
"hashchange": false,
"hash_util": false,
"FetchStatus": false,
"message_list": false,
"Filter": false,
"flatpickr": false,
"pointer": false,
"util": false, "util": false,
"poll_widget": false, "MessageListData": false,
"widgetize": false, "MessageListView": false,
"zcommand": false, "blueslip": false,
"zform": false, "rows": false,
"zxcvbn": false "WinChan": false,
"muting_ui": false,
"Socket": false,
"channel": false,
"components": false,
"scroll_util": false,
"message_viewport": false,
"upload_widget": false,
"avatar": false,
"realm_icon": false,
"feature_flags": false,
"search_suggestion": false,
"notifications": false,
"message_flags": false,
"bot_data": false,
"top_left_corner": false,
"stream_sort": false,
"stream_list": false,
"stream_popover": false,
"narrow_state": false,
"narrow": false,
"admin_sections": false,
"admin": false,
"stream_data": false,
"topic_data": false,
"list_util": false,
"muting": false,
"Dict": false,
"unread": false,
"alert_words_ui": false,
"message_store": false,
"message_util": false,
"message_events": false,
"message_fetch": false,
"favicon": false,
"condense": false,
"list_render": false,
"floating_recipient_bar": false,
"tab_bar": false,
"emoji": false,
"presence": false,
"user_search": false,
"buddy_data": false,
"buddy_list": false,
"list_cursor": false,
"activity": false,
"invite": false,
"colorspace": false,
"reactions": false,
"tutorial": false,
"templates": false,
"alert_words": false,
"fenced_code": false,
"markdown": false,
"echo": false,
"localstorage": false,
"localStorage": false,
"current_msg_list": true,
"home_msg_list": false,
"pm_list": false,
"pm_conversations": false,
"recent_senders": false,
"unread_ui": false,
"unread_ops": false,
"upload": false,
"user_events": false,
"Plotly": false,
"emoji_codes": false,
"drafts": false,
"katex": false,
"ClipboardJS": false,
"emoji_picker": false,
"hotspots": false,
"compose_ui": false,
"common": false,
"panels": false,
"PerfectScrollbar": false
}, },
"plugins": [ "plugins": [
"eslint-plugin-empty-returns" "eslint-plugin-empty-returns"
@@ -223,9 +186,9 @@
"array-callback-return": "error", "array-callback-return": "error",
"array-bracket-spacing": "error", "array-bracket-spacing": "error",
"arrow-spacing": [ "error", { "before": true, "after": true } ], "arrow-spacing": [ "error", { "before": true, "after": true } ],
"block-scoped-var": "error", "block-scoped-var": 2,
"brace-style": [ "error", "1tbs", { "allowSingleLine": true } ], "brace-style": [ "error", "1tbs", { "allowSingleLine": true } ],
"camelcase": "off", "camelcase": 0,
"comma-dangle": [ "error", "comma-dangle": [ "error",
{ {
"arrays": "always-multiline", "arrays": "always-multiline",
@@ -235,20 +198,14 @@
"functions": "never" "functions": "never"
} }
], ],
"comma-spacing": [ "error", "complexity": [ 0, 4 ],
{ "curly": 2,
"before": false,
"after": true
}
],
"complexity": [ "off", 4 ],
"curly": "error",
"dot-notation": [ "error", { "allowKeywords": true } ], "dot-notation": [ "error", { "allowKeywords": true } ],
"empty-returns/main": "error", "empty-returns/main": "error",
"eol-last": [ "error", "always" ], "eol-last": [ "error", "always" ],
"eqeqeq": "error", "eqeqeq": 2,
"func-style": [ "off", "expression" ], "func-style": [ "off", "expression" ],
"guard-for-in": "error", "guard-for-in": 2,
"indent": ["error", 4, { "indent": ["error", 4, {
"ArrayExpression": "first", "ArrayExpression": "first",
"outerIIFEBody": 0, "outerIIFEBody": 0,
@@ -258,12 +215,6 @@
"FunctionExpression": {"parameters": "first"}, "FunctionExpression": {"parameters": "first"},
"FunctionDeclaration": {"parameters": "first"} "FunctionDeclaration": {"parameters": "first"}
}], }],
"key-spacing": [ "error",
{
"beforeColon": false,
"afterColon": true
}
],
"keyword-spacing": [ "error", "keyword-spacing": [ "error",
{ {
"before": true, "before": true,
@@ -275,7 +226,7 @@
} }
} }
], ],
"max-depth": [ "off", 4 ], "max-depth": [ 0, 4 ],
"max-len": [ "error", 100, 2, "max-len": [ "error", 100, 2,
{ {
"ignoreUrls": true, "ignoreUrls": true,
@@ -285,76 +236,75 @@
"ignoreTemplateLiterals": true "ignoreTemplateLiterals": true
} }
], ],
"max-params": [ "off", 3 ], "max-params": [ 0, 3 ],
"max-statements": [ "off", 10 ], "max-statements": [ 0, 10 ],
"new-cap": [ "error", "new-cap": [ "error",
{ {
"newIsCap": true, "newIsCap": true,
"capIsNew": false "capIsNew": false
} }
], ],
"new-parens": "error", "new-parens": 2,
"newline-per-chained-call": "off", "newline-per-chained-call": 0,
"no-alert": "error", "no-alert": 2,
"no-array-constructor": "error", "no-array-constructor": "error",
"no-bitwise": "error", "no-bitwise": 2,
"no-caller": "error", "no-caller": 2,
"no-case-declarations": "error", "no-case-declarations": "error",
"no-catch-shadow": "error", "no-catch-shadow": 2,
"no-console": "off", "no-console": 0,
"no-const-assign": "error", "no-const-assign": "error",
"no-control-regex": "error", "no-control-regex": 2,
"no-debugger": "error", "no-debugger": 2,
"no-delete-var": "error", "no-delete-var": 2,
"no-div-regex": "error", "no-div-regex": 2,
"no-dupe-class-members": "error", "no-dupe-class-members": "error",
"no-dupe-keys": "error", "no-dupe-keys": 2,
"no-duplicate-imports": "error", "no-duplicate-imports": "error",
"no-else-return": "error", "no-else-return": 2,
"no-empty": "error", "no-empty": 2,
"no-empty-character-class": "error", "no-empty-character-class": 2,
"no-eq-null": "error", "no-eq-null": 2,
"no-eval": "error", "no-eval": 2,
"no-ex-assign": "error", "no-ex-assign": 2,
"no-extra-parens": ["error", "all"], "no-extra-parens": [ "error", "functions" ],
"no-extra-semi": "error", "no-extra-semi": 2,
"no-fallthrough": "error", "no-fallthrough": 2,
"no-floating-decimal": "error", "no-floating-decimal": 2,
"no-func-assign": "error", "no-func-assign": 2,
"no-implied-eval": "error", "no-implied-eval": 2,
"no-iterator": "error", "no-iterator": "error",
"no-label-var": "error", "no-label-var": 2,
"no-labels": "error", "no-labels": 2,
"no-loop-func": "error", "no-loop-func": 2,
"no-mixed-requires": [ "off", false ], "no-mixed-requires": [ 0, false ],
"no-multi-str": "error", "no-multi-str": 2,
"no-native-reassign": "error", "no-native-reassign": 2,
"no-nested-ternary": "off", "no-nested-ternary": 0,
"no-new-func": "error", "no-new-func": "error",
"no-new-object": "error", "no-new-object": 2,
"no-new-wrappers": "error", "no-new-wrappers": 2,
"no-obj-calls": "error", "no-obj-calls": 2,
"no-octal": "error", "no-octal": 2,
"no-octal-escape": "error", "no-octal-escape": 2,
"no-param-reassign": "off", "no-param-reassign": 0,
"no-plusplus": "error", "no-plusplus": 2,
"no-proto": "error", "no-proto": 2,
"no-redeclare": "error", "no-redeclare": 2,
"no-regex-spaces": "error", "no-regex-spaces": 2,
"no-restricted-syntax": "off", "no-restricted-syntax": 0,
"no-return-assign": "error", "no-return-assign": 2,
"no-script-url": "error", "no-script-url": 2,
"no-self-compare": "error", "no-self-compare": 2,
"no-shadow": "off", "no-shadow": 0,
"no-sync": "error", "no-sync": 2,
"no-ternary": "off", "no-ternary": 0,
"no-trailing-spaces": "error",
"no-undef": "error", "no-undef": "error",
"no-undef-init": "error", "no-undef-init": 2,
"no-underscore-dangle": "off", "no-underscore-dangle": 0,
"no-unneeded-ternary": [ "error", { "defaultAssignment": false } ], "no-unneeded-ternary": [ "error", { "defaultAssignment": false } ],
"no-unreachable": "error", "no-unreachable": 2,
"no-unused-expressions": "error", "no-unused-expressions": 2,
"no-unused-vars": [ "error", "no-unused-vars": [ "error",
{ {
"vars": "local", "vars": "local",
@@ -362,18 +312,17 @@
"varsIgnorePattern": "print_elapsed_time|check_duplicate_ids" "varsIgnorePattern": "print_elapsed_time|check_duplicate_ids"
} }
], ],
"no-use-before-define": "error", "no-use-before-define": 2,
"no-useless-constructor": "error", "no-useless-constructor": "error",
// The Zulip codebase complies partially with the "no-useless-escape" // The Zulip codebase complies partially with the "no-useless-escape"
// rule; only regex expressions haven't been updated yet. // rule; only regex expressions haven't been updated yet.
// Updated regex expressions are currently being tested in casper // Updated regex expressions are currently being tested in casper
// files and will decide about a potential future enforcement of this rule. // files and will decide about a potential future enforcement of this rule.
"no-useless-escape": "off", "no-useless-escape": 0,
"space-unary-ops": "error", "no-whitespace-before-property": 0,
"no-whitespace-before-property": "error", "no-with": 2,
"no-with": "error",
"one-var": [ "error", "never" ], "one-var": [ "error", "never" ],
"padded-blocks": "off", "padded-blocks": 0,
"prefer-const": [ "error", "prefer-const": [ "error",
{ {
"destructuring": "any", "destructuring": "any",
@@ -387,11 +336,10 @@
"numbers": false "numbers": false
} }
], ],
"quotes": [ "off", "single" ], "quotes": [ 0, "single" ],
"radix": "error", "radix": 2,
"semi": "error", "semi": 2,
"semi-spacing": ["error", {"before": false, "after": true}], "space-before-blocks": 2,
"space-before-blocks": "error",
"space-before-function-paren": [ "error", "space-before-function-paren": [ "error",
{ {
"anonymous": "always", "anonymous": "always",
@@ -399,88 +347,16 @@
"asyncArrow": "always" "asyncArrow": "always"
} }
], ],
"space-in-parens": "error", "space-in-parens": 2,
"space-infix-ops": "error", "space-infix-ops": 0,
"spaced-comment": "off", "spaced-comment": 0,
"strict": "off", "strict": 0,
"template-curly-spacing": "error", "template-curly-spacing": "error",
"unnecessary-strict": "off", "unnecessary-strict": 0,
"use-isnan": "error", "use-isnan": 2,
"valid-typeof": [ "error", { "requireStringLiterals": true } ], "valid-typeof": [ "error", { "requireStringLiterals": true } ],
"wrap-iife": [ "error", "outside", { "functionPrototypeMethods": false } ], "wrap-iife": [ "error", "outside", { "functionPrototypeMethods": false } ],
"wrap-regex": "off", "wrap-regex": 0,
"yoda": "error" "yoda": 2
}, }
"overrides": [
{
"files": ["**/*.ts"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "static/js/tsconfig.json"
},
"plugins": ["@typescript-eslint"],
"rules": {
// Disable base rule to avoid conflict
"empty-returns/main": "off",
"indent": "off",
"func-call-spacing": "off",
"no-magic-numbers": "off",
"semi": "off",
"no-unused-vars": "off",
"@typescript-eslint/adjacent-overload-signatures": "error",
"@typescript-eslint/array-type": "error",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/ban-types": "error",
"@typescript-eslint/ban-ts-ignore": "off",
"@typescript-eslint/camelcase": "off",
"@typescript-eslint/class-name-casing": "error",
"@typescript-eslint/explicit-function-return-type": ["error", { "allowExpressions": true }],
"@typescript-eslint/explicit-member-accessibility": "off",
"@typescript-eslint/func-call-spacing": "error",
"@typescript-eslint/generic-type-naming": "off",
"@typescript-eslint/indent": "error",
"@typescript-eslint/interface-name-prefix": "off",
"@typescript-eslint/member-delimiter-style": "error",
"@typescript-eslint/member-naming": ["error", { "private": "^_" } ],
"@typescript-eslint/member-ordering": "error",
"@typescript-eslint/no-angle-bracket-type-assertion": "error",
"@typescript-eslint/no-array-constructor": "error",
"@typescript-eslint/no-empty-interface": "error",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-extraneous-class": "error",
"@typescript-eslint/no-for-in-array": "off",
"@typescript-eslint/no-inferrable-types": "error",
"@typescript-eslint/no-magic-numbers": "error",
"@typescript-eslint/no-misused-new": "error",
"@typescript-eslint/no-namespace": "error",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-object-literal-type-assertion": "error",
"@typescript-eslint/no-parameter-properties": "error",
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-this-alias": "off",
"@typescript-eslint/no-triple-slash-reference": "error",
"@typescript-eslint/no-type-alias": "off",
"@typescript-eslint/no-unnecessary-qualifier": "error",
"@typescript-eslint/no-unnecessary-type-assertion": "error",
"@typescript-eslint/no-unused-vars": ["error", { "varsIgnorePattern": "^_" } ],
"@typescript-eslint/no-use-before-define": "error",
"@typescript-eslint/no-useless-constructor": "error",
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/prefer-for-of": "off",
"@typescript-eslint/prefer-function-type": "off",
"@typescript-eslint/prefer-includes": "error",
"@typescript-eslint/prefer-interface": "off",
"@typescript-eslint/prefer-namespace-keyword": "error",
"@typescript-eslint/prefer-regexp-exec": "error",
"@typescript-eslint/prefer-string-starts-ends-with": "error",
"@typescript-eslint/promise-function-async": "error",
"@typescript-eslint/restrict-plus-operands": "off",
"@typescript-eslint/semi": "error",
"@typescript-eslint/type-annotation-spacing": "error",
"@typescript-eslint/unbound-method": "off",
"@typescript-eslint/unified-signatures": "error"
}
}
]
} }

1
.gitattributes vendored
View File

@@ -11,3 +11,4 @@
*.otf binary *.otf binary
*.tif binary *.tif binary
*.ogg binary *.ogg binary
yarn.lock binary

9
.gitignore vendored
View File

@@ -29,17 +29,9 @@ package-lock.json
/.vagrant /.vagrant
/var /var
/.dmypy.json
# Dockerfiles generated for CircleCI # Dockerfiles generated for CircleCI
/tools/circleci/images /tools/circleci/images
# Generated i18n data
/locale/en
/locale/language_options.json
/locale/language_name_map.json
/locale/*/mobile.json
# Static build # Static build
*.mo *.mo
npm-debug.log npm-debug.log
@@ -48,7 +40,6 @@ npm-debug.log
/staticfiles.json /staticfiles.json
/webpack-stats-production.json /webpack-stats-production.json
/yarn-error.log /yarn-error.log
zulip-git-version
# Test / analysis tools # Test / analysis tools
.coverage .coverage

View File

@@ -1,67 +0,0 @@
{
"rules": {
# Stylistic rules for CSS.
"function-comma-space-after": "always",
"function-comma-space-before": "never",
"function-max-empty-lines": 0,
"function-whitespace-after": "always",
"value-keyword-case": "lower",
"value-list-comma-newline-after": "always-multi-line",
"value-list-comma-space-after": "always-single-line",
"value-list-comma-space-before": "never",
"value-list-max-empty-lines": 0,
"unit-case": "lower",
"property-case": "lower",
"color-hex-case": "lower",
"declaration-bang-space-before": "always",
"declaration-colon-newline-after": "always-multi-line",
"declaration-colon-space-after": "always-single-line",
"declaration-colon-space-before": "never",
"declaration-block-semicolon-newline-after": "always",
"declaration-block-semicolon-space-before": "never",
"declaration-block-trailing-semicolon": "always",
"block-closing-brace-empty-line-before": "never",
"block-closing-brace-newline-after": "always",
"block-closing-brace-newline-before": "always",
"block-opening-brace-newline-after": "always",
"block-opening-brace-space-before": "always",
"selector-attribute-brackets-space-inside": "never",
"selector-attribute-operator-space-after": "never",
"selector-attribute-operator-space-before": "never",
"selector-combinator-space-after": "always",
"selector-combinator-space-before": "always",
"selector-descendant-combinator-no-non-space": true,
"selector-pseudo-class-parentheses-space-inside": "never",
"selector-pseudo-element-case": "lower",
"selector-pseudo-element-colon-notation": "double",
"selector-type-case": "lower",
"selector-list-comma-newline-after": "always",
"selector-list-comma-space-before": "never",
"media-feature-colon-space-after": "always",
"media-feature-colon-space-before": "never",
"media-feature-name-case": "lower",
"media-feature-parentheses-space-inside": "never",
"media-feature-range-operator-space-after": "always",
"media-feature-range-operator-space-before": "always",
"media-query-list-comma-newline-after": "always",
"media-query-list-comma-space-before": "never",
"at-rule-name-case": "lower",
"at-rule-name-space-after": "always",
"at-rule-semicolon-newline-after": "always",
"at-rule-semicolon-space-before": "never",
"comment-whitespace-inside": "always",
"indentation": 4,
# Limit language features
"color-no-hex": true,
"color-named": "never",
}
}

View File

@@ -1,6 +1,7 @@
# See https://zulip.readthedocs.io/en/latest/testing/continuous-integration.html for # See https://zulip.readthedocs.io/en/latest/testing/travis.html for
# high-level documentation on our Travis CI setup. # high-level documentation on our Travis CI setup.
dist: xenial dist: trusty
group: deprecated-2017Q4
install: install:
# Disable sometimes-broken sources.list in Travis base images # Disable sometimes-broken sources.list in Travis base images
- sudo rm -vf /etc/apt/sources.list.d/* - sudo rm -vf /etc/apt/sources.list.d/*
@@ -14,7 +15,7 @@ install:
- mispipe "pip install codecov" ts || mispipe "pip install codecov" ts - mispipe "pip install codecov" ts || mispipe "pip install codecov" ts
# This is the main setup job for the test suite # This is the main setup job for the test suite
- mispipe "tools/ci/setup-$TEST_SUITE" ts - mispipe "tools/travis/setup-$TEST_SUITE" ts
# Clean any caches that are not in use to avoid our cache # Clean any caches that are not in use to avoid our cache
# becoming huge. # becoming huge.
@@ -25,7 +26,7 @@ script:
# broken running their system puppet with Ruby. See # broken running their system puppet with Ruby. See
# https://travis-ci.org/zulip/zulip/jobs/240120991 for an example traceback. # https://travis-ci.org/zulip/zulip/jobs/240120991 for an example traceback.
- unset GEM_PATH - unset GEM_PATH
- mispipe "./tools/ci/$TEST_SUITE" ts - mispipe "./tools/travis/$TEST_SUITE" ts
cache: cache:
yarn: true yarn: true
apt: false apt: false
@@ -37,17 +38,17 @@ cache:
- $HOME/misc - $HOME/misc
env: env:
global: global:
- BOTO_CONFIG=/nonexistent - BOTO_CONFIG=/tmp/nowhere
language: python language: python
# Our test suites generally run on Python 3.5, the version in # Our test suites generally run on Python 3.4, the version in
# Ubuntu 16.04 xenial, which is the oldest OS release we support. # Ubuntu 14.04 trusty, which is the oldest OS release we support.
matrix: matrix:
include: include:
# Travis will actually run the jobs in the order they're listed here; # Travis will actually run the jobs in the order they're listed here;
# that doesn't seem to be documented, but it's what we see empirically. # that doesn't seem to be documented, but it's what we see empirically.
# We only get 4 jobs running at a time, so we try to make the first few # We only get 4 jobs running at a time, so we try to make the first few
# the most likely to break. # the most likely to break.
- python: "3.5" - python: "3.4"
env: TEST_SUITE=production env: TEST_SUITE=production
# Other suites moved to CircleCI -- see .circleci/. # Other suites moved to CircleCI -- see .circleci/.
sudo: required sudo: required
@@ -58,7 +59,7 @@ addons:
# debugging test flakes. # debugging test flakes.
- $(ls var/casper/* | tr "\n" ":") - $(ls var/casper/* | tr "\n" ":")
- $(ls /tmp/zulip-test-event-log/* | tr "\n" ":") - $(ls /tmp/zulip-test-event-log/* | tr "\n" ":")
postgresql: "9.5" postgresql: "9.3"
apt: apt:
packages: packages:
- moreutils - moreutils

View File

@@ -3,31 +3,31 @@ host = https://www.transifex.com
lang_map = zh-Hans: zh_Hans, zh-Hant: zh_Hant lang_map = zh-Hans: zh_Hans, zh-Hant: zh_Hant
[zulip.djangopo] [zulip.djangopo]
file_filter = locale/<lang>/LC_MESSAGES/django.po source_file = static/locale/en/LC_MESSAGES/django.po
source_file = locale/en/LC_MESSAGES/django.po
source_lang = en source_lang = en
type = PO type = PO
file_filter = static/locale/<lang>/LC_MESSAGES/django.po
[zulip.translationsjson] [zulip.translationsjson]
file_filter = locale/<lang>/translations.json source_file = static/locale/en/translations.json
source_file = locale/en/translations.json
source_lang = en source_lang = en
type = KEYVALUEJSON type = KEYVALUEJSON
file_filter = static/locale/<lang>/translations.json
[zulip.mobile] [zulip.messages]
file_filter = locale/<lang>/mobile.json source_file = static/locale/en/mobile.json
source_file = locale/en/mobile.json
source_lang = en source_lang = en
type = KEYVALUEJSON type = KEYVALUEJSON
file_filter = static/locale/<lang>/mobile.json
[zulip-test.djangopo] [zulip-test.djangopo]
file_filter = locale/<lang>/LC_MESSAGES/django.po source_file = static/locale/en/LC_MESSAGES/django.po
source_file = locale/en/LC_MESSAGES/django.po
source_lang = en source_lang = en
type = PO type = PO
file_filter = static/locale/<lang>/LC_MESSAGES/django.po
[zulip-test.translationsjson] [zulip-test.translationsjson]
file_filter = locale/<lang>/translations.json source_file = static/locale/en/translations.json
source_file = locale/en/translations.json
source_lang = en source_lang = en
type = KEYVALUEJSON type = KEYVALUEJSON
file_filter = static/locale/<lang>/translations.json

View File

@@ -1 +0,0 @@
ignore-scripts true

View File

@@ -78,7 +78,7 @@ something you can do while a violation is happening, do it. A lot of the
harms of harassment and other violations can be mitigated by the victim harms of harassment and other violations can be mitigated by the victim
knowing that the other people present are on their side. knowing that the other people present are on their side.
All reports will be kept confidential. In some cases, we may determine that a All reports will be kept confidential. In some cases we may determine that a
public statement will need to be made. In such cases, the identities of all public statement will need to be made. In such cases, the identities of all
victims and reporters will remain confidential unless those individuals victims and reporters will remain confidential unless those individuals
instruct us otherwise. instruct us otherwise.

View File

@@ -13,8 +13,7 @@ user, or anything else. Make sure to read the
before posting. The Zulip community is also governed by a before posting. The Zulip community is also governed by a
[code of conduct](https://zulip.readthedocs.io/en/latest/code-of-conduct.html). [code of conduct](https://zulip.readthedocs.io/en/latest/code-of-conduct.html).
You can subscribe to zulip-devel-announce@googlegroups.com or our You can subscribe to zulip-devel@googlegroups.com for a lower traffic (~1
[Twitter](https://twitter.com/zulip) account for a lower traffic (~1
email/month) way to hear about things like mentorship opportunities with Google email/month) way to hear about things like mentorship opportunities with Google
Code-in, in-person sprints at conferences, and other opportunities to Code-in, in-person sprints at conferences, and other opportunities to
contribute. contribute.
@@ -29,10 +28,10 @@ needs doing:
[backend](https://github.com/zulip/zulip), web [backend](https://github.com/zulip/zulip), web
[frontend](https://github.com/zulip/zulip), React Native [frontend](https://github.com/zulip/zulip), React Native
[mobile app](https://github.com/zulip/zulip-mobile), or Electron [mobile app](https://github.com/zulip/zulip-mobile), or Electron
[desktop app](https://github.com/zulip/zulip-desktop). [desktop app](https://github.com/zulip/zulip-electron).
* Building out our * Building out our
[Python API and bots](https://github.com/zulip/python-zulip-api) framework. [Python API and bots](https://github.com/zulip/python-zulip-api) framework.
* [Writing an integration](https://zulipchat.com/api/integrations-overview). * [Writing an integration](https://zulipchat.com/api/integration-guide).
* Improving our [user](https://zulipchat.com/help/) or * Improving our [user](https://zulipchat.com/help/) or
[developer](https://zulip.readthedocs.io/en/latest/) documentation. [developer](https://zulip.readthedocs.io/en/latest/) documentation.
* [Reviewing code](https://zulip.readthedocs.io/en/latest/contributing/code-reviewing.html) * [Reviewing code](https://zulip.readthedocs.io/en/latest/contributing/code-reviewing.html)
@@ -59,22 +58,21 @@ to help.
[Zulip community server](https://zulip.readthedocs.io/en/latest/contributing/chat-zulip-org.html), [Zulip community server](https://zulip.readthedocs.io/en/latest/contributing/chat-zulip-org.html),
paying special attention to the community norms. If you'd like, introduce paying special attention to the community norms. If you'd like, introduce
yourself in yourself in
[#new members](https://chat.zulip.org/#narrow/stream/95-new-members), using [#new members](https://chat.zulip.org/#narrow/stream/new.20members), using
your name as the topic. Bonus: tell us about your first impressions of your name as the topic. Bonus: tell us about your first impressions of
Zulip, and anything that felt confusing/broken as you started using the Zulip, and anything that felt confusing/broken as you started using the
product. product.
* Read [What makes a great Zulip contributor](#what-makes-a-great-zulip-contributor). * Read [What makes a great Zulip contributor](#what-makes-a-great-zulip-contributor).
* [Install the development environment](https://zulip.readthedocs.io/en/latest/development/overview.html), * [Install the development environment](https://zulip.readthedocs.io/en/latest/development/overview.html),
getting help in getting help in
[#development help](https://chat.zulip.org/#narrow/stream/49-development-help) [#development help](https://chat.zulip.org/#narrow/stream/development.20help)
if you run into any troubles. if you run into any troubles.
* Read the * Read the
[Zulip guide to Git](https://zulip.readthedocs.io/en/latest/git/index.html) [Zulip guide to Git](https://zulip.readthedocs.io/en/latest/git/index.html)
and do the Git tutorial (coming soon) if you are unfamiliar with and do the Git tutorial (coming soon) if you are unfamiliar with Git,
Git, getting help in getting help in
[#git help](https://chat.zulip.org/#narrow/stream/44-git-help) if [#git help](https://chat.zulip.org/#narrow/stream/git.20help) if you run
you run into any troubles. Be sure to check out the into any troubles.
[extremely useful Zulip-specific tools page](https://zulip.readthedocs.io/en/latest/git/zulip-tools.html).
* Sign the * Sign the
[Dropbox Contributor License Agreement](https://opensource.dropbox.com/cla/). [Dropbox Contributor License Agreement](https://opensource.dropbox.com/cla/).
@@ -86,53 +84,43 @@ on.
* If you're interested in * If you're interested in
[mobile](https://github.com/zulip/zulip-mobile/issues?q=is%3Aopen+is%3Aissue), [mobile](https://github.com/zulip/zulip-mobile/issues?q=is%3Aopen+is%3Aissue),
[desktop](https://github.com/zulip/zulip-desktop/issues?q=is%3Aopen+is%3Aissue), [desktop](https://github.com/zulip/zulip-electron/issues?q=is%3Aopen+is%3Aissue),
or or
[bots](https://github.com/zulip/python-zulip-api/issues?q=is%3Aopen+is%3Aissue) [bots](https://github.com/zulip/python-zulip-api/issues?q=is%3Aopen+is%3Aissue)
development, check the respective links for open issues, or post in development, check the respective links for open issues, or post in
[#mobile](https://chat.zulip.org/#narrow/stream/48-mobile), [#mobile](https://chat.zulip.org/#narrow/stream/mobile),
[#desktop](https://chat.zulip.org/#narrow/stream/16-desktop), or [#electron](https://chat.zulip.org/#narrow/stream/electron), or
[#integration](https://chat.zulip.org/#narrow/stream/127-integrations). [#bots](https://chat.zulip.org/#narrow/stream/bots).
* For the main server and web repository, we recommend browsing * For the main server and web repository, start by looking through issues
recently opened issues to look for issues you are confident you can with the label
fix correctly in a way that clearly communicates why your changes [good first issue](https://github.com/zulip/zulip/issues?q=is%3Aopen+is%3Aissue+label%3A"good+first+issue").
are the correct fix. Our GitHub workflow bot, zulipbot, limits These are smaller projects particularly suitable for a first contribution.
users who have 0 commits merged to claiming a single issue labeled
with "good first issue" or "help wanted".
* We also partition all of our issues in the main repo into areas like * We also partition all of our issues in the main repo into areas like
admin, compose, emoji, hotkeys, i18n, onboarding, search, etc. Look admin, compose, emoji, hotkeys, i18n, onboarding, search, etc. Look
through our [list of labels](https://github.com/zulip/zulip/labels), and through our [list of labels](https://github.com/zulip/zulip/labels), and
click on some of the `area:` labels to see all the issues related to your click on some of the `area:` labels to see all the issues related to your
areas of interest. areas of interest.
* If the lists of issues are overwhelming, post in * If the lists of issues are overwhelming, post in
[#new members](https://chat.zulip.org/#narrow/stream/95-new-members) with a [#new members](https://chat.zulip.org/#narrow/stream/new.20members) with a
bit about your background and interests, and we'll help you out. The most bit about your background and interests, and we'll help you out. The most
important thing to say is whether you're looking for a backend (Python), important thing to say is whether you're looking for a backend (Python),
frontend (JavaScript and TypeScript), mobile (React Native), desktop (Electron), frontend (JavaScript), mobile (React Native), desktop (Electron),
documentation (English) or visual design (JavaScript/TypeScript + CSS) issue, and a documentation (English) or visual design (JavaScript + CSS) issue, and a
bit about your programming experience and available time. bit about your programming experience and available time.
We also welcome suggestions of features that you feel would be valuable or We also welcome suggestions of features that you feel would be valuable or
changes that you feel would make Zulip a better open source project. If you changes that you feel would make Zulip a better open source project. If you
have a new feature you'd like to add, we recommend you start by posting in have a new feature you'd like to add, we recommend you start by posting in
[#new members](https://chat.zulip.org/#narrow/stream/95-new-members) with the [#new members](https://chat.zulip.org/#narrow/stream/new.20members) with the
feature idea and the problem that you're hoping to solve. feature idea and the problem that you're hoping to solve.
Other notes: Other notes:
* For a first pull request, it's better to aim for a smaller contribution * For a first pull request, it's better to aim for a smaller contribution
than a bigger one. Many first contributions have fewer than 10 lines of than a bigger one. Many first contributions have fewer than 10 lines of
changes (not counting changes to tests). changes (not counting changes to tests).
* The full list of issues explicitly looking for a contributor can be * The full list of issues looking for a contributor can be found with the
found with the
[good first issue](https://github.com/zulip/zulip/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22)
and
[help wanted](https://github.com/zulip/zulip/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22) [help wanted](https://github.com/zulip/zulip/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22)
labels. Avoid issues with the "difficult" label unless you label.
understand why it is difficult and are confident you can resolve the
issue correctly and completely. Issues without one of these labels
are fair game if Tim has written a clear technical design proposal
in the issue, or it is a bug that you can reproduce and you are
confident you can fix the issue correctly.
* For most new contributors, there's a lot to learn while making your first * For most new contributors, there's a lot to learn while making your first
pull request. It's OK if it takes you a while; that's normal! You'll be pull request. It's OK if it takes you a while; that's normal! You'll be
able to work a lot faster as you build experience. able to work a lot faster as you build experience.
@@ -144,12 +132,6 @@ the issue thread. [Zulipbot](https://github.com/zulip/zulipbot) is a GitHub
workflow bot; it will assign you to the issue and label the issue as "in workflow bot; it will assign you to the issue and label the issue as "in
progress". Some additional notes: progress". Some additional notes:
* You can only claim issues with the
[good first issue](https://github.com/zulip/zulip/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22)
or
[help wanted](https://github.com/zulip/zulip/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22)
labels. Zulipbot will give you an error if you try to claim an issue
without one of those labels.
* You're encouraged to ask questions on how to best implement or debug your * You're encouraged to ask questions on how to best implement or debug your
changes -- the Zulip maintainers are excited to answer questions to help changes -- the Zulip maintainers are excited to answer questions to help
you stay unblocked and working efficiently. You can ask questions on you stay unblocked and working efficiently. You can ask questions on
@@ -204,9 +186,9 @@ bugs, feel free to just open an issue on the relevant project on GitHub.
If you have a feature request or are not yet sure what the underlying bug If you have a feature request or are not yet sure what the underlying bug
is, the best place to post issues is is, the best place to post issues is
[#issues](https://chat.zulip.org/#narrow/stream/9-issues) (or [#issues](https://chat.zulip.org/#narrow/stream/issues) (or
[#mobile](https://chat.zulip.org/#narrow/stream/48-mobile) or [#mobile](https://chat.zulip.org/#narrow/stream/mobile) or
[#desktop](https://chat.zulip.org/#narrow/stream/16-desktop)) on the [#electron](https://chat.zulip.org/#narrow/stream/electron)) on the
[Zulip community server](https://zulip.readthedocs.io/en/latest/contributing/chat-zulip-org.html). [Zulip community server](https://zulip.readthedocs.io/en/latest/contributing/chat-zulip-org.html).
This allows us to interactively figure out what is going on, let you know if This allows us to interactively figure out what is going on, let you know if
a similar issue has already been opened, and collect any other information a similar issue has already been opened, and collect any other information
@@ -319,7 +301,7 @@ list typically takes about 15 minutes.
* Star us on GitHub. There are four main repositories: * Star us on GitHub. There are four main repositories:
[server/web](https://github.com/zulip/zulip), [server/web](https://github.com/zulip/zulip),
[mobile](https://github.com/zulip/zulip-mobile), [mobile](https://github.com/zulip/zulip-mobile),
[desktop](https://github.com/zulip/zulip-desktop), and [desktop](https://github.com/zulip/zulip-electron), and
[Python API](https://github.com/zulip/python-zulip-api). [Python API](https://github.com/zulip/python-zulip-api).
* [Follow us](https://twitter.com/zulip) on Twitter. * [Follow us](https://twitter.com/zulip) on Twitter.

17
Dockerfile-dev Normal file
View File

@@ -0,0 +1,17 @@
FROM ubuntu:trusty
EXPOSE 9991
RUN apt-get update && apt-get install -y wget
RUN locale-gen en_US.UTF-8
RUN useradd -d /home/zulip -m zulip && echo 'zulip ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
USER zulip
RUN ln -nsf /srv/zulip ~/zulip
RUN echo 'export LC_ALL="en_US.UTF-8" LANG="en_US.UTF-8" LANGUAGE="en_US.UTF-8"' >> ~zulip/.bashrc
WORKDIR /srv/zulip

View File

@@ -1,23 +0,0 @@
# To build run `docker build -f Dockerfile-postgresql .` from the root of the
# zulip repo.
# Currently the postgres images do not support automatic upgrading of
# the on-disk data in volumes. So the base image can not currently be upgraded
# without users needing a manual pgdump and restore.
# Install hunspell, zulip stop words, and run zulip database
# init.
FROM postgres:10
COPY puppet/zulip/files/postgresql/zulip_english.stop /usr/share/postgresql/$PG_MAJOR/tsearch_data/zulip_english.stop
COPY scripts/setup/create-db.sql /docker-entrypoint-initdb.d/zulip-create-db.sql
COPY scripts/setup/create-pgroonga.sql /docker-entrypoint-initdb.d/zulip-create-pgroonga.sql
COPY scripts/setup/pgroonga-debian.asc /tmp
RUN apt-key add /tmp/pgroonga-debian.asc \
&& echo "deb http://packages.groonga.org/debian/ stretch main" > /etc/apt/sources.list.d/zulip.list \
&& apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \
hunspell-en-us \
postgresql-${PG_MAJOR}-pgroonga \
&& ln -sf /var/cache/postgresql/dicts/en_us.dict "/usr/share/postgresql/$PG_MAJOR/tsearch_data/en_us.dict" \
&& ln -sf /var/cache/postgresql/dicts/en_us.affix "/usr/share/postgresql/$PG_MAJOR/tsearch_data/en_us.affix" \
&& rm -rf /var/lib/apt/lists/*

47
LICENSE
View File

@@ -1,4 +1,24 @@
Copyright 2011-2018 Dropbox, Inc., Kandra Labs, Inc., and contributors Copyright 2011-2017 Dropbox, Inc., Kandra Labs, Inc., and contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
The software includes some works released by third parties under other
free and open source licenses. Those works are redistributed under the
license terms under which the works were received. For more details,
see the ``docs/THIRDPARTY`` file included with this distribution.
--------------------------------------------------------------------------------
Apache License Apache License
Version 2.0, January 2004 Version 2.0, January 2004
@@ -176,28 +196,3 @@ Copyright 2011-2018 Dropbox, Inc., Kandra Labs, Inc., and contributors
of your accepting any such warranty or additional liability. of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

16
NOTICE
View File

@@ -1,16 +0,0 @@
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this project except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
The software includes some works released by third parties under other
free and open source licenses. Those works are redistributed under the
license terms under which the works were received. For more details,
see the ``docs/THIRDPARTY`` file included with this distribution.

View File

@@ -5,13 +5,13 @@ immediacy of real-time chat with the productivity benefits of threaded
conversations. Zulip is used by open source projects, Fortune 500 companies, conversations. Zulip is used by open source projects, Fortune 500 companies,
large standards bodies, and others who need a real-time chat system that large standards bodies, and others who need a real-time chat system that
allows users to easily process hundreds or thousands of messages a day. With allows users to easily process hundreds or thousands of messages a day. With
over 500 contributors merging over 500 commits a month, Zulip is also the over 300 contributors merging over 500 commits a month, Zulip is also the
largest and fastest growing open source group chat project. largest and fastest growing open source group chat project.
[![CircleCI branch](https://img.shields.io/circleci/project/github/zulip/zulip/master.svg)](https://circleci.com/gh/zulip/zulip/tree/master) [![CircleCI Build Status](https://circleci.com/gh/zulip/zulip.svg?style=svg)](https://circleci.com/gh/zulip/zulip)
[![Coverage Status](https://img.shields.io/codecov/c/github/zulip/zulip/master.svg)](https://codecov.io/gh/zulip/zulip/branch/master) [![Travis Build Status](https://travis-ci.org/zulip/zulip.svg?branch=master)](https://travis-ci.org/zulip/zulip)
[![Coverage Status](https://img.shields.io/codecov/c/github/zulip/zulip.svg)](https://codecov.io/gh/zulip/zulip)
[![Mypy coverage](https://img.shields.io/badge/mypy-100%25-green.svg)][mypy-coverage] [![Mypy coverage](https://img.shields.io/badge/mypy-100%25-green.svg)][mypy-coverage]
[![GitHub release](https://img.shields.io/github/release/zulip/zulip.svg)](https://github.com/zulip/zulip/releases/latest)
[![docs](https://readthedocs.org/projects/zulip/badge/?version=latest)](https://zulip.readthedocs.io/en/latest/) [![docs](https://readthedocs.org/projects/zulip/badge/?version=latest)](https://zulip.readthedocs.io/en/latest/)
[![Zulip chat](https://img.shields.io/badge/zulip-join_chat-brightgreen.svg)](https://chat.zulip.org) [![Zulip chat](https://img.shields.io/badge/zulip-join_chat-brightgreen.svg)](https://chat.zulip.org)
[![Twitter](https://img.shields.io/badge/twitter-@zulip-blue.svg?style=flat)](https://twitter.com/zulip) [![Twitter](https://img.shields.io/badge/twitter-@zulip-blue.svg?style=flat)](https://twitter.com/zulip)
@@ -55,11 +55,12 @@ You might be interested in:
[companies](https://zulipchat.com/for/companies/), or Zulip for [companies](https://zulipchat.com/for/companies/), or Zulip for
[working groups and part time communities](https://zulipchat.com/for/working-groups-and-communities/). [working groups and part time communities](https://zulipchat.com/for/working-groups-and-communities/).
* **Running a Zulip server**. Use a preconfigured [Digital Ocean droplet](https://marketplace.digitalocean.com/apps/zulip), * **Running a Zulip server**. Setting up a server takes just a couple of
[install Zulip](https://zulip.readthedocs.io/en/stable/production/install.html) minutes. Zulip runs on Ubuntu 16.04 Xenial and Ubuntu 14.04 Trusty. The
directly, or use Zulip's installation process is
experimental [Docker image](https://zulip.readthedocs.io/en/latest/production/deployment.html#zulip-in-docker). [documented here](https://zulip.readthedocs.io/en/stable/prod.html).
Commercial support is available; see <https://zulipchat.com/plans> for details. Commercial support is available; see <https://zulipchat.com/plans> for
details.
* **Using Zulip without setting up a server**. <https://zulipchat.com> offers * **Using Zulip without setting up a server**. <https://zulipchat.com> offers
free and commercial hosting. free and commercial hosting.

137
Vagrantfile vendored
View File

@@ -19,6 +19,43 @@ if Vagrant::VERSION == "1.8.7" then
end end
end end
# Workaround: the lxc-config in vagrant-lxc is incompatible with changes in
# LXC 2.1.0, found in Ubuntu 17.10 artful. LXC 2.1.1 (in 18.04 LTS bionic)
# ignores the old config key, so this will only be needed for artful.
#
# vagrant-lxc upstream has an attempted fix:
# https://github.com/fgrehm/vagrant-lxc/issues/445
# but it didn't work in our testing. This is a temporary issue, so we just
# hack in a fix: we patch the skeleton `lxc-config` file right in the
# distribution of the vagrant-lxc "box" we use. If the user doesn't yet
# have the box (e.g. on first setup), Vagrant would download it but too
# late for us to patch it like this; so we prompt them to explicitly add it
# first and then rerun.
if ['up', 'provision'].include? ARGV[0]
if command? "lxc-ls"
LXC_VERSION = `lxc-ls --version`.strip unless defined? LXC_VERSION
if LXC_VERSION == "2.1.0"
lxc_config_file = ENV['HOME'] + "/.vagrant.d/boxes/fgrehm-VAGRANTSLASH-trusty64-lxc/1.2.0/lxc/lxc-config"
if File.file?(lxc_config_file)
lines = File.readlines(lxc_config_file)
deprecated_line = "lxc.pivotdir = lxc_putold\n"
if lines[1] == deprecated_line
lines[1] = "# #{deprecated_line}"
File.open(lxc_config_file, 'w') do |f|
f.puts(lines)
end
end
else
puts 'You are running LXC 2.1.0, and fgrehm/trusty64-lxc box is incompatible '\
"with it by default. First add the box by doing:\n"\
" vagrant box add https://vagrantcloud.com/fgrehm/trusty64-lxc\n"\
'Once this command succeeds, do "vagrant up" again.'
exit
end
end
end
end
# Workaround: Vagrant removed the atlas.hashicorp.com to # Workaround: Vagrant removed the atlas.hashicorp.com to
# vagrantcloud.com redirect in February 2018. The value of # vagrantcloud.com redirect in February 2018. The value of
# DEFAULT_SERVER_URL in Vagrant versions less than 1.9.3 is # DEFAULT_SERVER_URL in Vagrant versions less than 1.9.3 is
@@ -29,38 +66,24 @@ if Vagrant::DEFAULT_SERVER_URL == "atlas.hashicorp.com"
Vagrant::DEFAULT_SERVER_URL.replace('https://vagrantcloud.com') Vagrant::DEFAULT_SERVER_URL.replace('https://vagrantcloud.com')
end end
# Monkey patch https://github.com/hashicorp/vagrant/pull/10879 so we
# can fall back to another provider if docker is not installed.
begin
require Vagrant.source_root.join("plugins", "providers", "docker", "provider")
rescue LoadError
else
VagrantPlugins::DockerProvider::Provider.class_eval do
method(:usable?).owner == singleton_class or def self.usable?(raise_error=false)
VagrantPlugins::DockerProvider::Driver.new.execute("docker", "version")
true
rescue Vagrant::Errors::CommandUnavailable, VagrantPlugins::DockerProvider::Errors::ExecuteError
raise if raise_error
return false
end
end
end
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
# For LXC. VirtualBox hosts use a different box, described below.
config.vm.box = "fgrehm/trusty64-lxc"
# The Zulip development environment runs on 9991 on the guest. # The Zulip development environment runs on 9991 on the guest.
host_port = 9991 host_port = 9991
http_proxy = https_proxy = no_proxy = nil http_proxy = https_proxy = no_proxy = nil
host_ip_addr = "127.0.0.1" host_ip_addr = "127.0.0.1"
# System settings for the virtual machine.
vm_num_cpus = "2"
vm_memory = "2048"
ubuntu_mirror = ""
config.vm.synced_folder ".", "/vagrant", disabled: true config.vm.synced_folder ".", "/vagrant", disabled: true
config.vm.synced_folder ".", "/srv/zulip" if (/darwin/ =~ RUBY_PLATFORM) != nil
config.vm.synced_folder ".", "/srv/zulip", type: "nfs",
linux__nfs_options: ['rw']
config.vm.network "private_network", type: "dhcp"
else
config.vm.synced_folder ".", "/srv/zulip"
end
vagrant_config_file = ENV['HOME'] + "/.zulip-vagrant-config" vagrant_config_file = ENV['HOME'] + "/.zulip-vagrant-config"
if File.file?(vagrant_config_file) if File.file?(vagrant_config_file)
@@ -74,9 +97,6 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
when "NO_PROXY"; no_proxy = value when "NO_PROXY"; no_proxy = value
when "HOST_PORT"; host_port = value.to_i when "HOST_PORT"; host_port = value.to_i
when "HOST_IP_ADDR"; host_ip_addr = value when "HOST_IP_ADDR"; host_ip_addr = value
when "GUEST_CPUS"; vm_num_cpus = value
when "GUEST_MEMORY_MB"; vm_memory = value
when "UBUNTU_MIRROR"; ubuntu_mirror = value
end end
end end
end end
@@ -102,28 +122,35 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.network "forwarded_port", guest: 9991, host: host_port, host_ip: host_ip_addr config.vm.network "forwarded_port", guest: 9991, host: host_port, host_ip: host_ip_addr
config.vm.network "forwarded_port", guest: 9994, host: host_port + 3, host_ip: host_ip_addr config.vm.network "forwarded_port", guest: 9994, host: host_port + 3, host_ip: host_ip_addr
# Specify Docker provider before VirtualBox provider so it's preferred. # Specify LXC provider before VirtualBox provider so it's preferred.
config.vm.provider "docker" do |d, override| config.vm.provider "lxc" do |lxc|
d.build_dir = File.join(__dir__, "tools", "setup", "dev-vagrant-docker") if command? "lxc-ls"
d.build_args = ["--build-arg", "VAGRANT_UID=#{Process.uid}"] LXC_VERSION = `lxc-ls --version`.strip unless defined? LXC_VERSION
if !ubuntu_mirror.empty? if LXC_VERSION >= "1.1.0" and LXC_VERSION < "3.0.0"
d.build_args += ["--build-arg", "UBUNTU_MIRROR=#{ubuntu_mirror}"] # Allow start without AppArmor, otherwise Box will not Start on Ubuntu 14.10
# see https://github.com/fgrehm/vagrant-lxc/issues/333
lxc.customize 'aa_allow_incomplete', 1
end
if LXC_VERSION >= "3.0.0"
lxc.customize 'apparmor.allow_incomplete', 1
end
if LXC_VERSION >= "2.0.0"
lxc.backingstore = 'dir'
end
end end
d.has_ssh = true
d.create_args = ["--ulimit", "nofile=1024:65536"]
end end
config.vm.provider "virtualbox" do |vb, override| config.vm.provider "virtualbox" do |vb, override|
override.vm.box = "ubuntu/bionic64" override.vm.box = "ubuntu/trusty64"
# An unnecessary log file gets generated when running vagrant up for the
# first time with the Ubuntu Bionic box. This looks like it is being
# caused upstream by the base box containing a Vagrantfile with a similar
# line to the one below.
# see https://github.com/hashicorp/vagrant/issues/9425
vb.customize [ "modifyvm", :id, "--uartmode1", "disconnected" ]
# It's possible we can get away with just 1.5GB; more testing needed # It's possible we can get away with just 1.5GB; more testing needed
vb.memory = vm_memory vb.memory = 2048
vb.cpus = vm_num_cpus vb.cpus = 2
end
config.vm.provider "vmware_fusion" do |vb, override|
override.vm.box = "puphpet/ubuntu1404-x64"
vb.vmx["memsize"] = "2048"
vb.vmx["numvcpus"] = "2"
end end
$provision_script = <<SCRIPT $provision_script = <<SCRIPT
@@ -135,15 +162,19 @@ set -o pipefail
# something that we don't want to happen when running provision in a # something that we don't want to happen when running provision in a
# development environment not using Vagrant. # development environment not using Vagrant.
# Set the Ubuntu mirror
[ ! '#{ubuntu_mirror}' ] || sudo sed -i 's|http://\\(\\w*\\.\\)*archive\\.ubuntu\\.com/ubuntu/\\? |#{ubuntu_mirror} |' /etc/apt/sources.list
# Set the MOTD on the system to have Zulip instructions # Set the MOTD on the system to have Zulip instructions
sudo ln -nsf /srv/zulip/tools/setup/dev-motd /etc/update-motd.d/99-zulip-dev sudo rm -f /etc/update-motd.d/*
sudo rm -f /etc/update-motd.d/10-help-text sudo bash -c 'cat << EndOfMessage > /etc/motd
sudo dpkg --purge landscape-client landscape-common ubuntu-release-upgrader-core update-manager-core update-notifier-common ubuntu-server Welcome to the Zulip development environment! Popular commands:
sudo dpkg-divert --add --rename /etc/default/motd-news * tools/provision - Update the development environment
sudo sh -c 'echo ENABLED=0 > /etc/default/motd-news' * tools/run-dev.py - Run the development server
* tools/lint - Run the linter (quick and catches many problmes)
* tools/test-* - Run tests (use --help to learn about options)
Read https://zulip.readthedocs.io/en/latest/testing/testing.html to learn
how to run individual test suites so that you can get a fast debug cycle.
EndOfMessage'
# If the host is running SELinux remount the /sys/fs/selinux directory as read only, # If the host is running SELinux remount the /sys/fs/selinux directory as read only,
# needed for apt-get to work. # needed for apt-get to work.
@@ -171,7 +202,7 @@ if [ ! -w /srv/zulip ]; then
# sudo is required since our uid is not 1000 # sudo is required since our uid is not 1000
echo ' vagrant halt -f' echo ' vagrant halt -f'
echo ' rm -rf /PATH/TO/ZULIP/CLONE/.vagrant' echo ' rm -rf /PATH/TO/ZULIP/CLONE/.vagrant'
echo ' sudo chown -R 1000:$(id -g) /PATH/TO/ZULIP/CLONE' echo ' sudo chown -R 1000:$(whoami) /PATH/TO/ZULIP/CLONE'
echo "Replace /PATH/TO/ZULIP/CLONE with the path to where zulip code is cloned." echo "Replace /PATH/TO/ZULIP/CLONE with the path to where zulip code is cloned."
echo "You can resume setting up your vagrant environment by running:" echo "You can resume setting up your vagrant environment by running:"
echo " vagrant up" echo " vagrant up"

View File

@@ -2,14 +2,14 @@ import time
from collections import OrderedDict, defaultdict from collections import OrderedDict, defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
from typing import Callable, Dict, List, \ from typing import Any, Callable, Dict, List, \
Optional, Tuple, Type, Union Optional, Tuple, Type, Union
from django.conf import settings from django.conf import settings
from django.db import connection from django.db import connection, models
from django.db.models import F from django.db.models import F
from analytics.models import BaseCount, \ from analytics.models import Anomaly, BaseCount, \
FillState, InstallationCount, RealmCount, StreamCount, \ FillState, InstallationCount, RealmCount, StreamCount, \
UserCount, installation_epoch, last_successful_fill UserCount, installation_epoch, last_successful_fill
from zerver.lib.logging_util import log_to_file from zerver.lib.logging_util import log_to_file
@@ -226,6 +226,7 @@ def do_drop_all_analytics_tables() -> None:
RealmCount.objects.all().delete() RealmCount.objects.all().delete()
InstallationCount.objects.all().delete() InstallationCount.objects.all().delete()
FillState.objects.all().delete() FillState.objects.all().delete()
Anomaly.objects.all().delete()
def do_drop_single_stat(property: str) -> None: def do_drop_single_stat(property: str) -> None:
UserCount.objects.filter(property=property).delete() UserCount.objects.filter(property=property).delete()
@@ -295,8 +296,8 @@ count_message_by_user_query = """
zerver_userprofile.id = zerver_message.sender_id zerver_userprofile.id = zerver_message.sender_id
WHERE WHERE
zerver_userprofile.date_joined < %%(time_end)s AND zerver_userprofile.date_joined < %%(time_end)s AND
zerver_message.date_sent >= %%(time_start)s AND zerver_message.pub_date >= %%(time_start)s AND
zerver_message.date_sent < %%(time_end)s zerver_message.pub_date < %%(time_end)s
GROUP BY zerver_userprofile.id %(group_by_clause)s GROUP BY zerver_userprofile.id %(group_by_clause)s
""" """
@@ -322,8 +323,8 @@ count_message_type_by_user_query = """
JOIN zerver_message JOIN zerver_message
ON ON
zerver_userprofile.id = zerver_message.sender_id AND zerver_userprofile.id = zerver_message.sender_id AND
zerver_message.date_sent >= %%(time_start)s AND zerver_message.pub_date >= %%(time_start)s AND
zerver_message.date_sent < %%(time_end)s zerver_message.pub_date < %%(time_end)s
JOIN zerver_recipient JOIN zerver_recipient
ON ON
zerver_message.recipient_id = zerver_recipient.id zerver_message.recipient_id = zerver_recipient.id
@@ -359,8 +360,8 @@ count_message_by_stream_query = """
WHERE WHERE
zerver_stream.date_created < %%(time_end)s AND zerver_stream.date_created < %%(time_end)s AND
zerver_recipient.type = 2 AND zerver_recipient.type = 2 AND
zerver_message.date_sent >= %%(time_start)s AND zerver_message.pub_date >= %%(time_start)s AND
zerver_message.date_sent < %%(time_end)s zerver_message.pub_date < %%(time_end)s
GROUP BY zerver_stream.id %(group_by_clause)s GROUP BY zerver_stream.id %(group_by_clause)s
""" """
@@ -385,7 +386,7 @@ count_user_by_realm_query = """
# Currently hardcodes the query needed for active_users_audit:is_bot:day. # Currently hardcodes the query needed for active_users_audit:is_bot:day.
# Assumes that a user cannot have two RealmAuditLog entries with the same event_time and # Assumes that a user cannot have two RealmAuditLog entries with the same event_time and
# event_type in [RealmAuditLog.USER_CREATED, USER_DEACTIVATED, etc]. # event_type in ['user_created', 'user_deactivated', etc].
# In particular, it's important to ensure that migrations don't cause that to happen. # In particular, it's important to ensure that migrations don't cause that to happen.
check_realmauditlog_by_user_query = """ check_realmauditlog_by_user_query = """
INSERT INTO analytics_usercount INSERT INTO analytics_usercount
@@ -397,7 +398,7 @@ check_realmauditlog_by_user_query = """
SELECT modified_user_id, max(event_time) AS max_event_time SELECT modified_user_id, max(event_time) AS max_event_time
FROM zerver_realmauditlog FROM zerver_realmauditlog
WHERE WHERE
event_type in ({user_created}, {user_activated}, {user_deactivated}, {user_reactivated}) AND event_type in ('user_created', 'user_deactivated', 'user_activated', 'user_reactivated') AND
event_time < %%(time_end)s event_time < %%(time_end)s
GROUP BY modified_user_id GROUP BY modified_user_id
) ral2 ) ral2
@@ -408,11 +409,8 @@ check_realmauditlog_by_user_query = """
ON ON
ral1.modified_user_id = zerver_userprofile.id ral1.modified_user_id = zerver_userprofile.id
WHERE WHERE
ral1.event_type in ({user_created}, {user_activated}, {user_reactivated}) ral1.event_type in ('user_created', 'user_activated', 'user_reactivated')
""".format(user_created=RealmAuditLog.USER_CREATED, """
user_activated=RealmAuditLog.USER_ACTIVATED,
user_deactivated=RealmAuditLog.USER_DEACTIVATED,
user_reactivated=RealmAuditLog.USER_REACTIVATED)
check_useractivityinterval_by_user_query = """ check_useractivityinterval_by_user_query = """
INSERT INTO analytics_usercount INSERT INTO analytics_usercount
@@ -515,9 +513,6 @@ count_stats_ = [
# User Activity stats # User Activity stats
# Stats that measure user activity in the UserActivityInterval sense. # Stats that measure user activity in the UserActivityInterval sense.
CountStat('1day_actives::day',
sql_data_collector(UserCount, check_useractivityinterval_by_user_query, None),
CountStat.DAY, interval=timedelta(days=1)-UserActivityInterval.MIN_INTERVAL_LENGTH),
CountStat('15day_actives::day', CountStat('15day_actives::day',
sql_data_collector(UserCount, check_useractivityinterval_by_user_query, None), sql_data_collector(UserCount, check_useractivityinterval_by_user_query, None),
CountStat.DAY, interval=timedelta(days=15)-UserActivityInterval.MIN_INTERVAL_LENGTH), CountStat.DAY, interval=timedelta(days=15)-UserActivityInterval.MIN_INTERVAL_LENGTH),

View File

@@ -15,7 +15,7 @@ def compute_stats(log_level: int) -> None:
one_week_ago = timestamp_to_datetime(time.time()) - datetime.timedelta(weeks=1) one_week_ago = timestamp_to_datetime(time.time()) - datetime.timedelta(weeks=1)
mit_query = Message.objects.filter(sender__realm__string_id="zephyr", mit_query = Message.objects.filter(sender__realm__string_id="zephyr",
recipient__type=Recipient.STREAM, recipient__type=Recipient.STREAM,
date_sent__gt=one_week_ago) pub_date__gt=one_week_ago)
for bot_sender_start in ["imap.", "rcmd.", "sys."]: for bot_sender_start in ["imap.", "rcmd.", "sys."]:
mit_query = mit_query.exclude(sender__email__startswith=(bot_sender_start)) mit_query = mit_query.exclude(sender__email__startswith=(bot_sender_start))
# Filtering for "/" covers tabbott/extra@ and all the daemon/foo bots. # Filtering for "/" covers tabbott/extra@ and all the daemon/foo bots.

View File

@@ -1,9 +1,10 @@
from argparse import ArgumentParser
from datetime import timedelta from datetime import timedelta
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from analytics.models import installation_epoch, \ from analytics.models import InstallationCount, installation_epoch, \
last_successful_fill last_successful_fill
from analytics.lib.counts import COUNT_STATS, CountStat from analytics.lib.counts import COUNT_STATS, CountStat
from zerver.lib.timestamp import floor_to_hour, floor_to_day, verify_UTC, \ from zerver.lib.timestamp import floor_to_hour, floor_to_day, verify_UTC, \
@@ -11,6 +12,8 @@ from zerver.lib.timestamp import floor_to_hour, floor_to_day, verify_UTC, \
from zerver.models import Realm from zerver.models import Realm
import os import os
import subprocess
import sys
import time import time
from typing import Any, Dict from typing import Any, Dict
@@ -37,7 +40,7 @@ class Command(BaseCommand):
with open(state_file_tmp, "w") as f: with open(state_file_tmp, "w") as f:
f.write("%s|%s|%s|%s\n" % ( f.write("%s|%s|%s|%s\n" % (
int(time.time()), status, states[status], message)) int(time.time()), status, states[status], message))
os.rename(state_file_tmp, state_file_path) subprocess.check_call(["mv", state_file_tmp, state_file_path])
def get_fill_state(self) -> Dict[str, Any]: def get_fill_state(self) -> Dict[str, Any]:
if not Realm.objects.exists(): if not Realm.objects.exists():

View File

@@ -1,7 +1,8 @@
import sys
from argparse import ArgumentParser from argparse import ArgumentParser
from typing import Any from typing import Any
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand
from analytics.lib.counts import do_drop_all_analytics_tables from analytics.lib.counts import do_drop_all_analytics_tables
@@ -17,4 +18,5 @@ class Command(BaseCommand):
if options['force']: if options['force']:
do_drop_all_analytics_tables() do_drop_all_analytics_tables()
else: else:
raise CommandError("Would delete all data from analytics tables (!); use --force to do so.") print("Would delete all data from analytics tables (!); use --force to do so.")
sys.exit(1)

View File

@@ -1,7 +1,8 @@
import sys
from argparse import ArgumentParser from argparse import ArgumentParser
from typing import Any from typing import Any
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand
from analytics.lib.counts import COUNT_STATS, do_drop_single_stat from analytics.lib.counts import COUNT_STATS, do_drop_single_stat
@@ -19,8 +20,10 @@ class Command(BaseCommand):
def handle(self, *args: Any, **options: Any) -> None: def handle(self, *args: Any, **options: Any) -> None:
property = options['property'] property = options['property']
if property not in COUNT_STATS: if property not in COUNT_STATS:
raise CommandError("Invalid property: %s" % (property,)) print("Invalid property: %s" % (property,))
sys.exit(1)
if not options['force']: if not options['force']:
raise CommandError("No action taken. Use --force.") print("No action taken. Use --force.")
sys.exit(1)
do_drop_single_stat(property) do_drop_single_stat(property)

View File

@@ -1,6 +1,6 @@
import datetime import datetime
from argparse import ArgumentParser from argparse import ArgumentParser
from typing import Any, Optional from typing import Any
from django.db.models import Count, QuerySet from django.db.models import Count, QuerySet
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
@@ -56,7 +56,7 @@ Usage examples:
print("%25s %15d" % (count[1], count[0])) print("%25s %15d" % (count[1], count[0]))
print("Total:", total) print("Total:", total)
def handle(self, *args: Any, **options: Optional[str]) -> None: def handle(self, *args: Any, **options: str) -> None:
realm = self.get_realm(options) realm = self.get_realm(options)
if options["user"] is None: if options["user"] is None:
if options["target"] == "server" and realm is None: if options["target"] == "server" and realm is None:

View File

@@ -1,6 +1,6 @@
from datetime import timedelta
from typing import Any, Dict, List, Mapping, Optional, Type from datetime import datetime, timedelta
import mock from typing import Any, Dict, List, Mapping, Optional, Type, Union
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
@@ -9,13 +9,10 @@ from analytics.lib.counts import COUNT_STATS, \
CountStat, do_drop_all_analytics_tables CountStat, do_drop_all_analytics_tables
from analytics.lib.fixtures import generate_time_series_data from analytics.lib.fixtures import generate_time_series_data
from analytics.lib.time_utils import time_range from analytics.lib.time_utils import time_range
from analytics.models import BaseCount, FillState, RealmCount, UserCount, \ from analytics.models import BaseCount, FillState, RealmCount, UserCount, StreamCount
StreamCount, InstallationCount
from zerver.lib.actions import do_change_is_admin, STREAM_ASSIGNMENT_COLORS
from zerver.lib.create_user import create_user
from zerver.lib.timestamp import floor_to_day from zerver.lib.timestamp import floor_to_day
from zerver.models import Realm, Stream, Client, \ from zerver.models import Realm, UserProfile, Stream, Message, Client, \
Recipient, Subscription RealmAuditLog, Recipient
class Command(BaseCommand): class Command(BaseCommand):
help = """Populates analytics tables with randomly generated data.""" help = """Populates analytics tables with randomly generated data."""
@@ -23,6 +20,20 @@ class Command(BaseCommand):
DAYS_OF_DATA = 100 DAYS_OF_DATA = 100
random_seed = 26 random_seed = 26
def create_user(self, email: str,
full_name: str,
is_staff: bool,
date_joined: datetime,
realm: Realm) -> UserProfile:
user = UserProfile.objects.create(
email=email, full_name=full_name, is_staff=is_staff,
realm=realm, short_name=full_name, pointer=-1, last_pointer_updater='none',
api_key='42', date_joined=date_joined)
RealmAuditLog.objects.create(
realm=realm, modified_user=user, event_type='user_created',
event_time=user.date_joined)
return user
def generate_fixture_data(self, stat: CountStat, business_hours_base: float, def generate_fixture_data(self, stat: CountStat, business_hours_base: float,
non_business_hours_base: float, growth: float, non_business_hours_base: float, growth: float,
autocorrelation: float, spikiness: float, autocorrelation: float, spikiness: float,
@@ -35,51 +46,24 @@ class Command(BaseCommand):
frequency=stat.frequency, partial_sum=partial_sum, random_seed=self.random_seed) frequency=stat.frequency, partial_sum=partial_sum, random_seed=self.random_seed)
def handle(self, *args: Any, **options: Any) -> None: def handle(self, *args: Any, **options: Any) -> None:
# TODO: This should arguably only delete the objects
# associated with the "analytics" realm.
do_drop_all_analytics_tables() do_drop_all_analytics_tables()
# I believe this also deletes any objects with this realm as a foreign key
# This also deletes any objects with this realm as a foreign key
Realm.objects.filter(string_id='analytics').delete() Realm.objects.filter(string_id='analytics').delete()
# Because we just deleted a bunch of objects in the database
# directly (rather than deleting individual objects in Django,
# in which case our post_save hooks would have flushed the
# individual objects from memcached for us), we need to flush
# memcached in order to ensure deleted objects aren't still
# present in the memcached cache.
from zerver.apps import flush_cache
flush_cache(None)
installation_time = timezone_now() - timedelta(days=self.DAYS_OF_DATA) installation_time = timezone_now() - timedelta(days=self.DAYS_OF_DATA)
last_end_time = floor_to_day(timezone_now()) last_end_time = floor_to_day(timezone_now())
realm = Realm.objects.create( realm = Realm.objects.create(
string_id='analytics', name='Analytics', date_created=installation_time) string_id='analytics', name='Analytics', date_created=installation_time)
with mock.patch("zerver.lib.create_user.timezone_now", return_value=installation_time): shylock = self.create_user('shylock@analytics.ds', 'Shylock', True, installation_time, realm)
shylock = create_user('shylock@analytics.ds', 'Shylock', realm,
full_name='Shylock', short_name='shylock',
is_realm_admin=True)
do_change_is_admin(shylock, True)
stream = Stream.objects.create( stream = Stream.objects.create(
name='all', realm=realm, date_created=installation_time) name='all', realm=realm, date_created=installation_time)
recipient = Recipient.objects.create(type_id=stream.id, type=Recipient.STREAM) Recipient.objects.create(type_id=stream.id, type=Recipient.STREAM)
# Subscribe shylock to the stream to avoid invariant failures.
# TODO: This should use subscribe_users_to_streams from populate_db.
subs = [
Subscription(recipient=recipient,
user_profile=shylock,
color=STREAM_ASSIGNMENT_COLORS[0]),
]
Subscription.objects.bulk_create(subs)
def insert_fixture_data(stat: CountStat, def insert_fixture_data(stat: CountStat,
fixture_data: Mapping[Optional[str], List[int]], fixture_data: Mapping[Optional[str], List[int]],
table: Type[BaseCount]) -> None: table: Type[BaseCount]) -> None:
end_times = time_range(last_end_time, last_end_time, stat.frequency, end_times = time_range(last_end_time, last_end_time, stat.frequency,
len(list(fixture_data.values())[0])) len(list(fixture_data.values())[0]))
if table == InstallationCount:
id_args = {} # type: Dict[str, Any]
if table == RealmCount: if table == RealmCount:
id_args = {'realm': realm} id_args = {'realm': realm}
if table == UserCount: if table == UserCount:
@@ -93,39 +77,11 @@ class Command(BaseCommand):
value=value, **id_args) value=value, **id_args)
for end_time, value in zip(end_times, values) if value != 0]) for end_time, value in zip(end_times, values) if value != 0])
stat = COUNT_STATS['1day_actives::day']
realm_data = {
None: self.generate_fixture_data(stat, .08, .02, 3, .3, 6, partial_sum=True),
} # type: Mapping[Optional[str], List[int]]
insert_fixture_data(stat, realm_data, RealmCount)
installation_data = {
None: self.generate_fixture_data(stat, .8, .2, 4, .3, 6, partial_sum=True),
} # type: Mapping[Optional[str], List[int]]
insert_fixture_data(stat, installation_data, InstallationCount)
FillState.objects.create(property=stat.property, end_time=last_end_time,
state=FillState.DONE)
stat = COUNT_STATS['realm_active_humans::day'] stat = COUNT_STATS['realm_active_humans::day']
realm_data = { realm_data = {
None: self.generate_fixture_data(stat, .1, .03, 3, .5, 3, partial_sum=True), None: self.generate_fixture_data(stat, .1, .03, 3, .5, 3, partial_sum=True),
} } # type: Mapping[Optional[str], List[int]]
insert_fixture_data(stat, realm_data, RealmCount) insert_fixture_data(stat, realm_data, RealmCount)
installation_data = {
None: self.generate_fixture_data(stat, 1, .3, 4, .5, 3, partial_sum=True),
}
insert_fixture_data(stat, installation_data, InstallationCount)
FillState.objects.create(property=stat.property, end_time=last_end_time,
state=FillState.DONE)
stat = COUNT_STATS['active_users_audit:is_bot:day']
realm_data = {
'false': self.generate_fixture_data(stat, .1, .03, 3.5, .8, 2, partial_sum=True),
}
insert_fixture_data(stat, realm_data, RealmCount)
installation_data = {
'false': self.generate_fixture_data(stat, 1, .3, 6, .8, 2, partial_sum=True),
}
insert_fixture_data(stat, installation_data, InstallationCount)
FillState.objects.create(property=stat.property, end_time=last_end_time, FillState.objects.create(property=stat.property, end_time=last_end_time,
state=FillState.DONE) state=FillState.DONE)
@@ -136,9 +92,6 @@ class Command(BaseCommand):
realm_data = {'false': self.generate_fixture_data(stat, 35, 15, 6, .6, 4), realm_data = {'false': self.generate_fixture_data(stat, 35, 15, 6, .6, 4),
'true': self.generate_fixture_data(stat, 15, 15, 3, .4, 2)} 'true': self.generate_fixture_data(stat, 15, 15, 3, .4, 2)}
insert_fixture_data(stat, realm_data, RealmCount) insert_fixture_data(stat, realm_data, RealmCount)
installation_data = {'false': self.generate_fixture_data(stat, 350, 150, 6, .6, 4),
'true': self.generate_fixture_data(stat, 150, 150, 3, .4, 2)}
insert_fixture_data(stat, installation_data, InstallationCount)
FillState.objects.create(property=stat.property, end_time=last_end_time, FillState.objects.create(property=stat.property, end_time=last_end_time,
state=FillState.DONE) state=FillState.DONE)
@@ -154,12 +107,6 @@ class Command(BaseCommand):
'private_message': self.generate_fixture_data(stat, 13, 5, 5, .6, 4), 'private_message': self.generate_fixture_data(stat, 13, 5, 5, .6, 4),
'huddle_message': self.generate_fixture_data(stat, 6, 3, 3, .6, 4)} 'huddle_message': self.generate_fixture_data(stat, 6, 3, 3, .6, 4)}
insert_fixture_data(stat, realm_data, RealmCount) insert_fixture_data(stat, realm_data, RealmCount)
installation_data = {
'public_stream': self.generate_fixture_data(stat, 300, 80, 5, .6, 4),
'private_stream': self.generate_fixture_data(stat, 70, 70, 5, .6, 4),
'private_message': self.generate_fixture_data(stat, 130, 50, 5, .6, 4),
'huddle_message': self.generate_fixture_data(stat, 60, 30, 3, .6, 4)}
insert_fixture_data(stat, installation_data, InstallationCount)
FillState.objects.create(property=stat.property, end_time=last_end_time, FillState.objects.create(property=stat.property, end_time=last_end_time,
state=FillState.DONE) state=FillState.DONE)
@@ -189,17 +136,6 @@ class Command(BaseCommand):
unused.id: self.generate_fixture_data(stat, 0, 0, 0, 0, 0), unused.id: self.generate_fixture_data(stat, 0, 0, 0, 0, 0),
long_webhook.id: self.generate_fixture_data(stat, 5, 5, 2, .6, 3)} long_webhook.id: self.generate_fixture_data(stat, 5, 5, 2, .6, 3)}
insert_fixture_data(stat, realm_data, RealmCount) insert_fixture_data(stat, realm_data, RealmCount)
installation_data = {
website.id: self.generate_fixture_data(stat, 300, 200, 5, .6, 3),
old_desktop.id: self.generate_fixture_data(stat, 50, 30, 8, .6, 3),
android.id: self.generate_fixture_data(stat, 50, 50, 2, .6, 3),
iOS.id: self.generate_fixture_data(stat, 50, 50, 2, .6, 3),
react_native.id: self.generate_fixture_data(stat, 5, 5, 10, .6, 3),
API.id: self.generate_fixture_data(stat, 50, 50, 5, .6, 3),
zephyr_mirror.id: self.generate_fixture_data(stat, 10, 10, 3, .6, 3),
unused.id: self.generate_fixture_data(stat, 0, 0, 0, 0, 0),
long_webhook.id: self.generate_fixture_data(stat, 50, 50, 2, .6, 3)}
insert_fixture_data(stat, installation_data, InstallationCount)
FillState.objects.create(property=stat.property, end_time=last_end_time, FillState.objects.create(property=stat.property, end_time=last_end_time,
state=FillState.DONE) state=FillState.DONE)

View File

@@ -2,7 +2,8 @@ import datetime
from argparse import ArgumentParser from argparse import ArgumentParser
from typing import Any, List from typing import Any, List
from django.core.management.base import BaseCommand, CommandError import pytz
from django.core.management.base import BaseCommand
from django.db.models import Count from django.db.models import Count
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
@@ -33,32 +34,32 @@ class Command(BaseCommand):
def messages_sent_by(self, user: UserProfile, days_ago: int) -> int: def messages_sent_by(self, user: UserProfile, days_ago: int) -> int:
sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago) sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago)
return human_messages.filter(sender=user, date_sent__gt=sent_time_cutoff).count() return human_messages.filter(sender=user, pub_date__gt=sent_time_cutoff).count()
def total_messages(self, realm: Realm, days_ago: int) -> int: def total_messages(self, realm: Realm, days_ago: int) -> int:
sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago) sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago)
return Message.objects.filter(sender__realm=realm, date_sent__gt=sent_time_cutoff).count() return Message.objects.filter(sender__realm=realm, pub_date__gt=sent_time_cutoff).count()
def human_messages(self, realm: Realm, days_ago: int) -> int: def human_messages(self, realm: Realm, days_ago: int) -> int:
sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago) sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago)
return human_messages.filter(sender__realm=realm, date_sent__gt=sent_time_cutoff).count() return human_messages.filter(sender__realm=realm, pub_date__gt=sent_time_cutoff).count()
def api_messages(self, realm: Realm, days_ago: int) -> int: def api_messages(self, realm: Realm, days_ago: int) -> int:
return (self.total_messages(realm, days_ago) - self.human_messages(realm, days_ago)) return (self.total_messages(realm, days_ago) - self.human_messages(realm, days_ago))
def stream_messages(self, realm: Realm, days_ago: int) -> int: def stream_messages(self, realm: Realm, days_ago: int) -> int:
sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago) sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago)
return human_messages.filter(sender__realm=realm, date_sent__gt=sent_time_cutoff, return human_messages.filter(sender__realm=realm, pub_date__gt=sent_time_cutoff,
recipient__type=Recipient.STREAM).count() recipient__type=Recipient.STREAM).count()
def private_messages(self, realm: Realm, days_ago: int) -> int: def private_messages(self, realm: Realm, days_ago: int) -> int:
sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago) sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago)
return human_messages.filter(sender__realm=realm, date_sent__gt=sent_time_cutoff).exclude( return human_messages.filter(sender__realm=realm, pub_date__gt=sent_time_cutoff).exclude(
recipient__type=Recipient.STREAM).exclude(recipient__type=Recipient.HUDDLE).count() recipient__type=Recipient.STREAM).exclude(recipient__type=Recipient.HUDDLE).count()
def group_private_messages(self, realm: Realm, days_ago: int) -> int: def group_private_messages(self, realm: Realm, days_ago: int) -> int:
sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago) sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago)
return human_messages.filter(sender__realm=realm, date_sent__gt=sent_time_cutoff).exclude( return human_messages.filter(sender__realm=realm, pub_date__gt=sent_time_cutoff).exclude(
recipient__type=Recipient.STREAM).exclude(recipient__type=Recipient.PERSONAL).count() recipient__type=Recipient.STREAM).exclude(recipient__type=Recipient.PERSONAL).count()
def report_percentage(self, numerator: float, denominator: float, text: str) -> None: def report_percentage(self, numerator: float, denominator: float, text: str) -> None:
@@ -73,7 +74,8 @@ class Command(BaseCommand):
try: try:
realms = [get_realm(string_id) for string_id in options['realms']] realms = [get_realm(string_id) for string_id in options['realms']]
except Realm.DoesNotExist as e: except Realm.DoesNotExist as e:
raise CommandError(e) print(e)
exit(1)
else: else:
realms = Realm.objects.all() realms = Realm.objects.all()
@@ -130,7 +132,7 @@ class Command(BaseCommand):
user_profile__in=user_profiles, active=True) user_profile__in=user_profiles, active=True)
# Streams not in home view # Streams not in home view
non_home_view = active_user_subs.filter(is_muted=True).values( non_home_view = active_user_subs.filter(in_home_view=False).values(
"user_profile").annotate(count=Count("user_profile")) "user_profile").annotate(count=Count("user_profile"))
print("%d users have %d streams not in home view" % ( print("%d users have %d streams not in home view" % (
len(non_home_view), sum([elt["count"] for elt in non_home_view]))) len(non_home_view), sum([elt["count"] for elt in non_home_view])))
@@ -143,7 +145,7 @@ class Command(BaseCommand):
len(markup_messages), sum([elt["count"] for elt in markup_messages]))) len(markup_messages), sum([elt["count"] for elt in markup_messages])))
# Notifications for stream messages # Notifications for stream messages
notifications = active_user_subs.filter(desktop_notifications=True).values( notifications = active_user_subs.filter(notifications=True).values(
"user_profile").annotate(count=Count("user_profile")) "user_profile").annotate(count=Count("user_profile"))
print("%d users receive desktop notifications for %d streams" % ( print("%d users receive desktop notifications for %d streams" % (
len(notifications), sum([elt["count"] for elt in notifications]))) len(notifications), sum([elt["count"] for elt in notifications])))

View File

@@ -1,7 +1,7 @@
from argparse import ArgumentParser from argparse import ArgumentParser
from typing import Any from typing import Any
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand
from django.db.models import Q from django.db.models import Q
from zerver.models import Message, Realm, \ from zerver.models import Message, Realm, \
@@ -19,38 +19,26 @@ class Command(BaseCommand):
try: try:
realms = [get_realm(string_id) for string_id in options['realms']] realms = [get_realm(string_id) for string_id in options['realms']]
except Realm.DoesNotExist as e: except Realm.DoesNotExist as e:
raise CommandError(e) print(e)
exit(1)
else: else:
realms = Realm.objects.all() realms = Realm.objects.all()
for realm in realms: for realm in realms:
print(realm.string_id)
print("------------")
print("%25s %15s %10s" % ("stream", "subscribers", "messages"))
streams = Stream.objects.filter(realm=realm).exclude(Q(name__istartswith="tutorial-")) streams = Stream.objects.filter(realm=realm).exclude(Q(name__istartswith="tutorial-"))
# private stream count invite_only_count = 0
private_count = 0
# public stream count
public_count = 0
for stream in streams: for stream in streams:
if stream.invite_only: if stream.invite_only:
private_count += 1 invite_only_count += 1
else: continue
public_count += 1
print("------------")
print(realm.string_id, end=' ')
print("%10s %d public streams and" % ("(", public_count), end=' ')
print("%d private streams )" % (private_count,))
print("------------")
print("%25s %15s %10s %12s" % ("stream", "subscribers", "messages", "type"))
for stream in streams:
if stream.invite_only:
stream_type = 'private'
else:
stream_type = 'public'
print("%25s" % (stream.name,), end=' ') print("%25s" % (stream.name,), end=' ')
recipient = Recipient.objects.filter(type=Recipient.STREAM, type_id=stream.id) recipient = Recipient.objects.filter(type=Recipient.STREAM, type_id=stream.id)
print("%10d" % (len(Subscription.objects.filter(recipient=recipient, print("%10d" % (len(Subscription.objects.filter(recipient=recipient,
active=True)),), end=' ') active=True)),), end=' ')
num_messages = len(Message.objects.filter(recipient=recipient)) num_messages = len(Message.objects.filter(recipient=recipient))
print("%12d" % (num_messages,), end=' ') print("%12d" % (num_messages,))
print("%15s" % (stream_type,)) print("%d invite-only streams" % (invite_only_count,))
print("") print("")

View File

@@ -11,7 +11,6 @@ from django.utils.timezone import utc as timezone_utc
from analytics.lib.counts import COUNT_STATS, logger, process_count_stat from analytics.lib.counts import COUNT_STATS, logger, process_count_stat
from scripts.lib.zulip_tools import ENDC, WARNING from scripts.lib.zulip_tools import ENDC, WARNING
from zerver.lib.remote_server import send_analytics_to_remote_server
from zerver.lib.timestamp import floor_to_hour from zerver.lib.timestamp import floor_to_hour
from zerver.models import Realm from zerver.models import Realm
@@ -85,6 +84,3 @@ class Command(BaseCommand):
print("Finished updating analytics counts through %s in %.3fs" % print("Finished updating analytics counts through %s in %.3fs" %
(fill_to_time, time.time() - start)) (fill_to_time, time.time() - start))
logger.info("Finished updating analytics counts through %s" % (fill_to_time,)) logger.info("Finished updating analytics counts through %s" % (fill_to_time,))
if settings.PUSH_NOTIFICATION_BOUNCER_URL and settings.SUBMIT_USAGE_STATISTICS:
send_analytics_to_remote_server()

View File

@@ -2,7 +2,7 @@ import datetime
from argparse import ArgumentParser from argparse import ArgumentParser
from typing import Any from typing import Any
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from zerver.models import Message, Realm, Stream, UserProfile, get_realm from zerver.models import Message, Realm, Stream, UserProfile, get_realm
@@ -17,14 +17,15 @@ class Command(BaseCommand):
def messages_sent_by(self, user: UserProfile, week: int) -> int: def messages_sent_by(self, user: UserProfile, week: int) -> int:
start = timezone_now() - datetime.timedelta(days=(week + 1)*7) start = timezone_now() - datetime.timedelta(days=(week + 1)*7)
end = timezone_now() - datetime.timedelta(days=week*7) end = timezone_now() - datetime.timedelta(days=week*7)
return Message.objects.filter(sender=user, date_sent__gt=start, date_sent__lte=end).count() return Message.objects.filter(sender=user, pub_date__gt=start, pub_date__lte=end).count()
def handle(self, *args: Any, **options: Any) -> None: def handle(self, *args: Any, **options: Any) -> None:
if options['realms']: if options['realms']:
try: try:
realms = [get_realm(string_id) for string_id in options['realms']] realms = [get_realm(string_id) for string_id in options['realms']]
except Realm.DoesNotExist as e: except Realm.DoesNotExist as e:
raise CommandError(e) print(e)
exit(1)
else: else:
realms = Realm.objects.all() realms = Realm.objects.all()
@@ -37,5 +38,5 @@ class Command(BaseCommand):
for user_profile in user_profiles: for user_profile in user_profiles:
print("%35s" % (user_profile.email,), end=' ') print("%35s" % (user_profile.email,), end=' ')
for week in range(10): for week in range(10):
print("%5d" % (self.messages_sent_by(user_profile, week),), end=' ') print("%5d" % (self.messages_sent_by(user_profile, week)), end=' ')
print("") print("")

View File

@@ -3,6 +3,8 @@ import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import zerver.lib.str_utils
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@@ -17,7 +19,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('info', models.CharField(max_length=1000)), ('info', models.CharField(max_length=1000)),
], ],
bases=(models.Model,), bases=(zerver.lib.str_utils.ModelReprMixin, models.Model),
), ),
migrations.CreateModel( migrations.CreateModel(
name='HuddleCount', name='HuddleCount',
@@ -31,7 +33,7 @@ class Migration(migrations.Migration):
('value', models.BigIntegerField()), ('value', models.BigIntegerField()),
('anomaly', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='analytics.Anomaly', null=True)), ('anomaly', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='analytics.Anomaly', null=True)),
], ],
bases=(models.Model,), bases=(zerver.lib.str_utils.ModelReprMixin, models.Model),
), ),
migrations.CreateModel( migrations.CreateModel(
name='InstallationCount', name='InstallationCount',
@@ -43,7 +45,7 @@ class Migration(migrations.Migration):
('value', models.BigIntegerField()), ('value', models.BigIntegerField()),
('anomaly', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='analytics.Anomaly', null=True)), ('anomaly', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='analytics.Anomaly', null=True)),
], ],
bases=(models.Model,), bases=(zerver.lib.str_utils.ModelReprMixin, models.Model),
), ),
migrations.CreateModel( migrations.CreateModel(
name='RealmCount', name='RealmCount',
@@ -57,7 +59,7 @@ class Migration(migrations.Migration):
('anomaly', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='analytics.Anomaly', null=True)), ('anomaly', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='analytics.Anomaly', null=True)),
], ],
bases=(models.Model,), bases=(zerver.lib.str_utils.ModelReprMixin, models.Model),
), ),
migrations.CreateModel( migrations.CreateModel(
name='StreamCount', name='StreamCount',
@@ -71,7 +73,7 @@ class Migration(migrations.Migration):
('value', models.BigIntegerField()), ('value', models.BigIntegerField()),
('anomaly', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='analytics.Anomaly', null=True)), ('anomaly', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='analytics.Anomaly', null=True)),
], ],
bases=(models.Model,), bases=(zerver.lib.str_utils.ModelReprMixin, models.Model),
), ),
migrations.CreateModel( migrations.CreateModel(
name='UserCount', name='UserCount',
@@ -85,7 +87,7 @@ class Migration(migrations.Migration):
('value', models.BigIntegerField()), ('value', models.BigIntegerField()),
('anomaly', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='analytics.Anomaly', null=True)), ('anomaly', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='analytics.Anomaly', null=True)),
], ],
bases=(models.Model,), bases=(zerver.lib.str_utils.ModelReprMixin, models.Model),
), ),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
name='usercount', name='usercount',

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from django.db import migrations from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@@ -1,6 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from django.db import migrations, models from django.db import migrations, models
import zerver.lib.str_utils
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@@ -17,6 +19,6 @@ class Migration(migrations.Migration):
('state', models.PositiveSmallIntegerField()), ('state', models.PositiveSmallIntegerField()),
('last_modified', models.DateTimeField(auto_now=True)), ('last_modified', models.DateTimeField(auto_now=True)),
], ],
bases=(models.Model,), bases=(zerver.lib.str_utils.ModelReprMixin, models.Model),
), ),
] ]

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from django.db import migrations from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-16 20:50 # Generated by Django 1.10.4 on 2017-01-16 20:50
from django.conf import settings
from django.db import migrations from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@@ -1,34 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.18 on 2019-02-02 02:47
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('analytics', '0012_add_on_delete'),
]
operations = [
migrations.RemoveField(
model_name='installationcount',
name='anomaly',
),
migrations.RemoveField(
model_name='realmcount',
name='anomaly',
),
migrations.RemoveField(
model_name='streamcount',
name='anomaly',
),
migrations.RemoveField(
model_name='usercount',
name='anomaly',
),
migrations.DeleteModel(
name='Anomaly',
),
]

View File

@@ -1,10 +1,10 @@
import datetime import datetime
from typing import Optional from typing import Any, Dict, Optional, Tuple, Union
from django.db import models from django.db import models
from zerver.lib.timestamp import floor_to_day from zerver.lib.timestamp import floor_to_day
from zerver.models import Realm, Stream, UserProfile from zerver.models import Realm, Recipient, Stream, UserProfile
class FillState(models.Model): class FillState(models.Model):
property = models.CharField(max_length=40, unique=True) # type: str property = models.CharField(max_length=40, unique=True) # type: str
@@ -34,6 +34,13 @@ def last_successful_fill(property: str) -> Optional[datetime.datetime]:
return fillstate.end_time return fillstate.end_time
return fillstate.end_time - datetime.timedelta(hours=1) return fillstate.end_time - datetime.timedelta(hours=1)
# would only ever make entries here by hand
class Anomaly(models.Model):
info = models.CharField(max_length=1000) # type: str
def __str__(self) -> str:
return "<Anomaly: %s... %s>" % (self.info, self.id)
class BaseCount(models.Model): class BaseCount(models.Model):
# Note: When inheriting from BaseCount, you may want to rearrange # Note: When inheriting from BaseCount, you may want to rearrange
# the order of the columns in the migration to make sure they # the order of the columns in the migration to make sure they
@@ -42,6 +49,7 @@ class BaseCount(models.Model):
subgroup = models.CharField(max_length=16, null=True) # type: Optional[str] subgroup = models.CharField(max_length=16, null=True) # type: Optional[str]
end_time = models.DateTimeField() # type: datetime.datetime end_time = models.DateTimeField() # type: datetime.datetime
value = models.BigIntegerField() # type: int value = models.BigIntegerField() # type: int
anomaly = models.ForeignKey(Anomaly, on_delete=models.SET_NULL, null=True) # type: Optional[Anomaly]
class Meta: class Meta:
abstract = True abstract = True

View File

@@ -1,7 +1,7 @@
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple, Type
import mock from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple, Type, Union
import ujson import ujson
from django.apps import apps from django.apps import apps
from django.db import models from django.db import models
@@ -10,21 +10,19 @@ from django.test import TestCase
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from django.utils.timezone import utc as timezone_utc from django.utils.timezone import utc as timezone_utc
from analytics.lib.counts import COUNT_STATS, CountStat, \ from analytics.lib.counts import COUNT_STATS, CountStat, DataCollector, \
DependentCountStat, LoggingCountStat, do_aggregate_to_summary_table, \ DependentCountStat, LoggingCountStat, do_aggregate_to_summary_table, \
do_drop_all_analytics_tables, do_drop_single_stat, \ do_drop_all_analytics_tables, do_drop_single_stat, \
do_fill_count_stat_at_hour, do_increment_logging_stat, \ do_fill_count_stat_at_hour, do_increment_logging_stat, \
process_count_stat, sql_data_collector process_count_stat, sql_data_collector
from analytics.models import BaseCount, \ from analytics.models import Anomaly, BaseCount, \
FillState, InstallationCount, RealmCount, StreamCount, \ FillState, InstallationCount, RealmCount, StreamCount, \
UserCount, installation_epoch UserCount, installation_epoch, last_successful_fill
from zerver.lib.actions import do_activate_user, do_create_user, \ from zerver.lib.actions import do_activate_user, do_create_user, \
do_deactivate_user, do_reactivate_user, update_user_activity_interval, \ do_deactivate_user, do_reactivate_user, update_user_activity_interval, \
do_invite_users, do_revoke_user_invite, do_resend_user_invite_email, \ do_invite_users, do_revoke_user_invite, do_resend_user_invite_email, \
InvitationError InvitationError
from zerver.lib.create_user import create_user
from zerver.lib.timestamp import TimezoneNotUTCException, floor_to_day from zerver.lib.timestamp import TimezoneNotUTCException, floor_to_day
from zerver.lib.topic import DB_TOPIC_NAME
from zerver.models import Client, Huddle, Message, Realm, \ from zerver.models import Client, Huddle, Message, Realm, \
RealmAuditLog, Recipient, Stream, UserActivityInterval, \ RealmAuditLog, Recipient, Stream, UserActivityInterval, \
UserProfile, get_client, get_user, PreregistrationUser UserProfile, get_client, get_user, PreregistrationUser
@@ -37,7 +35,6 @@ class AnalyticsTestCase(TestCase):
TIME_LAST_HOUR = TIME_ZERO - HOUR TIME_LAST_HOUR = TIME_ZERO - HOUR
def setUp(self) -> None: def setUp(self) -> None:
super().setUp()
self.default_realm = Realm.objects.create( self.default_realm = Realm.objects.create(
string_id='realmtest', name='Realm Test', date_created=self.TIME_ZERO - 2*self.DAY) string_id='realmtest', name='Realm Test', date_created=self.TIME_ZERO - 2*self.DAY)
# used to generate unique names in self.create_* # used to generate unique names in self.create_*
@@ -53,21 +50,13 @@ class AnalyticsTestCase(TestCase):
'date_joined': self.TIME_LAST_HOUR, 'date_joined': self.TIME_LAST_HOUR,
'full_name': 'full_name', 'full_name': 'full_name',
'short_name': 'short_name', 'short_name': 'short_name',
'is_active': True, 'pointer': -1,
'is_bot': False, 'last_pointer_updater': 'seems unused?',
'realm': self.default_realm} 'realm': self.default_realm,
'api_key': '42'}
for key, value in defaults.items(): for key, value in defaults.items():
kwargs[key] = kwargs.get(key, value) kwargs[key] = kwargs.get(key, value)
kwargs['delivery_email'] = kwargs['email'] return UserProfile.objects.create(**kwargs)
with mock.patch("zerver.lib.create_user.timezone_now", return_value=kwargs['date_joined']):
pass_kwargs = {} # type: Dict[str, Any]
if kwargs['is_bot']:
pass_kwargs['bot_type'] = UserProfile.DEFAULT_BOT
pass_kwargs['bot_owner'] = None
return create_user(kwargs['email'], 'password', kwargs['realm'],
active=kwargs['is_active'],
full_name=kwargs['full_name'], short_name=kwargs['short_name'],
is_realm_admin=True, **pass_kwargs)
def create_stream_with_recipient(self, **kwargs: Any) -> Tuple[Stream, Recipient]: def create_stream_with_recipient(self, **kwargs: Any) -> Tuple[Stream, Recipient]:
self.name_counter += 1 self.name_counter += 1
@@ -93,9 +82,9 @@ class AnalyticsTestCase(TestCase):
defaults = { defaults = {
'sender': sender, 'sender': sender,
'recipient': recipient, 'recipient': recipient,
DB_TOPIC_NAME: 'subject', 'subject': 'subject',
'content': 'hi', 'content': 'hi',
'date_sent': self.TIME_LAST_HOUR, 'pub_date': self.TIME_LAST_HOUR,
'sending_client': get_client("website")} 'sending_client': get_client("website")}
for key, value in defaults.items(): for key, value in defaults.items():
kwargs[key] = kwargs.get(key, value) kwargs[key] = kwargs.get(key, value)
@@ -325,7 +314,7 @@ class TestCountStats(AnalyticsTestCase):
recipient = self.create_stream_with_recipient( recipient = self.create_stream_with_recipient(
name='stream %s' % (minutes_ago,), realm=self.second_realm, name='stream %s' % (minutes_ago,), realm=self.second_realm,
date_created=creation_time)[1] date_created=creation_time)[1]
self.create_message(user, recipient, date_sent=creation_time) self.create_message(user, recipient, pub_date=creation_time)
self.hourly_user = get_user('user-1@second.analytics', self.second_realm) self.hourly_user = get_user('user-1@second.analytics', self.second_realm)
self.daily_user = get_user('user-61@second.analytics', self.second_realm) self.daily_user = get_user('user-61@second.analytics', self.second_realm)
@@ -370,8 +359,8 @@ class TestCountStats(AnalyticsTestCase):
bot = self.create_user(is_bot=True) bot = self.create_user(is_bot=True)
human1 = self.create_user() human1 = self.create_user()
human2 = self.create_user() human2 = self.create_user()
recipient_human1 = Recipient.objects.get(type_id=human1.id, recipient_human1 = Recipient.objects.create(type_id=human1.id,
type=Recipient.PERSONAL) type=Recipient.PERSONAL)
recipient_stream = self.create_stream_with_recipient()[1] recipient_stream = self.create_stream_with_recipient()[1]
recipient_huddle = self.create_huddle_with_recipient()[1] recipient_huddle = self.create_huddle_with_recipient()[1]
@@ -425,9 +414,9 @@ class TestCountStats(AnalyticsTestCase):
self.create_message(user2, recipient_huddle2) self.create_message(user2, recipient_huddle2)
# private messages # private messages
recipient_user1 = Recipient.objects.get(type_id=user1.id, type=Recipient.PERSONAL) recipient_user1 = Recipient.objects.create(type_id=user1.id, type=Recipient.PERSONAL)
recipient_user2 = Recipient.objects.get(type_id=user2.id, type=Recipient.PERSONAL) recipient_user2 = Recipient.objects.create(type_id=user2.id, type=Recipient.PERSONAL)
recipient_user3 = Recipient.objects.get(type_id=user3.id, type=Recipient.PERSONAL) recipient_user3 = Recipient.objects.create(type_id=user3.id, type=Recipient.PERSONAL)
self.create_message(user1, recipient_user2) self.create_message(user1, recipient_user2)
self.create_message(user2, recipient_user1) self.create_message(user2, recipient_user1)
self.create_message(user3, recipient_user3) self.create_message(user3, recipient_user3)
@@ -459,7 +448,7 @@ class TestCountStats(AnalyticsTestCase):
self.current_property = stat.property self.current_property = stat.property
user = self.create_user(id=1000) user = self.create_user(id=1000)
user_recipient = Recipient.objects.get(type_id=user.id, type=Recipient.PERSONAL) user_recipient = Recipient.objects.create(type_id=user.id, type=Recipient.PERSONAL)
stream_recipient = self.create_stream_with_recipient(id=1000)[1] stream_recipient = self.create_stream_with_recipient(id=1000)[1]
huddle_recipient = self.create_huddle_with_recipient(id=1000)[1] huddle_recipient = self.create_huddle_with_recipient(id=1000)[1]
@@ -479,7 +468,7 @@ class TestCountStats(AnalyticsTestCase):
user1 = self.create_user(is_bot=True) user1 = self.create_user(is_bot=True)
user2 = self.create_user() user2 = self.create_user()
recipient_user2 = Recipient.objects.get(type_id=user2.id, type=Recipient.PERSONAL) recipient_user2 = Recipient.objects.create(type_id=user2.id, type=Recipient.PERSONAL)
recipient_stream = self.create_stream_with_recipient()[1] recipient_stream = self.create_stream_with_recipient()[1]
recipient_huddle = self.create_huddle_with_recipient()[1] recipient_huddle = self.create_huddle_with_recipient()[1]
@@ -515,7 +504,7 @@ class TestCountStats(AnalyticsTestCase):
bot = self.create_user(is_bot=True) bot = self.create_user(is_bot=True)
human1 = self.create_user() human1 = self.create_user()
human2 = self.create_user() human2 = self.create_user()
recipient_human1 = Recipient.objects.get(type_id=human1.id, type=Recipient.PERSONAL) recipient_human1 = Recipient.objects.create(type_id=human1.id, type=Recipient.PERSONAL)
stream1, recipient_stream1 = self.create_stream_with_recipient() stream1, recipient_stream1 = self.create_stream_with_recipient()
stream2, recipient_stream2 = self.create_stream_with_recipient() stream2, recipient_stream2 = self.create_stream_with_recipient()
@@ -551,49 +540,6 @@ class TestCountStats(AnalyticsTestCase):
user_profile=user, start=self.TIME_ZERO-start_offset, user_profile=user, start=self.TIME_ZERO-start_offset,
end=self.TIME_ZERO-end_offset) end=self.TIME_ZERO-end_offset)
def test_1day_actives(self) -> None:
stat = COUNT_STATS['1day_actives::day']
self.current_property = stat.property
_1day = 1*self.DAY - UserActivityInterval.MIN_INTERVAL_LENGTH
# Outside time range, should not appear. Also tests upper boundary.
user1 = self.create_user()
self.create_interval(user1, _1day + self.DAY, _1day + timedelta(seconds=1))
self.create_interval(user1, timedelta(0), -self.HOUR)
# On lower boundary, should appear
user2 = self.create_user()
self.create_interval(user2, _1day + self.DAY, _1day)
# Multiple intervals, including one outside boundary
user3 = self.create_user()
self.create_interval(user3, 2*self.DAY, 1*self.DAY)
self.create_interval(user3, 20*self.HOUR, 19*self.HOUR)
self.create_interval(user3, 20*self.MINUTE, 19*self.MINUTE)
# Intervals crossing boundary
user4 = self.create_user()
self.create_interval(user4, 1.5*self.DAY, 0.5*self.DAY)
user5 = self.create_user()
self.create_interval(user5, self.MINUTE, -self.MINUTE)
# Interval subsuming time range
user6 = self.create_user()
self.create_interval(user6, 2*self.DAY, -2*self.DAY)
# Second realm
user7 = self.create_user(realm=self.second_realm)
self.create_interval(user7, 20*self.MINUTE, 19*self.MINUTE)
do_fill_count_stat_at_hour(stat, self.TIME_ZERO)
self.assertTableState(UserCount, ['value', 'user'],
[[1, user2], [1, user3], [1, user4], [1, user5], [1, user6], [1, user7]])
self.assertTableState(RealmCount, ['value', 'realm'],
[[5, self.default_realm], [1, self.second_realm]])
self.assertTableState(InstallationCount, ['value'], [[6]])
self.assertTableState(StreamCount, [], [])
def test_15day_actives(self) -> None: def test_15day_actives(self) -> None:
stat = COUNT_STATS['15day_actives::day'] stat = COUNT_STATS['15day_actives::day']
self.current_property = stat.property self.current_property = stat.property
@@ -853,6 +799,7 @@ class TestDeleteStats(AnalyticsTestCase):
RealmCount.objects.create(realm=user.realm, **count_args) RealmCount.objects.create(realm=user.realm, **count_args)
InstallationCount.objects.create(**count_args) InstallationCount.objects.create(**count_args)
FillState.objects.create(property='test', end_time=self.TIME_ZERO, state=FillState.DONE) FillState.objects.create(property='test', end_time=self.TIME_ZERO, state=FillState.DONE)
Anomaly.objects.create(info='test anomaly')
analytics = apps.get_app_config('analytics') analytics = apps.get_app_config('analytics')
for table in list(analytics.models.values()): for table in list(analytics.models.values()):
@@ -875,6 +822,7 @@ class TestDeleteStats(AnalyticsTestCase):
InstallationCount.objects.create(**count_args) InstallationCount.objects.create(**count_args)
FillState.objects.create(property='to_delete', end_time=self.TIME_ZERO, state=FillState.DONE) FillState.objects.create(property='to_delete', end_time=self.TIME_ZERO, state=FillState.DONE)
FillState.objects.create(property='to_save', end_time=self.TIME_ZERO, state=FillState.DONE) FillState.objects.create(property='to_save', end_time=self.TIME_ZERO, state=FillState.DONE)
Anomaly.objects.create(info='test anomaly')
analytics = apps.get_app_config('analytics') analytics = apps.get_app_config('analytics')
for table in list(analytics.models.values()): for table in list(analytics.models.values()):
@@ -882,8 +830,11 @@ class TestDeleteStats(AnalyticsTestCase):
do_drop_single_stat('to_delete') do_drop_single_stat('to_delete')
for table in list(analytics.models.values()): for table in list(analytics.models.values()):
self.assertFalse(table.objects.filter(property='to_delete').exists()) if table._meta.db_table == 'analytics_anomaly':
self.assertTrue(table.objects.filter(property='to_save').exists()) self.assertTrue(table.objects.exists())
else:
self.assertFalse(table.objects.filter(property='to_delete').exists())
self.assertTrue(table.objects.filter(property='to_save').exists())
class TestActiveUsersAudit(AnalyticsTestCase): class TestActiveUsersAudit(AnalyticsTestCase):
def setUp(self) -> None: def setUp(self) -> None:
@@ -892,7 +843,7 @@ class TestActiveUsersAudit(AnalyticsTestCase):
self.stat = COUNT_STATS['active_users_audit:is_bot:day'] self.stat = COUNT_STATS['active_users_audit:is_bot:day']
self.current_property = self.stat.property self.current_property = self.stat.property
def add_event(self, event_type: int, days_offset: float, def add_event(self, event_type: str, days_offset: float,
user: Optional[UserProfile]=None) -> None: user: Optional[UserProfile]=None) -> None:
hours_offset = int(24*days_offset) hours_offset = int(24*days_offset)
if user is None: if user is None:
@@ -902,49 +853,49 @@ class TestActiveUsersAudit(AnalyticsTestCase):
event_time=self.TIME_ZERO - hours_offset*self.HOUR) event_time=self.TIME_ZERO - hours_offset*self.HOUR)
def test_user_deactivated_in_future(self) -> None: def test_user_deactivated_in_future(self) -> None:
self.add_event(RealmAuditLog.USER_CREATED, 1) self.add_event('user_created', 1)
self.add_event(RealmAuditLog.USER_DEACTIVATED, 0) self.add_event('user_deactivated', 0)
do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO)
self.assertTableState(UserCount, ['subgroup'], [['false']]) self.assertTableState(UserCount, ['subgroup'], [['false']])
def test_user_reactivated_in_future(self) -> None: def test_user_reactivated_in_future(self) -> None:
self.add_event(RealmAuditLog.USER_DEACTIVATED, 1) self.add_event('user_deactivated', 1)
self.add_event(RealmAuditLog.USER_REACTIVATED, 0) self.add_event('user_reactivated', 0)
do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO)
self.assertTableState(UserCount, [], []) self.assertTableState(UserCount, [], [])
def test_user_active_then_deactivated_same_day(self) -> None: def test_user_active_then_deactivated_same_day(self) -> None:
self.add_event(RealmAuditLog.USER_CREATED, 1) self.add_event('user_created', 1)
self.add_event(RealmAuditLog.USER_DEACTIVATED, .5) self.add_event('user_deactivated', .5)
do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO)
self.assertTableState(UserCount, [], []) self.assertTableState(UserCount, [], [])
def test_user_unactive_then_activated_same_day(self) -> None: def test_user_unactive_then_activated_same_day(self) -> None:
self.add_event(RealmAuditLog.USER_DEACTIVATED, 1) self.add_event('user_deactivated', 1)
self.add_event(RealmAuditLog.USER_REACTIVATED, .5) self.add_event('user_reactivated', .5)
do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO)
self.assertTableState(UserCount, ['subgroup'], [['false']]) self.assertTableState(UserCount, ['subgroup'], [['false']])
# Arguably these next two tests are duplicates of the _in_future tests, but are # Arguably these next two tests are duplicates of the _in_future tests, but are
# a guard against future refactorings where they may no longer be duplicates # a guard against future refactorings where they may no longer be duplicates
def test_user_active_then_deactivated_with_day_gap(self) -> None: def test_user_active_then_deactivated_with_day_gap(self) -> None:
self.add_event(RealmAuditLog.USER_CREATED, 2) self.add_event('user_created', 2)
self.add_event(RealmAuditLog.USER_DEACTIVATED, 1) self.add_event('user_deactivated', 1)
process_count_stat(self.stat, self.TIME_ZERO) process_count_stat(self.stat, self.TIME_ZERO)
self.assertTableState(UserCount, ['subgroup', 'end_time'], self.assertTableState(UserCount, ['subgroup', 'end_time'],
[['false', self.TIME_ZERO - self.DAY]]) [['false', self.TIME_ZERO - self.DAY]])
def test_user_deactivated_then_reactivated_with_day_gap(self) -> None: def test_user_deactivated_then_reactivated_with_day_gap(self) -> None:
self.add_event(RealmAuditLog.USER_DEACTIVATED, 2) self.add_event('user_deactivated', 2)
self.add_event(RealmAuditLog.USER_REACTIVATED, 1) self.add_event('user_reactivated', 1)
process_count_stat(self.stat, self.TIME_ZERO) process_count_stat(self.stat, self.TIME_ZERO)
self.assertTableState(UserCount, ['subgroup'], [['false']]) self.assertTableState(UserCount, ['subgroup'], [['false']])
def test_event_types(self) -> None: def test_event_types(self) -> None:
self.add_event(RealmAuditLog.USER_CREATED, 4) self.add_event('user_created', 4)
self.add_event(RealmAuditLog.USER_DEACTIVATED, 3) self.add_event('user_deactivated', 3)
self.add_event(RealmAuditLog.USER_ACTIVATED, 2) self.add_event('user_activated', 2)
self.add_event(RealmAuditLog.USER_REACTIVATED, 1) self.add_event('user_reactivated', 1)
for i in range(4): for i in range(4):
do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO - i*self.DAY) do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO - i*self.DAY)
self.assertTableState(UserCount, ['subgroup', 'end_time'], self.assertTableState(UserCount, ['subgroup', 'end_time'],
@@ -959,7 +910,7 @@ class TestActiveUsersAudit(AnalyticsTestCase):
user3 = self.create_user(realm=second_realm) user3 = self.create_user(realm=second_realm)
user4 = self.create_user(realm=second_realm, is_bot=True) user4 = self.create_user(realm=second_realm, is_bot=True)
for user in [user1, user2, user3, user4]: for user in [user1, user2, user3, user4]:
self.add_event(RealmAuditLog.USER_CREATED, 1, user=user) self.add_event('user_created', 1, user=user)
do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO)
self.assertTableState(UserCount, ['subgroup', 'user'], self.assertTableState(UserCount, ['subgroup', 'user'],
[['false', user1], ['false', user2], ['false', user3], ['true', user4]]) [['false', user1], ['false', user2], ['false', user3], ['true', user4]])
@@ -977,7 +928,7 @@ class TestActiveUsersAudit(AnalyticsTestCase):
# CountStat.HOUR from CountStat.DAY, this will fail, while many of the # CountStat.HOUR from CountStat.DAY, this will fail, while many of the
# tests above will not. # tests above will not.
def test_update_from_two_days_ago(self) -> None: def test_update_from_two_days_ago(self) -> None:
self.add_event(RealmAuditLog.USER_CREATED, 2) self.add_event('user_created', 2)
process_count_stat(self.stat, self.TIME_ZERO) process_count_stat(self.stat, self.TIME_ZERO)
self.assertTableState(UserCount, ['subgroup', 'end_time'], self.assertTableState(UserCount, ['subgroup', 'end_time'],
[['false', self.TIME_ZERO], ['false', self.TIME_ZERO-self.DAY]]) [['false', self.TIME_ZERO], ['false', self.TIME_ZERO-self.DAY]])
@@ -986,22 +937,22 @@ class TestActiveUsersAudit(AnalyticsTestCase):
# doesn't go through do_create_user. Mainly just want to make sure that # doesn't go through do_create_user. Mainly just want to make sure that
# that situation doesn't throw an error. # that situation doesn't throw an error.
def test_empty_realm_or_user_with_no_relevant_activity(self) -> None: def test_empty_realm_or_user_with_no_relevant_activity(self) -> None:
self.add_event(RealmAuditLog.USER_SOFT_ACTIVATED, 1) self.add_event('unrelated', 1)
self.create_user() # also test a user with no RealmAuditLog entries self.create_user() # also test a user with no RealmAuditLog entries
Realm.objects.create(string_id='moo', name='moo') Realm.objects.create(string_id='moo', name='moo')
do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO)
self.assertTableState(UserCount, [], []) self.assertTableState(UserCount, [], [])
def test_max_audit_entry_is_unrelated(self) -> None: def test_max_audit_entry_is_unrelated(self) -> None:
self.add_event(RealmAuditLog.USER_CREATED, 1) self.add_event('user_created', 1)
self.add_event(RealmAuditLog.USER_SOFT_ACTIVATED, .5) self.add_event('unrelated', .5)
do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO)
self.assertTableState(UserCount, ['subgroup'], [['false']]) self.assertTableState(UserCount, ['subgroup'], [['false']])
# Simultaneous related audit entries should not be allowed, and so not testing for that. # Simultaneous related audit entries should not be allowed, and so not testing for that.
def test_simultaneous_unrelated_audit_entry(self) -> None: def test_simultaneous_unrelated_audit_entry(self) -> None:
self.add_event(RealmAuditLog.USER_CREATED, 1) self.add_event('user_created', 1)
self.add_event(RealmAuditLog.USER_SOFT_ACTIVATED, 1) self.add_event('unrelated', 1)
do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO)
self.assertTableState(UserCount, ['subgroup'], [['false']]) self.assertTableState(UserCount, ['subgroup'], [['false']])
@@ -1009,10 +960,10 @@ class TestActiveUsersAudit(AnalyticsTestCase):
user1 = self.create_user() user1 = self.create_user()
user2 = self.create_user() user2 = self.create_user()
user3 = self.create_user() user3 = self.create_user()
self.add_event(RealmAuditLog.USER_CREATED, .5, user=user1) self.add_event('user_created', .5, user=user1)
self.add_event(RealmAuditLog.USER_CREATED, .5, user=user2) self.add_event('user_created', .5, user=user2)
self.add_event(RealmAuditLog.USER_CREATED, 1, user=user3) self.add_event('user_created', 1, user=user3)
self.add_event(RealmAuditLog.USER_DEACTIVATED, .5, user=user3) self.add_event('user_deactivated', .5, user=user3)
do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO)
self.assertTableState(UserCount, ['user', 'subgroup'], self.assertTableState(UserCount, ['user', 'subgroup'],
[[user1, 'false'], [user2, 'false']]) [[user1, 'false'], [user2, 'false']])
@@ -1031,7 +982,7 @@ class TestActiveUsersAudit(AnalyticsTestCase):
self.assertTrue(UserCount.objects.filter( self.assertTrue(UserCount.objects.filter(
user=user, property=self.current_property, subgroup='false', user=user, property=self.current_property, subgroup='false',
end_time=end_time, value=1).exists()) end_time=end_time, value=1).exists())
self.assertFalse(UserCount.objects.filter(user=user2, end_time=end_time).exists()) self.assertFalse(UserCount.objects.filter(user=user2).exists())
class TestRealmActiveHumans(AnalyticsTestCase): class TestRealmActiveHumans(AnalyticsTestCase):
def setUp(self) -> None: def setUp(self) -> None:

View File

@@ -1,23 +1,19 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import List, Optional from typing import Dict, List, Optional
import mock import mock
from django.utils.timezone import utc from django.utils.timezone import utc
from django.http import HttpResponse
import ujson
from analytics.lib.counts import COUNT_STATS, CountStat from analytics.lib.counts import COUNT_STATS, CountStat
from analytics.lib.time_utils import time_range from analytics.lib.time_utils import time_range
from analytics.models import FillState, \ from analytics.models import FillState, \
RealmCount, UserCount, last_successful_fill RealmCount, UserCount, last_successful_fill
from analytics.views import rewrite_client_arrays, \ from analytics.views import get_chart_data, rewrite_client_arrays, \
sort_by_totals, sort_client_labels sort_by_totals, sort_client_labels, stats
from zerver.lib.test_classes import ZulipTestCase from zerver.lib.test_classes import ZulipTestCase
from zerver.lib.timestamp import ceiling_to_day, \ from zerver.lib.timestamp import ceiling_to_day, \
ceiling_to_hour, datetime_to_timestamp ceiling_to_hour, datetime_to_timestamp
from zerver.lib.actions import do_create_multiuse_invite_link, \ from zerver.models import Client, get_realm
do_send_realm_reactivation_email
from zerver.models import Client, get_realm, MultiuseInvite
class TestStatsEndpoint(ZulipTestCase): class TestStatsEndpoint(ZulipTestCase):
def test_stats(self) -> None: def test_stats(self) -> None:
@@ -28,15 +24,6 @@ class TestStatsEndpoint(ZulipTestCase):
# Check that we get something back # Check that we get something back
self.assert_in_response("Zulip analytics for", result) self.assert_in_response("Zulip analytics for", result)
def test_guest_user_cant_access_stats(self) -> None:
self.user = self.example_user('polonius')
self.login(self.user.email)
result = self.client_get('/stats')
self.assert_json_error(result, "Not allowed for guest users", 400)
result = self.client_get('/json/analytics/chart_data')
self.assert_json_error(result, "Not allowed for guest users", 400)
def test_stats_for_realm(self) -> None: def test_stats_for_realm(self) -> None:
user_profile = self.example_user('hamlet') user_profile = self.example_user('hamlet')
self.login(user_profile.email) self.login(user_profile.email)
@@ -55,24 +42,8 @@ class TestStatsEndpoint(ZulipTestCase):
self.assertEqual(result.status_code, 200) self.assertEqual(result.status_code, 200)
self.assert_in_response("Zulip analytics for", result) self.assert_in_response("Zulip analytics for", result)
def test_stats_for_installation(self) -> None:
user_profile = self.example_user('hamlet')
self.login(user_profile.email)
result = self.client_get('/stats/installation')
self.assertEqual(result.status_code, 302)
user_profile = self.example_user('hamlet')
user_profile.is_staff = True
user_profile.save(update_fields=['is_staff'])
result = self.client_get('/stats/installation')
self.assertEqual(result.status_code, 200)
self.assert_in_response("Zulip analytics for", result)
class TestGetChartData(ZulipTestCase): class TestGetChartData(ZulipTestCase):
def setUp(self) -> None: def setUp(self) -> None:
super().setUp()
self.realm = get_realm('zulip') self.realm = get_realm('zulip')
self.user = self.example_user('hamlet') self.user = self.example_user('hamlet')
self.login(self.user.email) self.login(self.user.email)
@@ -106,10 +77,6 @@ class TestGetChartData(ZulipTestCase):
def test_number_of_humans(self) -> None: def test_number_of_humans(self) -> None:
stat = COUNT_STATS['realm_active_humans::day'] stat = COUNT_STATS['realm_active_humans::day']
self.insert_data(stat, [None], []) self.insert_data(stat, [None], [])
stat = COUNT_STATS['1day_actives::day']
self.insert_data(stat, [None], [])
stat = COUNT_STATS['active_users_audit:is_bot:day']
self.insert_data(stat, ['false'], [])
result = self.client_get('/json/analytics/chart_data', result = self.client_get('/json/analytics/chart_data',
{'chart_name': 'number_of_humans'}) {'chart_name': 'number_of_humans'})
self.assert_json_success(result) self.assert_json_success(result)
@@ -118,7 +85,7 @@ class TestGetChartData(ZulipTestCase):
'msg': '', 'msg': '',
'end_times': [datetime_to_timestamp(dt) for dt in self.end_times_day], 'end_times': [datetime_to_timestamp(dt) for dt in self.end_times_day],
'frequency': CountStat.DAY, 'frequency': CountStat.DAY,
'everyone': {'_1day': self.data(100), '_15day': self.data(100), 'all_time': self.data(100)}, 'realm': {'human': self.data(100)},
'display_order': None, 'display_order': None,
'result': 'success', 'result': 'success',
}) })
@@ -134,7 +101,7 @@ class TestGetChartData(ZulipTestCase):
'msg': '', 'msg': '',
'end_times': [datetime_to_timestamp(dt) for dt in self.end_times_hour], 'end_times': [datetime_to_timestamp(dt) for dt in self.end_times_hour],
'frequency': CountStat.HOUR, 'frequency': CountStat.HOUR,
'everyone': {'bot': self.data(100), 'human': self.data(101)}, 'realm': {'bot': self.data(100), 'human': self.data(101)},
'user': {'bot': self.data(0), 'human': self.data(200)}, 'user': {'bot': self.data(0), 'human': self.data(200)},
'display_order': None, 'display_order': None,
'result': 'success', 'result': 'success',
@@ -152,8 +119,8 @@ class TestGetChartData(ZulipTestCase):
'msg': '', 'msg': '',
'end_times': [datetime_to_timestamp(dt) for dt in self.end_times_day], 'end_times': [datetime_to_timestamp(dt) for dt in self.end_times_day],
'frequency': CountStat.DAY, 'frequency': CountStat.DAY,
'everyone': {'Public streams': self.data(100), 'Private streams': self.data(0), 'realm': {'Public streams': self.data(100), 'Private streams': self.data(0),
'Private messages': self.data(101), 'Group private messages': self.data(0)}, 'Private messages': self.data(101), 'Group private messages': self.data(0)},
'user': {'Public streams': self.data(200), 'Private streams': self.data(201), 'user': {'Public streams': self.data(200), 'Private streams': self.data(201),
'Private messages': self.data(0), 'Group private messages': self.data(0)}, 'Private messages': self.data(0), 'Group private messages': self.data(0)},
'display_order': ['Private messages', 'Public streams', 'Private streams', 'Group private messages'], 'display_order': ['Private messages', 'Public streams', 'Private streams', 'Group private messages'],
@@ -176,8 +143,8 @@ class TestGetChartData(ZulipTestCase):
'msg': '', 'msg': '',
'end_times': [datetime_to_timestamp(dt) for dt in self.end_times_day], 'end_times': [datetime_to_timestamp(dt) for dt in self.end_times_day],
'frequency': CountStat.DAY, 'frequency': CountStat.DAY,
'everyone': {'client 4': self.data(100), 'client 3': self.data(101), 'realm': {'client 4': self.data(100), 'client 3': self.data(101),
'client 2': self.data(102)}, 'client 2': self.data(102)},
'user': {'client 3': self.data(200), 'client 1': self.data(201)}, 'user': {'client 3': self.data(200), 'client 1': self.data(201)},
'display_order': ['client 1', 'client 2', 'client 3', 'client 4'], 'display_order': ['client 1', 'client 2', 'client 3', 'client 4'],
'result': 'success', 'result': 'success',
@@ -191,7 +158,7 @@ class TestGetChartData(ZulipTestCase):
{'chart_name': 'number_of_humans'}) {'chart_name': 'number_of_humans'})
self.assert_json_success(result) self.assert_json_success(result)
data = result.json() data = result.json()
self.assertEqual(data['everyone'], {"_1day": [0], "_15day": [0], "all_time": [0]}) self.assertEqual(data['realm'], {'human': [0]})
self.assertFalse('user' in data) self.assertFalse('user' in data)
FillState.objects.create( FillState.objects.create(
@@ -201,7 +168,7 @@ class TestGetChartData(ZulipTestCase):
{'chart_name': 'messages_sent_over_time'}) {'chart_name': 'messages_sent_over_time'})
self.assert_json_success(result) self.assert_json_success(result)
data = result.json() data = result.json()
self.assertEqual(data['everyone'], {'human': [0], 'bot': [0]}) self.assertEqual(data['realm'], {'human': [0], 'bot': [0]})
self.assertEqual(data['user'], {'human': [0], 'bot': [0]}) self.assertEqual(data['user'], {'human': [0], 'bot': [0]})
FillState.objects.create( FillState.objects.create(
@@ -211,7 +178,7 @@ class TestGetChartData(ZulipTestCase):
{'chart_name': 'messages_sent_by_message_type'}) {'chart_name': 'messages_sent_by_message_type'})
self.assert_json_success(result) self.assert_json_success(result)
data = result.json() data = result.json()
self.assertEqual(data['everyone'], { self.assertEqual(data['realm'], {
'Public streams': [0], 'Private streams': [0], 'Public streams': [0], 'Private streams': [0],
'Private messages': [0], 'Group private messages': [0]}) 'Private messages': [0], 'Group private messages': [0]})
self.assertEqual(data['user'], { self.assertEqual(data['user'], {
@@ -225,16 +192,12 @@ class TestGetChartData(ZulipTestCase):
{'chart_name': 'messages_sent_by_client'}) {'chart_name': 'messages_sent_by_client'})
self.assert_json_success(result) self.assert_json_success(result)
data = result.json() data = result.json()
self.assertEqual(data['everyone'], {}) self.assertEqual(data['realm'], {})
self.assertEqual(data['user'], {}) self.assertEqual(data['user'], {})
def test_start_and_end(self) -> None: def test_start_and_end(self) -> None:
stat = COUNT_STATS['realm_active_humans::day'] stat = COUNT_STATS['realm_active_humans::day']
self.insert_data(stat, [None], []) self.insert_data(stat, [None], [])
stat = COUNT_STATS['1day_actives::day']
self.insert_data(stat, [None], [])
stat = COUNT_STATS['active_users_audit:is_bot:day']
self.insert_data(stat, ['false'], [])
end_time_timestamps = [datetime_to_timestamp(dt) for dt in self.end_times_day] end_time_timestamps = [datetime_to_timestamp(dt) for dt in self.end_times_day]
# valid start and end # valid start and end
@@ -245,7 +208,7 @@ class TestGetChartData(ZulipTestCase):
self.assert_json_success(result) self.assert_json_success(result)
data = result.json() data = result.json()
self.assertEqual(data['end_times'], end_time_timestamps[1:3]) self.assertEqual(data['end_times'], end_time_timestamps[1:3])
self.assertEqual(data['everyone'], {'_1day': [0, 100], '_15day': [0, 100], 'all_time': [0, 100]}) self.assertEqual(data['realm'], {'human': [0, 100]})
# start later then end # start later then end
result = self.client_get('/json/analytics/chart_data', result = self.client_get('/json/analytics/chart_data',
@@ -257,10 +220,6 @@ class TestGetChartData(ZulipTestCase):
def test_min_length(self) -> None: def test_min_length(self) -> None:
stat = COUNT_STATS['realm_active_humans::day'] stat = COUNT_STATS['realm_active_humans::day']
self.insert_data(stat, [None], []) self.insert_data(stat, [None], [])
stat = COUNT_STATS['1day_actives::day']
self.insert_data(stat, [None], [])
stat = COUNT_STATS['active_users_audit:is_bot:day']
self.insert_data(stat, ['false'], [])
# test min_length is too short to change anything # test min_length is too short to change anything
result = self.client_get('/json/analytics/chart_data', result = self.client_get('/json/analytics/chart_data',
{'chart_name': 'number_of_humans', {'chart_name': 'number_of_humans',
@@ -268,7 +227,7 @@ class TestGetChartData(ZulipTestCase):
self.assert_json_success(result) self.assert_json_success(result)
data = result.json() data = result.json()
self.assertEqual(data['end_times'], [datetime_to_timestamp(dt) for dt in self.end_times_day]) self.assertEqual(data['end_times'], [datetime_to_timestamp(dt) for dt in self.end_times_day])
self.assertEqual(data['everyone'], {'_1day': self.data(100), '_15day': self.data(100), 'all_time': self.data(100)}) self.assertEqual(data['realm'], {'human': self.data(100)})
# test min_length larger than filled data # test min_length larger than filled data
result = self.client_get('/json/analytics/chart_data', result = self.client_get('/json/analytics/chart_data',
{'chart_name': 'number_of_humans', {'chart_name': 'number_of_humans',
@@ -277,7 +236,7 @@ class TestGetChartData(ZulipTestCase):
data = result.json() data = result.json()
end_times = [ceiling_to_day(self.realm.date_created) + timedelta(days=i) for i in range(-1, 4)] end_times = [ceiling_to_day(self.realm.date_created) + timedelta(days=i) for i in range(-1, 4)]
self.assertEqual(data['end_times'], [datetime_to_timestamp(dt) for dt in end_times]) self.assertEqual(data['end_times'], [datetime_to_timestamp(dt) for dt in end_times])
self.assertEqual(data['everyone'], {'_1day': [0]+self.data(100), '_15day': [0]+self.data(100), 'all_time': [0]+self.data(100)}) self.assertEqual(data['realm'], {'human': [0]+self.data(100)})
def test_non_existent_chart(self) -> None: def test_non_existent_chart(self) -> None:
result = self.client_get('/json/analytics/chart_data', result = self.client_get('/json/analytics/chart_data',
@@ -314,233 +273,6 @@ class TestGetChartData(ZulipTestCase):
{'chart_name': 'number_of_humans'}) {'chart_name': 'number_of_humans'})
self.assert_json_success(result) self.assert_json_success(result)
def test_get_chart_data_for_installation(self) -> None:
user_profile = self.example_user('hamlet')
self.login(user_profile.email)
result = self.client_get('/json/analytics/chart_data/installation',
{'chart_name': 'number_of_humans'})
self.assert_json_error(result, "Must be an server administrator", 400)
user_profile = self.example_user('hamlet')
user_profile.is_staff = True
user_profile.save(update_fields=['is_staff'])
stat = COUNT_STATS['realm_active_humans::day']
self.insert_data(stat, [None], [])
result = self.client_get('/json/analytics/chart_data/installation',
{'chart_name': 'number_of_humans'})
self.assert_json_success(result)
class TestSupportEndpoint(ZulipTestCase):
def test_search(self) -> None:
def check_hamlet_user_query_result(result: HttpResponse) -> None:
self.assert_in_success_response(['<span class="label">user</span>\n', '<h3>King Hamlet</h3>',
'<b>Email</b>: hamlet@zulip.com', '<b>Is active</b>: True<br>',
'<b>Admins</b>: iago@zulip.com\n',
'class="copy-button" data-copytext="iago@zulip.com"'
], result)
def check_zulip_realm_query_result(result: HttpResponse) -> None:
zulip_realm = get_realm("zulip")
self.assert_in_success_response(['<input type="hidden" name="realm_id" value="%s"' % (zulip_realm.id,),
'Zulip Dev</h3>',
'<option value="1" selected>Self Hosted</option>',
'<option value="2" >Limited</option>',
'input type="number" name="discount" value="None"',
'<option value="active" selected>Active</option>',
'<option value="deactivated" >Deactivated</option>',
'scrub-realm-button">',
'data-string-id="zulip"'], result)
def check_lear_realm_query_result(result: HttpResponse) -> None:
lear_realm = get_realm("lear")
self.assert_in_success_response(['<input type="hidden" name="realm_id" value="%s"' % (lear_realm.id,),
'Lear &amp; Co.</h3>',
'<option value="1" selected>Self Hosted</option>',
'<option value="2" >Limited</option>',
'input type="number" name="discount" value="None"',
'<option value="active" selected>Active</option>',
'<option value="deactivated" >Deactivated</option>',
'scrub-realm-button">',
'data-string-id="lear"'], result)
def check_preregistration_user_query_result(result: HttpResponse, email: str, invite: Optional[bool]=False) -> None:
self.assert_in_success_response(['<span class="label">preregistration user</span>\n',
'<b>Email</b>: {}'.format(email),
], result)
if invite:
self.assert_in_success_response(['<span class="label">invite</span>'], result)
self.assert_in_success_response(['<b>Expires in</b>: 1\xa0week, 3',
'<b>Status</b>: Link has never been clicked'], result)
self.assert_in_success_response([], result)
else:
self.assert_not_in_success_response(['<span class="label">invite</span>'], result)
self.assert_in_success_response(['<b>Expires in</b>: 1\xa0day',
'<b>Status</b>: Link has never been clicked'], result)
def check_realm_creation_query_result(result: HttpResponse, email: str) -> None:
self.assert_in_success_response(['<span class="label">preregistration user</span>\n',
'<span class="label">realm creation</span>\n',
'<b>Link</b>: http://zulip.testserver/accounts/do_confirm/',
'<b>Expires in</b>: 1\xa0day<br>\n'
], result)
def check_multiuse_invite_link_query_result(result: HttpResponse) -> None:
self.assert_in_success_response(['<span class="label">multiuse invite</span>\n',
'<b>Link</b>: http://zulip.testserver/join/',
'<b>Expires in</b>: 1\xa0week, 3'
], result)
def check_realm_reactivation_link_query_result(result: HttpResponse) -> None:
self.assert_in_success_response(['<span class="label">realm reactivation</span>\n',
'<b>Link</b>: http://zulip.testserver/reactivate/',
'<b>Expires in</b>: 1\xa0day'
], result)
cordelia_email = self.example_email("cordelia")
self.login(cordelia_email)
result = self.client_get("/activity/support")
self.assertEqual(result.status_code, 302)
self.assertEqual(result["Location"], "/login/")
iago_email = self.example_email("iago")
self.login(iago_email)
result = self.client_get("/activity/support")
self.assert_in_success_response(['<input type="text" name="q" class="input-xxlarge search-query"'], result)
result = self.client_get("/activity/support", {"q": "hamlet@zulip.com"})
check_hamlet_user_query_result(result)
check_zulip_realm_query_result(result)
result = self.client_get("/activity/support", {"q": "lear"})
check_lear_realm_query_result(result)
result = self.client_get("/activity/support", {"q": "http://lear.testserver"})
check_lear_realm_query_result(result)
with self.settings(REALM_HOSTS={'zulip': 'localhost'}):
result = self.client_get("/activity/support", {"q": "http://localhost"})
check_zulip_realm_query_result(result)
result = self.client_get("/activity/support", {"q": "hamlet@zulip.com, lear"})
check_hamlet_user_query_result(result)
check_zulip_realm_query_result(result)
check_lear_realm_query_result(result)
result = self.client_get("/activity/support", {"q": "lear, Hamlet <hamlet@zulip.com>"})
check_hamlet_user_query_result(result)
check_zulip_realm_query_result(result)
check_lear_realm_query_result(result)
self.client_post('/accounts/home/', {'email': self.nonreg_email("test")})
self.login(iago_email)
result = self.client_get("/activity/support", {"q": self.nonreg_email("test")})
check_preregistration_user_query_result(result, self.nonreg_email("test"))
check_zulip_realm_query_result(result)
stream_ids = [self.get_stream_id("Denmark")]
invitee_emails = [self.nonreg_email("test1")]
self.client_post("/json/invites", {"invitee_emails": invitee_emails,
"stream_ids": ujson.dumps(stream_ids), "invite_as": 1})
result = self.client_get("/activity/support", {"q": self.nonreg_email("test1")})
check_preregistration_user_query_result(result, self.nonreg_email("test1"), invite=True)
check_zulip_realm_query_result(result)
email = self.nonreg_email('alice')
self.client_post('/new/', {'email': email})
result = self.client_get("/activity/support", {"q": email})
check_realm_creation_query_result(result, email)
do_create_multiuse_invite_link(self.example_user("hamlet"), invited_as=1)
result = self.client_get("/activity/support", {"q": "zulip"})
check_multiuse_invite_link_query_result(result)
check_zulip_realm_query_result(result)
MultiuseInvite.objects.all().delete()
do_send_realm_reactivation_email(get_realm("zulip"))
result = self.client_get("/activity/support", {"q": "zulip"})
check_realm_reactivation_link_query_result(result)
check_zulip_realm_query_result(result)
def test_change_plan_type(self) -> None:
cordelia = self.example_user("cordelia")
self.login(cordelia.email)
result = self.client_post("/activity/support", {"realm_id": "%s" % (cordelia.realm_id,), "plan_type": "2"})
self.assertEqual(result.status_code, 302)
self.assertEqual(result["Location"], "/login/")
iago = self.example_user("iago")
self.login(iago.email)
with mock.patch("analytics.views.do_change_plan_type") as m:
result = self.client_post("/activity/support", {"realm_id": "%s" % (iago.realm_id,), "plan_type": "2"})
m.assert_called_once_with(get_realm("zulip"), 2)
self.assert_in_success_response(["Plan type of Zulip Dev changed from self hosted to limited"], result)
def test_attach_discount(self) -> None:
lear_realm = get_realm("lear")
cordelia_email = self.example_email("cordelia")
self.login(cordelia_email)
result = self.client_post("/activity/support", {"realm_id": "%s" % (lear_realm.id,), "discount": "25"})
self.assertEqual(result.status_code, 302)
self.assertEqual(result["Location"], "/login/")
iago_email = self.example_email("iago")
self.login(iago_email)
with mock.patch("analytics.views.attach_discount_to_realm") as m:
result = self.client_post("/activity/support", {"realm_id": "%s" % (lear_realm.id,), "discount": "25"})
m.assert_called_once_with(get_realm("lear"), 25)
self.assert_in_success_response(["Discount of Lear &amp; Co. changed to 25 from None"], result)
def test_activate_or_deactivate_realm(self) -> None:
lear_realm = get_realm("lear")
cordelia_email = self.example_email("cordelia")
self.login(cordelia_email)
result = self.client_post("/activity/support", {"realm_id": "%s" % (lear_realm.id,), "status": "deactivated"})
self.assertEqual(result.status_code, 302)
self.assertEqual(result["Location"], "/login/")
iago_email = self.example_email("iago")
self.login(iago_email)
with mock.patch("analytics.views.do_deactivate_realm") as m:
result = self.client_post("/activity/support", {"realm_id": "%s" % (lear_realm.id,), "status": "deactivated"})
m.assert_called_once_with(lear_realm, self.example_user("iago"))
self.assert_in_success_response(["Lear &amp; Co. deactivated"], result)
with mock.patch("analytics.views.do_reactivate_realm") as m:
result = self.client_post("/activity/support", {"realm_id": "%s" % (lear_realm.id,), "status": "active"})
m.assert_called_once_with(lear_realm)
self.assert_in_success_response(["Lear &amp; Co. reactivated."], result)
def test_scrub_realm(self) -> None:
lear_realm = get_realm("lear")
cordelia_email = self.example_email("cordelia")
self.login(cordelia_email)
result = self.client_post("/activity/support", {"realm_id": "%s" % (lear_realm.id,), "discount": "25"})
self.assertEqual(result.status_code, 302)
self.assertEqual(result["Location"], "/login/")
iago_email = self.example_email("iago")
self.login(iago_email)
with mock.patch("analytics.views.do_scrub_realm") as m:
result = self.client_post("/activity/support", {"realm_id": "%s" % (lear_realm.id,), "scrub_realm": "scrub_realm"})
m.assert_called_once_with(lear_realm)
self.assert_in_success_response(["Lear &amp; Co. scrubbed"], result)
with mock.patch("analytics.views.do_scrub_realm") as m:
result = self.client_post("/activity/support", {"realm_id": "%s" % (lear_realm.id,)})
m.assert_not_called()
class TestGetChartDataHelpers(ZulipTestCase): class TestGetChartDataHelpers(ZulipTestCase):
# last_successful_fill is in analytics/models.py, but get_chart_data is # last_successful_fill is in analytics/models.py, but get_chart_data is
# the only function that uses it at the moment # the only function that uses it at the moment
@@ -561,7 +293,7 @@ class TestGetChartDataHelpers(ZulipTestCase):
self.assertEqual(sort_by_totals(value_arrays), ['a', 'b', 'c', 'd']) self.assertEqual(sort_by_totals(value_arrays), ['a', 'b', 'c', 'd'])
def test_sort_client_labels(self) -> None: def test_sort_client_labels(self) -> None:
data = {'everyone': {'a': [16], 'c': [15], 'b': [14], 'e': [13], 'd': [12], 'h': [11]}, data = {'realm': {'a': [16], 'c': [15], 'b': [14], 'e': [13], 'd': [12], 'h': [11]},
'user': {'a': [6], 'b': [5], 'd': [4], 'e': [3], 'f': [2], 'g': [1]}} 'user': {'a': [6], 'b': [5], 'd': [4], 'e': [3], 'f': [2], 'g': [1]}}
self.assertEqual(sort_client_labels(data), ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) self.assertEqual(sort_client_labels(data), ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'])

View File

@@ -7,23 +7,12 @@ i18n_urlpatterns = [
# Server admin (user_profile.is_staff) visible stats pages # Server admin (user_profile.is_staff) visible stats pages
url(r'^activity$', analytics.views.get_activity, url(r'^activity$', analytics.views.get_activity,
name='analytics.views.get_activity'), name='analytics.views.get_activity'),
url(r'^activity/support$', analytics.views.support,
name='analytics.views.support'),
url(r'^realm_activity/(?P<realm_str>[\S]+)/$', analytics.views.get_realm_activity, url(r'^realm_activity/(?P<realm_str>[\S]+)/$', analytics.views.get_realm_activity,
name='analytics.views.get_realm_activity'), name='analytics.views.get_realm_activity'),
url(r'^user_activity/(?P<email>[\S]+)/$', analytics.views.get_user_activity, url(r'^user_activity/(?P<email>[\S]+)/$', analytics.views.get_user_activity,
name='analytics.views.get_user_activity'), name='analytics.views.get_user_activity'),
url(r'^stats/realm/(?P<realm_str>[\S]+)/$', analytics.views.stats_for_realm, url(r'^stats/realm/(?P<realm_str>[\S]+)/$', analytics.views.stats_for_realm,
name='analytics.views.stats_for_realm'), name='analytics.views.stats_for_realm'),
url(r'^stats/installation$', analytics.views.stats_for_installation,
name='analytics.views.stats_for_installation'),
url(r'^stats/remote/(?P<remote_server_id>[\S]+)/installation$',
analytics.views.stats_for_remote_installation,
name='analytics.views.stats_for_remote_installation'),
url(r'^stats/remote/(?P<remote_server_id>[\S]+)/realm/(?P<remote_realm_id>[\S]+)/$',
analytics.views.stats_for_remote_realm,
name='analytics.views.stats_for_remote_realm'),
# User-visible stats page # User-visible stats page
url(r'^stats$', analytics.views.stats, url(r'^stats$', analytics.views.stats,
@@ -44,13 +33,6 @@ v1_api_and_json_patterns = [
{'GET': 'analytics.views.get_chart_data'}), {'GET': 'analytics.views.get_chart_data'}),
url(r'^analytics/chart_data/realm/(?P<realm_str>[\S]+)$', rest_dispatch, url(r'^analytics/chart_data/realm/(?P<realm_str>[\S]+)$', rest_dispatch,
{'GET': 'analytics.views.get_chart_data_for_realm'}), {'GET': 'analytics.views.get_chart_data_for_realm'}),
url(r'^analytics/chart_data/installation$', rest_dispatch,
{'GET': 'analytics.views.get_chart_data_for_installation'}),
url(r'^analytics/chart_data/remote/(?P<remote_server_id>[\S]+)/installation$', rest_dispatch,
{'GET': 'analytics.views.get_chart_data_for_remote_installation'}),
url(r'^analytics/chart_data/remote/(?P<remote_server_id>[\S]+)/realm/(?P<remote_realm_id>[\S]+)$',
rest_dispatch,
{'GET': 'analytics.views.get_chart_data_for_remote_realm'}),
] ]
i18n_urlpatterns += [ i18n_urlpatterns += [

View File

@@ -1,12 +1,11 @@
import itertools import itertools
import json
import logging import logging
import re import re
import time import time
import urllib
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
from decimal import Decimal
from typing import Any, Callable, Dict, List, \ from typing import Any, Callable, Dict, List, \
Optional, Set, Tuple, Type, Union Optional, Set, Tuple, Type, Union
@@ -14,194 +13,98 @@ import pytz
from django.conf import settings from django.conf import settings
from django.urls import reverse from django.urls import reverse
from django.db import connection from django.db import connection
from django.db.models import Sum
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.http import HttpRequest, HttpResponse, HttpResponseNotFound from django.http import HttpRequest, HttpResponse, HttpResponseNotFound
from django.shortcuts import render from django.shortcuts import render
from django.template import loader from django.template import RequestContext, loader
from django.utils.timezone import now as timezone_now, utc as timezone_utc from django.utils.timezone import now as timezone_now, utc as timezone_utc
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.utils.timesince import timesince
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
from jinja2 import Markup as mark_safe from jinja2 import Markup as mark_safe
from analytics.lib.counts import COUNT_STATS, CountStat from analytics.lib.counts import COUNT_STATS, CountStat, process_count_stat
from analytics.lib.time_utils import time_range from analytics.lib.time_utils import time_range
from analytics.models import BaseCount, InstallationCount, \ from analytics.models import BaseCount, InstallationCount, \
RealmCount, StreamCount, UserCount, last_successful_fill, installation_epoch RealmCount, StreamCount, UserCount, last_successful_fill
from confirmation.models import Confirmation, confirmation_url, _properties
from zerver.decorator import require_server_admin, require_server_admin_api, \ from zerver.decorator import require_server_admin, require_server_admin_api, \
to_non_negative_int, to_utc_datetime, zulip_login_required, require_non_guest_user to_non_negative_int, to_utc_datetime, zulip_login_required
from zerver.lib.exceptions import JsonableError from zerver.lib.exceptions import JsonableError
from zerver.lib.json_encoder_for_html import JSONEncoderForHTML
from zerver.lib.request import REQ, has_request_variables from zerver.lib.request import REQ, has_request_variables
from zerver.lib.response import json_success from zerver.lib.response import json_success
from zerver.lib.timestamp import convert_to_UTC, timestamp_to_datetime from zerver.lib.timestamp import ceiling_to_day, \
from zerver.lib.realm_icon import realm_icon_url ceiling_to_hour, convert_to_UTC, timestamp_to_datetime
from zerver.views.invite import get_invitee_emails_set from zerver.models import Client, get_realm, Realm, \
from zerver.lib.subdomains import get_subdomain_from_hostname UserActivity, UserActivityInterval, UserProfile
from zerver.lib.actions import do_change_plan_type, do_deactivate_realm, \
do_reactivate_realm, do_scrub_realm
from confirmation.settings import STATUS_ACTIVE
if settings.BILLING_ENABLED: def render_stats(request: HttpRequest, realm: Realm) -> HttpRequest:
from corporate.lib.stripe import attach_discount_to_realm, get_discount_for_realm
from zerver.models import Client, get_realm, Realm, UserActivity, UserActivityInterval, \
UserProfile, PreregistrationUser, MultiuseInvite
if settings.ZILENCER_ENABLED:
from zilencer.models import RemoteInstallationCount, RemoteRealmCount, \
RemoteZulipServer
else:
from mock import Mock
RemoteInstallationCount = Mock() # type: ignore # https://github.com/JukkaL/mypy/issues/1188
RemoteZulipServer = Mock() # type: ignore # https://github.com/JukkaL/mypy/issues/1188
RemoteRealmCount = Mock() # type: ignore # https://github.com/JukkaL/mypy/issues/1188
def render_stats(request: HttpRequest, data_url_suffix: str, target_name: str,
for_installation: bool=False, remote: bool=False) -> HttpRequest:
page_params = dict( page_params = dict(
data_url_suffix=data_url_suffix, is_staff = request.user.is_staff,
for_installation=for_installation, stats_realm = realm.string_id,
remote=remote, debug_mode = False,
debug_mode=False,
) )
return render(request, return render(request,
'analytics/stats.html', 'analytics/stats.html',
context=dict(target_name=target_name, context=dict(target_realm_name=realm.name,
page_params=page_params)) page_params=JSONEncoderForHTML().encode(page_params)))
@zulip_login_required @zulip_login_required
def stats(request: HttpRequest) -> HttpResponse: def stats(request: HttpRequest) -> HttpResponse:
realm = request.user.realm realm = request.user.realm
if request.user.is_guest: return render_stats(request, realm)
# TODO: Make @zulip_login_required pass the UserProfile so we
# can use @require_member_or_admin
raise JsonableError(_("Not allowed for guest users"))
return render_stats(request, '', realm.name or realm.string_id)
@require_server_admin @require_server_admin
@has_request_variables @has_request_variables
def stats_for_realm(request: HttpRequest, realm_str: str) -> HttpResponse: def stats_for_realm(request: HttpRequest, realm_str: str) -> HttpResponse:
try: realm = get_realm(realm_str)
realm = get_realm(realm_str) if realm is None:
except Realm.DoesNotExist:
return HttpResponseNotFound("Realm %s does not exist" % (realm_str,)) return HttpResponseNotFound("Realm %s does not exist" % (realm_str,))
return render_stats(request, '/realm/%s' % (realm_str,), realm.name or realm.string_id) return render_stats(request, realm)
@require_server_admin
@has_request_variables
def stats_for_remote_realm(request: HttpRequest, remote_server_id: str,
remote_realm_id: str) -> HttpResponse:
server = RemoteZulipServer.objects.get(id=remote_server_id)
return render_stats(request, '/remote/%s/realm/%s' % (server.id, remote_realm_id),
"Realm %s on server %s" % (remote_realm_id, server.hostname))
@require_server_admin_api @require_server_admin_api
@has_request_variables @has_request_variables
def get_chart_data_for_realm(request: HttpRequest, user_profile: UserProfile, def get_chart_data_for_realm(request: HttpRequest, user_profile: UserProfile,
realm_str: str, **kwargs: Any) -> HttpResponse: realm_str: str, **kwargs: Any) -> HttpResponse:
try: realm = get_realm(realm_str)
realm = get_realm(realm_str) if realm is None:
except Realm.DoesNotExist:
raise JsonableError(_("Invalid organization")) raise JsonableError(_("Invalid organization"))
return get_chart_data(request=request, user_profile=user_profile, realm=realm, **kwargs) return get_chart_data(request=request, user_profile=user_profile, realm=realm, **kwargs)
@require_server_admin_api
@has_request_variables
def get_chart_data_for_remote_realm(
request: HttpRequest, user_profile: UserProfile, remote_server_id: str,
remote_realm_id: str, **kwargs: Any) -> HttpResponse:
server = RemoteZulipServer.objects.get(id=remote_server_id)
return get_chart_data(request=request, user_profile=user_profile, server=server,
remote=True, remote_realm_id=int(remote_realm_id), **kwargs)
@require_server_admin
def stats_for_installation(request: HttpRequest) -> HttpResponse:
return render_stats(request, '/installation', 'Installation', True)
@require_server_admin
def stats_for_remote_installation(request: HttpRequest, remote_server_id: str) -> HttpResponse:
server = RemoteZulipServer.objects.get(id=remote_server_id)
return render_stats(request, '/remote/%s/installation' % (server.id,),
'remote Installation %s' % (server.hostname,), True, True)
@require_server_admin_api
@has_request_variables
def get_chart_data_for_installation(request: HttpRequest, user_profile: UserProfile,
chart_name: str=REQ(), **kwargs: Any) -> HttpResponse:
return get_chart_data(request=request, user_profile=user_profile, for_installation=True, **kwargs)
@require_server_admin_api
@has_request_variables
def get_chart_data_for_remote_installation(
request: HttpRequest,
user_profile: UserProfile,
remote_server_id: str,
chart_name: str=REQ(),
**kwargs: Any) -> HttpResponse:
server = RemoteZulipServer.objects.get(id=remote_server_id)
return get_chart_data(request=request, user_profile=user_profile, for_installation=True,
remote=True, server=server, **kwargs)
@require_non_guest_user
@has_request_variables @has_request_variables
def get_chart_data(request: HttpRequest, user_profile: UserProfile, chart_name: str=REQ(), def get_chart_data(request: HttpRequest, user_profile: UserProfile, chart_name: str=REQ(),
min_length: Optional[int]=REQ(converter=to_non_negative_int, default=None), min_length: Optional[int]=REQ(converter=to_non_negative_int, default=None),
start: Optional[datetime]=REQ(converter=to_utc_datetime, default=None), start: Optional[datetime]=REQ(converter=to_utc_datetime, default=None),
end: Optional[datetime]=REQ(converter=to_utc_datetime, default=None), end: Optional[datetime]=REQ(converter=to_utc_datetime, default=None),
realm: Optional[Realm]=None, for_installation: bool=False, realm: Optional[Realm]=None) -> HttpResponse:
remote: bool=False, remote_realm_id: Optional[int]=None,
server: Optional[RemoteZulipServer]=None) -> HttpResponse:
if for_installation:
if remote:
aggregate_table = RemoteInstallationCount
assert server is not None
else:
aggregate_table = InstallationCount
else:
if remote:
aggregate_table = RemoteRealmCount
assert server is not None
assert remote_realm_id is not None
else:
aggregate_table = RealmCount
if chart_name == 'number_of_humans': if chart_name == 'number_of_humans':
stats = [ stat = COUNT_STATS['realm_active_humans::day']
COUNT_STATS['1day_actives::day'], tables = [RealmCount]
COUNT_STATS['realm_active_humans::day'], subgroup_to_label = {None: 'human'} # type: Dict[Optional[str], str]
COUNT_STATS['active_users_audit:is_bot:day']]
tables = [aggregate_table]
subgroup_to_label = {
stats[0]: {None: '_1day'},
stats[1]: {None: '_15day'},
stats[2]: {'false': 'all_time'}} # type: Dict[CountStat, Dict[Optional[str], str]]
labels_sort_function = None labels_sort_function = None
include_empty_subgroups = True include_empty_subgroups = True
elif chart_name == 'messages_sent_over_time': elif chart_name == 'messages_sent_over_time':
stats = [COUNT_STATS['messages_sent:is_bot:hour']] stat = COUNT_STATS['messages_sent:is_bot:hour']
tables = [aggregate_table, UserCount] tables = [RealmCount, UserCount]
subgroup_to_label = {stats[0]: {'false': 'human', 'true': 'bot'}} subgroup_to_label = {'false': 'human', 'true': 'bot'}
labels_sort_function = None labels_sort_function = None
include_empty_subgroups = True include_empty_subgroups = True
elif chart_name == 'messages_sent_by_message_type': elif chart_name == 'messages_sent_by_message_type':
stats = [COUNT_STATS['messages_sent:message_type:day']] stat = COUNT_STATS['messages_sent:message_type:day']
tables = [aggregate_table, UserCount] tables = [RealmCount, UserCount]
subgroup_to_label = {stats[0]: {'public_stream': _('Public streams'), subgroup_to_label = {'public_stream': _('Public streams'),
'private_stream': _('Private streams'), 'private_stream': _('Private streams'),
'private_message': _('Private messages'), 'private_message': _('Private messages'),
'huddle_message': _('Group private messages')}} 'huddle_message': _('Group private messages')}
labels_sort_function = lambda data: sort_by_totals(data['everyone']) labels_sort_function = lambda data: sort_by_totals(data['realm'])
include_empty_subgroups = True include_empty_subgroups = True
elif chart_name == 'messages_sent_by_client': elif chart_name == 'messages_sent_by_client':
stats = [COUNT_STATS['messages_sent:client:day']] stat = COUNT_STATS['messages_sent:client:day']
tables = [aggregate_table, UserCount] tables = [RealmCount, UserCount]
# Note that the labels are further re-written by client_label_map # Note that the labels are further re-written by client_label_map
subgroup_to_label = {stats[0]: subgroup_to_label = {str(id): name for id, name in Client.objects.values_list('id', 'name')}
{str(id): name for id, name in Client.objects.values_list('id', 'name')}}
labels_sort_function = sort_client_labels labels_sort_function = sort_client_labels
include_empty_subgroups = False include_empty_subgroups = False
else: else:
@@ -218,66 +121,27 @@ def get_chart_data(request: HttpRequest, user_profile: UserProfile, chart_name:
{'start': start, 'end': end}) {'start': start, 'end': end})
if realm is None: if realm is None:
# Note that this value is invalid for Remote tables; be
# careful not to access it in those code paths.
realm = user_profile.realm realm = user_profile.realm
if start is None:
start = realm.date_created
if end is None:
end = last_successful_fill(stat.property)
if end is None or start > end:
logging.warning("User from realm %s attempted to access /stats, but the computed "
"start time: %s (creation time of realm) is later than the computed "
"end time: %s (last successful analytics update). Is the "
"analytics cron job running?" % (realm.string_id, start, end))
raise JsonableError(_("No analytics data available. Please contact your server administrator."))
if remote: end_times = time_range(start, end, stat.frequency, min_length)
# For remote servers, we don't have fillstate data, and thus data = {'end_times': end_times, 'frequency': stat.frequency}
# should simply use the first and last data points for the
# table.
assert server is not None
if not aggregate_table.objects.filter(server=server).exists():
raise JsonableError(_("No analytics data available. Please contact your server administrator."))
if start is None:
start = aggregate_table.objects.filter(server=server).first().end_time
if end is None:
end = aggregate_table.objects.filter(server=server).last().end_time
else:
# Otherwise, we can use tables on the current server to
# determine a nice range, and some additional validation.
if start is None:
if for_installation:
start = installation_epoch()
else:
start = realm.date_created
if end is None:
end = max(last_successful_fill(stat.property) or
datetime.min.replace(tzinfo=timezone_utc) for stat in stats)
if start > end:
logging.warning("User from realm %s attempted to access /stats, but the computed "
"start time: %s (creation of realm or installation) is later than the computed "
"end time: %s (last successful analytics update). Is the "
"analytics cron job running?" % (realm.string_id, start, end))
raise JsonableError(_("No analytics data available. Please contact your server administrator."))
assert len(set([stat.frequency for stat in stats])) == 1
end_times = time_range(start, end, stats[0].frequency, min_length)
data = {'end_times': end_times, 'frequency': stats[0].frequency} # type: Dict[str, Any]
aggregation_level = {
InstallationCount: 'everyone',
RealmCount: 'everyone',
RemoteInstallationCount: 'everyone',
RemoteRealmCount: 'everyone',
UserCount: 'user',
}
# -1 is a placeholder value, since there is no relevant filtering on InstallationCount
id_value = {
InstallationCount: -1,
RealmCount: realm.id,
RemoteInstallationCount: server.id if server is not None else None,
# TODO: RemoteRealmCount logic doesn't correctly handle
# filtering by server_id as well.
RemoteRealmCount: remote_realm_id,
UserCount: user_profile.id,
}
for table in tables: for table in tables:
data[aggregation_level[table]] = {} if table == RealmCount:
for stat in stats: data['realm'] = get_time_series_by_subgroup(
data[aggregation_level[table]].update(get_time_series_by_subgroup( stat, RealmCount, realm.id, end_times, subgroup_to_label, include_empty_subgroups)
stat, table, id_value[table], end_times, subgroup_to_label[stat], include_empty_subgroups)) if table == UserCount:
data['user'] = get_time_series_by_subgroup(
stat, UserCount, user_profile.id, end_times, subgroup_to_label, include_empty_subgroups)
if labels_sort_function is not None: if labels_sort_function is not None:
data['display_order'] = labels_sort_function(data) data['display_order'] = labels_sort_function(data)
else: else:
@@ -296,7 +160,7 @@ def sort_by_totals(value_arrays: Dict[str, List[int]]) -> List[str]:
# tries to rank the clients so that taking the first N elements of the # tries to rank the clients so that taking the first N elements of the
# sorted list has a reasonable chance of doing so. # sorted list has a reasonable chance of doing so.
def sort_client_labels(data: Dict[str, Dict[str, List[int]]]) -> List[str]: def sort_client_labels(data: Dict[str, Dict[str, List[int]]]) -> List[str]:
realm_order = sort_by_totals(data['everyone']) realm_order = sort_by_totals(data['realm'])
user_order = sort_by_totals(data['user']) user_order = sort_by_totals(data['user'])
label_sort_values = {} # type: Dict[str, float] label_sort_values = {} # type: Dict[str, float]
for i, label in enumerate(realm_order): for i, label in enumerate(realm_order):
@@ -315,10 +179,6 @@ def table_filtered_to_id(table: Type[BaseCount], key_id: int) -> QuerySet:
return StreamCount.objects.filter(stream_id=key_id) return StreamCount.objects.filter(stream_id=key_id)
elif table == InstallationCount: elif table == InstallationCount:
return InstallationCount.objects.all() return InstallationCount.objects.all()
elif table == RemoteInstallationCount:
return RemoteInstallationCount.objects.filter(server_id=key_id)
elif table == RemoteRealmCount:
return RemoteRealmCount.objects.filter(realm_id=key_id)
else: else:
raise AssertionError("Unknown table: %s" % (table,)) raise AssertionError("Unknown table: %s" % (table,))
@@ -407,7 +267,7 @@ def get_realm_day_counts() -> Dict[str, Dict[str, str]]:
query = ''' query = '''
select select
r.string_id, r.string_id,
(now()::date - date_sent::date) age, (now()::date - pub_date::date) age,
count(*) cnt count(*) cnt
from zerver_message m from zerver_message m
join zerver_userprofile up on up.id = m.sender_id join zerver_userprofile up on up.id = m.sender_id
@@ -416,7 +276,7 @@ def get_realm_day_counts() -> Dict[str, Dict[str, str]]:
where where
(not up.is_bot) (not up.is_bot)
and and
date_sent > now()::date - interval '8 day' pub_date > now()::date - interval '8 day'
and and
c.name not in ('zephyr_mirror', 'ZulipMonitoring') c.name not in ('zephyr_mirror', 'ZulipMonitoring')
group by group by
@@ -459,9 +319,6 @@ def get_realm_day_counts() -> Dict[str, Dict[str, str]]:
return result return result
def get_plan_name(plan_type: int) -> str:
return ['', 'self hosted', 'limited', 'standard', 'open source'][plan_type]
def realm_summary_table(realm_minutes: Dict[str, float]) -> str: def realm_summary_table(realm_minutes: Dict[str, float]) -> str:
now = timezone_now() now = timezone_now()
@@ -469,7 +326,6 @@ def realm_summary_table(realm_minutes: Dict[str, float]) -> str:
SELECT SELECT
realm.string_id, realm.string_id,
realm.date_created, realm.date_created,
realm.plan_type,
coalesce(user_counts.dau_count, 0) dau_count, coalesce(user_counts.dau_count, 0) dau_count,
coalesce(wau_counts.wau_count, 0) wau_count, coalesce(wau_counts.wau_count, 0) wau_count,
( (
@@ -522,7 +378,7 @@ def realm_summary_table(realm_minutes: Dict[str, float]) -> str:
FROM ( FROM (
SELECT SELECT
realm.id as realm_id, realm.id as realm_id,
up.delivery_email up.email
FROM zerver_useractivity ua FROM zerver_useractivity ua
JOIN zerver_userprofile up JOIN zerver_userprofile up
ON up.id = ua.user_profile_id ON up.id = ua.user_profile_id
@@ -539,7 +395,7 @@ def realm_summary_table(realm_minutes: Dict[str, float]) -> str:
'/json/users/me/pointer', '/json/users/me/pointer',
'update_pointer_backend' 'update_pointer_backend'
) )
GROUP by realm.id, up.delivery_email GROUP by realm.id, up.email
HAVING max(last_visit) > now() - interval '7 day' HAVING max(last_visit) > now() - interval '7 day'
) as wau_users ) as wau_users
GROUP BY realm_id GROUP BY realm_id
@@ -577,14 +433,13 @@ def realm_summary_table(realm_minutes: Dict[str, float]) -> str:
# Fetch all the realm administrator users # Fetch all the realm administrator users
realm_admins = defaultdict(list) # type: Dict[str, List[str]] realm_admins = defaultdict(list) # type: Dict[str, List[str]]
for up in UserProfile.objects.select_related("realm").filter( for up in UserProfile.objects.select_related("realm").filter(
role=UserProfile.ROLE_REALM_ADMINISTRATOR, is_realm_admin=True,
is_active=True is_active=True
): ):
realm_admins[up.realm.string_id].append(up.delivery_email) realm_admins[up.realm.string_id].append(up.email)
for row in rows: for row in rows:
row['date_created_day'] = row['date_created'].strftime('%Y-%m-%d') row['date_created_day'] = row['date_created'].strftime('%Y-%m-%d')
row['plan_type_string'] = get_plan_name(row['plan_type'])
row['age_days'] = int((now - row['date_created']).total_seconds() row['age_days'] = int((now - row['date_created']).total_seconds()
/ 86400) / 86400)
row['is_new'] = row['age_days'] < 12 * 7 row['is_new'] = row['age_days'] < 12 * 7
@@ -598,16 +453,6 @@ def realm_summary_table(realm_minutes: Dict[str, float]) -> str:
except Exception: except Exception:
row['history'] = '' row['history'] = ''
# estimate annual subscription revenue
total_amount = 0
if settings.BILLING_ENABLED:
from corporate.lib.stripe import estimate_annual_recurring_revenue_by_realm
estimated_arrs = estimate_annual_recurring_revenue_by_realm()
for row in rows:
if row['string_id'] in estimated_arrs:
row['amount'] = estimated_arrs[row['string_id']]
total_amount += sum(estimated_arrs.values())
# augment data with realm_minutes # augment data with realm_minutes
total_hours = 0.0 total_hours = 0.0
for row in rows: for row in rows:
@@ -643,10 +488,8 @@ def realm_summary_table(realm_minutes: Dict[str, float]) -> str:
total_bot_count += int(row['bot_count']) total_bot_count += int(row['bot_count'])
total_wau_count += int(row['wau_count']) total_wau_count += int(row['wau_count'])
total_row = dict( rows.append(dict(
string_id='Total', string_id='Total',
plan_type_string="",
amount=total_amount,
stats_link = '', stats_link = '',
date_created_day='', date_created_day='',
realm_admin_email='', realm_admin_email='',
@@ -655,9 +498,7 @@ def realm_summary_table(realm_minutes: Dict[str, float]) -> str:
bot_count=total_bot_count, bot_count=total_bot_count,
hours=int(total_hours), hours=int(total_hours),
wau_count=total_wau_count, wau_count=total_wau_count,
) ))
rows.insert(0, total_row)
content = loader.render_to_string( content = loader.render_to_string(
'analytics/realm_summary_table.html', 'analytics/realm_summary_table.html',
@@ -683,15 +524,15 @@ def user_activity_intervals() -> Tuple[mark_safe, Dict[str, float]]:
).only( ).only(
'start', 'start',
'end', 'end',
'user_profile__delivery_email', 'user_profile__email',
'user_profile__realm__string_id' 'user_profile__realm__string_id'
).order_by( ).order_by(
'user_profile__realm__string_id', 'user_profile__realm__string_id',
'user_profile__delivery_email' 'user_profile__email'
) )
by_string_id = lambda row: row.user_profile.realm.string_id by_string_id = lambda row: row.user_profile.realm.string_id
by_email = lambda row: row.user_profile.delivery_email by_email = lambda row: row.user_profile.email
realm_minutes = {} realm_minutes = {}
@@ -740,7 +581,7 @@ def sent_messages_report(realm: str) -> str:
) as series ) as series
left join ( left join (
select select
date_sent::date date_sent, pub_date::date pub_date,
count(*) cnt count(*) cnt
from zerver_message m from zerver_message m
join zerver_userprofile up on up.id = m.sender_id join zerver_userprofile up on up.id = m.sender_id
@@ -750,16 +591,16 @@ def sent_messages_report(realm: str) -> str:
and and
(not up.is_bot) (not up.is_bot)
and and
date_sent > now() - interval '2 week' pub_date > now() - interval '2 week'
group by group by
date_sent::date pub_date::date
order by order by
date_sent::date pub_date::date
) humans on ) humans on
series.day = humans.date_sent series.day = humans.pub_date
left join ( left join (
select select
date_sent::date date_sent, pub_date::date pub_date,
count(*) cnt count(*) cnt
from zerver_message m from zerver_message m
join zerver_userprofile up on up.id = m.sender_id join zerver_userprofile up on up.id = m.sender_id
@@ -769,13 +610,13 @@ def sent_messages_report(realm: str) -> str:
and and
up.is_bot up.is_bot
and and
date_sent > now() - interval '2 week' pub_date > now() - interval '2 week'
group by group by
date_sent::date pub_date::date
order by order by
date_sent::date pub_date::date
) bots on ) bots on
series.day = bots.date_sent series.day = bots.pub_date
''' '''
cursor = connection.cursor() cursor = connection.cursor()
cursor.execute(query, [realm, realm]) cursor.execute(query, [realm, realm])
@@ -785,8 +626,7 @@ def sent_messages_report(realm: str) -> str:
return make_table(title, cols, rows) return make_table(title, cols, rows)
def ad_hoc_queries() -> List[Dict[str, str]]: def ad_hoc_queries() -> List[Dict[str, str]]:
def get_page(query: str, cols: List[str], title: str, def get_page(query: str, cols: List[str], title: str) -> Dict[str, str]:
totals_columns: List[int]=[]) -> Dict[str, str]:
cursor = connection.cursor() cursor = connection.cursor()
cursor.execute(query) cursor.execute(query)
rows = cursor.fetchall() rows = cursor.fetchall()
@@ -798,24 +638,11 @@ def ad_hoc_queries() -> List[Dict[str, str]]:
for row in rows: for row in rows:
row[i] = fixup_func(row[i]) row[i] = fixup_func(row[i])
total_row = []
for i, col in enumerate(cols): for i, col in enumerate(cols):
if col == 'Realm': if col == 'Realm':
fix_rows(i, realm_activity_link) fix_rows(i, realm_activity_link)
elif col in ['Last time', 'Last visit']: elif col in ['Last time', 'Last visit']:
fix_rows(i, format_date_for_activity_reports) fix_rows(i, format_date_for_activity_reports)
elif col == 'Hostname':
for row in rows:
row[i] = remote_installation_stats_link(row[0], row[i])
if len(totals_columns) > 0:
if i == 0:
total_row.append("Total")
elif i in totals_columns:
total_row.append(str(sum(row[i] for row in rows if row[i] is not None)))
else:
total_row.append('')
if len(totals_columns) > 0:
rows.insert(0, total_row)
content = make_table(title, cols, rows) content = make_table(title, cols, rows)
@@ -965,49 +792,6 @@ def ad_hoc_queries() -> List[Dict[str, str]]:
pages.append(get_page(query, cols, title)) pages.append(get_page(query, cols, title))
title = 'Remote Zulip servers'
query = '''
with icount as (
select
server_id,
max(value) as max_value,
max(end_time) as max_end_time
from zilencer_remoteinstallationcount
where
property='active_users:is_bot:day'
and subgroup='false'
group by server_id
),
remote_push_devices as (
select server_id, count(distinct(user_id)) as push_user_count from zilencer_remotepushdevicetoken
group by server_id
)
select
rserver.id,
rserver.hostname,
rserver.contact_email,
max_value,
push_user_count,
max_end_time
from zilencer_remotezulipserver rserver
left join icount on icount.server_id = rserver.id
left join remote_push_devices on remote_push_devices.server_id = rserver.id
order by max_value DESC NULLS LAST, push_user_count DESC NULLS LAST
'''
cols = [
'ID',
'Hostname',
'Contact email',
'Analytics users',
'Mobile users',
'Last update time',
]
pages.append(get_page(query, cols, title,
totals_columns=[3, 4]))
return pages return pages
@require_server_admin @require_server_admin
@@ -1030,137 +814,10 @@ def get_activity(request: HttpRequest) -> HttpResponse:
context=dict(data=data, title=title, is_home=True), context=dict(data=data, title=title, is_home=True),
) )
def get_confirmations(types: List[int], object_ids: List[int],
hostname: Optional[str]=None) -> List[Dict[str, Any]]:
lowest_datetime = timezone_now() - timedelta(days=30)
confirmations = Confirmation.objects.filter(type__in=types, object_id__in=object_ids,
date_sent__gte=lowest_datetime)
confirmation_dicts = []
for confirmation in confirmations:
realm = confirmation.realm
content_object = confirmation.content_object
if realm is not None:
realm_host = realm.host
elif isinstance(content_object, Realm):
realm_host = content_object.host
else:
realm_host = hostname
type = confirmation.type
days_to_activate = _properties[type].validity_in_days
expiry_date = confirmation.date_sent + timedelta(days=days_to_activate)
if hasattr(content_object, "status"):
if content_object.status == STATUS_ACTIVE:
link_status = "Link has been clicked"
else:
link_status = "Link has never been clicked"
else:
link_status = ""
if timezone_now() < expiry_date:
expires_in = timesince(confirmation.date_sent, expiry_date)
else:
expires_in = "Expired"
url = confirmation_url(confirmation.confirmation_key, realm_host, type)
confirmation_dicts.append({"object": confirmation.content_object,
"url": url, "type": type, "link_status": link_status,
"expires_in": expires_in})
return confirmation_dicts
@require_server_admin
def support(request: HttpRequest) -> HttpResponse:
context = {} # type: Dict[str, Any]
if settings.BILLING_ENABLED and request.method == "POST":
realm_id = request.POST.get("realm_id", None)
realm = Realm.objects.get(id=realm_id)
new_plan_type = request.POST.get("plan_type", None)
if new_plan_type is not None:
new_plan_type = int(new_plan_type)
current_plan_type = realm.plan_type
do_change_plan_type(realm, new_plan_type)
msg = "Plan type of {} changed from {} to {} ".format(realm.name,
get_plan_name(current_plan_type),
get_plan_name(new_plan_type))
context["message"] = msg
new_discount = request.POST.get("discount", None)
if new_discount is not None:
new_discount = Decimal(new_discount)
current_discount = get_discount_for_realm(realm)
attach_discount_to_realm(realm, new_discount)
msg = "Discount of {} changed to {} from {} ".format(realm.name, new_discount, current_discount)
context["message"] = msg
status = request.POST.get("status", None)
if status is not None:
if status == "active":
do_reactivate_realm(realm)
context["message"] = "{} reactivated.".format(realm.name)
elif status == "deactivated":
do_deactivate_realm(realm, request.user)
context["message"] = "{} deactivated.".format(realm.name)
scrub_realm = request.POST.get("scrub_realm", None)
if scrub_realm is not None:
if scrub_realm == "scrub_realm":
do_scrub_realm(realm)
context["message"] = "{} scrubbed.".format(realm.name)
query = request.GET.get("q", None)
if query:
key_words = get_invitee_emails_set(query)
context["users"] = UserProfile.objects.filter(delivery_email__in=key_words)
realms = set(Realm.objects.filter(string_id__in=key_words))
for key_word in key_words:
try:
URLValidator()(key_word)
parse_result = urllib.parse.urlparse(key_word)
hostname = parse_result.hostname
if parse_result.port:
hostname = "{}:{}".format(hostname, parse_result.port)
subdomain = get_subdomain_from_hostname(hostname)
try:
realms.add(get_realm(subdomain))
except Realm.DoesNotExist:
pass
except ValidationError:
pass
context["realms"] = realms
confirmations = [] # type: List[Dict[str, Any]]
preregistration_users = PreregistrationUser.objects.filter(email__in=key_words)
confirmations += get_confirmations([Confirmation.USER_REGISTRATION, Confirmation.INVITATION,
Confirmation.REALM_CREATION], preregistration_users,
hostname=request.get_host())
multiuse_invites = MultiuseInvite.objects.filter(realm__in=realms)
confirmations += get_confirmations([Confirmation.MULTIUSE_INVITE], multiuse_invites)
confirmations += get_confirmations([Confirmation.REALM_REACTIVATION], [realm.id for realm in realms])
context["confirmations"] = confirmations
def realm_admin_emails(realm: Realm) -> str:
return ", ".join(realm.get_human_admin_users().values_list("delivery_email", flat=True))
context["realm_admin_emails"] = realm_admin_emails
context["get_discount_for_realm"] = get_discount_for_realm
context["realm_icon_url"] = realm_icon_url
context["Confirmation"] = Confirmation
return render(request, 'analytics/support.html', context=context)
def get_user_activity_records_for_realm(realm: str, is_bot: bool) -> QuerySet: def get_user_activity_records_for_realm(realm: str, is_bot: bool) -> QuerySet:
fields = [ fields = [
'user_profile__full_name', 'user_profile__full_name',
'user_profile__delivery_email', 'user_profile__email',
'query', 'query',
'client__name', 'client__name',
'count', 'count',
@@ -1172,7 +829,7 @@ def get_user_activity_records_for_realm(realm: str, is_bot: bool) -> QuerySet:
user_profile__is_active=True, user_profile__is_active=True,
user_profile__is_bot=is_bot user_profile__is_bot=is_bot
) )
records = records.order_by("user_profile__delivery_email", "-last_visit") records = records.order_by("user_profile__email", "-last_visit")
records = records.select_related('user_profile', 'client').only(*fields) records = records.select_related('user_profile', 'client').only(*fields)
return records return records
@@ -1186,7 +843,7 @@ def get_user_activity_records_for_email(email: str) -> List[QuerySet]:
] ]
records = UserActivity.objects.filter( records = UserActivity.objects.filter(
user_profile__delivery_email=email user_profile__email=email
) )
records = records.order_by("-last_visit") records = records.order_by("-last_visit")
records = records.select_related('user_profile', 'client').only(*fields) records = records.select_related('user_profile', 'client').only(*fields)
@@ -1285,12 +942,6 @@ def realm_stats_link(realm_str: str) -> mark_safe:
stats_link = '<a href="{}"><i class="fa fa-pie-chart"></i></a>'.format(url, realm_str) stats_link = '<a href="{}"><i class="fa fa-pie-chart"></i></a>'.format(url, realm_str)
return mark_safe(stats_link) return mark_safe(stats_link)
def remote_installation_stats_link(server_id: int, hostname: str) -> mark_safe:
url_name = 'analytics.views.stats_for_remote_installation'
url = reverse(url_name, kwargs=dict(remote_server_id=server_id))
stats_link = '<a href="{}"><i class="fa fa-pie-chart"></i>{}</a>'.format(url, hostname)
return mark_safe(stats_link)
def realm_client_table(user_summaries: Dict[str, Dict[str, Dict[str, Any]]]) -> str: def realm_client_table(user_summaries: Dict[str, Dict[str, Dict[str, Any]]]) -> str:
exclude_keys = [ exclude_keys = [
'internal', 'internal',
@@ -1366,7 +1017,7 @@ def realm_user_summary_table(all_records: List[QuerySet],
user_records = {} user_records = {}
def by_email(record: QuerySet) -> str: def by_email(record: QuerySet) -> str:
return record.user_profile.delivery_email return record.user_profile.email
for email, records in itertools.groupby(all_records, by_email): for email, records in itertools.groupby(all_records, by_email):
user_records[email] = get_user_activity_summary(list(records)) user_records[email] = get_user_activity_summary(list(records))
@@ -1433,11 +1084,11 @@ def get_realm_activity(request: HttpRequest, realm_str: str) -> HttpResponse:
all_user_records = {} # type: Dict[str, Any] all_user_records = {} # type: Dict[str, Any]
try: try:
admins = Realm.objects.get(string_id=realm_str).get_human_admin_users() admins = Realm.objects.get(string_id=realm_str).get_admin_users()
except Realm.DoesNotExist: except Realm.DoesNotExist:
return HttpResponseNotFound("Realm %s does not exist" % (realm_str,)) return HttpResponseNotFound("Realm %s does not exist" % (realm_str,))
admin_emails = {admin.delivery_email for admin in admins} admin_emails = {admin.email for admin in admins}
for is_bot, page_title in [(False, 'Humans'), (True, 'Bots')]: for is_bot, page_title in [(False, 'Humans'), (True, 'Bots')]:
all_records = list(get_user_activity_records_for_realm(realm_str, is_bot)) all_records = list(get_user_activity_records_for_realm(realm_str, is_bot))

View File

@@ -1,16 +0,0 @@
module.exports = {
presets: [
[
"@babel/preset-env",
{
corejs: 3,
useBuiltIns: "usage",
},
],
"@babel/typescript",
],
plugins: [
"@babel/proposal-class-properties",
],
sourceType: "unambiguous",
};

View File

@@ -16,11 +16,13 @@ from django.http import HttpRequest, HttpResponse
from django.shortcuts import render from django.shortcuts import render
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from zerver.lib.send_email import send_email
from zerver.lib.utils import generate_random_token
from zerver.models import PreregistrationUser, EmailChangeStatus, MultiuseInvite, \ from zerver.models import PreregistrationUser, EmailChangeStatus, MultiuseInvite, \
UserProfile, Realm UserProfile, Realm
from random import SystemRandom from random import SystemRandom
import string import string
from typing import Dict, Optional, Union from typing import Any, Dict, Optional, Union
class ConfirmationKeyException(Exception): class ConfirmationKeyException(Exception):
WRONG_LENGTH = 1 WRONG_LENGTH = 1
@@ -69,14 +71,8 @@ def create_confirmation_link(obj: ContentType, host: str,
confirmation_type: int, confirmation_type: int,
url_args: Optional[Dict[str, str]]=None) -> str: url_args: Optional[Dict[str, str]]=None) -> str:
key = generate_key() key = generate_key()
realm = None
if hasattr(obj, 'realm'):
realm = obj.realm
elif isinstance(obj, Realm):
realm = obj
Confirmation.objects.create(content_object=obj, date_sent=timezone_now(), confirmation_key=key, Confirmation.objects.create(content_object=obj, date_sent=timezone_now(), confirmation_key=key,
realm=realm, type=confirmation_type) realm=obj.realm, type=confirmation_type)
return confirmation_url(key, host, confirmation_type, url_args) return confirmation_url(key, host, confirmation_type, url_args)
def confirmation_url(confirmation_key: str, host: str, def confirmation_url(confirmation_key: str, host: str,
@@ -104,7 +100,6 @@ class Confirmation(models.Model):
SERVER_REGISTRATION = 5 SERVER_REGISTRATION = 5
MULTIUSE_INVITE = 6 MULTIUSE_INVITE = 6
REALM_CREATION = 7 REALM_CREATION = 7
REALM_REACTIVATION = 8
type = models.PositiveSmallIntegerField() # type: int type = models.PositiveSmallIntegerField() # type: int
def __str__(self) -> str: def __str__(self) -> str:
@@ -127,18 +122,8 @@ _properties = {
'zerver.views.registration.accounts_home_from_multiuse_invite', 'zerver.views.registration.accounts_home_from_multiuse_invite',
validity_in_days=settings.INVITATION_LINK_VALIDITY_DAYS), validity_in_days=settings.INVITATION_LINK_VALIDITY_DAYS),
Confirmation.REALM_CREATION: ConfirmationType('check_prereg_key_and_redirect'), Confirmation.REALM_CREATION: ConfirmationType('check_prereg_key_and_redirect'),
Confirmation.REALM_REACTIVATION: ConfirmationType('zerver.views.realm.realm_reactivation'),
} }
def one_click_unsubscribe_link(user_profile: UserProfile, email_type: str) -> str:
"""
Generate a unique link that a logged-out user can visit to unsubscribe from
Zulip e-mails without having to first log in.
"""
return create_confirmation_link(user_profile, user_profile.realm.host,
Confirmation.UNSUBSCRIBE,
url_args = {'email_type': email_type})
# Functions related to links generated by the generate_realm_creation_link.py # Functions related to links generated by the generate_realm_creation_link.py
# management command. # management command.
# Note that being validated here will just allow the user to access the create_realm # Note that being validated here will just allow the user to access the create_realm

View File

@@ -2,6 +2,8 @@
# Copyright: (c) 2008, Jarek Zgoda <jarek.zgoda@gmail.com> # Copyright: (c) 2008, Jarek Zgoda <jarek.zgoda@gmail.com>
from typing import Any, Dict
__revision__ = '$Id: settings.py 12 2008-11-23 19:38:52Z jarek.zgoda $' __revision__ = '$Id: settings.py 12 2008-11-23 19:38:52Z jarek.zgoda $'
STATUS_ACTIVE = 1 STATUS_ACTIVE = 1

View File

@@ -1,498 +0,0 @@
from datetime import datetime
from decimal import Decimal
from functools import wraps
import logging
import math
import os
from typing import Any, Callable, Dict, Optional, TypeVar, Tuple, cast
import ujson
from django.conf import settings
from django.db import transaction
from django.utils.translation import ugettext as _
from django.utils.timezone import now as timezone_now
from django.core.signing import Signer
import stripe
from zerver.lib.logging_util import log_to_file
from zerver.lib.timestamp import datetime_to_timestamp, timestamp_to_datetime
from zerver.lib.utils import generate_random_token
from zerver.models import Realm, UserProfile, RealmAuditLog
from corporate.models import Customer, CustomerPlan, LicenseLedger, \
get_current_plan
from zproject.settings import get_secret
STRIPE_PUBLISHABLE_KEY = get_secret('stripe_publishable_key')
stripe.api_key = get_secret('stripe_secret_key')
BILLING_LOG_PATH = os.path.join('/var/log/zulip'
if not settings.DEVELOPMENT
else settings.DEVELOPMENT_LOG_DIRECTORY,
'billing.log')
billing_logger = logging.getLogger('corporate.stripe')
log_to_file(billing_logger, BILLING_LOG_PATH)
log_to_file(logging.getLogger('stripe'), BILLING_LOG_PATH)
CallableT = TypeVar('CallableT', bound=Callable[..., Any])
MIN_INVOICED_LICENSES = 30
DEFAULT_INVOICE_DAYS_UNTIL_DUE = 30
def get_latest_seat_count(realm: Realm) -> int:
non_guests = UserProfile.objects.filter(
realm=realm, is_active=True, is_bot=False).exclude(role=UserProfile.ROLE_GUEST).count()
guests = UserProfile.objects.filter(
realm=realm, is_active=True, is_bot=False, role=UserProfile.ROLE_GUEST).count()
return max(non_guests, math.ceil(guests / 5))
def sign_string(string: str) -> Tuple[str, str]:
salt = generate_random_token(64)
signer = Signer(salt=salt)
return signer.sign(string), salt
def unsign_string(signed_string: str, salt: str) -> str:
signer = Signer(salt=salt)
return signer.unsign(signed_string)
# Be extremely careful changing this function. Historical billing periods
# are not stored anywhere, and are just computed on the fly using this
# function. Any change you make here should return the same value (or be
# within a few seconds) for basically any value from when the billing system
# went online to within a year from now.
def add_months(dt: datetime, months: int) -> datetime:
assert(months >= 0)
# It's fine that the max day in Feb is 28 for leap years.
MAX_DAY_FOR_MONTH = {1: 31, 2: 28, 3: 31, 4: 30, 5: 31, 6: 30,
7: 31, 8: 31, 9: 30, 10: 31, 11: 30, 12: 31}
year = dt.year
month = dt.month + months
while month > 12:
year += 1
month -= 12
day = min(dt.day, MAX_DAY_FOR_MONTH[month])
# datetimes don't support leap seconds, so don't need to worry about those
return dt.replace(year=year, month=month, day=day)
def next_month(billing_cycle_anchor: datetime, dt: datetime) -> datetime:
estimated_months = round((dt - billing_cycle_anchor).days * 12. / 365)
for months in range(max(estimated_months - 1, 0), estimated_months + 2):
proposed_next_month = add_months(billing_cycle_anchor, months)
if 20 < (proposed_next_month - dt).days < 40:
return proposed_next_month
raise AssertionError('Something wrong in next_month calculation with '
'billing_cycle_anchor: %s, dt: %s' % (billing_cycle_anchor, dt))
def start_of_next_billing_cycle(plan: CustomerPlan, event_time: datetime) -> datetime:
months_per_period = {
CustomerPlan.ANNUAL: 12,
CustomerPlan.MONTHLY: 1,
}[plan.billing_schedule]
periods = 1
dt = plan.billing_cycle_anchor
while dt <= event_time:
dt = add_months(plan.billing_cycle_anchor, months_per_period * periods)
periods += 1
return dt
def next_invoice_date(plan: CustomerPlan) -> Optional[datetime]:
if plan.status == CustomerPlan.ENDED:
return None
assert(plan.next_invoice_date is not None) # for mypy
months_per_period = {
CustomerPlan.ANNUAL: 12,
CustomerPlan.MONTHLY: 1,
}[plan.billing_schedule]
if plan.automanage_licenses:
months_per_period = 1
periods = 1
dt = plan.billing_cycle_anchor
while dt <= plan.next_invoice_date:
dt = add_months(plan.billing_cycle_anchor, months_per_period * periods)
periods += 1
return dt
def renewal_amount(plan: CustomerPlan, event_time: datetime) -> int: # nocoverage: TODO
if plan.fixed_price is not None:
return plan.fixed_price
last_ledger_entry = make_end_of_cycle_updates_if_needed(plan, event_time)
if last_ledger_entry is None:
return 0
if last_ledger_entry.licenses_at_next_renewal is None:
return 0
assert(plan.price_per_license is not None) # for mypy
return plan.price_per_license * last_ledger_entry.licenses_at_next_renewal
class BillingError(Exception):
# error messages
CONTACT_SUPPORT = _("Something went wrong. Please contact %s.") % (settings.ZULIP_ADMINISTRATOR,)
TRY_RELOADING = _("Something went wrong. Please reload the page.")
# description is used only for tests
def __init__(self, description: str, message: str=CONTACT_SUPPORT) -> None:
self.description = description
self.message = message
class StripeCardError(BillingError):
pass
class StripeConnectionError(BillingError):
pass
def catch_stripe_errors(func: CallableT) -> CallableT:
@wraps(func)
def wrapped(*args: Any, **kwargs: Any) -> Any:
if settings.DEVELOPMENT and not settings.TEST_SUITE: # nocoverage
if STRIPE_PUBLISHABLE_KEY is None:
raise BillingError('missing stripe config', "Missing Stripe config. "
"See https://zulip.readthedocs.io/en/latest/subsystems/billing.html.")
try:
return func(*args, **kwargs)
# See https://stripe.com/docs/api/python#error_handling, though
# https://stripe.com/docs/api/ruby#error_handling suggests there are additional fields, and
# https://stripe.com/docs/error-codes gives a more detailed set of error codes
except stripe.error.StripeError as e:
err = e.json_body.get('error', {})
billing_logger.error("Stripe error: %s %s %s %s" % (
e.http_status, err.get('type'), err.get('code'), err.get('param')))
if isinstance(e, stripe.error.CardError):
# TODO: Look into i18n for this
raise StripeCardError('card error', err.get('message'))
if isinstance(e, stripe.error.RateLimitError) or \
isinstance(e, stripe.error.APIConnectionError): # nocoverage TODO
raise StripeConnectionError(
'stripe connection error',
_("Something went wrong. Please wait a few seconds and try again."))
raise BillingError('other stripe error', BillingError.CONTACT_SUPPORT)
return wrapped # type: ignore # https://github.com/python/mypy/issues/1927
@catch_stripe_errors
def stripe_get_customer(stripe_customer_id: str) -> stripe.Customer:
return stripe.Customer.retrieve(stripe_customer_id, expand=["default_source"])
@catch_stripe_errors
def do_create_stripe_customer(user: UserProfile, stripe_token: Optional[str]=None) -> Customer:
realm = user.realm
# We could do a better job of handling race conditions here, but if two
# people from a realm try to upgrade at exactly the same time, the main
# bad thing that will happen is that we will create an extra stripe
# customer that we can delete or ignore.
stripe_customer = stripe.Customer.create(
description="%s (%s)" % (realm.string_id, realm.name),
email=user.email,
metadata={'realm_id': realm.id, 'realm_str': realm.string_id},
source=stripe_token)
event_time = timestamp_to_datetime(stripe_customer.created)
with transaction.atomic():
RealmAuditLog.objects.create(
realm=user.realm, acting_user=user, event_type=RealmAuditLog.STRIPE_CUSTOMER_CREATED,
event_time=event_time)
if stripe_token is not None:
RealmAuditLog.objects.create(
realm=user.realm, acting_user=user, event_type=RealmAuditLog.STRIPE_CARD_CHANGED,
event_time=event_time)
customer, created = Customer.objects.update_or_create(realm=realm, defaults={
'stripe_customer_id': stripe_customer.id})
user.is_billing_admin = True
user.save(update_fields=["is_billing_admin"])
return customer
@catch_stripe_errors
def do_replace_payment_source(user: UserProfile, stripe_token: str,
pay_invoices: bool=False) -> stripe.Customer:
stripe_customer = stripe_get_customer(Customer.objects.get(realm=user.realm).stripe_customer_id)
stripe_customer.source = stripe_token
# Deletes existing card: https://stripe.com/docs/api#update_customer-source
updated_stripe_customer = stripe.Customer.save(stripe_customer)
RealmAuditLog.objects.create(
realm=user.realm, acting_user=user, event_type=RealmAuditLog.STRIPE_CARD_CHANGED,
event_time=timezone_now())
if pay_invoices:
for stripe_invoice in stripe.Invoice.list(
billing='charge_automatically', customer=stripe_customer.id, status='open'):
# The user will get either a receipt or a "failed payment" email, but the in-app
# messaging could be clearer here (e.g. it could explictly tell the user that there
# were payment(s) and that they succeeded or failed).
# Worth fixing if we notice that a lot of cards end up failing at this step.
stripe.Invoice.pay(stripe_invoice)
return updated_stripe_customer
# event_time should roughly be timezone_now(). Not designed to handle
# event_times in the past or future
def make_end_of_cycle_updates_if_needed(plan: CustomerPlan,
event_time: datetime) -> Optional[LicenseLedger]:
last_ledger_entry = LicenseLedger.objects.filter(plan=plan).order_by('-id').first()
last_renewal = LicenseLedger.objects.filter(plan=plan, is_renewal=True) \
.order_by('-id').first().event_time
next_billing_cycle = start_of_next_billing_cycle(plan, last_renewal)
if next_billing_cycle <= event_time:
if plan.status == CustomerPlan.ACTIVE:
return LicenseLedger.objects.create(
plan=plan, is_renewal=True, event_time=next_billing_cycle,
licenses=last_ledger_entry.licenses_at_next_renewal,
licenses_at_next_renewal=last_ledger_entry.licenses_at_next_renewal)
if plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE:
process_downgrade(plan)
return None
return last_ledger_entry
# Returns Customer instead of stripe_customer so that we don't make a Stripe
# API call if there's nothing to update
def update_or_create_stripe_customer(user: UserProfile, stripe_token: Optional[str]=None) -> Customer:
realm = user.realm
customer = Customer.objects.filter(realm=realm).first()
if customer is None or customer.stripe_customer_id is None:
return do_create_stripe_customer(user, stripe_token=stripe_token)
if stripe_token is not None:
do_replace_payment_source(user, stripe_token)
return customer
def compute_plan_parameters(
automanage_licenses: bool, billing_schedule: int,
discount: Optional[Decimal]) -> Tuple[datetime, datetime, datetime, int]:
# Everything in Stripe is stored as timestamps with 1 second resolution,
# so standardize on 1 second resolution.
# TODO talk about leapseconds?
billing_cycle_anchor = timezone_now().replace(microsecond=0)
if billing_schedule == CustomerPlan.ANNUAL:
# TODO use variables to account for Zulip Plus
price_per_license = 8000
period_end = add_months(billing_cycle_anchor, 12)
elif billing_schedule == CustomerPlan.MONTHLY:
price_per_license = 800
period_end = add_months(billing_cycle_anchor, 1)
else:
raise AssertionError('Unknown billing_schedule: {}'.format(billing_schedule))
if discount is not None:
# There are no fractional cents in Stripe, so round down to nearest integer.
price_per_license = int(float(price_per_license * (1 - discount / 100)) + .00001)
next_invoice_date = period_end
if automanage_licenses:
next_invoice_date = add_months(billing_cycle_anchor, 1)
return billing_cycle_anchor, next_invoice_date, period_end, price_per_license
# Only used for cloud signups
@catch_stripe_errors
def process_initial_upgrade(user: UserProfile, licenses: int, automanage_licenses: bool,
billing_schedule: int, stripe_token: Optional[str]) -> None:
realm = user.realm
customer = update_or_create_stripe_customer(user, stripe_token=stripe_token)
if get_current_plan(customer) is not None:
# Unlikely race condition from two people upgrading (clicking "Make payment")
# at exactly the same time. Doesn't fully resolve the race condition, but having
# a check here reduces the likelihood.
billing_logger.warning(
"Customer {} trying to upgrade, but has an active subscription".format(customer))
raise BillingError('subscribing with existing subscription', BillingError.TRY_RELOADING)
billing_cycle_anchor, next_invoice_date, period_end, price_per_license = compute_plan_parameters(
automanage_licenses, billing_schedule, customer.default_discount)
# The main design constraint in this function is that if you upgrade with a credit card, and the
# charge fails, everything should be rolled back as if nothing had happened. This is because we
# expect frequent card failures on initial signup.
# Hence, if we're going to charge a card, do it at the beginning, even if we later may have to
# adjust the number of licenses.
charge_automatically = stripe_token is not None
if charge_automatically:
stripe_charge = stripe.Charge.create(
amount=price_per_license * licenses,
currency='usd',
customer=customer.stripe_customer_id,
description="Upgrade to Zulip Standard, ${} x {}".format(price_per_license/100, licenses),
receipt_email=user.email,
statement_descriptor='Zulip Standard')
# Not setting a period start and end, but maybe we should? Unclear what will make things
# most similar to the renewal case from an accounting perspective.
stripe.InvoiceItem.create(
amount=price_per_license * licenses * -1,
currency='usd',
customer=customer.stripe_customer_id,
description="Payment (Card ending in {})".format(cast(stripe.Card, stripe_charge.source).last4),
discountable=False)
# TODO: The correctness of this relies on user creation, deactivation, etc being
# in a transaction.atomic() with the relevant RealmAuditLog entries
with transaction.atomic():
# billed_licenses can greater than licenses if users are added between the start of
# this function (process_initial_upgrade) and now
billed_licenses = max(get_latest_seat_count(realm), licenses)
plan_params = {
'automanage_licenses': automanage_licenses,
'charge_automatically': charge_automatically,
'price_per_license': price_per_license,
'discount': customer.default_discount,
'billing_cycle_anchor': billing_cycle_anchor,
'billing_schedule': billing_schedule,
'tier': CustomerPlan.STANDARD}
plan = CustomerPlan.objects.create(
customer=customer,
next_invoice_date=next_invoice_date,
**plan_params)
ledger_entry = LicenseLedger.objects.create(
plan=plan,
is_renewal=True,
event_time=billing_cycle_anchor,
licenses=billed_licenses,
licenses_at_next_renewal=billed_licenses)
plan.invoiced_through = ledger_entry
plan.save(update_fields=['invoiced_through'])
RealmAuditLog.objects.create(
realm=realm, acting_user=user, event_time=billing_cycle_anchor,
event_type=RealmAuditLog.CUSTOMER_PLAN_CREATED,
extra_data=ujson.dumps(plan_params))
stripe.InvoiceItem.create(
currency='usd',
customer=customer.stripe_customer_id,
description='Zulip Standard',
discountable=False,
period = {'start': datetime_to_timestamp(billing_cycle_anchor),
'end': datetime_to_timestamp(period_end)},
quantity=billed_licenses,
unit_amount=price_per_license)
if charge_automatically:
billing_method = 'charge_automatically'
days_until_due = None
else:
billing_method = 'send_invoice'
days_until_due = DEFAULT_INVOICE_DAYS_UNTIL_DUE
stripe_invoice = stripe.Invoice.create(
auto_advance=True,
billing=billing_method,
customer=customer.stripe_customer_id,
days_until_due=days_until_due,
statement_descriptor='Zulip Standard')
stripe.Invoice.finalize_invoice(stripe_invoice)
from zerver.lib.actions import do_change_plan_type
do_change_plan_type(realm, Realm.STANDARD)
def update_license_ledger_for_automanaged_plan(realm: Realm, plan: CustomerPlan,
event_time: datetime) -> None:
last_ledger_entry = make_end_of_cycle_updates_if_needed(plan, event_time)
if last_ledger_entry is None:
return
licenses_at_next_renewal = get_latest_seat_count(realm)
licenses = max(licenses_at_next_renewal, last_ledger_entry.licenses)
LicenseLedger.objects.create(
plan=plan, event_time=event_time, licenses=licenses,
licenses_at_next_renewal=licenses_at_next_renewal)
def update_license_ledger_if_needed(realm: Realm, event_time: datetime) -> None:
customer = Customer.objects.filter(realm=realm).first()
if customer is None:
return
plan = get_current_plan(customer)
if plan is None:
return
if not plan.automanage_licenses:
return
update_license_ledger_for_automanaged_plan(realm, plan, event_time)
def invoice_plan(plan: CustomerPlan, event_time: datetime) -> None:
if plan.invoicing_status == CustomerPlan.STARTED:
raise NotImplementedError('Plan with invoicing_status==STARTED needs manual resolution.')
make_end_of_cycle_updates_if_needed(plan, event_time)
assert(plan.invoiced_through is not None)
licenses_base = plan.invoiced_through.licenses
invoice_item_created = False
for ledger_entry in LicenseLedger.objects.filter(plan=plan, id__gt=plan.invoiced_through.id,
event_time__lte=event_time).order_by('id'):
price_args = {} # type: Dict[str, int]
if ledger_entry.is_renewal:
if plan.fixed_price is not None:
price_args = {'amount': plan.fixed_price}
else:
assert(plan.price_per_license is not None) # needed for mypy
price_args = {'unit_amount': plan.price_per_license,
'quantity': ledger_entry.licenses}
description = "Zulip Standard - renewal"
elif ledger_entry.licenses != licenses_base:
assert(plan.price_per_license)
last_renewal = LicenseLedger.objects.filter(
plan=plan, is_renewal=True, event_time__lte=ledger_entry.event_time) \
.order_by('-id').first().event_time
period_end = start_of_next_billing_cycle(plan, ledger_entry.event_time)
proration_fraction = (period_end - ledger_entry.event_time) / (period_end - last_renewal)
price_args = {'unit_amount': int(plan.price_per_license * proration_fraction + .5),
'quantity': ledger_entry.licenses - licenses_base}
description = "Additional license ({} - {})".format(
ledger_entry.event_time.strftime('%b %-d, %Y'), period_end.strftime('%b %-d, %Y'))
if price_args:
plan.invoiced_through = ledger_entry
plan.invoicing_status = CustomerPlan.STARTED
plan.save(update_fields=['invoicing_status', 'invoiced_through'])
idempotency_key = 'ledger_entry:{}'.format(ledger_entry.id) # type: Optional[str]
if settings.TEST_SUITE:
idempotency_key = None
stripe.InvoiceItem.create(
currency='usd',
customer=plan.customer.stripe_customer_id,
description=description,
discountable=False,
period = {'start': datetime_to_timestamp(ledger_entry.event_time),
'end': datetime_to_timestamp(
start_of_next_billing_cycle(plan, ledger_entry.event_time))},
idempotency_key=idempotency_key,
**price_args)
invoice_item_created = True
plan.invoiced_through = ledger_entry
plan.invoicing_status = CustomerPlan.DONE
plan.save(update_fields=['invoicing_status', 'invoiced_through'])
licenses_base = ledger_entry.licenses
if invoice_item_created:
if plan.charge_automatically:
billing_method = 'charge_automatically'
days_until_due = None
else:
billing_method = 'send_invoice'
days_until_due = DEFAULT_INVOICE_DAYS_UNTIL_DUE
stripe_invoice = stripe.Invoice.create(
auto_advance=True,
billing=billing_method,
customer=plan.customer.stripe_customer_id,
days_until_due=days_until_due,
statement_descriptor='Zulip Standard')
stripe.Invoice.finalize_invoice(stripe_invoice)
plan.next_invoice_date = next_invoice_date(plan)
plan.save(update_fields=['next_invoice_date'])
def invoice_plans_as_needed(event_time: datetime=timezone_now()) -> None:
for plan in CustomerPlan.objects.filter(next_invoice_date__lte=event_time):
invoice_plan(plan, event_time)
def attach_discount_to_realm(realm: Realm, discount: Decimal) -> None:
Customer.objects.update_or_create(realm=realm, defaults={'default_discount': discount})
def get_discount_for_realm(realm: Realm) -> Optional[Decimal]:
customer = Customer.objects.filter(realm=realm).first()
if customer is not None:
return customer.default_discount
return None
def do_change_plan_status(plan: CustomerPlan, status: int) -> None:
plan.status = status
plan.save(update_fields=['status'])
billing_logger.info('Change plan status: Customer.id: %s, CustomerPlan.id: %s, status: %s' % (
plan.customer.id, plan.id, status))
def process_downgrade(plan: CustomerPlan) -> None:
from zerver.lib.actions import do_change_plan_type
do_change_plan_type(plan.customer.realm, Realm.LIMITED)
plan.status = CustomerPlan.ENDED
plan.save(update_fields=['status'])
def estimate_annual_recurring_revenue_by_realm() -> Dict[str, int]: # nocoverage
annual_revenue = {}
for plan in CustomerPlan.objects.filter(
status=CustomerPlan.ACTIVE).select_related('customer__realm'):
# TODO: figure out what to do for plans that don't automatically
# renew, but which probably will renew
renewal_cents = renewal_amount(plan, timezone_now())
if plan.billing_schedule == CustomerPlan.MONTHLY:
renewal_cents *= 12
# TODO: Decimal stuff
annual_revenue[plan.customer.realm.string_id] = int(renewal_cents / 100)
return annual_revenue

View File

@@ -1,53 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.14 on 2018-09-25 12:02
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('zerver', '0189_userprofile_add_some_emojisets'),
]
operations = [
migrations.CreateModel(
name='BillingProcessor',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('state', models.CharField(max_length=20)),
('last_modified', models.DateTimeField(auto_now=True)),
('log_row', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='zerver.RealmAuditLog')),
('realm', models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, to='zerver.Realm')),
],
),
migrations.CreateModel(
name='Coupon',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('percent_off', models.SmallIntegerField(unique=True)),
('stripe_coupon_id', models.CharField(max_length=255, unique=True)),
],
),
migrations.CreateModel(
name='Customer',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('stripe_customer_id', models.CharField(max_length=255, unique=True)),
('has_billing_relationship', models.BooleanField(default=False)),
('realm', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='zerver.Realm')),
],
),
migrations.CreateModel(
name='Plan',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('nickname', models.CharField(max_length=40, unique=True)),
('stripe_plan_id', models.CharField(max_length=255, unique=True)),
],
),
]

View File

@@ -1,20 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.16 on 2018-12-12 20:19
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('corporate', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='customer',
name='default_discount',
field=models.DecimalField(decimal_places=4, max_digits=7, null=True),
),
]

View File

@@ -1,35 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.16 on 2018-12-22 21:05
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('corporate', '0002_customer_default_discount'),
]
operations = [
migrations.CreateModel(
name='CustomerPlan',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('licenses', models.IntegerField()),
('automanage_licenses', models.BooleanField(default=False)),
('charge_automatically', models.BooleanField(default=False)),
('price_per_license', models.IntegerField(null=True)),
('fixed_price', models.IntegerField(null=True)),
('discount', models.DecimalField(decimal_places=4, max_digits=6, null=True)),
('billing_cycle_anchor', models.DateTimeField()),
('billing_schedule', models.SmallIntegerField()),
('billed_through', models.DateTimeField()),
('next_billing_date', models.DateTimeField(db_index=True)),
('tier', models.SmallIntegerField()),
('status', models.SmallIntegerField(default=1)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='corporate.Customer')),
],
),
]

View File

@@ -1,27 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.18 on 2019-01-19 05:01
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('corporate', '0003_customerplan'),
]
operations = [
migrations.CreateModel(
name='LicenseLedger',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_renewal', models.BooleanField(default=False)),
('event_time', models.DateTimeField()),
('licenses', models.IntegerField()),
('licenses_at_next_renewal', models.IntegerField(null=True)),
('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='corporate.CustomerPlan')),
],
),
]

View File

@@ -1,35 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.18 on 2019-01-28 13:04
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('corporate', '0004_licenseledger'),
]
operations = [
migrations.RenameField(
model_name='customerplan',
old_name='next_billing_date',
new_name='next_invoice_date',
),
migrations.RemoveField(
model_name='customerplan',
name='billed_through',
),
migrations.AddField(
model_name='customerplan',
name='invoiced_through',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='corporate.LicenseLedger'),
),
migrations.AddField(
model_name='customerplan',
name='invoicing_status',
field=models.SmallIntegerField(default=1),
),
]

View File

@@ -1,20 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.18 on 2019-01-29 01:46
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('corporate', '0005_customerplan_invoicing'),
]
operations = [
migrations.AlterField(
model_name='customer',
name='stripe_customer_id',
field=models.CharField(max_length=255, null=True, unique=True),
),
]

View File

@@ -1,40 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.18 on 2019-01-31 22:16
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('corporate', '0006_nullable_stripe_customer_id'),
]
operations = [
migrations.RemoveField(
model_name='billingprocessor',
name='log_row',
),
migrations.RemoveField(
model_name='billingprocessor',
name='realm',
),
migrations.DeleteModel(
name='Coupon',
),
migrations.DeleteModel(
name='Plan',
),
migrations.RemoveField(
model_name='customer',
name='has_billing_relationship',
),
migrations.RemoveField(
model_name='customerplan',
name='licenses',
),
migrations.DeleteModel(
name='BillingProcessor',
),
]

View File

@@ -1,20 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.20 on 2019-04-11 00:45
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('corporate', '0007_remove_deprecated_fields'),
]
operations = [
migrations.AlterField(
model_name='customerplan',
name='next_invoice_date',
field=models.DateTimeField(db_index=True, null=True),
),
]

View File

@@ -1,73 +0,0 @@
import datetime
from decimal import Decimal
from typing import Optional
from django.db import models
from django.db.models import CASCADE
from zerver.models import Realm
class Customer(models.Model):
realm = models.OneToOneField(Realm, on_delete=CASCADE) # type: Realm
stripe_customer_id = models.CharField(max_length=255, null=True, unique=True) # type: str
# A percentage, like 85.
default_discount = models.DecimalField(decimal_places=4, max_digits=7, null=True) # type: Optional[Decimal]
def __str__(self) -> str:
return "<Customer %s %s>" % (self.realm, self.stripe_customer_id)
class CustomerPlan(models.Model):
customer = models.ForeignKey(Customer, on_delete=CASCADE) # type: Customer
automanage_licenses = models.BooleanField(default=False) # type: bool
charge_automatically = models.BooleanField(default=False) # type: bool
# Both of these are in cents. Exactly one of price_per_license or
# fixed_price should be set. fixed_price is only for manual deals, and
# can't be set via the self-serve billing system.
price_per_license = models.IntegerField(null=True) # type: Optional[int]
fixed_price = models.IntegerField(null=True) # type: Optional[int]
# Discount that was applied. For display purposes only.
discount = models.DecimalField(decimal_places=4, max_digits=6, null=True) # type: Optional[Decimal]
billing_cycle_anchor = models.DateTimeField() # type: datetime.datetime
ANNUAL = 1
MONTHLY = 2
billing_schedule = models.SmallIntegerField() # type: int
next_invoice_date = models.DateTimeField(db_index=True, null=True) # type: Optional[datetime.datetime]
invoiced_through = models.ForeignKey(
'LicenseLedger', null=True, on_delete=CASCADE, related_name='+') # type: Optional[LicenseLedger]
DONE = 1
STARTED = 2
invoicing_status = models.SmallIntegerField(default=DONE) # type: int
STANDARD = 1
PLUS = 2 # not available through self-serve signup
ENTERPRISE = 10
tier = models.SmallIntegerField() # type: int
ACTIVE = 1
DOWNGRADE_AT_END_OF_CYCLE = 2
# "Live" plans should have a value < LIVE_STATUS_THRESHOLD.
# There should be at most one live plan per customer.
LIVE_STATUS_THRESHOLD = 10
ENDED = 11
NEVER_STARTED = 12
status = models.SmallIntegerField(default=ACTIVE) # type: int
# TODO maybe override setattr to ensure billing_cycle_anchor, etc are immutable
def get_current_plan(customer: Customer) -> Optional[CustomerPlan]:
return CustomerPlan.objects.filter(
customer=customer, status__lt=CustomerPlan.LIVE_STATUS_THRESHOLD).first()
class LicenseLedger(models.Model):
plan = models.ForeignKey(CustomerPlan, on_delete=CASCADE) # type: CustomerPlan
# Also True for the initial upgrade.
is_renewal = models.BooleanField(default=False) # type: bool
event_time = models.DateTimeField() # type: datetime.datetime
licenses = models.IntegerField() # type: int
# None means the plan does not automatically renew.
# This cannot be None if plan.automanage_licenses.
licenses_at_next_renewal = models.IntegerField(null=True) # type: Optional[int]

View File

@@ -1,112 +0,0 @@
{
"amount": 7200,
"amount_refunded": 0,
"application": null,
"application_fee": null,
"application_fee_amount": null,
"balance_transaction": "txn_NORMALIZED00000000000001",
"billing_details": {
"address": {
"city": "Pacific",
"country": "United States",
"line1": "Under the sea,",
"line2": null,
"postal_code": "33333",
"state": null
},
"email": null,
"name": "Ada Starr",
"phone": null
},
"captured": true,
"created": 1000000000,
"currency": "usd",
"customer": "cus_NORMALIZED0001",
"description": "Upgrade to Zulip Standard, $12.0 x 6",
"destination": null,
"dispute": null,
"failure_code": null,
"failure_message": null,
"fraud_details": {},
"id": "ch_NORMALIZED00000000000001",
"invoice": null,
"livemode": false,
"metadata": {},
"object": "charge",
"on_behalf_of": null,
"order": null,
"outcome": {
"network_status": "approved_by_network",
"reason": null,
"risk_level": "normal",
"risk_score": 00,
"seller_message": "Payment complete.",
"type": "authorized"
},
"paid": true,
"payment_intent": null,
"payment_method": "card_NORMALIZED00000000000001",
"payment_method_details": {
"card": {
"brand": "visa",
"checks": {
"address_line1_check": "pass",
"address_postal_code_check": "pass",
"cvc_check": "pass"
},
"country": "US",
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"last4": "4242",
"three_d_secure": null,
"wallet": null
},
"type": "card"
},
"receipt_email": "hamlet@zulip.com",
"receipt_number": null,
"receipt_url": "https://pay.stripe.com/receipts/acct_NORMALIZED000001/ch_NORMALIZED00000000000001/rcpt_NORMALIZED000000000000000000001",
"refunded": false,
"refunds": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/charges/ch_NORMALIZED00000000000001/refunds"
},
"review": null,
"shipping": null,
"source": {
"address_city": "Pacific",
"address_country": "United States",
"address_line1": "Under the sea,",
"address_line1_check": "pass",
"address_line2": null,
"address_state": null,
"address_zip": "33333",
"address_zip_check": "pass",
"brand": "Visa",
"country": "US",
"customer": "cus_NORMALIZED0001",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"id": "card_NORMALIZED00000000000001",
"last4": "4242",
"metadata": {},
"name": "Ada Starr",
"object": "card",
"tokenization_method": null
},
"source_transfer": null,
"statement_descriptor": "Zulip Standard",
"statement_descriptor_suffix": "Zulip Standard",
"status": "succeeded",
"transfer_data": null,
"transfer_group": null
}

View File

@@ -1,112 +0,0 @@
{
"amount": 36000,
"amount_refunded": 0,
"application": null,
"application_fee": null,
"application_fee_amount": null,
"balance_transaction": "txn_NORMALIZED00000000000002",
"billing_details": {
"address": {
"city": "Pacific",
"country": "United States",
"line1": "Under the sea,",
"line2": null,
"postal_code": "33333",
"state": null
},
"email": null,
"name": "Ada Starr",
"phone": null
},
"captured": true,
"created": 1000000000,
"currency": "usd",
"customer": "cus_NORMALIZED0001",
"description": "Upgrade to Zulip Standard, $60.0 x 6",
"destination": null,
"dispute": null,
"failure_code": null,
"failure_message": null,
"fraud_details": {},
"id": "ch_NORMALIZED00000000000002",
"invoice": null,
"livemode": false,
"metadata": {},
"object": "charge",
"on_behalf_of": null,
"order": null,
"outcome": {
"network_status": "approved_by_network",
"reason": null,
"risk_level": "normal",
"risk_score": 00,
"seller_message": "Payment complete.",
"type": "authorized"
},
"paid": true,
"payment_intent": null,
"payment_method": "card_NORMALIZED00000000000002",
"payment_method_details": {
"card": {
"brand": "visa",
"checks": {
"address_line1_check": "pass",
"address_postal_code_check": "pass",
"cvc_check": "pass"
},
"country": "US",
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"last4": "4242",
"three_d_secure": null,
"wallet": null
},
"type": "card"
},
"receipt_email": "hamlet@zulip.com",
"receipt_number": null,
"receipt_url": "https://pay.stripe.com/receipts/acct_NORMALIZED000001/ch_NORMALIZED00000000000002/rcpt_NORMALIZED000000000000000000002",
"refunded": false,
"refunds": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/charges/ch_NORMALIZED00000000000002/refunds"
},
"review": null,
"shipping": null,
"source": {
"address_city": "Pacific",
"address_country": "United States",
"address_line1": "Under the sea,",
"address_line1_check": "pass",
"address_line2": null,
"address_state": null,
"address_zip": "33333",
"address_zip_check": "pass",
"brand": "Visa",
"country": "US",
"customer": "cus_NORMALIZED0001",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"id": "card_NORMALIZED00000000000002",
"last4": "4242",
"metadata": {},
"name": "Ada Starr",
"object": "card",
"tokenization_method": null
},
"source_transfer": null,
"statement_descriptor": "Zulip Standard",
"statement_descriptor_suffix": "Zulip Standard",
"status": "succeeded",
"transfer_data": null,
"transfer_group": null
}

View File

@@ -1,113 +0,0 @@
{
"data": [
{
"amount": 7200,
"amount_refunded": 0,
"application": null,
"application_fee": null,
"application_fee_amount": null,
"balance_transaction": "txn_NORMALIZED00000000000001",
"billing_details": {
"address": {
"city": "Pacific",
"country": "United States",
"line1": "Under the sea,",
"line2": null,
"postal_code": "33333",
"state": null
},
"email": null,
"name": "Ada Starr",
"phone": null
},
"captured": true,
"created": 1000000000,
"currency": "usd",
"customer": "cus_NORMALIZED0001",
"description": "Upgrade to Zulip Standard, $12.0 x 6",
"destination": null,
"dispute": null,
"failure_code": null,
"failure_message": null,
"fraud_details": {},
"id": "ch_NORMALIZED00000000000001",
"invoice": null,
"livemode": false,
"metadata": {},
"object": "charge",
"on_behalf_of": null,
"order": null,
"outcome": {
"network_status": "approved_by_network",
"reason": null,
"risk_level": "normal",
"risk_score": 00,
"seller_message": "Payment complete.",
"type": "authorized"
},
"paid": true,
"payment_intent": null,
"payment_method": "card_NORMALIZED00000000000001",
"payment_method_details": {
"card": {
"brand": "visa",
"checks": {
"address_line1_check": "pass",
"address_postal_code_check": "pass",
"cvc_check": "pass"
},
"country": "US",
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"last4": "4242",
"three_d_secure": null,
"wallet": null
},
"type": "card"
},
"receipt_email": "hamlet@zulip.com",
"receipt_number": null,
"receipt_url": "https://pay.stripe.com/receipts/acct_NORMALIZED000001/ch_NORMALIZED00000000000001/rcpt_NORMALIZED000000000000000000001",
"refunded": false,
"refunds": {},
"review": null,
"shipping": null,
"source": {
"address_city": "Pacific",
"address_country": "United States",
"address_line1": "Under the sea,",
"address_line1_check": "pass",
"address_line2": null,
"address_state": null,
"address_zip": "33333",
"address_zip_check": "pass",
"brand": "Visa",
"country": "US",
"customer": "cus_NORMALIZED0001",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"id": "card_NORMALIZED00000000000001",
"last4": "4242",
"metadata": {},
"name": "Ada Starr",
"object": "card",
"tokenization_method": null
},
"source_transfer": null,
"statement_descriptor": "Zulip Standard",
"statement_descriptor_suffix": "Zulip Standard",
"status": "succeeded",
"transfer_data": null,
"transfer_group": null
}
],
"has_more": false,
"object": "list",
"url": "/v1/charges"
}

View File

@@ -1,219 +0,0 @@
{
"data": [
{
"amount": 36000,
"amount_refunded": 0,
"application": null,
"application_fee": null,
"application_fee_amount": null,
"balance_transaction": "txn_NORMALIZED00000000000002",
"billing_details": {
"address": {
"city": "Pacific",
"country": "United States",
"line1": "Under the sea,",
"line2": null,
"postal_code": "33333",
"state": null
},
"email": null,
"name": "Ada Starr",
"phone": null
},
"captured": true,
"created": 1000000000,
"currency": "usd",
"customer": "cus_NORMALIZED0001",
"description": "Upgrade to Zulip Standard, $60.0 x 6",
"destination": null,
"dispute": null,
"failure_code": null,
"failure_message": null,
"fraud_details": {},
"id": "ch_NORMALIZED00000000000002",
"invoice": null,
"livemode": false,
"metadata": {},
"object": "charge",
"on_behalf_of": null,
"order": null,
"outcome": {
"network_status": "approved_by_network",
"reason": null,
"risk_level": "normal",
"risk_score": 00,
"seller_message": "Payment complete.",
"type": "authorized"
},
"paid": true,
"payment_intent": null,
"payment_method": "card_NORMALIZED00000000000002",
"payment_method_details": {
"card": {
"brand": "visa",
"checks": {
"address_line1_check": "pass",
"address_postal_code_check": "pass",
"cvc_check": "pass"
},
"country": "US",
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"last4": "4242",
"three_d_secure": null,
"wallet": null
},
"type": "card"
},
"receipt_email": "hamlet@zulip.com",
"receipt_number": null,
"receipt_url": "https://pay.stripe.com/receipts/acct_NORMALIZED000001/ch_NORMALIZED00000000000002/rcpt_NORMALIZED000000000000000000002",
"refunded": false,
"refunds": {},
"review": null,
"shipping": null,
"source": {
"address_city": "Pacific",
"address_country": "United States",
"address_line1": "Under the sea,",
"address_line1_check": "pass",
"address_line2": null,
"address_state": null,
"address_zip": "33333",
"address_zip_check": "pass",
"brand": "Visa",
"country": "US",
"customer": "cus_NORMALIZED0001",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"id": "card_NORMALIZED00000000000002",
"last4": "4242",
"metadata": {},
"name": "Ada Starr",
"object": "card",
"tokenization_method": null
},
"source_transfer": null,
"statement_descriptor": "Zulip Standard",
"statement_descriptor_suffix": "Zulip Standard",
"status": "succeeded",
"transfer_data": null,
"transfer_group": null
},
{
"amount": 7200,
"amount_refunded": 0,
"application": null,
"application_fee": null,
"application_fee_amount": null,
"balance_transaction": "txn_NORMALIZED00000000000001",
"billing_details": {
"address": {
"city": "Pacific",
"country": "United States",
"line1": "Under the sea,",
"line2": null,
"postal_code": "33333",
"state": null
},
"email": null,
"name": "Ada Starr",
"phone": null
},
"captured": true,
"created": 1000000000,
"currency": "usd",
"customer": "cus_NORMALIZED0001",
"description": "Upgrade to Zulip Standard, $12.0 x 6",
"destination": null,
"dispute": null,
"failure_code": null,
"failure_message": null,
"fraud_details": {},
"id": "ch_NORMALIZED00000000000001",
"invoice": null,
"livemode": false,
"metadata": {},
"object": "charge",
"on_behalf_of": null,
"order": null,
"outcome": {
"network_status": "approved_by_network",
"reason": null,
"risk_level": "normal",
"risk_score": 00,
"seller_message": "Payment complete.",
"type": "authorized"
},
"paid": true,
"payment_intent": null,
"payment_method": "card_NORMALIZED00000000000001",
"payment_method_details": {
"card": {
"brand": "visa",
"checks": {
"address_line1_check": "pass",
"address_postal_code_check": "pass",
"cvc_check": "pass"
},
"country": "US",
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"last4": "4242",
"three_d_secure": null,
"wallet": null
},
"type": "card"
},
"receipt_email": "hamlet@zulip.com",
"receipt_number": null,
"receipt_url": "https://pay.stripe.com/receipts/acct_NORMALIZED000001/ch_NORMALIZED00000000000001/rcpt_NORMALIZED000000000000000000001",
"refunded": false,
"refunds": {},
"review": null,
"shipping": null,
"source": {
"address_city": "Pacific",
"address_country": "United States",
"address_line1": "Under the sea,",
"address_line1_check": "pass",
"address_line2": null,
"address_state": null,
"address_zip": "33333",
"address_zip_check": "pass",
"brand": "Visa",
"country": "US",
"customer": "cus_NORMALIZED0001",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"id": "card_NORMALIZED00000000000001",
"last4": "4242",
"metadata": {},
"name": "Ada Starr",
"object": "card",
"tokenization_method": null
},
"source_transfer": null,
"statement_descriptor": "Zulip Standard",
"statement_descriptor_suffix": "Zulip Standard",
"status": "succeeded",
"transfer_data": null,
"transfer_group": null
}
],
"has_more": false,
"object": "list",
"url": "/v1/charges"
}

View File

@@ -1,79 +0,0 @@
{
"account_balance": 0,
"address": null,
"balance": 0,
"created": 1000000000,
"currency": null,
"default_source": "card_NORMALIZED00000000000001",
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": "hamlet@zulip.com",
"id": "cus_NORMALIZED0001",
"invoice_prefix": "NORMA01",
"invoice_settings": {
"custom_fields": null,
"default_payment_method": null,
"footer": null
},
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"name": null,
"object": "customer",
"phone": null,
"preferred_locales": [],
"shipping": null,
"sources": {
"data": [
{
"address_city": "Pacific",
"address_country": "United States",
"address_line1": "Under the sea,",
"address_line1_check": "pass",
"address_line2": null,
"address_state": null,
"address_zip": "33333",
"address_zip_check": "pass",
"brand": "Visa",
"country": "US",
"customer": "cus_NORMALIZED0001",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"id": "card_NORMALIZED00000000000001",
"last4": "4242",
"metadata": {},
"name": "Ada Starr",
"object": "card",
"tokenization_method": null
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/customers/cus_NORMALIZED0001/sources"
},
"subscriptions": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/customers/cus_NORMALIZED0001/subscriptions"
},
"tax_exempt": "none",
"tax_ids": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/customers/cus_NORMALIZED0001/tax_ids"
},
"tax_info": null,
"tax_info_verification": null
}

View File

@@ -1,103 +0,0 @@
{
"account_balance": 0,
"address": null,
"balance": 0,
"created": 1000000000,
"currency": "usd",
"default_source": {
"address_city": "Pacific",
"address_country": "United States",
"address_line1": "Under the sea,",
"address_line1_check": "pass",
"address_line2": null,
"address_state": null,
"address_zip": "33333",
"address_zip_check": "pass",
"brand": "Visa",
"country": "US",
"customer": "cus_NORMALIZED0001",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"id": "card_NORMALIZED00000000000001",
"last4": "4242",
"metadata": {},
"name": "Ada Starr",
"object": "card",
"tokenization_method": null
},
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": "hamlet@zulip.com",
"id": "cus_NORMALIZED0001",
"invoice_prefix": "NORMA01",
"invoice_settings": {
"custom_fields": null,
"default_payment_method": null,
"footer": null
},
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"name": null,
"object": "customer",
"phone": null,
"preferred_locales": [],
"shipping": null,
"sources": {
"data": [
{
"address_city": "Pacific",
"address_country": "United States",
"address_line1": "Under the sea,",
"address_line1_check": "pass",
"address_line2": null,
"address_state": null,
"address_zip": "33333",
"address_zip_check": "pass",
"brand": "Visa",
"country": "US",
"customer": "cus_NORMALIZED0001",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"id": "card_NORMALIZED00000000000001",
"last4": "4242",
"metadata": {},
"name": "Ada Starr",
"object": "card",
"tokenization_method": null
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/customers/cus_NORMALIZED0001/sources"
},
"subscriptions": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/customers/cus_NORMALIZED0001/subscriptions"
},
"tax_exempt": "none",
"tax_ids": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/customers/cus_NORMALIZED0001/tax_ids"
},
"tax_info": null,
"tax_info_verification": null
}

View File

@@ -1,79 +0,0 @@
{
"account_balance": 0,
"address": null,
"balance": 0,
"created": 1000000000,
"currency": "usd",
"default_source": "card_NORMALIZED00000000000002",
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": "hamlet@zulip.com",
"id": "cus_NORMALIZED0001",
"invoice_prefix": "NORMA01",
"invoice_settings": {
"custom_fields": null,
"default_payment_method": null,
"footer": null
},
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"name": null,
"object": "customer",
"phone": null,
"preferred_locales": [],
"shipping": null,
"sources": {
"data": [
{
"address_city": "Pacific",
"address_country": "United States",
"address_line1": "Under the sea,",
"address_line1_check": "pass",
"address_line2": null,
"address_state": null,
"address_zip": "33333",
"address_zip_check": "pass",
"brand": "Visa",
"country": "US",
"customer": "cus_NORMALIZED0001",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"id": "card_NORMALIZED00000000000002",
"last4": "4242",
"metadata": {},
"name": "Ada Starr",
"object": "card",
"tokenization_method": null
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/customers/cus_NORMALIZED0001/sources"
},
"subscriptions": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/customers/cus_NORMALIZED0001/subscriptions"
},
"tax_exempt": "none",
"tax_ids": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/customers/cus_NORMALIZED0001/tax_ids"
},
"tax_info": null,
"tax_info_verification": null
}

View File

@@ -1,117 +0,0 @@
{
"account_country": "US",
"account_name": "Dev account",
"amount_due": 0,
"amount_paid": 0,
"amount_remaining": 0,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": true,
"billing": "charge_automatically",
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED0001",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "",
"discount": null,
"due_date": null,
"ending_balance": null,
"footer": null,
"hosted_invoice_url": null,
"id": "in_NORMALIZED00000000000001",
"invoice_pdf": null,
"lines": {
"data": [
{
"amount": 7200,
"currency": "usd",
"description": "Zulip Standard",
"discountable": false,
"id": "ii_NORMALIZED00000000000001",
"invoice_item": "ii_NORMALIZED00000000000001",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 6,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
},
{
"amount": -7200,
"currency": "usd",
"description": "Payment (Card ending in 4242)",
"discountable": false,
"id": "ii_NORMALIZED00000000000002",
"invoice_item": "ii_NORMALIZED00000000000002",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 1,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
}
],
"has_more": false,
"object": "list",
"total_count": 2,
"url": "/v1/invoices/in_NORMALIZED00000000000001/lines"
},
"livemode": false,
"metadata": {},
"next_payment_attempt": 1000000000,
"number": "NORMALI-0001",
"object": "invoice",
"paid": false,
"payment_intent": null,
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Standard",
"status": "draft",
"status_transitions": {
"finalized_at": null,
"marked_uncollectible_at": null,
"paid_at": null,
"voided_at": null
},
"subscription": null,
"subtotal": 0,
"tax": null,
"tax_percent": null,
"total": 0,
"total_tax_amounts": [],
"webhooks_delivered_at": null
}

View File

@@ -1,117 +0,0 @@
{
"account_country": "US",
"account_name": "Dev account",
"amount_due": 0,
"amount_paid": 0,
"amount_remaining": 0,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": true,
"billing": "charge_automatically",
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED0001",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "",
"discount": null,
"due_date": null,
"ending_balance": null,
"footer": null,
"hosted_invoice_url": null,
"id": "in_NORMALIZED00000000000002",
"invoice_pdf": null,
"lines": {
"data": [
{
"amount": 36000,
"currency": "usd",
"description": "Zulip Standard",
"discountable": false,
"id": "ii_NORMALIZED00000000000003",
"invoice_item": "ii_NORMALIZED00000000000003",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 6,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
},
{
"amount": -36000,
"currency": "usd",
"description": "Payment (Card ending in 4242)",
"discountable": false,
"id": "ii_NORMALIZED00000000000004",
"invoice_item": "ii_NORMALIZED00000000000004",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 1,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
}
],
"has_more": false,
"object": "list",
"total_count": 2,
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
},
"livemode": false,
"metadata": {},
"next_payment_attempt": 1000000000,
"number": "NORMALI-0002",
"object": "invoice",
"paid": false,
"payment_intent": null,
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Standard",
"status": "draft",
"status_transitions": {
"finalized_at": null,
"marked_uncollectible_at": null,
"paid_at": null,
"voided_at": null
},
"subscription": null,
"subtotal": 0,
"tax": null,
"tax_percent": null,
"total": 0,
"total_tax_amounts": [],
"webhooks_delivered_at": null
}

View File

@@ -1,117 +0,0 @@
{
"account_country": "US",
"account_name": "Dev account",
"amount_due": 0,
"amount_paid": 0,
"amount_remaining": 0,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": true,
"auto_advance": false,
"billing": "charge_automatically",
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED0001",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "",
"discount": null,
"due_date": 1000000000,
"ending_balance": 0,
"footer": null,
"hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001",
"id": "in_NORMALIZED00000000000001",
"invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001/pdf",
"lines": {
"data": [
{
"amount": 7200,
"currency": "usd",
"description": "Zulip Standard",
"discountable": false,
"id": "ii_NORMALIZED00000000000001",
"invoice_item": "ii_NORMALIZED00000000000001",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 6,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
},
{
"amount": -7200,
"currency": "usd",
"description": "Payment (Card ending in 4242)",
"discountable": false,
"id": "ii_NORMALIZED00000000000002",
"invoice_item": "ii_NORMALIZED00000000000002",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 1,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
}
],
"has_more": false,
"object": "list",
"total_count": 2,
"url": "/v1/invoices/in_NORMALIZED00000000000001/lines"
},
"livemode": false,
"metadata": {},
"next_payment_attempt": null,
"number": "NORMALI-0001",
"object": "invoice",
"paid": true,
"payment_intent": null,
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Standard",
"status": "paid",
"status_transitions": {
"finalized_at": 1000000000,
"marked_uncollectible_at": null,
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subtotal": 0,
"tax": null,
"tax_percent": null,
"total": 0,
"total_tax_amounts": [],
"webhooks_delivered_at": 1000000000
}

View File

@@ -1,117 +0,0 @@
{
"account_country": "US",
"account_name": "Dev account",
"amount_due": 0,
"amount_paid": 0,
"amount_remaining": 0,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": true,
"auto_advance": false,
"billing": "charge_automatically",
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED0001",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "",
"discount": null,
"due_date": 1000000000,
"ending_balance": 0,
"footer": null,
"hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002",
"id": "in_NORMALIZED00000000000002",
"invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002/pdf",
"lines": {
"data": [
{
"amount": 36000,
"currency": "usd",
"description": "Zulip Standard",
"discountable": false,
"id": "ii_NORMALIZED00000000000003",
"invoice_item": "ii_NORMALIZED00000000000003",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 6,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
},
{
"amount": -36000,
"currency": "usd",
"description": "Payment (Card ending in 4242)",
"discountable": false,
"id": "ii_NORMALIZED00000000000004",
"invoice_item": "ii_NORMALIZED00000000000004",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 1,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
}
],
"has_more": false,
"object": "list",
"total_count": 2,
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
},
"livemode": false,
"metadata": {},
"next_payment_attempt": null,
"number": "NORMALI-0002",
"object": "invoice",
"paid": true,
"payment_intent": null,
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Standard",
"status": "paid",
"status_transitions": {
"finalized_at": 1000000000,
"marked_uncollectible_at": null,
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subtotal": 0,
"tax": null,
"tax_percent": null,
"total": 0,
"total_tax_amounts": [],
"webhooks_delivered_at": 1000000000
}

View File

@@ -1,124 +0,0 @@
{
"data": [
{
"account_country": "US",
"account_name": "Dev account",
"amount_due": 0,
"amount_paid": 0,
"amount_remaining": 0,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": true,
"auto_advance": false,
"billing": "charge_automatically",
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED0001",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "",
"discount": null,
"due_date": 1000000000,
"ending_balance": 0,
"footer": null,
"hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001",
"id": "in_NORMALIZED00000000000001",
"invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001/pdf",
"lines": {
"data": [
{
"amount": 7200,
"currency": "usd",
"description": "Zulip Standard",
"discountable": false,
"id": "ii_NORMALIZED00000000000001",
"invoice_item": "ii_NORMALIZED00000000000001",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 6,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
},
{
"amount": -7200,
"currency": "usd",
"description": "Payment (Card ending in 4242)",
"discountable": false,
"id": "ii_NORMALIZED00000000000002",
"invoice_item": "ii_NORMALIZED00000000000002",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 1,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
}
],
"has_more": false,
"object": "list",
"total_count": 2,
"url": "/v1/invoices/in_NORMALIZED00000000000001/lines"
},
"livemode": false,
"metadata": {},
"next_payment_attempt": null,
"number": "NORMALI-0001",
"object": "invoice",
"paid": true,
"payment_intent": null,
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Standard",
"status": "paid",
"status_transitions": {
"finalized_at": 1000000000,
"marked_uncollectible_at": null,
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subtotal": 0,
"tax": null,
"tax_percent": null,
"total": 0,
"total_tax_amounts": [],
"webhooks_delivered_at": 1000000000
}
],
"has_more": false,
"object": "list",
"url": "/v1/invoices"
}

View File

@@ -1,241 +0,0 @@
{
"data": [
{
"account_country": "US",
"account_name": "Dev account",
"amount_due": 0,
"amount_paid": 0,
"amount_remaining": 0,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": true,
"auto_advance": false,
"billing": "charge_automatically",
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED0001",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "",
"discount": null,
"due_date": 1000000000,
"ending_balance": 0,
"footer": null,
"hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002",
"id": "in_NORMALIZED00000000000002",
"invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002/pdf",
"lines": {
"data": [
{
"amount": 36000,
"currency": "usd",
"description": "Zulip Standard",
"discountable": false,
"id": "ii_NORMALIZED00000000000003",
"invoice_item": "ii_NORMALIZED00000000000003",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 6,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
},
{
"amount": -36000,
"currency": "usd",
"description": "Payment (Card ending in 4242)",
"discountable": false,
"id": "ii_NORMALIZED00000000000004",
"invoice_item": "ii_NORMALIZED00000000000004",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 1,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
}
],
"has_more": false,
"object": "list",
"total_count": 2,
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
},
"livemode": false,
"metadata": {},
"next_payment_attempt": null,
"number": "NORMALI-0002",
"object": "invoice",
"paid": true,
"payment_intent": null,
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Standard",
"status": "paid",
"status_transitions": {
"finalized_at": 1000000000,
"marked_uncollectible_at": null,
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subtotal": 0,
"tax": null,
"tax_percent": null,
"total": 0,
"total_tax_amounts": [],
"webhooks_delivered_at": 1000000000
},
{
"account_country": "US",
"account_name": "Dev account",
"amount_due": 0,
"amount_paid": 0,
"amount_remaining": 0,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": true,
"auto_advance": false,
"billing": "charge_automatically",
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED0001",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "",
"discount": null,
"due_date": 1000000000,
"ending_balance": 0,
"footer": null,
"hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001",
"id": "in_NORMALIZED00000000000001",
"invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001/pdf",
"lines": {
"data": [
{
"amount": 7200,
"currency": "usd",
"description": "Zulip Standard",
"discountable": false,
"id": "ii_NORMALIZED00000000000001",
"invoice_item": "ii_NORMALIZED00000000000001",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 6,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
},
{
"amount": -7200,
"currency": "usd",
"description": "Payment (Card ending in 4242)",
"discountable": false,
"id": "ii_NORMALIZED00000000000002",
"invoice_item": "ii_NORMALIZED00000000000002",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 1,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
}
],
"has_more": false,
"object": "list",
"total_count": 2,
"url": "/v1/invoices/in_NORMALIZED00000000000001/lines"
},
"livemode": false,
"metadata": {},
"next_payment_attempt": null,
"number": "NORMALI-0001",
"object": "invoice",
"paid": true,
"payment_intent": null,
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Standard",
"status": "paid",
"status_transitions": {
"finalized_at": 1000000000,
"marked_uncollectible_at": null,
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subtotal": 0,
"tax": null,
"tax_percent": null,
"total": 0,
"total_tax_amounts": [],
"webhooks_delivered_at": 1000000000
}
],
"has_more": false,
"object": "list",
"url": "/v1/invoices"
}

View File

@@ -1,23 +0,0 @@
{
"amount": -7200,
"currency": "usd",
"customer": "cus_NORMALIZED0001",
"date": 1000000000,
"description": "Payment (Card ending in 4242)",
"discountable": false,
"id": "ii_NORMALIZED00000000000002",
"invoice": null,
"livemode": false,
"metadata": {},
"object": "invoiceitem",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 1,
"subscription": null,
"tax_rates": [],
"unit_amount": -7200
}

View File

@@ -1,23 +0,0 @@
{
"amount": 7200,
"currency": "usd",
"customer": "cus_NORMALIZED0001",
"date": 1000000000,
"description": "Zulip Standard",
"discountable": false,
"id": "ii_NORMALIZED00000000000001",
"invoice": null,
"livemode": false,
"metadata": {},
"object": "invoiceitem",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 6,
"subscription": null,
"tax_rates": [],
"unit_amount": 1200
}

View File

@@ -1,23 +0,0 @@
{
"amount": -36000,
"currency": "usd",
"customer": "cus_NORMALIZED0001",
"date": 1000000000,
"description": "Payment (Card ending in 4242)",
"discountable": false,
"id": "ii_NORMALIZED00000000000004",
"invoice": null,
"livemode": false,
"metadata": {},
"object": "invoiceitem",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 1,
"subscription": null,
"tax_rates": [],
"unit_amount": -36000
}

View File

@@ -1,23 +0,0 @@
{
"amount": 36000,
"currency": "usd",
"customer": "cus_NORMALIZED0001",
"date": 1000000000,
"description": "Zulip Standard",
"discountable": false,
"id": "ii_NORMALIZED00000000000003",
"invoice": null,
"livemode": false,
"metadata": {},
"object": "invoiceitem",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 6,
"subscription": null,
"tax_rates": [],
"unit_amount": 6000
}

View File

@@ -1,33 +0,0 @@
{
"card": {
"address_city": "Pacific",
"address_country": "United States",
"address_line1": "Under the sea,",
"address_line1_check": "unchecked",
"address_line2": null,
"address_state": null,
"address_zip": "33333",
"address_zip_check": "unchecked",
"brand": "Visa",
"country": "US",
"cvc_check": "unchecked",
"dynamic_last4": null,
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"id": "card_NORMALIZED00000000000001",
"last4": "4242",
"metadata": {},
"name": "Ada Starr",
"object": "card",
"tokenization_method": null
},
"client_ip": "0.0.0.0",
"created": 1000000000,
"id": "tok_NORMALIZED00000000000001",
"livemode": false,
"object": "token",
"type": "card",
"used": false
}

View File

@@ -1,33 +0,0 @@
{
"card": {
"address_city": "Pacific",
"address_country": "United States",
"address_line1": "Under the sea,",
"address_line1_check": "unchecked",
"address_line2": null,
"address_state": null,
"address_zip": "33333",
"address_zip_check": "unchecked",
"brand": "Visa",
"country": "US",
"cvc_check": "unchecked",
"dynamic_last4": null,
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"id": "card_NORMALIZED00000000000002",
"last4": "4242",
"metadata": {},
"name": "Ada Starr",
"object": "card",
"tokenization_method": null
},
"client_ip": "0.0.0.0",
"created": 1000000000,
"id": "tok_NORMALIZED00000000000002",
"livemode": false,
"object": "token",
"type": "card",
"used": false
}

View File

@@ -1,112 +0,0 @@
{
"amount": 48000,
"amount_refunded": 0,
"application": null,
"application_fee": null,
"application_fee_amount": null,
"balance_transaction": "txn_NORMALIZED00000000000001",
"billing_details": {
"address": {
"city": "Pacific",
"country": "United States",
"line1": "Under the sea,",
"line2": null,
"postal_code": "33333",
"state": null
},
"email": null,
"name": "Ada Starr",
"phone": null
},
"captured": true,
"created": 1000000000,
"currency": "usd",
"customer": "cus_NORMALIZED0001",
"description": "Upgrade to Zulip Standard, $80.0 x 6",
"destination": null,
"dispute": null,
"failure_code": null,
"failure_message": null,
"fraud_details": {},
"id": "ch_NORMALIZED00000000000001",
"invoice": null,
"livemode": false,
"metadata": {},
"object": "charge",
"on_behalf_of": null,
"order": null,
"outcome": {
"network_status": "approved_by_network",
"reason": null,
"risk_level": "normal",
"risk_score": 00,
"seller_message": "Payment complete.",
"type": "authorized"
},
"paid": true,
"payment_intent": null,
"payment_method": "card_NORMALIZED00000000000001",
"payment_method_details": {
"card": {
"brand": "visa",
"checks": {
"address_line1_check": "pass",
"address_postal_code_check": "pass",
"cvc_check": "pass"
},
"country": "US",
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"last4": "4242",
"three_d_secure": null,
"wallet": null
},
"type": "card"
},
"receipt_email": "hamlet@zulip.com",
"receipt_number": null,
"receipt_url": "https://pay.stripe.com/receipts/acct_NORMALIZED000001/ch_NORMALIZED00000000000001/rcpt_NORMALIZED000000000000000000001",
"refunded": false,
"refunds": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/charges/ch_NORMALIZED00000000000001/refunds"
},
"review": null,
"shipping": null,
"source": {
"address_city": "Pacific",
"address_country": "United States",
"address_line1": "Under the sea,",
"address_line1_check": "pass",
"address_line2": null,
"address_state": null,
"address_zip": "33333",
"address_zip_check": "pass",
"brand": "Visa",
"country": "US",
"customer": "cus_NORMALIZED0001",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"id": "card_NORMALIZED00000000000001",
"last4": "4242",
"metadata": {},
"name": "Ada Starr",
"object": "card",
"tokenization_method": null
},
"source_transfer": null,
"statement_descriptor": "Zulip Standard",
"statement_descriptor_suffix": "Zulip Standard",
"status": "succeeded",
"transfer_data": null,
"transfer_group": null
}

View File

@@ -1,79 +0,0 @@
{
"account_balance": 0,
"address": null,
"balance": 0,
"created": 1000000000,
"currency": null,
"default_source": "card_NORMALIZED00000000000001",
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": "hamlet@zulip.com",
"id": "cus_NORMALIZED0001",
"invoice_prefix": "NORMA01",
"invoice_settings": {
"custom_fields": null,
"default_payment_method": null,
"footer": null
},
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"name": null,
"object": "customer",
"phone": null,
"preferred_locales": [],
"shipping": null,
"sources": {
"data": [
{
"address_city": "Pacific",
"address_country": "United States",
"address_line1": "Under the sea,",
"address_line1_check": "pass",
"address_line2": null,
"address_state": null,
"address_zip": "33333",
"address_zip_check": "pass",
"brand": "Visa",
"country": "US",
"customer": "cus_NORMALIZED0001",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"id": "card_NORMALIZED00000000000001",
"last4": "4242",
"metadata": {},
"name": "Ada Starr",
"object": "card",
"tokenization_method": null
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/customers/cus_NORMALIZED0001/sources"
},
"subscriptions": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/customers/cus_NORMALIZED0001/subscriptions"
},
"tax_exempt": "none",
"tax_ids": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/customers/cus_NORMALIZED0001/tax_ids"
},
"tax_info": null,
"tax_info_verification": null
}

View File

@@ -1,103 +0,0 @@
{
"account_balance": 0,
"address": null,
"balance": 0,
"created": 1000000000,
"currency": "usd",
"default_source": {
"address_city": "Pacific",
"address_country": "United States",
"address_line1": "Under the sea,",
"address_line1_check": "pass",
"address_line2": null,
"address_state": null,
"address_zip": "33333",
"address_zip_check": "pass",
"brand": "Visa",
"country": "US",
"customer": "cus_NORMALIZED0001",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"id": "card_NORMALIZED00000000000001",
"last4": "4242",
"metadata": {},
"name": "Ada Starr",
"object": "card",
"tokenization_method": null
},
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": "hamlet@zulip.com",
"id": "cus_NORMALIZED0001",
"invoice_prefix": "NORMA01",
"invoice_settings": {
"custom_fields": null,
"default_payment_method": null,
"footer": null
},
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"name": null,
"object": "customer",
"phone": null,
"preferred_locales": [],
"shipping": null,
"sources": {
"data": [
{
"address_city": "Pacific",
"address_country": "United States",
"address_line1": "Under the sea,",
"address_line1_check": "pass",
"address_line2": null,
"address_state": null,
"address_zip": "33333",
"address_zip_check": "pass",
"brand": "Visa",
"country": "US",
"customer": "cus_NORMALIZED0001",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"id": "card_NORMALIZED00000000000001",
"last4": "4242",
"metadata": {},
"name": "Ada Starr",
"object": "card",
"tokenization_method": null
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/customers/cus_NORMALIZED0001/sources"
},
"subscriptions": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/customers/cus_NORMALIZED0001/subscriptions"
},
"tax_exempt": "none",
"tax_ids": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/customers/cus_NORMALIZED0001/tax_ids"
},
"tax_info": null,
"tax_info_verification": null
}

View File

@@ -1,103 +0,0 @@
{
"account_balance": 0,
"address": null,
"balance": 0,
"created": 1000000000,
"currency": "usd",
"default_source": {
"address_city": "Pacific",
"address_country": "United States",
"address_line1": "Under the sea,",
"address_line1_check": "pass",
"address_line2": null,
"address_state": null,
"address_zip": "33333",
"address_zip_check": "pass",
"brand": "Visa",
"country": "US",
"customer": "cus_NORMALIZED0001",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"id": "card_NORMALIZED00000000000001",
"last4": "4242",
"metadata": {},
"name": "Ada Starr",
"object": "card",
"tokenization_method": null
},
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": "hamlet@zulip.com",
"id": "cus_NORMALIZED0001",
"invoice_prefix": "NORMA01",
"invoice_settings": {
"custom_fields": null,
"default_payment_method": null,
"footer": null
},
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"name": null,
"object": "customer",
"phone": null,
"preferred_locales": [],
"shipping": null,
"sources": {
"data": [
{
"address_city": "Pacific",
"address_country": "United States",
"address_line1": "Under the sea,",
"address_line1_check": "pass",
"address_line2": null,
"address_state": null,
"address_zip": "33333",
"address_zip_check": "pass",
"brand": "Visa",
"country": "US",
"customer": "cus_NORMALIZED0001",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"id": "card_NORMALIZED00000000000001",
"last4": "4242",
"metadata": {},
"name": "Ada Starr",
"object": "card",
"tokenization_method": null
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/customers/cus_NORMALIZED0001/sources"
},
"subscriptions": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/customers/cus_NORMALIZED0001/subscriptions"
},
"tax_exempt": "none",
"tax_ids": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/customers/cus_NORMALIZED0001/tax_ids"
},
"tax_info": null,
"tax_info_verification": null
}

View File

@@ -1,117 +0,0 @@
{
"account_country": "US",
"account_name": "Dev account",
"amount_due": 0,
"amount_paid": 0,
"amount_remaining": 0,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": true,
"billing": "charge_automatically",
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED0001",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "",
"discount": null,
"due_date": null,
"ending_balance": null,
"footer": null,
"hosted_invoice_url": null,
"id": "in_NORMALIZED00000000000001",
"invoice_pdf": null,
"lines": {
"data": [
{
"amount": 48000,
"currency": "usd",
"description": "Zulip Standard",
"discountable": false,
"id": "ii_NORMALIZED00000000000001",
"invoice_item": "ii_NORMALIZED00000000000001",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 6,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
},
{
"amount": -48000,
"currency": "usd",
"description": "Payment (Card ending in 4242)",
"discountable": false,
"id": "ii_NORMALIZED00000000000002",
"invoice_item": "ii_NORMALIZED00000000000002",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 1,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
}
],
"has_more": false,
"object": "list",
"total_count": 2,
"url": "/v1/invoices/in_NORMALIZED00000000000001/lines"
},
"livemode": false,
"metadata": {},
"next_payment_attempt": 1000000000,
"number": "NORMALI-0001",
"object": "invoice",
"paid": false,
"payment_intent": null,
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Standard",
"status": "draft",
"status_transitions": {
"finalized_at": null,
"marked_uncollectible_at": null,
"paid_at": null,
"voided_at": null
},
"subscription": null,
"subtotal": 0,
"tax": null,
"tax_percent": null,
"total": 0,
"total_tax_amounts": [],
"webhooks_delivered_at": null
}

View File

@@ -1,117 +0,0 @@
{
"account_country": "US",
"account_name": "Dev account",
"amount_due": 0,
"amount_paid": 0,
"amount_remaining": 0,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": true,
"auto_advance": false,
"billing": "charge_automatically",
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED0001",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "",
"discount": null,
"due_date": 1000000000,
"ending_balance": 0,
"footer": null,
"hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001",
"id": "in_NORMALIZED00000000000001",
"invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001/pdf",
"lines": {
"data": [
{
"amount": 48000,
"currency": "usd",
"description": "Zulip Standard",
"discountable": false,
"id": "ii_NORMALIZED00000000000001",
"invoice_item": "ii_NORMALIZED00000000000001",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 6,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
},
{
"amount": -48000,
"currency": "usd",
"description": "Payment (Card ending in 4242)",
"discountable": false,
"id": "ii_NORMALIZED00000000000002",
"invoice_item": "ii_NORMALIZED00000000000002",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 1,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
}
],
"has_more": false,
"object": "list",
"total_count": 2,
"url": "/v1/invoices/in_NORMALIZED00000000000001/lines"
},
"livemode": false,
"metadata": {},
"next_payment_attempt": null,
"number": "NORMALI-0001",
"object": "invoice",
"paid": true,
"payment_intent": null,
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Standard",
"status": "paid",
"status_transitions": {
"finalized_at": 1000000000,
"marked_uncollectible_at": null,
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subtotal": 0,
"tax": null,
"tax_percent": null,
"total": 0,
"total_tax_amounts": [],
"webhooks_delivered_at": 1000000000
}

View File

@@ -1,23 +0,0 @@
{
"amount": -48000,
"currency": "usd",
"customer": "cus_NORMALIZED0001",
"date": 1000000000,
"description": "Payment (Card ending in 4242)",
"discountable": false,
"id": "ii_NORMALIZED00000000000002",
"invoice": null,
"livemode": false,
"metadata": {},
"object": "invoiceitem",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 1,
"subscription": null,
"tax_rates": [],
"unit_amount": -48000
}

View File

@@ -1,23 +0,0 @@
{
"amount": 48000,
"currency": "usd",
"customer": "cus_NORMALIZED0001",
"date": 1000000000,
"description": "Zulip Standard",
"discountable": false,
"id": "ii_NORMALIZED00000000000001",
"invoice": null,
"livemode": false,
"metadata": {},
"object": "invoiceitem",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 6,
"subscription": null,
"tax_rates": [],
"unit_amount": 8000
}

View File

@@ -1,33 +0,0 @@
{
"card": {
"address_city": "Pacific",
"address_country": "United States",
"address_line1": "Under the sea,",
"address_line1_check": "unchecked",
"address_line2": null,
"address_state": null,
"address_zip": "33333",
"address_zip_check": "unchecked",
"brand": "Visa",
"country": "US",
"cvc_check": "unchecked",
"dynamic_last4": null,
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"id": "card_NORMALIZED00000000000001",
"last4": "4242",
"metadata": {},
"name": "Ada Starr",
"object": "card",
"tokenization_method": null
},
"client_ip": "0.0.0.0",
"created": 1000000000,
"id": "tok_NORMALIZED00000000000001",
"livemode": false,
"object": "token",
"type": "card",
"used": false
}

View File

@@ -1,53 +0,0 @@
{
"account_balance": 0,
"address": null,
"balance": 0,
"created": 1000000000,
"currency": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": "hamlet@zulip.com",
"id": "cus_NORMALIZED0001",
"invoice_prefix": "NORMA01",
"invoice_settings": {
"custom_fields": null,
"default_payment_method": null,
"footer": null
},
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"name": null,
"object": "customer",
"phone": null,
"preferred_locales": [],
"shipping": null,
"sources": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/customers/cus_NORMALIZED0001/sources"
},
"subscriptions": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/customers/cus_NORMALIZED0001/subscriptions"
},
"tax_exempt": "none",
"tax_ids": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/customers/cus_NORMALIZED0001/tax_ids"
},
"tax_info": null,
"tax_info_verification": null
}

View File

@@ -1,95 +0,0 @@
{
"account_country": "US",
"account_name": "Dev account",
"amount_due": 984000,
"amount_paid": 0,
"amount_remaining": 984000,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": true,
"billing": "send_invoice",
"billing_reason": "manual",
"charge": null,
"collection_method": "send_invoice",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED0001",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "",
"discount": null,
"due_date": 1000000000,
"ending_balance": null,
"footer": null,
"hosted_invoice_url": null,
"id": "in_NORMALIZED00000000000001",
"invoice_pdf": null,
"lines": {
"data": [
{
"amount": 984000,
"currency": "usd",
"description": "Zulip Standard",
"discountable": false,
"id": "ii_NORMALIZED00000000000001",
"invoice_item": "ii_NORMALIZED00000000000001",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1357095845,
"start": 1325473445
},
"plan": null,
"proration": false,
"quantity": 123,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/invoices/in_NORMALIZED00000000000001/lines"
},
"livemode": false,
"metadata": {},
"next_payment_attempt": null,
"number": "NORMALI-0001",
"object": "invoice",
"paid": false,
"payment_intent": null,
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Standard",
"status": "draft",
"status_transitions": {
"finalized_at": null,
"marked_uncollectible_at": null,
"paid_at": null,
"voided_at": null
},
"subscription": null,
"subtotal": 984000,
"tax": null,
"tax_percent": null,
"total": 984000,
"total_tax_amounts": [],
"webhooks_delivered_at": null
}

View File

@@ -1,95 +0,0 @@
{
"account_country": "US",
"account_name": "Dev account",
"amount_due": 100,
"amount_paid": 0,
"amount_remaining": 100,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": true,
"billing": "send_invoice",
"billing_reason": "manual",
"charge": null,
"collection_method": "send_invoice",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED0001",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "",
"discount": null,
"due_date": 1000000000,
"ending_balance": null,
"footer": null,
"hosted_invoice_url": null,
"id": "in_NORMALIZED00000000000002",
"invoice_pdf": null,
"lines": {
"data": [
{
"amount": 100,
"currency": "usd",
"description": "Zulip Standard - renewal",
"discountable": false,
"id": "ii_NORMALIZED00000000000002",
"invoice_item": "ii_NORMALIZED00000000000002",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1388631845,
"start": 1357095845
},
"plan": null,
"proration": false,
"quantity": 1,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
},
"livemode": false,
"metadata": {},
"next_payment_attempt": null,
"number": "NORMALI-0002",
"object": "invoice",
"paid": false,
"payment_intent": null,
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Standard",
"status": "draft",
"status_transitions": {
"finalized_at": null,
"marked_uncollectible_at": null,
"paid_at": null,
"voided_at": null
},
"subscription": null,
"subtotal": 100,
"tax": null,
"tax_percent": null,
"total": 100,
"total_tax_amounts": [],
"webhooks_delivered_at": null
}

View File

@@ -1,95 +0,0 @@
{
"account_country": "US",
"account_name": "Dev account",
"amount_due": 984000,
"amount_paid": 0,
"amount_remaining": 984000,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": true,
"billing": "send_invoice",
"billing_reason": "manual",
"charge": null,
"collection_method": "send_invoice",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED0001",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "",
"discount": null,
"due_date": 1000000000,
"ending_balance": 0,
"footer": null,
"hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001",
"id": "in_NORMALIZED00000000000001",
"invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001/pdf",
"lines": {
"data": [
{
"amount": 984000,
"currency": "usd",
"description": "Zulip Standard",
"discountable": false,
"id": "ii_NORMALIZED00000000000001",
"invoice_item": "ii_NORMALIZED00000000000001",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1357095845,
"start": 1325473445
},
"plan": null,
"proration": false,
"quantity": 123,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/invoices/in_NORMALIZED00000000000001/lines"
},
"livemode": false,
"metadata": {},
"next_payment_attempt": null,
"number": "NORMALI-0001",
"object": "invoice",
"paid": false,
"payment_intent": "pi_1F96B0Gh0CmXqmnwFFVAGxWQ",
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Standard",
"status": "open",
"status_transitions": {
"finalized_at": 1000000000,
"marked_uncollectible_at": null,
"paid_at": null,
"voided_at": null
},
"subscription": null,
"subtotal": 984000,
"tax": null,
"tax_percent": null,
"total": 984000,
"total_tax_amounts": [],
"webhooks_delivered_at": 1000000000
}

View File

@@ -1,95 +0,0 @@
{
"account_country": "US",
"account_name": "Dev account",
"amount_due": 100,
"amount_paid": 0,
"amount_remaining": 100,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": true,
"billing": "send_invoice",
"billing_reason": "manual",
"charge": null,
"collection_method": "send_invoice",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED0001",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "",
"discount": null,
"due_date": 1000000000,
"ending_balance": 0,
"footer": null,
"hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002",
"id": "in_NORMALIZED00000000000002",
"invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002/pdf",
"lines": {
"data": [
{
"amount": 100,
"currency": "usd",
"description": "Zulip Standard - renewal",
"discountable": false,
"id": "ii_NORMALIZED00000000000002",
"invoice_item": "ii_NORMALIZED00000000000002",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1388631845,
"start": 1357095845
},
"plan": null,
"proration": false,
"quantity": 1,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
},
"livemode": false,
"metadata": {},
"next_payment_attempt": null,
"number": "NORMALI-0002",
"object": "invoice",
"paid": false,
"payment_intent": "pi_1F96B2Gh0CmXqmnwZLKqLOFy",
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Standard",
"status": "open",
"status_transitions": {
"finalized_at": 1000000000,
"marked_uncollectible_at": null,
"paid_at": null,
"voided_at": null
},
"subscription": null,
"subtotal": 100,
"tax": null,
"tax_percent": null,
"total": 100,
"total_tax_amounts": [],
"webhooks_delivered_at": 1000000000
}

View File

@@ -1,197 +0,0 @@
{
"data": [
{
"account_country": "US",
"account_name": "Dev account",
"amount_due": 100,
"amount_paid": 0,
"amount_remaining": 100,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": true,
"billing": "send_invoice",
"billing_reason": "manual",
"charge": null,
"collection_method": "send_invoice",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED0001",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "",
"discount": null,
"due_date": 1000000000,
"ending_balance": 0,
"footer": null,
"hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002",
"id": "in_NORMALIZED00000000000002",
"invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002/pdf",
"lines": {
"data": [
{
"amount": 100,
"currency": "usd",
"description": "Zulip Standard - renewal",
"discountable": false,
"id": "ii_NORMALIZED00000000000002",
"invoice_item": "ii_NORMALIZED00000000000002",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1388631845,
"start": 1357095845
},
"plan": null,
"proration": false,
"quantity": 1,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
},
"livemode": false,
"metadata": {},
"next_payment_attempt": null,
"number": "NORMALI-0002",
"object": "invoice",
"paid": false,
"payment_intent": "pi_1F96B2Gh0CmXqmnwZLKqLOFy",
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Standard",
"status": "open",
"status_transitions": {
"finalized_at": 1000000000,
"marked_uncollectible_at": null,
"paid_at": null,
"voided_at": null
},
"subscription": null,
"subtotal": 100,
"tax": null,
"tax_percent": null,
"total": 100,
"total_tax_amounts": [],
"webhooks_delivered_at": 1000000000
},
{
"account_country": "US",
"account_name": "Dev account",
"amount_due": 984000,
"amount_paid": 0,
"amount_remaining": 984000,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": true,
"billing": "send_invoice",
"billing_reason": "manual",
"charge": null,
"collection_method": "send_invoice",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED0001",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "",
"discount": null,
"due_date": 1000000000,
"ending_balance": 0,
"footer": null,
"hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001",
"id": "in_NORMALIZED00000000000001",
"invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001/pdf",
"lines": {
"data": [
{
"amount": 984000,
"currency": "usd",
"description": "Zulip Standard",
"discountable": false,
"id": "ii_NORMALIZED00000000000001",
"invoice_item": "ii_NORMALIZED00000000000001",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1357095845,
"start": 1325473445
},
"plan": null,
"proration": false,
"quantity": 123,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/invoices/in_NORMALIZED00000000000001/lines"
},
"livemode": false,
"metadata": {},
"next_payment_attempt": null,
"number": "NORMALI-0001",
"object": "invoice",
"paid": false,
"payment_intent": "pi_1F96B0Gh0CmXqmnwFFVAGxWQ",
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Standard",
"status": "open",
"status_transitions": {
"finalized_at": 1000000000,
"marked_uncollectible_at": null,
"paid_at": null,
"voided_at": null
},
"subscription": null,
"subtotal": 984000,
"tax": null,
"tax_percent": null,
"total": 984000,
"total_tax_amounts": [],
"webhooks_delivered_at": 1000000000
}
],
"has_more": false,
"object": "list",
"url": "/v1/invoices"
}

View File

@@ -1,23 +0,0 @@
{
"amount": 984000,
"currency": "usd",
"customer": "cus_NORMALIZED0001",
"date": 1000000000,
"description": "Zulip Standard",
"discountable": false,
"id": "ii_NORMALIZED00000000000001",
"invoice": null,
"livemode": false,
"metadata": {},
"object": "invoiceitem",
"period": {
"end": 1357095845,
"start": 1325473445
},
"plan": null,
"proration": false,
"quantity": 123,
"subscription": null,
"tax_rates": [],
"unit_amount": 8000
}

View File

@@ -1,23 +0,0 @@
{
"amount": 100,
"currency": "usd",
"customer": "cus_NORMALIZED0001",
"date": 1000000000,
"description": "Zulip Standard - renewal",
"discountable": false,
"id": "ii_NORMALIZED00000000000002",
"invoice": null,
"livemode": false,
"metadata": {},
"object": "invoiceitem",
"period": {
"end": 1388631845,
"start": 1357095845
},
"plan": null,
"proration": false,
"quantity": 1,
"subscription": null,
"tax_rates": [],
"unit_amount": 100
}

View File

@@ -1,112 +0,0 @@
{
"amount": 48000,
"amount_refunded": 0,
"application": null,
"application_fee": null,
"application_fee_amount": null,
"balance_transaction": "txn_NORMALIZED00000000000001",
"billing_details": {
"address": {
"city": "Pacific",
"country": "United States",
"line1": "Under the sea,",
"line2": null,
"postal_code": "33333",
"state": null
},
"email": null,
"name": "Ada Starr",
"phone": null
},
"captured": true,
"created": 1000000000,
"currency": "usd",
"customer": "cus_NORMALIZED0001",
"description": "Upgrade to Zulip Standard, $80.0 x 6",
"destination": null,
"dispute": null,
"failure_code": null,
"failure_message": null,
"fraud_details": {},
"id": "ch_NORMALIZED00000000000001",
"invoice": null,
"livemode": false,
"metadata": {},
"object": "charge",
"on_behalf_of": null,
"order": null,
"outcome": {
"network_status": "approved_by_network",
"reason": null,
"risk_level": "normal",
"risk_score": 00,
"seller_message": "Payment complete.",
"type": "authorized"
},
"paid": true,
"payment_intent": null,
"payment_method": "card_NORMALIZED00000000000001",
"payment_method_details": {
"card": {
"brand": "visa",
"checks": {
"address_line1_check": "pass",
"address_postal_code_check": "pass",
"cvc_check": "pass"
},
"country": "US",
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"last4": "4242",
"three_d_secure": null,
"wallet": null
},
"type": "card"
},
"receipt_email": "hamlet@zulip.com",
"receipt_number": null,
"receipt_url": "https://pay.stripe.com/receipts/acct_NORMALIZED000001/ch_NORMALIZED00000000000001/rcpt_NORMALIZED000000000000000000001",
"refunded": false,
"refunds": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/charges/ch_NORMALIZED00000000000001/refunds"
},
"review": null,
"shipping": null,
"source": {
"address_city": "Pacific",
"address_country": "United States",
"address_line1": "Under the sea,",
"address_line1_check": "pass",
"address_line2": null,
"address_state": null,
"address_zip": "33333",
"address_zip_check": "pass",
"brand": "Visa",
"country": "US",
"customer": "cus_NORMALIZED0001",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"id": "card_NORMALIZED00000000000001",
"last4": "4242",
"metadata": {},
"name": "Ada Starr",
"object": "card",
"tokenization_method": null
},
"source_transfer": null,
"statement_descriptor": "Zulip Standard",
"statement_descriptor_suffix": "Zulip Standard",
"status": "succeeded",
"transfer_data": null,
"transfer_group": null
}

View File

@@ -1,79 +0,0 @@
{
"account_balance": 0,
"address": null,
"balance": 0,
"created": 1000000000,
"currency": null,
"default_source": "card_NORMALIZED00000000000001",
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": "hamlet@zulip.com",
"id": "cus_NORMALIZED0001",
"invoice_prefix": "NORMA01",
"invoice_settings": {
"custom_fields": null,
"default_payment_method": null,
"footer": null
},
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"name": null,
"object": "customer",
"phone": null,
"preferred_locales": [],
"shipping": null,
"sources": {
"data": [
{
"address_city": "Pacific",
"address_country": "United States",
"address_line1": "Under the sea,",
"address_line1_check": "pass",
"address_line2": null,
"address_state": null,
"address_zip": "33333",
"address_zip_check": "pass",
"brand": "Visa",
"country": "US",
"customer": "cus_NORMALIZED0001",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"id": "card_NORMALIZED00000000000001",
"last4": "4242",
"metadata": {},
"name": "Ada Starr",
"object": "card",
"tokenization_method": null
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/customers/cus_NORMALIZED0001/sources"
},
"subscriptions": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/customers/cus_NORMALIZED0001/subscriptions"
},
"tax_exempt": "none",
"tax_ids": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/customers/cus_NORMALIZED0001/tax_ids"
},
"tax_info": null,
"tax_info_verification": null
}

View File

@@ -1,117 +0,0 @@
{
"account_country": "US",
"account_name": "Dev account",
"amount_due": 0,
"amount_paid": 0,
"amount_remaining": 0,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": true,
"billing": "charge_automatically",
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED0001",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "",
"discount": null,
"due_date": null,
"ending_balance": null,
"footer": null,
"hosted_invoice_url": null,
"id": "in_NORMALIZED00000000000001",
"invoice_pdf": null,
"lines": {
"data": [
{
"amount": 48000,
"currency": "usd",
"description": "Zulip Standard",
"discountable": false,
"id": "ii_NORMALIZED00000000000001",
"invoice_item": "ii_NORMALIZED00000000000001",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1357095845,
"start": 1325473445
},
"plan": null,
"proration": false,
"quantity": 6,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
},
{
"amount": -48000,
"currency": "usd",
"description": "Payment (Card ending in 4242)",
"discountable": false,
"id": "ii_NORMALIZED00000000000002",
"invoice_item": "ii_NORMALIZED00000000000002",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 1,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
}
],
"has_more": false,
"object": "list",
"total_count": 2,
"url": "/v1/invoices/in_NORMALIZED00000000000001/lines"
},
"livemode": false,
"metadata": {},
"next_payment_attempt": 1000000000,
"number": "NORMALI-0001",
"object": "invoice",
"paid": false,
"payment_intent": null,
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Standard",
"status": "draft",
"status_transitions": {
"finalized_at": null,
"marked_uncollectible_at": null,
"paid_at": null,
"voided_at": null
},
"subscription": null,
"subtotal": 0,
"tax": null,
"tax_percent": null,
"total": 0,
"total_tax_amounts": [],
"webhooks_delivered_at": null
}

View File

@@ -1,139 +0,0 @@
{
"account_country": "US",
"account_name": "Dev account",
"amount_due": 80697,
"amount_paid": 0,
"amount_remaining": 80697,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": true,
"billing": "charge_automatically",
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED0001",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "",
"discount": null,
"due_date": null,
"ending_balance": null,
"footer": null,
"hosted_invoice_url": null,
"id": "in_NORMALIZED00000000000002",
"invoice_pdf": null,
"lines": {
"data": [
{
"amount": 7255,
"currency": "usd",
"description": "Additional license (Feb 5, 2013 - Jan 2, 2014)",
"discountable": false,
"id": "ii_NORMALIZED00000000000003",
"invoice_item": "ii_NORMALIZED00000000000003",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1388631845,
"start": 1360033445
},
"plan": null,
"proration": false,
"quantity": 1,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
},
{
"amount": 56000,
"currency": "usd",
"description": "Zulip Standard - renewal",
"discountable": false,
"id": "ii_NORMALIZED00000000000004",
"invoice_item": "ii_NORMALIZED00000000000004",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1388631845,
"start": 1357095845
},
"plan": null,
"proration": false,
"quantity": 7,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
},
{
"amount": 17442,
"currency": "usd",
"description": "Additional license (Apr 11, 2012 - Jan 2, 2013)",
"discountable": false,
"id": "ii_NORMALIZED00000000000005",
"invoice_item": "ii_NORMALIZED00000000000005",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1357095845,
"start": 1334113445
},
"plan": null,
"proration": false,
"quantity": 3,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
}
],
"has_more": false,
"object": "list",
"total_count": 3,
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
},
"livemode": false,
"metadata": {},
"next_payment_attempt": 1000000000,
"number": "NORMALI-0002",
"object": "invoice",
"paid": false,
"payment_intent": null,
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Standard",
"status": "draft",
"status_transitions": {
"finalized_at": null,
"marked_uncollectible_at": null,
"paid_at": null,
"voided_at": null
},
"subscription": null,
"subtotal": 80697,
"tax": null,
"tax_percent": null,
"total": 80697,
"total_tax_amounts": [],
"webhooks_delivered_at": null
}

View File

@@ -1,117 +0,0 @@
{
"account_country": "US",
"account_name": "Dev account",
"amount_due": 0,
"amount_paid": 0,
"amount_remaining": 0,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": true,
"auto_advance": false,
"billing": "charge_automatically",
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED0001",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "",
"discount": null,
"due_date": 1000000000,
"ending_balance": 0,
"footer": null,
"hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001",
"id": "in_NORMALIZED00000000000001",
"invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001/pdf",
"lines": {
"data": [
{
"amount": 48000,
"currency": "usd",
"description": "Zulip Standard",
"discountable": false,
"id": "ii_NORMALIZED00000000000001",
"invoice_item": "ii_NORMALIZED00000000000001",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1357095845,
"start": 1325473445
},
"plan": null,
"proration": false,
"quantity": 6,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
},
{
"amount": -48000,
"currency": "usd",
"description": "Payment (Card ending in 4242)",
"discountable": false,
"id": "ii_NORMALIZED00000000000002",
"invoice_item": "ii_NORMALIZED00000000000002",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 1,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
}
],
"has_more": false,
"object": "list",
"total_count": 2,
"url": "/v1/invoices/in_NORMALIZED00000000000001/lines"
},
"livemode": false,
"metadata": {},
"next_payment_attempt": null,
"number": "NORMALI-0001",
"object": "invoice",
"paid": true,
"payment_intent": null,
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Standard",
"status": "paid",
"status_transitions": {
"finalized_at": 1000000000,
"marked_uncollectible_at": null,
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subtotal": 0,
"tax": null,
"tax_percent": null,
"total": 0,
"total_tax_amounts": [],
"webhooks_delivered_at": 1000000000
}

View File

@@ -1,139 +0,0 @@
{
"account_country": "US",
"account_name": "Dev account",
"amount_due": 80697,
"amount_paid": 0,
"amount_remaining": 80697,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": true,
"billing": "charge_automatically",
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED0001",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "",
"discount": null,
"due_date": 1000000000,
"ending_balance": 0,
"footer": null,
"hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002",
"id": "in_NORMALIZED00000000000002",
"invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002/pdf",
"lines": {
"data": [
{
"amount": 7255,
"currency": "usd",
"description": "Additional license (Feb 5, 2013 - Jan 2, 2014)",
"discountable": false,
"id": "ii_NORMALIZED00000000000003",
"invoice_item": "ii_NORMALIZED00000000000003",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1388631845,
"start": 1360033445
},
"plan": null,
"proration": false,
"quantity": 1,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
},
{
"amount": 56000,
"currency": "usd",
"description": "Zulip Standard - renewal",
"discountable": false,
"id": "ii_NORMALIZED00000000000004",
"invoice_item": "ii_NORMALIZED00000000000004",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1388631845,
"start": 1357095845
},
"plan": null,
"proration": false,
"quantity": 7,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
},
{
"amount": 17442,
"currency": "usd",
"description": "Additional license (Apr 11, 2012 - Jan 2, 2013)",
"discountable": false,
"id": "ii_NORMALIZED00000000000005",
"invoice_item": "ii_NORMALIZED00000000000005",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1357095845,
"start": 1334113445
},
"plan": null,
"proration": false,
"quantity": 3,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
}
],
"has_more": false,
"object": "list",
"total_count": 3,
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
},
"livemode": false,
"metadata": {},
"next_payment_attempt": 1000000000,
"number": "NORMALI-0002",
"object": "invoice",
"paid": false,
"payment_intent": "pi_1F96BCGh0CmXqmnwfafGafrD",
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Standard",
"status": "open",
"status_transitions": {
"finalized_at": 1000000000,
"marked_uncollectible_at": null,
"paid_at": null,
"voided_at": null
},
"subscription": null,
"subtotal": 80697,
"tax": null,
"tax_percent": null,
"total": 80697,
"total_tax_amounts": [],
"webhooks_delivered_at": 1000000000
}

View File

@@ -1,263 +0,0 @@
{
"data": [
{
"account_country": "US",
"account_name": "Dev account",
"amount_due": 80697,
"amount_paid": 0,
"amount_remaining": 80697,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": true,
"billing": "charge_automatically",
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED0001",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "",
"discount": null,
"due_date": 1000000000,
"ending_balance": 0,
"footer": null,
"hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002",
"id": "in_NORMALIZED00000000000002",
"invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002/pdf",
"lines": {
"data": [
{
"amount": 7255,
"currency": "usd",
"description": "Additional license (Feb 5, 2013 - Jan 2, 2014)",
"discountable": false,
"id": "ii_NORMALIZED00000000000003",
"invoice_item": "ii_NORMALIZED00000000000003",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1388631845,
"start": 1360033445
},
"plan": null,
"proration": false,
"quantity": 1,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
},
{
"amount": 56000,
"currency": "usd",
"description": "Zulip Standard - renewal",
"discountable": false,
"id": "ii_NORMALIZED00000000000004",
"invoice_item": "ii_NORMALIZED00000000000004",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1388631845,
"start": 1357095845
},
"plan": null,
"proration": false,
"quantity": 7,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
},
{
"amount": 17442,
"currency": "usd",
"description": "Additional license (Apr 11, 2012 - Jan 2, 2013)",
"discountable": false,
"id": "ii_NORMALIZED00000000000005",
"invoice_item": "ii_NORMALIZED00000000000005",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1357095845,
"start": 1334113445
},
"plan": null,
"proration": false,
"quantity": 3,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
}
],
"has_more": false,
"object": "list",
"total_count": 3,
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
},
"livemode": false,
"metadata": {},
"next_payment_attempt": 1000000000,
"number": "NORMALI-0002",
"object": "invoice",
"paid": false,
"payment_intent": "pi_1F96BCGh0CmXqmnwfafGafrD",
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Standard",
"status": "open",
"status_transitions": {
"finalized_at": 1000000000,
"marked_uncollectible_at": null,
"paid_at": null,
"voided_at": null
},
"subscription": null,
"subtotal": 80697,
"tax": null,
"tax_percent": null,
"total": 80697,
"total_tax_amounts": [],
"webhooks_delivered_at": 1000000000
},
{
"account_country": "US",
"account_name": "Dev account",
"amount_due": 0,
"amount_paid": 0,
"amount_remaining": 0,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": true,
"auto_advance": false,
"billing": "charge_automatically",
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED0001",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "",
"discount": null,
"due_date": 1000000000,
"ending_balance": 0,
"footer": null,
"hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001",
"id": "in_NORMALIZED00000000000001",
"invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001/pdf",
"lines": {
"data": [
{
"amount": 48000,
"currency": "usd",
"description": "Zulip Standard",
"discountable": false,
"id": "ii_NORMALIZED00000000000001",
"invoice_item": "ii_NORMALIZED00000000000001",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1357095845,
"start": 1325473445
},
"plan": null,
"proration": false,
"quantity": 6,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
},
{
"amount": -48000,
"currency": "usd",
"description": "Payment (Card ending in 4242)",
"discountable": false,
"id": "ii_NORMALIZED00000000000002",
"invoice_item": "ii_NORMALIZED00000000000002",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1000000000,
"start": 1000000000
},
"plan": null,
"proration": false,
"quantity": 1,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem"
}
],
"has_more": false,
"object": "list",
"total_count": 2,
"url": "/v1/invoices/in_NORMALIZED00000000000001/lines"
},
"livemode": false,
"metadata": {},
"next_payment_attempt": null,
"number": "NORMALI-0001",
"object": "invoice",
"paid": true,
"payment_intent": null,
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Standard",
"status": "paid",
"status_transitions": {
"finalized_at": 1000000000,
"marked_uncollectible_at": null,
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subtotal": 0,
"tax": null,
"tax_percent": null,
"total": 0,
"total_tax_amounts": [],
"webhooks_delivered_at": 1000000000
}
],
"has_more": false,
"object": "list",
"url": "/v1/invoices"
}

Some files were not shown because too many files have changed in this diff Show More