mirror of
				https://github.com/zulip/zulip.git
				synced 2025-10-30 19:43:47 +00:00 
			
		
		
		
	Compare commits
	
		
			8 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 2e4ae9c5dc | ||
|  | 139fb8c2ee | ||
|  | 93ffaa73bd | ||
|  | 960d736e55 | ||
|  | 28a3dcf787 | ||
|  | 4eb958b6d8 | ||
|  | d35d5953c7 | ||
|  | c256c5e91c | 
| @@ -1,6 +0,0 @@ | ||||
| > 0.2% | ||||
| > 0.2% in US | ||||
| last 2 versions | ||||
| Firefox ESR | ||||
| not dead | ||||
| Chrome 26  # similar to PhantomJS | ||||
| @@ -1,151 +0,0 @@ | ||||
| # 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: | ||||
| #   https://circleci.com/docs/2.0/language-python/ | ||||
| # | ||||
| version: 2.0 | ||||
| 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: | ||||
|   "xenial-backend-frontend-python3.5": | ||||
|     docker: | ||||
|       # This is built from tools/circleci/images/xenial/Dockerfile . | ||||
|       # Xenial ships with Python 3.5. | ||||
|       - image: gregprice/circleci:xenial-python-4.test | ||||
|  | ||||
|     working_directory: ~/zulip | ||||
|  | ||||
|     steps: | ||||
|       - 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: | ||||
|           name: do Bionic hack | ||||
|           command: | | ||||
|               # Temporary hack till `sudo service redis-server start` gets fixes in Bionic. See | ||||
|               # https://chat.zulip.org/#narrow/stream/3-backend/topic/Ubuntu.20bionic.20CircleCI | ||||
|               sudo sed -i '/^bind/s/bind.*/bind 0.0.0.0/' /etc/redis/redis.conf | ||||
|  | ||||
|       - *restore_cache_package_json | ||||
|       - *restore_cache_requirements | ||||
|       - *install_dependencies | ||||
|       - *save_cache_package_json | ||||
|       - *save_cache_requirements | ||||
|       - *run_backend_tests | ||||
|  | ||||
| workflows: | ||||
|   version: 2 | ||||
|   build: | ||||
|     jobs: | ||||
|       - "xenial-backend-frontend-python3.5" | ||||
|       - "bionic-backend-python3.6" | ||||
| @@ -5,8 +5,6 @@ coverage: | ||||
|     project: | ||||
|       default: | ||||
|         target: auto | ||||
|         # Codecov has the tendency to report a lot of false negatives, | ||||
|         # so we basically suppress comments completely. | ||||
|         threshold: 50% | ||||
|         threshold: 0.03 | ||||
|         base: auto | ||||
|     patch: off | ||||
|   | ||||
| @@ -6,20 +6,14 @@ charset = utf-8 | ||||
| trim_trailing_whitespace = true | ||||
| insert_final_newline = true | ||||
|  | ||||
| [*.{sh,py,pyi,js,ts,json,yml,xml,css,md,markdown,handlebars,html}] | ||||
| [*.{sh,py,js,json,yml,xml,css,md,markdown,handlebars,html}] | ||||
| indent_style = space | ||||
| indent_size = 4 | ||||
|  | ||||
| [*.py] | ||||
| max_line_length = 110 | ||||
|  | ||||
| [*.{js,ts}] | ||||
| max_line_length = 100 | ||||
|  | ||||
| [*.{svg,rb,pp,pl}] | ||||
| indent_style = space | ||||
| indent_size = 2 | ||||
|  | ||||
| [*.cfg] | ||||
| [*.{cfg}] | ||||
| indent_style = space | ||||
| indent_size = 8 | ||||
|   | ||||
| @@ -1,10 +1,2 @@ | ||||
| # This is intended for generated files and vendored third-party files. | ||||
| # For our source code, instead of adding files here, consider using | ||||
| # specific eslint-disable comments in the files themselves. | ||||
|  | ||||
| /docs/_build | ||||
| /static/generated | ||||
| /static/third | ||||
| /static/webpack-bundles | ||||
| /var | ||||
| /zulip-py3-venv | ||||
| static/js/blueslip.js | ||||
| static/webpack-bundles | ||||
|   | ||||
							
								
								
									
										599
									
								
								.eslintrc.json
									
									
									
									
									
								
							
							
						
						
									
										599
									
								
								.eslintrc.json
									
									
									
									
									
								
							| @@ -4,228 +4,166 @@ | ||||
|         "es6": true | ||||
|     }, | ||||
|     "parserOptions": { | ||||
|         "ecmaVersion": 2019, | ||||
|         "warnOnUnsupportedTypeScriptVersion": false, | ||||
|         "sourceType": "module" | ||||
|     }, | ||||
|     "globals": { | ||||
|         "$": 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, | ||||
|         "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, | ||||
|         "katex": false, | ||||
|         "keydown_util": false, | ||||
|         "lightbox": false, | ||||
|         "list_cursor": false, | ||||
|         "list_render": false, | ||||
|         "list_util": false, | ||||
|         "loading": false, | ||||
|         "localStorage": false, | ||||
|         "local_message": false, | ||||
|         "localstorage": false, | ||||
|         "location": false, | ||||
|         "markdown": false, | ||||
|         "Spinner": false, | ||||
|         "Handlebars": false, | ||||
|         "XDate": false, | ||||
|         "zxcvbn": false, | ||||
|         "LazyLoad": false, | ||||
|         "Dropbox": false, | ||||
|         "SockJS": false, | ||||
|         "marked": 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, | ||||
|         "muting": false, | ||||
|         "muting_ui": false, | ||||
|         "narrow": false, | ||||
|         "narrow_state": false, | ||||
|         "navigate": false, | ||||
|         "night_mode": false, | ||||
|         "notifications": false, | ||||
|         "overlays": false, | ||||
|         "padded_widget": false, | ||||
|         "i18n": false, | ||||
|         "DynamicText": false, | ||||
|         "LightboxCanvas": false, | ||||
|         "bridge": false, | ||||
|         "page_params": false, | ||||
|         "panels": false, | ||||
|         "people": false, | ||||
|         "pm_conversations": false, | ||||
|         "pm_list": false, | ||||
|         "pointer": false, | ||||
|         "popovers": false, | ||||
|         "presence": false, | ||||
|         "attachments_ui": false, | ||||
|         "csrf_token": false, | ||||
|         "typeahead_helper": false, | ||||
|         "pygments_data": false, | ||||
|         "reactions": 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, | ||||
|         "popovers": false, | ||||
|         "server_events": false, | ||||
|         "server_events_dispatch": false, | ||||
|         "settings": false, | ||||
|         "settings_account": false, | ||||
|         "settings_bots": 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, | ||||
|         "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_init": false, | ||||
|         "ui_report": false, | ||||
|         "ui_util": false, | ||||
|         "unread": false, | ||||
|         "unread_ops": false, | ||||
|         "unread_ui": false, | ||||
|         "upgrade": false, | ||||
|         "upload": false, | ||||
|         "upload_widget": false, | ||||
|         "user_events": false, | ||||
|         "user_groups": false, | ||||
|         "user_pill": false, | ||||
|         "user_search": false, | ||||
|         "user_status": false, | ||||
|         "user_status_ui": false, | ||||
|         "lightbox": false, | ||||
|         "stream_color": false, | ||||
|         "people": false, | ||||
|         "navigate": false, | ||||
|         "settings_account": false, | ||||
|         "settings_display": false, | ||||
|         "settings_notifications": false, | ||||
|         "settings_muting": false, | ||||
|         "settings_lab": false, | ||||
|         "settings_bots": false, | ||||
|         "settings_sections": false, | ||||
|         "settings_emoji": false, | ||||
|         "settings_org": false, | ||||
|         "settings_users": false, | ||||
|         "settings_streams": false, | ||||
|         "settings_filters": false, | ||||
|         "settings": false, | ||||
|         "resize": false, | ||||
|         "loading": false, | ||||
|         "typing": false, | ||||
|         "typing_events": false, | ||||
|         "typing_data": false, | ||||
|         "typing_status": false, | ||||
|         "sent_messages": 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, | ||||
|         "message_list": false, | ||||
|         "Filter": false, | ||||
|         "pointer": false, | ||||
|         "util": false, | ||||
|         "poll_widget": false, | ||||
|         "widgetize": false, | ||||
|         "zcommand": false, | ||||
|         "zform": false, | ||||
|         "zxcvbn": false | ||||
|         "MessageListView": false, | ||||
|         "blueslip": false, | ||||
|         "rows": false, | ||||
|         "WinChan": false, | ||||
|         "muting_ui": false, | ||||
|         "Socket": false, | ||||
|         "channel": false, | ||||
|         "components": 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, | ||||
|         "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, | ||||
|         "user_events": false, | ||||
|         "Plotly": false, | ||||
|         "emoji_codes": false, | ||||
|         "drafts": false, | ||||
|         "katex": false, | ||||
|         "Clipboard": false, | ||||
|         "emoji_picker": false, | ||||
|         "hotspots": false, | ||||
|         "compose_ui": false, | ||||
|         "common": false, | ||||
|         "desktop_notifications_panel": false | ||||
|     }, | ||||
|     "plugins": [ | ||||
|         "eslint-plugin-empty-returns" | ||||
|     ], | ||||
|     "rules": { | ||||
|         "array-callback-return": "error", | ||||
|         "array-bracket-spacing": "error", | ||||
|         "arrow-spacing": [ "error", { "before": true, "after": true } ], | ||||
|         "block-scoped-var": "error", | ||||
|         "block-scoped-var": 2, | ||||
|         "brace-style": [ "error", "1tbs", { "allowSingleLine": true } ], | ||||
|         "camelcase": "off", | ||||
|         "camelcase": 0, | ||||
|         "comma-dangle": [ "error", | ||||
|             { | ||||
|                 "arrays": "always-multiline", | ||||
| @@ -235,34 +173,13 @@ | ||||
|                 "functions": "never" | ||||
|             } | ||||
|         ], | ||||
|         "comma-spacing": [ "error", | ||||
|             { | ||||
|                 "before": false, | ||||
|                 "after": true | ||||
|             } | ||||
|         ], | ||||
|         "complexity": [ "off", 4 ], | ||||
|         "curly": "error", | ||||
|         "complexity": [ 0, 4 ], | ||||
|         "curly": 2, | ||||
|         "dot-notation": [ "error", { "allowKeywords": true } ], | ||||
|         "empty-returns/main": "error", | ||||
|         "eol-last": [ "error", "always" ], | ||||
|         "eqeqeq": "error", | ||||
|         "eqeqeq": 2, | ||||
|         "func-style": [ "off", "expression" ], | ||||
|         "guard-for-in": "error", | ||||
|         "indent": ["error", 4, { | ||||
|             "ArrayExpression": "first", | ||||
|             "ObjectExpression": "first", | ||||
|             "SwitchCase": 0, | ||||
|             "CallExpression": {"arguments": "first"}, | ||||
|             "FunctionExpression": {"parameters": "first"}, | ||||
|             "FunctionDeclaration": {"parameters": "first"} | ||||
|         }], | ||||
|         "key-spacing": [ "error", | ||||
|             { | ||||
|                 "beforeColon": false, | ||||
|                 "afterColon": true | ||||
|             } | ||||
|         ], | ||||
|         "guard-for-in": 2, | ||||
|         "keyword-spacing": [ "error", | ||||
|             { | ||||
|                 "before": true, | ||||
| @@ -274,7 +191,7 @@ | ||||
|                 } | ||||
|             } | ||||
|         ], | ||||
|         "max-depth": [ "off", 4 ], | ||||
|         "max-depth": [ 0, 4 ], | ||||
|         "max-len": [ "error", 100, 2, | ||||
|             { | ||||
|                 "ignoreUrls": true, | ||||
| @@ -284,76 +201,75 @@ | ||||
|                 "ignoreTemplateLiterals": true | ||||
|             } | ||||
|         ], | ||||
|         "max-params": [ "off", 3 ], | ||||
|         "max-statements": [ "off", 10 ], | ||||
|         "max-params": [ 0, 3 ], | ||||
|         "max-statements": [ 0, 10 ], | ||||
|         "new-cap": [ "error", | ||||
|             { | ||||
|                 "newIsCap": true, | ||||
|                 "capIsNew": false | ||||
|             } | ||||
|         ], | ||||
|         "new-parens": "error", | ||||
|         "newline-per-chained-call": "off", | ||||
|         "no-alert": "error", | ||||
|         "new-parens": 2, | ||||
|         "newline-per-chained-call": 0, | ||||
|         "no-alert": 2, | ||||
|         "no-array-constructor": "error", | ||||
|         "no-bitwise": "error", | ||||
|         "no-caller": "error", | ||||
|         "no-bitwise": 2, | ||||
|         "no-caller": 2, | ||||
|         "no-case-declarations": "error", | ||||
|         "no-catch-shadow": "error", | ||||
|         "no-console": "off", | ||||
|         "no-catch-shadow": 2, | ||||
|         "no-console": 0, | ||||
|         "no-const-assign": "error", | ||||
|         "no-control-regex": "error", | ||||
|         "no-debugger": "error", | ||||
|         "no-delete-var": "error", | ||||
|         "no-div-regex": "error", | ||||
|         "no-control-regex": 2, | ||||
|         "no-debugger": 2, | ||||
|         "no-delete-var": 2, | ||||
|         "no-div-regex": 2, | ||||
|         "no-dupe-class-members": "error", | ||||
|         "no-dupe-keys": "error", | ||||
|         "no-dupe-keys": 2, | ||||
|         "no-duplicate-imports": "error", | ||||
|         "no-else-return": "error", | ||||
|         "no-empty": "error", | ||||
|         "no-empty-character-class": "error", | ||||
|         "no-eq-null": "error", | ||||
|         "no-eval": "error", | ||||
|         "no-ex-assign": "error", | ||||
|         "no-extra-parens": ["error", "all"], | ||||
|         "no-extra-semi": "error", | ||||
|         "no-fallthrough": "error", | ||||
|         "no-floating-decimal": "error", | ||||
|         "no-func-assign": "error", | ||||
|         "no-implied-eval": "error", | ||||
|         "no-else-return": 2, | ||||
|         "no-empty": 2, | ||||
|         "no-empty-character-class": 2, | ||||
|         "no-eq-null": 2, | ||||
|         "no-eval": 2, | ||||
|         "no-ex-assign": 2, | ||||
|         "no-extra-parens": [ "error", "functions" ], | ||||
|         "no-extra-semi": 2, | ||||
|         "no-fallthrough": 2, | ||||
|         "no-floating-decimal": 2, | ||||
|         "no-func-assign": 2, | ||||
|         "no-implied-eval": 2, | ||||
|         "no-iterator": "error", | ||||
|         "no-label-var": "error", | ||||
|         "no-labels": "error", | ||||
|         "no-loop-func": "error", | ||||
|         "no-mixed-requires": [ "off", false ], | ||||
|         "no-multi-str": "error", | ||||
|         "no-native-reassign": "error", | ||||
|         "no-nested-ternary": "off", | ||||
|         "no-label-var": 2, | ||||
|         "no-labels": 2, | ||||
|         "no-loop-func": 2, | ||||
|         "no-mixed-requires": [ 0, false ], | ||||
|         "no-multi-str": 2, | ||||
|         "no-native-reassign": 2, | ||||
|         "no-nested-ternary": 0, | ||||
|         "no-new-func": "error", | ||||
|         "no-new-object": "error", | ||||
|         "no-new-wrappers": "error", | ||||
|         "no-obj-calls": "error", | ||||
|         "no-octal": "error", | ||||
|         "no-octal-escape": "error", | ||||
|         "no-param-reassign": "off", | ||||
|         "no-plusplus": "error", | ||||
|         "no-proto": "error", | ||||
|         "no-redeclare": "error", | ||||
|         "no-regex-spaces": "error", | ||||
|         "no-restricted-syntax": "off", | ||||
|         "no-return-assign": "error", | ||||
|         "no-script-url": "error", | ||||
|         "no-self-compare": "error", | ||||
|         "no-shadow": "off", | ||||
|         "no-sync": "error", | ||||
|         "no-ternary": "off", | ||||
|         "no-trailing-spaces": "error", | ||||
|         "no-new-object": 2, | ||||
|         "no-new-wrappers": 2, | ||||
|         "no-obj-calls": 2, | ||||
|         "no-octal": 2, | ||||
|         "no-octal-escape": 2, | ||||
|         "no-param-reassign": 0, | ||||
|         "no-plusplus": 2, | ||||
|         "no-proto": 2, | ||||
|         "no-redeclare": 2, | ||||
|         "no-regex-spaces": 2, | ||||
|         "no-restricted-syntax": 0, | ||||
|         "no-return-assign": 2, | ||||
|         "no-script-url": 2, | ||||
|         "no-self-compare": 2, | ||||
|         "no-shadow": 0, | ||||
|         "no-sync": 2, | ||||
|         "no-ternary": 0, | ||||
|         "no-undef": "error", | ||||
|         "no-undef-init": "error", | ||||
|         "no-underscore-dangle": "off", | ||||
|         "no-undef-init": 2, | ||||
|         "no-underscore-dangle": 0, | ||||
|         "no-unneeded-ternary": [ "error", { "defaultAssignment": false } ], | ||||
|         "no-unreachable": "error", | ||||
|         "no-unused-expressions": "error", | ||||
|         "no-unreachable": 2, | ||||
|         "no-unused-expressions": 2, | ||||
|         "no-unused-vars": [ "error", | ||||
|             { | ||||
|                 "vars": "local", | ||||
| @@ -361,19 +277,17 @@ | ||||
|                 "varsIgnorePattern": "print_elapsed_time|check_duplicate_ids" | ||||
|             } | ||||
|         ], | ||||
|         "no-use-before-define": "error", | ||||
|         "no-use-before-define": 2, | ||||
|         "no-useless-constructor": "error", | ||||
|         // The Zulip codebase complies partially with the "no-useless-escape" | ||||
|         // rule; only regex expressions haven't been updated yet. | ||||
|         // Updated regex expressions are currently being tested in casper | ||||
|         // files and will decide about a potential future enforcement of this rule. | ||||
|         "no-useless-escape": "off", | ||||
|         "no-var": "error", | ||||
|         "space-unary-ops": "error", | ||||
|         "no-whitespace-before-property": "error", | ||||
|         "no-with": "error", | ||||
|         "no-useless-escape": 0, | ||||
|         "no-whitespace-before-property": 0, | ||||
|         "no-with": 2, | ||||
|         "one-var": [ "error", "never" ], | ||||
|         "padded-blocks": "off", | ||||
|         "padded-blocks": 0, | ||||
|         "prefer-const": [ "error", | ||||
|             { | ||||
|                 "destructuring": "any", | ||||
| @@ -387,12 +301,10 @@ | ||||
|                 "numbers": false | ||||
|             } | ||||
|         ], | ||||
|         "quotes": [ "off", "single" ], | ||||
|         "radix": "error", | ||||
|         "semi": "error", | ||||
|         "semi-spacing": ["error", {"before": false, "after": true}], | ||||
|         "sort-imports": "error", | ||||
|         "space-before-blocks": "error", | ||||
|         "quotes": [ 0, "single" ], | ||||
|         "radix": 2, | ||||
|         "semi": 2, | ||||
|         "space-before-blocks": 2, | ||||
|         "space-before-function-paren": [ "error", | ||||
|             { | ||||
|                 "anonymous": "always", | ||||
| @@ -400,97 +312,16 @@ | ||||
|                 "asyncArrow": "always" | ||||
|             } | ||||
|         ], | ||||
|         "space-in-parens": "error", | ||||
|         "space-infix-ops": "error", | ||||
|         "spaced-comment": "off", | ||||
|         "strict": "off", | ||||
|         "space-in-parens": 2, | ||||
|         "space-infix-ops": 0, | ||||
|         "spaced-comment": 0, | ||||
|         "strict": 0, | ||||
|         "template-curly-spacing": "error", | ||||
|         "unnecessary-strict": "off", | ||||
|         "use-isnan": "error", | ||||
|         "unnecessary-strict": 0, | ||||
|         "use-isnan": 2, | ||||
|         "valid-typeof": [ "error", { "requireStringLiterals": true } ], | ||||
|         "wrap-iife": [ "error", "outside", { "functionPrototypeMethods": false } ], | ||||
|         "wrap-regex": "off", | ||||
|         "yoda": "error" | ||||
|     }, | ||||
|     "overrides": [ | ||||
|         { | ||||
|             "files": [ | ||||
|                 "frontend_tests/casper_tests/*.js", | ||||
|                 "frontend_tests/casper_lib/*.js" | ||||
|             ], | ||||
|             "rules": { | ||||
|                 "no-var": "off" // PhantomJS doesn’t support let, const | ||||
|             } | ||||
|         }, | ||||
|         { | ||||
|             "files": ["**/*.ts"], | ||||
|             "parser": "@typescript-eslint/parser", | ||||
|             "parserOptions": { | ||||
|                 "project": "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", | ||||
|                 "no-useless-constructor": "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/consistent-type-assertions": "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-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": "off", | ||||
|                 "@typescript-eslint/no-misused-new": "error", | ||||
|                 "@typescript-eslint/no-namespace": "error", | ||||
|                 "@typescript-eslint/no-non-null-assertion": "off", | ||||
|                 "@typescript-eslint/no-parameter-properties": "error", | ||||
|                 "@typescript-eslint/no-require-imports": "off", | ||||
|                 "@typescript-eslint/no-this-alias": "off", | ||||
|                 "@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/triple-slash-reference": "error", | ||||
|                 "@typescript-eslint/type-annotation-spacing": "error", | ||||
|                 "@typescript-eslint/unbound-method": "off", | ||||
|                 "@typescript-eslint/unified-signatures": "error" | ||||
|             } | ||||
|         } | ||||
|     ] | ||||
|         "wrap-regex": 0, | ||||
|         "yoda": 2 | ||||
|     } | ||||
| } | ||||
|   | ||||
							
								
								
									
										2
									
								
								.gitattributes
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitattributes
									
									
									
									
										vendored
									
									
								
							| @@ -1,7 +1,6 @@ | ||||
| *   text=auto eol=lf | ||||
| *.gif binary | ||||
| *.jpg binary | ||||
| *.jpeg binary | ||||
| *.eot binary | ||||
| *.woff binary | ||||
| *.woff2 binary | ||||
| @@ -10,4 +9,3 @@ | ||||
| *.png binary | ||||
| *.otf binary | ||||
| *.tif binary | ||||
| *.ogg binary | ||||
|   | ||||
							
								
								
									
										14
									
								
								.github/pull_request_template.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								.github/pull_request_template.md
									
									
									
									
										vendored
									
									
								
							| @@ -1,14 +0,0 @@ | ||||
| <!-- What's this PR for?  (Just a link to an issue is fine.) --> | ||||
|  | ||||
|  | ||||
| **Testing Plan:** <!-- How have you tested? --> | ||||
|  | ||||
|  | ||||
| **GIFs or Screenshots:** <!-- If a UI change.  See: | ||||
|   https://zulip.readthedocs.io/en/latest/tutorials/screenshot-and-gif-software.html | ||||
|   --> | ||||
|  | ||||
|  | ||||
| <!-- Also be sure to make clear, coherent commits: | ||||
|   https://zulip.readthedocs.io/en/latest/contributing/version-control.html | ||||
|   --> | ||||
							
								
								
									
										26
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										26
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -12,34 +12,19 @@ | ||||
| #  * Subdirectories with several internal things to ignore get their own | ||||
| #    `.gitignore` files. | ||||
| # | ||||
| #  * Comments must be on their own line.  (Otherwise they don't work.) | ||||
| # | ||||
| # See `git help ignore` for details on the format. | ||||
|  | ||||
| ## Config files for the dev environment | ||||
| /zproject/dev-secrets.conf | ||||
| /tools/conf.ini | ||||
| /tools/custom_provision | ||||
| /tools/droplets/conf.ini | ||||
|  | ||||
| ## Byproducts of setting up and using the dev environment | ||||
| *.pyc | ||||
| package-lock.json | ||||
|  | ||||
| /.vagrant | ||||
| /var | ||||
|  | ||||
| /.dmypy.json | ||||
|  | ||||
| # Dockerfiles generated for CircleCI | ||||
| /tools/circleci/images | ||||
|  | ||||
| # Generated i18n data | ||||
| /locale/en | ||||
| /locale/language_options.json | ||||
| /locale/language_name_map.json | ||||
| /locale/*/mobile.json | ||||
|  | ||||
| # Static build | ||||
| *.mo | ||||
| npm-debug.log | ||||
| @@ -48,16 +33,10 @@ npm-debug.log | ||||
| /staticfiles.json | ||||
| /webpack-stats-production.json | ||||
| /yarn-error.log | ||||
| zulip-git-version | ||||
|  | ||||
| # Test / analysis tools | ||||
| .coverage | ||||
|  | ||||
| ## Files (or really symlinks) created in a prod deployment | ||||
| /zproject/prod_settings.py | ||||
| /zulip-current-venv | ||||
| /zulip-py3-venv | ||||
|  | ||||
| ## Files left by various editors and local environments | ||||
| # (Ideally these should be in everyone's respective personal gitignore files.) | ||||
| *~ | ||||
| @@ -72,11 +51,6 @@ zulip.kdev4 | ||||
| *.sublime-workspace | ||||
| .vscode/ | ||||
| *.DS_Store | ||||
| # .cache/ is generated by VSCode's test runner | ||||
| .cache/ | ||||
| .eslintcache | ||||
|  | ||||
| ## Miscellaneous | ||||
| # (Ideally this section is empty.) | ||||
| zthumbor/thumbor_local_settings.py | ||||
| .transifexrc | ||||
|   | ||||
							
								
								
									
										6
									
								
								.gitlint
									
									
									
									
									
								
							
							
						
						
									
										6
									
								
								.gitlint
									
									
									
									
									
								
							| @@ -1,10 +1,10 @@ | ||||
| [general] | ||||
| ignore=title-trailing-punctuation, body-min-length, body-is-missing, title-imperative-mood | ||||
| ignore=title-trailing-punctuation, body-min-length, body-is-missing | ||||
|  | ||||
| extra-path=tools/lib/gitlint-rules.py | ||||
|  | ||||
| [title-match-regex-allow-exception] | ||||
| regex=^(.+:\ )?[A-Z].+\.$ | ||||
| [title-match-regex] | ||||
| regex=^.+\.$ | ||||
|  | ||||
| [title-max-length] | ||||
| line-length=76 | ||||
|   | ||||
							
								
								
									
										10
									
								
								.isort.cfg
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								.isort.cfg
									
									
									
									
									
								
							| @@ -1,10 +0,0 @@ | ||||
| [settings] | ||||
| line_length = 79 | ||||
| multi_line_output = 2 | ||||
| balanced_wrapping = true | ||||
| known_third_party = django, ujson, sqlalchemy | ||||
| known_first_party = zerver, zproject, version, confirmation, zilencer, analytics, frontend_tests, scripts, corporate | ||||
| sections = FUTURE, STDLIB, THIRDPARTY, FIRSTPARTY, LOCALFOLDER | ||||
| lines_after_imports = 1 | ||||
| # See the comment related to ioloop_logging for why this is skipped. | ||||
| skip = zerver/management/commands/runtornado.py | ||||
							
								
								
									
										67
									
								
								.stylelintrc
									
									
									
									
									
								
							
							
						
						
									
										67
									
								
								.stylelintrc
									
									
									
									
									
								
							| @@ -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", | ||||
|     } | ||||
| } | ||||
							
								
								
									
										37
									
								
								.travis.yml
									
									
									
									
									
								
							
							
						
						
									
										37
									
								
								.travis.yml
									
									
									
									
									
								
							| @@ -1,10 +1,9 @@ | ||||
| # See https://zulip.readthedocs.io/en/latest/testing/continuous-integration.html for | ||||
| # See https://zulip.readthedocs.io/en/latest/travis.html for | ||||
| # high-level documentation on our Travis CI setup. | ||||
| dist: xenial | ||||
| dist: trusty | ||||
| install: | ||||
|   # Disable sometimes-broken sources.list in Travis base images | ||||
|   - sudo rm -vf /etc/apt/sources.list.d/* | ||||
|   - sudo apt-get update | ||||
|   # Disable broken riak sources.list in Travis base image 2017-10-18 | ||||
|   - rm -vf "/etc/apt/sources.list.d/*riak*" | ||||
|  | ||||
|   # Disable Travis CI's built-in NVM installation | ||||
|   - mispipe "mv ~/.nvm ~/.travis-nvm-disabled" ts | ||||
| @@ -14,7 +13,7 @@ install: | ||||
|   - mispipe "pip install codecov" ts || mispipe "pip install codecov" ts | ||||
|  | ||||
|   # 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 | ||||
|   # becoming huge. | ||||
| @@ -25,7 +24,7 @@ script: | ||||
|   # broken running their system puppet with Ruby.  See | ||||
|   # https://travis-ci.org/zulip/zulip/jobs/240120991 for an example traceback. | ||||
|   - unset GEM_PATH | ||||
|   - mispipe "./tools/ci/$TEST_SUITE" ts | ||||
|   - mispipe "./tools/travis/$TEST_SUITE" ts | ||||
| cache: | ||||
|   yarn: true | ||||
|   apt: false | ||||
| @@ -34,22 +33,26 @@ cache: | ||||
|     - $HOME/zulip-npm-cache | ||||
|     - $HOME/zulip-emoji-cache | ||||
|     - $HOME/node | ||||
|     - $HOME/misc | ||||
| env: | ||||
|   global: | ||||
|     - BOTO_CONFIG=/nonexistent | ||||
|     - BOTO_CONFIG=/tmp/nowhere | ||||
| language: python | ||||
| # Our test suites generally run on Python 3.5, the version in | ||||
| # Ubuntu 16.04 xenial, which is the oldest OS release we support. | ||||
| # Our test suites generally run on Python 3.4, the version in | ||||
| # Ubuntu 14.04 trusty, which is the oldest OS release we support. | ||||
| matrix: | ||||
|   include: | ||||
|     # 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. | ||||
|     # We only get 4 jobs running at a time, so we try to make the first few | ||||
|     # the most likely to break. | ||||
|     - python: "3.5" | ||||
|     - python: "3.4" | ||||
|       env: TEST_SUITE=frontend | ||||
|     - python: "3.4" | ||||
|       env: TEST_SUITE=backend | ||||
|     - python: "3.4" | ||||
|       env: TEST_SUITE=production | ||||
|     # Other suites moved to CircleCI -- see .circleci/. | ||||
|     - python: "3.5" | ||||
|       env: TEST_SUITE=backend | ||||
| sudo: required | ||||
| addons: | ||||
|   artifacts: | ||||
| @@ -58,9 +61,15 @@ addons: | ||||
|       # debugging test flakes. | ||||
|       - $(ls var/casper/* | tr "\n" ":") | ||||
|       - $(ls /tmp/zulip-test-event-log/* | tr "\n" ":") | ||||
|   postgresql: "9.5" | ||||
|   postgresql: "9.3" | ||||
|   apt: | ||||
|     packages: | ||||
|       - moreutils | ||||
| after_success: | ||||
|   - codecov | ||||
| notifications: | ||||
|   webhooks: | ||||
|     urls: | ||||
|       - https://zulip.org/zulipbot/travis | ||||
|     on_success: always | ||||
|     on_failure: always | ||||
|   | ||||
							
								
								
									
										26
									
								
								.tx/config
									
									
									
									
									
								
							
							
						
						
									
										26
									
								
								.tx/config
									
									
									
									
									
								
							| @@ -3,31 +3,13 @@ host = https://www.transifex.com | ||||
| lang_map = zh-Hans: zh_Hans, zh-Hant: zh_Hant | ||||
|  | ||||
| [zulip.djangopo] | ||||
| file_filter = locale/<lang>/LC_MESSAGES/django.po | ||||
| source_file = locale/en/LC_MESSAGES/django.po | ||||
| source_file = static/locale/en/LC_MESSAGES/django.po | ||||
| source_lang = en | ||||
| type = PO | ||||
| file_filter = static/locale/<lang>/LC_MESSAGES/django.po | ||||
|  | ||||
| [zulip.translationsjson] | ||||
| file_filter = locale/<lang>/translations.json | ||||
| source_file = locale/en/translations.json | ||||
| source_lang = en | ||||
| type = KEYVALUEJSON | ||||
|  | ||||
| [zulip.mobile] | ||||
| file_filter = locale/<lang>/mobile.json | ||||
| source_file = locale/en/mobile.json | ||||
| source_lang = en | ||||
| type = KEYVALUEJSON | ||||
|  | ||||
| [zulip-test.djangopo] | ||||
| file_filter = locale/<lang>/LC_MESSAGES/django.po | ||||
| source_file = locale/en/LC_MESSAGES/django.po | ||||
| source_lang = en | ||||
| type = PO | ||||
|  | ||||
| [zulip-test.translationsjson] | ||||
| file_filter = locale/<lang>/translations.json | ||||
| source_file = locale/en/translations.json | ||||
| source_file = static/locale/en/translations.json | ||||
| source_lang = en | ||||
| type = KEYVALUEJSON | ||||
| file_filter = static/locale/<lang>/translations.json | ||||
|   | ||||
| @@ -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 | ||||
| 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 | ||||
| victims and reporters will remain confidential unless those individuals | ||||
| instruct us otherwise. | ||||
|   | ||||
							
								
								
									
										351
									
								
								CONTRIBUTING.md
									
									
									
									
									
								
							
							
						
						
									
										351
									
								
								CONTRIBUTING.md
									
									
									
									
									
								
							| @@ -1,351 +0,0 @@ | ||||
| # Contributing to Zulip | ||||
|  | ||||
| Welcome to the Zulip community! | ||||
|  | ||||
| ## Community | ||||
|  | ||||
| The | ||||
| [Zulip community server](https://zulip.readthedocs.io/en/latest/contributing/chat-zulip-org.html) | ||||
| is the primary communication forum for the Zulip community. It is a good | ||||
| place to start whether you have a question, are a new contributor, are a new | ||||
| user, or anything else. Make sure to read the | ||||
| [community norms](https://zulip.readthedocs.io/en/latest/contributing/chat-zulip-org.html#community-norms) | ||||
| before posting. The Zulip community is also governed by a | ||||
| [code of conduct](https://zulip.readthedocs.io/en/latest/code-of-conduct.html). | ||||
|  | ||||
| You can subscribe to zulip-devel-announce@googlegroups.com or our | ||||
| [Twitter](https://twitter.com/zulip) account for a lower traffic (~1 | ||||
| email/month) way to hear about things like mentorship opportunities with Google | ||||
| Code-in, in-person sprints at conferences, and other opportunities to | ||||
| contribute. | ||||
|  | ||||
| ## Ways to contribute | ||||
|  | ||||
| To make a code or documentation contribution, read our | ||||
| [step-by-step guide](#your-first-codebase-contribution) to getting | ||||
| started with the Zulip codebase. A small sample of the type of work that | ||||
| needs doing: | ||||
| * Bug squashing and feature development on our Python/Django | ||||
|   [backend](https://github.com/zulip/zulip), web | ||||
|   [frontend](https://github.com/zulip/zulip), React Native | ||||
|   [mobile app](https://github.com/zulip/zulip-mobile), or Electron | ||||
|   [desktop app](https://github.com/zulip/zulip-desktop). | ||||
| * Building out our | ||||
|   [Python API and bots](https://github.com/zulip/python-zulip-api) framework. | ||||
| * [Writing an integration](https://zulipchat.com/api/integrations-overview). | ||||
| * Improving our [user](https://zulipchat.com/help/) or | ||||
|   [developer](https://zulip.readthedocs.io/en/latest/) documentation. | ||||
| * [Reviewing code](https://zulip.readthedocs.io/en/latest/contributing/code-reviewing.html) | ||||
|   and manually testing pull requests. | ||||
|  | ||||
| **Non-code contributions**: Some of the most valuable ways to contribute | ||||
| don't require touching the codebase at all. We list a few of them below: | ||||
|  | ||||
| * [Reporting issues](#reporting-issues), including both feature requests and | ||||
|   bug reports. | ||||
| * [Giving feedback](#user-feedback) if you are evaluating or using Zulip. | ||||
| * [Translating](https://zulip.readthedocs.io/en/latest/translating/translating.html) | ||||
|   Zulip. | ||||
| * [Outreach](#zulip-outreach): Star us on GitHub, upvote us | ||||
|   on product comparison sites, or write for [the Zulip blog](http://blog.zulip.org/). | ||||
|  | ||||
| ## Your first (codebase) contribution | ||||
|  | ||||
| This section has a step by step guide to starting as a Zulip codebase | ||||
| contributor. It's long, but don't worry about doing all the steps perfectly; | ||||
| no one gets it right the first time, and there are a lot of people available | ||||
| to help. | ||||
| * First, make an account on the | ||||
|   [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 | ||||
|   yourself in | ||||
|   [#new members](https://chat.zulip.org/#narrow/stream/95-new-members), using | ||||
|   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 | ||||
|   product. | ||||
| * 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), | ||||
|   getting help in | ||||
|   [#development help](https://chat.zulip.org/#narrow/stream/49-development-help) | ||||
|   if you run into any troubles. | ||||
| * Read the | ||||
|   [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 | ||||
|   Git, getting help in | ||||
|   [#git help](https://chat.zulip.org/#narrow/stream/44-git-help) if | ||||
|   you run into any troubles.  Be sure to check out the | ||||
|   [extremely useful Zulip-specific tools page](https://zulip.readthedocs.io/en/latest/git/zulip-tools.html). | ||||
| * Sign the | ||||
|   [Dropbox Contributor License Agreement](https://opensource.dropbox.com/cla/). | ||||
|  | ||||
| ### Picking an issue | ||||
|  | ||||
| Now, you're ready to pick your first issue! There are hundreds of open issues | ||||
| in the main codebase alone. This section will help you find an issue to work | ||||
| on. | ||||
|  | ||||
| * If you're interested in | ||||
|   [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), | ||||
|   or | ||||
|   [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 | ||||
|   [#mobile](https://chat.zulip.org/#narrow/stream/48-mobile), | ||||
|   [#desktop](https://chat.zulip.org/#narrow/stream/16-desktop), or | ||||
|   [#integration](https://chat.zulip.org/#narrow/stream/127-integrations). | ||||
| * For the main server and web repository, we recommend browsing | ||||
|   recently opened issues to look for issues you are confident you can | ||||
|   fix correctly in a way that clearly communicates why your changes | ||||
|   are the correct fix.  Our GitHub workflow bot, zulipbot, limits | ||||
|   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 | ||||
|   admin, compose, emoji, hotkeys, i18n, onboarding, search, etc. Look | ||||
|   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 | ||||
|   areas of interest. | ||||
| * If the lists of issues are overwhelming, post in | ||||
|   [#new members](https://chat.zulip.org/#narrow/stream/95-new-members) with a | ||||
|   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), | ||||
|   frontend (JavaScript and TypeScript), mobile (React Native), desktop (Electron), | ||||
|   documentation (English) or visual design (JavaScript/TypeScript + CSS) issue, and a | ||||
|   bit about your programming experience and available time. | ||||
|  | ||||
| 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 | ||||
| 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 | ||||
| feature idea and the problem that you're hoping to solve. | ||||
|  | ||||
| Other notes: | ||||
| * 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 | ||||
|   changes (not counting changes to tests). | ||||
| * The full list of issues explicitly looking for a contributor can be | ||||
|   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) | ||||
|   labels.  Avoid issues with the "difficult" label unless you | ||||
|   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 | ||||
|   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. | ||||
|  | ||||
| ### Working on an issue | ||||
|  | ||||
| To work on an issue, claim it by adding a comment with `@zulipbot claim` to | ||||
| 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 | ||||
| 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 | ||||
|   changes -- the Zulip maintainers are excited to answer questions to help | ||||
|   you stay unblocked and working efficiently. You can ask questions on | ||||
|   chat.zulip.org, or on the GitHub issue or pull request. | ||||
| * We encourage early pull requests for work in progress. Prefix the title of | ||||
|   work in progress pull requests with `[WIP]`, and remove the prefix when | ||||
|   you think it might be mergeable and want it to be reviewed. | ||||
| * After updating a PR, add a comment to the GitHub thread mentioning that it | ||||
|   is ready for another review. GitHub only notifies maintainers of the | ||||
|   changes when you post a comment, so if you don't, your PR will likely be | ||||
|   neglected by accident! | ||||
|  | ||||
| ### And beyond | ||||
|  | ||||
| A great place to look for a second issue is to look for issues with the same | ||||
| `area:` label as the last issue you resolved. You'll be able to reuse the | ||||
| work you did learning how that part of the codebase works. Also, the path to | ||||
| becoming a core developer often involves taking ownership of one of these area | ||||
| labels. | ||||
|  | ||||
| ## What makes a great Zulip contributor? | ||||
|  | ||||
| Zulip runs a lot of [internship programs](#internship-programs), so we have | ||||
| a lot of experience with new contributors. In our experience, these are the | ||||
| best predictors of success: | ||||
|  | ||||
| * Posting good questions. This generally means explaining your current | ||||
|   understanding, saying what you've done or tried so far, and including | ||||
|   tracebacks or other error messages if appropriate. | ||||
| * Learning and practicing | ||||
|   [Git commit discipline](https://zulip.readthedocs.io/en/latest/contributing/version-control.html#commit-discipline). | ||||
| * Submitting carefully tested code. This generally means checking your work | ||||
|   through a combination of automated tests and manually clicking around the | ||||
|   UI trying to find bugs in your work. See | ||||
|   [things to look for](https://zulip.readthedocs.io/en/latest/contributing/code-reviewing.html#things-to-look-for) | ||||
|   for additional ideas. | ||||
| * Posting | ||||
|   [screenshots or GIFs](https://zulip.readthedocs.io/en/latest/tutorials/screenshot-and-gif-software.html) | ||||
|   for frontend changes. | ||||
| * Being responsive to feedback on pull requests. This means incorporating or | ||||
|   responding to all suggested changes, and leaving a note if you won't be | ||||
|   able to address things within a few days. | ||||
| * Being helpful and friendly on chat.zulip.org. | ||||
|  | ||||
| These are also the main criteria we use to select interns for all of our | ||||
| internship programs. | ||||
|  | ||||
| ## Reporting issues | ||||
|  | ||||
| If you find an easily reproducible bug and/or are experienced in reporting | ||||
| 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 | ||||
| is, the best place to post issues is | ||||
| [#issues](https://chat.zulip.org/#narrow/stream/9-issues) (or | ||||
| [#mobile](https://chat.zulip.org/#narrow/stream/48-mobile) or | ||||
| [#desktop](https://chat.zulip.org/#narrow/stream/16-desktop)) on the | ||||
| [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 | ||||
| a similar issue has already been opened, and collect any other information | ||||
| we need. Choose a 2-4 word topic that describes the issue, explain the issue | ||||
| and how to reproduce it if known, your browser/OS if relevant, and a | ||||
| [screenshot or screenGIF](https://zulip.readthedocs.io/en/latest/tutorials/screenshot-and-gif-software.html) | ||||
| if appropriate. | ||||
|  | ||||
| **Reporting security issues**. Please do not report security issues | ||||
|   publicly, including on public streams on chat.zulip.org. You can email | ||||
|   zulip-security@googlegroups.com. We create a CVE for every security issue. | ||||
|  | ||||
| ## User feedback | ||||
|  | ||||
| Nearly every feature we develop starts with a user request. If you are part | ||||
| of a group that is either using or considering using Zulip, we would love to | ||||
| hear about your experience with the product. If you're not sure what to | ||||
| write, here are some questions we're always very curious to know the answer | ||||
| to: | ||||
|  | ||||
| * Evaluation: What is the process by which your organization chose or will | ||||
|   choose a group chat product? | ||||
| * Pros and cons: What are the pros and cons of Zulip for your organization, | ||||
|   and the pros and cons of other products you are evaluating? | ||||
| * Features: What are the features that are most important for your | ||||
|   organization? In the best case scenario, what would your chat solution do | ||||
|   for you? | ||||
| * Onboarding: If you remember it, what was your impression during your first | ||||
|   few minutes of using Zulip? What did you notice, and how did you feel? Was | ||||
|   there anything that stood out to you as confusing, or broken, or great? | ||||
| * Organization: What does your organization do? How big is the organization? | ||||
|   A link to your organization's website? | ||||
|  | ||||
| ## Internship programs | ||||
|  | ||||
| Zulip runs internship programs with | ||||
| [Outreachy](https://www.outreachy.org/), | ||||
| [Google Summer of Code (GSoC)](https://developers.google.com/open-source/gsoc/) | ||||
| [1], and the | ||||
| [MIT Externship program](https://alum.mit.edu/students/NetworkwithAlumni/ExternshipProgram), | ||||
| and has in the past taken summer interns from Harvard, MIT, and | ||||
| Stanford. | ||||
|  | ||||
| While each third-party program has its own rules and requirements, the | ||||
| Zulip community's approaches all of these programs with these ideas in | ||||
| mind: | ||||
| * We try to make the application process as valuable for the applicant as | ||||
|   possible. Expect high quality code reviews, a supportive community, and | ||||
|   publicly viewable patches you can link to from your resume, regardless of | ||||
|   whether you are selected. | ||||
| * To apply, you'll have to submit at least one pull request to a Zulip | ||||
|   repository.  Most students accepted to one of our programs have | ||||
|   several merged pull requests (including at least one larger PR) by | ||||
|   the time of the application deadline. | ||||
| * The main criteria we use is quality of your best contributions, and | ||||
|   the bullets listed at | ||||
|   [What makes a great Zulip contributor](#what-makes-a-great-zulip-contributor). | ||||
|   Because we focus on evaluating your best work, it doesn't hurt your | ||||
|   application to makes mistakes in your first few PRs as long as your | ||||
|   work improves. | ||||
|  | ||||
| Zulip also participates in | ||||
| [Google Code-In](https://developers.google.com/open-source/gci/). Our | ||||
| selection criteria for Finalists and Grand Prize Winners is the same as our | ||||
| selection criteria for interns above. | ||||
|  | ||||
| Most of our interns end up sticking around the project long-term, and many | ||||
| quickly become core team members. We hope you apply! | ||||
|  | ||||
| ### Google Summer of Code | ||||
|  | ||||
| GSoC is by far the largest of our internship programs (14 students in | ||||
| 2017; 11 in 2018; 17 in 2019).  While we don't control how many slots | ||||
| Google allocates to Zulip, we hope to mentor a similar number of | ||||
| students in future summers. | ||||
|  | ||||
| If you're reading this well before the application deadline and want | ||||
| to make your application strong, we recommend getting involved in the | ||||
| community and fixing issues in Zulip now. Having good contributions | ||||
| and building a reputation for doing good work is best way to have a | ||||
| strong application.  About half of Zulip's GSoC students for Summer | ||||
| 2017 had made significant contributions to the project by February | ||||
| 2017, and about half had not.  Our | ||||
| [GSoC project ideas page][gsoc-guide] has lots more details on how | ||||
| Zulip does GSoC, as well as project ideas (though the project idea | ||||
| list is maintained only during the GSoC application period, so if | ||||
| you're looking at some other time of year, the project list is likely | ||||
| out-of-date). | ||||
|  | ||||
| We also have in some past years run a Zulip Summer of Code (ZSoC) | ||||
| program for students who we didn't have enough slots to accept for | ||||
| GSoC but were able to find funding for.  Student expectations are the | ||||
| same as with GSoC, and it has no separate application process; your | ||||
| GSoC application is your ZSoC application.  If we'd like to select you | ||||
| for ZSoC, we'll contact you when the GSoC results are announced. | ||||
|  | ||||
| [gsoc-guide]: https://zulip.readthedocs.io/en/latest/overview/gsoc-ideas.html | ||||
| [gsoc-faq]: https://developers.google.com/open-source/gsoc/faq | ||||
|  | ||||
| [1] Formally, [GSoC isn't an internship][gsoc-faq], but it is similar | ||||
| enough that we're treating it as such for the purposes of this | ||||
| documentation. | ||||
|  | ||||
| ## Zulip Outreach | ||||
|  | ||||
| **Upvoting Zulip**. Upvotes and reviews make a big difference in the public | ||||
| perception of projects like Zulip. We've collected a few sites below | ||||
| where we know Zulip has been discussed. Doing everything in the following | ||||
| list typically takes about 15 minutes. | ||||
| * Star us on GitHub. There are four main repositories: | ||||
|   [server/web](https://github.com/zulip/zulip), | ||||
|   [mobile](https://github.com/zulip/zulip-mobile), | ||||
|   [desktop](https://github.com/zulip/zulip-desktop), and | ||||
|   [Python API](https://github.com/zulip/python-zulip-api). | ||||
| * [Follow us](https://twitter.com/zulip) on Twitter. | ||||
|  | ||||
| For both of the following, you'll need to make an account on the site if you | ||||
| don't already have one. | ||||
|  | ||||
| * [Like Zulip](https://alternativeto.net/software/zulip-chat-server/) on | ||||
|   AlternativeTo. We recommend upvoting a couple of other products you like | ||||
|   as well, both to give back to their community, and since single-upvote | ||||
|   accounts are generally given less weight. You can also | ||||
|   [upvote Zulip](https://alternativeto.net/software/slack/) on their page | ||||
|   for Slack. | ||||
| * [Add Zulip to your stack](https://stackshare.io/zulip) on StackShare, star | ||||
|   it, and upvote the reasons why people like Zulip that you find most | ||||
|   compelling. Again, we recommend adding a few other products that you like | ||||
|   as well. | ||||
|  | ||||
| We have a doc with more detailed instructions and a few other sites, if you | ||||
| have been using Zulip for a while and want to contribute more. | ||||
|  | ||||
| **Blog posts**. Writing a blog post about your experiences with Zulip, or | ||||
| about a technical aspect of Zulip can be a great way to spread the word | ||||
| about Zulip. | ||||
|  | ||||
| We also occasionally [publish](http://blog.zulip.org/) longer form | ||||
| articles related to Zulip. Our posts typically get tens of thousands | ||||
| of views, and we always have good ideas for blog posts that we can | ||||
| outline but don't have time to write. If you are an experienced writer | ||||
| or copyeditor, send us a portfolio; we'd love to talk! | ||||
							
								
								
									
										17
									
								
								Dockerfile-dev
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								Dockerfile-dev
									
									
									
									
									
										Normal 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 | ||||
| @@ -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/* | ||||
							
								
								
									
										1
									
								
								LICENSE
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								LICENSE
									
									
									
									
									
								
							| @@ -1,4 +1,3 @@ | ||||
| Copyright 2011-2018 Dropbox, Inc., Kandra Labs, Inc., and contributors | ||||
|  | ||||
|                                  Apache License | ||||
|                            Version 2.0, January 2004 | ||||
|   | ||||
							
								
								
									
										16
									
								
								NOTICE
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								NOTICE
									
									
									
									
									
								
							| @@ -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. | ||||
							
								
								
									
										349
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										349
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,82 +1,301 @@ | ||||
| **[Zulip overview](#zulip-overview)** | | ||||
| **[Community](#community)** | | ||||
| **[Installing for dev](#installing-the-zulip-development-environment)** | | ||||
| **[Installing for production](#running-zulip-in-production)** | | ||||
| **[Ways to contribute](#ways-to-contribute)** | | ||||
| **[How to get involved](#how-to-get-involved-with-contributing-to-zulip)** | | ||||
| **[License](#license)** | ||||
|  | ||||
| # Zulip overview | ||||
|  | ||||
| Zulip is a powerful, open source group chat application that combines the | ||||
| immediacy of real-time chat with the productivity benefits of threaded | ||||
| conversations. Zulip is used by open source projects, Fortune 500 companies, | ||||
| 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 | ||||
| over 500 contributors merging over 500 commits a month, Zulip is also the | ||||
| largest and fastest growing open source group chat project. | ||||
| Zulip is a powerful, open source group chat application. Written in | ||||
| Python and using the Django framework, Zulip supports both private | ||||
| messaging and group chats via conversation streams. | ||||
|  | ||||
| [](https://circleci.com/gh/zulip/zulip/tree/master) | ||||
| [](https://codecov.io/gh/zulip/zulip/branch/master) | ||||
| [][mypy-coverage] | ||||
| [](https://github.com/zulip/zulip/releases/latest) | ||||
| [](https://zulip.readthedocs.io/en/latest/) | ||||
| Zulip also supports fast search, drag-and-drop file uploads, image | ||||
| previews, group private messages, audible notifications, | ||||
| missed-message emails, desktop apps, and much more. | ||||
|  | ||||
| Further information on the Zulip project and its features can be found | ||||
| at <https://www.zulip.org>. | ||||
|  | ||||
| [](https://travis-ci.org/zulip/zulip) | ||||
| [](https://codecov.io/gh/zulip/zulip) | ||||
| [](http://blog.zulip.org/2016/10/13/static-types-in-python-oh-mypy/) | ||||
| [](http://zulip.readthedocs.io/en/latest/) | ||||
| [](https://chat.zulip.org) | ||||
| [](https://twitter.com/zulip) | ||||
| [](http://twitter.com/zulip) | ||||
|  | ||||
| [mypy-coverage]: https://blog.zulip.org/2016/10/13/static-types-in-python-oh-mypy/ | ||||
| ## Community | ||||
|  | ||||
| ## Getting started | ||||
| There are several places online where folks discuss Zulip. | ||||
|  | ||||
| Click on the appropriate link below. If nothing seems to apply, | ||||
| join us on the | ||||
| [Zulip community server](https://zulip.readthedocs.io/en/latest/contributing/chat-zulip-org.html) | ||||
| and tell us what's up! | ||||
| * The primary place is the | ||||
|   [Zulip development community Zulip server][czo-doc] at | ||||
|   chat.zulip.org. | ||||
|  | ||||
| You might be interested in: | ||||
| * For Google Summer of Code students and applicants, we have | ||||
| [a mailing list](https://groups.google.com/forum/#!forum/zulip-gsoc) | ||||
| for help, questions, and announcements.  But it's often simpler to | ||||
| [visit chat.zulip.org][czo-doc] instead. | ||||
|  | ||||
| * **Contributing code**. Check out our | ||||
|   [guide for new contributors](https://zulip.readthedocs.io/en/latest/overview/contributing.html) | ||||
|   to get started. Zulip prides itself on maintaining a clean and | ||||
|   well-tested codebase, and a stock of hundreds of | ||||
|   [beginner-friendly issues][beginner-friendly]. | ||||
| * We have a [public development discussion mailing list][zulip-devel], | ||||
| zulip-devel, which is currently pretty low traffic because most | ||||
| discussions happen in our public Zulip instance.  We use it to | ||||
| announce Zulip developer community gatherings and ask for feedback on | ||||
| major technical or design decisions.  It has several hundred | ||||
| subscribers, so you can use it to ask questions about features or | ||||
| possible bugs, but please don't use it ask for generic help getting | ||||
| started as a contributor (e.g. because you want to do Google Summer of | ||||
| Code).  The rest of this page covers how to get involved in the Zulip | ||||
| project in detail. | ||||
|  | ||||
| * **Contributing non-code**. | ||||
|   [Report an issue](https://zulip.readthedocs.io/en/latest/overview/contributing.html#reporting-issue), | ||||
|   [translate](https://zulip.readthedocs.io/en/latest/translating/translating.html) Zulip | ||||
|   into your language, | ||||
|   [write](https://zulip.readthedocs.io/en/latest/overview/contributing.html#zulip-outreach) | ||||
|   for the Zulip blog, or | ||||
|   [give us feedback](https://zulip.readthedocs.io/en/latest/overview/contributing.html#user-feedback). We | ||||
|   would love to hear from you, even if you're just trying the product out. | ||||
| * Zulip also has a [blog](https://blog.zulip.org/) and | ||||
|   [twitter account](https://twitter.com/zulip). | ||||
|  | ||||
| * **Supporting Zulip**. Advocate for your organization to use Zulip, write a | ||||
|   review in the mobile app stores, or | ||||
|   [upvote Zulip](https://zulip.readthedocs.io/en/latest/overview/contributing.html#zulip-outreach) on | ||||
|   product comparison sites. | ||||
| * Last but not least, we use [GitHub](https://github.com/zulip/zulip) | ||||
| to track Zulip-related issues (and store our code, of course). | ||||
| Anybody with a GitHub account should be able to create Issues there | ||||
| pertaining to bugs or enhancement requests.  We also use Pull Requests | ||||
| as our primary mechanism to receive code contributions. | ||||
|  | ||||
| * **Checking Zulip out**. The best way to see Zulip in action is to drop by | ||||
|   the | ||||
|   [Zulip community server](https://zulip.readthedocs.io/en/latest/contributing/chat-zulip-org.html). We | ||||
|   also recommend reading Zulip for | ||||
|   [open source](https://zulipchat.com/for/open-source/), 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/). | ||||
| The Zulip community has a [Code of Conduct][code-of-conduct]. | ||||
|  | ||||
| * **Running a Zulip server**. Use a preconfigured [Digital Ocean droplet](https://marketplace.digitalocean.com/apps/zulip), | ||||
|   [install Zulip](https://zulip.readthedocs.io/en/stable/production/install.html) | ||||
|   directly, or use Zulip's | ||||
|   experimental [Docker image](https://zulip.readthedocs.io/en/latest/production/deployment.html#zulip-in-docker). | ||||
|   Commercial support is available; see <https://zulipchat.com/plans> for details. | ||||
| [zulip-devel]: https://groups.google.com/forum/#!forum/zulip-devel | ||||
|  | ||||
| * **Using Zulip without setting up a server**. <https://zulipchat.com> offers | ||||
|   free and commercial hosting. | ||||
| ## Installing the Zulip Development environment | ||||
|  | ||||
| * **Applying for a Zulip internship**. Zulip runs internship programs with | ||||
|   [Outreachy](https://www.outreachy.org/), | ||||
|   [Google Summer of Code](https://developers.google.com/open-source/gsoc/), | ||||
|   and the | ||||
|   [MIT Externship program](https://alum.mit.edu/students/NetworkwithAlumni/ExternshipProgram). Zulip | ||||
|   also participates in | ||||
|   [Google Code-In](https://developers.google.com/open-source/gci/). More | ||||
|   information is available | ||||
|   [here](https://zulip.readthedocs.io/en/latest/overview/contributing.html#internship-programs). | ||||
| The Zulip development environment is the recommended option for folks | ||||
| interested in trying out Zulip, since it is very easy to install. | ||||
| This is documented in [the developer installation guide][dev-install]. | ||||
|  | ||||
| You may also be interested in reading our [blog](http://blog.zulip.org/) or | ||||
| following us on [twitter](https://twitter.com/zulip). | ||||
| Zulip is distributed under the | ||||
| [Apache 2.0](https://github.com/zulip/zulip/blob/master/LICENSE) license. | ||||
| ## Running Zulip in production | ||||
|  | ||||
| [beginner-friendly]: https://github.com/zulip/zulip/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22 | ||||
| Zulip in production supports Ubuntu 16.04 Xenial and Ubuntu 14.04 | ||||
| Trusty. We're happy to support work to enable Zulip to run on | ||||
| additional platforms. The installation process is | ||||
| [documented here](https://zulip.readthedocs.io/en/latest/prod.html). | ||||
|  | ||||
| ## Ways to contribute | ||||
|  | ||||
| Zulip welcomes all forms of contributions!  This page documents the | ||||
| Zulip development process. | ||||
|  | ||||
| * **Pull requests**. Before a pull request can be merged, you need to | ||||
| sign the [Dropbox Contributor License Agreement][cla].  Also, | ||||
| please skim our [commit message style guidelines][doc-commit-style]. | ||||
| We encourage early pull requests for work in progress. Prefix the title | ||||
| of your pull request with `[WIP]` and reference it when asking for | ||||
| community feedback. When you are ready for final review, remove | ||||
| the `[WIP]`. | ||||
|  | ||||
| * **Testing**. The Zulip automated tests all run automatically when | ||||
| you submit a pull request, but you can also run them all in your | ||||
| development environment following the instructions in the [testing | ||||
| docs][doc-test]. You can also try out [our new desktop | ||||
| client][electron], which is in alpha; we'd appreciate testing and | ||||
| [feedback](https://github.com/zulip/zulip-electron/issues/new). | ||||
|  | ||||
| * **Developer Documentation**.  Zulip has a growing collection of | ||||
| developer documentation on [Read The Docs][doc].  Recommended reading | ||||
| for new contributors includes the [directory structure][doc-dirstruct] | ||||
| and [new feature tutorial][doc-newfeat]. You can also improve | ||||
| [Zulip.org][z-org]. | ||||
|  | ||||
| * **Mailing lists and bug tracker**. Zulip has a [development | ||||
| discussion mailing list](#community) and uses [GitHub issues | ||||
| ][gh-issues].  There are also lists for the [Android][email-android] | ||||
| and [iOS][email-ios] apps.  Feel free to send any questions or | ||||
| suggestions of areas where you'd love to see more documentation to the | ||||
| relevant list! Check out our [bug report guidelines][bug-report] | ||||
| before submitting. Please report any security issues you discover to | ||||
| zulip-security@googlegroups.com. | ||||
|  | ||||
| * **App codebases**. This repository is for the Zulip server and web | ||||
| app (including most integrations).  The | ||||
| [beta React Native mobile app][mobile], [Java Android app][Android] | ||||
| (see [our mobile strategy][mobile-strategy]), | ||||
| [new Electron desktop app][electron], and | ||||
| [legacy Qt-based desktop app][desktop] are all separate repositories. | ||||
|  | ||||
| * **Glue code**. We maintain a [Hubot adapter][hubot-adapter] and several | ||||
| integrations ([Phabricator][phab], [Jenkins][], [Puppet][], [Redmine][], | ||||
| and [Trello][]), plus [node.js API bindings][node], an [isomorphic | ||||
|  JavaScript library][zulip-js], and a [full-text search PostgreSQL | ||||
|  extension][tsearch], as separate repos. | ||||
|  | ||||
| * **Translations**.  Zulip is in the process of being translated into | ||||
| 10+ languages, and we love contributions to our translations.  See our | ||||
| [translating documentation][transifex] if you're interested in | ||||
| contributing! | ||||
|  | ||||
| * **Code Reviews**. Zulip is all about community and helping each | ||||
| other out.  Check out [#code review][code-review] on | ||||
| [chat.zulip.org][czo-doc] to help review PRs and give comments on | ||||
| other people's work. Everyone is welcome to participate, even those | ||||
| new to Zulip! Even just checking out the code, manually testing it, | ||||
| and posting on whether or not it worked is valuable. | ||||
|  | ||||
| [cla]: https://opensource.dropbox.com/cla/ | ||||
| [code-of-conduct]: https://zulip.readthedocs.io/en/latest/code-of-conduct.html | ||||
| [dev-install]: https://zulip.readthedocs.io/en/latest/dev-overview.html | ||||
| [doc]: https://zulip.readthedocs.io/ | ||||
| [doc-commit-style]: http://zulip.readthedocs.io/en/latest/version-control.html#commit-messages | ||||
| [doc-dirstruct]: http://zulip.readthedocs.io/en/latest/directory-structure.html | ||||
| [doc-newfeat]: http://zulip.readthedocs.io/en/latest/new-feature-tutorial.html | ||||
| [doc-test]: http://zulip.readthedocs.io/en/latest/testing.html | ||||
| [electron]: https://github.com/zulip/zulip-electron | ||||
| [gh-issues]: https://github.com/zulip/zulip/issues | ||||
| [desktop]: https://github.com/zulip/zulip-desktop | ||||
| [android]: https://github.com/zulip/zulip-android | ||||
| [mobile]: https://github.com/zulip/zulip-mobile | ||||
| [mobile-strategy]: https://github.com/zulip/zulip-android/blob/master/android-strategy.md | ||||
| [email-android]: https://groups.google.com/forum/#!forum/zulip-android | ||||
| [email-ios]: https://groups.google.com/forum/#!forum/zulip-ios | ||||
| [hubot-adapter]: https://github.com/zulip/hubot-zulip | ||||
| [jenkins]: https://github.com/zulip/zulip-jenkins-plugin | ||||
| [node]: https://github.com/zulip/zulip-node | ||||
| [zulip-js]: https://github.com/zulip/zulip-js | ||||
| [phab]: https://github.com/zulip/phabricator-to-zulip | ||||
| [puppet]: https://github.com/matthewbarr/puppet-zulip | ||||
| [redmine]: https://github.com/zulip/zulip-redmine-plugin | ||||
| [trello]: https://github.com/zulip/trello-to-zulip | ||||
| [tsearch]: https://github.com/zulip/tsearch_extras | ||||
| [transifex]: https://zulip.readthedocs.io/en/latest/translating.html#testing-translations | ||||
| [z-org]: https://github.com/zulip/zulip.github.io | ||||
| [code-review]: https://chat.zulip.org/#narrow/stream/code.20review | ||||
| [bug-report]: http://zulip.readthedocs.io/en/latest/bug-reports.html | ||||
|  | ||||
| ## Google Summer of Code | ||||
|  | ||||
| We participated in | ||||
| [GSoC](https://developers.google.com/open-source/gsoc/) in 2016 (with | ||||
| [great results](https://blog.zulip.org/2016/10/13/static-types-in-python-oh-mypy/)) | ||||
| and [are participating](https://github.com/zulip/zulip.github.io/blob/master/gsoc-ideas.md) | ||||
| in 2017 as well. | ||||
|  | ||||
| ## How to get involved with contributing to Zulip | ||||
|  | ||||
| First, subscribe to the Zulip [development discussion mailing | ||||
| list](#community). | ||||
|  | ||||
| The Zulip project uses a system of labels in our [issue | ||||
| tracker][gh-issues] to make it easy to find a project if you don't | ||||
| have your own project idea in mind or want to get some experience with | ||||
| working on Zulip before embarking on a larger project you have in | ||||
| mind: | ||||
|  | ||||
| * [Integrations](https://github.com/zulip/zulip/labels/area%3A%20integrations). | ||||
|   Integrate Zulip with another piece of software and contribute it | ||||
|   back to the community!  Writing an integration can be a great first | ||||
|   contribution.  There's detailed documentation on how to write | ||||
|   integrations in [the Zulip integration writing | ||||
|   guide](https://zulip.readthedocs.io/en/latest/integration-guide.html). | ||||
|  | ||||
| * [Good first issue](https://github.com/zulip/zulip/labels/good%20first%20issue): | ||||
|   Smaller projects that might be a great first contribution. | ||||
|  | ||||
| * [Documentation](https://github.com/zulip/zulip/labels/area%3A%20documentation): | ||||
|   The Zulip project loves contributions of new documentation. | ||||
|  | ||||
| * [Help Wanted](https://github.com/zulip/zulip/labels/help%20wanted): | ||||
|   A broader list of projects that nobody is currently working on. | ||||
|  | ||||
| * [Platform support](https://github.com/zulip/zulip/labels/Platform%20support): | ||||
|   These are open issues about making it possible to install Zulip on a | ||||
|   wider range of platforms. | ||||
|  | ||||
| * [Bugs](https://github.com/zulip/zulip/labels/bug): Open bugs. | ||||
|  | ||||
| * [Feature requests](https://github.com/zulip/zulip/labels/enhancement): | ||||
|   Browsing this list can be a great way to find feature ideas to | ||||
|   implement that other Zulip users are excited about. | ||||
|  | ||||
| * [2016 roadmap milestone](http://zulip.readthedocs.io/en/latest/roadmap.html): | ||||
|   The projects that are | ||||
|   [priorities for the Zulip project](https://zulip.readthedocs.io/en/latest/roadmap.html). | ||||
|   These are great projects if you're looking to make an impact. | ||||
|  | ||||
| Another way to find issues in Zulip is to take advantage of our | ||||
| `area:<foo>` convention in separating out issues.  We partition all of | ||||
| our issues into areas like admin, compose, emoji, hotkeys, i18n, | ||||
| onboarding, search, etc.  Look through our | ||||
| [list of labels](https://github.com/zulip/zulip/labels), and click on | ||||
| some of the `area:` labels to see all the tickets related to your | ||||
| areas of interest. | ||||
|  | ||||
| If you're excited about helping with an open issue, make sure to claim | ||||
| the issue by commenting the following in the comment section: | ||||
| "**@zulipbot** claim". **@zulipbot** will assign you to the issue and | ||||
| label the issue as **in progress**. For more details, check out | ||||
| [**@zulipbot**](https://github.com/zulip/zulipbot). | ||||
|  | ||||
| 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 you stay unblocked and working efficiently. It's great to ask | ||||
| questions in comments on GitHub issues and pull requests, or | ||||
| [on chat.zulip.org][czo-doc].  We'll direct longer discussions to | ||||
| Zulip chat, but please post a summary of what you learned from the | ||||
| chat, or link to the conversation, in a comment on the GitHub issue. | ||||
|  | ||||
| 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, and are happy to support you in adding new features or | ||||
| other user experience improvements to Zulip. | ||||
|  | ||||
| If you have a new feature you'd like to add, we recommend you start by | ||||
| opening a GitHub issue about the feature idea explaining the problem | ||||
| that you're hoping to solve and that you're excited to work on it.  A | ||||
| Zulip maintainer will usually reply within a day with feedback on the | ||||
| idea, notes on any important issues or concerns, and and often tips on | ||||
| how to implement or test it.  Please feel free to ping the thread if | ||||
| you don't hear a response from the maintainers -- we try to be very | ||||
| responsive so this usually means we missed your message. | ||||
|  | ||||
| For significant changes to the visual design, user experience, data | ||||
| model, or architecture, we highly recommend posting a mockup, | ||||
| screenshot, or description of what you have in mind to the | ||||
| [#design](https://chat.zulip.org/#narrow/stream/design) stream on | ||||
| [chat.zulip.org][czo-doc] to get broad feedback before you spend too | ||||
| much time on implementation details. | ||||
|  | ||||
| Finally, before implementing a larger feature, we highly recommend | ||||
| looking at the | ||||
| [new feature tutorial](http://zulip.readthedocs.io/en/latest/new-feature-tutorial.html) | ||||
| and [coding style guidelines](http://zulip.readthedocs.io/en/latest/code-style.html) | ||||
| on ReadTheDocs. | ||||
|  | ||||
| Feedback on how to make this development process more efficient, fun, | ||||
| and friendly to new contributors is very welcome!  Just send an email | ||||
| to the [zulip-devel](#community) list with your thoughts. | ||||
|  | ||||
| When you feel like you have completed your work on an issue, post your | ||||
| PR to the | ||||
| [#code review](https://chat.zulip.org/#narrow/stream/code.20review) | ||||
| stream on [chat.zulip.org][czo-doc].  This is our lightweight process | ||||
| that gives other developers the opportunity to give you comments and | ||||
| suggestions on your work. | ||||
|  | ||||
| ## License | ||||
|  | ||||
| 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. | ||||
|  | ||||
|  | ||||
| [czo-doc]: https://zulip.readthedocs.io/en/latest/chat-zulip-org.html | ||||
|   | ||||
							
								
								
									
										122
									
								
								Vagrantfile
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										122
									
								
								Vagrantfile
									
									
									
									
										vendored
									
									
								
							| @@ -10,7 +10,7 @@ end | ||||
| if Vagrant::VERSION == "1.8.7" then | ||||
|     path = `which curl` | ||||
|     if path.include?('/opt/vagrant/embedded/bin/curl') then | ||||
|         puts "In Vagrant 1.8.7, curl is broken. Please use Vagrant 2.0.2 "\ | ||||
|         puts "In Vagrant 1.8.7, curl is broken. Please use Vagrant 1.8.6 "\ | ||||
|              "or run 'sudo rm -f /opt/vagrant/embedded/bin/curl' to fix the "\ | ||||
|              "issue before provisioning. See "\ | ||||
|              "https://github.com/mitchellh/vagrant/issues/7997 "\ | ||||
| @@ -19,46 +19,16 @@ if Vagrant::VERSION == "1.8.7" then | ||||
|     end | ||||
| end | ||||
|  | ||||
| # Workaround: Vagrant removed the atlas.hashicorp.com to | ||||
| # vagrantcloud.com redirect in February 2018. The value of | ||||
| # DEFAULT_SERVER_URL in Vagrant versions less than 1.9.3 is | ||||
| # atlas.hashicorp.com, which means that removal broke the fetching and | ||||
| # updating of boxes (since the old URL doesn't work).  See | ||||
| # https://github.com/hashicorp/vagrant/issues/9442 | ||||
| if Vagrant::DEFAULT_SERVER_URL == "atlas.hashicorp.com" | ||||
|   Vagrant::DEFAULT_SERVER_URL.replace('https://vagrantcloud.com') | ||||
| 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| | ||||
|  | ||||
|   # 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. | ||||
|   host_port = 9991 | ||||
|   http_proxy = https_proxy = no_proxy = nil | ||||
|   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 ".", "/srv/zulip" | ||||
|  | ||||
| @@ -74,9 +44,6 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| | ||||
|       when "NO_PROXY"; no_proxy = value | ||||
|       when "HOST_PORT"; host_port = value.to_i | ||||
|       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 | ||||
| @@ -101,54 +68,42 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| | ||||
|   end | ||||
|  | ||||
|   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 | ||||
|   # Specify Docker provider before VirtualBox provider so it's preferred. | ||||
|   config.vm.provider "docker" do |d, override| | ||||
|     d.build_dir = File.join(__dir__, "tools", "setup", "dev-vagrant-docker") | ||||
|     d.build_args = ["--build-arg", "VAGRANT_UID=#{Process.uid}"] | ||||
|     if !ubuntu_mirror.empty? | ||||
|       d.build_args += ["--build-arg", "UBUNTU_MIRROR=#{ubuntu_mirror}"] | ||||
|   # Specify LXC provider before VirtualBox provider so it's preferred. | ||||
|   config.vm.provider "lxc" do |lxc| | ||||
|     if command? "lxc-ls" | ||||
|       LXC_VERSION = `lxc-ls --version`.strip unless defined? LXC_VERSION | ||||
|       if LXC_VERSION >= "1.1.0" | ||||
|         # 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 >= "2.0.0" | ||||
|         lxc.backingstore = 'dir' | ||||
|       end | ||||
|     end | ||||
|     d.has_ssh = true | ||||
|     d.create_args = ["--ulimit", "nofile=1024:65536"] | ||||
|   end | ||||
|  | ||||
|   config.vm.provider "virtualbox" do |vb, override| | ||||
|     override.vm.box = "ubuntu/bionic64" | ||||
|     # 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" ] | ||||
|     override.vm.box = "ubuntu/trusty64" | ||||
|     # It's possible we can get away with just 1.5GB; more testing needed | ||||
|     vb.memory = vm_memory | ||||
|     vb.cpus = vm_num_cpus | ||||
|     vb.memory = 2048 | ||||
|     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 | ||||
|  | ||||
| $provision_script = <<SCRIPT | ||||
| set -x | ||||
| set -e | ||||
| set -o pipefail | ||||
|  | ||||
| # Code should go here, rather than tools/provision, only if it is | ||||
| # something that we don't want to happen when running provision in a | ||||
| # 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 | ||||
| sudo ln -nsf /srv/zulip/tools/setup/dev-motd /etc/update-motd.d/99-zulip-dev | ||||
| sudo rm -f /etc/update-motd.d/10-help-text | ||||
| sudo dpkg --purge landscape-client landscape-common ubuntu-release-upgrader-core update-manager-core update-notifier-common ubuntu-server | ||||
| sudo dpkg-divert --add --rename /etc/default/motd-news | ||||
| sudo sh -c 'echo ENABLED=0 > /etc/default/motd-news' | ||||
|  | ||||
| # If the host is running SELinux remount the /sys/fs/selinux directory as read only, | ||||
| # needed for apt-get to work. | ||||
| if [ -d "/sys/fs/selinux" ]; then | ||||
|     sudo mount -o remount,ro /sys/fs/selinux | ||||
|   sudo mount -o remount,ro /sys/fs/selinux | ||||
| fi | ||||
|  | ||||
| # Set default locale, this prevents errors if the user has another locale set. | ||||
| @@ -156,35 +111,14 @@ if ! grep -q 'LC_ALL=en_US.UTF-8' /etc/default/locale; then | ||||
|     echo "LC_ALL=en_US.UTF-8" | sudo tee -a /etc/default/locale | ||||
| fi | ||||
|  | ||||
| # Set an environment variable, so that we won't print the virtualenv | ||||
| # shell warning (it'll be wrong, since the shell is dying anyway) | ||||
| export SKIP_VENV_SHELL_WARNING=1 | ||||
|  | ||||
| # End `set -x`, so that the end of provision doesn't look like an error | ||||
| # message after a successful run. | ||||
| set +x | ||||
|  | ||||
| # Check if the zulip directory is writable | ||||
| if [ ! -w /srv/zulip ]; then | ||||
|     echo "The vagrant user is unable to write to the zulip directory." | ||||
|     echo "To fix this, run the following commands on the host machine:" | ||||
|     # sudo is required since our uid is not 1000 | ||||
|     echo '    vagrant halt -f' | ||||
|     echo '    rm -rf /PATH/TO/ZULIP/CLONE/.vagrant' | ||||
|     echo '    sudo chown -R 1000:$(id -g) /PATH/TO/ZULIP/CLONE' | ||||
|     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 "    vagrant up" | ||||
|     exit 1 | ||||
| fi | ||||
| # Provision the development environment | ||||
| ln -nsf /srv/zulip ~/zulip | ||||
| /srv/zulip/tools/provision | ||||
|  | ||||
| # Run any custom provision hooks the user has configured | ||||
| if [ -f /srv/zulip/tools/custom_provision ]; then | ||||
|     chmod +x /srv/zulip/tools/custom_provision | ||||
|     /srv/zulip/tools/custom_provision | ||||
|   chmod +x /srv/zulip/tools/custom_provision | ||||
|   /srv/zulip/tools/custom_provision | ||||
| fi | ||||
| SCRIPT | ||||
|  | ||||
|   | ||||
| @@ -1,40 +1,38 @@ | ||||
| import time | ||||
| from collections import OrderedDict, defaultdict | ||||
| from datetime import datetime, timedelta | ||||
| import logging | ||||
| from typing import Callable, Dict, List, \ | ||||
|     Optional, Tuple, Type, Union | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.db import connection | ||||
| from django.db import connection, models | ||||
| from django.db.models import F | ||||
|  | ||||
| from analytics.models import BaseCount, \ | ||||
|     FillState, InstallationCount, RealmCount, StreamCount, \ | ||||
|     UserCount, installation_epoch, last_successful_fill | ||||
| from zerver.lib.logging_util import log_to_file | ||||
| from zerver.lib.timestamp import ceiling_to_day, \ | ||||
|     ceiling_to_hour, floor_to_hour, verify_UTC | ||||
| from zerver.models import Message, Realm, RealmAuditLog, \ | ||||
|     Stream, UserActivityInterval, UserProfile, models | ||||
| from analytics.models import InstallationCount, RealmCount, \ | ||||
|     UserCount, StreamCount, BaseCount, FillState, Anomaly, installation_epoch, \ | ||||
|     last_successful_fill | ||||
| from zerver.models import Realm, UserProfile, Message, Stream, \ | ||||
|     UserActivityInterval, RealmAuditLog, models | ||||
| from zerver.lib.timestamp import floor_to_day, floor_to_hour, ceiling_to_day, \ | ||||
|     ceiling_to_hour, verify_UTC | ||||
|  | ||||
| from typing import Any, Callable, Dict, List, Optional, Text, Tuple, Type, Union | ||||
|  | ||||
| from collections import defaultdict, OrderedDict | ||||
| from datetime import timedelta, datetime | ||||
| from zerver.lib.logging_util import create_logger | ||||
| import time | ||||
|  | ||||
| ## Logging setup ## | ||||
|  | ||||
| logger = logging.getLogger('zulip.management') | ||||
| log_to_file(logger, settings.ANALYTICS_LOG_PATH) | ||||
| logger = create_logger('zulip.management', settings.ANALYTICS_LOG_PATH, 'INFO') | ||||
|  | ||||
| # You can't subtract timedelta.max from a datetime, so use this instead | ||||
| TIMEDELTA_MAX = timedelta(days=365*1000) | ||||
|  | ||||
| ## Class definitions ## | ||||
|  | ||||
| class CountStat: | ||||
| class CountStat(object): | ||||
|     HOUR = 'hour' | ||||
|     DAY = 'day' | ||||
|     FREQUENCIES = frozenset([HOUR, DAY]) | ||||
|  | ||||
|     def __init__(self, property: str, data_collector: 'DataCollector', frequency: str, | ||||
|                  interval: Optional[timedelta]=None) -> None: | ||||
|     def __init__(self, property, data_collector, frequency, interval=None): | ||||
|         # type: (str, DataCollector, str, Optional[timedelta]) -> None | ||||
|         self.property = property | ||||
|         self.data_collector = data_collector | ||||
|         # might have to do something different for bitfields | ||||
| @@ -48,28 +46,31 @@ class CountStat: | ||||
|         else:  # frequency == CountStat.DAY | ||||
|             self.interval = timedelta(days=1) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return "<CountStat: %s>" % (self.property,) | ||||
|     def __unicode__(self): | ||||
|         # type: () -> Text | ||||
|         return u"<CountStat: %s>" % (self.property,) | ||||
|  | ||||
| class LoggingCountStat(CountStat): | ||||
|     def __init__(self, property: str, output_table: Type[BaseCount], frequency: str) -> None: | ||||
|     def __init__(self, property, output_table, frequency): | ||||
|         # type: (str, Type[BaseCount], str) -> None | ||||
|         CountStat.__init__(self, property, DataCollector(output_table, None), frequency) | ||||
|  | ||||
| class DependentCountStat(CountStat): | ||||
|     def __init__(self, property: str, data_collector: 'DataCollector', frequency: str, | ||||
|                  interval: Optional[timedelta]=None, dependencies: List[str]=[]) -> None: | ||||
|     def __init__(self, property, data_collector, frequency, interval=None, dependencies=[]): | ||||
|         # type: (str, DataCollector, str, Optional[timedelta], List[str]) -> None | ||||
|         CountStat.__init__(self, property, data_collector, frequency, interval=interval) | ||||
|         self.dependencies = dependencies | ||||
|  | ||||
| class DataCollector: | ||||
|     def __init__(self, output_table: Type[BaseCount], | ||||
|                  pull_function: Optional[Callable[[str, datetime, datetime], int]]) -> None: | ||||
| class DataCollector(object): | ||||
|     def __init__(self, output_table, pull_function): | ||||
|         # type: (Type[BaseCount], Optional[Callable[[str, datetime, datetime], int]]) -> None | ||||
|         self.output_table = output_table | ||||
|         self.pull_function = pull_function | ||||
|  | ||||
| ## CountStat-level operations ## | ||||
|  | ||||
| def process_count_stat(stat: CountStat, fill_to_time: datetime) -> None: | ||||
| def process_count_stat(stat, fill_to_time): | ||||
|     # type: (CountStat, datetime) -> None | ||||
|     if stat.frequency == CountStat.HOUR: | ||||
|         time_increment = timedelta(hours=1) | ||||
|     elif stat.frequency == CountStat.DAY: | ||||
| @@ -119,14 +120,16 @@ def process_count_stat(stat: CountStat, fill_to_time: datetime) -> None: | ||||
|         currently_filled = currently_filled + time_increment | ||||
|         logger.info("DONE %s (%dms)" % (stat.property, (end-start)*1000)) | ||||
|  | ||||
| def do_update_fill_state(fill_state: FillState, end_time: datetime, state: int) -> None: | ||||
| def do_update_fill_state(fill_state, end_time, state): | ||||
|     # type: (FillState, datetime, int) -> None | ||||
|     fill_state.end_time = end_time | ||||
|     fill_state.state = state | ||||
|     fill_state.save() | ||||
|  | ||||
| # We assume end_time is valid (e.g. is on a day or hour boundary as appropriate) | ||||
| # and is timezone aware. It is the caller's responsibility to enforce this! | ||||
| def do_fill_count_stat_at_hour(stat: CountStat, end_time: datetime) -> None: | ||||
| def do_fill_count_stat_at_hour(stat, end_time): | ||||
|     # type: (CountStat, datetime) -> None | ||||
|     start_time = end_time - stat.interval | ||||
|     if not isinstance(stat, LoggingCountStat): | ||||
|         timer = time.time() | ||||
| @@ -136,7 +139,8 @@ def do_fill_count_stat_at_hour(stat: CountStat, end_time: datetime) -> None: | ||||
|                     (stat.property, (time.time()-timer)*1000, rows_added)) | ||||
|     do_aggregate_to_summary_table(stat, end_time) | ||||
|  | ||||
| def do_delete_counts_at_hour(stat: CountStat, end_time: datetime) -> None: | ||||
| def do_delete_counts_at_hour(stat, end_time): | ||||
|     # type: (CountStat, datetime) -> None | ||||
|     if isinstance(stat, LoggingCountStat): | ||||
|         InstallationCount.objects.filter(property=stat.property, end_time=end_time).delete() | ||||
|         if stat.data_collector.output_table in [UserCount, StreamCount]: | ||||
| @@ -147,7 +151,8 @@ def do_delete_counts_at_hour(stat: CountStat, end_time: datetime) -> None: | ||||
|         RealmCount.objects.filter(property=stat.property, end_time=end_time).delete() | ||||
|         InstallationCount.objects.filter(property=stat.property, end_time=end_time).delete() | ||||
|  | ||||
| def do_aggregate_to_summary_table(stat: CountStat, end_time: datetime) -> None: | ||||
| def do_aggregate_to_summary_table(stat, end_time): | ||||
|     # type: (CountStat, datetime) -> None | ||||
|     cursor = connection.cursor() | ||||
|  | ||||
|     # Aggregate into RealmCount | ||||
| @@ -172,8 +177,7 @@ def do_aggregate_to_summary_table(stat: CountStat, end_time: datetime) -> None: | ||||
|         start = time.time() | ||||
|         cursor.execute(realmcount_query, {'end_time': end_time}) | ||||
|         end = time.time() | ||||
|         logger.info("%s RealmCount aggregation (%dms/%sr)" % ( | ||||
|             stat.property, (end - start) * 1000, cursor.rowcount)) | ||||
|         logger.info("%s RealmCount aggregation (%dms/%sr)" % (stat.property, (end-start)*1000, cursor.rowcount)) | ||||
|  | ||||
|     # Aggregate into InstallationCount | ||||
|     installationcount_query = """ | ||||
| @@ -190,16 +194,14 @@ def do_aggregate_to_summary_table(stat: CountStat, end_time: datetime) -> None: | ||||
|     start = time.time() | ||||
|     cursor.execute(installationcount_query, {'end_time': end_time}) | ||||
|     end = time.time() | ||||
|     logger.info("%s InstallationCount aggregation (%dms/%sr)" % ( | ||||
|         stat.property, (end - start) * 1000, cursor.rowcount)) | ||||
|     logger.info("%s InstallationCount aggregation (%dms/%sr)" % (stat.property, (end-start)*1000, cursor.rowcount)) | ||||
|     cursor.close() | ||||
|  | ||||
| ## Utility functions called from outside counts.py ## | ||||
|  | ||||
| # called from zerver/lib/actions.py; should not throw any errors | ||||
| def do_increment_logging_stat(zerver_object: Union[Realm, UserProfile, Stream], stat: CountStat, | ||||
|                               subgroup: Optional[Union[str, int, bool]], event_time: datetime, | ||||
|                               increment: int=1) -> None: | ||||
| def do_increment_logging_stat(zerver_object, stat, subgroup, event_time, increment=1): | ||||
|     # type: (Union[Realm, UserProfile, Stream], CountStat, Optional[Union[str, int, bool]], datetime, int) -> None | ||||
|     table = stat.data_collector.output_table | ||||
|     if table == RealmCount: | ||||
|         id_args = {'realm': zerver_object} | ||||
| @@ -220,14 +222,17 @@ def do_increment_logging_stat(zerver_object: Union[Realm, UserProfile, Stream], | ||||
|         row.value = F('value') + increment | ||||
|         row.save(update_fields=['value']) | ||||
|  | ||||
| def do_drop_all_analytics_tables() -> None: | ||||
| def do_drop_all_analytics_tables(): | ||||
|     # type: () -> None | ||||
|     UserCount.objects.all().delete() | ||||
|     StreamCount.objects.all().delete() | ||||
|     RealmCount.objects.all().delete() | ||||
|     InstallationCount.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): | ||||
|     # type: (str) -> None | ||||
|     UserCount.objects.filter(property=property).delete() | ||||
|     StreamCount.objects.filter(property=property).delete() | ||||
|     RealmCount.objects.filter(property=property).delete() | ||||
| @@ -236,8 +241,8 @@ def do_drop_single_stat(property: str) -> None: | ||||
|  | ||||
| ## DataCollector-level operations ## | ||||
|  | ||||
| def do_pull_by_sql_query(property: str, start_time: datetime, end_time: datetime, query: str, | ||||
|                          group_by: Optional[Tuple[models.Model, str]]) -> int: | ||||
| def do_pull_by_sql_query(property, start_time, end_time, query, group_by): | ||||
|     # type: (str, datetime, datetime, str, Optional[Tuple[models.Model, str]]) -> int | ||||
|     if group_by is None: | ||||
|         subgroup = 'NULL' | ||||
|         group_by_clause  = '' | ||||
| @@ -257,13 +262,15 @@ def do_pull_by_sql_query(property: str, start_time: datetime, end_time: datetime | ||||
|     cursor.close() | ||||
|     return rowcount | ||||
|  | ||||
| def sql_data_collector(output_table: Type[BaseCount], query: str, | ||||
|                        group_by: Optional[Tuple[models.Model, str]]) -> DataCollector: | ||||
|     def pull_function(property: str, start_time: datetime, end_time: datetime) -> int: | ||||
| def sql_data_collector(output_table, query, group_by): | ||||
|     # type: (Type[BaseCount], str, Optional[Tuple[models.Model, str]]) -> DataCollector | ||||
|     def pull_function(property, start_time, end_time): | ||||
|         # type: (str, datetime, datetime) -> int | ||||
|         return do_pull_by_sql_query(property, start_time, end_time, query, group_by) | ||||
|     return DataCollector(output_table, pull_function) | ||||
|  | ||||
| def do_pull_minutes_active(property: str, start_time: datetime, end_time: datetime) -> int: | ||||
| def do_pull_minutes_active(property, start_time, end_time): | ||||
|     # type: (str, datetime, datetime) -> int | ||||
|     user_activity_intervals = UserActivityInterval.objects.filter( | ||||
|         end__gt=start_time, start__lt=end_time | ||||
|     ).select_related( | ||||
| @@ -287,16 +294,15 @@ count_message_by_user_query = """ | ||||
|     INSERT INTO analytics_usercount | ||||
|         (user_id, realm_id, value, property, subgroup, end_time) | ||||
|     SELECT | ||||
|         zerver_userprofile.id, zerver_userprofile.realm_id, count(*), | ||||
|         '%(property)s', %(subgroup)s, %%(time_end)s | ||||
|         zerver_userprofile.id, zerver_userprofile.realm_id, count(*), '%(property)s', %(subgroup)s, %%(time_end)s | ||||
|     FROM zerver_userprofile | ||||
|     JOIN zerver_message | ||||
|     ON | ||||
|         zerver_userprofile.id = zerver_message.sender_id | ||||
|     WHERE | ||||
|         zerver_userprofile.date_joined < %%(time_end)s AND | ||||
|         zerver_message.date_sent >= %%(time_start)s AND | ||||
|         zerver_message.date_sent < %%(time_end)s | ||||
|         zerver_message.pub_date >= %%(time_start)s AND | ||||
|         zerver_message.pub_date < %%(time_end)s | ||||
|     GROUP BY zerver_userprofile.id %(group_by_clause)s | ||||
| """ | ||||
|  | ||||
| @@ -322,17 +328,15 @@ count_message_type_by_user_query = """ | ||||
|         JOIN zerver_message | ||||
|         ON | ||||
|             zerver_userprofile.id = zerver_message.sender_id AND | ||||
|             zerver_message.date_sent >= %%(time_start)s AND | ||||
|             zerver_message.date_sent < %%(time_end)s | ||||
|             zerver_message.pub_date >= %%(time_start)s AND | ||||
|             zerver_message.pub_date < %%(time_end)s | ||||
|         JOIN zerver_recipient | ||||
|         ON | ||||
|             zerver_message.recipient_id = zerver_recipient.id | ||||
|         LEFT JOIN zerver_stream | ||||
|         ON | ||||
|             zerver_recipient.type_id = zerver_stream.id | ||||
|         GROUP BY | ||||
|             zerver_userprofile.realm_id, zerver_userprofile.id, | ||||
|             zerver_recipient.type, zerver_stream.invite_only | ||||
|         GROUP BY zerver_userprofile.realm_id, zerver_userprofile.id, zerver_recipient.type, zerver_stream.invite_only | ||||
|     ) AS subquery | ||||
|     GROUP BY realm_id, id, message_type | ||||
| """ | ||||
| @@ -359,8 +363,8 @@ count_message_by_stream_query = """ | ||||
|     WHERE | ||||
|         zerver_stream.date_created < %%(time_end)s AND | ||||
|         zerver_recipient.type = 2 AND | ||||
|         zerver_message.date_sent >= %%(time_start)s AND | ||||
|         zerver_message.date_sent < %%(time_end)s | ||||
|         zerver_message.pub_date >= %%(time_start)s AND | ||||
|         zerver_message.pub_date < %%(time_end)s | ||||
|     GROUP BY zerver_stream.id %(group_by_clause)s | ||||
| """ | ||||
|  | ||||
| @@ -385,7 +389,7 @@ count_user_by_realm_query = """ | ||||
|  | ||||
| # 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 | ||||
| # 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. | ||||
| check_realmauditlog_by_user_query = """ | ||||
|     INSERT INTO analytics_usercount | ||||
| @@ -397,7 +401,7 @@ check_realmauditlog_by_user_query = """ | ||||
|         SELECT modified_user_id, max(event_time) AS max_event_time | ||||
|         FROM zerver_realmauditlog | ||||
|         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 | ||||
|         GROUP BY modified_user_id | ||||
|     ) ral2 | ||||
| @@ -408,11 +412,8 @@ check_realmauditlog_by_user_query = """ | ||||
|     ON | ||||
|         ral1.modified_user_id = zerver_userprofile.id | ||||
|     WHERE | ||||
|         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) | ||||
|         ral1.event_type in ('user_created', 'user_activated', 'user_reactivated') | ||||
| """ | ||||
|  | ||||
| check_useractivityinterval_by_user_query = """ | ||||
|     INSERT INTO analytics_usercount | ||||
| @@ -515,19 +516,11 @@ count_stats_ = [ | ||||
|     # User Activity stats | ||||
|     # 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', | ||||
|               sql_data_collector(UserCount, check_useractivityinterval_by_user_query, None), | ||||
|               CountStat.DAY, interval=timedelta(days=15)-UserActivityInterval.MIN_INTERVAL_LENGTH), | ||||
|     CountStat('minutes_active::day', DataCollector(UserCount, do_pull_minutes_active), CountStat.DAY), | ||||
|  | ||||
|     # Rate limiting stats | ||||
|  | ||||
|     # Used to limit the number of invitation emails sent by a realm | ||||
|     LoggingCountStat('invites_sent::day', RealmCount, CountStat.DAY), | ||||
|  | ||||
|     # Dependent stats | ||||
|     # Must come after their dependencies. | ||||
|  | ||||
|   | ||||
| @@ -1,14 +1,19 @@ | ||||
| from zerver.models import Realm, UserProfile, Stream, Message | ||||
| from analytics.models import InstallationCount, RealmCount, UserCount, StreamCount | ||||
| from analytics.lib.counts import CountStat | ||||
| from analytics.lib.time_utils import time_range | ||||
|  | ||||
| from datetime import datetime | ||||
| from math import sqrt | ||||
| from random import gauss, random, seed | ||||
| from typing import List | ||||
|  | ||||
| from analytics.lib.counts import CountStat | ||||
| from six.moves import zip | ||||
|  | ||||
| def generate_time_series_data(days: int=100, business_hours_base: float=10, | ||||
|                               non_business_hours_base: float=10, growth: float=1, | ||||
|                               autocorrelation: float=0, spikiness: float=1, | ||||
|                               holiday_rate: float=0, frequency: str=CountStat.DAY, | ||||
|                               partial_sum: bool=False, random_seed: int=26) -> List[int]: | ||||
| def generate_time_series_data(days=100, business_hours_base=10, non_business_hours_base=10, | ||||
|                               growth=1, autocorrelation=0, spikiness=1, holiday_rate=0, | ||||
|                               frequency=CountStat.DAY, partial_sum=False, random_seed=26): | ||||
|     # type: (int, float, float, float, float, float, float, str, bool, int) -> List[int] | ||||
|     """ | ||||
|     Generate semi-realistic looking time series data for testing analytics graphs. | ||||
|  | ||||
|   | ||||
| @@ -1,15 +1,16 @@ | ||||
| from zerver.lib.timestamp import floor_to_hour, floor_to_day, \ | ||||
|     timestamp_to_datetime, verify_UTC | ||||
| from analytics.lib.counts import CountStat | ||||
|  | ||||
| from datetime import datetime, timedelta | ||||
| from typing import List, Optional | ||||
|  | ||||
| from analytics.lib.counts import CountStat | ||||
| from zerver.lib.timestamp import floor_to_day, floor_to_hour, verify_UTC | ||||
|  | ||||
| # If min_length is None, returns end_times from ceiling(start) to floor(end), inclusive. | ||||
| # If min_length is greater than 0, pads the list to the left. | ||||
| # So informally, time_range(Sep 20, Sep 22, day, None) returns [Sep 20, Sep 21, Sep 22], | ||||
| # and time_range(Sep 20, Sep 22, day, 5) returns [Sep 18, Sep 19, Sep 20, Sep 21, Sep 22] | ||||
| def time_range(start: datetime, end: datetime, frequency: str, | ||||
|                min_length: Optional[int]) -> List[datetime]: | ||||
| def time_range(start, end, frequency, min_length): | ||||
|     # type: (datetime, datetime, str, Optional[int]) -> List[datetime] | ||||
|     verify_UTC(start) | ||||
|     verify_UTC(end) | ||||
|     if frequency == CountStat.HOUR: | ||||
|   | ||||
| @@ -1,21 +1,21 @@ | ||||
| import datetime | ||||
| import logging | ||||
| import time | ||||
| from typing import Any, Dict | ||||
|  | ||||
| from django.core.management.base import BaseCommand, CommandParser | ||||
|  | ||||
| from zerver.models import Recipient, Message | ||||
| from zerver.lib.timestamp import timestamp_to_datetime | ||||
| from zerver.models import Message, Recipient | ||||
| import datetime | ||||
| import time | ||||
| import logging | ||||
|  | ||||
| def compute_stats(log_level: int) -> None: | ||||
| def compute_stats(log_level): | ||||
|     # type: (int) -> None | ||||
|     logger = logging.getLogger() | ||||
|     logger.setLevel(log_level) | ||||
|  | ||||
|     one_week_ago = timestamp_to_datetime(time.time()) - datetime.timedelta(weeks=1) | ||||
|     mit_query = Message.objects.filter(sender__realm__string_id="zephyr", | ||||
|                                        recipient__type=Recipient.STREAM, | ||||
|                                        date_sent__gt=one_week_ago) | ||||
|                                        pub_date__gt=one_week_ago) | ||||
|     for bot_sender_start in ["imap.", "rcmd.", "sys."]: | ||||
|         mit_query = mit_query.exclude(sender__email__startswith=(bot_sender_start)) | ||||
|     # Filtering for "/" covers tabbott/extra@ and all the daemon/foo bots. | ||||
| @@ -71,10 +71,12 @@ def compute_stats(log_level: int) -> None: | ||||
| class Command(BaseCommand): | ||||
|     help = "Compute statistics on MIT Zephyr usage." | ||||
|  | ||||
|     def add_arguments(self, parser: CommandParser) -> None: | ||||
|     def add_arguments(self, parser): | ||||
|         # type: (CommandParser) -> None | ||||
|         parser.add_argument('--verbose', default=False, action='store_true') | ||||
|  | ||||
|     def handle(self, *args: Any, **options: Any) -> None: | ||||
|     def handle(self, *args, **options): | ||||
|         # type: (*Any, **Any) -> None | ||||
|         level = logging.INFO | ||||
|         if options["verbose"]: | ||||
|             level = logging.DEBUG | ||||
|   | ||||
| @@ -1,13 +1,14 @@ | ||||
| import datetime | ||||
| from typing import Any, Dict | ||||
|  | ||||
| from zerver.lib.statistics import seconds_usage_between | ||||
|  | ||||
| from django.core.management.base import BaseCommand, CommandParser | ||||
| from zerver.models import UserProfile | ||||
| import datetime | ||||
| from django.utils.timezone import utc | ||||
|  | ||||
| from zerver.lib.statistics import seconds_usage_between | ||||
| from zerver.models import UserProfile | ||||
|  | ||||
| def analyze_activity(options: Dict[str, Any]) -> None: | ||||
| def analyze_activity(options): | ||||
|     # type: (Dict[str, Any]) -> None | ||||
|     day_start = datetime.datetime.strptime(options["date"], "%Y-%m-%d").replace(tzinfo=utc) | ||||
|     day_end = day_start + datetime.timedelta(days=options["duration"]) | ||||
|  | ||||
| @@ -46,11 +47,13 @@ Usage: ./manage.py analyze_user_activity [--realm=zulip] [--date=2013-09-10] [-- | ||||
| By default, if no date is selected 2013-09-10 is used. If no realm is provided, information | ||||
| is shown for all realms""" | ||||
|  | ||||
|     def add_arguments(self, parser: CommandParser) -> None: | ||||
|     def add_arguments(self, parser): | ||||
|         # type: (CommandParser) -> None | ||||
|         parser.add_argument('--realm', action='store') | ||||
|         parser.add_argument('--date', action='store', default="2013-09-06") | ||||
|         parser.add_argument('--duration', action='store', default=1, type=int, | ||||
|                             help="How many days to show usage information for") | ||||
|  | ||||
|     def handle(self, *args: Any, **options: Any) -> None: | ||||
|     def handle(self, *args, **options): | ||||
|         # type: (*Any, **Any) -> None | ||||
|         analyze_activity(options) | ||||
|   | ||||
| @@ -1,82 +0,0 @@ | ||||
| from datetime import timedelta | ||||
|  | ||||
| from django.core.management.base import BaseCommand | ||||
| from django.utils.timezone import now as timezone_now | ||||
|  | ||||
| from analytics.models import installation_epoch, \ | ||||
|     last_successful_fill | ||||
| from analytics.lib.counts import COUNT_STATS, CountStat | ||||
| from zerver.lib.timestamp import floor_to_hour, floor_to_day, verify_UTC, \ | ||||
|     TimezoneNotUTCException | ||||
| from zerver.models import Realm | ||||
|  | ||||
| import os | ||||
| import time | ||||
| from typing import Any, Dict | ||||
|  | ||||
| states = { | ||||
|     0: "OK", | ||||
|     1: "WARNING", | ||||
|     2: "CRITICAL", | ||||
|     3: "UNKNOWN" | ||||
| } | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     help = """Checks FillState table. | ||||
|  | ||||
|     Run as a cron job that runs every hour.""" | ||||
|  | ||||
|     def handle(self, *args: Any, **options: Any) -> None: | ||||
|         fill_state = self.get_fill_state() | ||||
|         status = fill_state['status'] | ||||
|         message = fill_state['message'] | ||||
|  | ||||
|         state_file_path = "/var/lib/nagios_state/check-analytics-state" | ||||
|         state_file_tmp = state_file_path + "-tmp" | ||||
|  | ||||
|         with open(state_file_tmp, "w") as f: | ||||
|             f.write("%s|%s|%s|%s\n" % ( | ||||
|                 int(time.time()), status, states[status], message)) | ||||
|         os.rename(state_file_tmp, state_file_path) | ||||
|  | ||||
|     def get_fill_state(self) -> Dict[str, Any]: | ||||
|         if not Realm.objects.exists(): | ||||
|             return {'status': 0, 'message': 'No realms exist, so not checking FillState.'} | ||||
|  | ||||
|         warning_unfilled_properties = [] | ||||
|         critical_unfilled_properties = [] | ||||
|         for property, stat in COUNT_STATS.items(): | ||||
|             last_fill = last_successful_fill(property) | ||||
|             if last_fill is None: | ||||
|                 last_fill = installation_epoch() | ||||
|             try: | ||||
|                 verify_UTC(last_fill) | ||||
|             except TimezoneNotUTCException: | ||||
|                 return {'status': 2, 'message': 'FillState not in UTC for %s' % (property,)} | ||||
|  | ||||
|             if stat.frequency == CountStat.DAY: | ||||
|                 floor_function = floor_to_day | ||||
|                 warning_threshold = timedelta(hours=26) | ||||
|                 critical_threshold = timedelta(hours=50) | ||||
|             else:  # CountStat.HOUR | ||||
|                 floor_function = floor_to_hour | ||||
|                 warning_threshold = timedelta(minutes=90) | ||||
|                 critical_threshold = timedelta(minutes=150) | ||||
|  | ||||
|             if floor_function(last_fill) != last_fill: | ||||
|                 return {'status': 2, 'message': 'FillState not on %s boundary for %s' % | ||||
|                         (stat.frequency, property)} | ||||
|  | ||||
|             time_to_last_fill = timezone_now() - last_fill | ||||
|             if time_to_last_fill > critical_threshold: | ||||
|                 critical_unfilled_properties.append(property) | ||||
|             elif time_to_last_fill > warning_threshold: | ||||
|                 warning_unfilled_properties.append(property) | ||||
|  | ||||
|         if len(critical_unfilled_properties) == 0 and len(warning_unfilled_properties) == 0: | ||||
|             return {'status': 0, 'message': 'FillState looks fine.'} | ||||
|         if len(critical_unfilled_properties) == 0: | ||||
|             return {'status': 1, 'message': 'Missed filling %s once.' % | ||||
|                     (', '.join(warning_unfilled_properties),)} | ||||
|         return {'status': 2, 'message': 'Missed filling %s once. Missed filling %s at least twice.' % | ||||
|                 (', '.join(warning_unfilled_properties), ', '.join(critical_unfilled_properties))} | ||||
| @@ -1,20 +1,26 @@ | ||||
| from argparse import ArgumentParser | ||||
| from typing import Any | ||||
| import sys | ||||
|  | ||||
| from django.core.management.base import BaseCommand, CommandError | ||||
| from argparse import ArgumentParser | ||||
| from django.db import connection | ||||
| from django.core.management.base import BaseCommand | ||||
|  | ||||
| from analytics.lib.counts import do_drop_all_analytics_tables | ||||
|  | ||||
| from typing import Any | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     help = """Clear analytics tables.""" | ||||
|  | ||||
|     def add_arguments(self, parser: ArgumentParser) -> None: | ||||
|     def add_arguments(self, parser): | ||||
|         # type: (ArgumentParser) -> None | ||||
|         parser.add_argument('--force', | ||||
|                             action='store_true', | ||||
|                             help="Clear analytics tables.") | ||||
|  | ||||
|     def handle(self, *args: Any, **options: Any) -> None: | ||||
|     def handle(self, *args, **options): | ||||
|         # type: (*Any, **Any) -> None | ||||
|         if options['force']: | ||||
|             do_drop_all_analytics_tables() | ||||
|         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) | ||||
|   | ||||
| @@ -1,14 +1,18 @@ | ||||
| import sys | ||||
|  | ||||
| from argparse import ArgumentParser | ||||
| from django.db import connection | ||||
| from django.core.management.base import BaseCommand | ||||
|  | ||||
| from analytics.lib.counts import do_drop_single_stat, COUNT_STATS | ||||
|  | ||||
| from typing import Any | ||||
|  | ||||
| from django.core.management.base import BaseCommand, CommandError | ||||
|  | ||||
| from analytics.lib.counts import COUNT_STATS, do_drop_single_stat | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     help = """Clear analytics tables.""" | ||||
|  | ||||
|     def add_arguments(self, parser: ArgumentParser) -> None: | ||||
|     def add_arguments(self, parser): | ||||
|         # type: (ArgumentParser) -> None | ||||
|         parser.add_argument('--force', | ||||
|                             action='store_true', | ||||
|                             help="Actually do it.") | ||||
| @@ -16,11 +20,14 @@ class Command(BaseCommand): | ||||
|                             type=str, | ||||
|                             help="The property of the stat to be cleared.") | ||||
|  | ||||
|     def handle(self, *args: Any, **options: Any) -> None: | ||||
|     def handle(self, *args, **options): | ||||
|         # type: (*Any, **Any) -> None | ||||
|         property = options['property'] | ||||
|         if property not in COUNT_STATS: | ||||
|             raise CommandError("Invalid property: %s" % (property,)) | ||||
|             print("Invalid property: %s" % (property,)) | ||||
|             sys.exit(1) | ||||
|         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) | ||||
|   | ||||
| @@ -1,13 +1,14 @@ | ||||
| import datetime | ||||
| from argparse import ArgumentParser | ||||
| from typing import Any, Optional | ||||
| from typing import Any | ||||
|  | ||||
| from argparse import ArgumentParser | ||||
| from django.db.models import Count, QuerySet | ||||
| from django.utils.timezone import now as timezone_now | ||||
|  | ||||
| from zerver.lib.management import ZulipBaseCommand | ||||
| from zerver.models import UserActivity | ||||
|  | ||||
| import datetime | ||||
|  | ||||
| class Command(ZulipBaseCommand): | ||||
|     help = """Report rough client activity globally, for a realm, or for a user | ||||
|  | ||||
| @@ -17,16 +18,18 @@ Usage examples: | ||||
| ./manage.py client_activity --target realm --realm zulip | ||||
| ./manage.py client_activity --target user --user hamlet@zulip.com --realm zulip""" | ||||
|  | ||||
|     def add_arguments(self, parser: ArgumentParser) -> None: | ||||
|     def add_arguments(self, parser): | ||||
|         # type: (ArgumentParser) -> None | ||||
|         parser.add_argument('--target', dest='target', required=True, type=str, | ||||
|                             help="'server' will calculate client activity of the entire server. " | ||||
|                                  "'realm' will calculate client activity of realm. " | ||||
|                                  "'user' will calculate client activity of the user.") | ||||
|         parser.add_argument('--user', dest='user', type=str, | ||||
|                             help="The email address of the user you want to calculate activity.") | ||||
|                             help="The email adress of the user you want to calculate activity.") | ||||
|         self.add_realm_args(parser) | ||||
|  | ||||
|     def compute_activity(self, user_activity_objects: QuerySet) -> None: | ||||
|     def compute_activity(self, user_activity_objects): | ||||
|         # type: (QuerySet) -> None | ||||
|         # Report data from the past week. | ||||
|         # | ||||
|         # This is a rough report of client activity because we inconsistently | ||||
| @@ -56,7 +59,8 @@ Usage examples: | ||||
|             print("%25s %15d" % (count[1], count[0])) | ||||
|         print("Total:", total) | ||||
|  | ||||
|     def handle(self, *args: Any, **options: Optional[str]) -> None: | ||||
|     def handle(self, *args, **options): | ||||
|         # type: (*Any, **str) -> None | ||||
|         realm = self.get_realm(options) | ||||
|         if options["user"] is None: | ||||
|             if options["target"] == "server" and realm is None: | ||||
|   | ||||
| @@ -1,21 +1,22 @@ | ||||
| from datetime import timedelta | ||||
| from typing import Any, Dict, List, Mapping, Optional, Type | ||||
| import mock | ||||
|  | ||||
| from argparse import ArgumentParser | ||||
|  | ||||
| from django.core.management.base import BaseCommand | ||||
| from django.utils.timezone import now as timezone_now | ||||
|  | ||||
| from analytics.lib.counts import COUNT_STATS, \ | ||||
|     CountStat, do_drop_all_analytics_tables | ||||
| from analytics.lib.counts import COUNT_STATS, CountStat, do_drop_all_analytics_tables | ||||
| from analytics.lib.fixtures import generate_time_series_data | ||||
| from analytics.lib.time_utils import time_range | ||||
| from analytics.models import BaseCount, FillState, RealmCount, UserCount, \ | ||||
|     StreamCount, InstallationCount | ||||
| from zerver.lib.actions import do_change_is_admin, STREAM_ASSIGNMENT_COLORS | ||||
| from zerver.lib.create_user import create_user | ||||
| from analytics.models import BaseCount, InstallationCount, RealmCount, \ | ||||
|     UserCount, StreamCount, FillState | ||||
| from zerver.lib.timestamp import floor_to_day | ||||
| from zerver.models import Realm, Stream, Client, \ | ||||
|     Recipient, Subscription | ||||
| from zerver.models import Realm, UserProfile, Stream, Message, Client, \ | ||||
|     RealmAuditLog | ||||
|  | ||||
| from datetime import datetime, timedelta | ||||
|  | ||||
| from six.moves import zip | ||||
| from typing import Any, Dict, List, Optional, Text, Type, Union, Mapping | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     help = """Populates analytics tables with randomly generated data.""" | ||||
| @@ -23,10 +24,21 @@ class Command(BaseCommand): | ||||
|     DAYS_OF_DATA = 100 | ||||
|     random_seed = 26 | ||||
|  | ||||
|     def generate_fixture_data(self, stat: CountStat, business_hours_base: float, | ||||
|                               non_business_hours_base: float, growth: float, | ||||
|                               autocorrelation: float, spikiness: float, | ||||
|                               holiday_rate: float=0, partial_sum: bool=False) -> List[int]: | ||||
|     def create_user(self, email, full_name, is_staff, date_joined, realm): | ||||
|         # type: (Text, Text, Text, bool, datetime, 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, business_hours_base, non_business_hours_base, | ||||
|                               growth, autocorrelation, spikiness, holiday_rate=0, | ||||
|                               partial_sum=False): | ||||
|         # type: (CountStat, float, float, float, float, float, float, bool) -> List[int] | ||||
|         self.random_seed += 1 | ||||
|         return generate_time_series_data( | ||||
|             days=self.DAYS_OF_DATA, business_hours_base=business_hours_base, | ||||
| @@ -34,100 +46,37 @@ class Command(BaseCommand): | ||||
|             autocorrelation=autocorrelation, spikiness=spikiness, holiday_rate=holiday_rate, | ||||
|             frequency=stat.frequency, partial_sum=partial_sum, random_seed=self.random_seed) | ||||
|  | ||||
|     def handle(self, *args: Any, **options: Any) -> None: | ||||
|         # TODO: This should arguably only delete the objects | ||||
|         # associated with the "analytics" realm. | ||||
|     def handle(self, *args, **options): | ||||
|         # type: (*Any, **Any) -> None | ||||
|         do_drop_all_analytics_tables() | ||||
|  | ||||
|         # This also deletes any objects with this realm as a foreign key | ||||
|         # I believe this also deletes any objects with this realm as a foreign key | ||||
|         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) | ||||
|         last_end_time = floor_to_day(timezone_now()) | ||||
|         realm = Realm.objects.create( | ||||
|             string_id='analytics', name='Analytics', date_created=installation_time) | ||||
|         with mock.patch("zerver.lib.create_user.timezone_now", return_value=installation_time): | ||||
|             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( | ||||
|             name='all', realm=realm, date_created=installation_time) | ||||
|         recipient = Recipient.objects.create(type_id=stream.id, type=Recipient.STREAM) | ||||
|         stream.recipient = recipient | ||||
|         stream.save(update_fields=["recipient"]) | ||||
|         shylock = self.create_user('shylock@analytics.ds', 'Shylock', True, installation_time, realm) | ||||
|  | ||||
|         # 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, | ||||
|                                 fixture_data: Mapping[Optional[str], List[int]], | ||||
|                                 table: Type[BaseCount]) -> None: | ||||
|         def insert_fixture_data(stat, fixture_data, table): | ||||
|             # type: (CountStat, Mapping[Optional[str], List[int]], Type[BaseCount]) -> None | ||||
|             end_times = time_range(last_end_time, last_end_time, stat.frequency, | ||||
|                                    len(list(fixture_data.values())[0])) | ||||
|             if table == InstallationCount: | ||||
|                 id_args = {}  # type: Dict[str, Any] | ||||
|             if table == RealmCount: | ||||
|                 id_args = {'realm': realm} | ||||
|             if table == UserCount: | ||||
|                 id_args = {'realm': realm, 'user': shylock} | ||||
|             if table == StreamCount: | ||||
|                 id_args = {'stream': stream, 'realm': realm} | ||||
|  | ||||
|             for subgroup, values in fixture_data.items(): | ||||
|                 table.objects.bulk_create([ | ||||
|                     table(property=stat.property, subgroup=subgroup, end_time=end_time, | ||||
|                           value=value, **id_args) | ||||
|                     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'] | ||||
|         realm_data = { | ||||
|             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) | ||||
|         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, | ||||
|                                  state=FillState.DONE) | ||||
|  | ||||
| @@ -138,9 +87,6 @@ class Command(BaseCommand): | ||||
|         realm_data = {'false': self.generate_fixture_data(stat, 35, 15, 6, .6, 4), | ||||
|                       'true': self.generate_fixture_data(stat, 15, 15, 3, .4, 2)} | ||||
|         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, | ||||
|                                  state=FillState.DONE) | ||||
|  | ||||
| @@ -156,12 +102,6 @@ class Command(BaseCommand): | ||||
|             'private_message': self.generate_fixture_data(stat, 13, 5, 5, .6, 4), | ||||
|             'huddle_message': self.generate_fixture_data(stat, 6, 3, 3, .6, 4)} | ||||
|         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, | ||||
|                                  state=FillState.DONE) | ||||
|  | ||||
| @@ -191,26 +131,7 @@ class Command(BaseCommand): | ||||
|             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)} | ||||
|         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, | ||||
|                                  state=FillState.DONE) | ||||
|  | ||||
|         stat = COUNT_STATS['messages_in_stream:is_bot:day'] | ||||
|         realm_data = {'false': self.generate_fixture_data(stat, 30, 5, 6, .6, 4), | ||||
|                       'true': self.generate_fixture_data(stat, 20, 2, 3, .2, 3)} | ||||
|         insert_fixture_data(stat, realm_data, RealmCount) | ||||
|         stream_data = {'false': self.generate_fixture_data(stat, 10, 7, 5, .6, 4), | ||||
|                        'true': self.generate_fixture_data(stat, 5, 3, 2, .4, 2)}  # type: Mapping[Optional[str], List[int]] | ||||
|         insert_fixture_data(stat, stream_data, StreamCount) | ||||
|         FillState.objects.create(property=stat.property, end_time=last_end_time, | ||||
|                                  state=FillState.DONE) | ||||
|         # TODO: messages_sent_to_stream:is_bot | ||||
|   | ||||
| @@ -1,13 +1,15 @@ | ||||
| import datetime | ||||
| from argparse import ArgumentParser | ||||
| from typing import Any, List | ||||
|  | ||||
| from django.core.management.base import BaseCommand, CommandError | ||||
| from argparse import ArgumentParser | ||||
| import datetime | ||||
| import pytz | ||||
|  | ||||
| from django.core.management.base import BaseCommand | ||||
| from django.db.models import Count | ||||
| from django.utils.timezone import now as timezone_now | ||||
|  | ||||
| from zerver.models import Message, Realm, Recipient, Stream, \ | ||||
|     Subscription, UserActivity, UserMessage, UserProfile, get_realm | ||||
| from zerver.models import UserProfile, Realm, Stream, Message, Recipient, UserActivity, \ | ||||
|     Subscription, UserMessage, get_realm | ||||
|  | ||||
| MOBILE_CLIENT_LIST = ["Android", "ios"] | ||||
| HUMAN_CLIENT_LIST = MOBILE_CLIENT_LIST + ["website"] | ||||
| @@ -17,11 +19,13 @@ human_messages = Message.objects.filter(sending_client__name__in=HUMAN_CLIENT_LI | ||||
| class Command(BaseCommand): | ||||
|     help = "Generate statistics on realm activity." | ||||
|  | ||||
|     def add_arguments(self, parser: ArgumentParser) -> None: | ||||
|     def add_arguments(self, parser): | ||||
|         # type: (ArgumentParser) -> None | ||||
|         parser.add_argument('realms', metavar='<realm>', type=str, nargs='*', | ||||
|                             help="realm to generate statistics for") | ||||
|  | ||||
|     def active_users(self, realm: Realm) -> List[UserProfile]: | ||||
|     def active_users(self, realm): | ||||
|         # type: (Realm) -> List[UserProfile] | ||||
|         # Has been active (on the website, for now) in the last 7 days. | ||||
|         activity_cutoff = timezone_now() - datetime.timedelta(days=7) | ||||
|         return [activity.user_profile for activity in ( | ||||
| @@ -31,49 +35,59 @@ class Command(BaseCommand): | ||||
|                                         query="/json/users/me/pointer", | ||||
|                                         client__name="website"))] | ||||
|  | ||||
|     def messages_sent_by(self, user: UserProfile, days_ago: int) -> int: | ||||
|     def messages_sent_by(self, user, days_ago): | ||||
|         # type: (UserProfile, int) -> int | ||||
|         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, days_ago): | ||||
|         # type: (Realm, int) -> int | ||||
|         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, days_ago): | ||||
|         # type: (Realm, int) -> int | ||||
|         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, days_ago): | ||||
|         # type: (Realm, int) -> int | ||||
|         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, days_ago): | ||||
|         # type: (Realm, int) -> int | ||||
|         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() | ||||
|  | ||||
|     def private_messages(self, realm: Realm, days_ago: int) -> int: | ||||
|     def private_messages(self, realm, days_ago): | ||||
|         # type: (Realm, int) -> int | ||||
|         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() | ||||
|  | ||||
|     def group_private_messages(self, realm: Realm, days_ago: int) -> int: | ||||
|     def group_private_messages(self, realm, days_ago): | ||||
|         # type: (Realm, int) -> int | ||||
|         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() | ||||
|  | ||||
|     def report_percentage(self, numerator: float, denominator: float, text: str) -> None: | ||||
|     def report_percentage(self, numerator, denominator, text): | ||||
|         # type: (float, float, str) -> None | ||||
|         if not denominator: | ||||
|             fraction = 0.0 | ||||
|         else: | ||||
|             fraction = numerator / float(denominator) | ||||
|         print("%.2f%% of" % (fraction * 100,), text) | ||||
|  | ||||
|     def handle(self, *args: Any, **options: Any) -> None: | ||||
|     def handle(self, *args, **options): | ||||
|         # type: (*Any, **Any) -> None | ||||
|         if options['realms']: | ||||
|             try: | ||||
|                 realms = [get_realm(string_id) for string_id in options['realms']] | ||||
|             except Realm.DoesNotExist as e: | ||||
|                 raise CommandError(e) | ||||
|                 print(e) | ||||
|                 exit(1) | ||||
|         else: | ||||
|             realms = Realm.objects.all() | ||||
|  | ||||
| @@ -130,7 +144,7 @@ class Command(BaseCommand): | ||||
|                 user_profile__in=user_profiles, active=True) | ||||
|  | ||||
|             # 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")) | ||||
|             print("%d users have %d streams not in home view" % ( | ||||
|                 len(non_home_view), sum([elt["count"] for elt in non_home_view]))) | ||||
| @@ -143,7 +157,7 @@ class Command(BaseCommand): | ||||
|                 len(markup_messages), sum([elt["count"] for elt in markup_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")) | ||||
|             print("%d users receive desktop notifications for %d streams" % ( | ||||
|                 len(notifications), sum([elt["count"] for elt in notifications]))) | ||||
|   | ||||
| @@ -1,56 +1,43 @@ | ||||
| from argparse import ArgumentParser | ||||
| from typing import Any | ||||
|  | ||||
| from django.core.management.base import BaseCommand, CommandError | ||||
| from argparse import ArgumentParser | ||||
| from django.core.management.base import BaseCommand | ||||
| from django.db.models import Q | ||||
|  | ||||
| from zerver.models import Message, Realm, \ | ||||
|     Recipient, Stream, Subscription, get_realm | ||||
| from zerver.models import Realm, Stream, Message, Subscription, Recipient, get_realm | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     help = "Generate statistics on the streams for a realm." | ||||
|  | ||||
|     def add_arguments(self, parser: ArgumentParser) -> None: | ||||
|     def add_arguments(self, parser): | ||||
|         # type: (ArgumentParser) -> None | ||||
|         parser.add_argument('realms', metavar='<realm>', type=str, nargs='*', | ||||
|                             help="realm to generate statistics for") | ||||
|  | ||||
|     def handle(self, *args: Any, **options: str) -> None: | ||||
|     def handle(self, *args, **options): | ||||
|         # type: (*Any, **str) -> None | ||||
|         if options['realms']: | ||||
|             try: | ||||
|                 realms = [get_realm(string_id) for string_id in options['realms']] | ||||
|             except Realm.DoesNotExist as e: | ||||
|                 raise CommandError(e) | ||||
|                 print(e) | ||||
|                 exit(1) | ||||
|         else: | ||||
|             realms = Realm.objects.all() | ||||
|  | ||||
|         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-")) | ||||
|             # private stream count | ||||
|             private_count = 0 | ||||
|             # public stream count | ||||
|             public_count = 0 | ||||
|             invite_only_count = 0 | ||||
|             for stream in streams: | ||||
|                 if stream.invite_only: | ||||
|                     private_count += 1 | ||||
|                 else: | ||||
|                     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' | ||||
|                     invite_only_count += 1 | ||||
|                     continue | ||||
|                 print("%25s" % (stream.name,), end=' ') | ||||
|                 recipient = Recipient.objects.filter(type=Recipient.STREAM, type_id=stream.id) | ||||
|                 print("%10d" % (len(Subscription.objects.filter(recipient=recipient, | ||||
|                                                                 active=True)),), end=' ') | ||||
|                 print("%10d" % (len(Subscription.objects.filter(recipient=recipient, active=True)),), end=' ') | ||||
|                 num_messages = len(Message.objects.filter(recipient=recipient)) | ||||
|                 print("%12d" % (num_messages,), end=' ') | ||||
|                 print("%15s" % (stream_type,)) | ||||
|                 print("%12d" % (num_messages,)) | ||||
|             print("%d invite-only streams" % (invite_only_count,)) | ||||
|             print("") | ||||
|   | ||||
| @@ -1,30 +1,34 @@ | ||||
| import os | ||||
| import time | ||||
| from argparse import ArgumentParser | ||||
| from typing import Any, Dict | ||||
| import sys | ||||
| from scripts.lib.zulip_tools import ENDC, WARNING | ||||
|  | ||||
| from argparse import ArgumentParser | ||||
| from datetime import timedelta | ||||
| import time | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.core.management.base import BaseCommand | ||||
| from django.utils.dateparse import parse_datetime | ||||
| from django.utils.timezone import now as timezone_now | ||||
| from django.utils.timezone import utc as timezone_utc | ||||
| from django.utils.dateparse import parse_datetime | ||||
| from django.conf import settings | ||||
|  | ||||
| from analytics.models import RealmCount, UserCount | ||||
| from analytics.lib.counts import COUNT_STATS, logger, process_count_stat | ||||
| 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.models import Realm | ||||
| from zerver.models import UserProfile, Message, Realm | ||||
|  | ||||
| from typing import Any, Dict | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     help = """Fills Analytics tables. | ||||
|  | ||||
|     Run as a cron job that runs every hour.""" | ||||
|  | ||||
|     def add_arguments(self, parser: ArgumentParser) -> None: | ||||
|     def add_arguments(self, parser): | ||||
|         # type: (ArgumentParser) -> None | ||||
|         parser.add_argument('--time', '-t', | ||||
|                             type=str, | ||||
|                             help='Update stat tables from current state to' | ||||
|                                  '--time. Defaults to the current time.', | ||||
|                             help='Update stat tables from current state to --time. Defaults to the current time.', | ||||
|                             default=timezone_now().isoformat()) | ||||
|         parser.add_argument('--utc', | ||||
|                             action='store_true', | ||||
| @@ -38,7 +42,8 @@ class Command(BaseCommand): | ||||
|                             help="Print timing information to stdout.", | ||||
|                             default=False) | ||||
|  | ||||
|     def handle(self, *args: Any, **options: Any) -> None: | ||||
|     def handle(self, *args, **options): | ||||
|         # type: (*Any, **Any) -> None | ||||
|         try: | ||||
|             os.mkdir(settings.ANALYTICS_LOCK_DIR) | ||||
|         except OSError: | ||||
| @@ -50,7 +55,8 @@ class Command(BaseCommand): | ||||
|         finally: | ||||
|             os.rmdir(settings.ANALYTICS_LOCK_DIR) | ||||
|  | ||||
|     def run_update_analytics_counts(self, options: Dict[str, Any]) -> None: | ||||
|     def run_update_analytics_counts(self, options): | ||||
|         # type: (Dict[str, Any]) -> None | ||||
|         # installation_epoch relies on there being at least one realm; we | ||||
|         # shouldn't run the analytics code if that condition isn't satisfied | ||||
|         if not Realm.objects.exists(): | ||||
| @@ -85,6 +91,3 @@ class Command(BaseCommand): | ||||
|             print("Finished updating analytics counts through %s in %.3fs" % | ||||
|                   (fill_to_time, time.time() - start)) | ||||
|         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() | ||||
|   | ||||
| @@ -1,30 +1,35 @@ | ||||
| import datetime | ||||
| from argparse import ArgumentParser | ||||
| import datetime | ||||
| import pytz | ||||
| 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 zerver.models import Message, Realm, Stream, UserProfile, get_realm | ||||
| from zerver.models import UserProfile, Realm, Stream, Message, get_realm | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     help = "Generate statistics on user activity." | ||||
|  | ||||
|     def add_arguments(self, parser: ArgumentParser) -> None: | ||||
|     def add_arguments(self, parser): | ||||
|         # type: (ArgumentParser) -> None | ||||
|         parser.add_argument('realms', metavar='<realm>', type=str, nargs='*', | ||||
|                             help="realm to generate statistics for") | ||||
|  | ||||
|     def messages_sent_by(self, user: UserProfile, week: int) -> int: | ||||
|     def messages_sent_by(self, user, week): | ||||
|         # type: (UserProfile, int) -> int | ||||
|         start = timezone_now() - datetime.timedelta(days=(week + 1)*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, **options): | ||||
|         # type: (*Any, **Any) -> None | ||||
|         if options['realms']: | ||||
|             try: | ||||
|                 realms = [get_realm(string_id) for string_id in options['realms']] | ||||
|             except Realm.DoesNotExist as e: | ||||
|                 raise CommandError(e) | ||||
|                 print(e) | ||||
|                 exit(1) | ||||
|         else: | ||||
|             realms = Realm.objects.all() | ||||
|  | ||||
| @@ -37,5 +42,5 @@ class Command(BaseCommand): | ||||
|             for user_profile in user_profiles: | ||||
|                 print("%35s" % (user_profile.email,), end=' ') | ||||
|                 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("") | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| from django.db import models, migrations | ||||
| import django.db.models.deletion | ||||
| from django.conf import settings | ||||
| from django.db import migrations, models | ||||
| import zerver.lib.str_utils | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
| @@ -17,7 +19,7 @@ class Migration(migrations.Migration): | ||||
|                 ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), | ||||
|                 ('info', models.CharField(max_length=1000)), | ||||
|             ], | ||||
|             bases=(models.Model,), | ||||
|             bases=(zerver.lib.str_utils.ModelReprMixin, models.Model), | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name='HuddleCount', | ||||
| @@ -31,7 +33,7 @@ class Migration(migrations.Migration): | ||||
|                 ('value', models.BigIntegerField()), | ||||
|                 ('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( | ||||
|             name='InstallationCount', | ||||
| @@ -43,7 +45,7 @@ class Migration(migrations.Migration): | ||||
|                 ('value', models.BigIntegerField()), | ||||
|                 ('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( | ||||
|             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)), | ||||
|  | ||||
|             ], | ||||
|             bases=(models.Model,), | ||||
|             bases=(zerver.lib.str_utils.ModelReprMixin, models.Model), | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name='StreamCount', | ||||
| @@ -71,7 +73,7 @@ class Migration(migrations.Migration): | ||||
|                 ('value', models.BigIntegerField()), | ||||
|                 ('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( | ||||
|             name='UserCount', | ||||
| @@ -85,7 +87,7 @@ class Migration(migrations.Migration): | ||||
|                 ('value', models.BigIntegerField()), | ||||
|                 ('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( | ||||
|             name='usercount', | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| from django.db import migrations | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| from django.db import migrations, models | ||||
| import zerver.lib.str_utils | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
| @@ -17,6 +19,6 @@ class Migration(migrations.Migration): | ||||
|                 ('state', models.PositiveSmallIntegerField()), | ||||
|                 ('last_modified', models.DateTimeField(auto_now=True)), | ||||
|             ], | ||||
|             bases=(models.Model,), | ||||
|             bases=(zerver.lib.str_utils.ModelReprMixin, models.Model), | ||||
|         ), | ||||
|     ] | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| from django.db import migrations | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| # Generated by Django 1.10.4 on 2017-01-16 20:50 | ||||
| from django.conf import settings | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
| # Generated by Django 1.10.5 on 2017-02-01 22:28 | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|   | ||||
| @@ -1,9 +1,11 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| from django.db import migrations | ||||
| from django.db.backends.postgresql_psycopg2.schema import DatabaseSchemaEditor | ||||
| from django.db.migrations.state import StateApps | ||||
| from django.db import migrations | ||||
|  | ||||
| def delete_messages_sent_to_stream_stat(apps: StateApps, schema_editor: DatabaseSchemaEditor) -> None: | ||||
|  | ||||
| def delete_messages_sent_to_stream_stat(apps, schema_editor): | ||||
|     # type: (StateApps, DatabaseSchemaEditor) -> None | ||||
|     UserCount = apps.get_model('analytics', 'UserCount') | ||||
|     StreamCount = apps.get_model('analytics', 'StreamCount') | ||||
|     RealmCount = apps.get_model('analytics', 'RealmCount') | ||||
|   | ||||
| @@ -1,9 +1,10 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| from django.db import migrations | ||||
| from django.db.backends.postgresql_psycopg2.schema import DatabaseSchemaEditor | ||||
| from django.db.migrations.state import StateApps | ||||
| from django.db import migrations | ||||
|  | ||||
| def clear_message_sent_by_message_type_values(apps: StateApps, schema_editor: DatabaseSchemaEditor) -> None: | ||||
| def clear_message_sent_by_message_type_values(apps, schema_editor): | ||||
|     # type: (StateApps, DatabaseSchemaEditor) -> None | ||||
|     UserCount = apps.get_model('analytics', 'UserCount') | ||||
|     StreamCount = apps.get_model('analytics', 'StreamCount') | ||||
|     RealmCount = apps.get_model('analytics', 'RealmCount') | ||||
|   | ||||
| @@ -1,9 +1,11 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| from django.db import migrations | ||||
| from django.db.backends.postgresql_psycopg2.schema import DatabaseSchemaEditor | ||||
| from django.db.migrations.state import StateApps | ||||
| from django.db import migrations | ||||
|  | ||||
| def clear_analytics_tables(apps: StateApps, schema_editor: DatabaseSchemaEditor) -> None: | ||||
|  | ||||
| def clear_analytics_tables(apps, schema_editor): | ||||
|     # type: (StateApps, DatabaseSchemaEditor) -> None | ||||
|     UserCount = apps.get_model('analytics', 'UserCount') | ||||
|     StreamCount = apps.get_model('analytics', 'StreamCount') | ||||
|     RealmCount = apps.get_model('analytics', 'RealmCount') | ||||
|   | ||||
| @@ -1,36 +0,0 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| # Generated by Django 1.11.6 on 2018-01-29 08:14 | ||||
| from __future__ import unicode_literals | ||||
|  | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('analytics', '0011_clear_analytics_tables'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name='installationcount', | ||||
|             name='anomaly', | ||||
|             field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='analytics.Anomaly'), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='realmcount', | ||||
|             name='anomaly', | ||||
|             field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='analytics.Anomaly'), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='streamcount', | ||||
|             name='anomaly', | ||||
|             field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='analytics.Anomaly'), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='usercount', | ||||
|             name='anomaly', | ||||
|             field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='analytics.Anomaly'), | ||||
|         ), | ||||
|     ] | ||||
| @@ -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', | ||||
|         ), | ||||
|     ] | ||||
| @@ -1,13 +1,15 @@ | ||||
| import datetime | ||||
| from typing import Optional | ||||
|  | ||||
| from django.db import models | ||||
|  | ||||
| from zerver.models import Realm, UserProfile, Stream, Recipient | ||||
| from zerver.lib.str_utils import ModelReprMixin | ||||
| from zerver.lib.timestamp import floor_to_day | ||||
| from zerver.models import Realm, Stream, UserProfile | ||||
|  | ||||
| class FillState(models.Model): | ||||
|     property = models.CharField(max_length=40, unique=True)  # type: str | ||||
| import datetime | ||||
|  | ||||
| from typing import Optional, Tuple, Union, Dict, Any, Text | ||||
|  | ||||
| class FillState(ModelReprMixin, models.Model): | ||||
|     property = models.CharField(max_length=40, unique=True)  # type: Text | ||||
|     end_time = models.DateTimeField()  # type: datetime.datetime | ||||
|  | ||||
|     # Valid states are {DONE, STARTED} | ||||
| @@ -17,16 +19,19 @@ class FillState(models.Model): | ||||
|  | ||||
|     last_modified = models.DateTimeField(auto_now=True)  # type: datetime.datetime | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return "<FillState: %s %s %s>" % (self.property, self.end_time, self.state) | ||||
|     def __unicode__(self): | ||||
|         # type: () -> Text | ||||
|         return u"<FillState: %s %s %s>" % (self.property, self.end_time, self.state) | ||||
|  | ||||
| # The earliest/starting end_time in FillState | ||||
| # We assume there is at least one realm | ||||
| def installation_epoch() -> datetime.datetime: | ||||
| def installation_epoch(): | ||||
|     # type: () -> datetime.datetime | ||||
|     earliest_realm_creation = Realm.objects.aggregate(models.Min('date_created'))['date_created__min'] | ||||
|     return floor_to_day(earliest_realm_creation) | ||||
|  | ||||
| def last_successful_fill(property: str) -> Optional[datetime.datetime]: | ||||
| def last_successful_fill(property): | ||||
|     # type: (str) -> Optional[datetime.datetime] | ||||
|     fillstate = FillState.objects.filter(property=property).first() | ||||
|     if fillstate is None: | ||||
|         return None | ||||
| @@ -34,59 +39,71 @@ def last_successful_fill(property: str) -> Optional[datetime.datetime]: | ||||
|         return fillstate.end_time | ||||
|     return fillstate.end_time - datetime.timedelta(hours=1) | ||||
|  | ||||
| class BaseCount(models.Model): | ||||
| # would only ever make entries here by hand | ||||
| class Anomaly(ModelReprMixin, models.Model): | ||||
|     info = models.CharField(max_length=1000)  # type: Text | ||||
|  | ||||
|     def __unicode__(self): | ||||
|         # type: () -> Text | ||||
|         return u"<Anomaly: %s... %s>" % (self.info, self.id) | ||||
|  | ||||
| class BaseCount(ModelReprMixin, models.Model): | ||||
|     # Note: When inheriting from BaseCount, you may want to rearrange | ||||
|     # the order of the columns in the migration to make sure they | ||||
|     # match how you'd like the table to be arranged. | ||||
|     property = models.CharField(max_length=32)  # type: str | ||||
|     subgroup = models.CharField(max_length=16, null=True)  # type: Optional[str] | ||||
|     property = models.CharField(max_length=32)  # type: Text | ||||
|     subgroup = models.CharField(max_length=16, null=True)  # type: Optional[Text] | ||||
|     end_time = models.DateTimeField()  # type: datetime.datetime | ||||
|     value = models.BigIntegerField()  # type: int | ||||
|     anomaly = models.ForeignKey(Anomaly, null=True)  # type: Optional[Anomaly] | ||||
|  | ||||
|     class Meta: | ||||
|     class Meta(object): | ||||
|         abstract = True | ||||
|  | ||||
| class InstallationCount(BaseCount): | ||||
|  | ||||
|     class Meta: | ||||
|     class Meta(object): | ||||
|         unique_together = ("property", "subgroup", "end_time") | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return "<InstallationCount: %s %s %s>" % (self.property, self.subgroup, self.value) | ||||
|     def __unicode__(self): | ||||
|         # type: () -> Text | ||||
|         return u"<InstallationCount: %s %s %s>" % (self.property, self.subgroup, self.value) | ||||
|  | ||||
| class RealmCount(BaseCount): | ||||
|     realm = models.ForeignKey(Realm, on_delete=models.CASCADE) | ||||
|     realm = models.ForeignKey(Realm) | ||||
|  | ||||
|     class Meta: | ||||
|     class Meta(object): | ||||
|         unique_together = ("realm", "property", "subgroup", "end_time") | ||||
|         index_together = ["property", "end_time"] | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return "<RealmCount: %s %s %s %s>" % (self.realm, self.property, self.subgroup, self.value) | ||||
|     def __unicode__(self): | ||||
|         # type: () -> Text | ||||
|         return u"<RealmCount: %s %s %s %s>" % (self.realm, self.property, self.subgroup, self.value) | ||||
|  | ||||
| class UserCount(BaseCount): | ||||
|     user = models.ForeignKey(UserProfile, on_delete=models.CASCADE) | ||||
|     realm = models.ForeignKey(Realm, on_delete=models.CASCADE) | ||||
|     user = models.ForeignKey(UserProfile) | ||||
|     realm = models.ForeignKey(Realm) | ||||
|  | ||||
|     class Meta: | ||||
|     class Meta(object): | ||||
|         unique_together = ("user", "property", "subgroup", "end_time") | ||||
|         # This index dramatically improves the performance of | ||||
|         # aggregating from users to realms | ||||
|         index_together = ["property", "realm", "end_time"] | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return "<UserCount: %s %s %s %s>" % (self.user, self.property, self.subgroup, self.value) | ||||
|     def __unicode__(self): | ||||
|         # type: () -> Text | ||||
|         return u"<UserCount: %s %s %s %s>" % (self.user, self.property, self.subgroup, self.value) | ||||
|  | ||||
| class StreamCount(BaseCount): | ||||
|     stream = models.ForeignKey(Stream, on_delete=models.CASCADE) | ||||
|     realm = models.ForeignKey(Realm, on_delete=models.CASCADE) | ||||
|     stream = models.ForeignKey(Stream) | ||||
|     realm = models.ForeignKey(Realm) | ||||
|  | ||||
|     class Meta: | ||||
|     class Meta(object): | ||||
|         unique_together = ("stream", "property", "subgroup", "end_time") | ||||
|         # This index dramatically improves the performance of | ||||
|         # aggregating from streams to realms | ||||
|         index_together = ["property", "realm", "end_time"] | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return "<StreamCount: %s %s %s %s %s>" % ( | ||||
|             self.stream, self.property, self.subgroup, self.value, self.id) | ||||
|     def __unicode__(self): | ||||
|         # type: () -> Text | ||||
|         return u"<StreamCount: %s %s %s %s %s>" % (self.stream, self.property, self.subgroup, self.value, self.id) | ||||
|   | ||||
| @@ -1,8 +1,4 @@ | ||||
| from datetime import datetime, timedelta | ||||
| from typing import Any, Dict, List, Optional, Tuple, Type | ||||
|  | ||||
| import mock | ||||
| import ujson | ||||
| from django.apps import apps | ||||
| from django.db import models | ||||
| from django.db.models import Sum | ||||
| @@ -10,24 +6,23 @@ from django.test import TestCase | ||||
| from django.utils.timezone import now as timezone_now | ||||
| from django.utils.timezone import utc as timezone_utc | ||||
|  | ||||
| from analytics.lib.counts import COUNT_STATS, CountStat, \ | ||||
|     DependentCountStat, LoggingCountStat, do_aggregate_to_summary_table, \ | ||||
|     do_drop_all_analytics_tables, do_drop_single_stat, \ | ||||
|     do_fill_count_stat_at_hour, do_increment_logging_stat, \ | ||||
|     process_count_stat, sql_data_collector | ||||
| from analytics.models import BaseCount, \ | ||||
|     FillState, InstallationCount, RealmCount, StreamCount, \ | ||||
|     UserCount, installation_epoch | ||||
| from zerver.lib.actions import do_activate_user, do_create_user, \ | ||||
|     do_deactivate_user, do_reactivate_user, update_user_activity_interval, \ | ||||
|     do_invite_users, do_revoke_user_invite, do_resend_user_invite_email, \ | ||||
|     InvitationError | ||||
| from zerver.lib.create_user import create_user | ||||
| 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, \ | ||||
|     RealmAuditLog, Recipient, Stream, UserActivityInterval, \ | ||||
|     UserProfile, get_client, get_user, PreregistrationUser | ||||
| from analytics.lib.counts import CountStat, COUNT_STATS, process_count_stat, \ | ||||
|     do_fill_count_stat_at_hour, do_increment_logging_stat, DataCollector, \ | ||||
|     sql_data_collector, LoggingCountStat, do_aggregate_to_summary_table, \ | ||||
|     do_drop_all_analytics_tables, do_drop_single_stat, DependentCountStat | ||||
| from analytics.models import BaseCount, InstallationCount, RealmCount, \ | ||||
|     UserCount, StreamCount, FillState, Anomaly, installation_epoch, \ | ||||
|     last_successful_fill | ||||
| from zerver.lib.actions import do_create_user, do_deactivate_user, \ | ||||
|     do_activate_user, do_reactivate_user, update_user_activity_interval | ||||
| from zerver.lib.timestamp import floor_to_day, TimezoneNotUTCException | ||||
| from zerver.models import Realm, UserProfile, Message, Stream, Recipient, \ | ||||
|     Huddle, Client, UserActivityInterval, RealmAuditLog, get_client, get_user | ||||
|  | ||||
| from datetime import datetime, timedelta | ||||
| import ujson | ||||
|  | ||||
| from typing import Any, Dict, List, Optional, Text, Tuple, Type, Union | ||||
|  | ||||
| class AnalyticsTestCase(TestCase): | ||||
|     MINUTE = timedelta(seconds = 60) | ||||
| @@ -36,8 +31,8 @@ class AnalyticsTestCase(TestCase): | ||||
|     TIME_ZERO = datetime(1988, 3, 14).replace(tzinfo=timezone_utc) | ||||
|     TIME_LAST_HOUR = TIME_ZERO - HOUR | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         super().setUp() | ||||
|     def setUp(self): | ||||
|         # type: () -> None | ||||
|         self.default_realm = Realm.objects.create( | ||||
|             string_id='realmtest', name='Realm Test', date_created=self.TIME_ZERO - 2*self.DAY) | ||||
|         # used to generate unique names in self.create_* | ||||
| @@ -46,30 +41,24 @@ class AnalyticsTestCase(TestCase): | ||||
|         self.current_property = None  # type: Optional[str] | ||||
|  | ||||
|     # Lightweight creation of users, streams, and messages | ||||
|     def create_user(self, **kwargs: Any) -> UserProfile: | ||||
|     def create_user(self, **kwargs): | ||||
|         # type: (**Any) -> UserProfile | ||||
|         self.name_counter += 1 | ||||
|         defaults = { | ||||
|             'email': 'user%s@domain.tld' % (self.name_counter,), | ||||
|             'date_joined': self.TIME_LAST_HOUR, | ||||
|             'full_name': 'full_name', | ||||
|             'short_name': 'short_name', | ||||
|             'is_active': True, | ||||
|             'is_bot': False, | ||||
|             'realm': self.default_realm} | ||||
|             'pointer': -1, | ||||
|             'last_pointer_updater': 'seems unused?', | ||||
|             'realm': self.default_realm, | ||||
|             'api_key': '42'} | ||||
|         for key, value in defaults.items(): | ||||
|             kwargs[key] = kwargs.get(key, value) | ||||
|         kwargs['delivery_email'] = kwargs['email'] | ||||
|         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) | ||||
|         return UserProfile.objects.create(**kwargs) | ||||
|  | ||||
|     def create_stream_with_recipient(self, **kwargs: Any) -> Tuple[Stream, Recipient]: | ||||
|     def create_stream_with_recipient(self, **kwargs): | ||||
|         # type: (**Any) -> Tuple[Stream, Recipient] | ||||
|         self.name_counter += 1 | ||||
|         defaults = {'name': 'stream name %s' % (self.name_counter,), | ||||
|                     'realm': self.default_realm, | ||||
| @@ -80,7 +69,8 @@ class AnalyticsTestCase(TestCase): | ||||
|         recipient = Recipient.objects.create(type_id=stream.id, type=Recipient.STREAM) | ||||
|         return stream, recipient | ||||
|  | ||||
|     def create_huddle_with_recipient(self, **kwargs: Any) -> Tuple[Huddle, Recipient]: | ||||
|     def create_huddle_with_recipient(self, **kwargs): | ||||
|         # type: (**Any) -> Tuple[Huddle, Recipient] | ||||
|         self.name_counter += 1 | ||||
|         defaults = {'huddle_hash': 'hash%s' % (self.name_counter,)} | ||||
|         for key, value in defaults.items(): | ||||
| @@ -89,22 +79,23 @@ class AnalyticsTestCase(TestCase): | ||||
|         recipient = Recipient.objects.create(type_id=huddle.id, type=Recipient.HUDDLE) | ||||
|         return huddle, recipient | ||||
|  | ||||
|     def create_message(self, sender: UserProfile, recipient: Recipient, **kwargs: Any) -> Message: | ||||
|     def create_message(self, sender, recipient, **kwargs): | ||||
|         # type: (UserProfile, Recipient, **Any) -> Message | ||||
|         defaults = { | ||||
|             'sender': sender, | ||||
|             'recipient': recipient, | ||||
|             DB_TOPIC_NAME: 'subject', | ||||
|             'subject': 'subject', | ||||
|             'content': 'hi', | ||||
|             'date_sent': self.TIME_LAST_HOUR, | ||||
|             'pub_date': self.TIME_LAST_HOUR, | ||||
|             'sending_client': get_client("website")} | ||||
|         for key, value in defaults.items(): | ||||
|             kwargs[key] = kwargs.get(key, value) | ||||
|         return Message.objects.create(**kwargs) | ||||
|  | ||||
|     # kwargs should only ever be a UserProfile or Stream. | ||||
|     def assertCountEquals(self, table: Type[BaseCount], value: int, property: Optional[str]=None, | ||||
|                           subgroup: Optional[str]=None, end_time: datetime=TIME_ZERO, | ||||
|                           realm: Optional[Realm]=None, **kwargs: models.Model) -> None: | ||||
|     def assertCountEquals(self, table, value, property=None, subgroup=None, | ||||
|                           end_time=TIME_ZERO, realm=None, **kwargs): | ||||
|         # type: (Type[BaseCount], int, Optional[Text], Optional[Text], datetime, Optional[Realm], **models.Model) -> None | ||||
|         if property is None: | ||||
|             property = self.current_property | ||||
|         queryset = table.objects.filter(property=property, end_time=end_time).filter(**kwargs) | ||||
| @@ -116,8 +107,8 @@ class AnalyticsTestCase(TestCase): | ||||
|             queryset = queryset.filter(subgroup=subgroup) | ||||
|         self.assertEqual(queryset.values_list('value', flat=True)[0], value) | ||||
|  | ||||
|     def assertTableState(self, table: Type[BaseCount], arg_keys: List[str], | ||||
|                          arg_values: List[List[object]]) -> None: | ||||
|     def assertTableState(self, table, arg_keys, arg_values): | ||||
|         # type: (Type[BaseCount], List[str], List[List[Union[int, str, bool, datetime, Realm, UserProfile, Stream]]]) -> None | ||||
|         """Assert that the state of a *Count table is what it should be. | ||||
|  | ||||
|         Example usage: | ||||
| @@ -162,18 +153,20 @@ class AnalyticsTestCase(TestCase): | ||||
|         self.assertEqual(table.objects.count(), len(arg_values)) | ||||
|  | ||||
| class TestProcessCountStat(AnalyticsTestCase): | ||||
|     def make_dummy_count_stat(self, property: str) -> CountStat: | ||||
|     def make_dummy_count_stat(self, property): | ||||
|         # type: (str) -> CountStat | ||||
|         query = """INSERT INTO analytics_realmcount (realm_id, value, property, end_time) | ||||
|                    VALUES (%s, 1, '%s', %%%%(time_end)s)""" % (self.default_realm.id, property) | ||||
|         return CountStat(property, sql_data_collector(RealmCount, query, None), CountStat.HOUR) | ||||
|  | ||||
|     def assertFillStateEquals(self, stat: CountStat, end_time: datetime, | ||||
|                               state: int=FillState.DONE) -> None: | ||||
|     def assertFillStateEquals(self, stat, end_time, state=FillState.DONE): | ||||
|         # type: (CountStat, datetime, int) -> None | ||||
|         fill_state = FillState.objects.filter(property=stat.property).first() | ||||
|         self.assertEqual(fill_state.end_time, end_time) | ||||
|         self.assertEqual(fill_state.state, state) | ||||
|  | ||||
|     def test_process_stat(self) -> None: | ||||
|     def test_process_stat(self): | ||||
|         # type: () -> None | ||||
|         # process new stat | ||||
|         current_time = installation_epoch() + self.HOUR | ||||
|         stat = self.make_dummy_count_stat('test stat') | ||||
| @@ -199,7 +192,8 @@ class TestProcessCountStat(AnalyticsTestCase): | ||||
|         self.assertFillStateEquals(stat, current_time) | ||||
|         self.assertEqual(InstallationCount.objects.filter(property=stat.property).count(), 2) | ||||
|  | ||||
|     def test_bad_fill_to_time(self) -> None: | ||||
|     def test_bad_fill_to_time(self): | ||||
|         # type: () -> None | ||||
|         stat = self.make_dummy_count_stat('test stat') | ||||
|         with self.assertRaises(ValueError): | ||||
|             process_count_stat(stat, installation_epoch() + 65*self.MINUTE) | ||||
| @@ -209,7 +203,8 @@ class TestProcessCountStat(AnalyticsTestCase): | ||||
|     # This tests the LoggingCountStat branch of the code in do_delete_counts_at_hour. | ||||
|     # It is important that do_delete_counts_at_hour not delete any of the collected | ||||
|     # logging data! | ||||
|     def test_process_logging_stat(self) -> None: | ||||
|     def test_process_logging_stat(self): | ||||
|         # type: () -> None | ||||
|         end_time = self.TIME_ZERO | ||||
|  | ||||
|         user_stat = LoggingCountStat('user stat', UserCount, CountStat.DAY) | ||||
| @@ -231,13 +226,9 @@ class TestProcessCountStat(AnalyticsTestCase): | ||||
|         self.assertTableState(UserCount, ['property', 'value'], [[user_stat.property, 5]]) | ||||
|         self.assertTableState(StreamCount, ['property', 'value'], [[stream_stat.property, 5]]) | ||||
|         self.assertTableState(RealmCount, ['property', 'value'], | ||||
|                               [[user_stat.property, 5], | ||||
|                                [stream_stat.property, 5], | ||||
|                                [realm_stat.property, 5]]) | ||||
|                               [[user_stat.property, 5], [stream_stat.property, 5], [realm_stat.property, 5]]) | ||||
|         self.assertTableState(InstallationCount, ['property', 'value'], | ||||
|                               [[user_stat.property, 5], | ||||
|                                [stream_stat.property, 5], | ||||
|                                [realm_stat.property, 5]]) | ||||
|                               [[user_stat.property, 5], [stream_stat.property, 5], [realm_stat.property, 5]]) | ||||
|  | ||||
|         # Change the logged data and mark FillState as dirty | ||||
|         UserCount.objects.update(value=6) | ||||
| @@ -251,21 +242,17 @@ class TestProcessCountStat(AnalyticsTestCase): | ||||
|         self.assertTableState(UserCount, ['property', 'value'], [[user_stat.property, 6]]) | ||||
|         self.assertTableState(StreamCount, ['property', 'value'], [[stream_stat.property, 6]]) | ||||
|         self.assertTableState(RealmCount, ['property', 'value'], | ||||
|                               [[user_stat.property, 6], | ||||
|                                [stream_stat.property, 6], | ||||
|                                [realm_stat.property, 6]]) | ||||
|                               [[user_stat.property, 6], [stream_stat.property, 6], [realm_stat.property, 6]]) | ||||
|         self.assertTableState(InstallationCount, ['property', 'value'], | ||||
|                               [[user_stat.property, 6], | ||||
|                                [stream_stat.property, 6], | ||||
|                                [realm_stat.property, 6]]) | ||||
|                               [[user_stat.property, 6], [stream_stat.property, 6], [realm_stat.property, 6]]) | ||||
|  | ||||
|     def test_process_dependent_stat(self) -> None: | ||||
|     def test_process_dependent_stat(self): | ||||
|         # type: () -> None | ||||
|         stat1 = self.make_dummy_count_stat('stat1') | ||||
|         stat2 = self.make_dummy_count_stat('stat2') | ||||
|         query = """INSERT INTO analytics_realmcount (realm_id, value, property, end_time) | ||||
|                    VALUES (%s, 1, '%s', %%%%(time_end)s)""" % (self.default_realm.id, 'stat3') | ||||
|         stat3 = DependentCountStat('stat3', sql_data_collector(RealmCount, query, None), | ||||
|                                    CountStat.HOUR, | ||||
|         stat3 = DependentCountStat('stat3', sql_data_collector(RealmCount, query, None), CountStat.HOUR, | ||||
|                                    dependencies=['stat1', 'stat2']) | ||||
|         hour = [installation_epoch() + i*self.HOUR for i in range(5)] | ||||
|  | ||||
| @@ -298,8 +285,7 @@ class TestProcessCountStat(AnalyticsTestCase): | ||||
|         # test daily dependent stat with hourly dependencies | ||||
|         query = """INSERT INTO analytics_realmcount (realm_id, value, property, end_time) | ||||
|                    VALUES (%s, 1, '%s', %%%%(time_end)s)""" % (self.default_realm.id, 'stat4') | ||||
|         stat4 = DependentCountStat('stat4', sql_data_collector(RealmCount, query, None), | ||||
|                                    CountStat.DAY, | ||||
|         stat4 = DependentCountStat('stat4', sql_data_collector(RealmCount, query, None), CountStat.DAY, | ||||
|                                    dependencies=['stat1', 'stat2']) | ||||
|         hour24 = installation_epoch() + 24*self.HOUR | ||||
|         hour25 = installation_epoch() + 25*self.HOUR | ||||
| @@ -310,8 +296,9 @@ class TestProcessCountStat(AnalyticsTestCase): | ||||
|         self.assertFillStateEquals(stat4, hour24) | ||||
|  | ||||
| class TestCountStats(AnalyticsTestCase): | ||||
|     def setUp(self) -> None: | ||||
|         super().setUp() | ||||
|     def setUp(self): | ||||
|         # type: () -> None | ||||
|         super(TestCountStats, self).setUp() | ||||
|         # This tests two things for each of the queries/CountStats: Handling | ||||
|         # more than 1 realm, and the time bounds (time_start and time_end in | ||||
|         # the queries). | ||||
| @@ -325,7 +312,7 @@ class TestCountStats(AnalyticsTestCase): | ||||
|             recipient = self.create_stream_with_recipient( | ||||
|                 name='stream %s' % (minutes_ago,), realm=self.second_realm, | ||||
|                 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.daily_user = get_user('user-61@second.analytics', self.second_realm) | ||||
|  | ||||
| @@ -339,7 +326,8 @@ class TestCountStats(AnalyticsTestCase): | ||||
|         # This huddle should not show up anywhere | ||||
|         self.create_huddle_with_recipient() | ||||
|  | ||||
|     def test_active_users_by_is_bot(self) -> None: | ||||
|     def test_active_users_by_is_bot(self): | ||||
|         # type: () -> None | ||||
|         stat = COUNT_STATS['active_users:is_bot:day'] | ||||
|         self.current_property = stat.property | ||||
|  | ||||
| @@ -357,21 +345,19 @@ class TestCountStats(AnalyticsTestCase): | ||||
|                               [[2, 'true'], [1, 'false'], | ||||
|                                [3, 'false', self.second_realm], | ||||
|                                [1, 'false', self.no_message_realm]]) | ||||
|         self.assertTableState(InstallationCount, | ||||
|                               ['value', 'subgroup'], | ||||
|                               [[2, 'true'], [5, 'false']]) | ||||
|         self.assertTableState(InstallationCount, ['value', 'subgroup'], [[2, 'true'], [5, 'false']]) | ||||
|         self.assertTableState(UserCount, [], []) | ||||
|         self.assertTableState(StreamCount, [], []) | ||||
|  | ||||
|     def test_messages_sent_by_is_bot(self) -> None: | ||||
|     def test_messages_sent_by_is_bot(self): | ||||
|         # type: () -> None | ||||
|         stat = COUNT_STATS['messages_sent:is_bot:hour'] | ||||
|         self.current_property = stat.property | ||||
|  | ||||
|         bot = self.create_user(is_bot=True) | ||||
|         human1 = 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) | ||||
|  | ||||
|         recipient_stream = self.create_stream_with_recipient()[1] | ||||
|         recipient_huddle = self.create_huddle_with_recipient()[1] | ||||
| @@ -392,7 +378,8 @@ class TestCountStats(AnalyticsTestCase): | ||||
|         self.assertTableState(InstallationCount, ['value', 'subgroup'], [[3, 'false'], [3, 'true']]) | ||||
|         self.assertTableState(StreamCount, [], []) | ||||
|  | ||||
|     def test_messages_sent_by_message_type(self) -> None: | ||||
|     def test_messages_sent_by_message_type(self): | ||||
|         # type: () -> None | ||||
|         stat = COUNT_STATS['messages_sent:message_type:day'] | ||||
|         self.current_property = stat.property | ||||
|  | ||||
| @@ -425,9 +412,9 @@ class TestCountStats(AnalyticsTestCase): | ||||
|         self.create_message(user2, recipient_huddle2) | ||||
|  | ||||
|         # private messages | ||||
|         recipient_user1 = Recipient.objects.get(type_id=user1.id, type=Recipient.PERSONAL) | ||||
|         recipient_user2 = Recipient.objects.get(type_id=user2.id, type=Recipient.PERSONAL) | ||||
|         recipient_user3 = Recipient.objects.get(type_id=user3.id, type=Recipient.PERSONAL) | ||||
|         recipient_user1 = Recipient.objects.create(type_id=user1.id, type=Recipient.PERSONAL) | ||||
|         recipient_user2 = Recipient.objects.create(type_id=user2.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(user2, recipient_user1) | ||||
|         self.create_message(user3, recipient_user3) | ||||
| @@ -454,12 +441,13 @@ class TestCountStats(AnalyticsTestCase): | ||||
|                                [2, 'huddle_message']]) | ||||
|         self.assertTableState(StreamCount, [], []) | ||||
|  | ||||
|     def test_messages_sent_to_recipients_with_same_id(self) -> None: | ||||
|     def test_messages_sent_to_recipients_with_same_id(self): | ||||
|         # type: () -> None | ||||
|         stat = COUNT_STATS['messages_sent:message_type:day'] | ||||
|         self.current_property = stat.property | ||||
|  | ||||
|         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] | ||||
|         huddle_recipient = self.create_huddle_with_recipient(id=1000)[1] | ||||
|  | ||||
| @@ -473,13 +461,14 @@ class TestCountStats(AnalyticsTestCase): | ||||
|         self.assertCountEquals(UserCount, 1, subgroup='huddle_message') | ||||
|         self.assertCountEquals(UserCount, 1, subgroup='public_stream') | ||||
|  | ||||
|     def test_messages_sent_by_client(self) -> None: | ||||
|     def test_messages_sent_by_client(self): | ||||
|         # type: () -> None | ||||
|         stat = COUNT_STATS['messages_sent:client:day'] | ||||
|         self.current_property = stat.property | ||||
|  | ||||
|         user1 = self.create_user(is_bot=True) | ||||
|         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_huddle = self.create_huddle_with_recipient()[1] | ||||
| @@ -508,14 +497,15 @@ class TestCountStats(AnalyticsTestCase): | ||||
|                               [[4, website_client_id], [3, client2_id]]) | ||||
|         self.assertTableState(StreamCount, [], []) | ||||
|  | ||||
|     def test_messages_sent_to_stream_by_is_bot(self) -> None: | ||||
|     def test_messages_sent_to_stream_by_is_bot(self): | ||||
|         # type: () -> None | ||||
|         stat = COUNT_STATS['messages_in_stream:is_bot:day'] | ||||
|         self.current_property = stat.property | ||||
|  | ||||
|         bot = self.create_user(is_bot=True) | ||||
|         human1 = 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() | ||||
|         stream2, recipient_stream2 = self.create_stream_with_recipient() | ||||
| @@ -545,56 +535,14 @@ class TestCountStats(AnalyticsTestCase): | ||||
|         self.assertTableState(InstallationCount, ['value', 'subgroup'], [[5, 'false'], [2, 'true']]) | ||||
|         self.assertTableState(UserCount, [], []) | ||||
|  | ||||
|     def create_interval(self, user: UserProfile, start_offset: timedelta, | ||||
|                         end_offset: timedelta) -> None: | ||||
|     def create_interval(self, user, start_offset, end_offset): | ||||
|         # type: (UserProfile, timedelta, timedelta) -> None | ||||
|         UserActivityInterval.objects.create( | ||||
|             user_profile=user, start=self.TIME_ZERO-start_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): | ||||
|         # type: () -> None | ||||
|         stat = COUNT_STATS['15day_actives::day'] | ||||
|         self.current_property = stat.property | ||||
|  | ||||
| @@ -637,7 +585,8 @@ class TestCountStats(AnalyticsTestCase): | ||||
|         self.assertTableState(InstallationCount, ['value'], [[6]]) | ||||
|         self.assertTableState(StreamCount, [], []) | ||||
|  | ||||
|     def test_minutes_active(self) -> None: | ||||
|     def test_minutes_active(self): | ||||
|         # type: () -> None | ||||
|         stat = COUNT_STATS['minutes_active::day'] | ||||
|         self.current_property = stat.property | ||||
|  | ||||
| @@ -685,14 +634,16 @@ class TestDoAggregateToSummaryTable(AnalyticsTestCase): | ||||
|     # feature important for keeping the size of the analytics tables small, | ||||
|     # which is that if there is no relevant data in the table being | ||||
|     # aggregated, the aggregation table doesn't get a row with value 0. | ||||
|     def test_no_aggregated_zeros(self) -> None: | ||||
|     def test_no_aggregated_zeros(self): | ||||
|         # type: () -> None | ||||
|         stat = LoggingCountStat('test stat', UserCount, CountStat.HOUR) | ||||
|         do_aggregate_to_summary_table(stat, self.TIME_ZERO) | ||||
|         self.assertFalse(RealmCount.objects.exists()) | ||||
|         self.assertFalse(InstallationCount.objects.exists()) | ||||
|  | ||||
| class TestDoIncrementLoggingStat(AnalyticsTestCase): | ||||
|     def test_table_and_id_args(self) -> None: | ||||
|     def test_table_and_id_args(self): | ||||
|         # type: () -> None | ||||
|         # For realms, streams, and users, tests that the new rows are going to | ||||
|         # the appropriate *Count table, and that using a different zerver_object | ||||
|         # results in a new row being created | ||||
| @@ -717,7 +668,8 @@ class TestDoIncrementLoggingStat(AnalyticsTestCase): | ||||
|         do_increment_logging_stat(stream2, stat, None, self.TIME_ZERO) | ||||
|         self.assertTableState(StreamCount, ['stream'], [[stream1], [stream2]]) | ||||
|  | ||||
|     def test_frequency(self) -> None: | ||||
|     def test_frequency(self): | ||||
|         # type: () -> None | ||||
|         times = [self.TIME_ZERO - self.MINUTE*i for i in [0, 1, 61, 24*60+1]] | ||||
|  | ||||
|         stat = LoggingCountStat('day test', RealmCount, CountStat.DAY) | ||||
| @@ -734,7 +686,8 @@ class TestDoIncrementLoggingStat(AnalyticsTestCase): | ||||
|                                [1, 'hour test', self.TIME_LAST_HOUR], | ||||
|                                [1, 'hour test', self.TIME_ZERO - self.DAY]]) | ||||
|  | ||||
|     def test_get_or_create(self) -> None: | ||||
|     def test_get_or_create(self): | ||||
|         # type: () -> None | ||||
|         stat = LoggingCountStat('test', RealmCount, CountStat.HOUR) | ||||
|         # All these should trigger the create part of get_or_create. | ||||
|         # property is tested in test_frequency, and id_args are tested in test_id_args, | ||||
| @@ -752,7 +705,8 @@ class TestDoIncrementLoggingStat(AnalyticsTestCase): | ||||
|                               [[2, 'subgroup1', self.TIME_ZERO], [1, 'subgroup2', self.TIME_ZERO], | ||||
|                               [1, 'subgroup1', self.TIME_LAST_HOUR]]) | ||||
|  | ||||
|     def test_increment(self) -> None: | ||||
|     def test_increment(self): | ||||
|         # type: () -> None | ||||
|         stat = LoggingCountStat('test', RealmCount, CountStat.DAY) | ||||
|         self.current_property = 'test' | ||||
|         do_increment_logging_stat(self.default_realm, stat, None, self.TIME_ZERO, increment=-1) | ||||
| @@ -763,7 +717,8 @@ class TestDoIncrementLoggingStat(AnalyticsTestCase): | ||||
|         self.assertTableState(RealmCount, ['value'], [[3]]) | ||||
|  | ||||
| class TestLoggingCountStats(AnalyticsTestCase): | ||||
|     def test_aggregation(self) -> None: | ||||
|     def test_aggregation(self): | ||||
|         # type: () -> None | ||||
|         stat = LoggingCountStat('realm test', RealmCount, CountStat.DAY) | ||||
|         do_increment_logging_stat(self.default_realm, stat, None, self.TIME_ZERO) | ||||
|         process_count_stat(stat, self.TIME_ZERO) | ||||
| @@ -785,7 +740,8 @@ class TestLoggingCountStats(AnalyticsTestCase): | ||||
|         self.assertTableState(UserCount, ['property', 'value'], [['user test', 1]]) | ||||
|         self.assertTableState(StreamCount, ['property', 'value'], [['stream test', 1]]) | ||||
|  | ||||
|     def test_active_users_log_by_is_bot(self) -> None: | ||||
|     def test_active_users_log_by_is_bot(self): | ||||
|         # type: () -> None | ||||
|         property = 'active_users_log:is_bot:day' | ||||
|         user = do_create_user('email', 'password', self.default_realm, 'full_name', 'short_name') | ||||
|         self.assertEqual(1, RealmCount.objects.filter(property=property, subgroup=False) | ||||
| @@ -803,47 +759,9 @@ class TestLoggingCountStats(AnalyticsTestCase): | ||||
|         self.assertEqual(1, RealmCount.objects.filter(property=property, subgroup=False) | ||||
|                          .aggregate(Sum('value'))['value__sum']) | ||||
|  | ||||
|     def test_invites_sent(self) -> None: | ||||
|         property = 'invites_sent::day' | ||||
|  | ||||
|         def assertInviteCountEquals(count: int) -> None: | ||||
|             self.assertEqual(count, RealmCount.objects.filter(property=property, subgroup=None) | ||||
|                              .aggregate(Sum('value'))['value__sum']) | ||||
|  | ||||
|         user = self.create_user(email='first@domain.tld') | ||||
|         stream, _ = self.create_stream_with_recipient() | ||||
|         do_invite_users(user, ['user1@domain.tld', 'user2@domain.tld'], [stream]) | ||||
|         assertInviteCountEquals(2) | ||||
|  | ||||
|         # We currently send emails when re-inviting users that haven't | ||||
|         # turned into accounts, so count them towards the total | ||||
|         do_invite_users(user, ['user1@domain.tld', 'user2@domain.tld'], [stream]) | ||||
|         assertInviteCountEquals(4) | ||||
|  | ||||
|         # Test mix of good and malformed invite emails | ||||
|         try: | ||||
|             do_invite_users(user, ['user3@domain.tld', 'malformed'], [stream]) | ||||
|         except InvitationError: | ||||
|             pass | ||||
|         assertInviteCountEquals(4) | ||||
|  | ||||
|         # Test inviting existing users | ||||
|         try: | ||||
|             do_invite_users(user, ['first@domain.tld', 'user4@domain.tld'], [stream]) | ||||
|         except InvitationError: | ||||
|             pass | ||||
|         assertInviteCountEquals(5) | ||||
|  | ||||
|         # Revoking invite should not give you credit | ||||
|         do_revoke_user_invite(PreregistrationUser.objects.filter(realm=user.realm).first()) | ||||
|         assertInviteCountEquals(5) | ||||
|  | ||||
|         # Resending invite should cost you | ||||
|         do_resend_user_invite_email(PreregistrationUser.objects.first()) | ||||
|         assertInviteCountEquals(6) | ||||
|  | ||||
| class TestDeleteStats(AnalyticsTestCase): | ||||
|     def test_do_drop_all_analytics_tables(self) -> None: | ||||
|     def test_do_drop_all_analytics_tables(self): | ||||
|         # type: () -> None | ||||
|         user = self.create_user() | ||||
|         stream = self.create_stream_with_recipient()[0] | ||||
|         count_args = {'property': 'test', 'end_time': self.TIME_ZERO, 'value': 10} | ||||
| @@ -853,6 +771,7 @@ class TestDeleteStats(AnalyticsTestCase): | ||||
|         RealmCount.objects.create(realm=user.realm, **count_args) | ||||
|         InstallationCount.objects.create(**count_args) | ||||
|         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') | ||||
|         for table in list(analytics.models.values()): | ||||
| @@ -862,7 +781,8 @@ class TestDeleteStats(AnalyticsTestCase): | ||||
|         for table in list(analytics.models.values()): | ||||
|             self.assertFalse(table.objects.exists()) | ||||
|  | ||||
|     def test_do_drop_single_stat(self) -> None: | ||||
|     def test_do_drop_single_stat(self): | ||||
|         # type: () -> None | ||||
|         user = self.create_user() | ||||
|         stream = self.create_stream_with_recipient()[0] | ||||
|         count_args_to_delete = {'property': 'to_delete', 'end_time': self.TIME_ZERO, 'value': 10} | ||||
| @@ -875,6 +795,7 @@ class TestDeleteStats(AnalyticsTestCase): | ||||
|             InstallationCount.objects.create(**count_args) | ||||
|         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) | ||||
|         Anomaly.objects.create(info='test anomaly') | ||||
|  | ||||
|         analytics = apps.get_app_config('analytics') | ||||
|         for table in list(analytics.models.values()): | ||||
| @@ -882,18 +803,22 @@ class TestDeleteStats(AnalyticsTestCase): | ||||
|  | ||||
|         do_drop_single_stat('to_delete') | ||||
|         for table in list(analytics.models.values()): | ||||
|             self.assertFalse(table.objects.filter(property='to_delete').exists()) | ||||
|             self.assertTrue(table.objects.filter(property='to_save').exists()) | ||||
|             if table._meta.db_table == 'analytics_anomaly': | ||||
|                 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): | ||||
|     def setUp(self) -> None: | ||||
|         super().setUp() | ||||
|     def setUp(self): | ||||
|         # type: () -> None | ||||
|         super(TestActiveUsersAudit, self).setUp() | ||||
|         self.user = self.create_user() | ||||
|         self.stat = COUNT_STATS['active_users_audit:is_bot:day'] | ||||
|         self.current_property = self.stat.property | ||||
|  | ||||
|     def add_event(self, event_type: int, days_offset: float, | ||||
|                   user: Optional[UserProfile]=None) -> None: | ||||
|     def add_event(self, event_type, days_offset, user=None): | ||||
|         # type: (str, float, Optional[UserProfile]) -> None | ||||
|         hours_offset = int(24*days_offset) | ||||
|         if user is None: | ||||
|             user = self.user | ||||
| @@ -901,50 +826,57 @@ class TestActiveUsersAudit(AnalyticsTestCase): | ||||
|             realm=user.realm, modified_user=user, event_type=event_type, | ||||
|             event_time=self.TIME_ZERO - hours_offset*self.HOUR) | ||||
|  | ||||
|     def test_user_deactivated_in_future(self) -> None: | ||||
|         self.add_event(RealmAuditLog.USER_CREATED, 1) | ||||
|         self.add_event(RealmAuditLog.USER_DEACTIVATED, 0) | ||||
|     def test_user_deactivated_in_future(self): | ||||
|         # type: () -> None | ||||
|         self.add_event('user_created', 1) | ||||
|         self.add_event('user_deactivated', 0) | ||||
|         do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, ['subgroup'], [['false']]) | ||||
|  | ||||
|     def test_user_reactivated_in_future(self) -> None: | ||||
|         self.add_event(RealmAuditLog.USER_DEACTIVATED, 1) | ||||
|         self.add_event(RealmAuditLog.USER_REACTIVATED, 0) | ||||
|     def test_user_reactivated_in_future(self): | ||||
|         # type: () -> None | ||||
|         self.add_event('user_deactivated', 1) | ||||
|         self.add_event('user_reactivated', 0) | ||||
|         do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, [], []) | ||||
|  | ||||
|     def test_user_active_then_deactivated_same_day(self) -> None: | ||||
|         self.add_event(RealmAuditLog.USER_CREATED, 1) | ||||
|         self.add_event(RealmAuditLog.USER_DEACTIVATED, .5) | ||||
|     def test_user_active_then_deactivated_same_day(self): | ||||
|         # type: () -> None | ||||
|         self.add_event('user_created', 1) | ||||
|         self.add_event('user_deactivated', .5) | ||||
|         do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, [], []) | ||||
|  | ||||
|     def test_user_unactive_then_activated_same_day(self) -> None: | ||||
|         self.add_event(RealmAuditLog.USER_DEACTIVATED, 1) | ||||
|         self.add_event(RealmAuditLog.USER_REACTIVATED, .5) | ||||
|     def test_user_unactive_then_activated_same_day(self): | ||||
|         # type: () -> None | ||||
|         self.add_event('user_deactivated', 1) | ||||
|         self.add_event('user_reactivated', .5) | ||||
|         do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, ['subgroup'], [['false']]) | ||||
|  | ||||
|     # 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 | ||||
|     def test_user_active_then_deactivated_with_day_gap(self) -> None: | ||||
|         self.add_event(RealmAuditLog.USER_CREATED, 2) | ||||
|         self.add_event(RealmAuditLog.USER_DEACTIVATED, 1) | ||||
|     def test_user_active_then_deactivated_with_day_gap(self): | ||||
|         # type: () -> None | ||||
|         self.add_event('user_created', 2) | ||||
|         self.add_event('user_deactivated', 1) | ||||
|         process_count_stat(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, ['subgroup', 'end_time'], | ||||
|                               [['false', self.TIME_ZERO - self.DAY]]) | ||||
|  | ||||
|     def test_user_deactivated_then_reactivated_with_day_gap(self) -> None: | ||||
|         self.add_event(RealmAuditLog.USER_DEACTIVATED, 2) | ||||
|         self.add_event(RealmAuditLog.USER_REACTIVATED, 1) | ||||
|     def test_user_deactivated_then_reactivated_with_day_gap(self): | ||||
|         # type: () -> None | ||||
|         self.add_event('user_deactivated', 2) | ||||
|         self.add_event('user_reactivated', 1) | ||||
|         process_count_stat(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, ['subgroup'], [['false']]) | ||||
|  | ||||
|     def test_event_types(self) -> None: | ||||
|         self.add_event(RealmAuditLog.USER_CREATED, 4) | ||||
|         self.add_event(RealmAuditLog.USER_DEACTIVATED, 3) | ||||
|         self.add_event(RealmAuditLog.USER_ACTIVATED, 2) | ||||
|         self.add_event(RealmAuditLog.USER_REACTIVATED, 1) | ||||
|     def test_event_types(self): | ||||
|         # type: () -> None | ||||
|         self.add_event('user_created', 4) | ||||
|         self.add_event('user_deactivated', 3) | ||||
|         self.add_event('user_activated', 2) | ||||
|         self.add_event('user_reactivated', 1) | ||||
|         for i in range(4): | ||||
|             do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO - i*self.DAY) | ||||
|         self.assertTableState(UserCount, ['subgroup', 'end_time'], | ||||
| @@ -952,14 +884,15 @@ class TestActiveUsersAudit(AnalyticsTestCase): | ||||
|  | ||||
|     # Also tests that aggregation to RealmCount and InstallationCount is | ||||
|     # being done, and that we're storing the user correctly in UserCount | ||||
|     def test_multiple_users_realms_and_bots(self) -> None: | ||||
|     def test_multiple_users_realms_and_bots(self): | ||||
|         # type: () -> None | ||||
|         user1 = self.create_user() | ||||
|         user2 = self.create_user() | ||||
|         second_realm = Realm.objects.create(string_id='moo', name='moo') | ||||
|         user3 = self.create_user(realm=second_realm) | ||||
|         user4 = self.create_user(realm=second_realm, is_bot=True) | ||||
|         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) | ||||
|         self.assertTableState(UserCount, ['subgroup', 'user'], | ||||
|                               [['false', user1], ['false', user2], ['false', user3], ['true', user4]]) | ||||
| @@ -976,8 +909,9 @@ class TestActiveUsersAudit(AnalyticsTestCase): | ||||
|     # do_fill_count_stat_at_hour. E.g. if one changes self.stat.frequency to | ||||
|     # CountStat.HOUR from CountStat.DAY, this will fail, while many of the | ||||
|     # tests above will not. | ||||
|     def test_update_from_two_days_ago(self) -> None: | ||||
|         self.add_event(RealmAuditLog.USER_CREATED, 2) | ||||
|     def test_update_from_two_days_ago(self): | ||||
|         # type: () -> None | ||||
|         self.add_event('user_created', 2) | ||||
|         process_count_stat(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, ['subgroup', 'end_time'], | ||||
|                               [['false', self.TIME_ZERO], ['false', self.TIME_ZERO-self.DAY]]) | ||||
| @@ -985,39 +919,44 @@ class TestActiveUsersAudit(AnalyticsTestCase): | ||||
|     # User with no relevant activity could happen e.g. for a system bot that | ||||
|     # doesn't go through do_create_user. Mainly just want to make sure that | ||||
|     # that situation doesn't throw an error. | ||||
|     def test_empty_realm_or_user_with_no_relevant_activity(self) -> None: | ||||
|         self.add_event(RealmAuditLog.USER_SOFT_ACTIVATED, 1) | ||||
|     def test_empty_realm_or_user_with_no_relevant_activity(self): | ||||
|         # type: () -> None | ||||
|         self.add_event('unrelated', 1) | ||||
|         self.create_user()  # also test a user with no RealmAuditLog entries | ||||
|         Realm.objects.create(string_id='moo', name='moo') | ||||
|         do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, [], []) | ||||
|  | ||||
|     def test_max_audit_entry_is_unrelated(self) -> None: | ||||
|         self.add_event(RealmAuditLog.USER_CREATED, 1) | ||||
|         self.add_event(RealmAuditLog.USER_SOFT_ACTIVATED, .5) | ||||
|     def test_max_audit_entry_is_unrelated(self): | ||||
|         # type: () -> None | ||||
|         self.add_event('user_created', 1) | ||||
|         self.add_event('unrelated', .5) | ||||
|         do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, ['subgroup'], [['false']]) | ||||
|  | ||||
|     # Simultaneous related audit entries should not be allowed, and so not testing for that. | ||||
|     def test_simultaneous_unrelated_audit_entry(self) -> None: | ||||
|         self.add_event(RealmAuditLog.USER_CREATED, 1) | ||||
|         self.add_event(RealmAuditLog.USER_SOFT_ACTIVATED, 1) | ||||
|     def test_simultaneous_unrelated_audit_entry(self): | ||||
|         # type: () -> None | ||||
|         self.add_event('user_created', 1) | ||||
|         self.add_event('unrelated', 1) | ||||
|         do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, ['subgroup'], [['false']]) | ||||
|  | ||||
|     def test_simultaneous_max_audit_entries_of_different_users(self) -> None: | ||||
|     def test_simultaneous_max_audit_entries_of_different_users(self): | ||||
|         # type: () -> None | ||||
|         user1 = self.create_user() | ||||
|         user2 = self.create_user() | ||||
|         user3 = self.create_user() | ||||
|         self.add_event(RealmAuditLog.USER_CREATED, .5, user=user1) | ||||
|         self.add_event(RealmAuditLog.USER_CREATED, .5, user=user2) | ||||
|         self.add_event(RealmAuditLog.USER_CREATED, 1, user=user3) | ||||
|         self.add_event(RealmAuditLog.USER_DEACTIVATED, .5, user=user3) | ||||
|         self.add_event('user_created', .5, user=user1) | ||||
|         self.add_event('user_created', .5, user=user2) | ||||
|         self.add_event('user_created', 1, user=user3) | ||||
|         self.add_event('user_deactivated', .5, user=user3) | ||||
|         do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, ['user', 'subgroup'], | ||||
|                               [[user1, 'false'], [user2, 'false']]) | ||||
|  | ||||
|     def test_end_to_end_with_actions_dot_py(self) -> None: | ||||
|     def test_end_to_end_with_actions_dot_py(self): | ||||
|         # type: () -> None | ||||
|         user1 = do_create_user('email1', 'password', self.default_realm, 'full_name', 'short_name') | ||||
|         user2 = do_create_user('email2', 'password', self.default_realm, 'full_name', 'short_name') | ||||
|         user3 = do_create_user('email3', 'password', self.default_realm, 'full_name', 'short_name') | ||||
| @@ -1031,29 +970,33 @@ class TestActiveUsersAudit(AnalyticsTestCase): | ||||
|             self.assertTrue(UserCount.objects.filter( | ||||
|                 user=user, property=self.current_property, subgroup='false', | ||||
|                 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): | ||||
|     def setUp(self) -> None: | ||||
|         super().setUp() | ||||
|     def setUp(self): | ||||
|         # type: () -> None | ||||
|         super(TestRealmActiveHumans, self).setUp() | ||||
|         self.stat = COUNT_STATS['realm_active_humans::day'] | ||||
|         self.current_property = self.stat.property | ||||
|  | ||||
|     def mark_audit_active(self, user: UserProfile, end_time: Optional[datetime]=None) -> None: | ||||
|     def mark_audit_active(self, user, end_time=None): | ||||
|         # type: (UserProfile, Optional[datetime]) -> None | ||||
|         if end_time is None: | ||||
|             end_time = self.TIME_ZERO | ||||
|         UserCount.objects.create( | ||||
|             user=user, realm=user.realm, property='active_users_audit:is_bot:day', | ||||
|             subgroup=ujson.dumps(user.is_bot), end_time=end_time, value=1) | ||||
|  | ||||
|     def mark_15day_active(self, user: UserProfile, end_time: Optional[datetime]=None) -> None: | ||||
|     def mark_15day_active(self, user, end_time=None): | ||||
|         # type: (UserProfile, Optional[datetime]) -> None | ||||
|         if end_time is None: | ||||
|             end_time = self.TIME_ZERO | ||||
|         UserCount.objects.create( | ||||
|             user=user, realm=user.realm, property='15day_actives::day', | ||||
|             end_time=end_time, value=1) | ||||
|  | ||||
|     def test_basic_boolean_logic(self) -> None: | ||||
|     def test_basic_boolean_logic(self): | ||||
|         # type: () -> None | ||||
|         user = self.create_user() | ||||
|         self.mark_audit_active(user, end_time=self.TIME_ZERO - self.DAY) | ||||
|         self.mark_15day_active(user, end_time=self.TIME_ZERO) | ||||
| @@ -1064,14 +1007,16 @@ class TestRealmActiveHumans(AnalyticsTestCase): | ||||
|             do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO + i*self.DAY) | ||||
|         self.assertTableState(RealmCount, ['value', 'end_time'], [[1, self.TIME_ZERO + self.DAY]]) | ||||
|  | ||||
|     def test_bots_not_counted(self) -> None: | ||||
|     def test_bots_not_counted(self): | ||||
|         # type: () -> None | ||||
|         bot = self.create_user(is_bot=True) | ||||
|         self.mark_audit_active(bot) | ||||
|         self.mark_15day_active(bot) | ||||
|         do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(RealmCount, [], []) | ||||
|  | ||||
|     def test_multiple_users_realms_and_times(self) -> None: | ||||
|     def test_multiple_users_realms_and_times(self): | ||||
|         # type: () -> None | ||||
|         user1 = self.create_user() | ||||
|         user2 = self.create_user() | ||||
|         second_realm = Realm.objects.create(string_id='second', name='second') | ||||
| @@ -1111,7 +1056,8 @@ class TestRealmActiveHumans(AnalyticsTestCase): | ||||
|                                [1, self.default_realm, self.TIME_ZERO - self.DAY], | ||||
|                                [2, second_realm, self.TIME_ZERO - self.DAY]]) | ||||
|  | ||||
|     def test_end_to_end(self) -> None: | ||||
|     def test_end_to_end(self): | ||||
|         # type: () -> None | ||||
|         user1 = do_create_user('email1', 'password', self.default_realm, 'full_name', 'short_name') | ||||
|         user2 = do_create_user('email2', 'password', self.default_realm, 'full_name', 'short_name') | ||||
|         do_create_user('email3', 'password', self.default_realm, 'full_name', 'short_name') | ||||
|   | ||||
| @@ -1,10 +1,12 @@ | ||||
| from zerver.lib.test_classes import ZulipTestCase | ||||
|  | ||||
| from analytics.lib.counts import CountStat | ||||
| from analytics.lib.fixtures import generate_time_series_data | ||||
| from zerver.lib.test_classes import ZulipTestCase | ||||
|  | ||||
| # A very light test suite; the code being tested is not run in production. | ||||
| class TestFixtures(ZulipTestCase): | ||||
|     def test_deterministic_settings(self) -> None: | ||||
|     def test_deterministic_settings(self): | ||||
|         # type: () -> None | ||||
|         # test basic business_hour / non_business_hour calculation | ||||
|         # test we get an array of the right length with frequency=CountStat.DAY | ||||
|         data = generate_time_series_data( | ||||
|   | ||||
| @@ -1,26 +1,25 @@ | ||||
| from datetime import datetime, timedelta | ||||
| from typing import List, Optional | ||||
| from django.utils.timezone import get_fixed_timezone, utc | ||||
| from zerver.lib.test_classes import ZulipTestCase | ||||
| from zerver.lib.timestamp import ceiling_to_hour, ceiling_to_day, \ | ||||
|     datetime_to_timestamp | ||||
| from zerver.models import Realm, UserProfile, Client, get_realm | ||||
|  | ||||
| from analytics.lib.counts import CountStat, COUNT_STATS | ||||
| from analytics.lib.time_utils import time_range | ||||
| from analytics.models import RealmCount, UserCount, BaseCount, \ | ||||
|     FillState, last_successful_fill | ||||
| from analytics.views import stats, get_chart_data, sort_by_totals, \ | ||||
|     sort_client_labels, rewrite_client_arrays | ||||
|  | ||||
| from datetime import datetime, timedelta | ||||
| import mock | ||||
| from django.utils.timezone import utc | ||||
| from django.http import HttpResponse | ||||
| import ujson | ||||
|  | ||||
| from analytics.lib.counts import COUNT_STATS, CountStat | ||||
| from analytics.lib.time_utils import time_range | ||||
| from analytics.models import FillState, \ | ||||
|     RealmCount, UserCount, last_successful_fill | ||||
| from analytics.views import rewrite_client_arrays, \ | ||||
|     sort_by_totals, sort_client_labels | ||||
| from zerver.lib.test_classes import ZulipTestCase | ||||
| from zerver.lib.timestamp import ceiling_to_day, \ | ||||
|     ceiling_to_hour, datetime_to_timestamp | ||||
| from zerver.lib.actions import do_create_multiuse_invite_link, \ | ||||
|     do_send_realm_reactivation_email | ||||
| from zerver.models import Client, get_realm, MultiuseInvite | ||||
| from typing import List, Dict, Optional | ||||
|  | ||||
| class TestStatsEndpoint(ZulipTestCase): | ||||
|     def test_stats(self) -> None: | ||||
|     def test_stats(self): | ||||
|         # type: () -> None | ||||
|         self.user = self.example_user('hamlet') | ||||
|         self.login(self.user.email) | ||||
|         result = self.client_get('/stats') | ||||
| @@ -28,51 +27,9 @@ class TestStatsEndpoint(ZulipTestCase): | ||||
|         # Check that we get something back | ||||
|         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: | ||||
|         user_profile = self.example_user('hamlet') | ||||
|         self.login(user_profile.email) | ||||
|  | ||||
|         result = self.client_get('/stats/realm/zulip/') | ||||
|         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/realm/not_existing_realm/') | ||||
|         self.assertEqual(result.status_code, 302) | ||||
|  | ||||
|         result = self.client_get('/stats/realm/zulip/') | ||||
|         self.assertEqual(result.status_code, 200) | ||||
|         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): | ||||
|     def setUp(self) -> None: | ||||
|         super().setUp() | ||||
|     def setUp(self): | ||||
|         # type: () -> None | ||||
|         self.realm = get_realm('zulip') | ||||
|         self.user = self.example_user('hamlet') | ||||
|         self.login(self.user.email) | ||||
| @@ -81,11 +38,12 @@ class TestGetChartData(ZulipTestCase): | ||||
|         self.end_times_day = [ceiling_to_day(self.realm.date_created) + timedelta(days=i) | ||||
|                               for i in range(4)] | ||||
|  | ||||
|     def data(self, i: int) -> List[int]: | ||||
|     def data(self, i): | ||||
|         # type: (int) -> List[int] | ||||
|         return [0, 0, i, 0] | ||||
|  | ||||
|     def insert_data(self, stat: CountStat, realm_subgroups: List[Optional[str]], | ||||
|                     user_subgroups: List[str]) -> None: | ||||
|     def insert_data(self, stat, realm_subgroups, user_subgroups): | ||||
|         # type: (CountStat, List[Optional[str]], List[str]) -> None | ||||
|         if stat.frequency == CountStat.HOUR: | ||||
|             insert_time = self.end_times_hour[2] | ||||
|             fill_time = self.end_times_hour[-1] | ||||
| @@ -103,13 +61,10 @@ class TestGetChartData(ZulipTestCase): | ||||
|             for i, subgroup in enumerate(user_subgroups)]) | ||||
|         FillState.objects.create(property=stat.property, end_time=fill_time, state=FillState.DONE) | ||||
|  | ||||
|     def test_number_of_humans(self) -> None: | ||||
|     def test_number_of_humans(self): | ||||
|         # type: () -> None | ||||
|         stat = COUNT_STATS['realm_active_humans::day'] | ||||
|         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', | ||||
|                                  {'chart_name': 'number_of_humans'}) | ||||
|         self.assert_json_success(result) | ||||
| @@ -118,12 +73,13 @@ class TestGetChartData(ZulipTestCase): | ||||
|             'msg': '', | ||||
|             'end_times': [datetime_to_timestamp(dt) for dt in self.end_times_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, | ||||
|             'result': 'success', | ||||
|         }) | ||||
|  | ||||
|     def test_messages_sent_over_time(self) -> None: | ||||
|     def test_messages_sent_over_time(self): | ||||
|         # type: () -> None | ||||
|         stat = COUNT_STATS['messages_sent:is_bot:hour'] | ||||
|         self.insert_data(stat, ['true', 'false'], ['false']) | ||||
|         result = self.client_get('/json/analytics/chart_data', | ||||
| @@ -134,13 +90,14 @@ class TestGetChartData(ZulipTestCase): | ||||
|             'msg': '', | ||||
|             'end_times': [datetime_to_timestamp(dt) for dt in self.end_times_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)}, | ||||
|             'display_order': None, | ||||
|             'result': 'success', | ||||
|         }) | ||||
|  | ||||
|     def test_messages_sent_by_message_type(self) -> None: | ||||
|     def test_messages_sent_by_message_type(self): | ||||
|         # type: () -> None | ||||
|         stat = COUNT_STATS['messages_sent:message_type:day'] | ||||
|         self.insert_data(stat, ['public_stream', 'private_message'], | ||||
|                          ['public_stream', 'private_stream']) | ||||
| @@ -152,15 +109,16 @@ class TestGetChartData(ZulipTestCase): | ||||
|             'msg': '', | ||||
|             'end_times': [datetime_to_timestamp(dt) for dt in self.end_times_day], | ||||
|             'frequency': CountStat.DAY, | ||||
|             'everyone': {'Public streams': self.data(100), 'Private streams': self.data(0), | ||||
|                          'Private messages': self.data(101), 'Group private messages': 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)}, | ||||
|             'user': {'Public streams': self.data(200), 'Private streams': self.data(201), | ||||
|                      'Private messages': self.data(0), 'Group private messages': self.data(0)}, | ||||
|             'display_order': ['Private messages', 'Public streams', 'Private streams', 'Group private messages'], | ||||
|             'result': 'success', | ||||
|         }) | ||||
|  | ||||
|     def test_messages_sent_by_client(self) -> None: | ||||
|     def test_messages_sent_by_client(self): | ||||
|         # type: () -> None | ||||
|         stat = COUNT_STATS['messages_sent:client:day'] | ||||
|         client1 = Client.objects.create(name='client 1') | ||||
|         client2 = Client.objects.create(name='client 2') | ||||
| @@ -176,65 +134,57 @@ class TestGetChartData(ZulipTestCase): | ||||
|             'msg': '', | ||||
|             'end_times': [datetime_to_timestamp(dt) for dt in self.end_times_day], | ||||
|             'frequency': CountStat.DAY, | ||||
|             'everyone': {'client 4': self.data(100), 'client 3': self.data(101), | ||||
|                          'client 2': self.data(102)}, | ||||
|             'realm': {'client 4': self.data(100), 'client 3': self.data(101), | ||||
|                       'client 2': self.data(102)}, | ||||
|             'user': {'client 3': self.data(200), 'client 1': self.data(201)}, | ||||
|             'display_order': ['client 1', 'client 2', 'client 3', 'client 4'], | ||||
|             'result': 'success', | ||||
|         }) | ||||
|  | ||||
|     def test_include_empty_subgroups(self) -> None: | ||||
|     def test_include_empty_subgroups(self): | ||||
|         # type: () -> None | ||||
|         FillState.objects.create( | ||||
|             property='realm_active_humans::day', end_time=self.end_times_day[0], | ||||
|             state=FillState.DONE) | ||||
|             property='realm_active_humans::day', end_time=self.end_times_day[0], state=FillState.DONE) | ||||
|         result = self.client_get('/json/analytics/chart_data', | ||||
|                                  {'chart_name': 'number_of_humans'}) | ||||
|         self.assert_json_success(result) | ||||
|         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) | ||||
|  | ||||
|         FillState.objects.create( | ||||
|             property='messages_sent:is_bot:hour', end_time=self.end_times_hour[0], | ||||
|             state=FillState.DONE) | ||||
|             property='messages_sent:is_bot:hour', end_time=self.end_times_hour[0], state=FillState.DONE) | ||||
|         result = self.client_get('/json/analytics/chart_data', | ||||
|                                  {'chart_name': 'messages_sent_over_time'}) | ||||
|         self.assert_json_success(result) | ||||
|         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]}) | ||||
|  | ||||
|         FillState.objects.create( | ||||
|             property='messages_sent:message_type:day', end_time=self.end_times_day[0], | ||||
|             state=FillState.DONE) | ||||
|             property='messages_sent:message_type:day', end_time=self.end_times_day[0], state=FillState.DONE) | ||||
|         result = self.client_get('/json/analytics/chart_data', | ||||
|                                  {'chart_name': 'messages_sent_by_message_type'}) | ||||
|         self.assert_json_success(result) | ||||
|         data = result.json() | ||||
|         self.assertEqual(data['everyone'], { | ||||
|             'Public streams': [0], 'Private streams': [0], | ||||
|             'Private messages': [0], 'Group private messages': [0]}) | ||||
|         self.assertEqual(data['realm'], { | ||||
|             'Public streams': [0], 'Private streams': [0], 'Private messages': [0], 'Group private messages': [0]}) | ||||
|         self.assertEqual(data['user'], { | ||||
|             'Public streams': [0], 'Private streams': [0], | ||||
|             'Private messages': [0], 'Group private messages': [0]}) | ||||
|             'Public streams': [0], 'Private streams': [0], 'Private messages': [0], 'Group private messages': [0]}) | ||||
|  | ||||
|         FillState.objects.create( | ||||
|             property='messages_sent:client:day', end_time=self.end_times_day[0], | ||||
|             state=FillState.DONE) | ||||
|             property='messages_sent:client:day', end_time=self.end_times_day[0], state=FillState.DONE) | ||||
|         result = self.client_get('/json/analytics/chart_data', | ||||
|                                  {'chart_name': 'messages_sent_by_client'}) | ||||
|         self.assert_json_success(result) | ||||
|         data = result.json() | ||||
|         self.assertEqual(data['everyone'], {}) | ||||
|         self.assertEqual(data['realm'], {}) | ||||
|         self.assertEqual(data['user'], {}) | ||||
|  | ||||
|     def test_start_and_end(self) -> None: | ||||
|     def test_start_and_end(self): | ||||
|         # type: () -> None | ||||
|         stat = COUNT_STATS['realm_active_humans::day'] | ||||
|         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] | ||||
|  | ||||
|         # valid start and end | ||||
| @@ -245,7 +195,7 @@ class TestGetChartData(ZulipTestCase): | ||||
|         self.assert_json_success(result) | ||||
|         data = result.json() | ||||
|         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 | ||||
|         result = self.client_get('/json/analytics/chart_data', | ||||
| @@ -254,13 +204,10 @@ class TestGetChartData(ZulipTestCase): | ||||
|                                   'end': end_time_timestamps[1]}) | ||||
|         self.assert_json_error_contains(result, 'Start time is later than') | ||||
|  | ||||
|     def test_min_length(self) -> None: | ||||
|     def test_min_length(self): | ||||
|         # type: () -> None | ||||
|         stat = COUNT_STATS['realm_active_humans::day'] | ||||
|         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 | ||||
|         result = self.client_get('/json/analytics/chart_data', | ||||
|                                  {'chart_name': 'number_of_humans', | ||||
| @@ -268,7 +215,7 @@ class TestGetChartData(ZulipTestCase): | ||||
|         self.assert_json_success(result) | ||||
|         data = result.json() | ||||
|         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 | ||||
|         result = self.client_get('/json/analytics/chart_data', | ||||
|                                  {'chart_name': 'number_of_humans', | ||||
| @@ -277,14 +224,16 @@ class TestGetChartData(ZulipTestCase): | ||||
|         data = result.json() | ||||
|         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['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): | ||||
|         # type: () -> None | ||||
|         result = self.client_get('/json/analytics/chart_data', | ||||
|                                  {'chart_name': 'does_not_exist'}) | ||||
|         self.assert_json_error_contains(result, 'Unknown chart name') | ||||
|  | ||||
|     def test_analytics_not_running(self) -> None: | ||||
|     def test_analytics_not_running(self): | ||||
|         # type: () -> None | ||||
|         # try to get data for a valid chart, but before we've put anything in the database | ||||
|         # (e.g. before update_analytics_counts has been run) | ||||
|         with mock.patch('logging.warning'): | ||||
| @@ -292,259 +241,11 @@ class TestGetChartData(ZulipTestCase): | ||||
|                                      {'chart_name': 'number_of_humans'}) | ||||
|         self.assert_json_error_contains(result, 'No analytics data available') | ||||
|  | ||||
|     def test_get_chart_data_for_realm(self) -> None: | ||||
|         user_profile = self.example_user('hamlet') | ||||
|         self.login(user_profile.email) | ||||
|  | ||||
|         result = self.client_get('/json/analytics/chart_data/realm/zulip/', | ||||
|                                  {'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/realm/not_existing_realm', | ||||
|                                  {'chart_name': 'number_of_humans'}) | ||||
|         self.assert_json_error(result, 'Invalid organization', 400) | ||||
|  | ||||
|         result = self.client_get('/json/analytics/chart_data/realm/zulip', | ||||
|                                  {'chart_name': 'number_of_humans'}) | ||||
|         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 & 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 & 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 & Co. deactivated"], result) | ||||
|  | ||||
|         with mock.patch("analytics.views.do_send_realm_reactivation_email") 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(["Realm reactivation email sent to admins of Lear"], 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 & 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): | ||||
|     # last_successful_fill is in analytics/models.py, but get_chart_data is | ||||
|     # the only function that uses it at the moment | ||||
|     def test_last_successful_fill(self) -> None: | ||||
|     def test_last_successful_fill(self): | ||||
|         # type: () -> None | ||||
|         self.assertIsNone(last_successful_fill('non-existant')) | ||||
|         a_time = datetime(2016, 3, 14, 19).replace(tzinfo=utc) | ||||
|         one_hour_before = datetime(2016, 3, 14, 18).replace(tzinfo=utc) | ||||
| @@ -555,18 +256,21 @@ class TestGetChartDataHelpers(ZulipTestCase): | ||||
|         fillstate.save() | ||||
|         self.assertEqual(last_successful_fill('property'), one_hour_before) | ||||
|  | ||||
|     def test_sort_by_totals(self) -> None: | ||||
|     def test_sort_by_totals(self): | ||||
|         # type: () -> None | ||||
|         empty = []  # type: List[int] | ||||
|         value_arrays = {'c': [0, 1], 'a': [9], 'b': [1, 1, 1], 'd': empty} | ||||
|         self.assertEqual(sort_by_totals(value_arrays), ['a', 'b', 'c', 'd']) | ||||
|  | ||||
|     def test_sort_client_labels(self) -> None: | ||||
|         data = {'everyone': {'a': [16], 'c': [15], 'b': [14], 'e': [13], 'd': [12], 'h': [11]}, | ||||
|     def test_sort_client_labels(self): | ||||
|         # type: () -> None | ||||
|         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]}} | ||||
|         self.assertEqual(sort_client_labels(data), ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) | ||||
|  | ||||
| class TestTimeRange(ZulipTestCase): | ||||
|     def test_time_range(self) -> None: | ||||
|     def test_time_range(self): | ||||
|         # type: () -> None | ||||
|         HOUR = timedelta(hours=1) | ||||
|         DAY = timedelta(days=1) | ||||
|  | ||||
| @@ -592,7 +296,8 @@ class TestTimeRange(ZulipTestCase): | ||||
|                          [floor_day-2*DAY, floor_day-DAY, floor_day, floor_day+DAY]) | ||||
|  | ||||
| class TestMapArrays(ZulipTestCase): | ||||
|     def test_map_arrays(self) -> None: | ||||
|     def test_map_arrays(self): | ||||
|         # type: () -> None | ||||
|         a = {'desktop app 1.0': [1, 2, 3], | ||||
|              'desktop app 2.0': [10, 12, 13], | ||||
|              'desktop app 3.0': [21, 22, 23], | ||||
|   | ||||
| @@ -1,30 +1,17 @@ | ||||
| from django.conf.urls import include, url | ||||
| from django.conf.urls import url, include | ||||
| from zerver.lib.rest import rest_dispatch | ||||
|  | ||||
| import analytics.views | ||||
| from zerver.lib.rest import rest_dispatch | ||||
|  | ||||
| i18n_urlpatterns = [ | ||||
|     # Server admin (user_profile.is_staff) visible stats pages | ||||
|     url(r'^activity$', 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, | ||||
|         name='analytics.views.get_realm_activity'), | ||||
|     url(r'^user_activity/(?P<email>[\S]+)/$', analytics.views.get_user_activity, | ||||
|         name='analytics.views.get_user_activity'), | ||||
|  | ||||
|     url(r'^stats/realm/(?P<realm_str>[\S]+)/$', 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 | ||||
|     url(r'^stats$', analytics.views.stats, | ||||
|         name='analytics.views.stats'), | ||||
| @@ -42,15 +29,6 @@ v1_api_and_json_patterns = [ | ||||
|     # get data for the graphs at /stats | ||||
|     url(r'^analytics/chart_data$', rest_dispatch, | ||||
|         {'GET': 'analytics.views.get_chart_data'}), | ||||
|     url(r'^analytics/chart_data/realm/(?P<realm_str>[\S]+)$', rest_dispatch, | ||||
|         {'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 += [ | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,17 +0,0 @@ | ||||
| module.exports = { | ||||
|     presets: [ | ||||
|         [ | ||||
|             "@babel/preset-env", | ||||
|             { | ||||
|                 corejs: 3, | ||||
|                 useBuiltIns: "usage", | ||||
|             }, | ||||
|         ], | ||||
|         "@babel/typescript", | ||||
|     ], | ||||
|     plugins: [ | ||||
|         "@babel/proposal-class-properties", | ||||
|         ["@babel/plugin-proposal-unicode-property-regex", { useUnicodeFlag: false }], | ||||
|     ], | ||||
|     sourceType: "unambiguous", | ||||
| }; | ||||
| @@ -1,22 +0,0 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| # Generated by Django 1.11.6 on 2017-11-30 00:13 | ||||
| from __future__ import unicode_literals | ||||
|  | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('zerver', '0124_stream_enable_notifications'), | ||||
|         ('confirmation', '0004_remove_confirmationmanager'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='confirmation', | ||||
|             name='realm', | ||||
|             field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='zerver.Realm'), | ||||
|         ), | ||||
|     ] | ||||
| @@ -1,20 +0,0 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| # Generated by Django 1.11.6 on 2018-01-29 18:39 | ||||
| from __future__ import unicode_literals | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('confirmation', '0005_confirmation_realm'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='realmcreationkey', | ||||
|             name='presume_email_valid', | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|     ] | ||||
| @@ -7,51 +7,53 @@ __revision__ = '$Id: models.py 28 2009-10-22 15:03:02Z jarek.zgoda $' | ||||
| import datetime | ||||
|  | ||||
| from django.db import models | ||||
| from django.db.models import CASCADE | ||||
| from django.urls import reverse | ||||
| from django.core.urlresolvers import reverse | ||||
| from django.conf import settings | ||||
| from django.contrib.sites.models import Site | ||||
| from django.contrib.contenttypes.models import ContentType | ||||
| from django.contrib.contenttypes.fields import GenericForeignKey | ||||
| from django.http import HttpRequest, HttpResponse | ||||
| from django.shortcuts import render | ||||
| from django.utils.timezone import now as timezone_now | ||||
|  | ||||
| from zerver.models import PreregistrationUser, EmailChangeStatus, MultiuseInvite, \ | ||||
|     UserProfile, Realm | ||||
| from zerver.lib.send_email import send_email | ||||
| from zerver.lib.utils import generate_random_token | ||||
| from zerver.models import PreregistrationUser, EmailChangeStatus, MultiuseInvite | ||||
| from random import SystemRandom | ||||
| import string | ||||
| from typing import Dict, Optional, Union | ||||
| from typing import Any, Dict, Optional, Text, Union | ||||
|  | ||||
| class ConfirmationKeyException(Exception): | ||||
|     WRONG_LENGTH = 1 | ||||
|     EXPIRED = 2 | ||||
|     DOES_NOT_EXIST = 3 | ||||
|  | ||||
|     def __init__(self, error_type: int) -> None: | ||||
|         super().__init__() | ||||
|     def __init__(self, error_type): | ||||
|         # type: (int) -> None | ||||
|         super(ConfirmationKeyException, self).__init__() | ||||
|         self.error_type = error_type | ||||
|  | ||||
| def render_confirmation_key_error(request: HttpRequest, exception: ConfirmationKeyException) -> HttpResponse: | ||||
| def render_confirmation_key_error(request, exception): | ||||
|     # type: (HttpRequest, ConfirmationKeyException) -> HttpResponse | ||||
|     if exception.error_type == ConfirmationKeyException.WRONG_LENGTH: | ||||
|         return render(request, 'confirmation/link_malformed.html') | ||||
|     if exception.error_type == ConfirmationKeyException.EXPIRED: | ||||
|         return render(request, 'confirmation/link_expired.html') | ||||
|     return render(request, 'confirmation/link_does_not_exist.html') | ||||
|  | ||||
| def generate_key() -> str: | ||||
| def generate_key(): | ||||
|     # type: () -> str | ||||
|     generator = SystemRandom() | ||||
|     # 24 characters * 5 bits of entropy/character = 120 bits of entropy | ||||
|     return ''.join(generator.choice(string.ascii_lowercase + string.digits) for _ in range(24)) | ||||
|  | ||||
| ConfirmationObjT = Union[MultiuseInvite, PreregistrationUser, EmailChangeStatus] | ||||
| def get_object_from_key(confirmation_key: str, | ||||
|                         confirmation_type: int) -> ConfirmationObjT: | ||||
| def get_object_from_key(confirmation_key): | ||||
|     # type: (str) -> Union[MultiuseInvite, PreregistrationUser, EmailChangeStatus] | ||||
|     # Confirmation keys used to be 40 characters | ||||
|     if len(confirmation_key) not in (24, 40): | ||||
|         raise ConfirmationKeyException(ConfirmationKeyException.WRONG_LENGTH) | ||||
|     try: | ||||
|         confirmation = Confirmation.objects.get(confirmation_key=confirmation_key, | ||||
|                                                 type=confirmation_type) | ||||
|         confirmation = Confirmation.objects.get(confirmation_key=confirmation_key) | ||||
|     except Confirmation.DoesNotExist: | ||||
|         raise ConfirmationKeyException(ConfirmationKeyException.DOES_NOT_EXIST) | ||||
|  | ||||
| @@ -65,23 +67,15 @@ def get_object_from_key(confirmation_key: str, | ||||
|         obj.save(update_fields=['status']) | ||||
|     return obj | ||||
|  | ||||
| def create_confirmation_link(obj: ContentType, host: str, | ||||
|                              confirmation_type: int, | ||||
|                              url_args: Optional[Dict[str, str]]=None) -> str: | ||||
| def create_confirmation_link(obj, host, confirmation_type, url_args=None): | ||||
|     # type: (Union[ContentType, int], str, int, Optional[Dict[str, str]]) -> str | ||||
|     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, | ||||
|                                 realm=realm, type=confirmation_type) | ||||
|                                 type=confirmation_type) | ||||
|     return confirmation_url(key, host, confirmation_type, url_args) | ||||
|  | ||||
| def confirmation_url(confirmation_key: str, host: str, | ||||
|                      confirmation_type: int, | ||||
|                      url_args: Optional[Dict[str, str]]=None) -> str: | ||||
| def confirmation_url(confirmation_key, host, confirmation_type, url_args=None): | ||||
|     # type: (str, str, int, Optional[Dict[str, str]]) -> str | ||||
|     if url_args is None: | ||||
|         url_args = {} | ||||
|     url_args['confirmation_key'] = confirmation_key | ||||
| @@ -89,12 +83,11 @@ def confirmation_url(confirmation_key: str, host: str, | ||||
|                        reverse(_properties[confirmation_type].url_name, kwargs=url_args)) | ||||
|  | ||||
| class Confirmation(models.Model): | ||||
|     content_type = models.ForeignKey(ContentType, on_delete=CASCADE) | ||||
|     content_type = models.ForeignKey(ContentType) | ||||
|     object_id = models.PositiveIntegerField()  # type: int | ||||
|     content_object = GenericForeignKey('content_type', 'object_id') | ||||
|     date_sent = models.DateTimeField()  # type: datetime.datetime | ||||
|     confirmation_key = models.CharField(max_length=40)  # type: str | ||||
|     realm = models.ForeignKey(Realm, null=True, on_delete=CASCADE)  # type: Optional[Realm] | ||||
|  | ||||
|     # The following list is the set of valid types | ||||
|     USER_REGISTRATION = 1 | ||||
| @@ -103,80 +96,51 @@ class Confirmation(models.Model): | ||||
|     UNSUBSCRIBE = 4 | ||||
|     SERVER_REGISTRATION = 5 | ||||
|     MULTIUSE_INVITE = 6 | ||||
|     REALM_CREATION = 7 | ||||
|     REALM_REACTIVATION = 8 | ||||
|     type = models.PositiveSmallIntegerField()  # type: int | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|     def __unicode__(self): | ||||
|         # type: () -> Text | ||||
|         return '<Confirmation: %s>' % (self.content_object,) | ||||
|  | ||||
| class ConfirmationType: | ||||
|     def __init__(self, url_name: str, | ||||
|                  validity_in_days: int=settings.CONFIRMATION_LINK_DEFAULT_VALIDITY_DAYS) -> None: | ||||
| class ConfirmationType(object): | ||||
|     def __init__(self, url_name, validity_in_days=settings.CONFIRMATION_LINK_DEFAULT_VALIDITY_DAYS): | ||||
|         # type: (str, int) -> None | ||||
|         self.url_name = url_name | ||||
|         self.validity_in_days = validity_in_days | ||||
|  | ||||
| _properties = { | ||||
|     Confirmation.USER_REGISTRATION: ConfirmationType('check_prereg_key_and_redirect'), | ||||
|     Confirmation.INVITATION: ConfirmationType('check_prereg_key_and_redirect', | ||||
|     Confirmation.USER_REGISTRATION: ConfirmationType('confirmation.views.confirm'), | ||||
|     Confirmation.INVITATION: ConfirmationType('confirmation.views.confirm', | ||||
|                                               validity_in_days=settings.INVITATION_LINK_VALIDITY_DAYS), | ||||
|     Confirmation.EMAIL_CHANGE: ConfirmationType('zerver.views.user_settings.confirm_email_change'), | ||||
|     Confirmation.UNSUBSCRIBE: ConfirmationType('zerver.views.unsubscribe.email_unsubscribe', | ||||
|                                                validity_in_days=1000000),  # should never expire | ||||
|     Confirmation.MULTIUSE_INVITE: ConfirmationType( | ||||
|         'zerver.views.registration.accounts_home_from_multiuse_invite', | ||||
|         validity_in_days=settings.INVITATION_LINK_VALIDITY_DAYS), | ||||
|     Confirmation.REALM_CREATION: ConfirmationType('check_prereg_key_and_redirect'), | ||||
|     Confirmation.REALM_REACTIVATION: ConfirmationType('zerver.views.realm.realm_reactivation'), | ||||
|     Confirmation.MULTIUSE_INVITE: ConfirmationType('zerver.views.registration.accounts_home_from_multiuse_invite', | ||||
|                                                    validity_in_days=settings.INVITATION_LINK_VALIDITY_DAYS) | ||||
| } | ||||
|  | ||||
| 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}) | ||||
| # Conirmation pathways for which there is no content_object that we need to | ||||
| # keep track of. | ||||
|  | ||||
| # Functions related to links generated by the generate_realm_creation_link.py | ||||
| # management command. | ||||
| # Note that being validated here will just allow the user to access the create_realm | ||||
| # form, where they will enter their email and go through the regular | ||||
| # Confirmation.REALM_CREATION pathway. | ||||
| # Arguably RealmCreationKey should just be another ConfirmationObjT and we should | ||||
| # add another Confirmation.type for this; it's this way for historical reasons. | ||||
| def check_key_is_valid(creation_key): | ||||
|     # type: (Text) -> bool | ||||
|     if not RealmCreationKey.objects.filter(creation_key=creation_key).exists(): | ||||
|         return False | ||||
|     days_sofar = (timezone_now() - RealmCreationKey.objects.get(creation_key=creation_key).date_created).days | ||||
|     # Realm creation link expires after settings.REALM_CREATION_LINK_VALIDITY_DAYS | ||||
|     if days_sofar <= settings.REALM_CREATION_LINK_VALIDITY_DAYS: | ||||
|         return True | ||||
|     return False | ||||
|  | ||||
| def validate_key(creation_key: Optional[str]) -> Optional['RealmCreationKey']: | ||||
|     """Get the record for this key, raising InvalidCreationKey if non-None but invalid.""" | ||||
|     if creation_key is None: | ||||
|         return None | ||||
|     try: | ||||
|         key_record = RealmCreationKey.objects.get(creation_key=creation_key) | ||||
|     except RealmCreationKey.DoesNotExist: | ||||
|         raise RealmCreationKey.Invalid() | ||||
|     time_elapsed = timezone_now() - key_record.date_created | ||||
|     if time_elapsed.total_seconds() > settings.REALM_CREATION_LINK_VALIDITY_DAYS * 24 * 3600: | ||||
|         raise RealmCreationKey.Invalid() | ||||
|     return key_record | ||||
|  | ||||
| def generate_realm_creation_url(by_admin: bool=False) -> str: | ||||
| def generate_realm_creation_url(): | ||||
|     # type: () -> Text | ||||
|     key = generate_key() | ||||
|     RealmCreationKey.objects.create(creation_key=key, | ||||
|                                     date_created=timezone_now(), | ||||
|                                     presume_email_valid=by_admin) | ||||
|     return '%s%s%s' % (settings.EXTERNAL_URI_SCHEME, | ||||
|                        settings.EXTERNAL_HOST, | ||||
|                        reverse('zerver.views.create_realm', | ||||
|                                kwargs={'creation_key': key})) | ||||
|     RealmCreationKey.objects.create(creation_key=key, date_created=timezone_now()) | ||||
|     return u'%s%s%s' % (settings.EXTERNAL_URI_SCHEME, | ||||
|                         settings.EXTERNAL_HOST, | ||||
|                         reverse('zerver.views.create_realm', | ||||
|                                 kwargs={'creation_key': key})) | ||||
|  | ||||
| class RealmCreationKey(models.Model): | ||||
|     creation_key = models.CharField('activation key', max_length=40) | ||||
|     date_created = models.DateTimeField('created', default=timezone_now) | ||||
|  | ||||
|     # True just if we should presume the email address the user enters | ||||
|     # is theirs, and skip sending mail to it to confirm that. | ||||
|     presume_email_valid = models.BooleanField(default=False)  # type: bool | ||||
|  | ||||
|     class Invalid(Exception): | ||||
|         pass | ||||
|   | ||||
| @@ -2,6 +2,8 @@ | ||||
|  | ||||
| # 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 $' | ||||
|  | ||||
| STATUS_ACTIVE = 1 | ||||
|   | ||||
							
								
								
									
										31
									
								
								confirmation/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								confirmation/views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
|  | ||||
| # Copyright: (c) 2008, Jarek Zgoda <jarek.zgoda@gmail.com> | ||||
|  | ||||
| __revision__ = '$Id: views.py 21 2008-12-05 09:21:03Z jarek.zgoda $' | ||||
|  | ||||
|  | ||||
| from django.shortcuts import render | ||||
| from django.template import RequestContext | ||||
| from django.conf import settings | ||||
| from django.http import HttpRequest, HttpResponse | ||||
|  | ||||
| from confirmation.models import Confirmation, get_object_from_key, ConfirmationKeyException, \ | ||||
|     render_confirmation_key_error | ||||
| from zerver.models import PreregistrationUser | ||||
|  | ||||
| from typing import Any, Dict | ||||
|  | ||||
| # This is currently only used for confirming PreregistrationUser. | ||||
| # Do not add other confirmation paths here. | ||||
| def confirm(request, confirmation_key): | ||||
|     # type: (HttpRequest, str) -> HttpResponse | ||||
|     try: | ||||
|         get_object_from_key(confirmation_key) | ||||
|     except ConfirmationKeyException as exception: | ||||
|         return render_confirmation_key_error(request, exception) | ||||
|  | ||||
|     return render(request, 'confirmation/confirm_preregistrationuser.html', | ||||
|                   context={ | ||||
|                       'key': confirmation_key, | ||||
|                       'full_name': request.GET.get("full_name", None)}) | ||||
| @@ -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.config 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.delivery_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.delivery_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 | ||||
| @@ -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)), | ||||
|             ], | ||||
|         ), | ||||
|     ] | ||||
| @@ -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), | ||||
|         ), | ||||
|     ] | ||||
| @@ -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')), | ||||
|             ], | ||||
|         ), | ||||
|     ] | ||||
| @@ -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')), | ||||
|             ], | ||||
|         ), | ||||
|     ] | ||||
| @@ -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), | ||||
|         ), | ||||
|     ] | ||||
| @@ -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), | ||||
|         ), | ||||
|     ] | ||||
| @@ -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', | ||||
|         ), | ||||
|     ] | ||||
| @@ -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), | ||||
|         ), | ||||
|     ] | ||||
| @@ -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] | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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" | ||||
| } | ||||
| @@ -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" | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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" | ||||
| } | ||||
| @@ -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" | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user