├── .gitignore ├── .gitmodules ├── .travis.yml ├── AUTHORS ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── _static │ └── jelly.png ├── _templates │ ├── hacks.html │ ├── sidebar-full.html │ └── sidebar-logo.html ├── conf.py └── index.rst ├── flask_bitmapist ├── __init__.py ├── core.py ├── decorators.py ├── extensions │ ├── __init__.py │ └── flask_login.py ├── mixins.py ├── static │ └── css │ │ └── dashboard.css ├── templates │ └── bitmapist │ │ ├── _event.html │ │ ├── _heatmap.html │ │ ├── base.html │ │ ├── cohort.html │ │ └── index.html ├── utils.py └── views.py ├── requirements-test.txt ├── scripts ├── release.py └── seed.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── conftest.py └── test_extension.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | app.py 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | tests/db/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | 57 | # Sphinx documentation 58 | docs/_build/ 59 | 60 | # PyBuilder 61 | target/ 62 | 63 | # Ipython Notebook 64 | .ipynb_checkpoints 65 | 66 | # Redis snapshots 67 | dump.rdb 68 | 69 | # Other 70 | .DS_Store 71 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "docs/_themes"] 2 | path = docs/_themes 3 | url = git://github.com/mitsuhiko/flask-sphinx-themes.git 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '2.6' 4 | - '2.7' 5 | sudo: false 6 | cache: 7 | - apt 8 | - pip 9 | install: 10 | - pip install -r requirements-test.txt -e . 11 | - pip install coverage coveralls Mako 12 | before_script: 13 | - redis-server --port 6399 & 14 | script: py.test --cov flask_bitmapist --cov-report term-missing --pep8 --flakes 15 | after_success: 16 | - coveralls 17 | notifications: 18 | slack: 19 | rooms: 20 | secure: Tok8grsXdoAXuWhig0fO66CLW4SvQsSaRZJ0ZZ/2bkt276LrggcgdNYwSJs4qDuMYz1/eMFASLe0obfgd0EKtliHIZFQLW05aoLKABj6N0k0XhIrqkWWhwhYdL+P8rnsr2VPNEFcX+iXY9V93KBnCBnrixH+J6VaMBr/gvHEZuZbM+gWWDeN/C1P+4T/NtmsvQmSt1jv3pskn7MKpbikIwDNyTvvH1jVPu1mZuaZ0DP2WMO8Pqr1fXsQ3IWlNVULx/JK9wwYRDcCK41qKIjYxUXMYUTkPdxn4aBtJ/oiSNasLwB06x9PYTkbgVoXouj6yxRqJduSR/qCZ/jfaYGAWOhtPVbK3dbUv/qi6gy0Fa62Qd6uFVykUinNPTWz+uNfpmwyO8ZNSKAuyVRMQGJgY9jSXpWaVFQjZVrjyPjfGwo51sqvUYgL3rA8Zq70T4m/Nwb/f7hxHCWZkW0dNopuT3sviBuRBsvdyKvqh51CqKGWI9qV3nxltXjZ9v8GClXXs5PL/spvbmUwa91vgqUSLrK+RttETGxAjoak11A9/SLpXmJcBNBUVMG2xCGUlFR01njyUnPkgSWJI+8oKG1jWER0CvBMDHC8ydToE4sh6I8VPeX1s+G7wpHB9UBmWzOU05SMxrtlzWnDD+NzJTag8/HXN5FIs7DpNoSxTei/lVs= 21 | deploy: 22 | provider: pypi 23 | user: cuttlesoft 24 | password: 25 | secure: jX4mBERKgONSMyv8/PLuql6Zx59ENngLRPklnrQLbibjyiMaGwdCx0f0C1JsqZu8TsWaEufkYk6I+PZig8Fp3CREYoos1Ayhbi7+Gy96BUn3W5dFeOGoLPOfDKz+9R69JMlDRV3uiM2gRNDS2EmAamI8Dg6nUImiIHpnokMUNpWh8zaGE8f0//viKt8wJkqntmzi6p3ydPwnAu+rMLHaV4vUD1RDu/rBsMCaS0D7HFvEOSr+JyR9IMHD4fmxdypEKamQ7SFwTQH/DituSwNgy/eqxChTc0eNJyYvIsQzqGO9eBozLPfzzmSzvYa2sLIvi/JF6qavgNUS0LrcTcSEgVMgJuBt9Ey+rxNNAq9P3vVwVK1oexx9Hu4LTneSa7WuX17wwKsglQFMzvWXFMj2DzSNhXcDrjgJ//UNnwh5SRdgvUG66yfVkvUm1d1Bqaz2B+BkMMvRS2OLQS23gZEplozA0Vgem5Cij9EUUPYAoliSLLwvhSE6w3/PPevOviCj042+RF1TiTbgEKTxR7fsn7jxKuvpPItI3J/L0cF4LOBwI/7DawA29XB/pBO5bSM48maWzWY3nMkZd2cyOmOa+s+AaAmvuE7XRPeiR/nnYoaF3eYYm7T1EISEDfTkXpFhqDp/6L+m7OzaYgbGyZcXMgHhsc8T69KzR0TeIUb9arc= 26 | on: 27 | tags: true 28 | repo: cuttlesoft/flask-bitmapist 29 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Authors 2 | ------- 3 | * Frank Valcarcel 4 | * Katie Russ 5 | * Benji Shankwitz 6 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | .. :changelog: 2 | 3 | Release History 4 | --------------- 5 | 6 | 0.1.0 (2016-03-30) 7 | ++++++++++++++++++ 8 | 9 | - First release. 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Cuttlesoft, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include HISTORY.rst 2 | include requirements-test.txt 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Flask-Bitmapist 3 | =============== 4 | 5 | .. image:: https://travis-ci.org/cuttlesoft/flask-bitmapist.svg?branch=master 6 | :target: https://travis-ci.org/cuttlesoft/flask-bitmapist 7 | 8 | .. image:: https://coveralls.io/repos/github/cuttlesoft/flask-bitmapist/badge.svg?branch=master 9 | :target: https://coveralls.io/github/cuttlesoft/flask-bitmapist?branch=master 10 | 11 | Flask extension that creates a simple interface to the Bitmapist analytics library. 12 | 13 | 14 | About 15 | ----- 16 | 17 | `Bitmapist `_ is: 18 | 19 | [A] Python library [that] makes it possible to implement real-time, highly scalable analytics that can answer the following questions: 20 | 21 | - Has user 123 been online today? This week? This month? 22 | - Has user 123 performed action "X"? 23 | - How many users have been active this month? This hour? 24 | - How many unique users have performed action "X" this week? 25 | - What % of users that were active last week are still active? 26 | - What % of users that were active last month are still active this month? 27 | - Which users performed action "X"? 28 | 29 | 30 | Installation 31 | ------------ 32 | 33 | :: 34 | 35 | $ pip install flask-bitmapist 36 | 37 | 38 | Usage 39 | ----- 40 | 41 | Example app: 42 | 43 | .. code-block:: python 44 | 45 | from flask import Flask 46 | from flask_bitmapist import FlaskBitmapist, mark 47 | 48 | app = Flask(__name__) 49 | 50 | flaskbitmapist = FlaskBitmapist() 51 | flaskbitmapist.init_app(app) 52 | 53 | @app.route('/') 54 | @mark('index:visited', 1) # current_user.id 55 | def index(): 56 | """using the mark decorator, the first argument is the event 57 | and the second is the id of the current_user 58 | """ 59 | return 'Hello, world!' 60 | 61 | if __name__ == '__main__': 62 | app.run() 63 | 64 | 65 | For documentation on the ``mark`` decorator, look at the ``mark_event`` `Bitmapist function `_. 66 | 67 | 68 | Config 69 | ------ 70 | 71 | =============================== =========== ====================================================================== 72 | Name Type Description 73 | =============================== =========== ====================================================================== 74 | ``BITMAPIST_REDIS_SYSTEM`` ``string`` Name of Redis System; defaults to ``default`` 75 | ``BITMAPIST_REDIS_URL`` ``string`` URL to connect to Redis server; defaults to ``redis://localhost:6379`` 76 | ``BITMAPIST_TRACK_HOURLY`` ``boolean`` Tells Bitmapist to track hourly; can also be passed to ``mark`` (e.g., ``@mark('active', 1, track_hourly=False)``) 77 | 78 | ``BITMAPIST_DISABLE_BLUEPRINT`` ``boolean`` Disables registration of default Bitmapist Blueprint 79 | =============================== =========== ====================================================================== 80 | 81 | 82 | Cohort Blueprint 83 | ---------------- 84 | 85 | One of the nice things about Bitmapist is its simple bit operations API and the data cohort that you get. 86 | For more information about the cohort, visit the `Bitmapist README `_. 87 | 88 | When you initialize the ``flask-bitmapist`` extension, a blueprint is registered with the application. 89 | 90 | ======== ===================== ============================================ 91 | Name Path Description 92 | ======== ===================== ============================================ 93 | `index` ``/bitmapist/`` Default Bitmapist index 94 | `cohort` ``/bitmapist/cohort`` Demo cohort retrieval and heatmap generation 95 | ======== ===================== ============================================ 96 | 97 | 98 | Tests 99 | ----- 100 | 101 | To run the tests, ensure that you have Redis running on port 6399:: 102 | 103 | $ redis-server --port 6399 104 | 105 | 106 | Then you can simply run:: 107 | 108 | $ python setup.py test 109 | 110 | 111 | To seed fake data for testing, run:: 112 | 113 | $ python scripts/seed.py 114 | 115 | 116 | Documentation 117 | ------------- 118 | 119 | The full Flask-Bitmapist documentation is available at `ReadTheDocs `_. 120 | 121 | 122 | Contributing 123 | ------------ 124 | 125 | If you're interested in contributing to Flask-Bitmapist, get started by creating an issue `here `_. Thanks! 126 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python $(shell which sphinx-build) 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help 18 | help: 19 | @echo "Please use \`make ' where is one of" 20 | @echo " html to make standalone HTML files" 21 | @echo " dirhtml to make HTML files named index.html in directories" 22 | @echo " singlehtml to make a single large HTML file" 23 | @echo " pickle to make pickle files" 24 | @echo " json to make JSON files" 25 | @echo " htmlhelp to make HTML files and a HTML help project" 26 | @echo " qthelp to make HTML files and a qthelp project" 27 | @echo " applehelp to make an Apple Help Book" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " epub3 to make an epub3" 31 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 32 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 33 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 34 | @echo " text to make text files" 35 | @echo " man to make manual pages" 36 | @echo " texinfo to make Texinfo files" 37 | @echo " info to make Texinfo files and run them through makeinfo" 38 | @echo " gettext to make PO message catalogs" 39 | @echo " changes to make an overview of all changed/added/deprecated items" 40 | @echo " xml to make Docutils-native XML files" 41 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 42 | @echo " linkcheck to check all external links for integrity" 43 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 44 | @echo " coverage to run coverage check of the documentation (if enabled)" 45 | @echo " dummy to check syntax errors of document sources" 46 | 47 | .PHONY: clean 48 | clean: 49 | rm -rf $(BUILDDIR)/* 50 | 51 | .PHONY: html 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | .PHONY: dirhtml 58 | dirhtml: 59 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 60 | @echo 61 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 62 | 63 | .PHONY: singlehtml 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | .PHONY: pickle 70 | pickle: 71 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 72 | @echo 73 | @echo "Build finished; now you can process the pickle files." 74 | 75 | .PHONY: json 76 | json: 77 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 78 | @echo 79 | @echo "Build finished; now you can process the JSON files." 80 | 81 | .PHONY: htmlhelp 82 | htmlhelp: 83 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 84 | @echo 85 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 86 | ".hhp project file in $(BUILDDIR)/htmlhelp." 87 | 88 | .PHONY: qthelp 89 | qthelp: 90 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 91 | @echo 92 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 93 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 94 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/flask-bitmapist.qhcp" 95 | @echo "To view the help file:" 96 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/flask-bitmapist.qhc" 97 | 98 | .PHONY: applehelp 99 | applehelp: 100 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 101 | @echo 102 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 103 | @echo "N.B. You won't be able to view it unless you put it in" \ 104 | "~/Library/Documentation/Help or install it in your application" \ 105 | "bundle." 106 | 107 | .PHONY: devhelp 108 | devhelp: 109 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 110 | @echo 111 | @echo "Build finished." 112 | @echo "To view the help file:" 113 | @echo "# mkdir -p $$HOME/.local/share/devhelp/flask-bitmapist" 114 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/flask-bitmapist" 115 | @echo "# devhelp" 116 | 117 | .PHONY: epub 118 | epub: 119 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 120 | @echo 121 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 122 | 123 | .PHONY: epub3 124 | epub3: 125 | $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 126 | @echo 127 | @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." 128 | 129 | .PHONY: latex 130 | latex: 131 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 132 | @echo 133 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 134 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 135 | "(use \`make latexpdf' here to do that automatically)." 136 | 137 | .PHONY: latexpdf 138 | latexpdf: 139 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 140 | @echo "Running LaTeX files through pdflatex..." 141 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 142 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 143 | 144 | .PHONY: latexpdfja 145 | latexpdfja: 146 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 147 | @echo "Running LaTeX files through platex and dvipdfmx..." 148 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 149 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 150 | 151 | .PHONY: text 152 | text: 153 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 154 | @echo 155 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 156 | 157 | .PHONY: man 158 | man: 159 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 160 | @echo 161 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 162 | 163 | .PHONY: texinfo 164 | texinfo: 165 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 166 | @echo 167 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 168 | @echo "Run \`make' in that directory to run these through makeinfo" \ 169 | "(use \`make info' here to do that automatically)." 170 | 171 | .PHONY: info 172 | info: 173 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 174 | @echo "Running Texinfo files through makeinfo..." 175 | make -C $(BUILDDIR)/texinfo info 176 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 177 | 178 | .PHONY: gettext 179 | gettext: 180 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 181 | @echo 182 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 183 | 184 | .PHONY: changes 185 | changes: 186 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 187 | @echo 188 | @echo "The overview file is in $(BUILDDIR)/changes." 189 | 190 | .PHONY: linkcheck 191 | linkcheck: 192 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 193 | @echo 194 | @echo "Link check complete; look for any errors in the above output " \ 195 | "or in $(BUILDDIR)/linkcheck/output.txt." 196 | 197 | .PHONY: doctest 198 | doctest: 199 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 200 | @echo "Testing of doctests in the sources finished, look at the " \ 201 | "results in $(BUILDDIR)/doctest/output.txt." 202 | 203 | .PHONY: coverage 204 | coverage: 205 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 206 | @echo "Testing of coverage in the sources finished, look at the " \ 207 | "results in $(BUILDDIR)/coverage/python.txt." 208 | 209 | .PHONY: xml 210 | xml: 211 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 212 | @echo 213 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 214 | 215 | .PHONY: pseudoxml 216 | pseudoxml: 217 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 218 | @echo 219 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 220 | 221 | .PHONY: dummy 222 | dummy: 223 | $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy 224 | @echo 225 | @echo "Build finished. Dummy builder generates no files." 226 | -------------------------------------------------------------------------------- /docs/_static/jelly.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cuttlesoft/flask-bitmapist/cb7e7020391e7ef8c5ffda7c282edc8cab271003/docs/_static/jelly.png -------------------------------------------------------------------------------- /docs/_templates/hacks.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/_templates/sidebar-full.html: -------------------------------------------------------------------------------- 1 | 6 | 7 |

8 | Flask-Bitmapist is a Flask extension that creates a simple interface to the 9 | Bitmapist analytics library. 10 |

11 | 12 |

Stay Informed

13 |

Receive updates on new releases and upcoming projects.

14 | 15 |

Join Our Mailing List.

16 | 17 |

18 | 19 | 20 | 21 |

Useful Links

22 | 27 | -------------------------------------------------------------------------------- /docs/_templates/sidebar-logo.html: -------------------------------------------------------------------------------- 1 | 6 | 7 |

8 | Flask-Bitmapist is a Flask extension that creates a simple interface to the 9 | Bitmapist analytics library. 10 |

11 | 12 |

Stay Informed

13 |

Receive updates on new releases and upcoming projects.

14 | 15 |

Join Our Mailing List.

16 | 17 |

18 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # flask-bitmapist documentation build configuration file, created by 4 | # sphinx-quickstart on Mon Aug 15 15:57:58 2016. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | 19 | import os 20 | import sys 21 | 22 | sys.path.insert(0, os.path.abspath('../')) 23 | 24 | from flask_bitmapist import __version__ 25 | 26 | 27 | # -- General configuration ------------------------------------------------ 28 | 29 | # If your documentation needs a minimal Sphinx version, state it here. 30 | # 31 | # needs_sphinx = '1.0' 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 35 | # ones. 36 | extensions = [ 37 | 'sphinx.ext.autodoc', 38 | ] 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ['_templates'] 42 | 43 | # The suffix(es) of source filenames. 44 | # You can specify multiple suffix as a list of string: 45 | # 46 | # source_suffix = ['.rst', '.md'] 47 | source_suffix = '.rst' 48 | 49 | # The encoding of source files. 50 | # 51 | # source_encoding = 'utf-8-sig' 52 | 53 | # The master toctree document. 54 | master_doc = 'index' 55 | 56 | # General information about the project. 57 | project = u'flask-bitmapist' 58 | copyright = u'2016, Cuttlesoft' 59 | author = u'Cuttlesoft' 60 | 61 | # The version info for the project you're documenting, acts as replacement for 62 | # |version| and |release|, also used in various other places throughout the 63 | # built documents. 64 | # 65 | # The short X.Y version. 66 | version = __version__ 67 | # The full version, including alpha/beta/rc tags. 68 | release = __version__ 69 | 70 | # The language for content autogenerated by Sphinx. Refer to documentation 71 | # for a list of supported languages. 72 | # 73 | # This is also used if you do content translation via gettext catalogs. 74 | # Usually you set "language" from the command line for these cases. 75 | language = None 76 | 77 | # There are two options for replacing |today|: either, you set today to some 78 | # non-false value, then it is used: 79 | # 80 | # today = '' 81 | # 82 | # Else, today_fmt is used as the format for a strftime call. 83 | # 84 | # today_fmt = '%B %d, %Y' 85 | 86 | # List of patterns, relative to source directory, that match files and 87 | # directories to ignore when looking for source files. 88 | # This patterns also effect to html_static_path and html_extra_path 89 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 90 | 91 | # The reST default role (used for this markup: `text`) to use for all 92 | # documents. 93 | # 94 | # default_role = None 95 | 96 | # If true, '()' will be appended to :func: etc. cross-reference text. 97 | # 98 | # add_function_parentheses = True 99 | 100 | # If true, the current module name will be prepended to all description 101 | # unit titles (such as .. function::). 102 | # 103 | # add_module_names = True 104 | 105 | # If true, sectionauthor and moduleauthor directives will be shown in the 106 | # output. They are ignored by default. 107 | # 108 | # show_authors = False 109 | 110 | # The name of the Pygments (syntax highlighting) style to use. 111 | pygments_style = 'sphinx' 112 | 113 | # A list of ignored prefixes for module index sorting. 114 | # modindex_common_prefix = [] 115 | 116 | # If true, keep warnings as "system message" paragraphs in the built documents. 117 | # keep_warnings = False 118 | 119 | # If true, `todo` and `todoList` produce output, else they produce nothing. 120 | todo_include_todos = False 121 | 122 | 123 | # -- Options for HTML output ---------------------------------------------- 124 | 125 | # The theme to use for HTML and HTML Help pages. See the documentation for 126 | # a list of builtin themes. 127 | # 128 | html_theme = 'alabaster' 129 | 130 | # Theme options are theme-specific and customize the look and feel of a theme 131 | # further. For a list of options available for each theme, see the 132 | # documentation. 133 | # 134 | # html_theme_options = {} 135 | 136 | # Add any paths that contain custom themes here, relative to this directory. 137 | # html_theme_path = [] 138 | 139 | # The name for this set of Sphinx documents. 140 | # " v documentation" by default. 141 | # 142 | # html_title = u'flask-bitmapist v0.1.0' 143 | 144 | # A shorter title for the navigation bar. Default is the same as html_title. 145 | # 146 | # html_short_title = None 147 | 148 | # The name of an image file (relative to this directory) to place at the top 149 | # of the sidebar. 150 | # 151 | # html_logo = None 152 | 153 | # The name of an image file (relative to this directory) to use as a favicon of 154 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 155 | # pixels large. 156 | # 157 | # html_favicon = None 158 | 159 | # Add any paths that contain custom static files (such as style sheets) here, 160 | # relative to this directory. They are copied after the builtin static files, 161 | # so a file named "default.css" will overwrite the builtin "default.css". 162 | html_static_path = ['_static'] 163 | 164 | # Add any extra paths that contain custom files (such as robots.txt or 165 | # .htaccess) here, relative to this directory. These files are copied 166 | # directly to the root of the documentation. 167 | # 168 | # html_extra_path = [] 169 | 170 | # If not None, a 'Last updated on:' timestamp is inserted at every page 171 | # bottom, using the given strftime format. 172 | # The empty string is equivalent to '%b %d, %Y'. 173 | # 174 | # html_last_updated_fmt = None 175 | 176 | # If true, SmartyPants will be used to convert quotes and dashes to 177 | # typographically correct entities. 178 | # 179 | # html_use_smartypants = True 180 | 181 | 182 | # Custom sidebar templates, maps document names to template names. 183 | html_sidebars = { 184 | 'index': ['sidebar-full.html', 'sourcelink.html', 'searchbox.html', 185 | 'hacks.html'], 186 | '**': ['sidebar-logo.html', 'localtoc.html', 'relations.html', 187 | 'sourcelink.html', 'searchbox.html', 'hacks.html'] 188 | } 189 | 190 | # Additional templates that should be rendered to pages, maps page names to 191 | # template names. 192 | # 193 | # html_additional_pages = {} 194 | 195 | # If false, no module index is generated. 196 | # 197 | # html_domain_indices = True 198 | 199 | # If false, no index is generated. 200 | # 201 | # html_use_index = True 202 | 203 | # If true, the index is split into individual pages for each letter. 204 | # 205 | # html_split_index = False 206 | 207 | # If true, links to the reST sources are added to the pages. 208 | # 209 | # html_show_sourcelink = True 210 | 211 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 212 | # 213 | # html_show_sphinx = True 214 | 215 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 216 | # 217 | # html_show_copyright = True 218 | 219 | # If true, an OpenSearch description file will be output, and all pages will 220 | # contain a tag referring to it. The value of this option must be the 221 | # base URL from which the finished HTML is served. 222 | # 223 | # html_use_opensearch = '' 224 | 225 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 226 | # html_file_suffix = None 227 | 228 | # Language to be used for generating the HTML full-text search index. 229 | # Sphinx supports the following languages: 230 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 231 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' 232 | # 233 | # html_search_language = 'en' 234 | 235 | # A dictionary with options for the search language support, empty by default. 236 | # 'ja' uses this config value. 237 | # 'zh' user can custom change `jieba` dictionary path. 238 | # 239 | # html_search_options = {'type': 'default'} 240 | 241 | # The name of a javascript file (relative to the configuration directory) that 242 | # implements a search results scorer. If empty, the default will be used. 243 | # 244 | # html_search_scorer = 'scorer.js' 245 | 246 | # Output file base name for HTML help builder. 247 | htmlhelp_basename = 'flask-bitmapistdoc' 248 | 249 | # -- Options for LaTeX output --------------------------------------------- 250 | 251 | latex_elements = { 252 | # The paper size ('letterpaper' or 'a4paper'). 253 | # 254 | # 'papersize': 'letterpaper', 255 | 256 | # The font size ('10pt', '11pt' or '12pt'). 257 | # 258 | # 'pointsize': '10pt', 259 | 260 | # Additional stuff for the LaTeX preamble. 261 | # 262 | # 'preamble': '', 263 | 264 | # Latex figure (float) alignment 265 | # 266 | # 'figure_align': 'htbp', 267 | } 268 | 269 | # Grouping the document tree into LaTeX files. List of tuples 270 | # (source start file, target name, title, 271 | # author, documentclass [howto, manual, or own class]). 272 | latex_documents = [ 273 | (master_doc, 'flask-bitmapist.tex', u'flask-bitmapist Documentation', 274 | u'Cuttlesoft', 'manual'), 275 | ] 276 | 277 | # The name of an image file (relative to this directory) to place at the top of 278 | # the title page. 279 | # 280 | # latex_logo = None 281 | 282 | # For "manual" documents, if this is true, then toplevel headings are parts, 283 | # not chapters. 284 | # 285 | # latex_use_parts = False 286 | 287 | # If true, show page references after internal links. 288 | # 289 | # latex_show_pagerefs = False 290 | 291 | # If true, show URL addresses after external links. 292 | # 293 | # latex_show_urls = False 294 | 295 | # Documents to append as an appendix to all manuals. 296 | # 297 | # latex_appendices = [] 298 | 299 | # If false, no module index is generated. 300 | # 301 | # latex_domain_indices = True 302 | 303 | 304 | # -- Options for manual page output --------------------------------------- 305 | 306 | # One entry per manual page. List of tuples 307 | # (source start file, name, description, authors, manual section). 308 | man_pages = [ 309 | (master_doc, 'flask-bitmapist', u'flask-bitmapist Documentation', 310 | [author], 1) 311 | ] 312 | 313 | # If true, show URL addresses after external links. 314 | # 315 | # man_show_urls = False 316 | 317 | 318 | # -- Options for Texinfo output ------------------------------------------- 319 | 320 | # Grouping the document tree into Texinfo files. List of tuples 321 | # (source start file, target name, title, author, 322 | # dir menu entry, description, category) 323 | texinfo_documents = [ 324 | (master_doc, 'flask-bitmapist', u'flask-bitmapist Documentation', 325 | author, 'flask-bitmapist', 'One line description of project.', 326 | 'Miscellaneous'), 327 | ] 328 | 329 | # Documents to append as an appendix to all manuals. 330 | # 331 | # texinfo_appendices = [] 332 | 333 | # If false, no module index is generated. 334 | # 335 | # texinfo_domain_indices = True 336 | 337 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 338 | # 339 | # texinfo_show_urls = 'footnote' 340 | 341 | # If true, do not generate a @detailmenu in the "Top" node's menu. 342 | # 343 | # texinfo_no_detailmenu = False 344 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. flask-bitmapist documentation master file, created by 2 | sphinx-quickstart on Mon Aug 15 15:57:58 2016. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | =============== 7 | Flask-Bitmapist 8 | =============== 9 | 10 | .. toctree:: 11 | :maxdepth: 2 12 | 13 | 14 | Flask-Bitmapist is a Flask extension that creates a simple interface to the `Bitmapist `_ analytics library. 15 | 16 | Events are registered with the name of the event (e.g., "user:logged_in") and the object id (e.g., the logged in user's id). 17 | 18 | There are four different ways to register events from your Flask app: 19 | * Call a function decorated with the ``@mark()`` decorator 20 | * Use the ``Bitmapistable`` mixin (note: current ORM support is limited to SQLAlchemy) 21 | * With the Flask-Login extension, user login/logout will register corresponding events automatically 22 | * Call the ``mark_event()`` function directly 23 | 24 | To use the ``@mark()`` decorator:: 25 | 26 | @mark('user:reset_password', user.id) 27 | def reset_password(): 28 | pass 29 | 30 | 31 | To use the ``Bitmapistable`` mixin:: 32 | 33 | from flask_bitmapist import Bitmapistable 34 | 35 | class User(db.Model, Bitmapistable): 36 | pass 37 | 38 | .. from flask_bitmapist import SQLAlchemyBitmapistable as Bitmapistable 39 | 40 | 41 | If you are using Flask-Login, "user:logged_in" and "user:logged_out" events will be registered automatically on user login and user logout, respectively:: 42 | 43 | >>> flask_login.login_user(user) 44 | >>> flask_login.logout_user() 45 | 46 | 47 | You can also call the ``mark_event()`` function directly:: 48 | 49 | >>> mark_event('user:action_taken', user.id) 50 | 51 | 52 | Installation 53 | ============ 54 | 55 | Install the extension using pip:: 56 | 57 | $ pip install flask-bitmapist 58 | 59 | 60 | Quickstart 61 | ========== 62 | 63 | 64 | Initialization 65 | -------------- 66 | Marking a user-based event is very simple with Flask-Bitmapist. 67 | 68 | Begin by importing FlaskBitmapist and initializing the FlaskBitmapist application (this will need to be a Flask app):: 69 | 70 | from flask import Flask 71 | from flask_bitmapist import FlaskBitmapist 72 | 73 | # create Flask app object 74 | app = Flask(__name__) 75 | 76 | # initialize flask_bitmapist with the app object 77 | flaskbitmapist = FlaskBitmapist() 78 | flaskbitmapist.init_app(app) 79 | 80 | 81 | Ensure that Redis is running; you can specify a port (default: 6379) with the ``--port`` flag:: 82 | 83 | $ redis-server 84 | 85 | You are then free to use whichever method(s) you find best suited to your application for marking and registering events. 86 | 87 | 88 | Configuration Options 89 | --------------------- 90 | 91 | =========================== ======================================================== ======================== 92 | Configuration Options Description Default 93 | =========================== ======================================================== ======================== 94 | BITMAPIST_REDIS_URL Location where Redis server is running "redis://localhost:6379" 95 | BITMAPIST_REDIS_SYSTEM Name of Redis system to use for Bitmapist "default" 96 | BITMAPIST_TRACK_HOURLY Whether to track events down to the hour False 97 | BITMAPIST_DISABLE_BLUEPRINT Whether to disable registration of the default blueprint False 98 | =========================== ======================================================== ======================== 99 | 100 | 101 | Usage 102 | ----- 103 | 104 | Decorator 105 | ^^^^^^^^^ 106 | 107 | Usage of the ``@mark()`` decorator can be useful when you want to track interactions that do not deal directly with the database model. 108 | 109 | To use, import the decorator and attach it to the function, providing the event name and user id:: 110 | 111 | from flask_bitmapist import mark 112 | 113 | @mark('index:visited', current_user.id) 114 | def index(): 115 | return render_template('index.html') 116 | 117 | 118 | Mixin 119 | ^^^^^ 120 | 121 | The mixin can be used to track when a user object is created, updated, or deleted. It interacts directly with the ORM to register events on insert, update, or delete. 122 | 123 | To use, import the mixin and extend the desired class with it:: 124 | 125 | from flask_bitmapist import Bitmapistable 126 | 127 | class User(db.Model, Bitmapistable): 128 | id = db.Column(db.Integer, primary_key=True) 129 | 130 | The event "user:created" will then be registered when a new user is instantiated and committed to the database:: 131 | 132 | user = User() 133 | db.session.add(user) 134 | db.session.commit() 135 | 136 | Similarly, "user:updated" and "user:deleted" will be registered for a given user on updating and deleting, respectively. 137 | 138 | 139 | Flask-Login 140 | ^^^^^^^^^^^ 141 | 142 | The Flask-Login extension is a common means of user management for many Flask applications. Flask-Bitmapist integrates with this extension to track user login and logout events automatically via Flask-Login's LoginManager and UserMixin:: 143 | 144 | from flask_login import LoginManager, UserMixin 145 | 146 | class User(UserMixin): 147 | id = None 148 | 149 | login_manager = LoginManager() 150 | login_manager.init_app(app) 151 | 152 | Create and log in the user, and the event "user:logged_in" will be registered automatically; the same works for logging out a user and the "user:logged_out" event:: 153 | 154 | from flask_login import login_user, logout_user 155 | 156 | user = User(id=user_id) 157 | 158 | # login user 159 | login_user(user) 160 | 161 | # logout user 162 | logout_user() 163 | 164 | 165 | Function Call 166 | ^^^^^^^^^^^^^ 167 | 168 | The most raw way to use Flask-Bitmapist is to directly call ``mark_event()``:: 169 | 170 | from flask_bitmapist import mark_event 171 | 172 | mark_event('event:completed', current_user.id) 173 | 174 | 175 | 176 | Small Example App 177 | ----------------- 178 | 179 | .. Let's start by creating a simple app:: 180 | 181 | :: 182 | 183 | from flask import Flask 184 | from flask_bitmapist import FlaskBitmapist, mark 185 | 186 | app = Flask(__name__) 187 | 188 | flaskbitmapist = FlaskBitmapist() 189 | flaskbitmapist.init_app(app) 190 | 191 | @app.route('/') 192 | @mark('index:visited', 1) # current_user.id 193 | def index(): 194 | """using the mark decorator, the first argument is the event 195 | and the second is the id of the current_user 196 | """ 197 | return 'Hello, world!' 198 | 199 | if __name__ == '__main__': 200 | app.run() 201 | 202 | 203 | Testing 204 | ======= 205 | 206 | To run the tests, ensure that you have Redis running on port 6399:: 207 | 208 | $ redis-server --port 6399 209 | 210 | Then you can simply run:: 211 | 212 | $ python setup.py test 213 | 214 | 215 | To seed fake data for testing, run:: 216 | 217 |  $ python scripts/seed.py 218 | 219 | 220 | API 221 | === 222 | 223 | .. .. automodule:: flask_bitmapist.utils 224 | .. :members: 225 | 226 | .. autofunction:: flask_bitmapist.utils.get_event_data 227 | .. autofunction:: flask_bitmapist.utils.get_cohort 228 | .. autofunction:: flask_bitmapist.utils.chain_events 229 | 230 | 231 | Indices and tables 232 | ------------------ 233 | 234 | * :ref:`genindex` 235 | * :ref:`modindex` 236 | * :ref:`search` 237 | -------------------------------------------------------------------------------- /flask_bitmapist/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | flask_bitmapist 4 | ~~~~~~~~~~~~~~ 5 | 6 | :copyright: (c) 2016 by Cuttlesoft, LLC. 7 | :license: MIT, see LICENSE for more details 8 | """ 9 | 10 | from core import FlaskBitmapist 11 | from decorators import mark 12 | from utils import chain_events, get_cohort, get_event_data 13 | 14 | try: 15 | import flask_login 16 | from extensions.flask_login import mark_login, mark_logout 17 | except ImportError: 18 | pass 19 | 20 | from bitmapist import (mark_event, unmark_event, 21 | MonthEvents, WeekEvents, DayEvents, HourEvents, 22 | BitOpAnd, BitOpOr, get_event_names) 23 | 24 | 25 | __version__ = '0.1.2' 26 | __versionfull__ = __version__ 27 | 28 | __all__ = ['FlaskBitmapist', 'mark', 'mark_event', 'unmark_event', 29 | 'MonthEvents', 'WeekEvents', 'DayEvents', 'HourEvents', 30 | 'BitOpAnd', 'BitOpOr', 'get_event_names', 31 | 'chain_events', 'get_cohort', 'get_event_data'] 32 | -------------------------------------------------------------------------------- /flask_bitmapist/core.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | flask_bitmapist.core 4 | ~~~~~~~~~~~~~~~~~~~~ 5 | Implements the core integration from flask to bitmapist 6 | 7 | :copyright: (c) 2016 by Cuttlesoft, LLC. 8 | :license: MIT, see LICENSE for more details. 9 | """ 10 | 11 | import bitmapist as _bitmapist 12 | 13 | from .utils import _get_redis_connection 14 | from .views import bitmapist_bp 15 | 16 | 17 | class FlaskBitmapist(object): 18 | """ 19 | This class is used to initialize the Flask Bitmapist extension 20 | """ 21 | 22 | app = None 23 | redis_url = None 24 | SYSTEMS = _bitmapist.SYSTEMS 25 | TRACK_HOURLY = _bitmapist.TRACK_HOURLY 26 | 27 | def __init__(self, app=None, config=None, **opts): 28 | if not (config is None or isinstance(config, dict)): 29 | raise ValueError("config must be an instance of dict or None") 30 | self.config = config 31 | 32 | self.app = app 33 | if app is not None: 34 | self.init_app(app, config) 35 | 36 | def init_app(self, app, config=None): 37 | "This is used to initialize bitmapist with your app object" 38 | self.app = app 39 | self.redis_url = app.config.get('BITMAPIST_REDIS_URL', 'redis://localhost:6379') 40 | 41 | if self.redis_url not in _bitmapist.SYSTEMS.values(): 42 | host, port = _get_redis_connection(self.redis_url) 43 | _bitmapist.setup_redis( 44 | app.config.get('BITMAPIST_REDIS_SYSTEM', 'default'), 45 | host, 46 | port) 47 | 48 | _bitmapist.TRACK_HOURLY = app.config.get('BITMAPIST_TRACK_HOURLY', False) 49 | 50 | if not hasattr(app, 'extensions'): 51 | app.extensions = {} 52 | 53 | app.extensions['bitmapist'] = self 54 | 55 | if not app.config.get('BITMAPIST_DISABLE_BLUEPRINT', False): 56 | app.register_blueprint(bitmapist_bp) 57 | -------------------------------------------------------------------------------- /flask_bitmapist/decorators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | flask_bitmapist.decorators 4 | ~~~~~~~~~~~~~~~~~~~~ 5 | Function decorators for bitmapist. 6 | 7 | :copyright: (c) 2016 by Cuttlesoft, LLC. 8 | :license: MIT, see LICENSE for more details. 9 | """ 10 | 11 | from functools import wraps 12 | 13 | from bitmapist import mark_event 14 | 15 | 16 | def mark(event_name, uuid, system='default', now=None, track_hourly=None, use_pipeline=True): 17 | """ 18 | A wrapper around bitmapist.mark_event 19 | 20 | Marks an event for hours, days, weeks and months. 21 | 22 | :param :event_name The name of the event, could be "active" or "new_signups" 23 | 24 | :param :uuid An unique id, typically user id. The id should not be huge, 25 | read Redis documentation why (bitmaps) 26 | 27 | :param :system The Redis system to use (string, Redis instance, or Pipeline 28 | instance). 29 | 30 | :param :now Which date should be used as a reference point, default is 31 | `datetime.utcnow()` 32 | 33 | :param :track_hourly Should hourly stats be tracked, defaults to 34 | bitmapist.TRACK_HOURLY 35 | 36 | :param :use_pipeline Boolean flag indicating if the command should use 37 | pipelines or not. You may want to avoid using pipeline within the 38 | command if you provide the pipeline object in `system` argument and 39 | want to manage the pipe execution yourself. 40 | """ 41 | 42 | # print('called with', event_name) 43 | 44 | def decorator(fn): 45 | @wraps(fn) 46 | def wrapper(*args, **kwargs): 47 | mark_event(event_name, uuid, system, now, track_hourly, use_pipeline) 48 | return fn(*args, **kwargs) 49 | return wrapper 50 | return decorator 51 | -------------------------------------------------------------------------------- /flask_bitmapist/extensions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cuttlesoft/flask-bitmapist/cb7e7020391e7ef8c5ffda7c282edc8cab271003/flask_bitmapist/extensions/__init__.py -------------------------------------------------------------------------------- /flask_bitmapist/extensions/flask_login.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from flask.ext.login import user_logged_in, user_logged_out 4 | 5 | from bitmapist import mark_event 6 | 7 | 8 | @user_logged_in.connect 9 | def mark_login(sender, user, **extra): 10 | mark_event('user:logged_in', user.id) 11 | 12 | 13 | @user_logged_out.connect 14 | def mark_logout(sender, user, **extra): 15 | mark_event('user:logged_out', user.id) 16 | -------------------------------------------------------------------------------- /flask_bitmapist/mixins.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | flask_bitmapist.mixins 4 | ~~~~~~~~~~~~~~~~~~~~ 5 | Mixins for bitmapist. 6 | 7 | :copyright: (c) 2016 by Cuttlesoft, LLC. 8 | :license: MIT, see LICENSE for more details. 9 | """ 10 | 11 | from sqlalchemy import event 12 | 13 | from bitmapist import mark_event 14 | 15 | 16 | class Bitmapistable(object): 17 | """A mixin class to make a model Bitmapist-ready.""" 18 | 19 | @staticmethod 20 | def bitmapist_after_insert(mapper, connection, target): 21 | mark_event('%s:created' % target.__class__.__name__.lower(), target.id) 22 | 23 | @staticmethod 24 | def bitmapist_before_update(mapper, connection, target): 25 | mark_event('%s:updated' % target.__class__.__name__.lower(), target.id) 26 | 27 | @staticmethod 28 | def bitmapist_before_delete(mapper, connection, target): 29 | mark_event('%s:deleted' % target.__class__.__name__.lower(), target.id) 30 | 31 | @classmethod 32 | def __declare_last__(self): 33 | event.listen(self, 'after_insert', self.bitmapist_after_insert) 34 | event.listen(self, 'before_update', self.bitmapist_before_update) 35 | event.listen(self, 'before_delete', self.bitmapist_before_delete) 36 | -------------------------------------------------------------------------------- /flask_bitmapist/static/css/dashboard.css: -------------------------------------------------------------------------------- 1 | tfoot { 2 | font-weight: bold; 3 | } 4 | 5 | .experiment { 6 | margin: 36px 0; 7 | } 8 | 9 | .experiment-header { 10 | border-bottom: 1px solid #e5e5e5; 11 | margin-bottom: 18px; 12 | } 13 | 14 | .experiment-header h2 { 15 | float: left; 16 | } 17 | 18 | .experiment table form { 19 | margin-bottom: 0; 20 | } 21 | 22 | #footer { 23 | margin-top: 45px; 24 | padding: 35px 0 36px; 25 | border-top: 1px solid #e5e5e5; 26 | } 27 | 28 | .inline-controls { 29 | float: right; 30 | } 31 | 32 | .inline-controls form { 33 | margin-bottom: 0; 34 | } 35 | 36 | .inline-controls form { 37 | display: inline-block; 38 | } 39 | 40 | .start-time { 41 | color: #999; 42 | font-size: 18px; 43 | vertical-align: middle; 44 | margin-right: 10px; 45 | } -------------------------------------------------------------------------------- /flask_bitmapist/templates/bitmapist/_event.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | event: {{ event }} 5 |

6 |
7 | start time 8 |
9 | 10 |
11 |
12 | 13 |
14 |
15 |
16 |
-------------------------------------------------------------------------------- /flask_bitmapist/templates/bitmapist/_heatmap.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 | {% for j in range(num_cols) %} 9 | 10 | 11 | {% endfor %} 12 | 13 | 14 | 15 | 16 | {% for i in range(num_rows) %} 17 | {% set cohort_row = cohort[i] or [] %} 18 | 19 | 20 | 21 | 22 | {% for j in range(num_cols) %} 23 | {% set value = cohort_row[j] if cohort_row[j] != None else '' %} 24 | 25 | {% if value %} 26 | {% set percent = value if as_percent else (value / row_totals[i])|float %} 27 | {% else %} 28 | {% set percent = 0 %} 29 | {% endif %} 30 | 31 | 38 | {% endfor %} 39 | 40 | 41 | {% endfor %} 42 | 43 | 44 | 45 | 46 | {% for average in averages %} 47 | {% set average = average or 0 %} 48 | 55 | {% endfor %} 56 | 57 | 58 | 59 | 60 | 61 | {% for col_total in col_totals %} 62 | 63 | {% endfor %} 64 | 65 | 66 |
Users{{ '< 1' if j == 0 else '+ %s' % j }} {{ time_group }}
{{ dates[i] }}{{ row_totals[i] }} 32 | {% if as_percent and value != '' %} 33 | {{ (value * 100)|round(2) }}% 34 | {% else %} 35 | {{ value }} 36 | {% endif %} 37 |
Average{{ (total / (row_totals|length))|int }} 49 | {% if as_percent %} 50 | {{ (average * 100)|round(2) }}% 51 | {% else %} 52 | {{ average|int }} 53 | {% endif %} 54 |
Total{{ total|int }}{{ (col_total if col_total else 0)|int }}
67 |
68 | -------------------------------------------------------------------------------- /flask_bitmapist/templates/bitmapist/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Flask-Bitmapist 6 | 7 | 8 | 9 | {% block style %} 10 | {% endblock %} 11 | 12 | 13 | 14 | 15 | 33 | 34 |
35 | {% block content %} 36 | {% endblock %} 37 | 40 |
41 | 42 | 43 | 44 | 45 | 46 | {% block js %} 47 | {% endblock %} 48 | 49 | 50 | -------------------------------------------------------------------------------- /flask_bitmapist/templates/bitmapist/cohort.html: -------------------------------------------------------------------------------- 1 | {% extends "bitmapist/base.html" %} 2 | 3 | {% block style %} 4 | 75 | {% endblock %} 76 | 77 | {% block content %} 78 |

Cohort

79 | 80 |
81 |
82 |
83 | 84 |
85 | 86 | Of those users who 87 | 88 | 89 |
90 | 96 |
97 |
98 | 99 | 100 |
101 |
102 | 103 | see how many also 104 | 105 | 106 |
107 | 113 |
114 | 115 |
116 | 121 | 122 | 125 | 126 | 129 |
130 |
131 |
132 | 133 |
134 | 135 | each 136 | 137 | 138 |
139 | 144 | 148 |
149 |
150 | 151 | 152 |
153 |
154 | 157 |
158 |
159 | 160 |
161 | 162 |
163 | 164 |
165 | 166 | Get results starting 167 | 168 | 169 |
170 | 175 | days ago 176 |
177 |
178 | 179 |
180 | 181 | and going forward 182 | 183 | 184 |
185 | 190 | days from there 191 |
192 |
193 | 194 |
195 |
196 |
197 | 200 |
201 |
202 |
203 |
204 |
205 |
206 | 209 |
210 |
211 |
212 |
213 |
214 |
215 | 216 |
217 | 218 |
219 | 220 | 237 | 238 | 239 | {% endblock %} 240 | 241 | {% block js %} 242 | 364 | {% endblock %} 365 | -------------------------------------------------------------------------------- /flask_bitmapist/templates/bitmapist/index.html: -------------------------------------------------------------------------------- 1 | {% extends "bitmapist/base.html" %} 2 | 3 | {% block content %} 4 | {% if events %} 5 |
6 | The list below contains all the registered events. 7 |
8 | {% for event in events %} 9 | {% include "bitmapist/_event.html" %} 10 | {% endfor %} 11 | 24 | 37 | 50 | {% else %} 51 |

No events have been recorded yet.

52 |

Check out the documentation for more help getting started.

53 | {% endif %} 54 | {% endblock %} 55 | -------------------------------------------------------------------------------- /flask_bitmapist/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | flask_bitmapist.utils 4 | ~~~~~~~~~~~~~~~~~~~~~ 5 | Generic utility functions. 6 | 7 | :copyright: (c) 2016 by Cuttlesoft, LLC. 8 | :license: MIT, see LICENSE for more details. 9 | 10 | """ 11 | 12 | from datetime import datetime 13 | from urlparse import urlparse 14 | 15 | from dateutil.relativedelta import relativedelta 16 | 17 | from bitmapist import (DayEvents, WeekEvents, MonthEvents, YearEvents, 18 | BitOpAnd, BitOpOr, BitOpXor, delete_runtime_bitop_keys) 19 | 20 | 21 | def _get_redis_connection(redis_url=None): 22 | url = urlparse(redis_url) 23 | return url.hostname, url.port 24 | 25 | 26 | def get_event_data(event_name, time_group='days', now=None, system='default'): 27 | """ 28 | Get the data for a single event at a single event in time. 29 | 30 | :param str event_name: Name of event for retrieval 31 | :param str time_group: Time scale by which to group results; can be `days`, 32 | `weeks`, `months`, `years` 33 | :param datetime now: Time point at which to get event data (defaults to 34 | current time if None) 35 | :param str system: Which bitmapist should be used 36 | :returns: Bitmapist events collection 37 | """ 38 | now = now or datetime.utcnow() 39 | return _events_fn(time_group)(event_name, now, system) 40 | 41 | 42 | def _events_fn(time_group='days'): 43 | if time_group == 'days' or time_group == 'day': 44 | return _day_events_fn 45 | elif time_group == 'weeks' or time_group == 'week': 46 | return _week_events_fn 47 | elif time_group == 'months' or time_group == 'month': 48 | return _month_events_fn 49 | elif time_group == 'years' or time_group == 'year': 50 | return _year_events_fn 51 | 52 | 53 | def get_cohort(primary_event_name, secondary_event_name, 54 | additional_events=[], time_group='days', 55 | num_rows=10, num_cols=10, system='default', 56 | with_replacement=False): 57 | """ 58 | Get the cohort data for multiple chained events at multiple points in time. 59 | 60 | :param str primary_event_name: Name of primary event for defining cohort 61 | :param str secondary_event_name: Name of secondary event for defining cohort 62 | :param list additional_events: List of additional events by which to filter 63 | cohort (e.g., ``[{'name': 'user:logged_in', 64 | 'op': 'and'}]``) 65 | :param str time_group: Time scale by which to group results; can be `days`, 66 | `weeks`, `months`, `years` 67 | :param int num_rows: How many results rows to get; corresponds to how far 68 | back to get results from current time 69 | :param int num_cols: How many results cols to get; corresponds to how far 70 | forward to get results from each time point 71 | :param str system: Which bitmapist should be used 72 | :param bool with_replacement: Whether more than one occurence of an event 73 | should be counted for a given user; e.g., if 74 | a user logged in multiple times, whether to 75 | include subsequent logins for the cohort 76 | :returns: Tuple of (list of lists of cohort results, list of dates for 77 | cohort, primary event total for each date) 78 | """ 79 | 80 | cohort = [] 81 | dates = [] 82 | primary_event_totals = [] # for percents 83 | 84 | fn_get_events = _events_fn(time_group) 85 | 86 | # TIMES 87 | 88 | def increment_delta(t): 89 | return relativedelta(**{time_group: t}) 90 | 91 | now = datetime.utcnow() 92 | # - 1 for deltas between time points (?) 93 | event_time = now - relativedelta(**{time_group: num_rows - 1}) 94 | 95 | if time_group == 'months': 96 | event_time -= relativedelta(days=event_time.day - 1) # (?) 97 | 98 | # COHORT 99 | 100 | for i in range(num_rows): 101 | # get results for each date interval from current time point for the row 102 | row = [] 103 | primary_event = fn_get_events(primary_event_name, event_time, system) 104 | 105 | primary_total = len(primary_event) 106 | primary_event_totals.append(primary_total) 107 | 108 | dates.append(event_time) 109 | 110 | if not primary_total: 111 | row = [None] * num_cols 112 | else: 113 | for j in range(num_cols): 114 | # get results for each event chain for current incremented time 115 | incremented = event_time + increment_delta(j) 116 | 117 | if incremented > now: 118 | # date in future; no events and no need to go through chain 119 | combined_total = None 120 | 121 | else: 122 | chained_events = chain_events(secondary_event_name, 123 | additional_events, 124 | incremented, time_group, system) 125 | 126 | if chained_events: 127 | combined_events = BitOpAnd(chained_events, primary_event) 128 | combined_total = len(combined_events) 129 | 130 | if not with_replacement: 131 | primary_event = BitOpXor(primary_event, combined_events) 132 | 133 | else: 134 | combined_total = 0 135 | 136 | row.append(combined_total) 137 | 138 | cohort.append(row) 139 | event_time += increment_delta(1) 140 | 141 | # Clean up results of BitOps 142 | delete_runtime_bitop_keys() 143 | 144 | return cohort, dates, primary_event_totals 145 | 146 | 147 | def chain_events(base_event_name, events_to_chain, now, time_group, 148 | system='default'): 149 | """ 150 | Chain additional events with a base set of events. 151 | 152 | Note: ``OR`` operators will apply only to their direct predecessors (i.e., 153 | ``A && B && C || D`` will be handled as ``A && B && (C || D)``, and 154 | ``A && B || C && D`` will be handled as ``A && (B || C) && D``). 155 | 156 | :param str base_event_name: Name of event to chain additional events to/with 157 | :param list events_to_chain: List of additional event names to chain 158 | (e.g., ``[{'name': 'user:logged_in', 159 | 'op': 'and'}]``) 160 | :param datetime now: Time point at which to get event data 161 | :param str time_group: Time scale by which to group results; can be `days`, 162 | `weeks`, `months`, `years` 163 | :param str system: Which bitmapist should be used 164 | :returns: Bitmapist events collection 165 | """ 166 | 167 | fn_get_events = _events_fn(time_group) 168 | base_event = fn_get_events(base_event_name, now, system) 169 | 170 | if not base_event.has_events_marked(): 171 | return '' 172 | 173 | if events_to_chain: 174 | chain_events = [] 175 | 176 | # for idx, event_to_chain in enumerate(events_to_chain): 177 | for event_to_chain in events_to_chain: 178 | event_name = event_to_chain.get('name') 179 | chain_event = fn_get_events(event_name, now, system) 180 | chain_events.append(chain_event) 181 | 182 | # Each OR should operate only on its immediate predecessor, e.g., 183 | # `A && B && C || D` should be handled as ~ `A && B && (C || D)`, 184 | # and 185 | # `A && B || C && D` should be handled as ~ `A && (B || C) && D`. 186 | op_or_indices = [idx for idx, e in enumerate(events_to_chain) if e['op'] == 'or'] 187 | 188 | # Work backwards; least impact on operator combos + list indexing 189 | for idx in reversed(op_or_indices): 190 | # If first of events to chain, OR will just operate on base event 191 | if idx > 0: 192 | prev_event = chain_events[idx - 1] 193 | or_event = chain_events.pop(idx) 194 | 195 | # OR events should not be re-chained below 196 | events_to_chain.pop(idx) 197 | 198 | chain_events[idx - 1] = BitOpOr(prev_event, or_event) 199 | 200 | for idx, name_and_op in enumerate(events_to_chain): 201 | if name_and_op.get('op') == 'or': 202 | base_event = BitOpOr(base_event, chain_events[idx]) 203 | else: 204 | base_event = BitOpAnd(base_event, chain_events[idx]) 205 | 206 | return base_event 207 | 208 | 209 | # PRIVATE methods: copied directly from Bitmapist because you can't import 210 | # from bitmapist.cohort without also having mako for the cohort __init__ 211 | 212 | def _dispatch(key, cls, cls_args): 213 | # ignoring CUSTOM_HANDLERS 214 | return cls(key, *cls_args) 215 | 216 | 217 | def _day_events_fn(key, date, system): 218 | cls = DayEvents 219 | cls_args = (date.year, date.month, date.day, system) 220 | return _dispatch(key, cls, cls_args) 221 | 222 | 223 | def _week_events_fn(key, date, system): 224 | cls = WeekEvents 225 | cls_args = (date.year, date.isocalendar()[1], system) 226 | return _dispatch(key, cls, cls_args) 227 | 228 | 229 | def _month_events_fn(key, date, system): 230 | cls = MonthEvents 231 | cls_args = (date.year, date.month, system) 232 | return _dispatch(key, cls, cls_args) 233 | 234 | 235 | def _year_events_fn(key, date, system): 236 | cls = YearEvents 237 | cls_args = (date.year, system) 238 | return _dispatch(key, cls, cls_args) 239 | -------------------------------------------------------------------------------- /flask_bitmapist/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | flask_bitmapist.views 4 | ~~~~~~~~~~~~~~~~~~~~~ 5 | This module provides the views for Flask-Bitmapist's web interface. 6 | 7 | :copyright: (c) 2016 by Cuttlesoft, LLC. 8 | :license: MIT, see LICENSE for more details. 9 | """ 10 | 11 | import json 12 | import os 13 | 14 | from datetime import datetime 15 | 16 | from flask import Blueprint, render_template, request 17 | 18 | from bitmapist import get_event_names 19 | 20 | from .utils import get_cohort, get_event_data 21 | 22 | 23 | root = os.path.abspath(os.path.dirname(__file__)) 24 | 25 | bitmapist_bp = Blueprint('bitmapist', 'flask_bitmapist', 26 | template_folder=os.path.join(root, 'templates'), 27 | static_folder=os.path.join(root, 'static'), 28 | url_prefix='/bitmapist') 29 | 30 | 31 | @bitmapist_bp.context_processor 32 | def inject_version(): 33 | from . import __version__ 34 | return dict(version=__version__) 35 | 36 | 37 | @bitmapist_bp.route('/') 38 | def index(): 39 | now = datetime.utcnow() 40 | 41 | day_events = len(list(get_event_data('user:logged_in', 'days', now))) 42 | week_events = len(list(get_event_data('user:logged_in', 'weeks', now))) 43 | month_events = len(list(get_event_data('user:logged_in', 'months', now))) 44 | year_events = len(list(get_event_data('user:logged_in', 'years', now))) 45 | return render_template('bitmapist/index.html', events=get_event_names(), 46 | day_events=day_events, week_events=week_events, 47 | month_events=month_events, year_events=year_events) 48 | 49 | 50 | @bitmapist_bp.route('/cohort', methods=['GET', 'POST']) 51 | def cohort(): 52 | if request.method == 'GET': 53 | event_names = get_event_names() 54 | # FOR DEMO PURPOSES: 55 | # Nicely format event names for dropdown selection; remove 'user:', 56 | # convert '_' to ' ', and prepend 'created/updated/deleted' with 'were' 57 | # for readability/grammar. 58 | event_options = [] 59 | for event_name in event_names: 60 | if 'user:' in event_name: 61 | formatted = event_name.replace('user:', '').replace('_', ' ') 62 | if formatted in ['created', 'updated', 'deleted']: 63 | formatted = 'were %s' % formatted 64 | event_options.append([formatted, event_name]) 65 | event_options = sorted(event_options) 66 | 67 | # FOR DEMO PURPOSES: list of totals per event 68 | now = datetime.utcnow() 69 | events = {} 70 | for event_name in event_names: 71 | # TODO: + hourly 72 | day = len(get_event_data(event_name, 'days', now)) 73 | week = len(get_event_data(event_name, 'weeks', now)) 74 | month = len(get_event_data(event_name, 'months', now)) 75 | year = len(get_event_data(event_name, 'years', now)) 76 | event = (year, month, week, day) 77 | events[event_name] = event 78 | 79 | time_groups = ['day', 'week', 'month', 'year'] # singular for display 80 | return render_template('bitmapist/cohort.html', 81 | event_options=event_options, 82 | time_groups=time_groups, 83 | events=events) 84 | 85 | elif request.method == 'POST': 86 | data = json.loads(request.data) 87 | 88 | # Cohort events 89 | primary_event = data.get('primary_event') 90 | secondary_event = data.get('secondary_event') 91 | additional_events = data.get('additional_events', []) 92 | # Cohort settings 93 | time_group = data.get('time_group', 'days') 94 | as_percent = data.get('as_percent', False) 95 | with_replacement = data.get('with_replacement', False) 96 | num_rows = int(data.get('num_rows', 20)) 97 | num_cols = int(data.get('num_cols', 10)) 98 | 99 | if time_group == 'years': 100 | # Three shall be the number thou shalt count, and the number of the 101 | # counting shall be three. Four shalt thou not count, neither count thou 102 | # two, excepting that thou then proceed to three. Five is right out. 103 | num_rows = 3 104 | 105 | # Columns > rows would extend into future and thus would just be empty 106 | num_cols = num_rows if num_cols > num_rows else num_cols 107 | 108 | # Get cohort data and associated dates 109 | cohort_data = get_cohort(primary_event, secondary_event, 110 | additional_events=additional_events, 111 | time_group=time_group, 112 | num_rows=num_rows, num_cols=num_cols, 113 | with_replacement=with_replacement) 114 | cohort, dates, row_totals = cohort_data 115 | 116 | # Format dates for table 117 | if time_group == 'years': 118 | dt_format = '%Y' 119 | elif time_group == 'months': 120 | dt_format = '%b %Y' 121 | elif time_group == 'weeks': 122 | dt_format = 'Week %U - %d %b %Y' 123 | else: 124 | dt_format = '%d %b %Y' 125 | 126 | date_strings = [dt.strftime(dt_format) for dt in dates] 127 | 128 | # Get overall total 129 | overall_total = sum(row_totals) 130 | 131 | # Get column totals (pre-percent) 132 | col_counts = [] 133 | col_totals = [] 134 | for j in range(num_cols): 135 | col = [row[j] for row in cohort if row[j] is not None] 136 | col_counts.append(len(col)) 137 | col_totals.append(sum(col)) 138 | 139 | # Get each cohort value as percents if as_percent 140 | if as_percent: 141 | for i, row in enumerate(cohort): 142 | if row_totals[i]: 143 | # calculate percent value for each (unless None) 144 | cohort[i] = [float(r) / row_totals[i] if r is not None else r for r in row] 145 | 146 | # Get column averages (post-percent) 147 | # TODO: do not loop range(num_cols) twice, but necessary as-is so that, 148 | # if getting results as percents, col totals are calculated with 149 | # numbers of users (always) but col averages are calculated with 150 | # percent values 151 | averages = [] 152 | for j in range(num_cols): 153 | col = [row[j] for row in cohort if row[j] is not None] 154 | average = float(sum(col)) / col_counts[j] if col_counts[j] else 0 155 | averages.append(average) 156 | 157 | # TODO: remove unnecessary keys from json return 158 | cohort_data = { 159 | 'cohort': cohort, 160 | 'dates': date_strings, 161 | 'total': overall_total, 162 | 'row_totals': row_totals, 163 | 'col_totals': col_totals, 164 | 'averages': averages, 165 | 'time_group': time_group, 166 | 'as_percent': as_percent, 167 | 'num_rows': num_rows, 168 | 'num_cols': num_cols 169 | } 170 | 171 | if request.args.get('json'): 172 | return json.dumps(cohort_data, indent=4) 173 | else: 174 | return render_template('bitmapist/_heatmap.html', **cohort_data) 175 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | alabaster==0.7.9 2 | apipkg==1.4 3 | Babel==2.3.4 4 | bitmapist==3.98 5 | blinker==1.4 6 | click==6.6 7 | coverage==4.2 8 | docutils==0.12 9 | execnet==1.4.1 10 | Flask==0.11.1 11 | Flask-Login==0.3.2 12 | Flask-SQLAlchemy==2.1 13 | future==0.15.2 14 | imagesize==0.7.1 15 | itsdangerous==0.24 16 | Jinja2==2.8 17 | MarkupSafe==0.23 18 | mock==2.0.0 19 | pep8==1.7.0 20 | py==1.4.31 21 | pyflakes==1.2.3 22 | Pygments==2.1.3 23 | pytest==2.9.2 24 | pytest-cache==1.0 25 | pytest-cov==2.2.1 26 | pytest-flakes==1.0.1 27 | pytest-pep8==1.0.6 28 | python-dateutil==2.5.3 29 | pytz==2016.6.1 30 | redis==2.10.5 31 | six==1.10.0 32 | snowballstemmer==1.2.1 33 | Sphinx==1.4.5 34 | SQLAlchemy==1.0.14 35 | Werkzeug==0.11.10 36 | -------------------------------------------------------------------------------- /scripts/release.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | make-release 5 | ~~~~~~~~~~~~ 6 | 7 | Helper script that performs a release. Does pretty much everything 8 | automatically for us. 9 | 10 | :copyright: (c) 2011 by Armin Ronacher. 11 | :license: BSD, see LICENSE for more details. 12 | """ 13 | import sys 14 | import os 15 | import re 16 | from datetime import datetime, date 17 | from subprocess import Popen, PIPE 18 | 19 | _date_clean_re = re.compile(r'(\d+)(st|nd|rd|th)') 20 | 21 | 22 | def installed_libraries(): 23 | return Popen(['pip', 'freeze'], stdout=PIPE).communicate()[0] 24 | 25 | 26 | def has_library_installed(library): 27 | return library + '==' in installed_libraries() 28 | 29 | 30 | def parse_changelog(): 31 | with open('CHANGES') as f: 32 | lineiter = iter(f) 33 | for line in lineiter: 34 | match = re.search('^Version\s+(.*)', line.strip()) 35 | 36 | if match is None: 37 | continue 38 | 39 | version = match.group(1).strip() 40 | 41 | if lineiter.next().count('-') != len(line.strip()): 42 | fail('Invalid hyphen count below version line: %s', line.strip()) 43 | 44 | while 1: 45 | released = lineiter.next().strip() 46 | if released: 47 | break 48 | 49 | match = re.search(r'Released (\w+\s+\d+\w+\s+\d+)', released) 50 | 51 | if match is None: 52 | fail('Could not find release date in version %s' % version) 53 | 54 | datestr = parse_date(match.group(1).strip()) 55 | 56 | return version, datestr 57 | 58 | 59 | def bump_version(version): 60 | try: 61 | parts = map(int, version.split('.')) 62 | except ValueError: 63 | fail('Current version is not numeric') 64 | parts[-1] += 1 65 | return '.'.join(map(str, parts)) 66 | 67 | 68 | def parse_date(string): 69 | string = _date_clean_re.sub(r'\1', string) 70 | return datetime.strptime(string, '%B %d %Y') 71 | 72 | 73 | def set_filename_version(filename, version_number, pattern): 74 | changed = [] 75 | 76 | def inject_version(match): 77 | before, old, after = match.groups() 78 | changed.append(True) 79 | return before + version_number + after 80 | 81 | with open(filename) as f: 82 | contents = re.sub(r"^(\s*%s\s*=\s*')(.+?)(')(?sm)" % pattern, 83 | inject_version, f.read()) 84 | 85 | if not changed: 86 | fail('Could not find %s in %s', pattern, filename) 87 | 88 | with open(filename, 'w') as f: 89 | f.write(contents) 90 | 91 | 92 | def set_init_version(version): 93 | info('Setting __init__.py version to %s', version) 94 | set_filename_version('flask_principal/__init__.py', version, '__version__') 95 | 96 | 97 | def set_setup_version(version): 98 | info('Setting setup.py version to %s', version) 99 | set_filename_version('setup.py', version, 'version') 100 | 101 | 102 | def set_docs_version(version): 103 | info('Setting docs/conf.py version to %s', version) 104 | set_filename_version('docs/conf.py', version, 'version') 105 | 106 | 107 | def build_and_upload(): 108 | Popen([sys.executable, 'setup.py', 'sdist', 'build_sphinx', 'upload', 'upload_sphinx']).wait() 109 | 110 | 111 | def fail(message, *args): 112 | print >> sys.stderr, 'Error:', message % args 113 | sys.exit(1) 114 | 115 | 116 | def info(message, *args): 117 | print >> sys.stderr, message % args 118 | 119 | 120 | def get_git_tags(): 121 | return set(Popen(['git', 'tag'], stdout=PIPE).communicate()[0].splitlines()) 122 | 123 | 124 | def git_is_clean(): 125 | return Popen(['git', 'diff', '--quiet']).wait() == 0 126 | 127 | 128 | def make_git_commit(message, *args): 129 | message = message % args 130 | Popen(['git', 'commit', '-am', message]).wait() 131 | 132 | 133 | def make_git_tag(tag): 134 | info('Tagging "%s"', tag) 135 | Popen(['git', 'tag', '-a', tag, '-m', '%s release' % tag]).wait() 136 | Popen(['git', 'push', '--tags']).wait() 137 | 138 | 139 | def update_version(version): 140 | for f in [set_init_version, set_setup_version, set_docs_version]: 141 | f(version) 142 | 143 | 144 | def get_branches(): 145 | return set(Popen(['git', 'branch'], stdout=PIPE).communicate()[0].splitlines()) 146 | 147 | 148 | def branch_is(branch): 149 | return '* ' + branch in get_branches() 150 | 151 | 152 | def main(): 153 | os.chdir(os.path.join(os.path.dirname(__file__), '..')) 154 | 155 | rv = parse_changelog() 156 | 157 | if rv is None: 158 | fail('Could not parse changelog') 159 | 160 | version, release_date = rv 161 | 162 | tags = get_git_tags() 163 | 164 | for lib in ['Sphinx', 'Sphinx-PyPI-upload']: 165 | if not has_library_installed(lib): 166 | fail('Build requires that %s be installed', lib) 167 | 168 | if version in tags: 169 | fail('Version "%s" is already tagged', version) 170 | if release_date.date() != date.today(): 171 | fail('Release date is not today') 172 | 173 | if not branch_is('master'): 174 | fail('You are not on the master branch') 175 | 176 | if not git_is_clean(): 177 | fail('You have uncommitted changes in git') 178 | 179 | info('Releasing %s (release date %s)', 180 | version, release_date.strftime('%d/%m/%Y')) 181 | 182 | update_version(version) 183 | make_git_commit('Bump version number to %s', version) 184 | make_git_tag(version) 185 | build_and_upload() 186 | 187 | 188 | if __name__ == '__main__': 189 | main() 190 | -------------------------------------------------------------------------------- /scripts/seed.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ Seed Redis database with ~10,000 entries. 4 | 5 | Entries consist of random user id between 0-99, user event, 6 | and timestamp between now and 3 years ago. 7 | 8 | Redis server must be running. 9 | """ 10 | 11 | import calendar 12 | from datetime import date, datetime, timedelta 13 | from random import choice, randint 14 | import time 15 | 16 | from bitmapist import mark_event 17 | 18 | 19 | now = datetime.utcnow() 20 | earliest = now - timedelta(days=(365 * 3)) 21 | 22 | 23 | class TimeTravelError(Exception): 24 | pass 25 | 26 | 27 | def mark_user_event(user_event, user_id, event_date): 28 | # to abstract out naming convention, easily prevent future events, and debug 29 | if event_date > now.date(): 30 | raise TimeTravelError('date has not yet come to pass (%s)' % event_date) 31 | 32 | event_name = 'user:%s' % user_event 33 | mark_event(event_name, user_id, now=event_date) 34 | # print '#%s - %s @ %s' % (user_id, event_name, event_date) 35 | 36 | 37 | def likelihood(n): 38 | return randint(1, 10) <= n 39 | 40 | 41 | def probably(): 42 | return likelihood(7) 43 | 44 | 45 | def maybe(): 46 | return likelihood(5) 47 | 48 | 49 | def probably_not(): 50 | return likelihood(3) 51 | 52 | 53 | def random_date(starting_date=None): 54 | starting_date = starting_date or earliest 55 | # ordinal dates are easier to pick random between 56 | start_date = starting_date.toordinal() 57 | end_date = now.toordinal() 58 | return date.fromordinal(randint(start_date, end_date)) 59 | 60 | 61 | def time_hop(event_date, minutes=None, days=None, months=None): 62 | # add random minutes/days/months up to the number of each provided # kwargs 63 | if minutes: 64 | event_date = event_date + timedelta(minutes=randint(1, minutes)) 65 | if days: 66 | event_date = event_date + timedelta(days=randint(1, days)) 67 | if months: 68 | days_for_months = randint(1, months) * 365 / 12 69 | event_date = event_date + timedelta(days=days_for_months) 70 | return event_date 71 | 72 | 73 | def hop_times(): 74 | hop_times = {'minutes': randint(0, 3000), 'days': randint(0, 30)} 75 | hop_times['months'] = randint(1, 3) if probably_not() else 0 76 | return hop_times 77 | 78 | 79 | def random_action(user_id, event_date): 80 | # TODO: super rudimentary... make less so? 81 | hop_date = time_hop(event_date, **hop_times()) 82 | 83 | door_number = randint(0, 8) 84 | 85 | if door_number <= 4: 86 | # simulate simple, single events 87 | simple_events = [ 88 | 'updated', 89 | 'updated_profile', 90 | 'reset_password', 91 | 'created_comment', 92 | 'uploaded_avatar' 93 | ] 94 | mark_user_event(simple_events[door_number], user_id, hop_date) 95 | 96 | elif door_number == 5: 97 | # simulate user potentially reading product reviews, adding product 98 | # to cart, and potentially purchasing product 99 | if maybe(): 100 | mark_user_event('product - read reviews', user_id, hop_date) 101 | hop_date = time_hop(hop_date, minutes=300) 102 | 103 | mark_user_event('product - added to cart', user_id, hop_date) 104 | 105 | if maybe(): 106 | hop_date = time_hop(hop_date, minutes=300) 107 | mark_user_event('product - purchased', user_id, hop_date) 108 | 109 | 110 | elif door_number == 6: 111 | # simulate user looking for answers to an issue they are having; 112 | # all, some, or none of these events might occur 113 | potential_events = ['searched_faq', 114 | 'submitted_bug_report', 115 | 'contacted_support'] 116 | hop_date = independent(user_id, hop_date, potential_events) 117 | 118 | elif door_number == 7: 119 | # simulate user progressing some distance through tracks with increasing 120 | # difficulty (sequence not necessarily completed) 121 | sequence_steps = ['completed_introductory_track', 122 | 'completed_beginner_track', 123 | 'completed_intermediate_track', 124 | 'completed_advanced_track', 125 | 'completed_expert_track'] 126 | hop_date = sequence(user_id, hop_date, sequence_steps) 127 | 128 | elif door_number == 8: 129 | # simulate user logging out and, later, logging in again 130 | sequence_steps = ['logged_out', 'logged_in'] 131 | hop_date = sequence(user_id, hop_date, sequence_steps, required=True) 132 | 133 | return hop_date 134 | 135 | 136 | # random action (6) 137 | def independent(user_id, action_date, potential_events, required=False): 138 | for event_name in potential_events: 139 | if required or probably(): 140 | sub_date = time_hop(action_date, minutes=300) 141 | mark_user_event(event_name, user_id, sub_date) 142 | return action_date 143 | 144 | 145 | # random action (7, 8) 146 | def sequence(user_id, action_date, sequence_steps, required=False): 147 | mark_user_event(sequence_steps[0], user_id, action_date) 148 | for step in sequence_steps[1:]: 149 | if required or maybe(): 150 | action_date = time_hop(action_date, **hop_times()) 151 | mark_user_event(step, user_id, action_date) 152 | else: 153 | return action_date 154 | return action_date 155 | 156 | 157 | # # for fun (completion-required sequence) 158 | # def stop_drop_and_roll(user_id, action_date): 159 | # sequence_steps = ['stopped', 'dropped', 'rolled'] 160 | # return sequence(user_id, action_date, sequence_steps, required=True) 161 | 162 | 163 | def create_user_from_campaign(user_id, event_date): 164 | campaigns = ['adwords', 'facebook', 'twitter'] 165 | campaign = 'signed_up_via_%s' % choice(campaigns) 166 | mark_user_event(campaign, user_id, event_date) 167 | 168 | 169 | def tell_user_story(user_id): 170 | event_date = random_date() 171 | 172 | try: 173 | # signed up 174 | mark_user_event('created', user_id, event_date) 175 | if maybe(): 176 | # probably signed up via ad campaign 177 | create_user_from_campaign(user_id, event_date) 178 | 179 | # logged in soon after 180 | event_date = time_hop(event_date, minutes=300) 181 | mark_user_event('logged_in', user_id, event_date) 182 | 183 | # took some number of actions over time 184 | for i in range(randint(10, 100)): 185 | event_date = random_action(user_id, event_date) 186 | 187 | # probably logged out 188 | if probably(): 189 | event_date = time_hop(event_date) 190 | mark_user_event('logged_out', user_id, event_date) 191 | 192 | # probably not - but maybe - deleted 193 | if likelihood(1): 194 | event_date = time_hop(event_date) 195 | mark_user_event('deleted', user_id, event_date) 196 | 197 | except TimeTravelError as e: 198 | # print 'TimeTravelError: %s' % e 199 | pass 200 | 201 | 202 | for i in range(1000): 203 | tell_user_story(i + 1) 204 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.rst 3 | 4 | [build_sphinx] 5 | source-dir = docs/ 6 | build-dir = docs/_build 7 | 8 | [upload_sphinx] 9 | upload-dir = docs/_build/html 10 | 11 | [pytest] 12 | pep8maxlinelength = 99 13 | 14 | pep8ignore = 15 | docs/* ALL 16 | scripts/* ALL 17 | 18 | flakes-ignore = 19 | ImportStarUsed 20 | flask_bitmapist/__init__.py UnusedImport 21 | docs/* ALL 22 | scripts/* ALL 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Flask-Bitmapist Setup 4 | ~~~~~~~~~~~~~~~~~~~~~ 5 | setup.py for Flask-Bitmapist 6 | 7 | :copyright: (c) 2016 by Cuttlesoft, LLC. 8 | :license: MIT, see LICENSE for more details. 9 | """ 10 | 11 | import ast 12 | import re 13 | import sys 14 | 15 | from setuptools import setup, find_packages 16 | from setuptools.command.test import test as TestCommand 17 | 18 | 19 | def get_requirements(suffix=''): 20 | with open('requirements%s.txt' % suffix) as f: 21 | rv = f.read().splitlines() 22 | return rv 23 | 24 | 25 | def get_version(): 26 | _version_re = re.compile(r'__version__\s+=\s+(.*)') 27 | 28 | with open('flask_bitmapist/__init__.py', 'rb') as f: 29 | version = str(ast.literal_eval(_version_re.search( 30 | f.read().decode('utf-8')).group(1))) 31 | 32 | return version 33 | 34 | 35 | class PyTest(TestCommand): 36 | 37 | def finalize_options(self): 38 | TestCommand.finalize_options(self) 39 | self.test_args = [ 40 | '-xrs', 41 | '--cov', 'flask_bitmapist', 42 | '--cov-report', 'term-missing', 43 | '--pep8', 44 | '--flakes', 45 | '--cache-clear' 46 | ] 47 | self.test_suite = True 48 | 49 | def run_tests(self): 50 | import pytest 51 | errno = pytest.main(self.test_args) 52 | sys.exit(errno) 53 | 54 | _version = get_version() 55 | 56 | 57 | setup( 58 | name='Flask-Bitmapist', 59 | version=_version, 60 | url='http://github.com/cuttlesoft/flask-bitmapist', 61 | download_url='https://github.com/cuttlesoft/flask-bitmapist/tarball/' + _version, 62 | license='MIT', 63 | author='Cuttlesoft, LLC', 64 | author_email='engineering@cuttlesoft.com', 65 | description='Flask extension that creates a simple interface to Bitmapist analytics library', 66 | long_description=open('README.rst').read() + '\n\n' + open('HISTORY.rst').read(), 67 | packages=find_packages(), 68 | keywords=['Flask', 'Bitmapist'], 69 | py_modules=['flask_bitmapist'], 70 | zip_safe=False, 71 | platforms='any', 72 | install_requires=[ 73 | 'Flask>=0.9', 74 | 'bitmapist>=3.97' 75 | ], 76 | tests_require=get_requirements('-test'), 77 | cmdclass={'test': PyTest}, 78 | classifiers=[ 79 | 'Development Status :: 3 - Alpha', 80 | 'Environment :: Web Environment', 81 | 'Intended Audience :: Developers', 82 | 'License :: OSI Approved :: MIT License', 83 | 'Operating System :: OS Independent', 84 | 'Programming Language :: Python :: 2', 85 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 86 | 'Topic :: Software Development :: Libraries :: Python Modules'] 87 | ) 88 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import pytest 4 | import redis 5 | import os 6 | 7 | from flask import Flask 8 | # from flask_login import LoginManager 9 | 10 | from flask_bitmapist import FlaskBitmapist 11 | from flask_bitmapist.mixins import Bitmapistable 12 | 13 | 14 | @pytest.fixture(scope='session') 15 | def app(request): 16 | app = Flask(__name__) 17 | app.debug = True 18 | app.config['TESTING'] = True 19 | # app.config['BITMAPIST_REDIS_URL'] = 'redis://localhost:6379' 20 | app.config['BITMAPIST_REDIS_URL'] = 'redis://localhost:6399' 21 | app.config['SECRET_KEY'] = 'secret' 22 | app.config['SECRET_KEY'] = 'verysecret' 23 | # login_manager = LoginManager() 24 | # login_manager.init_app(app) 25 | return app 26 | 27 | 28 | @pytest.fixture 29 | def config(app): 30 | return app.config 31 | 32 | 33 | @pytest.fixture 34 | def bitmap(request, app): 35 | bitmap = FlaskBitmapist(app) 36 | return bitmap 37 | 38 | 39 | @pytest.yield_fixture 40 | def client(app): 41 | """Flask test client. An instance of :class:`flask.testing.TestClient` by default.""" 42 | with app.test_client() as client: 43 | yield client 44 | 45 | 46 | @pytest.fixture 47 | def client_class(request, client): 48 | """Uses to set a ``client`` class attribute to current Flask test client:: 49 | @pytest.mark.usefixtures('client_class') 50 | class TestView: 51 | def login(self, email, password): 52 | credentials = {'email': email, 'password': password} 53 | return self.client.post(url_for('login'), data=credentials) 54 | def test_login(self): 55 | assert self.login('vital@example.com', 'pass').status_code == 200 56 | """ 57 | if request.cls is not None: 58 | request.cls.client = client 59 | 60 | 61 | @pytest.fixture 62 | def request_context(request, app): 63 | return app.test_request_context() 64 | 65 | 66 | # REDIS 67 | 68 | @pytest.fixture(scope='session', autouse=True) 69 | def setup_redis_for_bitmapist(): 70 | from bitmapist import SYSTEMS 71 | 72 | SYSTEMS['default'] = redis.Redis(host='localhost', port=6399) 73 | SYSTEMS['default_copy'] = redis.Redis(host='localhost', port=6399) 74 | 75 | 76 | @pytest.fixture(autouse=True) 77 | def clean_redis(): 78 | cli = redis.Redis(host='localhost', port=6399) 79 | keys = cli.keys('trackist_*') 80 | if len(keys) > 0: 81 | cli.delete(*keys) 82 | 83 | 84 | # SQLALCHEMY 85 | 86 | @pytest.fixture 87 | def sqlalchemy_db(app): 88 | from flask_sqlalchemy import SQLAlchemy 89 | 90 | db = SQLAlchemy(app) 91 | return db 92 | 93 | 94 | @pytest.fixture 95 | def sqlalchemy_user(sqlalchemy_db): 96 | db = sqlalchemy_db 97 | 98 | class User(db.Model, Bitmapistable): 99 | id = db.Column(db.Integer, primary_key=True) 100 | name = db.Column(db.String(50)) 101 | 102 | return User 103 | 104 | 105 | @pytest.fixture 106 | def sqlalchemy(app, request, sqlalchemy_db, sqlalchemy_user): 107 | db = sqlalchemy_db 108 | 109 | TESTS_PATH = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) 110 | TESTDB = 'test.sqlite' 111 | TESTDB_DIR = os.path.join(TESTS_PATH, 'tests/db') 112 | TESTDB_PATH = os.path.join(TESTDB_DIR, TESTDB) 113 | TESTDB_URI = 'sqlite:///' + TESTDB_PATH 114 | 115 | if not os.path.exists(TESTDB_DIR): 116 | os.makedirs(TESTDB_DIR) 117 | 118 | app.config['SQLALCHEMY_DATABASE_URI'] = TESTDB_URI 119 | # app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False 120 | 121 | def teardown(): 122 | db.drop_all() 123 | os.unlink(TESTDB_PATH) 124 | 125 | # if os.path.exists(TESTDB_PATH): 126 | # os.unlink(TESTDB_PATH) 127 | 128 | with app.test_request_context(): 129 | db.create_all() 130 | 131 | request.addfinalizer(teardown) 132 | # TODO: may return just db with tests using sqlalchemy_user fixture directly 133 | return db, sqlalchemy_user 134 | -------------------------------------------------------------------------------- /tests/test_extension.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from datetime import datetime, timedelta 4 | import mock 5 | from random import randint 6 | 7 | from flask import request 8 | from flask_login import LoginManager, UserMixin, current_user, login_user, logout_user 9 | 10 | from flask_bitmapist import (chain_events, get_cohort, get_event_data, 11 | mark, mark_event, unmark_event, 12 | MonthEvents, WeekEvents, DayEvents, HourEvents) 13 | from flask_bitmapist.extensions.flask_login import mark_login, mark_logout 14 | 15 | 16 | # import necessary for test_user_login/logout, but unused fails pyflakes 17 | mark_login 18 | mark_logout 19 | 20 | now = datetime.utcnow() 21 | 22 | 23 | # COHORTS 24 | 25 | def setup_users(now=None): 26 | now = now or datetime.utcnow() 27 | 28 | # mark specific events for single user with user_string as user_id 29 | event_mappings = { 30 | 'i': 'user:logged_in', 31 | 'o': 'user:logged_out', 32 | 'c': 'user:created', 33 | 'u': 'user:updated', 34 | 'd': 'user:deleted', 35 | } 36 | 37 | users = [ 38 | ('iocud', 11), 39 | ('iocd', 22), 40 | ('iou', 33), 41 | ('icd', 44), 42 | ('io', 55), 43 | ('ocud', 66), 44 | ('cud', 77), 45 | ('od', 88), 46 | ('d', 99) 47 | ] 48 | 49 | for user in users: 50 | for u in user[0]: 51 | event_name = event_mappings.get(u, None) 52 | if event_name: 53 | mark_event(event_name, user[1]) 54 | 55 | return users 56 | 57 | 58 | def setup_chain_events(time_group='days'): 59 | addons = { 60 | 'and_o': {'name': 'user:logged_out', 'op': 'and'}, 61 | 'and_c': {'name': 'user:created', 'op': 'and'}, 62 | 'and_u': {'name': 'user:updated', 'op': 'and'}, 63 | 'and_d': {'name': 'user:deleted', 'op': 'and'}, 64 | 'or_o': {'name': 'user:logged_out', 'op': 'or'}, 65 | 'or_c': {'name': 'user:created', 'op': 'or'}, 66 | 'or_u': {'name': 'user:updated', 'op': 'or'}, 67 | 'or_d': {'name': 'user:deleted', 'op': 'or'} 68 | } 69 | return setup_users(), addons 70 | 71 | 72 | # Test contents of cohort returns 73 | def test_get_cohort_contents(): 74 | # Generate events to ensure specific cohort returns 75 | 76 | # for cohort @days, m=4, n=4: 77 | # 6: [[5] [0] [0] [1] ] 78 | # 7: [[2] [1] [2] [None]] 79 | # 0: [[None][None][None][None]] 80 | # 5: [[4] [None][None][None]] 81 | # -- ---- ---- ---- ---- 82 | # T 18 11 1 2 1 83 | 84 | # ^^^ as_percent (not with replacement): 85 | # 6: [[83%] [0%] [0%] [17%] ] 86 | # 7: [[29%] [14%] [29%] [None]] 87 | # 0: [[None][None][None][None]] 88 | # 5: [[80%] [None][None][None]] 89 | # -- ---- ---- ---- ---- 90 | # T 18 11 1 2 1 91 | 92 | # ^^^ with_replacement (not as percent): 93 | # 6: [[5] [3] [0] [1] ] 94 | # 7: [[2] [2] [4] [None]] 95 | # 0: [[None][None][None][None]] 96 | # 5: [[4] [None][None][None]] 97 | # -- ---- ---- ---- ---- 98 | # T 18 11 5 4 1 99 | 100 | # TO BUILD: 18 users 101 | # [[5+0] [0+3] [0+0] [1+0] ] 102 | # [[2+0] [1+1] [2+2] [None]] 103 | # [[None][None][None][None]] 104 | # [[4+0] [None][None][None]] 105 | 106 | # construct events/history based on desired outcomes (see commented tables) 107 | 108 | event1 = 'user:popped' 109 | event2 = 'user:locked' 110 | 111 | dates = [now - timedelta(days=d) for d in range(4)] 112 | dates.reverse() 113 | 114 | # users = range(100, 118) 115 | users_event1 = [ 116 | range(100, 106), 117 | range(106, 113), 118 | [], 119 | range(113, 118) 120 | ] 121 | users_event2 = [ 122 | [[100, 101, 102, 103, 104], [100, 101, 102], [], [105]], 123 | [[106, 107], [107, 108], [106, 107, 109, 110], []], 124 | [[], [], [], []], 125 | [[113, 114, 115, 116], [], [], []] 126 | ] 127 | 128 | # mark constructed events 129 | for i, row_date in enumerate(dates): 130 | for user_id in users_event1[i]: 131 | mark_event(event1, user_id, now=row_date) 132 | 133 | for j in range(len(dates)): 134 | col_date = row_date + timedelta(days=j) 135 | for user_id in users_event2[i][j]: 136 | mark_event(event2, user_id, now=col_date) 137 | 138 | # establish expected cohort returns 139 | expected_c0 = [ 140 | [5, 0, 0, 1], 141 | [2, 1, 2, None], 142 | [None, None, None, None], 143 | [4, None, None, None] 144 | ] 145 | 146 | # expected_cp = [ 147 | # [0, 0, 0, 0], 148 | # [0, 0, 0, None], 149 | # [0, 0, None, None], 150 | # [0, None, None, None] 151 | # ] 152 | 153 | expected_cr = [ 154 | [5, 3, 0, 1], 155 | [2, 2, 4, None], 156 | [None, None, None, None], 157 | [4, None, None, None] 158 | ] 159 | 160 | expected_totals = [6, 7, 0, 5] 161 | 162 | # get cohort(s) 163 | c0, d0, t0 = get_cohort(event1, event2, time_group='days', num_rows=4, num_cols=4) 164 | # cp, dp, tp = get_cohort(event1, event2, time_group='days', num_rows=4, num_cols=4, 165 | # as_percent=True) 166 | cr, dr, tr = get_cohort(event1, event2, time_group='days', num_rows=4, num_cols=4, 167 | with_replacement=True) 168 | 169 | # assert that returned values match expected values 170 | assert c0 == expected_c0 171 | # assert cp == expected_cp 172 | assert cr == expected_cr 173 | 174 | assert t0 == expected_totals 175 | # assert tp == expected_totals 176 | assert tr == expected_totals 177 | 178 | 179 | # Test structure of cohort returns 180 | @mock.patch('flask_bitmapist.utils.BitOpAnd') 181 | @mock.patch('flask_bitmapist.utils.BitOpOr') 182 | @mock.patch('flask_bitmapist.utils.BitOpXor') 183 | @mock.patch('flask_bitmapist.utils.chain_events') 184 | @mock.patch('flask_bitmapist.utils.YearEvents') 185 | @mock.patch('flask_bitmapist.utils.MonthEvents') 186 | @mock.patch('flask_bitmapist.utils.WeekEvents') 187 | def test_get_cohort_structure(mock_week_events, mock_month_events, 188 | mock_year_events, mock_chain_events, 189 | mock_bit_op_xor, mock_bit_op_or, mock_bit_op_and): 190 | # Generate list of ints to act as user ids; 191 | # - between calls, should have some duplicate and some distinct 192 | # - temporarily convert to a set to force unique list items 193 | 194 | def e(): 195 | return list(set([randint(1, 25) for n in range(10)])) 196 | 197 | def ee(): 198 | return [e() for n in range(100)] 199 | 200 | mock_week_events.side_effect = ee() 201 | mock_month_events.side_effect = ee() 202 | mock_year_events.side_effect = ee() 203 | mock_chain_events.side_effect = ee() 204 | 205 | # Simulate BitOpAnd & BitOpOr returns but with lists 206 | mock_bit_op_and.side_effect = lambda x, y: list(set(x) & set(y)) 207 | mock_bit_op_or.side_effect = lambda x, y: list(set(x) | set(y)) 208 | mock_bit_op_xor.side_effect = lambda x, y: list(set(x) ^ set(y)) 209 | 210 | c1, d1, t1 = get_cohort('A', 'B', time_group='weeks', num_rows=4, num_cols=4) 211 | c2, d2, t2 = get_cohort('A', 'B', time_group='months', num_rows=6, num_cols=5) 212 | c3, d3, t3 = get_cohort('A', 'B', time_group='years', num_rows=2, num_cols=3) 213 | 214 | # Assert cohort (+ date and total) lengths based on num_rows 215 | assert len(c1) == 4 216 | assert len(c1) == len(d1) 217 | assert len(c1) == len(t1) 218 | assert len(c2) == 6 219 | assert len(c2) == len(d2) 220 | assert len(c2) == len(t2) 221 | assert len(c3) == 2 222 | assert len(c3) == len(d3) 223 | assert len(c3) == len(t3) 224 | # Assert cohort row lengths based on num_cols 225 | assert len(c1[0]) == 4 226 | assert len(c2[0]) == 5 227 | assert len(c3[0]) == 3 228 | 229 | # Assert date values based on time_group given 230 | # - dates are old->new, so use num_rows-1 to adjust index for timedelta 231 | 232 | def _week(x): 233 | return (x.year, x.month, x.day, x.isocalendar()[1]) 234 | 235 | def _month(x): 236 | return (x.year, x.month) 237 | 238 | def _year(x): 239 | return (x.year) 240 | 241 | # 1 - weeks 242 | for idx, d in enumerate(d1): 243 | assert _week(d) == _week(now - timedelta(weeks=3-idx)) 244 | # 2 - months 245 | for idx, d in enumerate(d2): 246 | this_month = now.replace(day=1) # work with first day of month 247 | months_ago = (5 - idx) * 365 / 12 # no 'months' arg for timedelta 248 | assert _month(d) == _month(this_month - timedelta(months_ago)) 249 | # 3 - years 250 | for idx, d in enumerate(d3): 251 | this_year = now.replace(month=1, day=1) # work with first day of year 252 | years_ago = (1 - idx) * 365 # no 'years' arg for timedelta 253 | assert _year(d) == _year(this_year - timedelta(years_ago)) 254 | 255 | 256 | def test_chain_events(): 257 | time_group = 'days' 258 | users, addons = setup_chain_events(time_group) 259 | 260 | # test results from no additional events 261 | logged_in_events = chain_events('user:logged_in', [], now, time_group) 262 | 263 | # which users should have 'user:logged_in' events marked 264 | logged_in_users = [u for u in users if 'i' in u[0]] # iocud/iocd/iou/icd/io 265 | 266 | assert len(logged_in_events) == len(logged_in_users) 267 | for u in logged_in_users: 268 | assert u[1] in get_event_data('user:logged_in', time_group, now) 269 | 270 | 271 | def test_chain_events_with_and(): 272 | base = 'user:logged_in' 273 | time_group = 'days' 274 | users, addons = setup_chain_events(time_group) 275 | 276 | # test results from 1-3 additional event with AND operator(s) 277 | one_event = [addons['and_o']] 278 | two_events = [addons['and_o'], addons['and_c']] 279 | three_events = [addons['and_o'], addons['and_c'], addons['and_d']] 280 | 281 | one = chain_events(base, one_event, now, time_group) 282 | two = chain_events(base, two_events, now, time_group) 283 | three = chain_events(base, three_events, now, time_group) 284 | 285 | # 'iocud', 'iocd', 'iou', 'icd', 'io', 'ocud', 'cud', 'od', 'd' 286 | one_users = [u for u in users if u[0] in ['iocud', 'iocd', 'iou', 'io']] 287 | two_users = [u for u in users if u[0] in ['iocud', 'iocd']] 288 | three_users = [u for u in users if u[0] in ['iocud', 'iocd']] 289 | 290 | # check lengths 291 | assert len(one) == len(one_users) 292 | assert len(two) == len(two_users) 293 | assert len(three) == len(three_users) 294 | 295 | # check contents 296 | for user in one_users: 297 | assert user[1] in one 298 | for user in two_users: 299 | assert user[1] in two 300 | for user in three_users: 301 | assert user[1] in three 302 | 303 | 304 | def test_chain_events_with_or(): 305 | base = 'user:logged_in' 306 | time_group = 'days' 307 | users, addons = setup_chain_events(time_group) 308 | 309 | # test results from 1-3 additional event with OR operator(s) 310 | one_event = [addons['or_o']] 311 | two_events = [addons['or_o'], addons['or_u']] 312 | three_events = [addons['or_o'], addons['or_u'], addons['or_d']] 313 | 314 | one = chain_events(base, one_event, now, time_group) 315 | two = chain_events(base, two_events, now, time_group) 316 | three = chain_events(base, three_events, now, time_group) 317 | 318 | # out of: 'iocud', 'iocd', 'iou', 'icd', 'io', 'ocud', 'cud', 'od', 'd' 319 | one_u = ['iocud', 'iocd', 'iou', 'icd', 'io', 'ocud', 'od'] 320 | two_u = ['iocud', 'iocd', 'iou', 'icd', 'io', 'ocud', 'cud', 'od'] 321 | three_u = ['iocud', 'iocd', 'iou', 'icd', 'io', 'ocud', 'cud', 'od', 'd'] 322 | 323 | one_users = [u for u in users if u[0] in one_u] 324 | two_users = [u for u in users if u[0] in two_u] 325 | three_users = [u for u in users if u[0] in three_u] 326 | 327 | # check lengths 328 | assert len(one) == len(one_users) 329 | assert len(two) == len(two_users) 330 | assert len(three) == len(three_users) 331 | 332 | # check contents 333 | for user in one_users: 334 | assert user[1] in one 335 | for user in two_users: 336 | assert user[1] in two 337 | for user in three_users: 338 | assert user[1] in three 339 | 340 | 341 | def test_chain_events_with_1_and_1_or(): 342 | base = 'user:logged_in' 343 | time_group = 'days' 344 | users, addons = setup_chain_events(time_group) 345 | 346 | # test results from 2 additional events with 1 'and' + 1 'or' operator(s) 347 | and_or_events = [addons['and_o'], addons['or_c']] 348 | or_and_events = [addons['or_o'], addons['and_c']] 349 | 350 | and_or = chain_events(base, and_or_events, now, time_group) 351 | or_and = chain_events(base, or_and_events, now, time_group) 352 | 353 | # out of: 'iocud', 'iocd', 'iou', 'icd', 'io', 'ocud', 'cud', 'od', 'd' 354 | and_or_u = ['iocud', 'iocd', 'iou', 'icd', 'io'] 355 | or_and_u = ['iocud', 'iocd', 'icd', 'ocud'] 356 | 357 | and_or_users = [u for u in users if u[0] in and_or_u] 358 | or_and_users = [u for u in users if u[0] in or_and_u] 359 | 360 | # check lengths 361 | assert len(and_or) == len(and_or_users) 362 | assert len(or_and) == len(or_and_users) 363 | 364 | # check contents 365 | for user in and_or_users: 366 | assert user[1] in and_or 367 | for user in or_and_users: 368 | assert user[1] in or_and 369 | 370 | 371 | def test_chain_events_with_2_and_1_or(): 372 | base = 'user:logged_in' 373 | time_group = 'days' 374 | users, addons = setup_chain_events(time_group) 375 | 376 | # test results from 3 additional events with 2 'and' + 1 'or' operator(s) 377 | and_and_or_events = [addons['and_o'], addons['and_c'], addons['or_d']] 378 | and_or_and_events = [addons['and_o'], addons['or_c'], addons['and_d']] 379 | or_and_and_events = [addons['or_o'], addons['and_c'], addons['and_d']] 380 | 381 | and_and_or = chain_events(base, and_and_or_events, now, time_group) 382 | and_or_and = chain_events(base, and_or_and_events, now, time_group) 383 | or_and_and = chain_events(base, or_and_and_events, now, time_group) 384 | 385 | # out of: 'iocud', 'iocd', 'iou', 'icd', 'io', 'ocud', 'cud', 'od', 'd' 386 | and_and_or_u = ['iocud', 'iocd'] 387 | and_or_and_u = ['iocud', 'iocd', 'icd'] 388 | or_and_and_u = ['iocud', 'iocd', 'icd', 'ocud'] 389 | 390 | and_and_or_users = [u for u in users if u[0] in and_and_or_u] 391 | and_or_and_users = [u for u in users if u[0] in and_or_and_u] 392 | or_and_and_users = [u for u in users if u[0] in or_and_and_u] 393 | 394 | # check lengths 395 | assert len(and_and_or) == len(and_and_or_users) 396 | assert len(and_or_and) == len(and_or_and_users) 397 | assert len(or_and_and) == len(or_and_and_users) 398 | 399 | # check contents 400 | for user in and_and_or_users: 401 | assert user[1] in and_and_or 402 | for user in and_or_and_users: 403 | assert user[1] in and_or_and 404 | for user in or_and_and_users: 405 | assert user[1] in or_and_and 406 | 407 | 408 | def test_chain_events_with_1_and_2_or(): 409 | base = 'user:logged_in' 410 | time_group = 'days' 411 | users, addons = setup_chain_events(time_group) 412 | 413 | # test results from 3 additional events with 1 'and' + 2 'or' operator(s) 414 | or_or_and_events = [addons['or_u'], addons['or_c'], addons['and_d']] 415 | or_and_or_events = [addons['or_u'], addons['and_c'], addons['or_d']] 416 | and_or_or_events = [addons['and_u'], addons['or_c'], addons['or_d']] 417 | 418 | or_or_and = chain_events(base, or_or_and_events, now, time_group) 419 | or_and_or = chain_events(base, or_and_or_events, now, time_group) 420 | and_or_or = chain_events(base, and_or_or_events, now, time_group) 421 | 422 | # out of: 'iocud', 'iocd', 'iou', 'icd', 'io', 'ocud', 'cud', 'od', 'd' 423 | or_or_and_u = ['iocud', 'iocd', 'icd', 'ocud', 'cud'] 424 | or_and_or_u = ['iocud', 'iocd', 'icd', 'ocud', 'cud'] 425 | and_or_or_u = ['iocud', 'iocd', 'iou', 'icd'] 426 | 427 | or_or_and_users = [u for u in users if u[0] in or_or_and_u] 428 | or_and_or_users = [u for u in users if u[0] in or_and_or_u] 429 | and_or_or_users = [u for u in users if u[0] in and_or_or_u] 430 | 431 | # check lengths 432 | assert len(or_or_and) == len(or_or_and_users) 433 | assert len(or_and_or) == len(or_and_or_users) 434 | assert len(and_or_or) == len(and_or_or_users) 435 | 436 | # check contents 437 | for user in or_or_and_users: 438 | assert user[1] in or_or_and 439 | for user in or_and_or_users: 440 | assert user[1] in or_and_or 441 | for user in and_or_or_users: 442 | assert user[1] in and_or_or 443 | 444 | 445 | # FLASK LOGIN 446 | 447 | class User(UserMixin): 448 | id = None 449 | 450 | 451 | def test_flask_login_user_login(app): 452 | # LoginManager could be set up in app fixture in conftest.py instead 453 | login_manager = LoginManager() 454 | login_manager.init_app(app) 455 | 456 | # TODO: once event is marked, user id exists in MonthEvents and test will 457 | # continue to pass, regardless of continued success; set to current 458 | # microsecond to temporarily circumvent, but there should be a better 459 | # way to fix user_id assignment (or tear down redis or something) 460 | user_id = datetime.now().microsecond 461 | 462 | with app.test_request_context(): 463 | # set up and log in user 464 | user = User() 465 | user.id = user_id 466 | login_user(user) 467 | 468 | # test that user was logged in 469 | assert current_user.is_active 470 | assert current_user.is_authenticated 471 | assert current_user == user 472 | 473 | # test that user id was marked with 'user:logged_in' event 474 | assert user_id in MonthEvents('user:logged_in', now.year, now.month) 475 | 476 | 477 | def test_flask_login_user_logout(app): 478 | login_manager = LoginManager() 479 | login_manager.init_app(app) 480 | 481 | user_id = datetime.now().microsecond 482 | 483 | with app.test_request_context(): 484 | # set up, log in, and log out user 485 | user = User() 486 | user.id = user_id 487 | login_user(user) 488 | logout_user() 489 | 490 | # test that user was logged out 491 | assert not current_user.is_active 492 | assert not current_user.is_authenticated 493 | assert not current_user == user 494 | 495 | # test that user id was marked with 'user:logged_out' event 496 | assert user_id in MonthEvents('user:logged_out', now.year, now.month) 497 | 498 | 499 | # SQLALCHEMY 500 | 501 | # TODO: Instead of sqlalchemy fixture (return: db, User), 502 | # each test could use sqlalchemy fixture (return: 503 | # db) and sqlalchemy_user fixture (return: User); 504 | # tests should use whichever is better practice. 505 | 506 | def test_sqlalchemy_after_insert(sqlalchemy): 507 | db, User = sqlalchemy 508 | 509 | with db.app.test_request_context(): 510 | # set up and save user 511 | user = User(name='Test User') 512 | db.session.add(user) 513 | db.session.commit() 514 | 515 | # test that user was saved 516 | assert user.id is not None 517 | 518 | # test that user id was marked with 'user:created' event 519 | assert user.id in MonthEvents('user:created', now.year, now.month) 520 | 521 | 522 | def test_sqlalchemy_before_update(sqlalchemy): 523 | db, User = sqlalchemy 524 | 525 | with db.app.test_request_context(): 526 | # set up and save user 527 | user = User(name='Test User') 528 | db.session.add(user) 529 | db.session.commit() 530 | 531 | # update user, and test that user is updated 532 | user.name = 'New Name' 533 | assert db.session.is_modified(user) 534 | 535 | db.session.add(user) 536 | db.session.commit() 537 | assert not db.session.is_modified(user) 538 | 539 | # test that user id was marked with 'user:updated' event 540 | assert user.id in MonthEvents('user:updated', now.year, now.month) 541 | 542 | 543 | def test_sqlalchemy_before_delete(sqlalchemy): 544 | db, User = sqlalchemy 545 | 546 | with db.app.test_request_context(): 547 | # set up and save user 548 | user = User(name='Test User') 549 | db.session.add(user) 550 | db.session.commit() 551 | 552 | # grab user id before we delete 553 | user_id = user.id 554 | 555 | # delete user, and test that user is deleted 556 | db.session.delete(user) 557 | db.session.commit() 558 | user_in_db = db.session.query(User).filter(User.id == user_id).first() 559 | assert not user_in_db 560 | 561 | # test that user id was marked with 'user:deleted' event 562 | assert user_id in MonthEvents('user:deleted', now.year, now.month) 563 | 564 | 565 | # GENERAL (redis, decorator, marking events, etc.) 566 | 567 | def test_redis_url_config(app, bitmap): 568 | assert bitmap.redis_url == app.config['BITMAPIST_REDIS_URL'] 569 | 570 | 571 | def test_redis_system_name_config(app, bitmap): 572 | assert 'default' in bitmap.SYSTEMS.keys() 573 | 574 | 575 | def test_track_hourly_config(app, bitmap): 576 | assert bitmap.TRACK_HOURLY is False 577 | 578 | 579 | def test_index(app, bitmap, client): 580 | with app.test_request_context('/bitmapist/'): 581 | assert request.endpoint == 'bitmapist.index' 582 | 583 | 584 | def test_cohort(app, bitmap, client): 585 | with app.test_request_context('/bitmapist/cohort'): 586 | assert request.endpoint == 'bitmapist.cohort' 587 | 588 | 589 | def test_mark_decorator(app, client): 590 | 591 | @app.route('/') 592 | @mark('test', 1) 593 | def index(): 594 | return '' 595 | 596 | @app.route('/hourly') 597 | @mark('test_hourly', 2, track_hourly=True) 598 | def index_hourly(): 599 | return '' 600 | 601 | client.get('/', follow_redirects=True) 602 | 603 | # month events 604 | assert 1 in MonthEvents('test', now.year, now.month) 605 | assert 2 not in MonthEvents('test', now.year, now.month) 606 | 607 | # week events 608 | assert 1 in WeekEvents('test', now.year, now.isocalendar()[1]) 609 | assert 2 not in WeekEvents('test', now.year, now.isocalendar()[1]) 610 | 611 | # day events 612 | assert 1 in DayEvents('test', now.year, now.month, now.day) 613 | assert 2 not in DayEvents('test', now.year, now.month, now.day) 614 | 615 | client.get('/hourly', follow_redirects=True) 616 | 617 | # hour events 618 | assert 1 not in HourEvents('test', now.year, now.month, now.day, now.hour) 619 | assert 2 in HourEvents('test_hourly', now.year, now.month, now.day, now.hour) 620 | assert 1 not in HourEvents('test_hourly', now.year, now.month, now.day, now.hour) 621 | assert 1 not in HourEvents('test_hourly', now.year, now.month, now.day, now.hour - 1) 622 | 623 | 624 | def test_mark_function(app, client): 625 | mark_event('active', 125) 626 | assert 125 in MonthEvents('active', now.year, now.month) 627 | 628 | 629 | def test_unmark_function(app, client): 630 | mark_event('active', 126) 631 | assert 126 in MonthEvents('active', now.year, now.month) 632 | 633 | unmark_event('active', 126) 634 | assert 126 not in MonthEvents('active', now.year, now.month) 635 | --------------------------------------------------------------------------------