mirror of
https://github.com/zulip/zulip.git
synced 2025-11-07 07:23:22 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd01b1e2e4 | ||
|
|
58a7f6085f | ||
|
|
3367593b52 | ||
|
|
1a92ec5d86 | ||
|
|
7a8d685a71 | ||
|
|
3c3a8747c3 |
@@ -6,7 +6,7 @@ charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{sh,py,js,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
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
static/js/bundle.js
|
||||
static/js/blueslip.js
|
||||
puppet/zulip_ops/files/statsd/local.js
|
||||
static/webpack-bundles
|
||||
|
||||
@@ -14,56 +14,25 @@
|
||||
"Dropbox": false,
|
||||
"SockJS": false,
|
||||
"marked": false,
|
||||
"moment": false,
|
||||
"i18n": false,
|
||||
"DynamicText": false,
|
||||
"bridge": false,
|
||||
"page_params": false,
|
||||
"status_classes": false,
|
||||
"password_quality": false,
|
||||
"attachments_ui": false,
|
||||
"csrf_token": false,
|
||||
"typeahead_helper": false,
|
||||
"pygments_data": false,
|
||||
"popovers": false,
|
||||
"server_events": false,
|
||||
"server_events_dispatch": false,
|
||||
"ui": false,
|
||||
"ui_report": false,
|
||||
"ui_util": 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,
|
||||
"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,
|
||||
@@ -71,10 +40,8 @@
|
||||
"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,
|
||||
@@ -87,41 +54,28 @@
|
||||
"Socket": false,
|
||||
"channel": false,
|
||||
"components": false,
|
||||
"message_viewport": false,
|
||||
"upload_widget": false,
|
||||
"viewport": false,
|
||||
"avatar": false,
|
||||
"realm_icon": false,
|
||||
"feature_flags": false,
|
||||
"search_suggestion": false,
|
||||
"referral": false,
|
||||
"notifications": false,
|
||||
"message_flags": false,
|
||||
"bot_data": false,
|
||||
"stream_sort": false,
|
||||
"stream_list": false,
|
||||
"stream_popover": false,
|
||||
"narrow_state": false,
|
||||
"narrow": false,
|
||||
"narrow_state": false,
|
||||
"admin_sections": false,
|
||||
"admin": false,
|
||||
"stream_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,
|
||||
@@ -130,26 +84,15 @@
|
||||
"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
|
||||
"emoji_codes": false
|
||||
},
|
||||
"rules": {
|
||||
"no-restricted-syntax": 0,
|
||||
@@ -192,6 +135,7 @@
|
||||
"no-new-func": "error",
|
||||
"space-before-function-paren": ["error", { "anonymous": "always", "named": "never", "asyncArrow": "always" }],
|
||||
"no-param-reassign": 0,
|
||||
"prefer-spread": "error",
|
||||
"arrow-spacing": ["error", { "before": true, "after": true }],
|
||||
"no-alert": 2,
|
||||
"no-array-constructor": 2,
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -18,11 +18,8 @@ coverage/
|
||||
/zproject/dev-secrets.conf
|
||||
static/js/bundle.js
|
||||
static/generated/emoji
|
||||
static/generated/pygments_data.js
|
||||
static/generated/github-contributors.json
|
||||
static/locale/language_options.json
|
||||
static/third/emoji-data
|
||||
static/webpack-bundles
|
||||
/node_modules
|
||||
/staticfiles.json
|
||||
npm-debug.log
|
||||
@@ -30,5 +27,3 @@ npm-debug.log
|
||||
var/*
|
||||
.vscode/
|
||||
tools/conf.ini
|
||||
tools/custom_provision
|
||||
api/bots/john/assets/var/database.db
|
||||
|
||||
13
.gitlint
13
.gitlint
@@ -1,13 +0,0 @@
|
||||
[general]
|
||||
ignore=title-trailing-punctuation, body-min-length, body-is-missing
|
||||
|
||||
extra-path=tools/lib/gitlint-rules.py
|
||||
|
||||
[title-match-regex]
|
||||
regex=^.+\.$
|
||||
|
||||
[title-max-length]
|
||||
line-length=76
|
||||
|
||||
[body-max-line-length]
|
||||
line-length=76
|
||||
50
.travis.yml
50
.travis.yml
@@ -1,31 +1,17 @@
|
||||
# See https://zulip.readthedocs.io/en/latest/events-system.html for
|
||||
# high-level documentation on our Travis CI setup.
|
||||
dist: trusty
|
||||
before_install:
|
||||
- nvm install 0.10
|
||||
install:
|
||||
# Disable Travis CI's built-in NVM installation
|
||||
- mv ~/.nvm ~/.travis-nvm-disabled
|
||||
|
||||
# Install coveralls, the library for the code coverage reporting tool we use
|
||||
- pip install coveralls
|
||||
|
||||
# This is the main setup job for the test suite
|
||||
- tools/travis/setup-$TEST_SUITE
|
||||
|
||||
# Clean any virtualenvs that are not in use to avoid our cache
|
||||
# becoming huge. TODO: Add similar cleanup code for the other caches.
|
||||
- tools/clean-venv-cache --travis
|
||||
script:
|
||||
# We unset GEM_PATH here as a hack to work around Travis CI having
|
||||
# broken running their system puppet with Ruby. See
|
||||
# https://travis-ci.org/zulip/zulip/jobs/240120991 for an example traceback.
|
||||
- unset GEM_PATH
|
||||
- ./tools/travis/$TEST_SUITE
|
||||
cache:
|
||||
- apt: false
|
||||
- directories:
|
||||
- $HOME/zulip-venv-cache
|
||||
- $HOME/zulip-npm-cache
|
||||
- $HOME/zulip-emoji-cache
|
||||
- node_modules
|
||||
- $HOME/node
|
||||
env:
|
||||
global:
|
||||
@@ -34,10 +20,13 @@ env:
|
||||
- COVERALLS_SERVICE_NAME=travis-pro
|
||||
- COVERALLS_REPO_TOKEN=hnXUEBKsORKHc8xIENGs9JjktlTb2HKlG
|
||||
- BOTO_CONFIG=/tmp/nowhere
|
||||
matrix:
|
||||
- TEST_SUITE=frontend
|
||||
- TEST_SUITE=backend
|
||||
language: python
|
||||
# We run all of our test suites for both Python 2.7 and 3.4, with the
|
||||
# exception of static analysis, which is just run once (and checks
|
||||
# against both Python versions).
|
||||
python:
|
||||
- "2.7"
|
||||
- "3.4"
|
||||
matrix:
|
||||
include:
|
||||
- python: "3.4"
|
||||
@@ -46,31 +35,20 @@ matrix:
|
||||
env: TEST_SUITE=production
|
||||
- python: "2.7"
|
||||
env: TEST_SUITE=production
|
||||
- python: "2.7"
|
||||
env: TEST_SUITE=frontend
|
||||
- python: "3.4"
|
||||
env: TEST_SUITE=frontend
|
||||
- python: "2.7"
|
||||
env: TEST_SUITE=backend
|
||||
- python: "3.4"
|
||||
env: TEST_SUITE=backend
|
||||
# command to run tests
|
||||
script:
|
||||
- unset GEM_PATH
|
||||
- ./tools/travis/$TEST_SUITE
|
||||
sudo: required
|
||||
services:
|
||||
- docker
|
||||
addons:
|
||||
artifacts:
|
||||
paths:
|
||||
# Casper debugging data (screenshots, etc.) is super useful for
|
||||
# debugging test flakes.
|
||||
- $(ls var/casper/* | tr "\n" ":")
|
||||
- $(ls /tmp/zulip-test-event-log/* | tr "\n" ":")
|
||||
postgresql: "9.3"
|
||||
after_success:
|
||||
coveralls
|
||||
notifications:
|
||||
webhooks:
|
||||
urls:
|
||||
- https://coveralls.io/webhook?repo_token=$COVERALLS_REPO_TOKEN
|
||||
- https://zulip.org/zulipbot/travis
|
||||
on_success: always
|
||||
on_failure: always
|
||||
webhooks: https://coveralls.io/webhook?repo_token=$COVERALLS_REPO_TOKEN
|
||||
|
||||
100
README.md
100
README.md
@@ -17,7 +17,7 @@ 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>.
|
||||
at https://www.zulip.org.
|
||||
|
||||
[](https://travis-ci.org/zulip/zulip) [](https://coveralls.io/github/zulip/zulip?branch=master) [](http://zulip.readthedocs.io/en/latest/) [](https://chat.zulip.org)
|
||||
|
||||
@@ -25,15 +25,18 @@ at <https://www.zulip.org>.
|
||||
|
||||
There are several places online where folks discuss Zulip.
|
||||
|
||||
* The primary place is the
|
||||
[Zulip development community Zulip server](https://zulip.readthedocs.io/en/latest/chat-zulip-org.html).
|
||||
One of those places is our [public Zulip instance](https://chat.zulip.org/).
|
||||
You can go through the simple signup process at that link, and then you
|
||||
will soon be talking to core Zulip developers and other users. To get
|
||||
help in real time, you will have the best luck finding core developers
|
||||
roughly between 16:00 UTC and 23:59 UTC. Most questions get a reply
|
||||
within minutes to a few hours, depending on time of day.
|
||||
|
||||
* 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 instead.
|
||||
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.
|
||||
|
||||
* We have
|
||||
We have
|
||||
[a public mailing list](https://groups.google.com/forum/#!forum/zulip-devel)
|
||||
that is currently pretty low traffic because most discussions happen
|
||||
in our public Zulip instance. We use it to announce Zulip developer
|
||||
@@ -44,14 +47,13 @@ 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.
|
||||
|
||||
* Zulip also has a [blog](https://blog.zulip.org/) and
|
||||
[twitter account](https://twitter.com/zuliposs).
|
||||
Zulip also has a [blog](https://blog.zulip.org/).
|
||||
|
||||
* Last but not least, we use [GitHub](https://github.com/zulip/zulip)
|
||||
to track Zulip-related issues (and store our code, of course).
|
||||
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.
|
||||
pertaining to bugs or enhancement requests. We also use Pull
|
||||
Requests as our primary mechanism to receive code contributions.
|
||||
|
||||
The Zulip community has a [Code of Conduct][code-of-conduct].
|
||||
|
||||
@@ -66,21 +68,17 @@ installation guide][dev-install].
|
||||
Zulip in production supports Ubuntu 14.04 Trusty and Ubuntu 16.04
|
||||
Xenial. Work is ongoing on adding support for additional
|
||||
platforms. The installation process is documented at
|
||||
<https://zulip.org/server.html> and in more detail in [the
|
||||
https://zulip.org/server.html and in more detail in [the
|
||||
documentation](https://zulip.readthedocs.io/en/latest/prod-install.html).
|
||||
|
||||
## Ways to contribute
|
||||
|
||||
Zulip welcomes all forms of contributions! This page documents the
|
||||
Zulip welcomes all forms of contributions! The 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
|
||||
@@ -104,10 +102,10 @@ relevant list! 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
|
||||
[React Native Mobile iOS app][ios-exp], [Android app][Android],
|
||||
[new Electron desktop app][electron], and
|
||||
[legacy QT-based desktop app][desktop] are all separate repositories.
|
||||
app (including most integrations); the [desktop][], [Android][], and
|
||||
[iOS][] apps, are separate repositories, as are our [experimental
|
||||
React Native iOS app][ios-exp] and [alpha Electron desktop
|
||||
app][electron].
|
||||
|
||||
* **Glue code**. We maintain a [Hubot adapter][hubot-adapter] and several
|
||||
integrations ([Phabricator][phab], [Jenkins][], [Puppet][], [Redmine][],
|
||||
@@ -120,14 +118,6 @@ and [Trello][]), plus [node.js API bindings][node], an [isomorphic
|
||||
[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](https://zulip.readthedocs.io/en/latest/chat-zulip-org.html)
|
||||
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
|
||||
@@ -140,6 +130,7 @@ is valuable.
|
||||
[gh-issues]: https://github.com/zulip/zulip/issues
|
||||
[desktop]: https://github.com/zulip/zulip-desktop
|
||||
[android]: https://github.com/zulip/zulip-android
|
||||
[ios]: https://github.com/zulip/zulip-ios
|
||||
[ios-exp]: https://github.com/zulip/zulip-mobile
|
||||
[email-android]: https://groups.google.com/forum/#!forum/zulip-android
|
||||
[email-ios]: https://groups.google.com/forum/#!forum/zulip-ios
|
||||
@@ -154,27 +145,16 @@ is valuable.
|
||||
[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
|
||||
|
||||
## 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 in 2017 as well. For guidance, please read
|
||||
[GSoC](https://developers.google.com/open-source/gsoc/) last year and
|
||||
hope to do so again in 2017. For guidance, please read
|
||||
[our GSoC instructions and ideas page](https://github.com/zulip/zulip.github.io/blob/master/gsoc-ideas.md)
|
||||
and feel free to email
|
||||
[our GSoC mailing list](https://groups.google.com/forum/#!forum/zulip-gsoc).
|
||||
|
||||
**Note**: For GSoC 2017, we will be focused on making our
|
||||
[React Native app](https://github.com/zulip/zulip-mobile) better
|
||||
rather than developing the
|
||||
[Java Android app](https://github.com/zulip/zulip-android) and
|
||||
[React Native app](https://github.com/zulip/zulip-mobile) in
|
||||
parallel. You can review
|
||||
[our detailed plan](https://github.com/zulip/zulip-android/blob/master/android-strategy.md)
|
||||
for further details on the motivation and logistics.
|
||||
|
||||
## How to get involved with contributing to Zulip
|
||||
|
||||
First, subscribe to the Zulip [development discussion mailing
|
||||
@@ -222,25 +202,16 @@ Another way to find issues in Zulip is to take advantage of our
|
||||
our issues into areas like admin, compose, emoji, hotkeys, i18n,
|
||||
onboarding, search, etc. You can see this here:
|
||||
|
||||
<https://github.com/zulip/zulip/labels>
|
||||
[https://github.com/zulip/zulip/labels]
|
||||
|
||||
Click on any of the "area:" labels and you will see all the tickets
|
||||
related to your area 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](https://zulip.readthedocs.io/en/latest/chat-zulip-org.html). 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.
|
||||
If you're excited about helping with an open issue, just post on the
|
||||
conversation thread that you're working on it. 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.
|
||||
|
||||
We also welcome suggestions of features that you feel would be
|
||||
valuable or changes that you feel would make Zulip a better open
|
||||
@@ -270,16 +241,9 @@ 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 Developers 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](https://zulip.readthedocs.io/en/latest/chat-zulip-org.html).
|
||||
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
|
||||
Copyright 2011-2016 Dropbox, 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.
|
||||
|
||||
13
Vagrantfile
vendored
13
Vagrantfile
vendored
@@ -99,21 +99,8 @@ set -o pipefail
|
||||
if [ -d "/sys/fs/selinux" ]; then
|
||||
sudo mount -o remount,ro /sys/fs/selinux
|
||||
fi
|
||||
|
||||
# Set default locale, this prevents errors if the user has another locale set.
|
||||
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
|
||||
|
||||
# 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
|
||||
fi
|
||||
SCRIPT
|
||||
|
||||
config.vm.provision "shell",
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
from django.conf import settings
|
||||
from django.db import connection, models
|
||||
from django.db.models import F
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
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
|
||||
UserCount, StreamCount, BaseCount, FillState, installation_epoch
|
||||
from zerver.models import Realm, UserProfile, Message, Stream, models
|
||||
from zerver.lib.timestamp import floor_to_day
|
||||
|
||||
from typing import Any, Callable, Dict, List, Optional, Text, Tuple, Type, Union
|
||||
from typing import Any, Optional, Type, Tuple, Text
|
||||
|
||||
from collections import defaultdict, OrderedDict
|
||||
from datetime import timedelta, datetime
|
||||
import logging
|
||||
import time
|
||||
|
||||
## Logging setup ##
|
||||
|
||||
log_format = '%(asctime)s %(levelname)-8s %(message)s'
|
||||
logging.basicConfig(format=log_format)
|
||||
|
||||
@@ -30,68 +25,47 @@ logger = logging.getLogger("zulip.management")
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
# You can't subtract timedelta.max from a datetime, so use this instead
|
||||
TIMEDELTA_MAX = timedelta(days=365*1000)
|
||||
|
||||
## Class definitions ##
|
||||
# First post office in Boston
|
||||
MIN_TIME = datetime(1639, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
class CountStat(object):
|
||||
HOUR = 'hour'
|
||||
DAY = 'day'
|
||||
FREQUENCIES = frozenset([HOUR, DAY])
|
||||
# Allowed intervals are HOUR, DAY, and, GAUGE
|
||||
GAUGE = 'gauge'
|
||||
|
||||
def __init__(self, property, data_collector, frequency, interval=None):
|
||||
# type: (str, DataCollector, str, Optional[timedelta]) -> None
|
||||
def __init__(self, property, zerver_count_query, filter_args, group_by, frequency, is_gauge):
|
||||
# type: (str, ZerverCountQuery, Dict[str, bool], Optional[Tuple[models.Model, str]], str, bool) -> None
|
||||
self.property = property
|
||||
self.data_collector = data_collector
|
||||
self.zerver_count_query = zerver_count_query
|
||||
# might have to do something different for bitfields
|
||||
self.filter_args = filter_args
|
||||
self.group_by = group_by
|
||||
if frequency not in self.FREQUENCIES:
|
||||
raise AssertionError("Unknown frequency: %s" % (frequency,))
|
||||
raise ValueError("Unknown frequency: %s" % (frequency,))
|
||||
self.frequency = frequency
|
||||
if interval is not None:
|
||||
self.interval = interval
|
||||
elif frequency == CountStat.HOUR:
|
||||
self.interval = timedelta(hours=1)
|
||||
else: # frequency == CountStat.DAY
|
||||
self.interval = timedelta(days=1)
|
||||
self.interval = self.GAUGE if is_gauge else frequency
|
||||
|
||||
def __unicode__(self):
|
||||
# type: () -> Text
|
||||
return u"<CountStat: %s>" % (self.property,)
|
||||
|
||||
class LoggingCountStat(CountStat):
|
||||
def __init__(self, property, output_table, frequency):
|
||||
# type: (str, Type[BaseCount], str) -> None
|
||||
CountStat.__init__(self, property, DataCollector(output_table, None), frequency)
|
||||
class ZerverCountQuery(object):
|
||||
def __init__(self, zerver_table, analytics_table, query):
|
||||
# type: (Type[models.Model], Type[BaseCount], Text) -> None
|
||||
self.zerver_table = zerver_table
|
||||
self.analytics_table = analytics_table
|
||||
self.query = query
|
||||
|
||||
class DependentCountStat(CountStat):
|
||||
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(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 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()
|
||||
|
||||
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:
|
||||
time_increment = timedelta(days=1)
|
||||
else:
|
||||
raise AssertionError("Unknown frequency: %s" % (stat.frequency,))
|
||||
|
||||
if floor_to_hour(fill_to_time) != fill_to_time:
|
||||
raise ValueError("fill_to_time must be on an hour boundary: %s" % (fill_to_time,))
|
||||
if fill_to_time.tzinfo is None:
|
||||
raise ValueError("fill_to_time must be timezone aware: %s" % (fill_to_time,))
|
||||
|
||||
fill_state = FillState.objects.filter(property=stat.property).first()
|
||||
if fill_state is None:
|
||||
currently_filled = installation_epoch()
|
||||
@@ -101,88 +75,81 @@ def process_count_stat(stat, fill_to_time):
|
||||
logger.info("INITIALIZED %s %s" % (stat.property, currently_filled))
|
||||
elif fill_state.state == FillState.STARTED:
|
||||
logger.info("UNDO START %s %s" % (stat.property, fill_state.end_time))
|
||||
do_delete_counts_at_hour(stat, fill_state.end_time)
|
||||
currently_filled = fill_state.end_time - time_increment
|
||||
do_delete_count_stat_at_hour(stat, fill_state.end_time)
|
||||
currently_filled = fill_state.end_time - timedelta(hours = 1)
|
||||
do_update_fill_state(fill_state, currently_filled, FillState.DONE)
|
||||
logger.info("UNDO DONE %s" % (stat.property,))
|
||||
elif fill_state.state == FillState.DONE:
|
||||
currently_filled = fill_state.end_time
|
||||
else:
|
||||
raise AssertionError("Unknown value for FillState.state: %s." % (fill_state.state,))
|
||||
raise ValueError("Unknown value for FillState.state: %s." % (fill_state.state,))
|
||||
|
||||
if isinstance(stat, DependentCountStat):
|
||||
for dependency in stat.dependencies:
|
||||
dependency_fill_time = last_successful_fill(dependency)
|
||||
if dependency_fill_time is None:
|
||||
logger.warning("DependentCountStat %s run before dependency %s." %
|
||||
(stat.property, dependency))
|
||||
return
|
||||
fill_to_time = min(fill_to_time, dependency_fill_time)
|
||||
|
||||
currently_filled = currently_filled + time_increment
|
||||
currently_filled = currently_filled + timedelta(hours = 1)
|
||||
while currently_filled <= fill_to_time:
|
||||
logger.info("START %s %s" % (stat.property, currently_filled))
|
||||
logger.info("START %s %s %s" % (stat.property, stat.interval, currently_filled))
|
||||
start = time.time()
|
||||
do_update_fill_state(fill_state, currently_filled, FillState.STARTED)
|
||||
do_fill_count_stat_at_hour(stat, currently_filled)
|
||||
do_update_fill_state(fill_state, currently_filled, FillState.DONE)
|
||||
end = time.time()
|
||||
currently_filled = currently_filled + time_increment
|
||||
logger.info("DONE %s (%dms)" % (stat.property, (end-start)*1000))
|
||||
currently_filled = currently_filled + timedelta(hours = 1)
|
||||
logger.info("DONE %s %s (%dms)" % (stat.property, stat.interval, (end-start)*1000))
|
||||
|
||||
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!
|
||||
# We assume end_time is on an hour boundary, and is timezone aware.
|
||||
# It is the caller's responsibility to enforce this!
|
||||
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()
|
||||
assert(stat.data_collector.pull_function is not None)
|
||||
rows_added = stat.data_collector.pull_function(stat.property, start_time, end_time)
|
||||
logger.info("%s run pull_function (%dms/%sr)" %
|
||||
(stat.property, (time.time()-timer)*1000, rows_added))
|
||||
if stat.frequency == CountStat.DAY and (end_time != floor_to_day(end_time)):
|
||||
return
|
||||
|
||||
if stat.interval == CountStat.HOUR:
|
||||
start_time = end_time - timedelta(hours = 1)
|
||||
elif stat.interval == CountStat.DAY:
|
||||
start_time = end_time - timedelta(days = 1)
|
||||
else: # stat.interval == CountStat.GAUGE
|
||||
start_time = MIN_TIME
|
||||
|
||||
do_pull_from_zerver(stat, start_time, end_time)
|
||||
do_aggregate_to_summary_table(stat, end_time)
|
||||
|
||||
def do_delete_counts_at_hour(stat, end_time):
|
||||
def do_delete_count_stat_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]:
|
||||
RealmCount.objects.filter(property=stat.property, end_time=end_time).delete()
|
||||
else:
|
||||
UserCount.objects.filter(property=stat.property, end_time=end_time).delete()
|
||||
StreamCount.objects.filter(property=stat.property, end_time=end_time).delete()
|
||||
RealmCount.objects.filter(property=stat.property, end_time=end_time).delete()
|
||||
InstallationCount.objects.filter(property=stat.property, end_time=end_time).delete()
|
||||
UserCount.objects.filter(property = stat.property, end_time = end_time).delete()
|
||||
StreamCount.objects.filter(property = stat.property, end_time = end_time).delete()
|
||||
RealmCount.objects.filter(property = stat.property, end_time = end_time).delete()
|
||||
InstallationCount.objects.filter(property = stat.property, end_time = end_time).delete()
|
||||
|
||||
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()
|
||||
|
||||
def do_aggregate_to_summary_table(stat, end_time):
|
||||
# type: (CountStat, datetime) -> None
|
||||
cursor = connection.cursor()
|
||||
|
||||
# Aggregate into RealmCount
|
||||
output_table = stat.data_collector.output_table
|
||||
if output_table in (UserCount, StreamCount):
|
||||
analytics_table = stat.zerver_count_query.analytics_table
|
||||
if analytics_table in (UserCount, StreamCount):
|
||||
realmcount_query = """
|
||||
INSERT INTO analytics_realmcount
|
||||
(realm_id, value, property, subgroup, end_time)
|
||||
SELECT
|
||||
zerver_realm.id, COALESCE(sum(%(output_table)s.value), 0), '%(property)s',
|
||||
%(output_table)s.subgroup, %%(end_time)s
|
||||
zerver_realm.id, COALESCE(sum(%(analytics_table)s.value), 0), '%(property)s',
|
||||
%(analytics_table)s.subgroup, %%(end_time)s
|
||||
FROM zerver_realm
|
||||
JOIN %(output_table)s
|
||||
JOIN %(analytics_table)s
|
||||
ON
|
||||
zerver_realm.id = %(output_table)s.realm_id
|
||||
WHERE
|
||||
%(output_table)s.property = '%(property)s' AND
|
||||
%(output_table)s.end_time = %%(end_time)s
|
||||
GROUP BY zerver_realm.id, %(output_table)s.subgroup
|
||||
""" % {'output_table': output_table._meta.db_table,
|
||||
(
|
||||
%(analytics_table)s.realm_id = zerver_realm.id AND
|
||||
%(analytics_table)s.property = '%(property)s' AND
|
||||
%(analytics_table)s.end_time = %%(end_time)s
|
||||
)
|
||||
GROUP BY zerver_realm.id, %(analytics_table)s.subgroup
|
||||
""" % {'analytics_table': analytics_table._meta.db_table,
|
||||
'property': stat.property}
|
||||
start = time.time()
|
||||
cursor.execute(realmcount_query, {'end_time': end_time})
|
||||
@@ -197,9 +164,10 @@ def do_aggregate_to_summary_table(stat, end_time):
|
||||
sum(value), '%(property)s', analytics_realmcount.subgroup, %%(end_time)s
|
||||
FROM analytics_realmcount
|
||||
WHERE
|
||||
(
|
||||
property = '%(property)s' AND
|
||||
end_time = %%(end_time)s
|
||||
GROUP BY analytics_realmcount.subgroup
|
||||
) GROUP BY analytics_realmcount.subgroup
|
||||
""" % {'property': stat.property}
|
||||
start = time.time()
|
||||
cursor.execute(installationcount_query, {'end_time': end_time})
|
||||
@@ -207,91 +175,55 @@ def do_aggregate_to_summary_table(stat, end_time):
|
||||
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, 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}
|
||||
elif table == UserCount:
|
||||
id_args = {'realm': zerver_object.realm, 'user': zerver_object}
|
||||
else: # StreamCount
|
||||
id_args = {'realm': zerver_object.realm, 'stream': zerver_object}
|
||||
|
||||
if stat.frequency == CountStat.DAY:
|
||||
end_time = ceiling_to_day(event_time)
|
||||
else: # CountStat.HOUR:
|
||||
end_time = ceiling_to_hour(event_time)
|
||||
|
||||
row, created = table.objects.get_or_create(
|
||||
property=stat.property, subgroup=subgroup, end_time=end_time,
|
||||
defaults={'value': increment}, **id_args)
|
||||
if not created:
|
||||
row.value = F('value') + increment
|
||||
row.save(update_fields=['value'])
|
||||
|
||||
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()
|
||||
|
||||
## DataCollector-level operations ##
|
||||
|
||||
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:
|
||||
# This is the only method that hits the prod databases directly.
|
||||
def do_pull_from_zerver(stat, start_time, end_time):
|
||||
# type: (CountStat, datetime, datetime) -> None
|
||||
zerver_table = stat.zerver_count_query.zerver_table._meta.db_table # type: ignore
|
||||
join_args = ' '.join('AND %s.%s = %s' % (zerver_table, key, value)
|
||||
for key, value in stat.filter_args.items())
|
||||
if stat.group_by is None:
|
||||
subgroup = 'NULL'
|
||||
group_by_clause = ''
|
||||
else:
|
||||
subgroup = '%s.%s' % (group_by[0]._meta.db_table, group_by[1])
|
||||
subgroup = '%s.%s' % (stat.group_by[0]._meta.db_table, stat.group_by[1])
|
||||
group_by_clause = ', ' + subgroup
|
||||
|
||||
# We do string replacement here because cursor.execute will reject a
|
||||
# group_by_clause given as a param.
|
||||
# We pass in the datetimes as params to cursor.execute so that we don't have to
|
||||
# think about how to convert python datetimes to SQL datetimes.
|
||||
query_ = query % {'property': property, 'subgroup': subgroup,
|
||||
'group_by_clause': group_by_clause}
|
||||
# We do string replacement here because passing join_args as a param
|
||||
# may result in problems when running cursor.execute; we do
|
||||
# the string formatting prior so that cursor.execute runs it as sql
|
||||
query_ = stat.zerver_count_query.query % {'zerver_table': zerver_table,
|
||||
'property': stat.property,
|
||||
'join_args': join_args,
|
||||
'subgroup': subgroup,
|
||||
'group_by_clause': group_by_clause}
|
||||
cursor = connection.cursor()
|
||||
start = time.time()
|
||||
cursor.execute(query_, {'time_start': start_time, 'time_end': end_time})
|
||||
rowcount = cursor.rowcount
|
||||
end = time.time()
|
||||
logger.info("%s do_pull_from_zerver (%dms/%sr)" % (stat.property, (end-start)*1000, cursor.rowcount))
|
||||
cursor.close()
|
||||
return rowcount
|
||||
|
||||
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, 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(
|
||||
'user_profile'
|
||||
).values_list(
|
||||
'user_profile_id', 'user_profile__realm_id', 'start', 'end')
|
||||
|
||||
seconds_active = defaultdict(float) # type: Dict[Tuple[int, int], float]
|
||||
for user_id, realm_id, interval_start, interval_end in user_activity_intervals:
|
||||
start = max(start_time, interval_start)
|
||||
end = min(end_time, interval_end)
|
||||
seconds_active[(user_id, realm_id)] += (end - start).total_seconds()
|
||||
|
||||
rows = [UserCount(user_id=ids[0], realm_id=ids[1], property=property,
|
||||
end_time=end_time, value=int(seconds // 60))
|
||||
for ids, seconds in seconds_active.items() if seconds >= 60]
|
||||
UserCount.objects.bulk_create(rows)
|
||||
return len(rows)
|
||||
count_user_by_realm_query = """
|
||||
INSERT INTO analytics_realmcount
|
||||
(realm_id, value, property, subgroup, end_time)
|
||||
SELECT
|
||||
zerver_realm.id, count(%(zerver_table)s),'%(property)s', %(subgroup)s, %%(time_end)s
|
||||
FROM zerver_realm
|
||||
JOIN zerver_userprofile
|
||||
ON
|
||||
(
|
||||
zerver_userprofile.realm_id = zerver_realm.id AND
|
||||
zerver_userprofile.date_joined >= %%(time_start)s AND
|
||||
zerver_userprofile.date_joined < %%(time_end)s
|
||||
%(join_args)s
|
||||
)
|
||||
WHERE
|
||||
zerver_realm.date_created < %%(time_end)s
|
||||
GROUP BY zerver_realm.id %(group_by_clause)s
|
||||
"""
|
||||
zerver_count_user_by_realm = ZerverCountQuery(UserProfile, RealmCount, count_user_by_realm_query)
|
||||
|
||||
# currently .sender_id is only Message specific thing
|
||||
count_message_by_user_query = """
|
||||
INSERT INTO analytics_usercount
|
||||
(user_id, realm_id, value, property, subgroup, end_time)
|
||||
@@ -300,162 +232,17 @@ count_message_by_user_query = """
|
||||
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.pub_date >= %%(time_start)s AND
|
||||
zerver_message.pub_date < %%(time_end)s
|
||||
GROUP BY zerver_userprofile.id %(group_by_clause)s
|
||||
"""
|
||||
|
||||
# Note: ignores the group_by / group_by_clause.
|
||||
count_message_type_by_user_query = """
|
||||
INSERT INTO analytics_usercount
|
||||
(realm_id, user_id, value, property, subgroup, end_time)
|
||||
SELECT realm_id, id, SUM(count) AS value, '%(property)s', message_type, %%(time_end)s
|
||||
FROM
|
||||
(
|
||||
SELECT zerver_userprofile.realm_id, zerver_userprofile.id, count(*),
|
||||
CASE WHEN
|
||||
zerver_recipient.type = 1 THEN 'private_message'
|
||||
WHEN
|
||||
zerver_recipient.type = 3 THEN 'huddle_message'
|
||||
WHEN
|
||||
zerver_stream.invite_only = TRUE THEN 'private_stream'
|
||||
ELSE 'public_stream'
|
||||
END
|
||||
message_type
|
||||
|
||||
FROM zerver_userprofile
|
||||
JOIN zerver_message
|
||||
ON
|
||||
zerver_userprofile.id = zerver_message.sender_id AND
|
||||
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
|
||||
) AS subquery
|
||||
GROUP BY realm_id, id, message_type
|
||||
"""
|
||||
|
||||
# This query joins to the UserProfile table since all current queries that
|
||||
# use this also subgroup on UserProfile.is_bot. If in the future there is a
|
||||
# stat that counts messages by stream and doesn't need the UserProfile
|
||||
# table, consider writing a new query for efficiency.
|
||||
count_message_by_stream_query = """
|
||||
INSERT INTO analytics_streamcount
|
||||
(stream_id, realm_id, value, property, subgroup, end_time)
|
||||
SELECT
|
||||
zerver_stream.id, zerver_stream.realm_id, count(*), '%(property)s', %(subgroup)s, %%(time_end)s
|
||||
FROM zerver_stream
|
||||
JOIN zerver_recipient
|
||||
ON
|
||||
zerver_stream.id = zerver_recipient.type_id
|
||||
JOIN zerver_message
|
||||
ON
|
||||
zerver_recipient.id = zerver_message.recipient_id
|
||||
JOIN zerver_userprofile
|
||||
ON
|
||||
zerver_message.sender_id = zerver_userprofile.id
|
||||
WHERE
|
||||
zerver_stream.date_created < %%(time_end)s AND
|
||||
zerver_recipient.type = 2 AND
|
||||
zerver_message.sender_id = zerver_userprofile.id AND
|
||||
zerver_message.pub_date >= %%(time_start)s AND
|
||||
zerver_message.pub_date < %%(time_end)s
|
||||
GROUP BY zerver_stream.id %(group_by_clause)s
|
||||
"""
|
||||
|
||||
# Hardcodes the query needed by active_users:is_bot:day, since that is
|
||||
# currently the only stat that uses this.
|
||||
count_user_by_realm_query = """
|
||||
INSERT INTO analytics_realmcount
|
||||
(realm_id, value, property, subgroup, end_time)
|
||||
SELECT
|
||||
zerver_realm.id, count(*),'%(property)s', %(subgroup)s, %%(time_end)s
|
||||
FROM zerver_realm
|
||||
JOIN zerver_userprofile
|
||||
ON
|
||||
zerver_realm.id = zerver_userprofile.realm_id
|
||||
%(join_args)s
|
||||
)
|
||||
WHERE
|
||||
zerver_realm.date_created < %%(time_end)s AND
|
||||
zerver_userprofile.date_joined >= %%(time_start)s AND
|
||||
zerver_userprofile.date_joined < %%(time_end)s AND
|
||||
zerver_userprofile.is_active = TRUE
|
||||
GROUP BY zerver_realm.id %(group_by_clause)s
|
||||
"""
|
||||
|
||||
# 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 ['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
|
||||
(user_id, realm_id, value, property, subgroup, end_time)
|
||||
SELECT
|
||||
ral1.modified_user_id, ral1.realm_id, 1, '%(property)s', %(subgroup)s, %%(time_end)s
|
||||
FROM zerver_realmauditlog ral1
|
||||
JOIN (
|
||||
SELECT modified_user_id, max(event_time) AS max_event_time
|
||||
FROM zerver_realmauditlog
|
||||
WHERE
|
||||
event_type in ('user_created', 'user_deactivated', 'user_activated', 'user_reactivated') AND
|
||||
event_time < %%(time_end)s
|
||||
GROUP BY modified_user_id
|
||||
) ral2
|
||||
ON
|
||||
ral1.event_time = max_event_time AND
|
||||
ral1.modified_user_id = ral2.modified_user_id
|
||||
JOIN zerver_userprofile
|
||||
ON
|
||||
ral1.modified_user_id = zerver_userprofile.id
|
||||
WHERE
|
||||
ral1.event_type in ('user_created', 'user_activated', 'user_reactivated')
|
||||
"""
|
||||
|
||||
check_useractivityinterval_by_user_query = """
|
||||
INSERT INTO analytics_usercount
|
||||
(user_id, realm_id, value, property, subgroup, end_time)
|
||||
SELECT
|
||||
zerver_userprofile.id, zerver_userprofile.realm_id, 1, '%(property)s', %(subgroup)s, %%(time_end)s
|
||||
FROM zerver_userprofile
|
||||
JOIN zerver_useractivityinterval
|
||||
ON
|
||||
zerver_userprofile.id = zerver_useractivityinterval.user_profile_id
|
||||
WHERE
|
||||
zerver_useractivityinterval.end >= %%(time_start)s AND
|
||||
zerver_useractivityinterval.start < %%(time_end)s
|
||||
zerver_userprofile.date_joined < %%(time_end)s
|
||||
GROUP BY zerver_userprofile.id %(group_by_clause)s
|
||||
"""
|
||||
|
||||
count_realm_active_humans_query = """
|
||||
INSERT INTO analytics_realmcount
|
||||
(realm_id, value, property, subgroup, end_time)
|
||||
SELECT
|
||||
usercount1.realm_id, count(*), '%(property)s', NULL, %%(time_end)s
|
||||
FROM (
|
||||
SELECT realm_id, user_id
|
||||
FROM analytics_usercount
|
||||
WHERE
|
||||
property = 'active_users_audit:is_bot:day' AND
|
||||
subgroup = 'false' AND
|
||||
end_time = %%(time_end)s
|
||||
) usercount1
|
||||
JOIN (
|
||||
SELECT realm_id, user_id
|
||||
FROM analytics_usercount
|
||||
WHERE
|
||||
property = '15day_actives::day' AND
|
||||
end_time = %%(time_end)s
|
||||
) usercount2
|
||||
ON
|
||||
usercount1.user_id = usercount2.user_id
|
||||
GROUP BY usercount1.realm_id
|
||||
"""
|
||||
zerver_count_message_by_user = ZerverCountQuery(Message, UserCount, count_message_by_user_query)
|
||||
|
||||
# Currently unused and untested
|
||||
count_stream_by_realm_query = """
|
||||
@@ -466,71 +253,101 @@ count_stream_by_realm_query = """
|
||||
FROM zerver_realm
|
||||
JOIN zerver_stream
|
||||
ON
|
||||
zerver_realm.id = zerver_stream.realm_id AND
|
||||
WHERE
|
||||
zerver_realm.date_created < %%(time_end)s AND
|
||||
(
|
||||
zerver_stream.realm_id = zerver_realm.id AND
|
||||
zerver_stream.date_created >= %%(time_start)s AND
|
||||
zerver_stream.date_created < %%(time_end)s
|
||||
%(join_args)s
|
||||
)
|
||||
WHERE
|
||||
zerver_realm.date_created < %%(time_end)s
|
||||
GROUP BY zerver_realm.id %(group_by_clause)s
|
||||
"""
|
||||
zerver_count_stream_by_realm = ZerverCountQuery(Stream, RealmCount, count_stream_by_realm_query)
|
||||
|
||||
## CountStat declarations ##
|
||||
# This query violates the count_X_by_Y_query conventions in several ways. One,
|
||||
# the X table is not specified by the query name; MessageType is not a zerver
|
||||
# table. Two, it ignores the subgroup column in the CountStat object; instead,
|
||||
# it uses 'message_type' from the subquery to fill in the subgroup column.
|
||||
count_message_type_by_user_query = """
|
||||
INSERT INTO analytics_usercount
|
||||
(realm_id, user_id, value, property, subgroup, end_time)
|
||||
SELECT realm_id, id, SUM(count) AS value, '%(property)s', message_type, %%(time_end)s
|
||||
FROM
|
||||
(
|
||||
SELECT zerver_userprofile.realm_id, zerver_userprofile.id, count(*),
|
||||
CASE WHEN
|
||||
zerver_recipient.type != 2 THEN 'private_message'
|
||||
WHEN
|
||||
zerver_stream.invite_only = TRUE THEN 'private_stream'
|
||||
ELSE 'public_stream'
|
||||
END
|
||||
message_type
|
||||
|
||||
count_stats_ = [
|
||||
# Messages Sent stats
|
||||
# Stats that count the number of messages sent in various ways.
|
||||
# These are also the set of stats that read from the Message table.
|
||||
FROM zerver_userprofile
|
||||
JOIN zerver_message
|
||||
ON
|
||||
zerver_message.sender_id = zerver_userprofile.id AND
|
||||
zerver_message.pub_date >= %%(time_start)s AND
|
||||
zerver_message.pub_date < %%(time_end)s
|
||||
%(join_args)s
|
||||
JOIN zerver_recipient
|
||||
ON
|
||||
zerver_recipient.id = zerver_message.recipient_id
|
||||
LEFT JOIN zerver_stream
|
||||
ON
|
||||
zerver_stream.id = zerver_recipient.type_id
|
||||
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
|
||||
"""
|
||||
zerver_count_message_type_by_user = ZerverCountQuery(Message, UserCount, count_message_type_by_user_query)
|
||||
|
||||
CountStat('messages_sent:is_bot:hour',
|
||||
sql_data_collector(UserCount, count_message_by_user_query, (UserProfile, 'is_bot')),
|
||||
CountStat.HOUR),
|
||||
CountStat('messages_sent:message_type:day',
|
||||
sql_data_collector(UserCount, count_message_type_by_user_query, None), CountStat.DAY),
|
||||
CountStat('messages_sent:client:day',
|
||||
sql_data_collector(UserCount, count_message_by_user_query, (Message, 'sending_client_id')),
|
||||
CountStat.DAY),
|
||||
CountStat('messages_in_stream:is_bot:day',
|
||||
sql_data_collector(StreamCount, count_message_by_stream_query, (UserProfile, 'is_bot')),
|
||||
CountStat.DAY),
|
||||
# Note that this query also joins to the UserProfile table, since all
|
||||
# current queries that use this also subgroup on UserProfile.is_bot. If in
|
||||
# the future there is a query that counts messages by stream and doesn't need
|
||||
# the UserProfile table, consider writing a new query for efficiency.
|
||||
count_message_by_stream_query = """
|
||||
INSERT INTO analytics_streamcount
|
||||
(stream_id, realm_id, value, property, subgroup, end_time)
|
||||
SELECT
|
||||
zerver_stream.id, zerver_stream.realm_id, count(*), '%(property)s', %(subgroup)s, %%(time_end)s
|
||||
FROM zerver_stream
|
||||
JOIN zerver_recipient
|
||||
ON
|
||||
(
|
||||
zerver_recipient.type = 2 AND
|
||||
zerver_stream.id = zerver_recipient.type_id
|
||||
)
|
||||
JOIN zerver_message
|
||||
ON
|
||||
(
|
||||
zerver_message.recipient_id = zerver_recipient.id AND
|
||||
zerver_message.pub_date >= %%(time_start)s AND
|
||||
zerver_message.pub_date < %%(time_end)s AND
|
||||
zerver_stream.date_created < %%(time_end)s
|
||||
%(join_args)s
|
||||
)
|
||||
JOIN zerver_userprofile
|
||||
ON zerver_userprofile.id = zerver_message.sender_id
|
||||
GROUP BY zerver_stream.id %(group_by_clause)s
|
||||
"""
|
||||
zerver_count_message_by_stream = ZerverCountQuery(Message, StreamCount, count_message_by_stream_query)
|
||||
|
||||
# Number of Users stats
|
||||
# Stats that count the number of active users in the UserProfile.is_active sense.
|
||||
|
||||
# 'active_users_audit:is_bot:day' is the canonical record of which users were
|
||||
# active on which days (in the UserProfile.is_active sense).
|
||||
# Important that this stay a daily stat, so that 'realm_active_humans::day' works as expected.
|
||||
CountStat('active_users_audit:is_bot:day',
|
||||
sql_data_collector(UserCount, check_realmauditlog_by_user_query, (UserProfile, 'is_bot')),
|
||||
CountStat.DAY),
|
||||
# Sanity check on 'active_users_audit:is_bot:day', and a archetype for future LoggingCountStats.
|
||||
# In RealmCount, 'active_users_audit:is_bot:day' should be the partial
|
||||
# sum sequence of 'active_users_log:is_bot:day', for any realm that
|
||||
# started after the latter stat was introduced.
|
||||
LoggingCountStat('active_users_log:is_bot:day', RealmCount, CountStat.DAY),
|
||||
# Another sanity check on 'active_users_audit:is_bot:day'. Is only an
|
||||
# approximation, e.g. if a user is deactivated between the end of the
|
||||
# day and when this stat is run, they won't be counted. However, is the
|
||||
# simplest of the three to inspect by hand.
|
||||
CountStat('active_users:is_bot:day',
|
||||
sql_data_collector(RealmCount, count_user_by_realm_query, (UserProfile, 'is_bot')),
|
||||
CountStat.DAY, interval=TIMEDELTA_MAX),
|
||||
|
||||
# User Activity stats
|
||||
# Stats that measure user activity in the UserActivityInterval sense.
|
||||
|
||||
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),
|
||||
|
||||
# Dependent stats
|
||||
# Must come after their dependencies.
|
||||
|
||||
# Canonical account of the number of active humans in a realm on each day.
|
||||
DependentCountStat('realm_active_humans::day',
|
||||
sql_data_collector(RealmCount, count_realm_active_humans_query, None),
|
||||
CountStat.DAY,
|
||||
dependencies=['active_users_audit:is_bot:day', '15day_actives::day'])
|
||||
]
|
||||
|
||||
COUNT_STATS = OrderedDict([(stat.property, stat) for stat in count_stats_])
|
||||
COUNT_STATS = {
|
||||
'active_users:is_bot:day': CountStat(
|
||||
'active_users:is_bot:day', zerver_count_user_by_realm, {'is_active': True},
|
||||
(UserProfile, 'is_bot'), CountStat.DAY, True),
|
||||
'messages_sent:is_bot:hour': CountStat(
|
||||
'messages_sent:is_bot:hour', zerver_count_message_by_user, {},
|
||||
(UserProfile, 'is_bot'), CountStat.HOUR, False),
|
||||
'messages_sent:message_type:day': CountStat(
|
||||
'messages_sent:message_type:day', zerver_count_message_type_by_user, {},
|
||||
None, CountStat.DAY, False),
|
||||
'messages_sent:client:day': CountStat(
|
||||
'messages_sent:client:day', zerver_count_message_by_user, {},
|
||||
(Message, 'sending_client_id'), CountStat.DAY, False),
|
||||
'messages_sent_to_stream:is_bot:hour': CountStat(
|
||||
'messages_sent_to_stream:is_bot', zerver_count_message_by_stream, {},
|
||||
(UserProfile, 'is_bot'), CountStat.HOUR, False)
|
||||
}
|
||||
|
||||
@@ -8,13 +8,12 @@ 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 six.moves import range, zip
|
||||
|
||||
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):
|
||||
frequency=CountStat.DAY, is_gauge=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.
|
||||
@@ -32,7 +31,7 @@ def generate_time_series_data(days=100, business_hours_base=10, non_business_hou
|
||||
the variance.
|
||||
holiday_rate -- Fraction of days randomly set to 0, largely for testing how we handle 0s.
|
||||
frequency -- Should be CountStat.HOUR or CountStat.DAY.
|
||||
partial_sum -- If True, return partial sum of the series.
|
||||
is_gauge -- If True, return partial sum of the series.
|
||||
random_seed -- Seed for random number generator.
|
||||
"""
|
||||
if frequency == CountStat.HOUR:
|
||||
@@ -50,10 +49,10 @@ def generate_time_series_data(days=100, business_hours_base=10, non_business_hou
|
||||
[24*non_business_hours_base] * 2
|
||||
holidays = [random() < holiday_rate for i in range(days)]
|
||||
else:
|
||||
raise AssertionError("Unknown frequency: %s" % (frequency,))
|
||||
raise ValueError("Unknown frequency: %s" % (frequency,))
|
||||
if length < 2:
|
||||
raise AssertionError("Must be generating at least 2 data points. "
|
||||
"Currently generating %s" % (length,))
|
||||
raise ValueError("Must be generating at least 2 data points. "
|
||||
"Currently generating %s" % (length,))
|
||||
growth_base = growth ** (1. / (length-1))
|
||||
values_no_noise = [seasonality[i % len(seasonality)] * (growth_base**i) for i in range(length)]
|
||||
|
||||
@@ -64,7 +63,7 @@ def generate_time_series_data(days=100, business_hours_base=10, non_business_hou
|
||||
|
||||
values = [0 if holiday else int(v + sqrt(v)*noise_scalar*spikiness)
|
||||
for v, noise_scalar, holiday in zip(values_no_noise, noise_scalars, holidays)]
|
||||
if partial_sum:
|
||||
if is_gauge:
|
||||
for i in range(1, length):
|
||||
values[i] = values[i-1] + values[i]
|
||||
return [max(v, 0) for v in values]
|
||||
|
||||
@@ -17,7 +17,7 @@ def time_range(start, end, frequency, min_length):
|
||||
end = floor_to_day(end)
|
||||
step = timedelta(days=1)
|
||||
else:
|
||||
raise AssertionError("Unknown frequency: %s" % (frequency,))
|
||||
raise ValueError("Unknown frequency: %s" % (frequency,))
|
||||
|
||||
times = []
|
||||
if min_length is not None:
|
||||
|
||||
@@ -2,8 +2,7 @@ from __future__ import absolute_import
|
||||
from __future__ import print_function
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils.timezone import now as timezone_now
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any
|
||||
|
||||
from zerver.models import UserPresence, UserActivity
|
||||
from zerver.lib.utils import statsd, statsd_key
|
||||
@@ -19,13 +18,13 @@ class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
# type: (*Any, **Any) -> None
|
||||
# Get list of all active users in the last 1 week
|
||||
cutoff = timezone_now() - timedelta(minutes=30, hours=168)
|
||||
cutoff = datetime.now() - timedelta(minutes=30, hours=168)
|
||||
|
||||
users = UserPresence.objects.select_related().filter(timestamp__gt=cutoff)
|
||||
|
||||
# Calculate 10min, 2hrs, 12hrs, 1day, 2 business days (TODO business days), 1 week bucket of stats
|
||||
hour_buckets = [0.16, 2, 12, 24, 48, 168]
|
||||
user_info = defaultdict(dict) # type: Dict[str, Dict[float, List[str]]]
|
||||
user_info = defaultdict(dict) # type: Dict[str, Dict[float, List[str]]]
|
||||
|
||||
for last_presence in users:
|
||||
if last_presence.status == UserPresence.IDLE:
|
||||
@@ -36,7 +35,7 @@ class Command(BaseCommand):
|
||||
for bucket in hour_buckets:
|
||||
if bucket not in user_info[last_presence.user_profile.realm.string_id]:
|
||||
user_info[last_presence.user_profile.realm.string_id][bucket] = []
|
||||
if timezone_now() - known_active < timedelta(hours=bucket):
|
||||
if datetime.now(known_active.tzinfo) - known_active < timedelta(hours=bucket):
|
||||
user_info[last_presence.user_profile.realm.string_id][bucket].append(last_presence.user_profile.email)
|
||||
|
||||
for realm, buckets in user_info.items():
|
||||
@@ -52,7 +51,7 @@ class Command(BaseCommand):
|
||||
for bucket in hour_buckets:
|
||||
if bucket not in user_info[activity.user_profile.realm.string_id]:
|
||||
user_info[activity.user_profile.realm.string_id][bucket] = []
|
||||
if timezone_now() - activity.last_visit < timedelta(hours=bucket):
|
||||
if datetime.now(activity.last_visit.tzinfo) - activity.last_visit < timedelta(hours=bucket):
|
||||
user_info[activity.user_profile.realm.string_id][bucket].append(activity.user_profile.email)
|
||||
for realm, buckets in user_info.items():
|
||||
print("Realm %s" % (realm,))
|
||||
|
||||
@@ -6,9 +6,7 @@ import pytz
|
||||
|
||||
from optparse import make_option
|
||||
from typing import Any
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandParser
|
||||
from django.utils.timezone import now as timezone_now
|
||||
from zerver.lib.statistics import activity_averages_during_day
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -22,9 +20,9 @@ class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
# type: (*Any, **Any) -> None
|
||||
if options["date"] is None:
|
||||
date = timezone_now() - datetime.timedelta(days=1)
|
||||
date = datetime.datetime.now() - datetime.timedelta(days=1)
|
||||
else:
|
||||
date = datetime.datetime.strptime(options["date"], "%Y-%m-%d").replace(tzinfo=pytz.utc)
|
||||
date = datetime.datetime.strptime(options["date"], "%Y-%m-%d")
|
||||
print("Activity data for", date)
|
||||
print(activity_averages_during_day(date))
|
||||
print("Please note that the total registered user count is a total for today")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import absolute_import
|
||||
from __future__ import print_function
|
||||
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
from optparse import make_option
|
||||
from django.core.management.base import BaseCommand, CommandParser
|
||||
@@ -17,7 +17,7 @@ def compute_stats(log_level):
|
||||
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",
|
||||
mit_query = Message.objects.filter(sender__realm__string_id="mit",
|
||||
recipient__type=Recipient.STREAM,
|
||||
pub_date__gt=one_week_ago)
|
||||
for bot_sender_start in ["imap.", "rcmd.", "sys."]:
|
||||
@@ -30,15 +30,15 @@ def compute_stats(log_level):
|
||||
"bitcoin@mit.edu", "lp@mit.edu", "clocks@mit.edu",
|
||||
"root@mit.edu", "nagios@mit.edu",
|
||||
"www-data|local-realm@mit.edu"])
|
||||
user_counts = {} # type: Dict[str, Dict[str, int]]
|
||||
user_counts = {} # type: Dict[str, Dict[str, int]]
|
||||
for m in mit_query.select_related("sending_client", "sender"):
|
||||
email = m.sender.email
|
||||
user_counts.setdefault(email, {})
|
||||
user_counts[email].setdefault(m.sending_client.name, 0)
|
||||
user_counts[email][m.sending_client.name] += 1
|
||||
|
||||
total_counts = {} # type: Dict[str, int]
|
||||
total_user_counts = {} # type: Dict[str, int]
|
||||
total_counts = {} # type: Dict[str, int]
|
||||
total_user_counts = {} # type: Dict[str, int]
|
||||
for email, counts in user_counts.items():
|
||||
total_user_counts.setdefault(email, 0)
|
||||
for client_name, count in counts.items():
|
||||
@@ -47,7 +47,7 @@ def compute_stats(log_level):
|
||||
total_user_counts[email] += count
|
||||
|
||||
logging.debug("%40s | %10s | %s" % ("User", "Messages", "Percentage Zulip"))
|
||||
top_percents = {} # type: Dict[int, float]
|
||||
top_percents = {} # type: Dict[int, float]
|
||||
for size in [10, 25, 50, 100, 200, len(total_user_counts.keys())]:
|
||||
top_percents[size] = 0.0
|
||||
for i, email in enumerate(sorted(total_user_counts.keys(),
|
||||
|
||||
@@ -6,7 +6,6 @@ from typing import Any
|
||||
from argparse import ArgumentParser
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Count, QuerySet
|
||||
from django.utils.timezone import now as timezone_now
|
||||
|
||||
from zerver.models import UserActivity, UserProfile, Realm, \
|
||||
get_realm, get_user_profile_by_email
|
||||
@@ -39,7 +38,7 @@ Usage examples:
|
||||
#
|
||||
# Importantly, this does NOT tell you anything about the relative
|
||||
# volumes of requests from clients.
|
||||
threshold = timezone_now() - datetime.timedelta(days=7)
|
||||
threshold = datetime.datetime.now() - datetime.timedelta(days=7)
|
||||
client_counts = user_activity_objects.filter(
|
||||
last_visit__gt=threshold).values("client__name").annotate(
|
||||
count=Count('client__name'))
|
||||
|
||||
@@ -3,21 +3,20 @@ from __future__ import absolute_import, print_function
|
||||
from argparse import ArgumentParser
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils.timezone import now as timezone_now
|
||||
from django.utils import timezone
|
||||
|
||||
from analytics.models import BaseCount, InstallationCount, RealmCount, \
|
||||
UserCount, StreamCount
|
||||
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, InstallationCount, RealmCount, \
|
||||
UserCount, StreamCount, FillState
|
||||
from zerver.lib.timestamp import floor_to_day
|
||||
from zerver.models import Realm, UserProfile, Stream, Message, Client, \
|
||||
RealmAuditLog
|
||||
from zerver.models import Realm, UserProfile, Stream, Message, Client
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from six.moves import zip
|
||||
from typing import Any, Dict, List, Optional, Text, Type, Union, Mapping
|
||||
from typing import Any, List, Optional, Text, Type, Union
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = """Populates analytics tables with randomly generated data."""
|
||||
@@ -27,40 +26,38 @@ class Command(BaseCommand):
|
||||
|
||||
def create_user(self, email, full_name, is_staff, date_joined, realm):
|
||||
# type: (Text, Text, Text, bool, datetime, Realm) -> UserProfile
|
||||
user = UserProfile.objects.create(
|
||||
return 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]
|
||||
growth, autocorrelation, spikiness, holiday_rate=0):
|
||||
# type: (CountStat, float, float, float, float, float, float) -> List[int]
|
||||
self.random_seed += 1
|
||||
return generate_time_series_data(
|
||||
days=self.DAYS_OF_DATA, business_hours_base=business_hours_base,
|
||||
non_business_hours_base=non_business_hours_base, growth=growth,
|
||||
autocorrelation=autocorrelation, spikiness=spikiness, holiday_rate=holiday_rate,
|
||||
frequency=stat.frequency, partial_sum=partial_sum, random_seed=self.random_seed)
|
||||
frequency=stat.frequency, is_gauge=(stat.interval == CountStat.GAUGE),
|
||||
random_seed=self.random_seed)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# type: (*Any, **Any) -> None
|
||||
do_drop_all_analytics_tables()
|
||||
# I believe this also deletes any objects with this realm as a foreign key
|
||||
Realm.objects.filter(string_id='analytics').delete()
|
||||
Client.objects.filter(name__endswith='_').delete()
|
||||
|
||||
installation_time = timezone_now() - timedelta(days=self.DAYS_OF_DATA)
|
||||
last_end_time = floor_to_day(timezone_now())
|
||||
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)
|
||||
string_id='analytics', name='Analytics', domain='analytics.ds',
|
||||
date_created=installation_time)
|
||||
shylock = self.create_user('shylock@analytics.ds', 'Shylock', True, installation_time, realm)
|
||||
|
||||
def insert_fixture_data(stat, fixture_data, table):
|
||||
# type: (CountStat, Mapping[Optional[str], List[int]], Type[BaseCount]) -> None
|
||||
# type: (CountStat, Dict[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 == RealmCount:
|
||||
@@ -73,66 +70,54 @@ class Command(BaseCommand):
|
||||
value=value, **id_args)
|
||||
for end_time, value in zip(end_times, values) if value != 0])
|
||||
|
||||
stat = COUNT_STATS['realm_active_humans::day']
|
||||
stat = COUNT_STATS['active_users:is_bot:day']
|
||||
realm_data = {
|
||||
None: self.generate_fixture_data(stat, .1, .03, 3, .5, 3, partial_sum=True),
|
||||
} # type: Mapping[Optional[str], List[int]]
|
||||
'false': self.generate_fixture_data(stat, .1, .03, 3, .5, 3),
|
||||
'true': self.generate_fixture_data(stat, .01, 0, 1, 0, 1)
|
||||
} # type: Dict[Optional[str], List[int]]
|
||||
insert_fixture_data(stat, realm_data, RealmCount)
|
||||
FillState.objects.create(property=stat.property, end_time=last_end_time,
|
||||
state=FillState.DONE)
|
||||
|
||||
stat = COUNT_STATS['messages_sent:is_bot:hour']
|
||||
user_data = {'false': self.generate_fixture_data(
|
||||
stat, 2, 1, 1.5, .6, 8, holiday_rate=.1)} # type: Mapping[Optional[str], List[int]]
|
||||
user_data = {'false': self.generate_fixture_data(stat, 2, 1, 1.5, .6, 8, holiday_rate=.1)}
|
||||
insert_fixture_data(stat, user_data, UserCount)
|
||||
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)
|
||||
FillState.objects.create(property=stat.property, end_time=last_end_time,
|
||||
state=FillState.DONE)
|
||||
|
||||
stat = COUNT_STATS['messages_sent:message_type:day']
|
||||
user_data = {
|
||||
'public_stream': self.generate_fixture_data(stat, 1.5, 1, 3, .6, 8),
|
||||
'private_message': self.generate_fixture_data(stat, .5, .3, 1, .6, 8),
|
||||
'huddle_message': self.generate_fixture_data(stat, .2, .2, 2, .6, 8)}
|
||||
'private_message': self.generate_fixture_data(stat, .5, .3, 1, .6, 8)}
|
||||
insert_fixture_data(stat, user_data, UserCount)
|
||||
realm_data = {
|
||||
'public_stream': self.generate_fixture_data(stat, 30, 8, 5, .6, 4),
|
||||
'private_stream': self.generate_fixture_data(stat, 7, 7, 5, .6, 4),
|
||||
'private_message': self.generate_fixture_data(stat, 13, 5, 5, .6, 4),
|
||||
'huddle_message': self.generate_fixture_data(stat, 6, 3, 3, .6, 4)}
|
||||
'private_message': self.generate_fixture_data(stat, 13, 5, 5, .6, 4)}
|
||||
insert_fixture_data(stat, realm_data, RealmCount)
|
||||
FillState.objects.create(property=stat.property, end_time=last_end_time,
|
||||
state=FillState.DONE)
|
||||
|
||||
website, created = Client.objects.get_or_create(name='website')
|
||||
old_desktop, created = Client.objects.get_or_create(name='desktop app Linux 0.3.7')
|
||||
android, created = Client.objects.get_or_create(name='ZulipAndroid')
|
||||
iOS, created = Client.objects.get_or_create(name='ZulipiOS')
|
||||
react_native, created = Client.objects.get_or_create(name='ZulipMobile')
|
||||
API, created = Client.objects.get_or_create(name='API: Python')
|
||||
zephyr_mirror, created = Client.objects.get_or_create(name='zephyr_mirror')
|
||||
unused, created = Client.objects.get_or_create(name='unused')
|
||||
long_webhook, created = Client.objects.get_or_create(name='ZulipLooooooooooongNameWebhook')
|
||||
website_ = Client.objects.create(name='website_')
|
||||
API_ = Client.objects.create(name='API_')
|
||||
android_ = Client.objects.create(name='android_')
|
||||
iOS_ = Client.objects.create(name='iOS_')
|
||||
react_native_ = Client.objects.create(name='react_native_')
|
||||
electron_ = Client.objects.create(name='electron_')
|
||||
barnowl_ = Client.objects.create(name='barnowl_')
|
||||
plan9_ = Client.objects.create(name='plan9_')
|
||||
|
||||
stat = COUNT_STATS['messages_sent:client:day']
|
||||
user_data = {
|
||||
website.id: self.generate_fixture_data(stat, 2, 1, 1.5, .6, 8),
|
||||
zephyr_mirror.id: self.generate_fixture_data(stat, 0, .3, 1.5, .6, 8)}
|
||||
website_.id: self.generate_fixture_data(stat, 2, 1, 1.5, .6, 8),
|
||||
barnowl_.id: self.generate_fixture_data(stat, 0, .3, 1.5, .6, 8)}
|
||||
insert_fixture_data(stat, user_data, UserCount)
|
||||
realm_data = {
|
||||
website.id: self.generate_fixture_data(stat, 30, 20, 5, .6, 3),
|
||||
old_desktop.id: self.generate_fixture_data(stat, 5, 3, 8, .6, 3),
|
||||
android.id: self.generate_fixture_data(stat, 5, 5, 2, .6, 3),
|
||||
iOS.id: self.generate_fixture_data(stat, 5, 5, 2, .6, 3),
|
||||
react_native.id: self.generate_fixture_data(stat, 5, 5, 10, .6, 3),
|
||||
API.id: self.generate_fixture_data(stat, 5, 5, 5, .6, 3),
|
||||
zephyr_mirror.id: self.generate_fixture_data(stat, 1, 1, 3, .6, 3),
|
||||
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)}
|
||||
website_.id: self.generate_fixture_data(stat, 30, 20, 5, .6, 3),
|
||||
API_.id: self.generate_fixture_data(stat, 5, 5, 5, .6, 3),
|
||||
android_.id: self.generate_fixture_data(stat, 5, 5, 2, .6, 3),
|
||||
iOS_.id: self.generate_fixture_data(stat, 5, 5, 2, .6, 3),
|
||||
react_native_.id: self.generate_fixture_data(stat, 5, 5, 10, .6, 3),
|
||||
electron_.id: self.generate_fixture_data(stat, 5, 3, 8, .6, 3),
|
||||
barnowl_.id: self.generate_fixture_data(stat, 1, 1, 3, .6, 3),
|
||||
plan9_.id: self.generate_fixture_data(stat, 0, 0, 0, 0, 0, 0)}
|
||||
insert_fixture_data(stat, realm_data, RealmCount)
|
||||
FillState.objects.create(property=stat.property, end_time=last_end_time,
|
||||
state=FillState.DONE)
|
||||
|
||||
# TODO: messages_sent_to_stream:is_bot
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import absolute_import
|
||||
from __future__ import division
|
||||
from __future__ import print_function
|
||||
|
||||
from typing import Any, List
|
||||
from typing import Any
|
||||
|
||||
from argparse import ArgumentParser
|
||||
import datetime
|
||||
@@ -10,8 +10,6 @@ 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 UserProfile, Realm, Stream, Message, Recipient, UserActivity, \
|
||||
Subscription, UserMessage, get_realm
|
||||
|
||||
@@ -31,7 +29,7 @@ class Command(BaseCommand):
|
||||
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)
|
||||
activity_cutoff = datetime.datetime.now(tz=pytz.utc) - datetime.timedelta(days=7)
|
||||
return [activity.user_profile for activity in (
|
||||
UserActivity.objects.filter(user_profile__realm=realm,
|
||||
user_profile__is_active=True,
|
||||
@@ -41,17 +39,17 @@ class Command(BaseCommand):
|
||||
|
||||
def messages_sent_by(self, user, days_ago):
|
||||
# type: (UserProfile, int) -> int
|
||||
sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago)
|
||||
sent_time_cutoff = datetime.datetime.now(tz=pytz.utc) - datetime.timedelta(days=days_ago)
|
||||
return human_messages.filter(sender=user, pub_date__gt=sent_time_cutoff).count()
|
||||
|
||||
def total_messages(self, realm, days_ago):
|
||||
# type: (Realm, int) -> int
|
||||
sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago)
|
||||
sent_time_cutoff = datetime.datetime.now(tz=pytz.utc) - datetime.timedelta(days=days_ago)
|
||||
return Message.objects.filter(sender__realm=realm, pub_date__gt=sent_time_cutoff).count()
|
||||
|
||||
def human_messages(self, realm, days_ago):
|
||||
# type: (Realm, int) -> int
|
||||
sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago)
|
||||
sent_time_cutoff = datetime.datetime.now(tz=pytz.utc) - datetime.timedelta(days=days_ago)
|
||||
return human_messages.filter(sender__realm=realm, pub_date__gt=sent_time_cutoff).count()
|
||||
|
||||
def api_messages(self, realm, days_ago):
|
||||
@@ -60,19 +58,19 @@ class Command(BaseCommand):
|
||||
|
||||
def stream_messages(self, realm, days_ago):
|
||||
# type: (Realm, int) -> int
|
||||
sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago)
|
||||
sent_time_cutoff = datetime.datetime.now(tz=pytz.utc) - datetime.timedelta(days=days_ago)
|
||||
return human_messages.filter(sender__realm=realm, pub_date__gt=sent_time_cutoff,
|
||||
recipient__type=Recipient.STREAM).count()
|
||||
|
||||
def private_messages(self, realm, days_ago):
|
||||
# type: (Realm, int) -> int
|
||||
sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago)
|
||||
sent_time_cutoff = datetime.datetime.now(tz=pytz.utc) - datetime.timedelta(days=days_ago)
|
||||
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, days_ago):
|
||||
# type: (Realm, int) -> int
|
||||
sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago)
|
||||
sent_time_cutoff = datetime.datetime.now(tz=pytz.utc) - datetime.timedelta(days=days_ago)
|
||||
return human_messages.filter(sender__realm=realm, pub_date__gt=sent_time_cutoff).exclude(
|
||||
recipient__type=Recipient.STREAM).exclude(recipient__type=Recipient.PERSONAL).count()
|
||||
|
||||
|
||||
@@ -7,20 +7,18 @@ from scripts.lib.zulip_tools import ENDC, WARNING
|
||||
|
||||
from argparse import ArgumentParser
|
||||
from datetime import timedelta
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils.timezone import now as timezone_now
|
||||
from django.utils.timezone import utc as timezone_utc
|
||||
from django.utils import timezone
|
||||
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 zerver.lib.timestamp import floor_to_hour
|
||||
from zerver.lib.timestamp import datetime_to_string, is_timezone_aware
|
||||
from zerver.models import UserProfile, Message
|
||||
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = """Fills Analytics tables.
|
||||
@@ -32,18 +30,17 @@ class Command(BaseCommand):
|
||||
parser.add_argument('--time', '-t',
|
||||
type=str,
|
||||
help='Update stat tables from current state to --time. Defaults to the current time.',
|
||||
default=timezone_now().isoformat())
|
||||
default=datetime_to_string(timezone.now()))
|
||||
parser.add_argument('--utc',
|
||||
action='store_true',
|
||||
type=bool,
|
||||
help="Interpret --time in UTC.",
|
||||
default=False)
|
||||
parser.add_argument('--stat', '-s',
|
||||
type=str,
|
||||
help="CountStat to process. If omitted, all stats are processed.")
|
||||
parser.add_argument('--verbose',
|
||||
action='store_true',
|
||||
help="Print timing information to stdout.",
|
||||
default=False)
|
||||
parser.add_argument('--quiet', '-q',
|
||||
type=str,
|
||||
help="Suppress output to stdout.")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# type: (*Any, **Any) -> None
|
||||
@@ -61,31 +58,18 @@ class Command(BaseCommand):
|
||||
def run_update_analytics_counts(self, options):
|
||||
# type: (Dict[str, Any]) -> None
|
||||
fill_to_time = parse_datetime(options['time'])
|
||||
|
||||
if options['utc']:
|
||||
fill_to_time = fill_to_time.replace(tzinfo=timezone_utc)
|
||||
if fill_to_time.tzinfo is None:
|
||||
fill_to_time = fill_to_time.replace(tzinfo=timezone.utc)
|
||||
|
||||
if not (is_timezone_aware(fill_to_time)):
|
||||
raise ValueError("--time must be timezone aware. Maybe you meant to use the --utc option?")
|
||||
|
||||
fill_to_time = floor_to_hour(fill_to_time.astimezone(timezone_utc))
|
||||
logger.info("Starting updating analytics counts through %s" % (fill_to_time,))
|
||||
|
||||
if options['stat'] is not None:
|
||||
stats = [COUNT_STATS[options['stat']]]
|
||||
process_count_stat(COUNT_STATS[options['stat']], fill_to_time)
|
||||
else:
|
||||
stats = list(COUNT_STATS.values())
|
||||
for stat in COUNT_STATS.values():
|
||||
process_count_stat(stat, fill_to_time)
|
||||
|
||||
logger.info("Starting updating analytics counts through %s" % (fill_to_time,))
|
||||
if options['verbose']:
|
||||
start = time.time()
|
||||
last = start
|
||||
|
||||
for stat in stats:
|
||||
process_count_stat(stat, fill_to_time)
|
||||
if options['verbose']:
|
||||
print("Updated %s in %.3fs" % (stat.property, time.time() - last))
|
||||
last = time.time()
|
||||
|
||||
if options['verbose']:
|
||||
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,))
|
||||
|
||||
@@ -7,8 +7,6 @@ import pytz
|
||||
from typing import Any
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils.timezone import now as timezone_now
|
||||
|
||||
from zerver.models import UserProfile, Realm, Stream, Message, get_realm
|
||||
from six.moves import range
|
||||
|
||||
@@ -22,8 +20,8 @@ class Command(BaseCommand):
|
||||
|
||||
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)
|
||||
start = datetime.datetime.now(tz=pytz.utc) - datetime.timedelta(days=(week + 1)*7)
|
||||
end = datetime.datetime.now(tz=pytz.utc) - datetime.timedelta(days=week*7)
|
||||
return Message.objects.filter(sender=user, pub_date__gt=start, pub_date__lte=end).count()
|
||||
|
||||
def handle(self, *args, **options):
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
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, schema_editor):
|
||||
# type: (StateApps, DatabaseSchemaEditor) -> None
|
||||
UserCount = apps.get_model('analytics', 'UserCount')
|
||||
StreamCount = apps.get_model('analytics', 'StreamCount')
|
||||
RealmCount = apps.get_model('analytics', 'RealmCount')
|
||||
InstallationCount = apps.get_model('analytics', 'InstallationCount')
|
||||
FillState = apps.get_model('analytics', 'FillState')
|
||||
|
||||
property = 'messages_sent_to_stream:is_bot'
|
||||
UserCount.objects.filter(property=property).delete()
|
||||
StreamCount.objects.filter(property=property).delete()
|
||||
RealmCount.objects.filter(property=property).delete()
|
||||
InstallationCount.objects.filter(property=property).delete()
|
||||
FillState.objects.filter(property=property).delete()
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('analytics', '0008_add_count_indexes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(delete_messages_sent_to_stream_stat),
|
||||
]
|
||||
@@ -1,27 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
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, schema_editor):
|
||||
# type: (StateApps, DatabaseSchemaEditor) -> None
|
||||
UserCount = apps.get_model('analytics', 'UserCount')
|
||||
StreamCount = apps.get_model('analytics', 'StreamCount')
|
||||
RealmCount = apps.get_model('analytics', 'RealmCount')
|
||||
InstallationCount = apps.get_model('analytics', 'InstallationCount')
|
||||
FillState = apps.get_model('analytics', 'FillState')
|
||||
|
||||
property = 'messages_sent:message_type:day'
|
||||
UserCount.objects.filter(property=property).delete()
|
||||
StreamCount.objects.filter(property=property).delete()
|
||||
RealmCount.objects.filter(property=property).delete()
|
||||
InstallationCount.objects.filter(property=property).delete()
|
||||
FillState.objects.filter(property=property).delete()
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [('analytics', '0009_remove_messages_to_stream_stat')]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(clear_message_sent_by_message_type_values),
|
||||
]
|
||||
@@ -1,29 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
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, schema_editor):
|
||||
# type: (StateApps, DatabaseSchemaEditor) -> None
|
||||
UserCount = apps.get_model('analytics', 'UserCount')
|
||||
StreamCount = apps.get_model('analytics', 'StreamCount')
|
||||
RealmCount = apps.get_model('analytics', 'RealmCount')
|
||||
InstallationCount = apps.get_model('analytics', 'InstallationCount')
|
||||
FillState = apps.get_model('analytics', 'FillState')
|
||||
|
||||
UserCount.objects.all().delete()
|
||||
StreamCount.objects.all().delete()
|
||||
RealmCount.objects.all().delete()
|
||||
InstallationCount.objects.all().delete()
|
||||
FillState.objects.all().delete()
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('analytics', '0010_clear_messages_sent_values'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(clear_analytics_tables),
|
||||
]
|
||||
@@ -1,23 +1,24 @@
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
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.lib.timestamp import datetime_to_UTC, floor_to_day
|
||||
|
||||
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
|
||||
property = models.CharField(max_length=40, unique=True) # type: Text
|
||||
end_time = models.DateTimeField() # type: datetime.datetime
|
||||
|
||||
# Valid states are {DONE, STARTED}
|
||||
DONE = 1
|
||||
STARTED = 2
|
||||
state = models.PositiveSmallIntegerField() # type: int
|
||||
state = models.PositiveSmallIntegerField() # type: int
|
||||
|
||||
last_modified = models.DateTimeField(auto_now=True) # type: datetime.datetime
|
||||
last_modified = models.DateTimeField(auto_now=True) # type: datetime.datetime
|
||||
|
||||
def __unicode__(self):
|
||||
# type: () -> Text
|
||||
@@ -28,20 +29,11 @@ class FillState(ModelReprMixin, models.Model):
|
||||
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):
|
||||
# type: (str) -> Optional[datetime.datetime]
|
||||
fillstate = FillState.objects.filter(property=property).first()
|
||||
if fillstate is None:
|
||||
return None
|
||||
if fillstate.state == FillState.DONE:
|
||||
return fillstate.end_time
|
||||
return fillstate.end_time - datetime.timedelta(hours=1)
|
||||
return floor_to_day(datetime_to_UTC(earliest_realm_creation))
|
||||
|
||||
# would only ever make entries here by hand
|
||||
class Anomaly(ModelReprMixin, models.Model):
|
||||
info = models.CharField(max_length=1000) # type: Text
|
||||
info = models.CharField(max_length=1000) # type: Text
|
||||
|
||||
def __unicode__(self):
|
||||
# type: () -> Text
|
||||
@@ -51,20 +43,40 @@ 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: 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]
|
||||
property = models.CharField(max_length=32) # type: Text
|
||||
subgroup = models.CharField(max_length=16, null=True) # type: Text
|
||||
end_time = models.DateTimeField() # type: datetime.datetime
|
||||
value = models.BigIntegerField() # type: int
|
||||
anomaly = models.ForeignKey(Anomaly, null=True) # type: Optional[Anomaly]
|
||||
|
||||
class Meta(object):
|
||||
abstract = True
|
||||
|
||||
@staticmethod
|
||||
def extended_id():
|
||||
# type: () -> Tuple[str, ...]
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def key_model():
|
||||
# type: () -> models.Model
|
||||
raise NotImplementedError
|
||||
|
||||
class InstallationCount(BaseCount):
|
||||
|
||||
class Meta(object):
|
||||
unique_together = ("property", "subgroup", "end_time")
|
||||
|
||||
@staticmethod
|
||||
def extended_id():
|
||||
# type: () -> Tuple[str, ...]
|
||||
return ()
|
||||
|
||||
@staticmethod
|
||||
def key_model():
|
||||
# type: () -> models.Model
|
||||
return None
|
||||
|
||||
def __unicode__(self):
|
||||
# type: () -> Text
|
||||
return u"<InstallationCount: %s %s %s>" % (self.property, self.subgroup, self.value)
|
||||
@@ -76,6 +88,16 @@ class RealmCount(BaseCount):
|
||||
unique_together = ("realm", "property", "subgroup", "end_time")
|
||||
index_together = ["property", "end_time"]
|
||||
|
||||
@staticmethod
|
||||
def extended_id():
|
||||
# type: () -> Tuple[str, ...]
|
||||
return ('realm_id',)
|
||||
|
||||
@staticmethod
|
||||
def key_model():
|
||||
# type: () -> models.Model
|
||||
return Realm
|
||||
|
||||
def __unicode__(self):
|
||||
# type: () -> Text
|
||||
return u"<RealmCount: %s %s %s %s>" % (self.realm, self.property, self.subgroup, self.value)
|
||||
@@ -90,6 +112,16 @@ class UserCount(BaseCount):
|
||||
# aggregating from users to realms
|
||||
index_together = ["property", "realm", "end_time"]
|
||||
|
||||
@staticmethod
|
||||
def extended_id():
|
||||
# type: () -> Tuple[str, ...]
|
||||
return ('user_id', 'realm_id')
|
||||
|
||||
@staticmethod
|
||||
def key_model():
|
||||
# type: () -> models.Model
|
||||
return UserProfile
|
||||
|
||||
def __unicode__(self):
|
||||
# type: () -> Text
|
||||
return u"<UserCount: %s %s %s %s>" % (self.user, self.property, self.subgroup, self.value)
|
||||
@@ -104,6 +136,16 @@ class StreamCount(BaseCount):
|
||||
# aggregating from streams to realms
|
||||
index_together = ["property", "realm", "end_time"]
|
||||
|
||||
@staticmethod
|
||||
def extended_id():
|
||||
# type: () -> Tuple[str, ...]
|
||||
return ('stream_id', 'realm_id')
|
||||
|
||||
@staticmethod
|
||||
def key_model():
|
||||
# type: () -> models.Model
|
||||
return Stream
|
||||
|
||||
def __unicode__(self):
|
||||
# type: () -> Text
|
||||
return u"<StreamCount: %s %s %s %s %s>" % (self.stream, self.property, self.subgroup, self.value, self.id)
|
||||
|
||||
@@ -1,47 +1,39 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.db.models import Sum
|
||||
from django.test import TestCase
|
||||
from django.utils.timezone import now as timezone_now
|
||||
from django.utils.timezone import utc as timezone_utc
|
||||
from django.utils import timezone
|
||||
|
||||
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, DependentCountStat
|
||||
zerver_count_user_by_realm, zerver_count_message_by_user, \
|
||||
zerver_count_message_by_stream, zerver_count_stream_by_realm, \
|
||||
do_fill_count_stat_at_hour, ZerverCountQuery
|
||||
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
|
||||
UserCount, StreamCount, FillState, installation_epoch
|
||||
from zerver.models import Realm, UserProfile, Message, Stream, Recipient, \
|
||||
Huddle, Client, UserActivityInterval, RealmAuditLog, \
|
||||
get_user_profile_by_email, get_client
|
||||
Huddle, Client, get_user_profile_by_email, get_client
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import ujson
|
||||
|
||||
from six.moves import range
|
||||
from typing import Any, Dict, List, Optional, Text, Tuple, Type, Union
|
||||
from typing import Any, Type, Optional, Text, Tuple, List, Union
|
||||
|
||||
class AnalyticsTestCase(TestCase):
|
||||
MINUTE = timedelta(seconds = 60)
|
||||
HOUR = MINUTE * 60
|
||||
DAY = HOUR * 24
|
||||
TIME_ZERO = datetime(1988, 3, 14).replace(tzinfo=timezone_utc)
|
||||
TIME_ZERO = datetime(1988, 3, 14).replace(tzinfo=timezone.utc)
|
||||
TIME_LAST_HOUR = TIME_ZERO - HOUR
|
||||
|
||||
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)
|
||||
string_id='realmtest', name='Realm Test',
|
||||
domain='test.analytics', date_created=self.TIME_ZERO - 2*self.DAY)
|
||||
# used to generate unique names in self.create_*
|
||||
self.name_counter = 100
|
||||
# used as defaults in self.assertCountEquals
|
||||
self.current_property = None # type: Optional[str]
|
||||
self.current_property = None # type: Optional[str]
|
||||
|
||||
# Lightweight creation of users, streams, and messages
|
||||
def create_user(self, **kwargs):
|
||||
@@ -111,7 +103,7 @@ class AnalyticsTestCase(TestCase):
|
||||
self.assertEqual(queryset.values_list('value', flat=True)[0], value)
|
||||
|
||||
def assertTableState(self, table, arg_keys, arg_values):
|
||||
# type: (Type[BaseCount], List[str], List[List[Union[int, str, bool, datetime, Realm, UserProfile, Stream]]]) -> None
|
||||
# type: (Type[BaseCount], List[str], List[List[Union[int, str, Realm, UserProfile, Stream]]]) -> None
|
||||
"""Assert that the state of a *Count table is what it should be.
|
||||
|
||||
Example usage:
|
||||
@@ -136,10 +128,9 @@ class AnalyticsTestCase(TestCase):
|
||||
defaults = {
|
||||
'property': self.current_property,
|
||||
'subgroup': None,
|
||||
'end_time': self.TIME_ZERO,
|
||||
'value': 1}
|
||||
'end_time': self.TIME_ZERO}
|
||||
for values in arg_values:
|
||||
kwargs = {} # type: Dict[str, Any]
|
||||
kwargs = {} # type: Dict[str, Any]
|
||||
for i in range(len(values)):
|
||||
kwargs[arg_keys[i]] = values[i]
|
||||
for key, value in defaults.items():
|
||||
@@ -156,15 +147,20 @@ class AnalyticsTestCase(TestCase):
|
||||
self.assertEqual(table.objects.count(), len(arg_values))
|
||||
|
||||
class TestProcessCountStat(AnalyticsTestCase):
|
||||
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 make_dummy_count_stat(self, current_time):
|
||||
# type: (datetime) -> CountStat
|
||||
dummy_query = """INSERT INTO analytics_realmcount (realm_id, property, end_time, value)
|
||||
VALUES (1, 'test stat', '%(end_time)s', 22)""" % {'end_time': current_time}
|
||||
count_stat = CountStat('test stat', ZerverCountQuery(Recipient, UserCount, dummy_query),
|
||||
{}, None, CountStat.HOUR, False)
|
||||
return count_stat
|
||||
|
||||
def assertFillStateEquals(self, stat, end_time, state=FillState.DONE):
|
||||
# type: (CountStat, datetime, int) -> None
|
||||
fill_state = FillState.objects.filter(property=stat.property).first()
|
||||
def assertFillStateEquals(self, end_time, state = FillState.DONE, property = None):
|
||||
# type: (datetime, int, Optional[Text]) -> None
|
||||
count_stat = self.make_dummy_count_stat(end_time)
|
||||
if property is None:
|
||||
property = count_stat.property
|
||||
fill_state = FillState.objects.filter(property=property).first()
|
||||
self.assertEqual(fill_state.end_time, end_time)
|
||||
self.assertEqual(fill_state.state, state)
|
||||
|
||||
@@ -172,131 +168,29 @@ class TestProcessCountStat(AnalyticsTestCase):
|
||||
# type: () -> None
|
||||
# process new stat
|
||||
current_time = installation_epoch() + self.HOUR
|
||||
stat = self.make_dummy_count_stat('test stat')
|
||||
process_count_stat(stat, current_time)
|
||||
self.assertFillStateEquals(stat, current_time)
|
||||
self.assertEqual(InstallationCount.objects.filter(property=stat.property).count(), 1)
|
||||
count_stat = self.make_dummy_count_stat(current_time)
|
||||
property = count_stat.property
|
||||
process_count_stat(count_stat, current_time)
|
||||
self.assertFillStateEquals(current_time)
|
||||
self.assertEqual(InstallationCount.objects.filter(property=property).count(), 1)
|
||||
|
||||
# dirty stat
|
||||
FillState.objects.filter(property=stat.property).update(state=FillState.STARTED)
|
||||
process_count_stat(stat, current_time)
|
||||
self.assertFillStateEquals(stat, current_time)
|
||||
self.assertEqual(InstallationCount.objects.filter(property=stat.property).count(), 1)
|
||||
FillState.objects.filter(property=property).update(state=FillState.STARTED)
|
||||
process_count_stat(count_stat, current_time)
|
||||
self.assertFillStateEquals(current_time)
|
||||
self.assertEqual(InstallationCount.objects.filter(property=property).count(), 1)
|
||||
|
||||
# clean stat, no update
|
||||
process_count_stat(stat, current_time)
|
||||
self.assertFillStateEquals(stat, current_time)
|
||||
self.assertEqual(InstallationCount.objects.filter(property=stat.property).count(), 1)
|
||||
process_count_stat(count_stat, current_time)
|
||||
self.assertFillStateEquals(current_time)
|
||||
self.assertEqual(InstallationCount.objects.filter(property=property).count(), 1)
|
||||
|
||||
# clean stat, with update
|
||||
current_time = current_time + self.HOUR
|
||||
stat = self.make_dummy_count_stat('test stat')
|
||||
process_count_stat(stat, current_time)
|
||||
self.assertFillStateEquals(stat, current_time)
|
||||
self.assertEqual(InstallationCount.objects.filter(property=stat.property).count(), 2)
|
||||
|
||||
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)
|
||||
with self.assertRaises(ValueError):
|
||||
process_count_stat(stat, installation_epoch().replace(tzinfo=None) + self.HOUR) # type: ignore # https://github.com/python/typeshed/pull/1347
|
||||
|
||||
# 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):
|
||||
# type: () -> None
|
||||
end_time = self.TIME_ZERO
|
||||
|
||||
user_stat = LoggingCountStat('user stat', UserCount, CountStat.DAY)
|
||||
stream_stat = LoggingCountStat('stream stat', StreamCount, CountStat.DAY)
|
||||
realm_stat = LoggingCountStat('realm stat', RealmCount, CountStat.DAY)
|
||||
user = self.create_user()
|
||||
stream = self.create_stream_with_recipient()[0]
|
||||
realm = self.default_realm
|
||||
UserCount.objects.create(
|
||||
user=user, realm=realm, property=user_stat.property, end_time=end_time, value=5)
|
||||
StreamCount.objects.create(
|
||||
stream=stream, realm=realm, property=stream_stat.property, end_time=end_time, value=5)
|
||||
RealmCount.objects.create(
|
||||
realm=realm, property=realm_stat.property, end_time=end_time, value=5)
|
||||
|
||||
# Normal run of process_count_stat
|
||||
for stat in [user_stat, stream_stat, realm_stat]:
|
||||
process_count_stat(stat, end_time)
|
||||
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]])
|
||||
self.assertTableState(InstallationCount, ['property', 'value'],
|
||||
[[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)
|
||||
StreamCount.objects.update(value=6)
|
||||
RealmCount.objects.filter(property=realm_stat.property).update(value=6)
|
||||
FillState.objects.update(state=FillState.STARTED)
|
||||
|
||||
# Check that the change propagated (and the collected data wasn't deleted)
|
||||
for stat in [user_stat, stream_stat, realm_stat]:
|
||||
process_count_stat(stat, end_time)
|
||||
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]])
|
||||
self.assertTableState(InstallationCount, ['property', 'value'],
|
||||
[[user_stat.property, 6], [stream_stat.property, 6], [realm_stat.property, 6]])
|
||||
|
||||
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,
|
||||
dependencies=['stat1', 'stat2'])
|
||||
hour = [installation_epoch() + i*self.HOUR for i in range(5)]
|
||||
|
||||
# test when one dependency has been run, and the other hasn't
|
||||
process_count_stat(stat1, hour[2])
|
||||
process_count_stat(stat3, hour[1])
|
||||
self.assertTableState(InstallationCount, ['property', 'end_time'],
|
||||
[['stat1', hour[1]], ['stat1', hour[2]]])
|
||||
self.assertFillStateEquals(stat3, hour[0])
|
||||
|
||||
# test that we don't fill past the fill_to_time argument, even if
|
||||
# dependencies have later last_successful_fill
|
||||
process_count_stat(stat2, hour[3])
|
||||
process_count_stat(stat3, hour[1])
|
||||
self.assertTableState(InstallationCount, ['property', 'end_time'],
|
||||
[['stat1', hour[1]], ['stat1', hour[2]],
|
||||
['stat2', hour[1]], ['stat2', hour[2]], ['stat2', hour[3]],
|
||||
['stat3', hour[1]]])
|
||||
self.assertFillStateEquals(stat3, hour[1])
|
||||
|
||||
# test that we don't fill past the dependency last_successful_fill times,
|
||||
# even if fill_to_time is later
|
||||
process_count_stat(stat3, hour[4])
|
||||
self.assertTableState(InstallationCount, ['property', 'end_time'],
|
||||
[['stat1', hour[1]], ['stat1', hour[2]],
|
||||
['stat2', hour[1]], ['stat2', hour[2]], ['stat2', hour[3]],
|
||||
['stat3', hour[1]], ['stat3', hour[2]]])
|
||||
self.assertFillStateEquals(stat3, hour[2])
|
||||
|
||||
# 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,
|
||||
dependencies=['stat1', 'stat2'])
|
||||
hour24 = installation_epoch() + 24*self.HOUR
|
||||
hour25 = installation_epoch() + 25*self.HOUR
|
||||
process_count_stat(stat1, hour25)
|
||||
process_count_stat(stat2, hour25)
|
||||
process_count_stat(stat4, hour25)
|
||||
self.assertEqual(InstallationCount.objects.filter(property='stat4').count(), 1)
|
||||
self.assertFillStateEquals(stat4, hour24)
|
||||
count_stat = self.make_dummy_count_stat(current_time)
|
||||
process_count_stat(count_stat, current_time)
|
||||
self.assertFillStateEquals(current_time)
|
||||
self.assertEqual(InstallationCount.objects.filter(property=property).count(), 2)
|
||||
|
||||
class TestCountStats(AnalyticsTestCase):
|
||||
def setUp(self):
|
||||
@@ -307,7 +201,7 @@ class TestCountStats(AnalyticsTestCase):
|
||||
# the queries).
|
||||
self.second_realm = Realm.objects.create(
|
||||
string_id='second-realm', name='Second Realm',
|
||||
date_created=self.TIME_ZERO-2*self.DAY)
|
||||
domain='second.analytics', date_created=self.TIME_ZERO-2*self.DAY)
|
||||
for minutes_ago in [0, 1, 61, 60*24+1]:
|
||||
creation_time = self.TIME_ZERO - minutes_ago*self.MINUTE
|
||||
user = self.create_user(email='user-%s@second.analytics' % (minutes_ago,),
|
||||
@@ -323,7 +217,7 @@ class TestCountStats(AnalyticsTestCase):
|
||||
# messages_* CountStats
|
||||
self.no_message_realm = Realm.objects.create(
|
||||
string_id='no-message-realm', name='No Message Realm',
|
||||
date_created=self.TIME_ZERO-2*self.DAY)
|
||||
domain='no.message', date_created=self.TIME_ZERO-2*self.DAY)
|
||||
self.create_user(realm=self.no_message_realm)
|
||||
self.create_stream_with_recipient(realm=self.no_message_realm)
|
||||
# This huddle should not show up anywhere
|
||||
@@ -429,19 +323,16 @@ class TestCountStats(AnalyticsTestCase):
|
||||
[2, 'private_stream', user2],
|
||||
[2, 'public_stream', user1],
|
||||
[1, 'public_stream', user2],
|
||||
[1, 'private_message', user1],
|
||||
[1, 'private_message', user2],
|
||||
[2, 'private_message', user1],
|
||||
[2, 'private_message', user2],
|
||||
[1, 'private_message', user3],
|
||||
[1, 'huddle_message', user1],
|
||||
[1, 'huddle_message', user2],
|
||||
[1, 'public_stream', self.hourly_user],
|
||||
[1, 'public_stream', self.daily_user]])
|
||||
self.assertTableState(RealmCount, ['value', 'subgroup', 'realm'],
|
||||
[[3, 'private_stream'], [3, 'public_stream'], [3, 'private_message'],
|
||||
[2, 'huddle_message'], [2, 'public_stream', self.second_realm]])
|
||||
[[3, 'private_stream'], [3, 'public_stream'], [5, 'private_message'],
|
||||
[2, 'public_stream', self.second_realm]])
|
||||
self.assertTableState(InstallationCount, ['value', 'subgroup'],
|
||||
[[3, 'private_stream'], [5, 'public_stream'], [3, 'private_message'],
|
||||
[2, 'huddle_message']])
|
||||
[[3, 'private_stream'], [5, 'public_stream'], [5, 'private_message']])
|
||||
self.assertTableState(StreamCount, [], [])
|
||||
|
||||
def test_messages_sent_to_recipients_with_same_id(self):
|
||||
@@ -460,8 +351,7 @@ class TestCountStats(AnalyticsTestCase):
|
||||
|
||||
do_fill_count_stat_at_hour(stat, self.TIME_ZERO)
|
||||
|
||||
self.assertCountEquals(UserCount, 1, subgroup='private_message')
|
||||
self.assertCountEquals(UserCount, 1, subgroup='huddle_message')
|
||||
self.assertCountEquals(UserCount, 2, subgroup='private_message')
|
||||
self.assertCountEquals(UserCount, 1, subgroup='public_stream')
|
||||
|
||||
def test_messages_sent_by_client(self):
|
||||
@@ -487,7 +377,7 @@ class TestCountStats(AnalyticsTestCase):
|
||||
do_fill_count_stat_at_hour(stat, self.TIME_ZERO)
|
||||
|
||||
client2_id = str(client2.id)
|
||||
website_client_id = str(get_client('website').id) # default for self.create_message
|
||||
website_client_id = str(get_client('website').id) # default for self.create_message
|
||||
self.assertTableState(UserCount, ['value', 'subgroup', 'user'],
|
||||
[[2, website_client_id, user1],
|
||||
[1, client2_id, user1], [2, client2_id, user2],
|
||||
@@ -502,7 +392,7 @@ class TestCountStats(AnalyticsTestCase):
|
||||
|
||||
def test_messages_sent_to_stream_by_is_bot(self):
|
||||
# type: () -> None
|
||||
stat = COUNT_STATS['messages_in_stream:is_bot:day']
|
||||
stat = COUNT_STATS['messages_sent_to_stream:is_bot:hour']
|
||||
self.current_property = stat.property
|
||||
|
||||
bot = self.create_user(is_bot=True)
|
||||
@@ -530,520 +420,9 @@ class TestCountStats(AnalyticsTestCase):
|
||||
|
||||
self.assertTableState(StreamCount, ['value', 'subgroup', 'stream'],
|
||||
[[2, 'false', stream1], [1, 'false', stream2], [2, 'true', stream2],
|
||||
# "hourly" and "daily" stream, from TestCountStats.setUp
|
||||
[1, 'false', Stream.objects.get(name='stream 1')],
|
||||
[1, 'false', Stream.objects.get(name='stream 61')]])
|
||||
# "hourly" stream, from TestCountStats.setUp
|
||||
[1, 'false', Stream.objects.get(name='stream 1')]])
|
||||
self.assertTableState(RealmCount, ['value', 'subgroup', 'realm'],
|
||||
[[3, 'false'], [2, 'true'], [2, 'false', self.second_realm]])
|
||||
self.assertTableState(InstallationCount, ['value', 'subgroup'], [[5, 'false'], [2, 'true']])
|
||||
[[3, 'false'], [2, 'true'], [1, 'false', self.second_realm]])
|
||||
self.assertTableState(InstallationCount, ['value', 'subgroup'], [[4, 'false'], [2, 'true']])
|
||||
self.assertTableState(UserCount, [], [])
|
||||
|
||||
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_15day_actives(self):
|
||||
# type: () -> None
|
||||
stat = COUNT_STATS['15day_actives::day']
|
||||
self.current_property = stat.property
|
||||
|
||||
_15day = 15*self.DAY - UserActivityInterval.MIN_INTERVAL_LENGTH
|
||||
|
||||
# Outside time range, should not appear. Also tests upper boundary.
|
||||
user1 = self.create_user()
|
||||
self.create_interval(user1, _15day + self.DAY, _15day + timedelta(seconds=1))
|
||||
self.create_interval(user1, timedelta(0), -self.HOUR)
|
||||
|
||||
# On lower boundary, should appear
|
||||
user2 = self.create_user()
|
||||
self.create_interval(user2, _15day + self.DAY, _15day)
|
||||
|
||||
# Multiple intervals, including one outside boundary
|
||||
user3 = self.create_user()
|
||||
self.create_interval(user3, 20*self.DAY, 19*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, 20*self.DAY, 10*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, 20*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_minutes_active(self):
|
||||
# type: () -> None
|
||||
stat = COUNT_STATS['minutes_active::day']
|
||||
self.current_property = stat.property
|
||||
|
||||
# Outside time range, should not appear. Also testing for intervals
|
||||
# starting and ending on boundary
|
||||
user1 = self.create_user()
|
||||
self.create_interval(user1, 25*self.HOUR, self.DAY)
|
||||
self.create_interval(user1, timedelta(0), -self.HOUR)
|
||||
|
||||
# Multiple intervals, including one outside boundary
|
||||
user2 = self.create_user()
|
||||
self.create_interval(user2, 20*self.DAY, 19*self.DAY)
|
||||
self.create_interval(user2, 20*self.HOUR, 19*self.HOUR)
|
||||
self.create_interval(user2, 20*self.MINUTE, 19*self.MINUTE)
|
||||
|
||||
# Intervals crossing boundary
|
||||
user3 = self.create_user()
|
||||
self.create_interval(user3, 25*self.HOUR, 22*self.HOUR)
|
||||
self.create_interval(user3, self.MINUTE, -self.MINUTE)
|
||||
|
||||
# Interval subsuming time range
|
||||
user4 = self.create_user()
|
||||
self.create_interval(user4, 2*self.DAY, -2*self.DAY)
|
||||
|
||||
# Less than 60 seconds, should not appear
|
||||
user5 = self.create_user()
|
||||
self.create_interval(user5, self.MINUTE, timedelta(seconds=30))
|
||||
self.create_interval(user5, timedelta(seconds=20), timedelta(seconds=10))
|
||||
|
||||
# Second realm
|
||||
user6 = self.create_user(realm=self.second_realm)
|
||||
self.create_interval(user6, 20*self.MINUTE, 19*self.MINUTE)
|
||||
|
||||
do_fill_count_stat_at_hour(stat, self.TIME_ZERO)
|
||||
self.assertTableState(UserCount, ['value', 'user'],
|
||||
[[61, user2], [121, user3], [24*60, user4], [1, user6]])
|
||||
self.assertTableState(RealmCount, ['value', 'realm'],
|
||||
[[61 + 121 + 24*60, self.default_realm], [1, self.second_realm]])
|
||||
self.assertTableState(InstallationCount, ['value'], [[61 + 121 + 24*60 + 1]])
|
||||
self.assertTableState(StreamCount, [], [])
|
||||
|
||||
class TestDoAggregateToSummaryTable(AnalyticsTestCase):
|
||||
# do_aggregate_to_summary_table is mostly tested by the end to end
|
||||
# nature of the tests in TestCountStats. But want to highlight one
|
||||
# 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):
|
||||
# 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):
|
||||
# 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
|
||||
self.current_property = 'test'
|
||||
second_realm = Realm.objects.create(string_id='moo', name='moo')
|
||||
stat = LoggingCountStat('test', RealmCount, CountStat.DAY)
|
||||
do_increment_logging_stat(self.default_realm, stat, None, self.TIME_ZERO)
|
||||
do_increment_logging_stat(second_realm, stat, None, self.TIME_ZERO)
|
||||
self.assertTableState(RealmCount, ['realm'], [[self.default_realm], [second_realm]])
|
||||
|
||||
user1 = self.create_user()
|
||||
user2 = self.create_user()
|
||||
stat = LoggingCountStat('test', UserCount, CountStat.DAY)
|
||||
do_increment_logging_stat(user1, stat, None, self.TIME_ZERO)
|
||||
do_increment_logging_stat(user2, stat, None, self.TIME_ZERO)
|
||||
self.assertTableState(UserCount, ['user'], [[user1], [user2]])
|
||||
|
||||
stream1 = self.create_stream_with_recipient()[0]
|
||||
stream2 = self.create_stream_with_recipient()[0]
|
||||
stat = LoggingCountStat('test', StreamCount, CountStat.DAY)
|
||||
do_increment_logging_stat(stream1, stat, None, self.TIME_ZERO)
|
||||
do_increment_logging_stat(stream2, stat, None, self.TIME_ZERO)
|
||||
self.assertTableState(StreamCount, ['stream'], [[stream1], [stream2]])
|
||||
|
||||
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)
|
||||
for time_ in times:
|
||||
do_increment_logging_stat(self.default_realm, stat, None, time_)
|
||||
stat = LoggingCountStat('hour test', RealmCount, CountStat.HOUR)
|
||||
for time_ in times:
|
||||
do_increment_logging_stat(self.default_realm, stat, None, time_)
|
||||
|
||||
self.assertTableState(RealmCount, ['value', 'property', 'end_time'],
|
||||
[[3, 'day test', self.TIME_ZERO],
|
||||
[1, 'day test', self.TIME_ZERO - self.DAY],
|
||||
[2, 'hour test', self.TIME_ZERO],
|
||||
[1, 'hour test', self.TIME_LAST_HOUR],
|
||||
[1, 'hour test', self.TIME_ZERO - self.DAY]])
|
||||
|
||||
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,
|
||||
# so this only tests a new subgroup and end_time
|
||||
do_increment_logging_stat(self.default_realm, stat, 'subgroup1', self.TIME_ZERO)
|
||||
do_increment_logging_stat(self.default_realm, stat, 'subgroup2', self.TIME_ZERO)
|
||||
do_increment_logging_stat(self.default_realm, stat, 'subgroup1', self.TIME_LAST_HOUR)
|
||||
self.current_property = 'test'
|
||||
self.assertTableState(RealmCount, ['value', 'subgroup', 'end_time'],
|
||||
[[1, 'subgroup1', self.TIME_ZERO], [1, 'subgroup2', self.TIME_ZERO],
|
||||
[1, 'subgroup1', self.TIME_LAST_HOUR]])
|
||||
# This should trigger the get part of get_or_create
|
||||
do_increment_logging_stat(self.default_realm, stat, 'subgroup1', self.TIME_ZERO)
|
||||
self.assertTableState(RealmCount, ['value', 'subgroup', 'end_time'],
|
||||
[[2, 'subgroup1', self.TIME_ZERO], [1, 'subgroup2', self.TIME_ZERO],
|
||||
[1, 'subgroup1', self.TIME_LAST_HOUR]])
|
||||
|
||||
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)
|
||||
self.assertTableState(RealmCount, ['value'], [[-1]])
|
||||
do_increment_logging_stat(self.default_realm, stat, None, self.TIME_ZERO, increment=3)
|
||||
self.assertTableState(RealmCount, ['value'], [[2]])
|
||||
do_increment_logging_stat(self.default_realm, stat, None, self.TIME_ZERO)
|
||||
self.assertTableState(RealmCount, ['value'], [[3]])
|
||||
|
||||
class TestLoggingCountStats(AnalyticsTestCase):
|
||||
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)
|
||||
|
||||
user = self.create_user()
|
||||
stat = LoggingCountStat('user test', UserCount, CountStat.DAY)
|
||||
do_increment_logging_stat(user, stat, None, self.TIME_ZERO)
|
||||
process_count_stat(stat, self.TIME_ZERO)
|
||||
|
||||
stream = self.create_stream_with_recipient()[0]
|
||||
stat = LoggingCountStat('stream test', StreamCount, CountStat.DAY)
|
||||
do_increment_logging_stat(stream, stat, None, self.TIME_ZERO)
|
||||
process_count_stat(stat, self.TIME_ZERO)
|
||||
|
||||
self.assertTableState(InstallationCount, ['property', 'value'],
|
||||
[['realm test', 1], ['user test', 1], ['stream test', 1]])
|
||||
self.assertTableState(RealmCount, ['property', 'value'],
|
||||
[['realm test', 1], ['user test', 1], ['stream test', 1]])
|
||||
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):
|
||||
# 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)
|
||||
.aggregate(Sum('value'))['value__sum'])
|
||||
do_deactivate_user(user)
|
||||
self.assertEqual(0, RealmCount.objects.filter(property=property, subgroup=False)
|
||||
.aggregate(Sum('value'))['value__sum'])
|
||||
do_activate_user(user)
|
||||
self.assertEqual(1, RealmCount.objects.filter(property=property, subgroup=False)
|
||||
.aggregate(Sum('value'))['value__sum'])
|
||||
do_deactivate_user(user)
|
||||
self.assertEqual(0, RealmCount.objects.filter(property=property, subgroup=False)
|
||||
.aggregate(Sum('value'))['value__sum'])
|
||||
do_reactivate_user(user)
|
||||
self.assertEqual(1, RealmCount.objects.filter(property=property, subgroup=False)
|
||||
.aggregate(Sum('value'))['value__sum'])
|
||||
|
||||
class TestDeleteStats(AnalyticsTestCase):
|
||||
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}
|
||||
|
||||
UserCount.objects.create(user=user, realm=user.realm, **count_args)
|
||||
StreamCount.objects.create(stream=stream, realm=stream.realm, **count_args)
|
||||
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()):
|
||||
self.assertTrue(table.objects.exists())
|
||||
|
||||
do_drop_all_analytics_tables()
|
||||
for table in list(analytics.models.values()):
|
||||
self.assertFalse(table.objects.exists())
|
||||
|
||||
class TestActiveUsersAudit(AnalyticsTestCase):
|
||||
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, days_offset, user=None):
|
||||
# type: (str, float, Optional[UserProfile]) -> None
|
||||
hours_offset = int(24*days_offset)
|
||||
if user is None:
|
||||
user = self.user
|
||||
RealmAuditLog.objects.create(
|
||||
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):
|
||||
# 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):
|
||||
# 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):
|
||||
# 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):
|
||||
# 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):
|
||||
# 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):
|
||||
# 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):
|
||||
# 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'],
|
||||
[['false', self.TIME_ZERO - i*self.DAY] for i in [3, 1, 0]])
|
||||
|
||||
# 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):
|
||||
# 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('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]])
|
||||
self.assertTableState(RealmCount, ['value', 'subgroup', 'realm'],
|
||||
[[2, 'false', self.default_realm], [1, 'false', second_realm],
|
||||
[1, 'true', second_realm]])
|
||||
self.assertTableState(InstallationCount, ['value', 'subgroup'], [[3, 'false'], [1, 'true']])
|
||||
self.assertTableState(StreamCount, [], [])
|
||||
|
||||
# Not that interesting a test if you look at the SQL query at hand, but
|
||||
# almost all other CountStats have a start_date, so guarding against a
|
||||
# refactoring that adds that in.
|
||||
# Also tests the slightly more end-to-end process_count_stat rather than
|
||||
# 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):
|
||||
# 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]])
|
||||
|
||||
# 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):
|
||||
# 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):
|
||||
# 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):
|
||||
# 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):
|
||||
# type: () -> None
|
||||
user1 = self.create_user()
|
||||
user2 = self.create_user()
|
||||
user3 = self.create_user()
|
||||
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):
|
||||
# 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')
|
||||
user4 = do_create_user('email4', 'password', self.default_realm, 'full_name', 'short_name')
|
||||
do_deactivate_user(user2)
|
||||
do_activate_user(user3)
|
||||
do_reactivate_user(user4)
|
||||
end_time = floor_to_day(timezone_now()) + self.DAY
|
||||
do_fill_count_stat_at_hour(self.stat, end_time)
|
||||
for user in [user1, user3, user4]:
|
||||
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).exists())
|
||||
|
||||
class TestRealmActiveHumans(AnalyticsTestCase):
|
||||
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, 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, 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):
|
||||
# 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)
|
||||
self.mark_audit_active(user, end_time=self.TIME_ZERO + self.DAY)
|
||||
self.mark_15day_active(user, end_time=self.TIME_ZERO + self.DAY)
|
||||
|
||||
for i in [-1, 0, 1]:
|
||||
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):
|
||||
# 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):
|
||||
# type: () -> None
|
||||
user1 = self.create_user()
|
||||
user2 = self.create_user()
|
||||
second_realm = Realm.objects.create(string_id='second', name='second')
|
||||
user3 = self.create_user(realm=second_realm)
|
||||
user4 = self.create_user(realm=second_realm)
|
||||
user5 = self.create_user(realm=second_realm)
|
||||
|
||||
for user in [user1, user2, user3, user4, user5]:
|
||||
self.mark_audit_active(user)
|
||||
self.mark_15day_active(user)
|
||||
for user in [user1, user3, user4]:
|
||||
self.mark_audit_active(user, end_time=self.TIME_ZERO - self.DAY)
|
||||
self.mark_15day_active(user, end_time=self.TIME_ZERO - self.DAY)
|
||||
|
||||
for i in [-1, 0, 1]:
|
||||
do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO + i*self.DAY)
|
||||
self.assertTableState(RealmCount, ['value', 'realm', 'end_time'],
|
||||
[[2, self.default_realm, self.TIME_ZERO],
|
||||
[3, second_realm, self.TIME_ZERO],
|
||||
[1, self.default_realm, self.TIME_ZERO - self.DAY],
|
||||
[2, second_realm, self.TIME_ZERO - self.DAY]])
|
||||
|
||||
# Check that adding spurious entries doesn't make a difference
|
||||
self.mark_audit_active(user1, end_time=self.TIME_ZERO + self.DAY)
|
||||
self.mark_15day_active(user2, end_time=self.TIME_ZERO + self.DAY)
|
||||
self.mark_15day_active(user2, end_time=self.TIME_ZERO - self.DAY)
|
||||
self.create_user()
|
||||
third_realm = Realm.objects.create(string_id='third', name='third')
|
||||
self.create_user(realm=third_realm)
|
||||
|
||||
RealmCount.objects.all().delete()
|
||||
for i in [-1, 0, 1]:
|
||||
do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO + i*self.DAY)
|
||||
self.assertTableState(RealmCount, ['value', 'realm', 'end_time'],
|
||||
[[2, self.default_realm, self.TIME_ZERO],
|
||||
[3, second_realm, self.TIME_ZERO],
|
||||
[1, self.default_realm, self.TIME_ZERO - self.DAY],
|
||||
[2, second_realm, self.TIME_ZERO - self.DAY]])
|
||||
|
||||
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')
|
||||
time_zero = floor_to_day(timezone_now()) + self.DAY
|
||||
update_user_activity_interval(user1, time_zero)
|
||||
update_user_activity_interval(user2, time_zero)
|
||||
do_deactivate_user(user2)
|
||||
for property in ['active_users_audit:is_bot:day', '15day_actives::day',
|
||||
'realm_active_humans::day']:
|
||||
FillState.objects.create(property=property, state=FillState.DONE, end_time=time_zero)
|
||||
process_count_stat(COUNT_STATS[property], time_zero+self.DAY)
|
||||
self.assertEqual(RealmCount.objects.filter(
|
||||
property='realm_active_humans::day', end_time=time_zero+self.DAY, value=1).count(), 1)
|
||||
self.assertEqual(RealmCount.objects.filter(property='realm_active_humans::day').count(), 1)
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
from zerver.lib.test_classes import ZulipTestCase
|
||||
|
||||
from analytics.lib.counts import CountStat
|
||||
from analytics.lib.fixtures import generate_time_series_data
|
||||
|
||||
# A very light test suite; the code being tested is not run in production.
|
||||
class TestFixtures(ZulipTestCase):
|
||||
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(
|
||||
days=7, business_hours_base=20, non_business_hours_base=15, spikiness=0)
|
||||
self.assertEqual(data, [400, 400, 400, 400, 400, 360, 360])
|
||||
|
||||
data = generate_time_series_data(
|
||||
days=1, business_hours_base=2000, non_business_hours_base=1500,
|
||||
growth=2, spikiness=0, frequency=CountStat.HOUR)
|
||||
# test we get an array of the right length with frequency=CountStat.HOUR
|
||||
self.assertEqual(len(data), 24)
|
||||
# test that growth doesn't affect the first data point
|
||||
self.assertEqual(data[0], 2000)
|
||||
# test that the last data point is growth times what it otherwise would be
|
||||
self.assertEqual(data[-1], 1500*2)
|
||||
|
||||
# test autocorrelation == 1, since that's the easiest value to test
|
||||
data = generate_time_series_data(
|
||||
days=1, business_hours_base=2000, non_business_hours_base=2000,
|
||||
autocorrelation=1, frequency=CountStat.HOUR)
|
||||
self.assertEqual(data[0], data[1])
|
||||
self.assertEqual(data[0], data[-1])
|
||||
@@ -1,283 +1,18 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from django.utils.timezone import get_fixed_timezone, utc
|
||||
from django.utils.timezone import get_fixed_timezone
|
||||
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, \
|
||||
get_user_profile_by_email
|
||||
|
||||
from analytics.lib.counts import CountStat, COUNT_STATS
|
||||
from analytics.lib.counts import CountStat
|
||||
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 analytics.views import rewrite_client_arrays
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import mock
|
||||
import ujson
|
||||
|
||||
from six.moves import range
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
class TestStatsEndpoint(ZulipTestCase):
|
||||
def test_stats(self):
|
||||
# type: () -> None
|
||||
self.user = get_user_profile_by_email('hamlet@zulip.com')
|
||||
self.login(self.user.email)
|
||||
result = self.client_get('/stats')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
# Check that we get something back
|
||||
self.assert_in_response("Zulip Analytics for", result)
|
||||
|
||||
class TestGetChartData(ZulipTestCase):
|
||||
def setUp(self):
|
||||
# type: () -> None
|
||||
self.realm = get_realm('zulip')
|
||||
self.user = get_user_profile_by_email('hamlet@zulip.com')
|
||||
self.login(self.user.email)
|
||||
self.end_times_hour = [ceiling_to_hour(self.realm.date_created) + timedelta(hours=i)
|
||||
for i in range(4)]
|
||||
self.end_times_day = [ceiling_to_day(self.realm.date_created) + timedelta(days=i)
|
||||
for i in range(4)]
|
||||
|
||||
def data(self, i):
|
||||
# type: (int) -> List[int]
|
||||
return [0, 0, i, 0]
|
||||
|
||||
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]
|
||||
if stat.frequency == CountStat.DAY:
|
||||
insert_time = self.end_times_day[2]
|
||||
fill_time = self.end_times_day[-1]
|
||||
|
||||
RealmCount.objects.bulk_create([
|
||||
RealmCount(property=stat.property, subgroup=subgroup, end_time=insert_time,
|
||||
value=100+i, realm=self.realm)
|
||||
for i, subgroup in enumerate(realm_subgroups)])
|
||||
UserCount.objects.bulk_create([
|
||||
UserCount(property=stat.property, subgroup=subgroup, end_time=insert_time,
|
||||
value=200+i, realm=self.realm, user=self.user)
|
||||
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):
|
||||
# type: () -> None
|
||||
stat = COUNT_STATS['realm_active_humans::day']
|
||||
self.insert_data(stat, [None], [])
|
||||
result = self.client_get('/json/analytics/chart_data',
|
||||
{'chart_name': 'number_of_humans'})
|
||||
self.assert_json_success(result)
|
||||
data = ujson.loads(result.content)
|
||||
self.assertEqual(data, {
|
||||
'msg': '',
|
||||
'end_times': [datetime_to_timestamp(dt) for dt in self.end_times_day],
|
||||
'frequency': CountStat.DAY,
|
||||
'realm': {'human': self.data(100)},
|
||||
'display_order': None,
|
||||
'result': 'success',
|
||||
})
|
||||
|
||||
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',
|
||||
{'chart_name': 'messages_sent_over_time'})
|
||||
self.assert_json_success(result)
|
||||
data = ujson.loads(result.content)
|
||||
self.assertEqual(data, {
|
||||
'msg': '',
|
||||
'end_times': [datetime_to_timestamp(dt) for dt in self.end_times_hour],
|
||||
'frequency': CountStat.HOUR,
|
||||
'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):
|
||||
# type: () -> None
|
||||
stat = COUNT_STATS['messages_sent:message_type:day']
|
||||
self.insert_data(stat, ['public_stream', 'private_message'],
|
||||
['public_stream', 'private_stream'])
|
||||
result = self.client_get('/json/analytics/chart_data',
|
||||
{'chart_name': 'messages_sent_by_message_type'})
|
||||
self.assert_json_success(result)
|
||||
data = ujson.loads(result.content)
|
||||
self.assertEqual(data, {
|
||||
'msg': '',
|
||||
'end_times': [datetime_to_timestamp(dt) for dt in self.end_times_day],
|
||||
'frequency': CountStat.DAY,
|
||||
'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):
|
||||
# type: () -> None
|
||||
stat = COUNT_STATS['messages_sent:client:day']
|
||||
client1 = Client.objects.create(name='client 1')
|
||||
client2 = Client.objects.create(name='client 2')
|
||||
client3 = Client.objects.create(name='client 3')
|
||||
client4 = Client.objects.create(name='client 4')
|
||||
self.insert_data(stat, [client4.id, client3.id, client2.id],
|
||||
[client3.id, client1.id])
|
||||
result = self.client_get('/json/analytics/chart_data',
|
||||
{'chart_name': 'messages_sent_by_client'})
|
||||
self.assert_json_success(result)
|
||||
data = ujson.loads(result.content)
|
||||
self.assertEqual(data, {
|
||||
'msg': '',
|
||||
'end_times': [datetime_to_timestamp(dt) for dt in self.end_times_day],
|
||||
'frequency': CountStat.DAY,
|
||||
'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):
|
||||
# type: () -> None
|
||||
FillState.objects.create(
|
||||
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 = ujson.loads(result.content)
|
||||
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)
|
||||
result = self.client_get('/json/analytics/chart_data',
|
||||
{'chart_name': 'messages_sent_over_time'})
|
||||
self.assert_json_success(result)
|
||||
data = ujson.loads(result.content)
|
||||
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)
|
||||
result = self.client_get('/json/analytics/chart_data',
|
||||
{'chart_name': 'messages_sent_by_message_type'})
|
||||
self.assert_json_success(result)
|
||||
data = ujson.loads(result.content)
|
||||
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]})
|
||||
|
||||
FillState.objects.create(
|
||||
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 = ujson.loads(result.content)
|
||||
self.assertEqual(data['realm'], {})
|
||||
self.assertEqual(data['user'], {})
|
||||
|
||||
def test_start_and_end(self):
|
||||
# type: () -> None
|
||||
stat = COUNT_STATS['realm_active_humans::day']
|
||||
self.insert_data(stat, [None], [])
|
||||
end_time_timestamps = [datetime_to_timestamp(dt) for dt in self.end_times_day]
|
||||
|
||||
# valid start and end
|
||||
result = self.client_get('/json/analytics/chart_data',
|
||||
{'chart_name': 'number_of_humans',
|
||||
'start': end_time_timestamps[1],
|
||||
'end': end_time_timestamps[2]})
|
||||
self.assert_json_success(result)
|
||||
data = ujson.loads(result.content)
|
||||
self.assertEqual(data['end_times'], end_time_timestamps[1:3])
|
||||
self.assertEqual(data['realm'], {'human': [0, 100]})
|
||||
|
||||
# start later then end
|
||||
result = self.client_get('/json/analytics/chart_data',
|
||||
{'chart_name': 'number_of_humans',
|
||||
'start': end_time_timestamps[2],
|
||||
'end': end_time_timestamps[1]})
|
||||
self.assert_json_error_contains(result, 'Start time is later than')
|
||||
|
||||
def test_min_length(self):
|
||||
# type: () -> None
|
||||
stat = COUNT_STATS['realm_active_humans::day']
|
||||
self.insert_data(stat, [None], [])
|
||||
# test min_length is too short to change anything
|
||||
result = self.client_get('/json/analytics/chart_data',
|
||||
{'chart_name': 'number_of_humans',
|
||||
'min_length': 2})
|
||||
self.assert_json_success(result)
|
||||
data = ujson.loads(result.content)
|
||||
self.assertEqual(data['end_times'], [datetime_to_timestamp(dt) for dt in self.end_times_day])
|
||||
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',
|
||||
'min_length': 5})
|
||||
self.assert_json_success(result)
|
||||
data = ujson.loads(result.content)
|
||||
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['realm'], {'human': [0]+self.data(100)})
|
||||
|
||||
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):
|
||||
# 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'):
|
||||
result = self.client_get('/json/analytics/chart_data',
|
||||
{'chart_name': 'number_of_humans'})
|
||||
self.assert_json_error_contains(result, 'No analytics data available')
|
||||
|
||||
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):
|
||||
# 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)
|
||||
fillstate = FillState.objects.create(property='property', end_time=a_time,
|
||||
state=FillState.DONE)
|
||||
self.assertEqual(last_successful_fill('property'), a_time)
|
||||
fillstate.state = FillState.STARTED
|
||||
fillstate.save()
|
||||
self.assertEqual(last_successful_fill('property'), one_hour_before)
|
||||
|
||||
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):
|
||||
# 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):
|
||||
# type: () -> None
|
||||
HOUR = timedelta(hours=1)
|
||||
DAY = timedelta(days=1)
|
||||
TZINFO = get_fixed_timezone(-100) # 100 minutes west of UTC
|
||||
TZINFO = get_fixed_timezone(-100) # 100 minutes west of UTC
|
||||
|
||||
# Using 22:59 so that converting to UTC and applying floor_to_{hour,day} do not commute
|
||||
a_time = datetime(2016, 3, 14, 22, 59).replace(tzinfo=TZINFO)
|
||||
|
||||
@@ -1,49 +1,46 @@
|
||||
from __future__ import absolute_import, division
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import urlresolvers
|
||||
from django.db import connection
|
||||
from django.db.models import Sum
|
||||
from django.db.models.query import QuerySet
|
||||
from django.http import HttpResponseNotFound, HttpRequest, HttpResponse
|
||||
from django.template import RequestContext, loader
|
||||
from django.utils.timezone import now as timezone_now
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.shortcuts import render
|
||||
from jinja2 import Markup as mark_safe
|
||||
|
||||
from analytics.lib.counts import CountStat, process_count_stat, COUNT_STATS
|
||||
from analytics.lib.time_utils import time_range
|
||||
from analytics.models import BaseCount, InstallationCount, RealmCount, \
|
||||
UserCount, StreamCount, last_successful_fill
|
||||
UserCount, StreamCount
|
||||
|
||||
from zerver.decorator import has_request_variables, REQ, require_server_admin, \
|
||||
from zerver.decorator import has_request_variables, REQ, zulip_internal, \
|
||||
zulip_login_required, to_non_negative_int, to_utc_datetime
|
||||
from zerver.lib.request import JsonableError
|
||||
from zerver.lib.response import json_success
|
||||
from zerver.lib.timestamp import ceiling_to_hour, ceiling_to_day, timestamp_to_datetime
|
||||
from zerver.models import Realm, UserProfile, UserActivity, \
|
||||
UserActivityInterval, Client
|
||||
from zproject.jinja2 import render_to_response
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import pytz
|
||||
import re
|
||||
import time
|
||||
|
||||
from six.moves import filter, map, range, zip
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Text, \
|
||||
Tuple, Type, Union
|
||||
from typing import Any, Dict, List, Tuple, Optional, Sequence, Callable, Type, \
|
||||
Union, Text
|
||||
|
||||
@zulip_login_required
|
||||
def stats(request):
|
||||
# type: (HttpRequest) -> HttpResponse
|
||||
return render(request,
|
||||
'analytics/stats.html',
|
||||
context=dict(realm_name = request.user.realm.name))
|
||||
return render_to_response('analytics/stats.html',
|
||||
context=dict(realm_name = request.user.realm.name))
|
||||
|
||||
@has_request_variables
|
||||
def get_chart_data(request, user_profile, chart_name=REQ(),
|
||||
@@ -51,94 +48,54 @@ def get_chart_data(request, user_profile, chart_name=REQ(),
|
||||
start=REQ(converter=to_utc_datetime, default=None),
|
||||
end=REQ(converter=to_utc_datetime, default=None)):
|
||||
# type: (HttpRequest, UserProfile, Text, Optional[int], Optional[datetime], Optional[datetime]) -> HttpResponse
|
||||
realm = user_profile.realm
|
||||
# These are implicitly relying on realm.date_created and timezone.now being in UTC.
|
||||
if start is None:
|
||||
start = realm.date_created
|
||||
if end is None:
|
||||
end = timezone.now()
|
||||
if start > end:
|
||||
raise JsonableError(_("Start time is later than end time. Start: %(start)s, End: %(end)s") %
|
||||
{'start': start, 'end': end})
|
||||
|
||||
if chart_name == 'number_of_humans':
|
||||
stat = COUNT_STATS['realm_active_humans::day']
|
||||
stat = COUNT_STATS['active_users:is_bot:day']
|
||||
tables = [RealmCount]
|
||||
subgroup_to_label = {None: 'human'} # type: Dict[Optional[str], str]
|
||||
labels_sort_function = None
|
||||
subgroups = ['false', 'true']
|
||||
labels = ['human', 'bot']
|
||||
include_empty_subgroups = True
|
||||
elif chart_name == 'messages_sent_over_time':
|
||||
stat = COUNT_STATS['messages_sent:is_bot:hour']
|
||||
tables = [RealmCount, UserCount]
|
||||
subgroup_to_label = {'false': 'human', 'true': 'bot'}
|
||||
labels_sort_function = None
|
||||
tables = [RealmCount]
|
||||
subgroups = ['false', 'true']
|
||||
labels = ['human', 'bot']
|
||||
include_empty_subgroups = True
|
||||
elif chart_name == 'messages_sent_by_message_type':
|
||||
stat = COUNT_STATS['messages_sent:message_type:day']
|
||||
tables = [RealmCount, UserCount]
|
||||
subgroup_to_label = {'public_stream': 'Public streams',
|
||||
'private_stream': 'Private streams',
|
||||
'private_message': 'Private messages',
|
||||
'huddle_message': 'Group private messages'}
|
||||
labels_sort_function = lambda data: sort_by_totals(data['realm'])
|
||||
subgroups = ['public_stream', 'private_stream', 'private_message']
|
||||
labels = None
|
||||
include_empty_subgroups = True
|
||||
elif chart_name == 'messages_sent_by_client':
|
||||
stat = COUNT_STATS['messages_sent:client:day']
|
||||
tables = [RealmCount, UserCount]
|
||||
# Note that the labels are further re-written by client_label_map
|
||||
subgroup_to_label = {str(id): name for id, name in Client.objects.values_list('id', 'name')}
|
||||
labels_sort_function = sort_client_labels
|
||||
subgroups = [str(x) for x in Client.objects.values_list('id', flat=True).order_by('id')]
|
||||
labels = list(Client.objects.values_list('name', flat=True).order_by('id'))
|
||||
include_empty_subgroups = False
|
||||
else:
|
||||
raise JsonableError(_("Unknown chart name: %s") % (chart_name,))
|
||||
|
||||
# Most likely someone using our API endpoint. The /stats page does not
|
||||
# pass a start or end in its requests.
|
||||
if start is not None and end is not None and start > end:
|
||||
raise JsonableError(_("Start time is later than end time. Start: %(start)s, End: %(end)s") %
|
||||
{'start': start, 'end': end})
|
||||
|
||||
realm = user_profile.realm
|
||||
if start is None:
|
||||
start = realm.date_created
|
||||
if end is None:
|
||||
end = last_successful_fill(stat.property)
|
||||
if end is None or start > end:
|
||||
logging.warning("User from realm %s attempted to access /stats, but the computed "
|
||||
"start time: %s (creation time of realm) is later than the computed "
|
||||
"end time: %s (last successful analytics update). Is the "
|
||||
"analytics cron job running?" % (realm.string_id, start, end))
|
||||
raise JsonableError(_("No analytics data available. Please contact your server administrator."))
|
||||
|
||||
end_times = time_range(start, end, stat.frequency, min_length)
|
||||
data = {'end_times': end_times, 'frequency': stat.frequency}
|
||||
data = {'end_times': end_times, 'frequency': stat.frequency, 'interval': stat.interval}
|
||||
for table in tables:
|
||||
if table == RealmCount:
|
||||
data['realm'] = get_time_series_by_subgroup(
|
||||
stat, RealmCount, realm.id, end_times, subgroup_to_label, include_empty_subgroups)
|
||||
stat, RealmCount, realm.id, end_times, subgroups, labels, include_empty_subgroups)
|
||||
if table == UserCount:
|
||||
data['user'] = get_time_series_by_subgroup(
|
||||
stat, UserCount, user_profile.id, end_times, subgroup_to_label, include_empty_subgroups)
|
||||
if labels_sort_function is not None:
|
||||
data['display_order'] = labels_sort_function(data)
|
||||
else:
|
||||
data['display_order'] = None
|
||||
stat, UserCount, user_profile.id, end_times, subgroups, labels, include_empty_subgroups)
|
||||
return json_success(data=data)
|
||||
|
||||
def sort_by_totals(value_arrays):
|
||||
# type: (Dict[str, List[int]]) -> List[str]
|
||||
totals = [(sum(values), label) for label, values in value_arrays.items()]
|
||||
totals.sort(reverse=True)
|
||||
return [label for total, label in totals]
|
||||
|
||||
# For any given user, we want to show a fixed set of clients in the chart,
|
||||
# regardless of the time aggregation or whether we're looking at realm or
|
||||
# user data. This fixed set ideally includes the clients most important in
|
||||
# understanding the realm's traffic and the user's traffic. This function
|
||||
# tries to rank the clients so that taking the first N elements of the
|
||||
# sorted list has a reasonable chance of doing so.
|
||||
def sort_client_labels(data):
|
||||
# type: (Dict[str, Dict[str, List[int]]]) -> List[str]
|
||||
realm_order = sort_by_totals(data['realm'])
|
||||
user_order = sort_by_totals(data['user'])
|
||||
label_sort_values = {} # type: Dict[str, float]
|
||||
for i, label in enumerate(realm_order):
|
||||
label_sort_values[label] = i
|
||||
for i, label in enumerate(user_order):
|
||||
label_sort_values[label] = min(i-.1, label_sort_values.get(label, i))
|
||||
return [label for label, sort_value in sorted(label_sort_values.items(),
|
||||
key=lambda x: x[1])]
|
||||
|
||||
def table_filtered_to_id(table, key_id):
|
||||
# type: (Type[BaseCount], int) -> QuerySet
|
||||
if table == RealmCount:
|
||||
@@ -150,7 +107,7 @@ def table_filtered_to_id(table, key_id):
|
||||
elif table == InstallationCount:
|
||||
return InstallationCount.objects.all()
|
||||
else:
|
||||
raise AssertionError("Unknown table: %s" % (table,))
|
||||
raise ValueError("Unknown table: %s" % (table,))
|
||||
|
||||
def client_label_map(name):
|
||||
# type: (str) -> str
|
||||
@@ -172,7 +129,7 @@ def client_label_map(name):
|
||||
|
||||
def rewrite_client_arrays(value_arrays):
|
||||
# type: (Dict[str, List[int]]) -> Dict[str, List[int]]
|
||||
mapped_arrays = {} # type: Dict[str, List[int]]
|
||||
mapped_arrays = {} # type: Dict[str, List[int]]
|
||||
for label, array in value_arrays.items():
|
||||
mapped_label = client_label_map(label)
|
||||
if mapped_label in mapped_arrays:
|
||||
@@ -182,15 +139,20 @@ def rewrite_client_arrays(value_arrays):
|
||||
mapped_arrays[mapped_label] = [value_arrays[label][i] for i in range(0, len(array))]
|
||||
return mapped_arrays
|
||||
|
||||
def get_time_series_by_subgroup(stat, table, key_id, end_times, subgroup_to_label, include_empty_subgroups):
|
||||
# type: (CountStat, Type[BaseCount], int, List[datetime], Dict[Optional[str], str], bool) -> Dict[str, List[int]]
|
||||
def get_time_series_by_subgroup(stat, table, key_id, end_times, subgroups, labels, include_empty_subgroups):
|
||||
# type: (CountStat, Type[BaseCount], Optional[int], List[datetime], List[str], Optional[List[str]], bool) -> Dict[str, List[int]]
|
||||
if labels is None:
|
||||
labels = subgroups
|
||||
if len(subgroups) != len(labels):
|
||||
raise ValueError("subgroups and labels have lengths %s and %s, which are different." %
|
||||
(len(subgroups), len(labels)))
|
||||
queryset = table_filtered_to_id(table, key_id).filter(property=stat.property) \
|
||||
.values_list('subgroup', 'end_time', 'value')
|
||||
value_dicts = defaultdict(lambda: defaultdict(int)) # type: Dict[Optional[str], Dict[datetime, int]]
|
||||
value_dicts = defaultdict(lambda: defaultdict(int)) # type: Dict[Optional[str], Dict[datetime, int]]
|
||||
for subgroup, end_time, value in queryset:
|
||||
value_dicts[subgroup][end_time] = value
|
||||
value_arrays = {}
|
||||
for subgroup, label in subgroup_to_label.items():
|
||||
for subgroup, label in zip(subgroups, labels):
|
||||
if (subgroup in value_dicts) or include_empty_subgroups:
|
||||
value_arrays[label] = [value_dicts[subgroup][end_time] for end_time in end_times]
|
||||
|
||||
@@ -261,7 +223,7 @@ def get_realm_day_counts():
|
||||
rows = dictfetchall(cursor)
|
||||
cursor.close()
|
||||
|
||||
counts = defaultdict(dict) # type: Dict[str, Dict[int, int]]
|
||||
counts = defaultdict(dict) # type: Dict[str, Dict[int, int]]
|
||||
for row in rows:
|
||||
counts[row['string_id']][row['age']] = row['cnt']
|
||||
|
||||
@@ -741,12 +703,12 @@ def ad_hoc_queries():
|
||||
|
||||
return pages
|
||||
|
||||
@require_server_admin
|
||||
@zulip_internal
|
||||
@has_request_variables
|
||||
def get_activity(request):
|
||||
# type: (HttpRequest) -> HttpResponse
|
||||
duration_content, realm_minutes = user_activity_intervals() # type: Tuple[mark_safe, Dict[str, float]]
|
||||
counts_content = realm_summary_table(realm_minutes) # type: str
|
||||
duration_content, realm_minutes = user_activity_intervals() # type: Tuple[mark_safe, Dict[str, float]]
|
||||
counts_content = realm_summary_table(realm_minutes) # type: str
|
||||
data = [
|
||||
('Counts', counts_content),
|
||||
('Durations', duration_content),
|
||||
@@ -756,10 +718,10 @@ def get_activity(request):
|
||||
|
||||
title = 'Activity'
|
||||
|
||||
return render(
|
||||
request,
|
||||
return render_to_response(
|
||||
'analytics/activity.html',
|
||||
context=dict(data=data, title=title, is_home=True),
|
||||
dict(data=data, title=title, is_home=True),
|
||||
request=request
|
||||
)
|
||||
|
||||
def get_user_activity_records_for_realm(realm, is_bot):
|
||||
@@ -828,7 +790,7 @@ def get_user_activity_summary(records):
|
||||
#: We could use something like:
|
||||
# `Union[Dict[str, Dict[str, int]], Dict[str, Dict[str, datetime]]]`
|
||||
#: but that would require this long `Union` to carry on throughout inner functions.
|
||||
summary = {} # type: Dict[str, Dict[str, Any]]
|
||||
summary = {} # type: Dict[str, Dict[str, Any]]
|
||||
|
||||
def update(action, record):
|
||||
# type: (str, QuerySet) -> None
|
||||
@@ -991,7 +953,7 @@ def realm_user_summary_table(all_records, admin_emails):
|
||||
|
||||
def is_recent(val):
|
||||
# type: (Optional[datetime]) -> bool
|
||||
age = timezone_now() - val
|
||||
age = datetime.now(val.tzinfo) - val
|
||||
return age.total_seconds() < 5 * 60
|
||||
|
||||
rows = []
|
||||
@@ -1013,7 +975,7 @@ def realm_user_summary_table(all_records, admin_emails):
|
||||
rows.append(row)
|
||||
|
||||
def by_used_time(row):
|
||||
# type: (Dict[str, Any]) -> str
|
||||
# type: (Dict[str, Sequence[str]]) -> str
|
||||
return row['cells'][3]
|
||||
|
||||
rows = sorted(rows, key=by_used_time, reverse=True)
|
||||
@@ -1035,11 +997,11 @@ def realm_user_summary_table(all_records, admin_emails):
|
||||
content = make_table(title, cols, rows, has_row_class=True)
|
||||
return user_records, content
|
||||
|
||||
@require_server_admin
|
||||
@zulip_internal
|
||||
def get_realm_activity(request, realm_str):
|
||||
# type: (HttpRequest, str) -> HttpResponse
|
||||
data = [] # type: List[Tuple[str, str]]
|
||||
all_user_records = {} # type: Dict[str, Any]
|
||||
data = [] # type: List[Tuple[str, str]]
|
||||
all_user_records = {} # type: Dict[str, Any]
|
||||
|
||||
try:
|
||||
admins = Realm.objects.get(string_id=realm_str).get_admin_users()
|
||||
@@ -1068,18 +1030,18 @@ def get_realm_activity(request, realm_str):
|
||||
realm_link += '&target=stats.gauges.staging.users.active.%s.0_16hr' % (realm_str,)
|
||||
|
||||
title = realm_str
|
||||
return render(
|
||||
request,
|
||||
return render_to_response(
|
||||
'analytics/activity.html',
|
||||
context=dict(data=data, realm_link=realm_link, title=title),
|
||||
dict(data=data, realm_link=realm_link, title=title),
|
||||
request=request
|
||||
)
|
||||
|
||||
@require_server_admin
|
||||
@zulip_internal
|
||||
def get_user_activity(request, email):
|
||||
# type: (HttpRequest, str) -> HttpResponse
|
||||
records = get_user_activity_records_for_email(email)
|
||||
|
||||
data = [] # type: List[Tuple[str, str]]
|
||||
data = [] # type: List[Tuple[str, str]]
|
||||
user_summary = get_user_activity_summary(records)
|
||||
content = user_activity_summary_table(user_summary)
|
||||
|
||||
@@ -1089,8 +1051,8 @@ def get_user_activity(request, email):
|
||||
data += [('Info', content)]
|
||||
|
||||
title = email
|
||||
return render(
|
||||
request,
|
||||
return render_to_response(
|
||||
'analytics/activity.html',
|
||||
context=dict(data=data, title=title),
|
||||
dict(data=data, title=title),
|
||||
request=request
|
||||
)
|
||||
|
||||
@@ -44,12 +44,7 @@ Alternatively, you may explicitly use "--user", "--api-key", and
|
||||
`--site` in our examples, which is especially useful when testing. If
|
||||
you are running several bots which share a home directory, we
|
||||
recommend using `--config` to specify the path to the `zuliprc` file
|
||||
for a specific bot. Finally, you can control the defaults for all of
|
||||
these variables using the environment variables `ZULIP_CONFIG`,
|
||||
`ZULIP_API_KEY`, `ZULIP_EMAIL`, `ZULIP_SITE`, `ZULIP_CERT`,
|
||||
`ZULIP_CERT_KEY`, and `ZULIP_CERT_BUNDLE`. Command-line options take
|
||||
precedence over environment variables take precedence over the config
|
||||
files.
|
||||
for a specific bot.
|
||||
|
||||
The command line equivalents for other configuration options are:
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ def main(argv=None):
|
||||
parser.add_option('-m', '--message',
|
||||
help='Specifies the message to send, prevents interactive prompting.')
|
||||
|
||||
group = optparse.OptionGroup(parser, 'Stream parameters') # type: ignore # https://github.com/python/typeshed/pull/1248
|
||||
group = optparse.OptionGroup(parser, 'Stream parameters')
|
||||
group.add_option('-s', '--stream',
|
||||
dest='stream',
|
||||
action='store',
|
||||
@@ -122,7 +122,7 @@ def main(argv=None):
|
||||
|
||||
if not do_send_message(client, message_data):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -1,130 +0,0 @@
|
||||
# See readme.md for instructions on running this code.
|
||||
|
||||
from __future__ import absolute_import
|
||||
from __future__ import division
|
||||
|
||||
import copy
|
||||
import importlib
|
||||
import sys
|
||||
from math import log10, floor
|
||||
|
||||
import utils
|
||||
import re
|
||||
|
||||
def is_float(value):
|
||||
try:
|
||||
float(value)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
# Rounds the number 'x' to 'digits' significant digits.
|
||||
# A normal 'round()' would round the number to an absolute amount of
|
||||
# fractional decimals, e.g. 0.00045 would become 0.0.
|
||||
# 'round_to()' rounds only the digits that are not 0.
|
||||
# 0.00045 would then become 0.0005.
|
||||
def round_to(x, digits):
|
||||
return round(x, digits-int(floor(log10(abs(x)))))
|
||||
|
||||
class ConverterHandler(object):
|
||||
'''
|
||||
This plugin allows users to make conversions between various units,
|
||||
e.g. Celsius to Fahrenheit, or kilobytes to gigabytes.
|
||||
It looks for messages of the format
|
||||
'@mention-bot <number> <unit_from> <unit_to>'
|
||||
The message '@mention-bot help' posts a short description of how to use
|
||||
the plugin, along with a list of all supported units.
|
||||
'''
|
||||
|
||||
def usage(self):
|
||||
return '''
|
||||
This plugin allows users to make conversions between
|
||||
various units, e.g. Celsius to Fahrenheit,
|
||||
or kilobytes to gigabytes. It looks for messages of
|
||||
the format '@mention-bot <number> <unit_from> <unit_to>'
|
||||
The message '@mention-bot help' posts a short description of
|
||||
how to use the plugin, along with a list of
|
||||
all supported units.
|
||||
'''
|
||||
|
||||
def handle_message(self, message, client, state_handler):
|
||||
bot_response = get_bot_converter_response(message, client)
|
||||
client.send_reply(message, bot_response)
|
||||
|
||||
def get_bot_converter_response(message, client):
|
||||
content = message['content']
|
||||
|
||||
words = content.lower().split()
|
||||
convert_indexes = [i for i, word in enumerate(words) if word == "@convert"]
|
||||
convert_indexes = [-1] + convert_indexes
|
||||
results = []
|
||||
|
||||
for convert_index in convert_indexes:
|
||||
if (convert_index + 1) < len(words) and words[convert_index + 1] == 'help':
|
||||
results.append(utils.HELP_MESSAGE)
|
||||
continue
|
||||
if (convert_index + 3) < len(words):
|
||||
number = words[convert_index + 1]
|
||||
unit_from = utils.ALIASES.get(words[convert_index + 2], words[convert_index + 2])
|
||||
unit_to = utils.ALIASES.get(words[convert_index + 3], words[convert_index + 3])
|
||||
exponent = 0
|
||||
|
||||
if not is_float(number):
|
||||
results.append(number + ' is not a valid number. ' + utils.QUICK_HELP)
|
||||
continue
|
||||
|
||||
number = float(number)
|
||||
number_res = copy.copy(number)
|
||||
|
||||
for key, exp in utils.PREFIXES.items():
|
||||
if unit_from.startswith(key):
|
||||
exponent += exp
|
||||
unit_from = unit_from[len(key):]
|
||||
if unit_to.startswith(key):
|
||||
exponent -= exp
|
||||
unit_to = unit_to[len(key):]
|
||||
|
||||
uf_to_std = utils.UNITS.get(unit_from, False)
|
||||
ut_to_std = utils.UNITS.get(unit_to, False)
|
||||
|
||||
if uf_to_std is False:
|
||||
results.append(unit_from + ' is not a valid unit. ' + utils.QUICK_HELP)
|
||||
if ut_to_std is False:
|
||||
results.append(unit_to + ' is not a valid unit.' + utils.QUICK_HELP)
|
||||
if uf_to_std is False or ut_to_std is False:
|
||||
continue
|
||||
|
||||
base_unit = uf_to_std[2]
|
||||
if uf_to_std[2] != ut_to_std[2]:
|
||||
unit_from = unit_from.capitalize() if uf_to_std[2] == 'kelvin' else unit_from
|
||||
results.append(unit_to.capitalize() + ' and ' + unit_from +
|
||||
' are not from the same category. ' + utils.QUICK_HELP)
|
||||
continue
|
||||
|
||||
# perform the conversion between the units
|
||||
number_res *= uf_to_std[1]
|
||||
number_res += uf_to_std[0]
|
||||
number_res -= ut_to_std[0]
|
||||
number_res /= ut_to_std[1]
|
||||
|
||||
if base_unit == 'bit':
|
||||
number_res *= 1024 ** (exponent // 3)
|
||||
else:
|
||||
number_res *= 10 ** exponent
|
||||
number_res = round_to(number_res, 7)
|
||||
|
||||
results.append('{} {} = {} {}'.format(number,
|
||||
words[convert_index + 2],
|
||||
number_res,
|
||||
words[convert_index + 3]))
|
||||
|
||||
else:
|
||||
results.append('Too few arguments given. ' + utils.QUICK_HELP)
|
||||
|
||||
new_content = ''
|
||||
for idx, result in enumerate(results, 1):
|
||||
new_content += ((str(idx) + '. conversion: ') if len(results) > 1 else '') + result + '\n'
|
||||
|
||||
return new_content
|
||||
|
||||
handler_class = ConverterHandler
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
from __future__ import absolute_import
|
||||
from __future__ import print_function
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
our_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.normpath(os.path.join(our_dir)))
|
||||
# For dev setups, we can find the API in the repo itself.
|
||||
if os.path.exists(os.path.join(our_dir, '..')):
|
||||
sys.path.insert(0, '..')
|
||||
from bots_test_lib import BotTestCase
|
||||
|
||||
class TestConverterBot(BotTestCase):
|
||||
bot_name = "converter"
|
||||
|
||||
def test_bot(self):
|
||||
expected = {
|
||||
"": ('Too few arguments given. Enter `@convert help` '
|
||||
'for help on using the converter.\n'),
|
||||
"foo bar": ('Too few arguments given. Enter `@convert help` '
|
||||
'for help on using the converter.\n'),
|
||||
"2 m cm": "2.0 m = 200.0 cm\n",
|
||||
"12.0 celsius fahrenheit": "12.0 celsius = 53.600054 fahrenheit\n",
|
||||
"0.002 kilometer millimile": "0.002 kilometer = 1.2427424 millimile\n",
|
||||
"3 megabyte kilobit": "3.0 megabyte = 24576.0 kilobit\n",
|
||||
}
|
||||
self.check_expected_responses(expected)
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
from __future__ import absolute_import
|
||||
from __future__ import print_function
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
our_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.normpath(os.path.join(our_dir)))
|
||||
# For dev setups, we can find the API in the repo itself.
|
||||
if os.path.exists(os.path.join(our_dir, '..')):
|
||||
sys.path.insert(0, '..')
|
||||
from bots_test_lib import BotTestCase
|
||||
|
||||
class TestDefineBot(BotTestCase):
|
||||
bot_name = "define"
|
||||
|
||||
def test_bot(self):
|
||||
expected = {
|
||||
"": 'Please enter a word to define.',
|
||||
"foo": "**foo**:\nDefinition not available.",
|
||||
"cat": ("**cat**:\n\n* (**noun**) a small domesticated carnivorous mammal "
|
||||
"with soft fur, a short snout, and retractile claws. It is widely "
|
||||
"kept as a pet or for catching mice, and many breeds have been "
|
||||
"developed.\n their pet cat\n\n"),
|
||||
}
|
||||
self.check_expected_responses(expected)
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
from __future__ import absolute_import
|
||||
from __future__ import print_function
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
our_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.normpath(os.path.join(our_dir)))
|
||||
# For dev setups, we can find the API in the repo itself.
|
||||
if os.path.exists(os.path.join(our_dir, '..')):
|
||||
sys.path.insert(0, '..')
|
||||
from bots_test_lib import BotTestCase
|
||||
|
||||
class TestEncryptBot(BotTestCase):
|
||||
bot_name = "encrypt"
|
||||
|
||||
def test_bot(self):
|
||||
expected = {
|
||||
"": "Encrypted/Decrypted text: ",
|
||||
"Let\'s Do It": "Encrypted/Decrypted text: Yrg\'f Qb Vg",
|
||||
"me&mom together..!!": "Encrypted/Decrypted text: zr&zbz gbtrgure..!!",
|
||||
"foo bar": "Encrypted/Decrypted text: sbb one",
|
||||
"Please encrypt this": "Encrypted/Decrypted text: Cyrnfr rapelcg guvf",
|
||||
}
|
||||
self.check_expected_responses(expected)
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
from __future__ import absolute_import
|
||||
from __future__ import print_function
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
our_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.normpath(os.path.join(our_dir)))
|
||||
# For dev setups, we can find the API in the repo itself.
|
||||
if os.path.exists(os.path.join(our_dir, '..')):
|
||||
sys.path.insert(0, '..')
|
||||
from bots_test_lib import BotTestCase
|
||||
|
||||
class TestFollowUpBot(BotTestCase):
|
||||
bot_name = "followup"
|
||||
|
||||
def test_bot(self):
|
||||
expected_send_reply = {
|
||||
"": 'Please specify the message you want to send to followup stream after @mention-bot'
|
||||
}
|
||||
self.check_expected_responses(expected_send_reply, expected_method='send_reply')
|
||||
|
||||
expected_send_message = {
|
||||
"foo": {
|
||||
'type': 'stream',
|
||||
'to': 'followup',
|
||||
'subject': 'foo_sender@zulip.com',
|
||||
'content': 'from foo_sender@zulip.com: foo',
|
||||
},
|
||||
"I have completed my task": {
|
||||
'type': 'stream',
|
||||
'to': 'followup',
|
||||
'subject': 'foo_sender@zulip.com',
|
||||
'content': 'from foo_sender@zulip.com: I have completed my task',
|
||||
},
|
||||
}
|
||||
self.check_expected_responses(expected_send_message, expected_method='send_message')
|
||||
@@ -1,61 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
from __future__ import absolute_import
|
||||
from __future__ import print_function
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
|
||||
our_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.normpath(os.path.join(our_dir)))
|
||||
# For dev setups, we can find the API in the repo itself.
|
||||
if os.path.exists(os.path.join(our_dir, '..')):
|
||||
sys.path.insert(0, '..')
|
||||
from bots_test_lib import BotTestCase
|
||||
from bots.giphy import giphy
|
||||
|
||||
def get_http_response_json(gif_url):
|
||||
response_json = {
|
||||
'meta': {
|
||||
'status': 200
|
||||
},
|
||||
'data': {
|
||||
'images': {
|
||||
'original': {
|
||||
'url': gif_url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return response_json
|
||||
|
||||
def get_bot_response(gif_url):
|
||||
return ('[Click to enlarge](%s)'
|
||||
'[](/static/images/interactive-bot/giphy/powered-by-giphy.png)'
|
||||
% (gif_url))
|
||||
|
||||
def get_http_request(keyword):
|
||||
return {
|
||||
'api_url': giphy.GIPHY_TRANSLATE_API,
|
||||
'params': {
|
||||
's': keyword,
|
||||
'api_key': giphy.get_giphy_api_key_from_config()
|
||||
}
|
||||
}
|
||||
|
||||
class TestGiphyBot(BotTestCase):
|
||||
bot_name = "giphy"
|
||||
|
||||
def test_bot(self):
|
||||
# This message calls `send_reply` function of BotHandlerApi
|
||||
keyword = "Hello"
|
||||
gif_url = "https://media4.giphy.com/media/3o6ZtpxSZbQRRnwCKQ/giphy.gif"
|
||||
expectations = {
|
||||
keyword: get_bot_response(gif_url)
|
||||
}
|
||||
self.check_expected_responses(
|
||||
expectations=expectations,
|
||||
http_request=get_http_request(keyword),
|
||||
http_response=get_http_response_json(gif_url)
|
||||
)
|
||||
@@ -1,93 +0,0 @@
|
||||
# See readme.md for instructions on running this code.
|
||||
from __future__ import print_function
|
||||
import logging
|
||||
import http.client
|
||||
from six.moves.urllib.request import urlopen
|
||||
|
||||
# Uses the Google search engine bindings
|
||||
# pip install --upgrade google
|
||||
from google import search
|
||||
|
||||
|
||||
def get_google_result(search_keywords):
|
||||
help_message = "To use this bot, start messages with @mentioned-bot, \
|
||||
followed by what you want to search for. If \
|
||||
found, Zulip will return the first search result \
|
||||
on Google.\
|
||||
\
|
||||
An example message that could be sent is:\
|
||||
'@mentioned-bot zulip' or \
|
||||
'@mentioned-bot how to create a chatbot'."
|
||||
if search_keywords == 'help':
|
||||
return help_message
|
||||
elif search_keywords == '' or search_keywords is None:
|
||||
return help_message
|
||||
else:
|
||||
try:
|
||||
urls = search(search_keywords, stop=20)
|
||||
urlopen('http://216.58.192.142', timeout=1)
|
||||
except http.client.RemoteDisconnected as er:
|
||||
logging.exception(er)
|
||||
return 'Error: No internet connection. {}.'.format(er)
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
return 'Error: Search failed. {}.'.format(e)
|
||||
|
||||
try:
|
||||
url = next(urls)
|
||||
except AttributeError as a_err:
|
||||
# google.search query failed and urls is of object
|
||||
# 'NoneType'
|
||||
logging.exception(a_err)
|
||||
return "Error: Google search failed with a NoneType result. {}.".format(a_err)
|
||||
except TypeError as t_err:
|
||||
# google.search query failed and returned None
|
||||
# This technically should not happen but the prior
|
||||
# error check assumed this behavior
|
||||
logging.exception(t_err)
|
||||
return "Error: Google search function failed. {}.".format(t_err)
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
return 'Error: Search failed. {}.'.format(e)
|
||||
|
||||
return 'Success: {}'.format(url)
|
||||
|
||||
|
||||
class GoogleSearchHandler(object):
|
||||
'''
|
||||
This plugin allows users to enter a search
|
||||
term in Zulip and get the top URL sent back
|
||||
to the context (stream or private) in which
|
||||
it was called. It looks for messages starting
|
||||
with @mentioned-bot.
|
||||
'''
|
||||
|
||||
def usage(self):
|
||||
return '''
|
||||
This plugin will allow users to search
|
||||
for a given search term on Google from
|
||||
Zulip. Use '@mentioned-bot help' to get
|
||||
more information on the bot usage. Users
|
||||
should preface messages with
|
||||
@mentioned-bot.
|
||||
'''
|
||||
|
||||
def handle_message(self, message, client, state_handler):
|
||||
original_content = message['content']
|
||||
result = get_google_result(original_content)
|
||||
client.send_reply(message, result)
|
||||
|
||||
handler_class = GoogleSearchHandler
|
||||
|
||||
|
||||
def test():
|
||||
try:
|
||||
urlopen('http://216.58.192.142', timeout=1)
|
||||
print('Success')
|
||||
return True
|
||||
except http.client.RemoteDisconnected as e:
|
||||
print('Error: {}'.format(e))
|
||||
return False
|
||||
|
||||
if __name__ == '__main__':
|
||||
test()
|
||||
@@ -1,23 +0,0 @@
|
||||
# Google Search bot
|
||||
|
||||
This bot allows users to do Google search queries and have the bot
|
||||
respond with the first search result. It is by default set to the
|
||||
highest safe-search setting.
|
||||
|
||||
## Usage
|
||||
|
||||
Run this bot as described
|
||||
[here](http://zulip.readthedocs.io/en/latest/bots-guide.html#how-to-deploy-a-bot).
|
||||
|
||||
Use this bot with the following command
|
||||
|
||||
`@mentioned-bot <search terms>`
|
||||
|
||||
This will return the first link found by Google for `<search terms>`
|
||||
and print the resulting URL.
|
||||
|
||||
If no `<search terms>` are entered, a help message is printed instead.
|
||||
|
||||
If there was an error in the process of running the search (socket
|
||||
errors, Google search function failed, or general failures), an error
|
||||
message is returned.
|
||||
@@ -1,18 +0,0 @@
|
||||
# See readme.md for instructions on running this code.
|
||||
|
||||
|
||||
class HelloWorldHandler(object):
|
||||
def usage(self):
|
||||
return '''
|
||||
This is a boilerplate bot that responds to a user query with
|
||||
"beep boop", which is robot for "Hello World".
|
||||
|
||||
This bot can be used as a template for other, more
|
||||
sophisticated, bots.
|
||||
'''
|
||||
|
||||
def handle_message(self, message, client, state_handler):
|
||||
content = 'beep boop'
|
||||
client.send_reply(message, content)
|
||||
|
||||
handler_class = HelloWorldHandler
|
||||
@@ -1,4 +0,0 @@
|
||||
Simple Zulip bot that will respond to any query with a "beep boop".
|
||||
|
||||
The helloworld bot is a boilerplate bot that can be used as a
|
||||
template for more sophisticated/evolved Zulip bots.
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
from __future__ import absolute_import
|
||||
from __future__ import print_function
|
||||
|
||||
import os
|
||||
import sys
|
||||
from six.moves import zip
|
||||
|
||||
our_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.normpath(os.path.join(our_dir)))
|
||||
# For dev setups, we can find the API in the repo itself.
|
||||
if os.path.exists(os.path.join(our_dir, '..')):
|
||||
sys.path.insert(0, '..')
|
||||
from bots_test_lib import BotTestCase
|
||||
|
||||
class TestHelloWorldBot(BotTestCase):
|
||||
bot_name = "helloworld"
|
||||
|
||||
def test_bot(self):
|
||||
txt = "beep boop"
|
||||
messages = ["", "foo", "Hi, my name is abc"]
|
||||
self.check_expected_responses(dict(list(zip(messages, len(messages)*[txt]))))
|
||||
@@ -1,18 +0,0 @@
|
||||
# See readme.md for instructions on running this code.
|
||||
|
||||
class HelpHandler(object):
|
||||
def usage(self):
|
||||
return '''
|
||||
This plugin will give info about Zulip to
|
||||
any user that types a message saying "help".
|
||||
|
||||
This is example code; ideally, you would flesh
|
||||
this out for more useful help pertaining to
|
||||
your Zulip instance.
|
||||
'''
|
||||
|
||||
def handle_message(self, message, client, state_handler):
|
||||
help_content = "Info on Zulip can be found here:\nhttps://github.com/zulip/zulip"
|
||||
client.send_reply(message, help_content)
|
||||
|
||||
handler_class = HelpHandler
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
from __future__ import absolute_import
|
||||
from __future__ import print_function
|
||||
|
||||
import os
|
||||
import sys
|
||||
from six.moves import zip
|
||||
|
||||
our_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.normpath(os.path.join(our_dir)))
|
||||
# For dev setups, we can find the API in the repo itself.
|
||||
if os.path.exists(os.path.join(our_dir, '..')):
|
||||
sys.path.insert(0, '..')
|
||||
from bots_test_lib import BotTestCase
|
||||
|
||||
class TestHelpBot(BotTestCase):
|
||||
bot_name = "help"
|
||||
|
||||
def test_bot(self):
|
||||
txt = "Info on Zulip can be found here:\nhttps://github.com/zulip/zulip"
|
||||
messages = ["", "help", "Hi, my name is abc"]
|
||||
self.check_expected_responses(dict(list(zip(messages, len(messages)*[txt]))))
|
||||
@@ -1,30 +0,0 @@
|
||||
# See readme.md for instructions on running this code.
|
||||
|
||||
|
||||
class IncrementorHandler(object):
|
||||
|
||||
def __init__(self):
|
||||
self.number = 0
|
||||
self.message_id = None
|
||||
|
||||
def usage(self):
|
||||
return '''
|
||||
This is a boilerplate bot that makes use of the
|
||||
update_message function. For the first @-mention, it initially
|
||||
replies with one message containing a `1`. Every time the bot
|
||||
is @-mentioned, this number will be incremented in the same message.
|
||||
'''
|
||||
|
||||
def handle_message(self, message, client, state_handler):
|
||||
self.number += 1
|
||||
if self.message_id is None:
|
||||
result = client.send_reply(message, str(self.number))
|
||||
self.message_id = result['id']
|
||||
else:
|
||||
client.update_message(dict(
|
||||
message_id=self.message_id,
|
||||
content=str(self.number),
|
||||
))
|
||||
|
||||
|
||||
handler_class = IncrementorHandler
|
||||
@@ -1,6 +0,0 @@
|
||||
# Incrementor bot
|
||||
|
||||
This is a boilerplate bot that makes use of the
|
||||
update_message function. For the first @-mention, it initially
|
||||
replies with one message containing a `1`. Every time the bot
|
||||
is @-mentioned, this number will be incremented in the same message.
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
from __future__ import absolute_import
|
||||
from __future__ import print_function
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
our_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.normpath(os.path.join(our_dir)))
|
||||
# For dev setups, we can find the API in the repo itself.
|
||||
if os.path.exists(os.path.join(our_dir, '..')):
|
||||
sys.path.insert(0, '..')
|
||||
from bots_test_lib import BotTestCase
|
||||
|
||||
class TestThesaurusBot(BotTestCase):
|
||||
bot_name = "thesaurus"
|
||||
|
||||
def test_bot(self):
|
||||
expected = {
|
||||
"synonym good": "great, satisfying, exceptional, positive, acceptable",
|
||||
"synonym nice": "cordial, kind, good, okay, fair",
|
||||
"synonym foo": "bar, thud, X, baz, corge",
|
||||
"antonym dirty": "ordered, sterile, spotless, moral, clean",
|
||||
"antonym bar": "loss, whole, advantage, aid, failure",
|
||||
"": ("To use this bot, start messages with either "
|
||||
"@mention-bot synonym (to get the synonyms of a given word) "
|
||||
"or @mention-bot antonym (to get the antonyms of a given word). "
|
||||
"Phrases are not accepted so only use single words "
|
||||
"to search. For example you could search '@mention-bot synonym hello' "
|
||||
"or '@mention-bot antonym goodbye'."),
|
||||
}
|
||||
self.check_expected_responses(expected)
|
||||
@@ -1,71 +0,0 @@
|
||||
# See zulip/api/bots/readme.md for instructions on running this code.
|
||||
from __future__ import print_function
|
||||
import sys
|
||||
import logging
|
||||
try:
|
||||
from PyDictionary import PyDictionary as Dictionary
|
||||
except ImportError:
|
||||
logging.error("Dependency Missing!")
|
||||
sys.exit(0)
|
||||
|
||||
#Uses Python's Dictionary module
|
||||
# pip install PyDictionary
|
||||
|
||||
def get_clean_response(m, method):
|
||||
try:
|
||||
response = method(m)
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
return e
|
||||
if isinstance(response, str):
|
||||
return response
|
||||
elif isinstance(response, list):
|
||||
return ', '.join(response)
|
||||
else:
|
||||
return "Sorry, no result found! Please check the word."
|
||||
|
||||
def get_thesaurus_result(original_content):
|
||||
help_message = ("To use this bot, start messages with either "
|
||||
"@mention-bot synonym (to get the synonyms of a given word) "
|
||||
"or @mention-bot antonym (to get the antonyms of a given word). "
|
||||
"Phrases are not accepted so only use single words "
|
||||
"to search. For example you could search '@mention-bot synonym hello' "
|
||||
"or '@mention-bot antonym goodbye'.")
|
||||
query = original_content.strip().split(' ', 1)
|
||||
if len(query) < 2:
|
||||
return help_message
|
||||
else:
|
||||
search_keyword = query[1]
|
||||
if original_content.startswith('synonym'):
|
||||
result = get_clean_response(search_keyword, method = Dictionary.synonym)
|
||||
elif original_content.startswith('antonym'):
|
||||
result = get_clean_response(search_keyword, method = Dictionary.antonym)
|
||||
else:
|
||||
result = help_message
|
||||
return result
|
||||
|
||||
class ThesaurusHandler(object):
|
||||
'''
|
||||
This plugin allows users to enter a word in zulip
|
||||
and get synonyms, and antonyms, for that word sent
|
||||
back to the context (stream or private) in which
|
||||
it was sent. It looks for messages starting with
|
||||
'@mention-bot synonym' or '@mention-bot @antonym'.
|
||||
'''
|
||||
|
||||
def usage(self):
|
||||
return '''
|
||||
This plugin will allow users to get both synonyms
|
||||
and antonyms for a given word from zulip. To use this
|
||||
plugin, users need to install the PyDictionary module
|
||||
using 'pip install PyDictionary'.Use '@mention-bot synonym help' or
|
||||
'@mention-bot antonym help' for more usage information. Users should
|
||||
preface messages with @mention-bot synonym or @mention-bot antonym.
|
||||
'''
|
||||
|
||||
def handle_message(self, message, client, state_handler):
|
||||
original_content = message['content'].strip()
|
||||
new_content = get_thesaurus_result(original_content)
|
||||
client.send_reply(message, new_content)
|
||||
|
||||
handler_class = ThesaurusHandler
|
||||
@@ -1,44 +0,0 @@
|
||||
# Virtual fs bot
|
||||
|
||||
This bot allows users to store information in a virtual file system,
|
||||
for a given stream or private chat.
|
||||
|
||||
## Usage
|
||||
|
||||
Run this bot as described in
|
||||
[here](http://zulip.readthedocs.io/en/latest/bots-guide.html#how-to-deploy-a-bot).
|
||||
|
||||
Use this bot with any of the following commands:
|
||||
|
||||
`@fs mkdir` : create a directory
|
||||
`@fs ls` : list a directory
|
||||
`@fs cd` : change directory
|
||||
`@fs pwd` : show current path
|
||||
`@fs write` : write text
|
||||
`@fs read` : read text
|
||||
`@fs rm` : remove a file
|
||||
`@fs rmdir` : remove a directory
|
||||
|
||||
where `fs` may be the name of the bot you registered in the zulip system.
|
||||
|
||||
### Usage examples
|
||||
|
||||
`@fs ls` - Initially shows nothing (with a warning)
|
||||
`@fs pwd` - Show which directory we are in: we start in /
|
||||
`@fs mkdir foo` - Make directory foo
|
||||
`@fs ls` - Show that foo is now created
|
||||
`@fs cd foo` - Change into foo (and do a pwd, automatically)
|
||||
`@fs write test hello world` - Write "hello world" to the file 'test'
|
||||
`@fs read test` - Check the text was written
|
||||
`@fs ls` - Show that the new file exists
|
||||
`@fs rm test` - Remove that file
|
||||
`@fs cd /` - Change back to root directory
|
||||
`@fs rmdir foo` - Remove foo
|
||||
|
||||
## Notes
|
||||
|
||||
* In a stream, the bot must be mentioned; in a private chat, the bot
|
||||
will assume every message is a command and so does not require this,
|
||||
though doing so will still work.
|
||||
|
||||
* Use commands like `@fs help write` for more details on a command.
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
from __future__ import absolute_import
|
||||
from __future__ import print_function
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
our_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.normpath(os.path.join(our_dir)))
|
||||
# For dev setups, we can find the API in the repo itself.
|
||||
if os.path.exists(os.path.join(our_dir, '..')):
|
||||
sys.path.insert(0, '..')
|
||||
from bots_test_lib import BotTestCase
|
||||
|
||||
class TestVirtualFsBot(BotTestCase):
|
||||
bot_name = "virtual_fs"
|
||||
|
||||
def test_bot(self):
|
||||
expected = {
|
||||
"cd /home": "foo_sender@zulip.com:\nERROR: invalid path",
|
||||
"mkdir home": "foo_sender@zulip.com:\ndirectory created",
|
||||
"pwd": "foo_sender@zulip.com:\n/",
|
||||
"help": ('foo_sender@zulip.com:\n\nThis bot implements a virtual file system for a stream.\n'
|
||||
'The locations of text are persisted for the lifetime of the bot\n'
|
||||
'running, and if you rename a stream, you will lose the info.\n'
|
||||
'Example commands:\n\n```\n'
|
||||
'@mention-bot sample_conversation: sample conversation with the bot\n'
|
||||
'@mention-bot mkdir: create a directory\n'
|
||||
'@mention-bot ls: list a directory\n'
|
||||
'@mention-bot cd: change directory\n'
|
||||
'@mention-bot pwd: show current path\n'
|
||||
'@mention-bot write: write text\n'
|
||||
'@mention-bot read: read text\n'
|
||||
'@mention-bot rm: remove a file\n'
|
||||
'@mention-bot rmdir: remove a directory\n'
|
||||
'```\n'
|
||||
'Use commands like `@mention-bot help write` for more details on specific\ncommands.\n'),
|
||||
"help ls": "foo_sender@zulip.com:\nsyntax: ls <optional_path>",
|
||||
"": ('foo_sender@zulip.com:\n\nThis bot implements a virtual file system for a stream.\n'
|
||||
'The locations of text are persisted for the lifetime of the bot\n'
|
||||
'running, and if you rename a stream, you will lose the info.\n'
|
||||
'Example commands:\n\n```\n'
|
||||
'@mention-bot sample_conversation: sample conversation with the bot\n'
|
||||
'@mention-bot mkdir: create a directory\n'
|
||||
'@mention-bot ls: list a directory\n'
|
||||
'@mention-bot cd: change directory\n'
|
||||
'@mention-bot pwd: show current path\n'
|
||||
'@mention-bot write: write text\n'
|
||||
'@mention-bot read: read text\n'
|
||||
'@mention-bot rm: remove a file\n'
|
||||
'@mention-bot rmdir: remove a directory\n'
|
||||
'```\n'
|
||||
'Use commands like `@mention-bot help write` for more details on specific\ncommands.\n'),
|
||||
}
|
||||
self.check_expected_responses(expected)
|
||||
@@ -1,2 +0,0 @@
|
||||
[weather-config]
|
||||
key=XXXXXXXX
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB |
@@ -1,16 +0,0 @@
|
||||
# WeatherBot
|
||||
|
||||
* This is a bot that sends weather information to a selected stream on
|
||||
request.
|
||||
|
||||
* Weather information is brought to the website using an
|
||||
OpenWeatherMap API. The bot posts the weather information to the
|
||||
stream from which the user inputs the message. If the user inputs a
|
||||
city that does not exist, the bot displays a "Sorry, city not found"
|
||||
message.
|
||||
|
||||
* Before using this bot, you have to generate an OpenWeatherMap API
|
||||
key and replace the dummy value in .weather_config.
|
||||
|
||||

|
||||

|
||||
@@ -1,75 +0,0 @@
|
||||
# See readme.md for instructions on running this code.
|
||||
from __future__ import print_function
|
||||
import requests
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from six.moves.configparser import SafeConfigParser
|
||||
|
||||
|
||||
class WeatherHandler(object):
|
||||
def __init__(self):
|
||||
self.directory = os.path.dirname(os.path.realpath(__file__)) + '/'
|
||||
self.config_name = '.weather_config'
|
||||
self.response_pattern = 'Weather in {}, {}:\n{} F / {} C\n{}'
|
||||
if not os.path.exists(self.directory + self.config_name):
|
||||
print('Weather bot config file not found, please set it up in {} file in this bot main directory'
|
||||
'\n\nUsing format:\n\n[weather-config]\nkey=<OpenWeatherMap API key here>\n\n'.format(self.config_name))
|
||||
sys.exit(1)
|
||||
super(WeatherHandler, self).__init__()
|
||||
|
||||
def usage(self):
|
||||
return '''
|
||||
This plugin will give info about weather in a specified city
|
||||
'''
|
||||
|
||||
def handle_message(self, message, client, state_handler):
|
||||
help_content = '''
|
||||
This bot returns weather info for specified city.
|
||||
You specify city in the following format:
|
||||
city, state/country
|
||||
state and country parameter is optional(useful when there are many cities with the same name)
|
||||
For example:
|
||||
@**Weather Bot** Portland
|
||||
@**Weather Bot** Portland, Me
|
||||
'''.strip()
|
||||
|
||||
if (message['content'] == 'help') or (message['content'] == ''):
|
||||
response = help_content
|
||||
else:
|
||||
url = 'http://api.openweathermap.org/data/2.5/weather?q=' + message['content'] + '&APPID='
|
||||
r = requests.get(url + get_weather_api_key_from_config(self.directory, self.config_name))
|
||||
if "city not found" in r.text:
|
||||
response = "Sorry, city not found"
|
||||
else:
|
||||
response = format_response(r.text, message['content'], self.response_pattern)
|
||||
|
||||
client.send_reply(message, response)
|
||||
|
||||
|
||||
def format_response(text, city, response_pattern):
|
||||
j = json.loads(text)
|
||||
city = j['name']
|
||||
country = j['sys']['country']
|
||||
fahrenheit = to_fahrenheit(j['main']['temp'])
|
||||
celsius = to_celsius(j['main']['temp'])
|
||||
description = j['weather'][0]['description'].title()
|
||||
|
||||
return response_pattern.format(city, country, fahrenheit, celsius, description)
|
||||
|
||||
|
||||
def to_celsius(temp_kelvin):
|
||||
return int(temp_kelvin) - 273.15
|
||||
|
||||
|
||||
def to_fahrenheit(temp_kelvin):
|
||||
return int(temp_kelvin) * 9 / 5 - 459.67
|
||||
|
||||
|
||||
def get_weather_api_key_from_config(directory, config_name):
|
||||
config = SafeConfigParser()
|
||||
with open(directory + config_name, 'r') as config_file:
|
||||
config.readfp(config_file)
|
||||
return config.get("weather-config", "key")
|
||||
|
||||
handler_class = WeatherHandler
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
from __future__ import absolute_import
|
||||
from __future__ import print_function
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
our_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.normpath(os.path.join(our_dir)))
|
||||
# For dev setups, we can find the API in the repo itself.
|
||||
if os.path.exists(os.path.join(our_dir, '..')):
|
||||
sys.path.insert(0, '..')
|
||||
from bots_test_lib import BotTestCase
|
||||
|
||||
class TestWikipediaBot(BotTestCase):
|
||||
bot_name = "wikipedia"
|
||||
|
||||
def test_bot(self):
|
||||
expected = {
|
||||
"": 'Please enter your message after @mention-bot',
|
||||
"sssssss kkkkk": ('I am sorry. The search term you provided '
|
||||
'is not found :slightly_frowning_face:'),
|
||||
"foo": ('For search term "foo", '
|
||||
'https://en.wikipedia.org/wiki/Foobar'),
|
||||
"123": ('For search term "123", '
|
||||
'https://en.wikipedia.org/wiki/123'),
|
||||
"laugh": ('For search term "laugh", '
|
||||
'https://en.wikipedia.org/wiki/Laughter'),
|
||||
}
|
||||
self.check_expected_responses(expected)
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
from __future__ import absolute_import
|
||||
from __future__ import print_function
|
||||
|
||||
import mock
|
||||
import os
|
||||
import sys
|
||||
|
||||
our_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.normpath(os.path.join(our_dir)))
|
||||
# For dev setups, we can find the API in the repo itself.
|
||||
if os.path.exists(os.path.join(our_dir, '..')):
|
||||
sys.path.insert(0, '..')
|
||||
from bots_test_lib import BotTestCase
|
||||
|
||||
class TestXkcdBot(BotTestCase):
|
||||
bot_name = "xkcd"
|
||||
|
||||
@mock.patch('logging.exception')
|
||||
def test_bot(self, mock_logging_exception):
|
||||
help_txt = "xkcd bot supports these commands:"
|
||||
err_txt = "xkcd bot only supports these commands:"
|
||||
commands = '''
|
||||
* `@xkcd help` to show this help message.
|
||||
* `@xkcd latest` to fetch the latest comic strip from xkcd.
|
||||
* `@xkcd random` to fetch a random comic strip from xkcd.
|
||||
* `@xkcd <comic id>` to fetch a comic strip based on `<comic id>` e.g `@xkcd 1234`.'''
|
||||
invalid_id_txt = "Sorry, there is likely no xkcd comic strip with id: #"
|
||||
expected = {
|
||||
"": err_txt+commands,
|
||||
"help": help_txt+commands,
|
||||
"x": err_txt+commands,
|
||||
"0": invalid_id_txt + "0",
|
||||
"1": ("#1: **Barrel - Part 1**\n[Don't we all.]"
|
||||
"(https://imgs.xkcd.com/comics/barrel_cropped_(1).jpg)"),
|
||||
"1800": ("#1800: **Chess Notation**\n"
|
||||
"[I've decided to score all my conversations "
|
||||
"using chess win-loss notation. (??)]"
|
||||
"(https://imgs.xkcd.com/comics/chess_notation.png)"),
|
||||
"999999999": invalid_id_txt + "999999999",
|
||||
}
|
||||
self.check_expected_responses(expected)
|
||||
@@ -1,136 +0,0 @@
|
||||
# See readme.md for instructions on running this code.
|
||||
|
||||
from __future__ import print_function
|
||||
import os
|
||||
import logging
|
||||
import ssl
|
||||
import sys
|
||||
try:
|
||||
import requests
|
||||
except ImportError as e:
|
||||
logging.error("Dependency missing!!\n{}".format(e))
|
||||
sys.exit(0)
|
||||
|
||||
HELP_MESSAGE = '''
|
||||
This bot allows users to translate a sentence into
|
||||
'Yoda speak'.
|
||||
Users should preface messages with '@mention-bot'.
|
||||
|
||||
Before running this, make sure to get a Mashape Api token.
|
||||
Instructions are in the 'readme.md' file.
|
||||
Store it in the 'yoda.config' file.
|
||||
The 'yoda.config' file should be located at '~/yoda.config'.
|
||||
Example input:
|
||||
@mention-bot You will learn how to speak like me someday.
|
||||
'''
|
||||
|
||||
|
||||
class ApiKeyError(Exception):
|
||||
'''raise this when there is an error with the Mashape Api Key'''
|
||||
|
||||
|
||||
class YodaSpeakHandler(object):
|
||||
'''
|
||||
This bot will allow users to translate a sentence into 'Yoda speak'.
|
||||
It looks for messages starting with '@mention-bot'.
|
||||
'''
|
||||
|
||||
def usage(self):
|
||||
return '''
|
||||
This bot will allow users to translate a sentence into
|
||||
'Yoda speak'.
|
||||
Users should preface messages with '@mention-bot'.
|
||||
|
||||
Before running this, make sure to get a Mashape Api token.
|
||||
Instructions are in the 'readme.md' file.
|
||||
Store it in the 'yoda.config' file.
|
||||
The 'yoda.config' file should be located at '~/yoda.config'.
|
||||
Example input:
|
||||
@mention-bot You will learn how to speak like me someday.
|
||||
'''
|
||||
|
||||
def handle_message(self, message, client, state_handler):
|
||||
handle_input(message, client)
|
||||
|
||||
handler_class = YodaSpeakHandler
|
||||
|
||||
|
||||
def send_to_yoda_api(sentence, api_key):
|
||||
# function for sending sentence to api
|
||||
|
||||
response = requests.get("https://yoda.p.mashape.com/yoda?sentence=" + sentence,
|
||||
headers={
|
||||
"X-Mashape-Key": api_key,
|
||||
"Accept": "text/plain"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.text
|
||||
if response.status_code == 403:
|
||||
raise ApiKeyError
|
||||
else:
|
||||
error_message = response.text['message']
|
||||
logging.error(error_message)
|
||||
error_code = response.status_code
|
||||
error_message = error_message + 'Error code: ' + error_code +\
|
||||
' Did you follow the instructions in the `readme.md` file?'
|
||||
return error_message
|
||||
|
||||
|
||||
def format_input(original_content):
|
||||
# gets rid of whitespace around the edges, so that they aren't a problem in the future
|
||||
message_content = original_content.strip()
|
||||
# replaces all spaces with '+' to be in the format the api requires
|
||||
sentence = message_content.replace(' ', '+')
|
||||
return sentence
|
||||
|
||||
|
||||
def handle_input(message, client):
|
||||
|
||||
original_content = message['content']
|
||||
if is_help(original_content):
|
||||
client.send_reply(message, HELP_MESSAGE)
|
||||
|
||||
else:
|
||||
sentence = format_input(original_content)
|
||||
try:
|
||||
reply_message = send_to_yoda_api(sentence, get_api_key())
|
||||
|
||||
except ssl.SSLError or TypeError:
|
||||
reply_message = 'The service is temporarily unavailable, please try again.'
|
||||
logging.error(reply_message)
|
||||
|
||||
except ApiKeyError:
|
||||
reply_message = 'Invalid Api Key. Did you follow the instructions in the ' \
|
||||
'`readme.md` file?'
|
||||
logging.error(reply_message)
|
||||
|
||||
client.send_reply(message, reply_message)
|
||||
|
||||
|
||||
def get_api_key():
|
||||
# function for getting Mashape api key
|
||||
home = os.path.expanduser('~')
|
||||
with open(home + '/yoda.config') as api_key_file:
|
||||
api_key = api_key_file.read().strip()
|
||||
return api_key
|
||||
|
||||
|
||||
def send_message(client, message, stream, subject):
|
||||
# function for sending a message
|
||||
client.send_message(dict(
|
||||
type='stream',
|
||||
to=stream,
|
||||
subject=subject,
|
||||
content=message
|
||||
))
|
||||
|
||||
|
||||
def is_help(original_content):
|
||||
# gets rid of whitespace around the edges, so that they aren't a problem in the future
|
||||
message_content = original_content.strip()
|
||||
if message_content == 'help':
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 81 KiB |
@@ -1,11 +0,0 @@
|
||||
# Youtube bot
|
||||
|
||||
Youtube bot is a Zulip bot that can fetch first video from youtube
|
||||
search results for a specified term. To use youtube bot you can simply
|
||||
call it with `@mention-bot` followed by a command. Like this:
|
||||
|
||||
```
|
||||
@mention-bot <search term>
|
||||
```
|
||||
|
||||

|
||||
@@ -1,31 +0,0 @@
|
||||
# See readme.md for instructions on running this bot.
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
class YoutubeHandler(object):
|
||||
def usage(self):
|
||||
return '''
|
||||
This bot will return the first Youtube search result for the give query.
|
||||
'''
|
||||
|
||||
def handle_message(self, message, client, state_handler):
|
||||
help_content = '''
|
||||
To use the, Youtube Bot send `@mention-bot search terms`
|
||||
Example:
|
||||
@mention-bot funny cats
|
||||
'''.strip()
|
||||
if message['content'] == '':
|
||||
client.send_reply(message, help_content)
|
||||
else:
|
||||
text_to_search = message['content']
|
||||
url = "https://www.youtube.com/results?search_query=" + text_to_search
|
||||
r = requests.get(url)
|
||||
soup = BeautifulSoup(r.text, 'lxml')
|
||||
video_id = soup.find(attrs={'class': 'yt-uix-tile-link'})
|
||||
try:
|
||||
link = 'https://www.youtube.com' + video_id['href']
|
||||
client.send_reply(message, link)
|
||||
except TypeError:
|
||||
client.send_reply(message, 'No video found for specified search terms')
|
||||
|
||||
handler_class = YoutubeHandler
|
||||
@@ -1,177 +0,0 @@
|
||||
from __future__ import print_function
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
import re
|
||||
|
||||
if False:
|
||||
from mypy_extensions import NoReturn
|
||||
from typing import Any, Optional, List, Dict
|
||||
from types import ModuleType
|
||||
|
||||
our_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# For dev setups, we can find the API in the repo itself.
|
||||
if os.path.exists(os.path.join(our_dir, '../zulip')):
|
||||
sys.path.insert(0, os.path.join(our_dir, '../'))
|
||||
|
||||
from zulip import Client
|
||||
|
||||
def exit_gracefully(signum, frame):
|
||||
# type: (int, Optional[Any]) -> None
|
||||
sys.exit(0)
|
||||
|
||||
class RateLimit(object):
|
||||
def __init__(self, message_limit, interval_limit):
|
||||
# type: (int, int) -> None
|
||||
self.message_limit = message_limit
|
||||
self.interval_limit = interval_limit
|
||||
self.message_list = [] # type: List[float]
|
||||
self.error_message = '-----> !*!*!*MESSAGE RATE LIMIT REACHED, EXITING*!*!*! <-----\n'
|
||||
'Is your bot trapped in an infinite loop by reacting to its own messages?'
|
||||
|
||||
def is_legal(self):
|
||||
# type: () -> bool
|
||||
self.message_list.append(time.time())
|
||||
if len(self.message_list) > self.message_limit:
|
||||
self.message_list.pop(0)
|
||||
time_diff = self.message_list[-1] - self.message_list[0]
|
||||
return time_diff >= self.interval_limit
|
||||
else:
|
||||
return True
|
||||
|
||||
def show_error_and_exit(self):
|
||||
# type: () -> NoReturn
|
||||
logging.error(self.error_message)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
class BotHandlerApi(object):
|
||||
def __init__(self, client):
|
||||
# type: (Client) -> None
|
||||
# Only expose a subset of our Client's functionality
|
||||
user_profile = client.get_profile()
|
||||
self._rate_limit = RateLimit(20, 5)
|
||||
self._client = client
|
||||
try:
|
||||
self.full_name = user_profile['full_name']
|
||||
self.email = user_profile['email']
|
||||
except KeyError:
|
||||
logging.error('Cannot fetch user profile, make sure you have set'
|
||||
' up the zuliprc file correctly.')
|
||||
sys.exit(1)
|
||||
|
||||
def send_message(self, message):
|
||||
# type: (Dict[str, Any]) -> Dict[str, Any]
|
||||
if self._rate_limit.is_legal():
|
||||
return self._client.send_message(message)
|
||||
else:
|
||||
self._rate_limit.show_error_and_exit()
|
||||
|
||||
def update_message(self, message):
|
||||
# type: (Dict[str, Any]) -> Dict[str, Any]
|
||||
if self._rate_limit.is_legal():
|
||||
return self._client.update_message(message)
|
||||
else:
|
||||
self._rate_limit.show_error_and_exit()
|
||||
|
||||
def send_reply(self, message, response):
|
||||
# type: (Dict[str, Any], str) -> Dict[str, Any]
|
||||
if message['type'] == 'private':
|
||||
return self.send_message(dict(
|
||||
type='private',
|
||||
to=[x['email'] for x in message['display_recipient'] if self.email != x['email']],
|
||||
content=response,
|
||||
))
|
||||
else:
|
||||
return self.send_message(dict(
|
||||
type='stream',
|
||||
to=message['display_recipient'],
|
||||
subject=message['subject'],
|
||||
content=response,
|
||||
))
|
||||
|
||||
class StateHandler(object):
|
||||
def __init__(self):
|
||||
# type: () -> None
|
||||
self.state = None # type: Any
|
||||
|
||||
def set_state(self, state):
|
||||
# type: (Any) -> None
|
||||
self.state = state
|
||||
|
||||
def get_state(self):
|
||||
# type: () -> Any
|
||||
return self.state
|
||||
|
||||
def run_message_handler_for_bot(lib_module, quiet, config_file):
|
||||
# type: (Any, bool, str) -> Any
|
||||
#
|
||||
# lib_module is of type Any, since it can contain any bot's
|
||||
# handler class. Eventually, we want bot's handler classes to
|
||||
# inherit from a common prototype specifying the handle_message
|
||||
# function.
|
||||
#
|
||||
# Make sure you set up your ~/.zuliprc
|
||||
client = Client(config_file=config_file)
|
||||
restricted_client = BotHandlerApi(client)
|
||||
|
||||
message_handler = lib_module.handler_class()
|
||||
|
||||
state_handler = StateHandler()
|
||||
|
||||
if not quiet:
|
||||
print(message_handler.usage())
|
||||
|
||||
def extract_query_without_mention(message, client):
|
||||
# type: (Dict[str, Any], BotHandlerApi) -> str
|
||||
"""
|
||||
If the bot is the first @mention in the message, then this function returns
|
||||
the message with the bot's @mention removed. Otherwise, it returns None.
|
||||
"""
|
||||
bot_mention = r'^@(\*\*{0}\*\*)'.format(client.full_name)
|
||||
start_with_mention = re.compile(bot_mention).match(message['content'])
|
||||
if start_with_mention is None:
|
||||
return None
|
||||
query_without_mention = message['content'][len(start_with_mention.group()):]
|
||||
return query_without_mention.lstrip()
|
||||
|
||||
def is_private(message, client):
|
||||
# type: (Dict[str, Any], BotHandlerApi) -> bool
|
||||
# bot will not reply if the sender name is the same as the bot name
|
||||
# to prevent infinite loop
|
||||
if message['type'] == 'private':
|
||||
return client.full_name != message['sender_full_name']
|
||||
return False
|
||||
|
||||
def handle_message(message):
|
||||
# type: (Dict[str, Any]) -> None
|
||||
logging.info('waiting for next message')
|
||||
|
||||
# is_mentioned is true if the bot is mentioned at ANY position (not necessarily
|
||||
# the first @mention in the message).
|
||||
is_mentioned = message['is_mentioned']
|
||||
is_private_message = is_private(message, restricted_client)
|
||||
|
||||
# Strip at-mention botname from the message
|
||||
if is_mentioned:
|
||||
# message['content'] will be None when the bot's @-mention is not at the beginning.
|
||||
# In that case, the message shall not be handled.
|
||||
message['content'] = extract_query_without_mention(message=message, client=restricted_client)
|
||||
if message['content'] is None:
|
||||
return
|
||||
|
||||
if is_private_message or is_mentioned:
|
||||
message_handler.handle_message(
|
||||
message=message,
|
||||
client=restricted_client,
|
||||
state_handler=state_handler
|
||||
)
|
||||
|
||||
signal.signal(signal.SIGINT, exit_gracefully)
|
||||
|
||||
logging.info('starting message handling...')
|
||||
client.call_on_each_message(handle_message)
|
||||
@@ -1,130 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
from __future__ import absolute_import
|
||||
from __future__ import print_function
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
import logging
|
||||
import requests
|
||||
import mock
|
||||
|
||||
from mock import MagicMock, patch
|
||||
|
||||
from run import get_lib_module
|
||||
from bot_lib import StateHandler
|
||||
from bots_api import bot_lib
|
||||
from six.moves import zip
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
from types import ModuleType
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
class BotTestCase(TestCase):
|
||||
bot_name = '' # type: str
|
||||
|
||||
def check_expected_responses(self, expectations, expected_method='send_reply',
|
||||
email="foo_sender@zulip.com", recipient="foo", subject="foo",
|
||||
type="all", http_request=None, http_response=None):
|
||||
# type: (Dict[str, Any], str, str, str, str, str, Dict[str, Any], Dict[str, Any]) -> None
|
||||
# To test send_message, Any would be a Dict type,
|
||||
# to test send_reply, Any would be a str type.
|
||||
if type not in ["private", "stream", "all"]:
|
||||
logging.exception("check_expected_response expects type to be 'private', 'stream' or 'all'")
|
||||
for m, r in expectations.items():
|
||||
if type != "stream":
|
||||
self.mock_test(
|
||||
messages={'content': m, 'type': "private", 'display_recipient': recipient,
|
||||
'sender_email': email}, bot_response=r, expected_method=expected_method,
|
||||
http_request=http_request, http_response=http_response)
|
||||
if type != "private":
|
||||
self.mock_test(
|
||||
messages={'content': m, 'type': "stream", 'display_recipient': recipient,
|
||||
'subject': subject, 'sender_email': email}, bot_response=r,
|
||||
expected_method=expected_method, http_request=http_request, http_response=http_response)
|
||||
|
||||
def mock_test(self, messages, bot_response, expected_method,
|
||||
http_request=None, http_response=None):
|
||||
# type: (Dict[str, str], Any, str, Dict[str, Any], Dict[str, Any]) -> None
|
||||
if expected_method == "send_message":
|
||||
# Since send_message function uses bot_response of type Dict, no
|
||||
# further changes required.
|
||||
self.assert_bot_output(messages=[messages], bot_response=[bot_response], expected_method=expected_method,
|
||||
http_request=http_request, http_response=http_response)
|
||||
else:
|
||||
# Since send_reply function uses bot_response of type str, we
|
||||
# do convert the str type to a Dict type to have the same assert_bot_output function.
|
||||
bot_response_type_dict = {'content': bot_response}
|
||||
self.assert_bot_output(messages=[messages], bot_response=[bot_response_type_dict], expected_method=expected_method,
|
||||
http_request=http_request, http_response=http_response)
|
||||
|
||||
def get_bot_message_handler(self):
|
||||
# type: () -> Any
|
||||
# message_handler is of type 'Any', since it can contain any bot's
|
||||
# handler class. Eventually, we want bot's handler classes to
|
||||
# inherit from a common prototype specifying the handle_message
|
||||
# function.
|
||||
bot_module = os.path.join(current_dir, "bots",
|
||||
self.bot_name, self.bot_name + ".py")
|
||||
message_handler = self.bot_to_run(bot_module)
|
||||
return message_handler
|
||||
|
||||
def call_request(self, message_handler, message, expected_method,
|
||||
MockClass, response):
|
||||
# type: (Any, Dict[str, Any], str, Any, Optional[Dict[str, Any]]) -> None
|
||||
# Send message to the concerned bot
|
||||
message_handler.handle_message(message, MockClass(), StateHandler())
|
||||
|
||||
# Check if the bot is sending a message via `send_message` function.
|
||||
# Where response is a dictionary here.
|
||||
instance = MockClass.return_value
|
||||
if expected_method == "send_message":
|
||||
instance.send_message.assert_called_with(response)
|
||||
else:
|
||||
instance.send_reply.assert_called_with(message, response['content'])
|
||||
|
||||
def assert_bot_output(self, messages, bot_response, expected_method,
|
||||
http_request=None, http_response=None):
|
||||
# type: (List[Dict[str, Any]], List[Dict[str, str]], str, Optional[Dict[str, Any]], Optional[Dict[str, Any]]) -> None
|
||||
message_handler = self.get_bot_message_handler()
|
||||
# Mocking BotHandlerApi
|
||||
with patch('bots_api.bot_lib.BotHandlerApi') as MockClass:
|
||||
for (message, response) in zip(messages, bot_response):
|
||||
# If not mock http_request/http_response are provided,
|
||||
# just call the request normally (potentially using
|
||||
# the Internet)
|
||||
if http_response is None:
|
||||
assert http_request is None
|
||||
self.call_request(message_handler, message, expected_method,
|
||||
MockClass, response)
|
||||
continue
|
||||
|
||||
# Otherwise, we mock requests, and verify that the bot
|
||||
# made the correct HTTP request to the third-party API
|
||||
# (and provide the correct third-party API response.
|
||||
# This allows us to test things that would require the
|
||||
# Internet without it).
|
||||
assert http_request is not None
|
||||
with patch('requests.get') as mock_get:
|
||||
mock_result = mock.MagicMock()
|
||||
mock_result.json.return_value = http_response
|
||||
mock_result.ok.return_value = True
|
||||
mock_get.return_value = mock_result
|
||||
self.call_request(message_handler, message, expected_method,
|
||||
MockClass, response)
|
||||
# Check if the bot is sending the correct http_request corresponding
|
||||
# to the given http_response.
|
||||
if http_request is not None:
|
||||
mock_get.assert_called_with(http_request['api_url'],
|
||||
params=http_request['params'])
|
||||
|
||||
def bot_to_run(self, bot_module):
|
||||
# Returning Any, same argument as in get_bot_message_handler function.
|
||||
# type: (str) -> Any
|
||||
lib_module = get_lib_module(bot_module)
|
||||
message_handler = lib_module.handler_class()
|
||||
return message_handler
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
from __future__ import absolute_import
|
||||
from __future__ import print_function
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest import TestCase
|
||||
|
||||
def dir_join(dir1, dir2):
|
||||
# type: (str, str) -> str
|
||||
return os.path.abspath(os.path.join(dir1, dir2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
description = 'Script to run test_<bot>.py files in bots/<bot> directories'
|
||||
parser = argparse.ArgumentParser(description=description)
|
||||
parser.add_argument('--bot',
|
||||
nargs=1,
|
||||
type=str,
|
||||
action='store',
|
||||
help='test specified single bot')
|
||||
args = parser.parse_args()
|
||||
|
||||
bots_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
root_dir = dir_join(bots_dir, '..')
|
||||
bots_test_dir = dir_join(bots_dir, '../bots')
|
||||
|
||||
sys.path.insert(0, root_dir)
|
||||
sys.path.insert(0, bots_test_dir)
|
||||
|
||||
# mypy doesn't recognize the TestLoader attribute, even though the code
|
||||
# is executable
|
||||
loader = unittest.TestLoader() # type: ignore
|
||||
if args.bot is not None:
|
||||
bots_test_dir = dir_join(bots_test_dir, args.bot[0])
|
||||
suite = loader.discover(start_dir=bots_test_dir, top_level_dir=root_dir)
|
||||
runner = unittest.TextTestRunner(verbosity=2)
|
||||
# same issue as for TestLoader
|
||||
result = runner.run(suite) # type: ignore
|
||||
if result.errors or result.failures:
|
||||
raise Exception('Test failed!')
|
||||
@@ -32,7 +32,7 @@ from typing import IO
|
||||
import zulip
|
||||
|
||||
class StringIO(_StringIO):
|
||||
name = '' # https://github.com/python/typeshed/issues/598
|
||||
name = '' # https://github.com/python/typeshed/issues/598
|
||||
|
||||
usage = """upload-file --user=<user's email address> --api-key=<user's api key> [options]
|
||||
|
||||
@@ -51,7 +51,7 @@ parser.add_option_group(zulip.generate_option_group(parser))
|
||||
|
||||
client = zulip.init_from_options(options)
|
||||
|
||||
file = None # type: IO
|
||||
file = None # type: IO
|
||||
if options.file_path:
|
||||
file = open(options.file_path, 'rb')
|
||||
else:
|
||||
|
||||
@@ -23,12 +23,34 @@
|
||||
|
||||
### REQUIRED CONFIGURATION ###
|
||||
|
||||
# Change these values to your Slack credentials.
|
||||
SLACK_TOKEN = 'slack_token'
|
||||
# Change these values to your Asana credentials.
|
||||
ASANA_API_KEY = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
# Change these values to the credentials for your Slack bot.
|
||||
ZULIP_USER = 'user-email@zulip.com'
|
||||
ZULIP_API_KEY = 'user-email_api_key'
|
||||
# Change these values to the credentials for your Asana bot.
|
||||
ZULIP_USER = "asana-bot@example.com"
|
||||
ZULIP_API_KEY = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
# The Zulip stream that will receive Asana task updates.
|
||||
ZULIP_STREAM_NAME = "asana"
|
||||
|
||||
|
||||
### OPTIONAL CONFIGURATION ###
|
||||
|
||||
# Set to None for logging to stdout when testing, and to a file for
|
||||
# logging in production.
|
||||
#LOG_FILE = "/var/tmp/zulip_asana.log"
|
||||
LOG_FILE = None
|
||||
|
||||
# This file is used to resume this mirror in case the script shuts down.
|
||||
# It is required and needs to be writeable.
|
||||
RESUME_FILE = "/var/tmp/zulip_asana.state"
|
||||
|
||||
# When initially started, how many hours of messages to include.
|
||||
ASANA_INITIAL_HISTORY_HOURS = 1
|
||||
|
||||
# Set this to your Zulip API server URI
|
||||
ZULIP_SITE = 'https://zulip.example.com'
|
||||
ZULIP_SITE = "https://zulip.example.com"
|
||||
|
||||
# If properly installed, the Zulip API should be in your import
|
||||
# path, but if not, set a custom path below
|
||||
ZULIP_API_PATH = None
|
||||
306
api/integrations/asana/zulip_asana_mirror
Executable file
306
api/integrations/asana/zulip_asana_mirror
Executable file
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Asana integration for Zulip
|
||||
#
|
||||
# Copyright © 2014 Zulip, Inc.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
#
|
||||
# The "zulip_asana_mirror" script is run continuously, possibly on a work computer
|
||||
# or preferably on a server.
|
||||
#
|
||||
# When restarted, it will attempt to pick up where it left off.
|
||||
#
|
||||
# python-dateutil is a dependency for this script.
|
||||
|
||||
from __future__ import print_function
|
||||
import base64
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Optional, Any, Tuple
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from six.moves import urllib
|
||||
from six.moves.urllib import request as urllib_request
|
||||
import sys
|
||||
|
||||
|
||||
try:
|
||||
import dateutil.parser
|
||||
from dateutil.tz import gettz
|
||||
except ImportError as e:
|
||||
print(e, file=sys.stderr)
|
||||
print("Please install the python-dateutil package.", file=sys.stderr)
|
||||
exit(1)
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
import zulip_asana_config as config
|
||||
VERSION = "0.9"
|
||||
|
||||
if config.ZULIP_API_PATH is not None:
|
||||
sys.path.append(config.ZULIP_API_PATH)
|
||||
import zulip
|
||||
|
||||
if config.LOG_FILE:
|
||||
logging.basicConfig(filename=config.LOG_FILE, level=logging.WARNING)
|
||||
else:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
client = zulip.Client(email=config.ZULIP_USER, api_key=config.ZULIP_API_KEY,
|
||||
site=config.ZULIP_SITE, client="ZulipAsana/" + VERSION)
|
||||
|
||||
def fetch_from_asana(path):
|
||||
# type: (str) -> Optional[Dict[str, Any]]
|
||||
"""
|
||||
Request a resource through the Asana API, authenticating using
|
||||
HTTP basic auth.
|
||||
"""
|
||||
auth = base64.encodestring(b'%s:' % (config.ASANA_API_KEY,))
|
||||
headers = {"Authorization": "Basic %s" % auth}
|
||||
|
||||
url = "https://app.asana.com/api/1.0" + path
|
||||
request = urllib_request.Request(url, None, headers) # type: ignore
|
||||
result = urllib_request.urlopen(request) # type: ignore
|
||||
|
||||
return json.load(result)
|
||||
|
||||
def send_zulip(topic, content):
|
||||
# type: (str, str) -> Dict[str, str]
|
||||
"""
|
||||
Send a message to Zulip using the configured stream and bot credentials.
|
||||
"""
|
||||
message = {"type": "stream",
|
||||
"sender": config.ZULIP_USER,
|
||||
"to": config.ZULIP_STREAM_NAME,
|
||||
"subject": topic,
|
||||
"content": content,
|
||||
}
|
||||
return client.send_message(message)
|
||||
|
||||
def datestring_to_datetime(datestring):
|
||||
# type: (str) -> datetime
|
||||
"""
|
||||
Given an ISO 8601 datestring, return the corresponding datetime object.
|
||||
"""
|
||||
return dateutil.parser.parse(datestring).replace(
|
||||
tzinfo=gettz('Z'))
|
||||
|
||||
class TaskDict(dict):
|
||||
"""
|
||||
A helper class to turn a dictionary with task information into an
|
||||
object where each of the keys is an attribute for easy access.
|
||||
"""
|
||||
def __getattr__(self, field):
|
||||
# type: (TaskDict, str) -> Any
|
||||
return self.get(field)
|
||||
|
||||
def format_topic(task, projects):
|
||||
# type: (TaskDict, Dict[str, str]) -> str
|
||||
"""
|
||||
Return a string that will be the Zulip message topic for this task.
|
||||
"""
|
||||
# Tasks can be associated with multiple projects, but in practice they seem
|
||||
# to mostly be associated with one.
|
||||
project_name = projects[task.projects[0]["id"]]
|
||||
return "%s: %s" % (project_name, task.name)
|
||||
|
||||
def format_assignee(task, users):
|
||||
# type: (TaskDict, Dict[str, str]) -> str
|
||||
"""
|
||||
Return a string describing the task's assignee.
|
||||
"""
|
||||
if task.assignee:
|
||||
assignee_name = users[task.assignee["id"]]
|
||||
assignee_info = "**Assigned to**: %s (%s)" % (
|
||||
assignee_name, task.assignee_status)
|
||||
else:
|
||||
assignee_info = "**Status**: Unassigned"
|
||||
|
||||
return assignee_info
|
||||
|
||||
def format_due_date(task):
|
||||
# type: (TaskDict) -> str
|
||||
"""
|
||||
Return a string describing the task's due date.
|
||||
"""
|
||||
if task.due_on:
|
||||
due_date_info = "**Due on**: %s" % (task.due_on,)
|
||||
else:
|
||||
due_date_info = "**Due date**: None"
|
||||
return due_date_info
|
||||
|
||||
def format_task_creation_event(task, projects, users):
|
||||
# type: (TaskDict, Dict[str, str], Dict[str, str]) -> Tuple[str, str]
|
||||
"""
|
||||
Format the topic and content for a newly-created task.
|
||||
"""
|
||||
topic = format_topic(task, projects)
|
||||
assignee_info = format_assignee(task, users)
|
||||
due_date_info = format_due_date(task)
|
||||
|
||||
content = """Task **%s** created:
|
||||
|
||||
~~~ quote
|
||||
%s
|
||||
~~~
|
||||
|
||||
%s
|
||||
%s
|
||||
""" % (task.name, task.notes, assignee_info, due_date_info)
|
||||
return topic, content
|
||||
|
||||
def format_task_completion_event(task, projects, users):
|
||||
# type: (TaskDict, Dict[str, str], Dict[str, str]) -> Tuple[str, str]
|
||||
"""
|
||||
Format the topic and content for a completed task.
|
||||
"""
|
||||
topic = format_topic(task, projects)
|
||||
assignee_info = format_assignee(task, users)
|
||||
due_date_info = format_due_date(task)
|
||||
|
||||
content = """Task **%s** completed. :white_check_mark:
|
||||
|
||||
%s
|
||||
%s
|
||||
""" % (task.name, assignee_info, due_date_info)
|
||||
return topic, content
|
||||
|
||||
def since():
|
||||
# type: () -> datetime
|
||||
"""
|
||||
Return a newness threshold for task events to be processed.
|
||||
"""
|
||||
# If we have a record of the last event processed and it is recent, use it,
|
||||
# else process everything from ASANA_INITIAL_HISTORY_HOURS ago.
|
||||
def default_since():
|
||||
# type: () -> datetime
|
||||
return datetime.utcnow() - timedelta(
|
||||
hours=config.ASANA_INITIAL_HISTORY_HOURS)
|
||||
|
||||
if os.path.exists(config.RESUME_FILE):
|
||||
try:
|
||||
with open(config.RESUME_FILE, "r") as f:
|
||||
datestring = f.readline().strip()
|
||||
timestamp = float(datestring)
|
||||
max_timestamp_processed = datetime.fromtimestamp(timestamp)
|
||||
logging.info("Reading from resume file: " + datestring)
|
||||
except (ValueError, IOError) as e:
|
||||
logging.warn("Could not open resume file: " + str(e))
|
||||
max_timestamp_processed = default_since()
|
||||
else:
|
||||
logging.info("No resume file, processing an initial history.")
|
||||
max_timestamp_processed = default_since()
|
||||
|
||||
# Even if we can read a timestamp from RESUME_FILE, if it is old don't use
|
||||
# it.
|
||||
return max(max_timestamp_processed, default_since())
|
||||
|
||||
def process_new_events():
|
||||
# type: () -> None
|
||||
"""
|
||||
Forward new Asana task events to Zulip.
|
||||
"""
|
||||
# In task queries, Asana only exposes IDs for projects and users, so we need
|
||||
# to look up the mappings.
|
||||
projects = dict((elt["id"], elt["name"]) for elt in
|
||||
fetch_from_asana("/projects")["data"])
|
||||
users = dict((elt["id"], elt["name"]) for elt in
|
||||
fetch_from_asana("/users")["data"])
|
||||
|
||||
cutoff = since()
|
||||
max_timestamp_processed = cutoff
|
||||
time_operations = (("created_at", format_task_creation_event),
|
||||
("completed_at", format_task_completion_event))
|
||||
task_fields = ["assignee", "assignee_status", "created_at", "completed_at",
|
||||
"modified_at", "due_on", "name", "notes", "projects"]
|
||||
|
||||
# First, gather all of the tasks that need processing. We'll
|
||||
# process them in order.
|
||||
new_events = []
|
||||
|
||||
for project_id in projects:
|
||||
project_url = "/projects/%d/tasks?opt_fields=%s" % (
|
||||
project_id, ",".join(task_fields))
|
||||
tasks = fetch_from_asana(project_url)["data"]
|
||||
|
||||
for task in tasks:
|
||||
task = TaskDict(task)
|
||||
|
||||
for time_field, operation in time_operations:
|
||||
if task[time_field]:
|
||||
operation_time = datestring_to_datetime(task[time_field])
|
||||
if operation_time > cutoff:
|
||||
new_events.append((operation_time, time_field, operation, task))
|
||||
|
||||
new_events.sort()
|
||||
now = datetime.utcnow()
|
||||
|
||||
for operation_time, time_field, operation, task in new_events:
|
||||
# Unfortunately, creating an Asana task is not an atomic operation. If
|
||||
# the task was just created, or is missing basic information, it is
|
||||
# probably because the task is still being filled out -- wait until the
|
||||
# next round to process it.
|
||||
if (time_field == "created_at") and \
|
||||
(now - operation_time < timedelta(seconds=30)):
|
||||
# The task was just created, give the user some time to fill out
|
||||
# more information.
|
||||
return
|
||||
|
||||
if (time_field == "created_at") and (not task.name) and \
|
||||
(now - operation_time < timedelta(seconds=60)):
|
||||
# If this new task hasn't had a name for a full 30 seconds, assume
|
||||
# you don't plan on giving it one.
|
||||
return
|
||||
|
||||
topic, content = operation(task, projects, users)
|
||||
logging.info("Sending Zulip for " + topic)
|
||||
result = send_zulip(topic, content)
|
||||
|
||||
# If the Zulip wasn't sent successfully, don't update the
|
||||
# max timestamp processed so the task has another change to
|
||||
# be forwarded. Exit, giving temporary issues time to
|
||||
# resolve.
|
||||
if not result.get("result"):
|
||||
logging.warn("Malformed result, exiting:")
|
||||
logging.warn(str(result))
|
||||
return
|
||||
|
||||
if result["result"] != "success":
|
||||
logging.warn(result["msg"])
|
||||
return
|
||||
|
||||
if operation_time > max_timestamp_processed:
|
||||
max_timestamp_processed = operation_time
|
||||
|
||||
if max_timestamp_processed > cutoff:
|
||||
max_datestring = max_timestamp_processed.strftime("%s.%f")
|
||||
logging.info("Updating resume file: " + max_datestring)
|
||||
open(config.RESUME_FILE, 'w').write(max_datestring)
|
||||
|
||||
while True:
|
||||
try:
|
||||
process_new_events()
|
||||
time.sleep(5)
|
||||
except KeyboardInterrupt:
|
||||
logging.info("Shutting down...")
|
||||
logging.info("Set LOG_FILE to log to a file instead of stdout.")
|
||||
break
|
||||
43
api/examples/get-presence → api/integrations/basecamp/zulip_basecamp_config.py
Executable file → Normal file
43
api/examples/get-presence → api/integrations/basecamp/zulip_basecamp_config.py
Executable file → Normal file
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright © 2012-2014 Zulip, Inc.
|
||||
#
|
||||
# Copyright © 2014 Zulip, Inc.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
@@ -21,24 +20,32 @@
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
|
||||
from __future__ import print_function
|
||||
import sys
|
||||
from os import path
|
||||
import optparse
|
||||
|
||||
usage = """get-presence --email=<email address> [options]
|
||||
# Change these values to configure authentication for basecamp account
|
||||
BASECAMP_ACCOUNT_ID = "12345678"
|
||||
BASECAMP_USERNAME = "foo@example.com"
|
||||
BASECAMP_PASSWORD = "p455w0rd"
|
||||
|
||||
Get presence data for another user.
|
||||
"""
|
||||
# This script will mirror this many hours of history on the first run.
|
||||
# On subsequent runs this value is ignored.
|
||||
BASECAMP_INITIAL_HISTORY_HOURS = 0
|
||||
|
||||
sys.path.append(path.join(path.dirname(__file__), '..'))
|
||||
import zulip
|
||||
# Change these values to configure Zulip authentication for the plugin
|
||||
ZULIP_USER = "basecamp-bot@example.com"
|
||||
ZULIP_API_KEY = "0123456789abcdef0123456789abcdef"
|
||||
ZULIP_STREAM_NAME = "basecamp"
|
||||
|
||||
parser = optparse.OptionParser(usage=usage)
|
||||
parser.add_option_group(zulip.generate_option_group(parser))
|
||||
parser.add_option('--email')
|
||||
(options, args) = parser.parse_args()
|
||||
## If properly installed, the Zulip API should be in your import
|
||||
## path, but if not, set a custom path below
|
||||
ZULIP_API_PATH = None
|
||||
|
||||
client = zulip.init_from_options(options)
|
||||
# Set this to your Zulip API server URI
|
||||
ZULIP_SITE = "https://zulip.example.com"
|
||||
|
||||
print(client.get_presence(options.email))
|
||||
# If you wish to log to a file rather than stdout/stderr,
|
||||
# please fill this out your desired path
|
||||
LOG_FILE = None
|
||||
|
||||
# This file is used to resume this mirror in case the script shuts down.
|
||||
# It is required and needs to be writeable.
|
||||
RESUME_FILE = "/var/tmp/zulip_basecamp.state"
|
||||
186
api/integrations/basecamp/zulip_basecamp_mirror
Executable file
186
api/integrations/basecamp/zulip_basecamp_mirror
Executable file
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Zulip mirror of Basecamp activity
|
||||
# Copyright © 2014 Zulip, Inc.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
#
|
||||
# The "basecamp-mirror.py" script is run continuously, possibly on a work computer
|
||||
# or preferably on a server.
|
||||
# You may need to install the python-requests library.
|
||||
|
||||
from __future__ import absolute_import
|
||||
import requests
|
||||
import logging
|
||||
import time
|
||||
import re
|
||||
import sys
|
||||
from stderror import write
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
import zulip_basecamp_config as config
|
||||
VERSION = "0.9"
|
||||
|
||||
if config.ZULIP_API_PATH is not None:
|
||||
sys.path.append(config.ZULIP_API_PATH)
|
||||
import zulip
|
||||
from six.moves.html_parser import HTMLParser
|
||||
from typing import Any, Dict
|
||||
import six
|
||||
|
||||
client = zulip.Client(
|
||||
email=config.ZULIP_USER,
|
||||
site=config.ZULIP_SITE,
|
||||
api_key=config.ZULIP_API_KEY,
|
||||
client="ZulipBasecamp/" + VERSION)
|
||||
user_agent = "Basecamp To Zulip Mirroring script (zulip-devel@googlegroups.com)"
|
||||
htmlParser = HTMLParser()
|
||||
|
||||
# find some form of JSON loader/dumper, with a preference order for speed.
|
||||
json_implementations = ['ujson', 'cjson', 'simplejson', 'json']
|
||||
|
||||
while len(json_implementations):
|
||||
try:
|
||||
json = __import__(json_implementations.pop(0))
|
||||
break
|
||||
except ImportError:
|
||||
continue
|
||||
|
||||
# void function that checks the permissions of the files this script needs.
|
||||
def check_permissions():
|
||||
# type: () -> None
|
||||
# check that the log file can be written
|
||||
if config.LOG_FILE:
|
||||
try:
|
||||
open(config.LOG_FILE, "w")
|
||||
except IOError as e:
|
||||
sys.stderr.write("Could not open up log for writing:")
|
||||
sys.stderr.write(str(e))
|
||||
# check that the resume file can be written (this creates if it doesn't exist)
|
||||
try:
|
||||
open(config.RESUME_FILE, "a+")
|
||||
except IOError as e:
|
||||
sys.stderr.write("Could not open up the file %s for reading and writing" % (config.RESUME_FILE),)
|
||||
sys.stderr.write(str(e))
|
||||
|
||||
# builds the message dict for sending a message with the Zulip API
|
||||
def build_message(event):
|
||||
# type: (Dict[str, Any]) -> Dict[str, Any]
|
||||
if not ('bucket' in event and 'creator' in event and 'html_url' in event):
|
||||
logging.error("Perhaps the Basecamp API changed behavior? "
|
||||
"This event doesn't have the expected format:\n%s" % (event,))
|
||||
return None
|
||||
# adjust the topic length to be bounded to 60 characters
|
||||
topic = event['bucket']['name']
|
||||
if len(topic) > 60:
|
||||
topic = topic[0:57] + "..."
|
||||
# get the action and target values
|
||||
action = htmlParser.unescape(re.sub(r"<[^<>]+>", "", event.get('action', '')))
|
||||
target = htmlParser.unescape(event.get('target', ''))
|
||||
# Some events have "excerpts", which we blockquote
|
||||
excerpt = htmlParser.unescape(event.get('excerpt', ''))
|
||||
if excerpt.strip() == "":
|
||||
message = '**%s** %s [%s](%s).' % (event['creator']['name'], action, target, event['html_url'])
|
||||
else:
|
||||
message = '**%s** %s [%s](%s).\n> %s' % (event['creator']['name'], action, target, event['html_url'], excerpt)
|
||||
# assemble the message data dict
|
||||
message_data = {
|
||||
"type": "stream",
|
||||
"to": config.ZULIP_STREAM_NAME,
|
||||
"subject": topic,
|
||||
"content": message,
|
||||
}
|
||||
return message_data
|
||||
|
||||
# the main run loop for this mirror script
|
||||
def run_mirror():
|
||||
# type: () -> None
|
||||
# we should have the right (write) permissions on the resume file, as seen
|
||||
# in check_permissions, but it may still be empty or corrupted
|
||||
try:
|
||||
with open(config.RESUME_FILE) as f:
|
||||
since = f.read() # type: Any
|
||||
since = re.search(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}-\d{2}:\d{2}", since)
|
||||
assert since, "resume file does not meet expected format"
|
||||
since = since.string
|
||||
except (AssertionError, IOError) as e:
|
||||
logging.warn("Could not open resume file: %s" % (e,))
|
||||
since = (datetime.utcnow() - timedelta(hours=config.BASECAMP_INITIAL_HISTORY_HOURS)).isoformat() + "-00:00"
|
||||
try:
|
||||
# we use an exponential backoff approach when we get 429 (Too Many Requests).
|
||||
sleepInterval = 1
|
||||
while True:
|
||||
time.sleep(sleepInterval)
|
||||
response = requests.get("https://basecamp.com/%s/api/v1/events.json" % (config.BASECAMP_ACCOUNT_ID),
|
||||
params={'since': since},
|
||||
auth=(config.BASECAMP_USERNAME, config.BASECAMP_PASSWORD),
|
||||
headers = {"User-Agent": user_agent})
|
||||
if response.status_code == 200:
|
||||
sleepInterval = 1
|
||||
events = json.loads(response.text)
|
||||
if len(events):
|
||||
logging.info("Got event(s): %s" % (response.text,))
|
||||
if response.status_code >= 500:
|
||||
logging.error(str(response.status_code))
|
||||
continue
|
||||
if response.status_code == 429:
|
||||
# exponential backoff
|
||||
sleepInterval *= 2
|
||||
logging.error(str(response.status_code))
|
||||
continue
|
||||
if response.status_code == 400:
|
||||
logging.error("Something went wrong. Basecamp must be unhappy for this reason: %s" % (response.text,))
|
||||
sys.exit(-1)
|
||||
if response.status_code == 401:
|
||||
logging.error("Bad authorization from Basecamp. Please check your Basecamp login credentials")
|
||||
sys.exit(-1)
|
||||
if len(events):
|
||||
since = events[0]['created_at']
|
||||
for event in reversed(events):
|
||||
message_data = build_message(event)
|
||||
if not message_data:
|
||||
continue
|
||||
zulip_api_result = client.send_message(message_data)
|
||||
if zulip_api_result['result'] == "success":
|
||||
logging.info("sent zulip with id: %s" % (zulip_api_result['id'],))
|
||||
else:
|
||||
logging.warn("%s %s" % (zulip_api_result['result'], zulip_api_result['msg']))
|
||||
# update 'since' each time in case we get KeyboardInterrupted
|
||||
since = event['created_at']
|
||||
# avoid hitting rate-limit
|
||||
time.sleep(0.2)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logging.info("Shutting down, please hold")
|
||||
open("events.last", 'w').write(since)
|
||||
logging.info("Done!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not isinstance(config.RESUME_FILE, six.string_types):
|
||||
sys.stderr.write("RESUME_FILE path not given; refusing to continue")
|
||||
check_permissions()
|
||||
if config.LOG_FILE:
|
||||
logging.basicConfig(filename=config.LOG_FILE, level=logging.INFO)
|
||||
else:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
run_mirror()
|
||||
@@ -33,7 +33,6 @@ from __future__ import print_function
|
||||
from __future__ import absolute_import
|
||||
import requests
|
||||
import logging
|
||||
import pytz
|
||||
import time
|
||||
import sys
|
||||
import os
|
||||
@@ -78,7 +77,7 @@ def make_api_call(path):
|
||||
# type: (str) -> Optional[List[Dict[str, Any]]]
|
||||
response = requests.get("https://api3.codebasehq.com/%s" % (path,),
|
||||
auth=(config.CODEBASE_API_USERNAME, config.CODEBASE_API_KEY),
|
||||
params={'raw': 'True'},
|
||||
params={'raw': True},
|
||||
headers = {"User-Agent": user_agent,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"})
|
||||
@@ -270,7 +269,7 @@ def run_mirror():
|
||||
# in check_permissions, but it may still be empty or corrupted
|
||||
def default_since():
|
||||
# type: () -> datetime
|
||||
return datetime.now(tz=pytz.utc) - timedelta(hours=config.CODEBASE_INITIAL_HISTORY_HOURS)
|
||||
return datetime.utcnow() - timedelta(hours=config.CODEBASE_INITIAL_HISTORY_HOURS)
|
||||
|
||||
try:
|
||||
with open(config.RESUME_FILE) as f:
|
||||
@@ -278,7 +277,7 @@ def run_mirror():
|
||||
if timestamp == '':
|
||||
since = default_since()
|
||||
else:
|
||||
since = datetime.fromtimestamp(float(timestamp), tz=pytz.utc)
|
||||
since = datetime.fromtimestamp(float(timestamp))
|
||||
except (ValueError, IOError) as e:
|
||||
logging.warn("Could not open resume file: %s" % (str(e)))
|
||||
since = default_since()
|
||||
@@ -291,7 +290,7 @@ def run_mirror():
|
||||
sleepInterval = 1
|
||||
for event in events:
|
||||
timestamp = event.get('event', {}).get('timestamp', '')
|
||||
event_date = dateutil.parser.parse(timestamp)
|
||||
event_date = dateutil.parser.parse(timestamp).replace(tzinfo=None)
|
||||
if event_date > since:
|
||||
handle_event(event)
|
||||
since = event_date
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# This script depends on python-dateutil and python-pytz for properly handling
|
||||
# times and time zones of calendar events.
|
||||
from __future__ import print_function
|
||||
import datetime
|
||||
import dateutil.parser
|
||||
import httplib2
|
||||
import itertools
|
||||
import logging
|
||||
import optparse
|
||||
import os
|
||||
import pytz
|
||||
from six.moves import urllib
|
||||
import sys
|
||||
import time
|
||||
@@ -107,45 +102,29 @@ def get_credentials():
|
||||
logging.error("Run the get-google-credentials script from this directory first.")
|
||||
|
||||
|
||||
def populate_events():
|
||||
# type: () -> Optional[None]
|
||||
global events
|
||||
|
||||
def get_events():
|
||||
# type: () -> Iterable[Tuple[int, datetime.datetime, str]]
|
||||
credentials = get_credentials()
|
||||
creds = credentials.authorize(httplib2.Http())
|
||||
service = discovery.build('calendar', 'v3', http=creds)
|
||||
|
||||
now = datetime.datetime.now(pytz.utc).isoformat()
|
||||
now = datetime.datetime.utcnow().isoformat() + 'Z' # 'Z' indicates UTC time
|
||||
feed = service.events().list(calendarId=options.calendarID, timeMin=now, maxResults=5,
|
||||
singleEvents=True, orderBy='startTime').execute()
|
||||
|
||||
events = []
|
||||
for event in feed["items"]:
|
||||
try:
|
||||
start = dateutil.parser.parse(event["start"]["dateTime"])
|
||||
# According to the API documentation, a time zone offset is required
|
||||
# for start.dateTime unless a time zone is explicitly specified in
|
||||
# start.timeZone.
|
||||
if start.tzinfo is None:
|
||||
event_timezone = pytz.timezone(event["start"]["timeZone"])
|
||||
# pytz timezones include an extra localize method that's not part
|
||||
# of the tzinfo base class.
|
||||
start = event_timezone.localize(start) # type: ignore
|
||||
start = event["start"]["dateTime"]
|
||||
except KeyError:
|
||||
# All-day events can have only a date.
|
||||
start_naive = dateutil.parser.parse(event["start"]["date"])
|
||||
|
||||
# All-day events don't have a time zone offset; instead, we use the
|
||||
# time zone of the calendar.
|
||||
calendar_timezone = pytz.timezone(feed["timeZone"])
|
||||
# pytz timezones include an extra localize method that's not part
|
||||
# of the tzinfo base class.
|
||||
start = calendar_timezone.localize(start_naive) # type: ignore
|
||||
|
||||
start = event["start"]["date"]
|
||||
start = start[:19]
|
||||
# All-day events can have only a date
|
||||
fmt = '%Y-%m-%dT%H:%M:%S' if 'T' in start else '%Y-%m-%d'
|
||||
start = datetime.datetime.strptime(start, fmt)
|
||||
try:
|
||||
events.append((event["id"], start, event["summary"]))
|
||||
yield (event["id"], start, event["summary"])
|
||||
except KeyError:
|
||||
events.append((event["id"], start, "(No Title)"))
|
||||
yield (event["id"], start, "(No Title)")
|
||||
|
||||
|
||||
def send_reminders():
|
||||
@@ -154,7 +133,7 @@ def send_reminders():
|
||||
|
||||
messages = []
|
||||
keys = set()
|
||||
now = datetime.datetime.now(tz=pytz.utc)
|
||||
now = datetime.datetime.now()
|
||||
|
||||
for id, start, summary in events:
|
||||
dt = start - now
|
||||
@@ -193,8 +172,8 @@ for i in itertools.count():
|
||||
# We check reminders every minute, but only
|
||||
# download the calendar every 10 minutes.
|
||||
if not i % 10:
|
||||
populate_events()
|
||||
events = list(get_events())
|
||||
send_reminders()
|
||||
except Exception:
|
||||
except:
|
||||
logging.exception("Couldn't download Google calendar and/or couldn't post to Zulip.")
|
||||
time.sleep(60)
|
||||
|
||||
@@ -87,7 +87,7 @@ def format_commit_lines(web_url, repo, base, tip):
|
||||
return "\n".join(summary for summary in commit_summaries)
|
||||
|
||||
def send_zulip(email, api_key, site, stream, subject, content):
|
||||
# type: (str, str, str, str, str, Text) -> None
|
||||
# type: (str, str, str, str, str, Text) -> str
|
||||
"""
|
||||
Send a message to Zulip using the provided credentials, which should be for
|
||||
a bot in most cases.
|
||||
@@ -114,7 +114,7 @@ def get_config(ui, item):
|
||||
return None
|
||||
|
||||
def hook(ui, repo, **kwargs):
|
||||
# type: (ui, repo, **Text) -> None
|
||||
# type: (ui, repo, Optional[Text]) -> None
|
||||
"""
|
||||
Invoked by configuring a [hook] entry in .hg/hgrc.
|
||||
"""
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Dict
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
import zulip_openshift_config as config
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
# THE SOFTWARE.
|
||||
|
||||
# https://github.com/python/mypy/issues/1141
|
||||
from typing import Dict, Text
|
||||
from typing import Text
|
||||
|
||||
# Change these values to configure authentication for the plugin
|
||||
ZULIP_USER = 'openshift-bot@example.com'
|
||||
@@ -69,7 +69,7 @@ def format_deployment_message(
|
||||
|
||||
## If properly installed, the Zulip API should be in your import
|
||||
## path, but if not, set a custom path below
|
||||
ZULIP_API_PATH = None # type: str
|
||||
ZULIP_API_PATH = None # type: str
|
||||
|
||||
# Set this to your Zulip server's API URI
|
||||
ZULIP_SITE = 'https://zulip.example.com'
|
||||
|
||||
@@ -31,7 +31,6 @@ from six.moves.html_parser import HTMLParser
|
||||
import logging
|
||||
import optparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from six.moves import urllib
|
||||
@@ -39,9 +38,9 @@ from typing import Dict, List, Tuple, Any
|
||||
|
||||
import feedparser
|
||||
import zulip
|
||||
VERSION = "0.9" # type: str
|
||||
RSS_DATA_DIR = os.path.expanduser(os.path.join('~', '.cache', 'zulip-rss')) # type: str
|
||||
OLDNESS_THRESHOLD = 30 # type: int
|
||||
VERSION = "0.9" # type: str
|
||||
RSS_DATA_DIR = os.path.expanduser(os.path.join('~', '.cache', 'zulip-rss')) # type: str
|
||||
OLDNESS_THRESHOLD = 30 # type: int
|
||||
|
||||
usage = """Usage: Send summaries of RSS entries for your favorite feeds to Zulip.
|
||||
|
||||
@@ -67,7 +66,7 @@ stream every 5 minutes is:
|
||||
|
||||
*/5 * * * * /usr/local/share/zulip/integrations/rss/rss-bot"""
|
||||
|
||||
parser = optparse.OptionParser(usage) # type: optparse.OptionParser
|
||||
parser = optparse.OptionParser(usage) # type: optparse.OptionParser
|
||||
parser.add_option('--stream',
|
||||
dest='stream',
|
||||
help='The stream to which to send RSS messages.',
|
||||
@@ -83,18 +82,8 @@ parser.add_option('--feed-file',
|
||||
help='The file containing a list of RSS feed URLs to follow, one URL per line',
|
||||
default=os.path.join(RSS_DATA_DIR, "rss-feeds"),
|
||||
action='store')
|
||||
parser.add_option('--unwrap',
|
||||
dest='unwrap',
|
||||
action='store_true',
|
||||
help='Convert word-wrapped paragraphs into single lines',
|
||||
default=False)
|
||||
parser.add_option('--math',
|
||||
dest='math',
|
||||
action='store_true',
|
||||
help='Convert $ to $$ (for KaTeX processing)',
|
||||
default=False)
|
||||
parser.add_option_group(zulip.generate_option_group(parser))
|
||||
(opts, args) = parser.parse_args() # type: Tuple[Any, List[str]]
|
||||
(opts, args) = parser.parse_args() # type: Tuple[Any, List[str]]
|
||||
|
||||
def mkdir_p(path):
|
||||
# type: (str) -> None
|
||||
@@ -114,15 +103,15 @@ except OSError:
|
||||
print("Unable to store RSS data at %s." % (opts.data_dir,), file=sys.stderr)
|
||||
exit(1)
|
||||
|
||||
log_file = os.path.join(opts.data_dir, "rss-bot.log") # type: str
|
||||
log_format = "%(asctime)s: %(message)s" # type: str
|
||||
log_file = os.path.join(opts.data_dir, "rss-bot.log") # type: str
|
||||
log_format = "%(asctime)s: %(message)s" # type: str
|
||||
logging.basicConfig(format=log_format)
|
||||
|
||||
formatter = logging.Formatter(log_format) # type: logging.Formatter
|
||||
file_handler = logging.FileHandler(log_file) # type: logging.FileHandler
|
||||
formatter = logging.Formatter(log_format) # type: logging.Formatter
|
||||
file_handler = logging.FileHandler(log_file) # type: logging.FileHandler
|
||||
file_handler.setFormatter(formatter)
|
||||
|
||||
logger = logging.getLogger(__name__) # type: logging.Logger
|
||||
logger = logging.getLogger(__name__) # type: logging.Logger
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
@@ -136,7 +125,7 @@ class MLStripper(HTMLParser):
|
||||
def __init__(self):
|
||||
# type: () -> None
|
||||
self.reset()
|
||||
self.fed = [] # type: List[str]
|
||||
self.fed = [] # type: List[str]
|
||||
|
||||
def handle_data(self, data):
|
||||
# type: (str) -> None
|
||||
@@ -158,12 +147,6 @@ def compute_entry_hash(entry):
|
||||
entry_id = entry.get("id", entry.get("link"))
|
||||
return hashlib.md5(entry_id + str(entry_time)).hexdigest()
|
||||
|
||||
def unwrap_text(body):
|
||||
# type: (str) -> str
|
||||
# Replace \n by space if it is preceded and followed by a non-\n.
|
||||
# Example: '\na\nb\nc\n\nd\n' -> '\na b c\n\nd\n'
|
||||
return re.sub('(?<=[^\n])\n(?=[^\n])', ' ', body)
|
||||
|
||||
def elide_subject(subject):
|
||||
# type: (str) -> str
|
||||
MAX_TOPIC_LENGTH = 60
|
||||
@@ -173,53 +156,45 @@ def elide_subject(subject):
|
||||
|
||||
def send_zulip(entry, feed_name):
|
||||
# type: (Any, str) -> Dict[str, Any]
|
||||
body = entry.summary # type: str
|
||||
if opts.unwrap:
|
||||
body = unwrap_text(body)
|
||||
|
||||
content = "**[%s](%s)**\n%s\n%s" % (entry.title,
|
||||
entry.link,
|
||||
strip_tags(body),
|
||||
entry.link) # type: str
|
||||
|
||||
if opts.math:
|
||||
content = content.replace('$', '$$')
|
||||
|
||||
strip_tags(entry.summary),
|
||||
entry.link) # type: str
|
||||
message = {"type": "stream",
|
||||
"sender": opts.zulip_email,
|
||||
"to": opts.stream,
|
||||
"subject": elide_subject(feed_name),
|
||||
"content": content,
|
||||
} # type: Dict[str, str]
|
||||
} # type: Dict[str, str]
|
||||
return client.send_message(message)
|
||||
|
||||
try:
|
||||
with open(opts.feed_file, "r") as f:
|
||||
feed_urls = [feed.strip() for feed in f.readlines()] # type: List[str]
|
||||
feed_urls = [feed.strip() for feed in f.readlines()] # type: List[str]
|
||||
except IOError:
|
||||
log_error_and_exit("Unable to read feed file at %s." % (opts.feed_file,))
|
||||
|
||||
client = zulip.Client(email=opts.zulip_email, api_key=opts.zulip_api_key,
|
||||
site=opts.zulip_site, client="ZulipRSS/" + VERSION) # type: zulip.Client
|
||||
site=opts.zulip_site, client="ZulipRSS/" + VERSION) # type: zulip.Client
|
||||
|
||||
first_message = True # type: bool
|
||||
first_message = True # type: bool
|
||||
|
||||
for feed_url in feed_urls:
|
||||
feed_file = os.path.join(opts.data_dir, urllib.parse.urlparse(feed_url).netloc) # Type: str
|
||||
feed_file = os.path.join(opts.data_dir, urllib.parse.urlparse(feed_url).netloc) # Type: str
|
||||
|
||||
try:
|
||||
with open(feed_file, "r") as f:
|
||||
old_feed_hashes = dict((line.strip(), True) for line in f.readlines()) # type: Dict[str, bool]
|
||||
old_feed_hashes = dict((line.strip(), True) for line in f.readlines()) # type: Dict[str, bool]
|
||||
except IOError:
|
||||
old_feed_hashes = {}
|
||||
|
||||
new_hashes = [] # type: List[str]
|
||||
data = feedparser.parse(feed_url) # type: feedparser.parse
|
||||
new_hashes = [] # type: List[str]
|
||||
data = feedparser.parse(feed_url) # type: feedparser.parse
|
||||
|
||||
for entry in data.entries:
|
||||
entry_hash = compute_entry_hash(entry) # type: str
|
||||
entry_hash = compute_entry_hash(entry) # type: str
|
||||
# An entry has either been published or updated.
|
||||
entry_time = entry.get("published_parsed", entry.get("updated_parsed")) # type: Tuple[int, int]
|
||||
entry_time = entry.get("published_parsed", entry.get("updated_parsed")) # type: Tuple[int, int]
|
||||
if entry_time is not None and (time.time() - calendar.timegm(entry_time)) > OLDNESS_THRESHOLD * 60 * 60 * 24:
|
||||
# As a safeguard against misbehaving feeds, don't try to process
|
||||
# entries older than some threshold.
|
||||
@@ -232,9 +207,9 @@ for feed_url in feed_urls:
|
||||
# entries in reverse chronological order.
|
||||
break
|
||||
|
||||
feed_name = data.feed.title or feed_url # type: str
|
||||
feed_name = data.feed.title or feed_url # type: str
|
||||
|
||||
response = send_zulip(entry, feed_name) # type: Dict[str, Any]
|
||||
response = send_zulip(entry, feed_name) # type: Dict[str, Any]
|
||||
if response["result"] != "success":
|
||||
logger.error("Error processing %s" % (feed_url,))
|
||||
logger.error(str(response))
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
#
|
||||
# slacker is a dependency for this script.
|
||||
#
|
||||
|
||||
from __future__ import absolute_import
|
||||
from __future__ import print_function
|
||||
|
||||
import sys
|
||||
import string
|
||||
import random
|
||||
from six.moves import range
|
||||
from typing import List, Dict
|
||||
|
||||
import zulip
|
||||
from slacker import Slacker, Response, Error as SlackError
|
||||
|
||||
import zulip_slack_config as config
|
||||
|
||||
|
||||
client = zulip.Client(email=config.ZULIP_USER, api_key=config.ZULIP_API_KEY, site=config.ZULIP_SITE)
|
||||
|
||||
|
||||
class FromSlackImporter(object):
|
||||
def __init__(self, slack_token, get_archived_channels=True):
|
||||
# type: (str, bool) -> None
|
||||
self.slack = Slacker(slack_token)
|
||||
self.get_archived_channels = get_archived_channels
|
||||
|
||||
self._check_slack_token()
|
||||
|
||||
def get_slack_users_email(self):
|
||||
# type: () -> Dict[str, Dict[str, str]]
|
||||
|
||||
r = self.slack.users.list()
|
||||
self._check_if_response_is_successful(r)
|
||||
results_dict = {}
|
||||
for user in r.body['members']:
|
||||
if user['profile'].get('email') and user.get('deleted') is False:
|
||||
results_dict[user['id']] = {'email': user['profile']['email'], 'name': user['profile']['real_name']}
|
||||
return results_dict
|
||||
|
||||
def get_slack_public_channels_names(self):
|
||||
# type: () -> List[Dict[str, str]]
|
||||
|
||||
r = self.slack.channels.list()
|
||||
self._check_if_response_is_successful(r)
|
||||
return [{'name': channel['name'], 'members': channel['members']} for channel in r.body['channels']]
|
||||
|
||||
def get_slack_private_channels_names(self):
|
||||
# type: () -> List[str]
|
||||
|
||||
r = self.slack.groups.list()
|
||||
self._check_if_response_is_successful(r)
|
||||
return [
|
||||
channel['name'] for channel in r.body['groups']
|
||||
if not channel['is_archived'] or self.get_archived_channels
|
||||
]
|
||||
|
||||
def _check_slack_token(self):
|
||||
# type: () -> None
|
||||
try:
|
||||
r = self.slack.api.test()
|
||||
self._check_if_response_is_successful(r)
|
||||
except SlackError as e:
|
||||
print(e)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
sys.exit(1)
|
||||
|
||||
def _check_if_response_is_successful(self, response):
|
||||
# type: (Response) -> None
|
||||
print(response)
|
||||
if not response.successful:
|
||||
print(response.error)
|
||||
sys.exit(1)
|
||||
|
||||
def _generate_random_password(size=10):
|
||||
# type: (int) -> str
|
||||
return ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(size))
|
||||
|
||||
def get_and_add_users(slack_importer):
|
||||
# type: (Slacker) -> Dict[str, Dict[str, str]]
|
||||
users = slack_importer.get_slack_users_email()
|
||||
added_users = {}
|
||||
print('######### IMPORTING USERS STARTED #########\n')
|
||||
for user_id, user in users.items():
|
||||
r = client.create_user({
|
||||
'email': user['email'],
|
||||
'full_name': user['name'],
|
||||
'short_name': user['name']
|
||||
})
|
||||
if not r.get('msg'):
|
||||
added_users[user_id] = user
|
||||
print(u"{} -> {}\nCreated\n".format(user['name'], user['email']))
|
||||
else:
|
||||
print(u"{} -> {}\n{}\n".format(user['name'], user['email'], r.get('msg')))
|
||||
print('######### IMPORTING USERS FINISHED #########\n')
|
||||
return added_users
|
||||
|
||||
def create_streams_and_add_subscribers(slack_importer, added_users):
|
||||
# type: (Slacker, Dict[str, Dict[str, str]]) -> None
|
||||
channels_list = slack_importer.get_slack_public_channels_names()
|
||||
print('######### IMPORTING STREAMS STARTED #########\n')
|
||||
for stream in channels_list:
|
||||
subscribed_users = [added_users[member]['email'] for member in stream['members'] if member in added_users.keys()]
|
||||
if subscribed_users:
|
||||
r = client.add_subscriptions([{"name": stream['name']}], principals=subscribed_users)
|
||||
if not r.get('msg'):
|
||||
print(u"{} -> created\n".format(stream['name']))
|
||||
else:
|
||||
print(u"{} -> {}\n".format(stream['name'], r.get('msg')))
|
||||
else:
|
||||
print(u"{} -> wasn't created\nNo subscribers\n".format(stream['name']))
|
||||
print('######### IMPORTING STREAMS FINISHED #########\n')
|
||||
|
||||
def main():
|
||||
# type: () -> None
|
||||
importer = FromSlackImporter(config.SLACK_TOKEN)
|
||||
added_users = get_and_add_users(importer)
|
||||
create_streams_and_add_subscribers(importer, added_users)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -57,7 +57,7 @@ path, rev = sys.argv[1:] # type: Tuple[Text, Text]
|
||||
# since its a local path, prepend "file://"
|
||||
path = "file://" + path
|
||||
|
||||
entry = svn.log(path, revision_end=pysvn.Revision(pysvn.opt_revision_kind.number, rev))[0] # type: Dict[Text, Any]
|
||||
entry = svn.log(path, revision_end=pysvn.Revision(pysvn.opt_revision_kind.number, rev))[0] # type: Dict[Text, Union[Text, pysvn.Revision, List[Dict[Text, pysvn.Revision]]]]
|
||||
message = "**{0}** committed revision r{1} to `{2}`.\n\n> {3}".format(
|
||||
entry['author'],
|
||||
rev,
|
||||
|
||||
@@ -43,7 +43,7 @@ import zulip_trac_config as config
|
||||
VERSION = "0.9"
|
||||
|
||||
if False:
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
if config.ZULIP_API_PATH is not None:
|
||||
sys.path.append(config.ZULIP_API_PATH)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/sh
|
||||
krb_user_id=1051
|
||||
env KRB5CCNAME=/tmp/krb5cc_"$krb_user_id".tmp kinit -k -t /home/zulip/tabbott.extra.keytab tabbott/extra@ATHENA.MIT.EDU; mv /tmp/krb5cc_"$krb_user_id".tmp /tmp/krb5cc_"$krb_user_id"
|
||||
15
api/setup.py
15
api/setup.py
@@ -3,7 +3,7 @@
|
||||
|
||||
from __future__ import print_function
|
||||
if False:
|
||||
from typing import Any, Dict, Generator, List, Tuple
|
||||
from typing import Any, Generator, List, Tuple
|
||||
|
||||
import os
|
||||
import sys
|
||||
@@ -34,19 +34,18 @@ package_info = dict(
|
||||
author='Zulip Open Source Project',
|
||||
author_email='zulip-devel@googlegroups.com',
|
||||
classifiers=[
|
||||
'Development Status :: 4 - Beta',
|
||||
'Development Status :: 3 - Alpha',
|
||||
'Environment :: Web Environment',
|
||||
'Intended Audience :: Developers',
|
||||
'License :: OSI Approved :: MIT License',
|
||||
'Topic :: Communications :: Chat',
|
||||
],
|
||||
url='https://www.zulip.org/',
|
||||
url='https://www.zulip.org/dist/api/',
|
||||
packages=['zulip'],
|
||||
data_files=[('share/zulip/examples',
|
||||
["examples/zuliprc",
|
||||
"examples/create-user",
|
||||
"examples/edit-message",
|
||||
"examples/get-presence",
|
||||
"examples/get-public-streams",
|
||||
"examples/list-members",
|
||||
"examples/list-subscriptions",
|
||||
@@ -57,12 +56,8 @@ package_info = dict(
|
||||
"examples/subscribe",
|
||||
"examples/unsubscribe",
|
||||
])] + list(recur_expand('share/zulip', 'integrations/')),
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'zulip-send=zulip.send:main',
|
||||
],
|
||||
},
|
||||
) # type: Dict[str, Any]
|
||||
scripts=["bin/zulip-send"],
|
||||
) # type: Dict[str, Any]
|
||||
|
||||
setuptools_info = dict(
|
||||
install_requires=['requests>=0.12.1',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user