├── .gitignore ├── .travis.yml ├── CONTRIBUTORS ├── LICENSE ├── MANIFEST.in ├── README.md ├── alembic.ini ├── alembic ├── README ├── env.py ├── script.py.mako └── versions │ ├── 07c46d368ba4_initial_empty_db.py │ ├── 0aedc36acb3f_upgrade_to_2_0_0.py │ ├── 799310dca712_increase_sql_path_column_length_to_128.py │ └── ca514840f404_increase_ip_info_size.py ├── docs ├── _themes │ ├── LICENSE │ ├── README │ ├── flask │ │ ├── layout.html │ │ ├── relations.html │ │ ├── static │ │ │ ├── flasky.css_t │ │ │ └── small_flask.css │ │ └── theme.conf │ ├── flask_small │ │ ├── layout.html │ │ ├── static │ │ │ └── flasky.css_t │ │ └── theme.conf │ └── flask_theme_support.py ├── conf.py ├── hooks.rst └── index.rst ├── requirements.txt ├── setup.py ├── src └── flask_track_usage │ ├── __init__.py │ ├── storage │ ├── __init__.py │ ├── couchdb.py │ ├── mongo.py │ ├── output.py │ ├── printer.py │ ├── redis_db.py │ └── sql.py │ └── summarization │ ├── __init__.py │ ├── mongoenginestorage.py │ └── sqlstorage.py └── test ├── __init__.py ├── test_blueprints_include_exclude.py ├── test_data.py ├── test_include_exclude.py ├── test_storage_mongo.py ├── test_storage_sqlalchemy.py ├── test_summarize_mongoengine.py └── test_summarize_sqlalchemy.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | /venv/ 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | *.env 38 | .cache 39 | .eggs 40 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.6" 5 | services: 6 | - mongodb 7 | - mysql 8 | before_script: 9 | - mysql -e 'create database track_usage_test;' 10 | - psql -c 'create database track_usage_test;' -U postgres 11 | - pip install coverage pep8 pymongo==3.5.1 sqlalchemy psycopg2 mysqlclient mongoengine 12 | install: 13 | - pip install -e . 14 | script: 15 | - pep8 --repeat src/ 16 | - nosetests --with-coverage --cover-package=flask_track_usage --cover-min-percentage=80 -v test/*.py 17 | notifications: 18 | email: false 19 | addons: 20 | postgresql: "9.5" 21 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | Arun Babu Neelicattu 2 | Gouthaman Balaraman 3 | ipinak 4 | jamylak 5 | John Dupuy 6 | Steve Milner 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Steve Milner 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | (1) Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | (2) Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in 13 | the documentation and/or other materials provided with the 14 | distribution. 15 | 16 | (3)The name of the author may not be used to 17 | endorse or promote products derived from this software without 18 | specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 21 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, 24 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 27 | HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 28 | STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING 29 | IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CONTRIBUTORS 2 | include MANIFEST.in 3 | include LICENSE 4 | include README.md 5 | recursive-include alembic * 6 | recursive-include test * 7 | recursive-include src * 8 | recursive-include docs * 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | flask-track-usage 2 | ================= 3 | 4 | [![Build Status](https://travis-ci.org/ashcrow/flask-track-usage.png?branch=master)](https://travis-ci.org/ashcrow/flask-track-usage) 5 | 6 | Basic metrics tracking for the Flask framework. This focuses more on ip addresses/locations rather than tracking specific users pathing through an application. No extra cookies or javascript is used for usage tracking. 7 | 8 | * [Read the latest docs](http://flask-track-usage.readthedocs.io/en/latest/) 9 | * [Read 2.0.0 docs](http://flask-track-usage.readthedocs.io/en/2.0.0/) 10 | * [Read 1.1.1 docs](http://flask-track-usage.readthedocs.io/en/1.1.1/) 11 | 12 | Similar Projects 13 | ---------------- 14 | * https://github.com/srounet/Flask-Analytics 15 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | #truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to alembic/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat alembic/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | sqlalchemy.url = driver://user:pass@localhost/dbname 39 | 40 | # Logging configuration 41 | [loggers] 42 | keys = root,sqlalchemy,alembic 43 | 44 | [handlers] 45 | keys = console 46 | 47 | [formatters] 48 | keys = generic 49 | 50 | [logger_root] 51 | level = WARN 52 | handlers = console 53 | qualname = 54 | 55 | [logger_sqlalchemy] 56 | level = WARN 57 | handlers = 58 | qualname = sqlalchemy.engine 59 | 60 | [logger_alembic] 61 | level = INFO 62 | handlers = 63 | qualname = alembic 64 | 65 | [handler_console] 66 | class = StreamHandler 67 | args = (sys.stderr,) 68 | level = NOTSET 69 | formatter = generic 70 | 71 | [formatter_generic] 72 | format = %(levelname)-5.5s [%(name)s] %(message)s 73 | datefmt = %H:%M:%S 74 | -------------------------------------------------------------------------------- /alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /alembic/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | from logging.config import fileConfig 5 | 6 | # this is the Alembic Config object, which provides 7 | # access to the values within the .ini file in use. 8 | config = context.config 9 | 10 | # Interpret the config file for Python logging. 11 | # This line sets up loggers basically. 12 | fileConfig(config.config_file_name) 13 | 14 | # add your model's MetaData object here 15 | # for 'autogenerate' support 16 | # from myapp import mymodel 17 | # target_metadata = mymodel.Base.metadata 18 | target_metadata = None 19 | 20 | # other values from the config, defined by the needs of env.py, 21 | # can be acquired: 22 | # my_important_option = config.get_main_option("my_important_option") 23 | # ... etc. 24 | 25 | 26 | def run_migrations_offline(): 27 | """Run migrations in 'offline' mode. 28 | 29 | This configures the context with just a URL 30 | and not an Engine, though an Engine is acceptable 31 | here as well. By skipping the Engine creation 32 | we don't even need a DBAPI to be available. 33 | 34 | Calls to context.execute() here emit the given string to the 35 | script output. 36 | 37 | """ 38 | url = config.get_main_option("sqlalchemy.url") 39 | context.configure( 40 | url=url, target_metadata=target_metadata, literal_binds=True) 41 | 42 | with context.begin_transaction(): 43 | context.run_migrations() 44 | 45 | 46 | def run_migrations_online(): 47 | """Run migrations in 'online' mode. 48 | 49 | In this scenario we need to create an Engine 50 | and associate a connection with the context. 51 | 52 | """ 53 | connectable = engine_from_config( 54 | config.get_section(config.config_ini_section), 55 | prefix='sqlalchemy.', 56 | poolclass=pool.NullPool) 57 | 58 | with connectable.connect() as connection: 59 | context.configure( 60 | connection=connection, 61 | target_metadata=target_metadata 62 | ) 63 | 64 | with context.begin_transaction(): 65 | context.run_migrations() 66 | 67 | if context.is_offline_mode(): 68 | run_migrations_offline() 69 | else: 70 | run_migrations_online() 71 | -------------------------------------------------------------------------------- /alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /alembic/versions/07c46d368ba4_initial_empty_db.py: -------------------------------------------------------------------------------- 1 | """Initial empty db 2 | 3 | Revision ID: 07c46d368ba4 4 | Revises: 5 | Create Date: 2018-04-25 09:39:19.043662 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '07c46d368ba4' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | pass 21 | 22 | 23 | def downgrade(): 24 | pass 25 | -------------------------------------------------------------------------------- /alembic/versions/0aedc36acb3f_upgrade_to_2_0_0.py: -------------------------------------------------------------------------------- 1 | """Upgrade to 2.0.0 2 | 3 | Revision ID: 0aedc36acb3f 4 | Revises: 07c46d368ba4 5 | Create Date: 2018-04-25 09:39:38.879327 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '0aedc36acb3f' 14 | down_revision = '07c46d368ba4' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.add_column('flask_usage', sa.Column('track_var', sa.String(128), nullable=True)) 21 | op.add_column('flask_usage', sa.Column('username', sa.String(128), nullable=True)) 22 | 23 | def downgrade(): 24 | op.drop_column('flask_usage', 'track_var') 25 | op.drop_column('flask_usage', 'username') 26 | -------------------------------------------------------------------------------- /alembic/versions/799310dca712_increase_sql_path_column_length_to_128.py: -------------------------------------------------------------------------------- 1 | """Increase sql path column length to 128 2 | 3 | Revision ID: 799310dca712 4 | Revises: ca514840f404 5 | Create Date: 2020-04-09 11:34:05.456439 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '799310dca712' 14 | down_revision = 'ca514840f404' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | 20 | def upgrade(): 21 | op.alter_column('flask_usage', 'path', type_=sa.String(128), existing_type=sa.String(length=32)) 22 | 23 | 24 | def downgrade(): 25 | op.alter_column('flask_usage', 'path', type_=sa.String(32), existing_type=sa.String(length=128)) 26 | -------------------------------------------------------------------------------- /alembic/versions/ca514840f404_increase_ip_info_size.py: -------------------------------------------------------------------------------- 1 | """Increase ip_info size 2 | 3 | Revision ID: ca514840f404 4 | Revises: 0aedc36acb3f 5 | Create Date: 2018-05-29 11:15:09.515284 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'ca514840f404' 14 | down_revision = '0aedc36acb3f' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.alter_column('flask_usage', 'ip_info', type_=sa.String(1024), existing_type=sa.String(length=128)) 21 | 22 | 23 | def downgrade(): 24 | op.alter_column('flask_usage', 'ip_info', type_=sa.String(128), existing_type=sa.String(length=1024)) 25 | -------------------------------------------------------------------------------- /docs/_themes/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 by Armin Ronacher. 2 | 3 | Some rights reserved. 4 | 5 | Redistribution and use in source and binary forms of the theme, with or 6 | without modification, are permitted provided that the following conditions 7 | are met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above 13 | copyright notice, this list of conditions and the following 14 | disclaimer in the documentation and/or other materials provided 15 | with the distribution. 16 | 17 | * The names of the contributors may not be used to endorse or 18 | promote products derived from this software without specific 19 | prior written permission. 20 | 21 | We kindly ask you to only use these themes in an unmodified manner just 22 | for Flask and Flask-related products, not for unrelated projects. If you 23 | like the visual style and want to use it for your own projects, please 24 | consider making some larger changes to the themes (such as changing 25 | font faces, sizes, colors or margins). 26 | 27 | THIS THEME IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 28 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 29 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 30 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 31 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 32 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 33 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 34 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 35 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 36 | ARISING IN ANY WAY OUT OF THE USE OF THIS THEME, EVEN IF ADVISED OF THE 37 | POSSIBILITY OF SUCH DAMAGE. 38 | -------------------------------------------------------------------------------- /docs/_themes/README: -------------------------------------------------------------------------------- 1 | Flask Sphinx Styles 2 | =================== 3 | 4 | This repository contains sphinx styles for Flask and Flask related 5 | projects. To use this style in your Sphinx documentation, follow 6 | this guide: 7 | 8 | 1. put this folder as _themes into your docs folder. Alternatively 9 | you can also use git submodules to check out the contents there. 10 | 2. add this to your conf.py: 11 | 12 | sys.path.append(os.path.abspath('_themes')) 13 | html_theme_path = ['_themes'] 14 | html_theme = 'flask' 15 | 16 | The following themes exist: 17 | 18 | - 'flask' - the standard flask documentation theme for large 19 | projects 20 | - 'flask_small' - small one-page theme. Intended to be used by 21 | very small addon libraries for flask. 22 | 23 | The following options exist for the flask_small theme: 24 | 25 | [options] 26 | index_logo = '' filename of a picture in _static 27 | to be used as replacement for the 28 | h1 in the index.rst file. 29 | index_logo_height = 120px height of the index logo 30 | github_fork = '' repository name on github for the 31 | "fork me" badge 32 | -------------------------------------------------------------------------------- /docs/_themes/flask/layout.html: -------------------------------------------------------------------------------- 1 | {%- extends "basic/layout.html" %} 2 | {%- block extrahead %} 3 | {{ super() }} 4 | {% if theme_touch_icon %} 5 | 6 | {% endif %} 7 | 9 | {% endblock %} 10 | {%- block relbar2 %}{% endblock %} 11 | {% block header %} 12 | {{ super() }} 13 | {% if pagename == 'index' %} 14 |
15 | {% endif %} 16 | {% endblock %} 17 | {%- block footer %} 18 | 22 | {% if pagename == 'index' %} 23 |
24 | {% endif %} 25 | {%- endblock %} 26 | -------------------------------------------------------------------------------- /docs/_themes/flask/relations.html: -------------------------------------------------------------------------------- 1 |

Related Topics

2 | 20 | -------------------------------------------------------------------------------- /docs/_themes/flask/static/flasky.css_t: -------------------------------------------------------------------------------- 1 | /* 2 | * flasky.css_t 3 | * ~~~~~~~~~~~~ 4 | * 5 | * :copyright: Copyright 2010 by Armin Ronacher. 6 | * :license: Flask Design License, see LICENSE for details. 7 | */ 8 | 9 | {% set page_width = '940px' %} 10 | {% set sidebar_width = '220px' %} 11 | 12 | @import url("basic.css"); 13 | 14 | /* -- page layout ----------------------------------------------------------- */ 15 | 16 | body { 17 | font-family: 'Georgia', serif; 18 | font-size: 17px; 19 | background-color: white; 20 | color: #000; 21 | margin: 0; 22 | padding: 0; 23 | } 24 | 25 | div.document { 26 | width: {{ page_width }}; 27 | margin: 30px auto 0 auto; 28 | } 29 | 30 | div.documentwrapper { 31 | float: left; 32 | width: 100%; 33 | } 34 | 35 | div.bodywrapper { 36 | margin: 0 0 0 {{ sidebar_width }}; 37 | } 38 | 39 | div.sphinxsidebar { 40 | width: {{ sidebar_width }}; 41 | } 42 | 43 | hr { 44 | border: 1px solid #B1B4B6; 45 | } 46 | 47 | div.body { 48 | background-color: #ffffff; 49 | color: #3E4349; 50 | padding: 0 30px 0 30px; 51 | } 52 | 53 | img.floatingflask { 54 | padding: 0 0 10px 10px; 55 | float: right; 56 | } 57 | 58 | div.footer { 59 | width: {{ page_width }}; 60 | margin: 20px auto 30px auto; 61 | font-size: 14px; 62 | color: #888; 63 | text-align: right; 64 | } 65 | 66 | div.footer a { 67 | color: #888; 68 | } 69 | 70 | div.related { 71 | display: none; 72 | } 73 | 74 | div.sphinxsidebar a { 75 | color: #444; 76 | text-decoration: none; 77 | border-bottom: 1px dotted #999; 78 | } 79 | 80 | div.sphinxsidebar a:hover { 81 | border-bottom: 1px solid #999; 82 | } 83 | 84 | div.sphinxsidebar { 85 | font-size: 14px; 86 | line-height: 1.5; 87 | } 88 | 89 | div.sphinxsidebarwrapper { 90 | padding: 18px 10px; 91 | } 92 | 93 | div.sphinxsidebarwrapper p.logo { 94 | padding: 0 0 20px 0; 95 | margin: 0; 96 | text-align: center; 97 | } 98 | 99 | div.sphinxsidebar h3, 100 | div.sphinxsidebar h4 { 101 | font-family: 'Garamond', 'Georgia', serif; 102 | color: #444; 103 | font-size: 24px; 104 | font-weight: normal; 105 | margin: 0 0 5px 0; 106 | padding: 0; 107 | } 108 | 109 | div.sphinxsidebar h4 { 110 | font-size: 20px; 111 | } 112 | 113 | div.sphinxsidebar h3 a { 114 | color: #444; 115 | } 116 | 117 | div.sphinxsidebar p.logo a, 118 | div.sphinxsidebar h3 a, 119 | div.sphinxsidebar p.logo a:hover, 120 | div.sphinxsidebar h3 a:hover { 121 | border: none; 122 | } 123 | 124 | div.sphinxsidebar p { 125 | color: #555; 126 | margin: 10px 0; 127 | } 128 | 129 | div.sphinxsidebar ul { 130 | margin: 10px 0; 131 | padding: 0; 132 | color: #000; 133 | } 134 | 135 | div.sphinxsidebar input { 136 | border: 1px solid #ccc; 137 | font-family: 'Georgia', serif; 138 | font-size: 1em; 139 | } 140 | 141 | /* -- body styles ----------------------------------------------------------- */ 142 | 143 | a { 144 | color: #004B6B; 145 | text-decoration: underline; 146 | } 147 | 148 | a:hover { 149 | color: #6D4100; 150 | text-decoration: underline; 151 | } 152 | 153 | div.body h1, 154 | div.body h2, 155 | div.body h3, 156 | div.body h4, 157 | div.body h5, 158 | div.body h6 { 159 | font-family: 'Garamond', 'Georgia', serif; 160 | font-weight: normal; 161 | margin: 30px 0px 10px 0px; 162 | padding: 0; 163 | } 164 | 165 | {% if theme_index_logo %} 166 | div.indexwrapper h1 { 167 | text-indent: -999999px; 168 | background: url({{ theme_index_logo }}) no-repeat center center; 169 | height: {{ theme_index_logo_height }}; 170 | } 171 | {% endif %} 172 | 173 | div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; } 174 | div.body h2 { font-size: 180%; } 175 | div.body h3 { font-size: 150%; } 176 | div.body h4 { font-size: 130%; } 177 | div.body h5 { font-size: 100%; } 178 | div.body h6 { font-size: 100%; } 179 | 180 | a.headerlink { 181 | color: #ddd; 182 | padding: 0 4px; 183 | text-decoration: none; 184 | } 185 | 186 | a.headerlink:hover { 187 | color: #444; 188 | background: #eaeaea; 189 | } 190 | 191 | div.body p, div.body dd, div.body li { 192 | line-height: 1.4em; 193 | } 194 | 195 | div.admonition { 196 | background: #fafafa; 197 | margin: 20px -30px; 198 | padding: 10px 30px; 199 | border-top: 1px solid #ccc; 200 | border-bottom: 1px solid #ccc; 201 | } 202 | 203 | div.admonition tt.xref, div.admonition a tt { 204 | border-bottom: 1px solid #fafafa; 205 | } 206 | 207 | dd div.admonition { 208 | margin-left: -60px; 209 | padding-left: 60px; 210 | } 211 | 212 | div.admonition p.admonition-title { 213 | font-family: 'Garamond', 'Georgia', serif; 214 | font-weight: normal; 215 | font-size: 24px; 216 | margin: 0 0 10px 0; 217 | padding: 0; 218 | line-height: 1; 219 | } 220 | 221 | div.admonition p.last { 222 | margin-bottom: 0; 223 | } 224 | 225 | div.highlight { 226 | background-color: white; 227 | } 228 | 229 | dt:target, .highlight { 230 | background: #FAF3E8; 231 | } 232 | 233 | div.note { 234 | background-color: #eee; 235 | border: 1px solid #ccc; 236 | } 237 | 238 | div.seealso { 239 | background-color: #ffc; 240 | border: 1px solid #ff6; 241 | } 242 | 243 | div.topic { 244 | background-color: #eee; 245 | } 246 | 247 | p.admonition-title { 248 | display: inline; 249 | } 250 | 251 | p.admonition-title:after { 252 | content: ":"; 253 | } 254 | 255 | pre, tt { 256 | font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; 257 | font-size: 0.9em; 258 | } 259 | 260 | img.screenshot { 261 | } 262 | 263 | tt.descname, tt.descclassname { 264 | font-size: 0.95em; 265 | } 266 | 267 | tt.descname { 268 | padding-right: 0.08em; 269 | } 270 | 271 | img.screenshot { 272 | -moz-box-shadow: 2px 2px 4px #eee; 273 | -webkit-box-shadow: 2px 2px 4px #eee; 274 | box-shadow: 2px 2px 4px #eee; 275 | } 276 | 277 | table.docutils { 278 | border: 1px solid #888; 279 | -moz-box-shadow: 2px 2px 4px #eee; 280 | -webkit-box-shadow: 2px 2px 4px #eee; 281 | box-shadow: 2px 2px 4px #eee; 282 | } 283 | 284 | table.docutils td, table.docutils th { 285 | border: 1px solid #888; 286 | padding: 0.25em 0.7em; 287 | } 288 | 289 | table.field-list, table.footnote { 290 | border: none; 291 | -moz-box-shadow: none; 292 | -webkit-box-shadow: none; 293 | box-shadow: none; 294 | } 295 | 296 | table.footnote { 297 | margin: 15px 0; 298 | width: 100%; 299 | border: 1px solid #eee; 300 | background: #fdfdfd; 301 | font-size: 0.9em; 302 | } 303 | 304 | table.footnote + table.footnote { 305 | margin-top: -15px; 306 | border-top: none; 307 | } 308 | 309 | table.field-list th { 310 | padding: 0 0.8em 0 0; 311 | } 312 | 313 | table.field-list td { 314 | padding: 0; 315 | } 316 | 317 | table.footnote td.label { 318 | width: 0px; 319 | padding: 0.3em 0 0.3em 0.5em; 320 | } 321 | 322 | table.footnote td { 323 | padding: 0.3em 0.5em; 324 | } 325 | 326 | dl { 327 | margin: 0; 328 | padding: 0; 329 | } 330 | 331 | dl dd { 332 | margin-left: 30px; 333 | } 334 | 335 | blockquote { 336 | margin: 0 0 0 30px; 337 | padding: 0; 338 | } 339 | 340 | ul, ol { 341 | margin: 10px 0 10px 30px; 342 | padding: 0; 343 | } 344 | 345 | pre { 346 | background: #eee; 347 | padding: 7px 30px; 348 | margin: 15px -30px; 349 | line-height: 1.3em; 350 | } 351 | 352 | dl pre, blockquote pre, li pre { 353 | margin-left: -60px; 354 | padding-left: 60px; 355 | } 356 | 357 | dl dl pre { 358 | margin-left: -90px; 359 | padding-left: 90px; 360 | } 361 | 362 | tt { 363 | background-color: #ecf0f3; 364 | color: #222; 365 | /* padding: 1px 2px; */ 366 | } 367 | 368 | tt.xref, a tt { 369 | background-color: #FBFBFB; 370 | border-bottom: 1px solid white; 371 | } 372 | 373 | a.reference { 374 | text-decoration: none; 375 | border-bottom: 1px dotted #004B6B; 376 | } 377 | 378 | a.reference:hover { 379 | border-bottom: 1px solid #6D4100; 380 | } 381 | 382 | a.footnote-reference { 383 | text-decoration: none; 384 | font-size: 0.7em; 385 | vertical-align: top; 386 | border-bottom: 1px dotted #004B6B; 387 | } 388 | 389 | a.footnote-reference:hover { 390 | border-bottom: 1px solid #6D4100; 391 | } 392 | 393 | a:hover tt { 394 | background: #EEE; 395 | } 396 | -------------------------------------------------------------------------------- /docs/_themes/flask/static/small_flask.css: -------------------------------------------------------------------------------- 1 | /* 2 | * small_flask.css_t 3 | * ~~~~~~~~~~~~~~~~~ 4 | * 5 | * :copyright: Copyright 2010 by Armin Ronacher. 6 | * :license: Flask Design License, see LICENSE for details. 7 | */ 8 | 9 | body { 10 | margin: 0; 11 | padding: 20px 30px; 12 | } 13 | 14 | div.documentwrapper { 15 | float: none; 16 | background: white; 17 | } 18 | 19 | div.sphinxsidebar { 20 | display: block; 21 | float: none; 22 | width: 102.5%; 23 | margin: 50px -30px -20px -30px; 24 | padding: 10px 20px; 25 | background: #333; 26 | color: white; 27 | } 28 | 29 | div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p, 30 | div.sphinxsidebar h3 a { 31 | color: white; 32 | } 33 | 34 | div.sphinxsidebar a { 35 | color: #aaa; 36 | } 37 | 38 | div.sphinxsidebar p.logo { 39 | display: none; 40 | } 41 | 42 | div.document { 43 | width: 100%; 44 | margin: 0; 45 | } 46 | 47 | div.related { 48 | display: block; 49 | margin: 0; 50 | padding: 10px 0 20px 0; 51 | } 52 | 53 | div.related ul, 54 | div.related ul li { 55 | margin: 0; 56 | padding: 0; 57 | } 58 | 59 | div.footer { 60 | display: none; 61 | } 62 | 63 | div.bodywrapper { 64 | margin: 0; 65 | } 66 | 67 | div.body { 68 | min-height: 0; 69 | padding: 0; 70 | } 71 | -------------------------------------------------------------------------------- /docs/_themes/flask/theme.conf: -------------------------------------------------------------------------------- 1 | [theme] 2 | inherit = basic 3 | stylesheet = flasky.css 4 | pygments_style = flask_theme_support.FlaskyStyle 5 | 6 | [options] 7 | index_logo = '' 8 | index_logo_height = 120px 9 | touch_icon = 10 | -------------------------------------------------------------------------------- /docs/_themes/flask_small/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "basic/layout.html" %} 2 | {% block header %} 3 | {{ super() }} 4 | {% if pagename == 'index' %} 5 |
6 | {% endif %} 7 | {% endblock %} 8 | {% block footer %} 9 | {% if pagename == 'index' %} 10 |
11 | {% endif %} 12 | {% endblock %} 13 | {# do not display relbars #} 14 | {% block relbar1 %}{% endblock %} 15 | {% block relbar2 %} 16 | {% if theme_github_fork %} 17 | Fork me on GitHub 19 | {% endif %} 20 | {% endblock %} 21 | {% block sidebar1 %}{% endblock %} 22 | {% block sidebar2 %}{% endblock %} 23 | -------------------------------------------------------------------------------- /docs/_themes/flask_small/static/flasky.css_t: -------------------------------------------------------------------------------- 1 | /* 2 | * flasky.css_t 3 | * ~~~~~~~~~~~~ 4 | * 5 | * Sphinx stylesheet -- flasky theme based on nature theme. 6 | * 7 | * :copyright: Copyright 2007-2010 by the Sphinx team, see AUTHORS. 8 | * :license: BSD, see LICENSE for details. 9 | * 10 | */ 11 | 12 | @import url("basic.css"); 13 | 14 | /* -- page layout ----------------------------------------------------------- */ 15 | 16 | body { 17 | font-family: 'Georgia', serif; 18 | font-size: 17px; 19 | color: #000; 20 | background: white; 21 | margin: 0; 22 | padding: 0; 23 | } 24 | 25 | div.documentwrapper { 26 | float: left; 27 | width: 100%; 28 | } 29 | 30 | div.bodywrapper { 31 | margin: 40px auto 0 auto; 32 | width: 700px; 33 | } 34 | 35 | hr { 36 | border: 1px solid #B1B4B6; 37 | } 38 | 39 | div.body { 40 | background-color: #ffffff; 41 | color: #3E4349; 42 | padding: 0 30px 30px 30px; 43 | } 44 | 45 | img.floatingflask { 46 | padding: 0 0 10px 10px; 47 | float: right; 48 | } 49 | 50 | div.footer { 51 | text-align: right; 52 | color: #888; 53 | padding: 10px; 54 | font-size: 14px; 55 | width: 650px; 56 | margin: 0 auto 40px auto; 57 | } 58 | 59 | div.footer a { 60 | color: #888; 61 | text-decoration: underline; 62 | } 63 | 64 | div.related { 65 | line-height: 32px; 66 | color: #888; 67 | } 68 | 69 | div.related ul { 70 | padding: 0 0 0 10px; 71 | } 72 | 73 | div.related a { 74 | color: #444; 75 | } 76 | 77 | /* -- body styles ----------------------------------------------------------- */ 78 | 79 | a { 80 | color: #004B6B; 81 | text-decoration: underline; 82 | } 83 | 84 | a:hover { 85 | color: #6D4100; 86 | text-decoration: underline; 87 | } 88 | 89 | div.body { 90 | padding-bottom: 40px; /* saved for footer */ 91 | } 92 | 93 | div.body h1, 94 | div.body h2, 95 | div.body h3, 96 | div.body h4, 97 | div.body h5, 98 | div.body h6 { 99 | font-family: 'Garamond', 'Georgia', serif; 100 | font-weight: normal; 101 | margin: 30px 0px 10px 0px; 102 | padding: 0; 103 | } 104 | 105 | {% if theme_index_logo %} 106 | div.indexwrapper h1 { 107 | text-indent: -999999px; 108 | background: url({{ theme_index_logo }}) no-repeat center center; 109 | height: {{ theme_index_logo_height }}; 110 | } 111 | {% endif %} 112 | 113 | div.body h2 { font-size: 180%; } 114 | div.body h3 { font-size: 150%; } 115 | div.body h4 { font-size: 130%; } 116 | div.body h5 { font-size: 100%; } 117 | div.body h6 { font-size: 100%; } 118 | 119 | a.headerlink { 120 | color: white; 121 | padding: 0 4px; 122 | text-decoration: none; 123 | } 124 | 125 | a.headerlink:hover { 126 | color: #444; 127 | background: #eaeaea; 128 | } 129 | 130 | div.body p, div.body dd, div.body li { 131 | line-height: 1.4em; 132 | } 133 | 134 | div.admonition { 135 | background: #fafafa; 136 | margin: 20px -30px; 137 | padding: 10px 30px; 138 | border-top: 1px solid #ccc; 139 | border-bottom: 1px solid #ccc; 140 | } 141 | 142 | div.admonition p.admonition-title { 143 | font-family: 'Garamond', 'Georgia', serif; 144 | font-weight: normal; 145 | font-size: 24px; 146 | margin: 0 0 10px 0; 147 | padding: 0; 148 | line-height: 1; 149 | } 150 | 151 | div.admonition p.last { 152 | margin-bottom: 0; 153 | } 154 | 155 | div.highlight{ 156 | background-color: white; 157 | } 158 | 159 | dt:target, .highlight { 160 | background: #FAF3E8; 161 | } 162 | 163 | div.note { 164 | background-color: #eee; 165 | border: 1px solid #ccc; 166 | } 167 | 168 | div.seealso { 169 | background-color: #ffc; 170 | border: 1px solid #ff6; 171 | } 172 | 173 | div.topic { 174 | background-color: #eee; 175 | } 176 | 177 | div.warning { 178 | background-color: #ffe4e4; 179 | border: 1px solid #f66; 180 | } 181 | 182 | p.admonition-title { 183 | display: inline; 184 | } 185 | 186 | p.admonition-title:after { 187 | content: ":"; 188 | } 189 | 190 | pre, tt { 191 | font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; 192 | font-size: 0.85em; 193 | } 194 | 195 | img.screenshot { 196 | } 197 | 198 | tt.descname, tt.descclassname { 199 | font-size: 0.95em; 200 | } 201 | 202 | tt.descname { 203 | padding-right: 0.08em; 204 | } 205 | 206 | img.screenshot { 207 | -moz-box-shadow: 2px 2px 4px #eee; 208 | -webkit-box-shadow: 2px 2px 4px #eee; 209 | box-shadow: 2px 2px 4px #eee; 210 | } 211 | 212 | table.docutils { 213 | border: 1px solid #888; 214 | -moz-box-shadow: 2px 2px 4px #eee; 215 | -webkit-box-shadow: 2px 2px 4px #eee; 216 | box-shadow: 2px 2px 4px #eee; 217 | } 218 | 219 | table.docutils td, table.docutils th { 220 | border: 1px solid #888; 221 | padding: 0.25em 0.7em; 222 | } 223 | 224 | table.field-list, table.footnote { 225 | border: none; 226 | -moz-box-shadow: none; 227 | -webkit-box-shadow: none; 228 | box-shadow: none; 229 | } 230 | 231 | table.footnote { 232 | margin: 15px 0; 233 | width: 100%; 234 | border: 1px solid #eee; 235 | } 236 | 237 | table.field-list th { 238 | padding: 0 0.8em 0 0; 239 | } 240 | 241 | table.field-list td { 242 | padding: 0; 243 | } 244 | 245 | table.footnote td { 246 | padding: 0.5em; 247 | } 248 | 249 | dl { 250 | margin: 0; 251 | padding: 0; 252 | } 253 | 254 | dl dd { 255 | margin-left: 30px; 256 | } 257 | 258 | pre { 259 | padding: 0; 260 | margin: 15px -30px; 261 | padding: 8px; 262 | line-height: 1.3em; 263 | padding: 7px 30px; 264 | background: #eee; 265 | border-radius: 2px; 266 | -moz-border-radius: 2px; 267 | -webkit-border-radius: 2px; 268 | } 269 | 270 | dl pre { 271 | margin-left: -60px; 272 | padding-left: 60px; 273 | } 274 | 275 | tt { 276 | background-color: #ecf0f3; 277 | color: #222; 278 | /* padding: 1px 2px; */ 279 | } 280 | 281 | tt.xref, a tt { 282 | background-color: #FBFBFB; 283 | } 284 | 285 | a:hover tt { 286 | background: #EEE; 287 | } 288 | -------------------------------------------------------------------------------- /docs/_themes/flask_small/theme.conf: -------------------------------------------------------------------------------- 1 | [theme] 2 | inherit = basic 3 | stylesheet = flasky.css 4 | nosidebar = true 5 | pygments_style = flask_theme_support.FlaskyStyle 6 | 7 | [options] 8 | index_logo = '' 9 | index_logo_height = 120px 10 | github_fork = '' 11 | -------------------------------------------------------------------------------- /docs/_themes/flask_theme_support.py: -------------------------------------------------------------------------------- 1 | # flasky extensions. flasky pygments style based on tango style 2 | from pygments.style import Style 3 | from pygments.token import Keyword, Name, Comment, String, Error, \ 4 | Number, Operator, Generic, Whitespace, Punctuation, Other, Literal 5 | 6 | 7 | class FlaskyStyle(Style): 8 | background_color = "#f8f8f8" 9 | default_style = "" 10 | 11 | styles = { 12 | # No corresponding class for the following: 13 | #Text: "", # class: '' 14 | Whitespace: "underline #f8f8f8", # class: 'w' 15 | Error: "#a40000 border:#ef2929", # class: 'err' 16 | Other: "#000000", # class 'x' 17 | 18 | Comment: "italic #8f5902", # class: 'c' 19 | Comment.Preproc: "noitalic", # class: 'cp' 20 | 21 | Keyword: "bold #004461", # class: 'k' 22 | Keyword.Constant: "bold #004461", # class: 'kc' 23 | Keyword.Declaration: "bold #004461", # class: 'kd' 24 | Keyword.Namespace: "bold #004461", # class: 'kn' 25 | Keyword.Pseudo: "bold #004461", # class: 'kp' 26 | Keyword.Reserved: "bold #004461", # class: 'kr' 27 | Keyword.Type: "bold #004461", # class: 'kt' 28 | 29 | Operator: "#582800", # class: 'o' 30 | Operator.Word: "bold #004461", # class: 'ow' - like keywords 31 | 32 | Punctuation: "bold #000000", # class: 'p' 33 | 34 | # because special names such as Name.Class, Name.Function, etc. 35 | # are not recognized as such later in the parsing, we choose them 36 | # to look the same as ordinary variables. 37 | Name: "#000000", # class: 'n' 38 | Name.Attribute: "#c4a000", # class: 'na' - to be revised 39 | Name.Builtin: "#004461", # class: 'nb' 40 | Name.Builtin.Pseudo: "#3465a4", # class: 'bp' 41 | Name.Class: "#000000", # class: 'nc' - to be revised 42 | Name.Constant: "#000000", # class: 'no' - to be revised 43 | Name.Decorator: "#888", # class: 'nd' - to be revised 44 | Name.Entity: "#ce5c00", # class: 'ni' 45 | Name.Exception: "bold #cc0000", # class: 'ne' 46 | Name.Function: "#000000", # class: 'nf' 47 | Name.Property: "#000000", # class: 'py' 48 | Name.Label: "#f57900", # class: 'nl' 49 | Name.Namespace: "#000000", # class: 'nn' - to be revised 50 | Name.Other: "#000000", # class: 'nx' 51 | Name.Tag: "bold #004461", # class: 'nt' - like a keyword 52 | Name.Variable: "#000000", # class: 'nv' - to be revised 53 | Name.Variable.Class: "#000000", # class: 'vc' - to be revised 54 | Name.Variable.Global: "#000000", # class: 'vg' - to be revised 55 | Name.Variable.Instance: "#000000", # class: 'vi' - to be revised 56 | 57 | Number: "#990000", # class: 'm' 58 | 59 | Literal: "#000000", # class: 'l' 60 | Literal.Date: "#000000", # class: 'ld' 61 | 62 | String: "#4e9a06", # class: 's' 63 | String.Backtick: "#4e9a06", # class: 'sb' 64 | String.Char: "#4e9a06", # class: 'sc' 65 | String.Doc: "italic #8f5902", # class: 'sd' - like a comment 66 | String.Double: "#4e9a06", # class: 's2' 67 | String.Escape: "#4e9a06", # class: 'se' 68 | String.Heredoc: "#4e9a06", # class: 'sh' 69 | String.Interpol: "#4e9a06", # class: 'si' 70 | String.Other: "#4e9a06", # class: 'sx' 71 | String.Regex: "#4e9a06", # class: 'sr' 72 | String.Single: "#4e9a06", # class: 's1' 73 | String.Symbol: "#4e9a06", # class: 'ss' 74 | 75 | Generic: "#000000", # class: 'g' 76 | Generic.Deleted: "#a40000", # class: 'gd' 77 | Generic.Emph: "italic #000000", # class: 'ge' 78 | Generic.Error: "#ef2929", # class: 'gr' 79 | Generic.Heading: "bold #000080", # class: 'gh' 80 | Generic.Inserted: "#00A000", # class: 'gi' 81 | Generic.Output: "#888", # class: 'go' 82 | Generic.Prompt: "#745334", # class: 'gp' 83 | Generic.Strong: "bold #000000", # class: 'gs' 84 | Generic.Subheading: "bold #800080", # class: 'gu' 85 | Generic.Traceback: "bold #a40000", # class: 'gt' 86 | } 87 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Flask-Track-Usage documentation build configuration file, created by 4 | # sphinx-quickstart on Sun May 5 11:18:16 2013. 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 | import sys 16 | import os 17 | 18 | sys.path.insert(0, 'src') 19 | 20 | # -- General configuration --------------------------------------------------- 21 | 22 | # If your documentation needs a minimal Sphinx version, state it here. 23 | needs_sphinx = '1.0' 24 | 25 | # Add any Sphinx extension module names here, as strings. They can be 26 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 27 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.todo', 'sphinx.ext.ifconfig'] 28 | 29 | # Add any paths that contain templates here, relative to this directory. 30 | templates_path = ['_templates'] 31 | 32 | # The suffix of source filenames. 33 | source_suffix = '.rst' 34 | 35 | # The encoding of source files. 36 | #source_encoding = 'utf-8-sig' 37 | 38 | # The master toctree document. 39 | master_doc = 'index' 40 | 41 | # General information about the project. 42 | project = u'Flask-Track-Usage' 43 | copyright = u'2013-2018, Steve Milner' 44 | 45 | # The version info for the project you're documenting, acts as replacement for 46 | # |version| and |release|, also used in various other places throughout the 47 | # built documents. 48 | # 49 | # The short X.Y version. 50 | version = '2.0.0' 51 | # The full version, including alpha/beta/rc tags. 52 | release = version 53 | 54 | # The language for content autogenerated by Sphinx. Refer to documentation 55 | # for a list of supported languages. 56 | #language = None 57 | 58 | # There are two options for replacing |today|: either, you set today to some 59 | # non-false value, then it is used: 60 | #today = '' 61 | # Else, today_fmt is used as the format for a strftime call. 62 | #today_fmt = '%B %d, %Y' 63 | 64 | # List of patterns, relative to source directory, that match files and 65 | # directories to ignore when looking for source files. 66 | exclude_patterns = ['_build'] 67 | 68 | # The reST default role (used for this markup: `text`) to use 69 | # for all documents. 70 | #default_role = None 71 | 72 | # If true, '()' will be appended to :func: etc. cross-reference text. 73 | #add_function_parentheses = True 74 | 75 | # If true, the current module name will be prepended to all description 76 | # unit titles (such as .. function::). 77 | #add_module_names = True 78 | 79 | # If true, sectionauthor and moduleauthor directives will be shown in the 80 | # output. They are ignored by default. 81 | #show_authors = False 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = 'sphinx' 85 | 86 | # A list of ignored prefixes for module index sorting. 87 | #modindex_common_prefix = [] 88 | 89 | # If true, keep warnings as "system message" paragraphs in the built documents. 90 | #keep_warnings = False 91 | 92 | 93 | # -- Options for HTML output ------------------------------------------------- 94 | 95 | # Flask theme 96 | sys.path.append(os.path.abspath('_themes')) 97 | html_theme_options = { 98 | 'github_fork': 'ashcrow/flask-track-usage', 99 | 'index_logo': False, 100 | } 101 | 102 | # The theme to use for HTML and HTML Help pages. See the documentation for 103 | # a list of builtin themes. 104 | html_theme = 'flask_small' 105 | 106 | # Theme options are theme-specific and customize the look and feel of a theme 107 | # further. For a list of options available for each theme, see the 108 | # documentation. 109 | #html_theme_options = {} 110 | 111 | # Add any paths that contain custom themes here, relative to this directory. 112 | #html_theme_path = [] 113 | html_theme_path = ['_themes'] 114 | 115 | # The name for this set of Sphinx documents. If None, it defaults to 116 | # " v documentation". 117 | #html_title = None 118 | 119 | # A shorter title for the navigation bar. Default is the same as html_title. 120 | #html_short_title = None 121 | 122 | # The name of an image file (relative to this directory) to place at the top 123 | # of the sidebar. 124 | #html_logo = None 125 | 126 | # The name of an image file (within the static path) to use as favicon of the 127 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 128 | # pixels large. 129 | #html_favicon = None 130 | 131 | # Add any paths that contain custom static files (such as style sheets) here, 132 | # relative to this directory. They are copied after the builtin static files, 133 | # so a file named "default.css" will overwrite the builtin "default.css". 134 | html_static_path = ['_static'] 135 | 136 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 137 | # using the given strftime format. 138 | #html_last_updated_fmt = '%b %d, %Y' 139 | 140 | # If true, SmartyPants will be used to convert quotes and dashes to 141 | # typographically correct entities. 142 | #html_use_smartypants = True 143 | 144 | # Custom sidebar templates, maps document names to template names. 145 | #html_sidebars = {} 146 | 147 | # Additional templates that should be rendered to pages, maps page names to 148 | # template names. 149 | #html_additional_pages = {} 150 | 151 | # If false, no module index is generated. 152 | #html_domain_indices = True 153 | 154 | # If false, no index is generated. 155 | #html_use_index = True 156 | 157 | # If true, the index is split into individual pages for each letter. 158 | #html_split_index = False 159 | 160 | # If true, links to the reST sources are added to the pages. 161 | #html_show_sourcelink = True 162 | 163 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 164 | #html_show_sphinx = True 165 | 166 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 167 | #html_show_copyright = True 168 | 169 | # If true, an OpenSearch description file will be output, and all pages will 170 | # contain a tag referring to it. The value of this option must be the 171 | # base URL from which the finished HTML is served. 172 | #html_use_opensearch = '' 173 | 174 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 175 | #html_file_suffix = None 176 | 177 | # Output file base name for HTML help builder. 178 | htmlhelp_basename = 'Flask-Track-Usagedoc' 179 | 180 | 181 | # -- Options for LaTeX output ------------------------------------------------ 182 | 183 | latex_elements = { 184 | # The paper size ('letterpaper' or 'a4paper'). 185 | #'papersize': 'letterpaper', 186 | 187 | # The font size ('10pt', '11pt' or '12pt'). 188 | #'pointsize': '10pt', 189 | 190 | # Additional stuff for the LaTeX preamble. 191 | #'preamble': '', 192 | } 193 | 194 | # Grouping the document tree into LaTeX files. List of tuples 195 | # (source start file, target name, title, author, documentclass 196 | # [howto/manual]). 197 | latex_documents = [( 198 | 'index', 'Flask-Track-Usage.tex', u'Flask-Track-Usage Documentation', 199 | u'Steve Milner', 'manual'), 200 | ] 201 | 202 | # The name of an image file (relative to this directory) to place at the top of 203 | # the title page. 204 | #latex_logo = None 205 | 206 | # For "manual" documents, if this is true, then toplevel headings are parts, 207 | # not chapters. 208 | #latex_use_parts = False 209 | 210 | # If true, show page references after internal links. 211 | #latex_show_pagerefs = False 212 | 213 | # If true, show URL addresses after external links. 214 | #latex_show_urls = False 215 | 216 | # Documents to append as an appendix to all manuals. 217 | #latex_appendices = [] 218 | 219 | # If false, no module index is generated. 220 | #latex_domain_indices = True 221 | 222 | 223 | # -- Options for manual page output ------------------------------------------ 224 | 225 | # One entry per manual page. List of tuples 226 | # (source start file, name, description, authors, manual section). 227 | man_pages = [ 228 | ('index', 'flask-track-usage', u'Flask-Track-Usage Documentation', 229 | [u'Steve Milner'], 1) 230 | ] 231 | 232 | # If true, show URL addresses after external links. 233 | #man_show_urls = False 234 | 235 | 236 | # -- Options for Texinfo output ---------------------------------------------- 237 | 238 | # Grouping the document tree into Texinfo files. List of tuples 239 | # (source start file, target name, title, author, 240 | # dir menu entry, description, category) 241 | texinfo_documents = [( 242 | 'index', 'Flask-Track-Usage', u'Flask-Track-Usage Documentation', 243 | u'Steve Milner', 'Flask-Track-Usage', 'One line description of project.', 244 | 'Miscellaneous'), 245 | ] 246 | 247 | # Documents to append as an appendix to all manuals. 248 | #texinfo_appendices = [] 249 | 250 | # If false, no module index is generated. 251 | #texinfo_domain_indices = True 252 | 253 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 254 | #texinfo_show_urls = 'footnote' 255 | 256 | # If true, do not generate a @detailmenu in the "Top" node's menu. 257 | #texinfo_no_detailmenu = False 258 | -------------------------------------------------------------------------------- /docs/hooks.rst: -------------------------------------------------------------------------------- 1 | Flask-Track-Usage Hooks 2 | ======================= 3 | 4 | The library supports post-storage functions that can optionally do more after the request itself is stored. 5 | 6 | How To Use 7 | ---------- 8 | 9 | To use, simply add the list of functions you wish to call from the context of call to TrackUsage. 10 | 11 | For example, to add sumRemotes and sumLanguages to a MongoEngineStorage storage: 12 | 13 | .. code-block:: python 14 | 15 | from flask.ext.track_usage import TrackUsage 16 | from flask.ext.track_usage.storage.mongo import MongoEngineStorage 17 | from flask.ext.track_usage.summarization import sumRemotes, sumLanguages 18 | 19 | t = TrackUsage(app, [MongoEngineStorage(hooks=[sumRemotes, sumLanguages])]) 20 | 21 | Standard Summary Hooks 22 | ---------------------- 23 | 24 | Time Periods for ALL Summary Hooks 25 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 26 | 27 | When keeping live metrics for each of the summaries, the following time periods are used: 28 | 29 | :Hour: 30 | one common unit of "storage" is kept to keep track of the hourly traffic for each hour of a particular date. 31 | 32 | :Date: 33 | one common unit of "storage" is kept to keep track of the daily traffic for a particular date. 34 | 35 | :Month: 36 | one common unit of "storage" is kept to keep track of the monthly traffic for a particular 37 | month. The month stored is the first day of the month. For example, the summary for March 38 | 2017 would be stored under the date of 2017-03-01. 39 | 40 | Please note that this library DOES NOT handle expiration of old data. If you wish to delete, say, hourly data that is over 60 days old, you will need to create a seperate process to handle this. This library merely adds or updates new data and presumes limitless storage. 41 | 42 | Summary Targets for ALL Summary Hooks 43 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 44 | 45 | Currently, the following two data targets are summarized for each of the Time Periods. 46 | 47 | :Hits: 48 | The total number of requests seen. 49 | :Transfer: 50 | The total number of bytes transfered in response to all requests seen. 51 | 52 | sumUrls -- URLs 53 | ~~~~~~~~~~~~~~~ 54 | 55 | Traffic is summarized for each URL requested of the Flask server. 56 | 57 | sumRemotes -- remote IPs 58 | ~~~~~~~~~~~~~~~~~~~~~~~~ 59 | 60 | Traffic is summarized for each remote IP address seen by the Flask server. 61 | 62 | sumUserAgents -- user agent clients 63 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 64 | 65 | Traffic is summarized for each client (aka web browser) seen by the Flask server. 66 | 67 | sumLanugages -- languages 68 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 69 | 70 | Traffic is summarized for each language seen in the requests sent to the Flask server. 71 | 72 | sumServer -- site-wide server hits/traffic 73 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 74 | 75 | Traffic is summarized for all requests sent to the Flask server. This metric is mostly useful for diagnosing performance. 76 | 77 | sumVisitors -- unique visitors (as tracked by cookies) 78 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 79 | 80 | Traffic is summarized for each unique visitor of the Flask server. For this to function, the optional TRACK_USAGE_COOKIE function must be enabled in config. 81 | 82 | This metric is limited by the cookie technology. User behavior such as switching browsers or turning on "anonymous mode" on a browser will make them appear to be multiple users. 83 | 84 | sumGeo -- physical country of remote IPs 85 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 86 | 87 | Traffic is summarized for the tracked geographies of remote IPs seen by the Flask server. For this to properly function, the optional TRACK_USAGE_FREEGEOIP config must be enabled. While the geography function provides a great deal of information, only the country is used for this summarization. 88 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Flask-Track-Usage |release| 2 | =========================== 3 | 4 | Basic metrics tracking for your `Flask`_ application. The core of library is very light and focuses more on storing basic metrics such as remote ip address and user agent. No extra cookies or javascript are used for usage tracking. 5 | 6 | * Simple. It's a Flask extension. 7 | * Supports either include or exempt for views. 8 | * Provides lite abstraction for data retrieval. 9 | * Multiple storage options available. 10 | * Multiple storage options can be used. 11 | * Pluggable functionality for storage instances. 12 | * Supports Python 2.7 and 3+. 13 | 14 | The following is optional: 15 | 16 | * `freegeoip.net `_ integration for storing geography of the visitor. 17 | * Unique visitor tracking if you are wanting to use Flask's cookie storage. 18 | * Summation hooks for live count of common web analysis statistics such as hit counts. 19 | 20 | 21 | .. _Flask: http://flask.pocoo.org/ 22 | 23 | 24 | Installation 25 | ------------ 26 | 27 | Requirements 28 | ~~~~~~~~~~~~ 29 | * Flask: http://flask.pocoo.org/ 30 | * A storage object to save the metrics data with 31 | 32 | Via pip 33 | ~~~~~~~ 34 | :: 35 | 36 | $ pip install Flask-Track-Usage 37 | 38 | 39 | Via source 40 | ~~~~~~~~~~ 41 | :: 42 | 43 | $ python setup.py install 44 | 45 | Usage 46 | ----- 47 | 48 | :: 49 | 50 | # Create the Flask 'app' 51 | from flask import Flask, g 52 | app = Flask(__name__) 53 | 54 | # Set the configuration items manually for the example 55 | app.config['TRACK_USAGE_USE_FREEGEOIP'] = False 56 | # You can use a different instance of freegeoip like so 57 | # app.config['TRACK_USAGE_FREEGEOIP_ENDPOINT'] = 'https://example.org/api/' 58 | app.config['TRACK_USAGE_INCLUDE_OR_EXCLUDE_VIEWS'] = 'include' 59 | 60 | # We will just print out the data for the example 61 | from flask_track_usage import TrackUsage 62 | 63 | # We will just print out the data for the example 64 | from flask_track_usage.storage.printer import PrintWriter 65 | from flask_track_usage.storage.output import OutputWriter 66 | 67 | # Make an instance of the extension and put two writers 68 | t = TrackUsage(app, [ 69 | PrintWriter(), 70 | OutputWriter(transform=lambda s: "OUTPUT: " + str(s)) 71 | ]) 72 | 73 | # Include the view in the metrics 74 | @t.include 75 | @app.route('/') 76 | def index(): 77 | g.track_var["optional"] = "something" 78 | return "Hello" 79 | 80 | # Run the application! 81 | app.run(debug=True) 82 | 83 | 84 | Upgrading Schema 85 | ---------------- 86 | 87 | SQL 88 | ~~~ 89 | 90 | 1.x -> 2.0.0 91 | ```````````` 92 | 1. Edit alembic.ini setting ``sqlalchemy.url`` to the database that you want to upgrade to 2.0.0. 93 | 2. Run the alembic upgrade like so:: 94 | 95 | $ alembic upgrade head 96 | INFO [alembic.runtime.migration] Context impl SQLiteImpl. 97 | INFO [alembic.runtime.migration] Will assume non-transactional DDL. 98 | INFO [alembic.runtime.migration] Running upgrade -> 07c46d368ba4, Initial empty db 99 | INFO [alembic.runtime.migration] Running upgrade 07c46d368ba4 -> 0aedc36acb3f, Upgrade to 2.0.0 100 | 101 | 102 | MongoDB 103 | ``````` 104 | MongoDB should not need modification 105 | 106 | Redis 107 | ````` 108 | Redis should not need modification 109 | 110 | CouchDB 111 | ``````` 112 | CouchDB should not need modification 113 | 114 | Blueprint Support 115 | ----------------- 116 | Blueprints can be included or excluded from Flask-TrackUsage in their entirety. 117 | 118 | Include 119 | ~~~~~~~ 120 | .. code-block:: python 121 | 122 | # ... 123 | app.config['TRACK_USAGE_INCLUDE_OR_EXCLUDE_VIEWS'] = 'include' 124 | 125 | # Make an instance of the extension 126 | t = TrackUsage(app, [PrintWriter()]) 127 | 128 | from my_blueprints import a_bluprint 129 | 130 | # Now ALL of a_blueprint's views will be in the include list 131 | t.include_blueprint(a_blueprint) 132 | 133 | 134 | Exclude 135 | ~~~~~~~ 136 | .. code-block:: python 137 | 138 | # ... 139 | app.config['TRACK_USAGE_INCLUDE_OR_EXCLUDE_VIEWS'] = 'exclude' 140 | 141 | # Make an instance of the extension 142 | t = TrackUsage(app, [PrintWriter()]) 143 | 144 | from my_blueprints import a_bluprint 145 | 146 | # Now ALL of different_blueprints will be in the exclude list 147 | t.exclude_blueprint(a_blueprint) 148 | 149 | 150 | Configuration 151 | ------------- 152 | 153 | TRACK_USAGE_USE_FREEGEOIP 154 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 155 | **Values**: True, False 156 | 157 | **Default**: False 158 | 159 | Turn FreeGeoIP integration on or off. If set to true, then geography information is also stored in the usage logs. 160 | 161 | .. versionchanged:: 1.1. 162 | The default server for using geoip integration changed to extreme-ip-lookup.com 163 | 164 | 165 | TRACK_USAGE_FREEGEOIP_ENDPOINT 166 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 167 | **Values**: URL for RESTful JSON query 168 | 169 | **Default**: "http://extreme-ip-lookup.com/json/{ip}" 170 | 171 | If TRACK_USAGE_USE_FREEGEOIP is True, then this field must be set. Mark the location for the IP address with "{ip}". For example: 172 | 173 | "http://example.com/json/{ip}/" 174 | 175 | would resolve (with an IP of 1.2.3.4) to: 176 | 177 | "http://example.com/json/1.2.3.4/" 178 | 179 | If using SQLStorage, the returned JSON is converted to a string. You will likely want to pass a field list in the URL to avoid exceeding the 128 character limit of the field. 180 | 181 | Set the URL prefix used to map the remote IP address of each request to a geography. The service must return a JSON response. 182 | 183 | TRACK_USAGE_INCLUDE_OR_EXCLUDE_VIEWS 184 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 185 | **Values**: include, exclude 186 | 187 | **Default**: exclude 188 | 189 | If views should be included or excluded by default. 190 | 191 | * When set to *exclude* each routed view must be explicitly included via decorator or blueprint include method. If a routed view is not included it will not be tracked. 192 | * When set to *include* each routed view must be explicitly excluded via decorator or blueprint exclude method. If a routed view is not excluded it will be tracked. 193 | 194 | TRACK_USAGE_COOKIE 195 | ~~~~~~~~~~~~~~~~~~ 196 | **Values**: True, False 197 | 198 | **Default**: False 199 | 200 | Turn on unique visitor tracking via cookie on or off. If True, then the unique visitor ID (a quasi-random number) is also stored in the usage logs. 201 | 202 | Storage 203 | ------- 204 | The following are built-in, ready-to-use storage backends. 205 | 206 | .. note:: Inputs for set_up should be passed in __init__ when creating a storage instance 207 | 208 | printer.PrintWriter 209 | ~~~~~~~~~~~~~~~~~~~~ 210 | .. note:: 211 | This storage backend is only for testing! 212 | 213 | .. autoclass:: flask_track_usage.storage.printer.PrintWriter 214 | :members: 215 | :inherited-members: 216 | 217 | couchdb.CouchDBStorage 218 | ~~~~~~~~~~~~~~~~~~~~~~ 219 | .. autoclass:: flask_track_usage.storage.couchdb.CouchDBStorage 220 | :members: 221 | :inherited-members: 222 | 223 | mongo.MongoPiggybackStorage 224 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 225 | .. autoclass:: flask_track_usage.storage.mongo.MongoPiggybackStorage 226 | :members: 227 | :inherited-members: 228 | 229 | mongo.MongoStorage 230 | ~~~~~~~~~~~~~~~~~~ 231 | .. autoclass:: flask_track_usage.storage.mongo.MongoStorage 232 | :members: 233 | :inherited-members: 234 | 235 | mongo.MongoEngineStorage 236 | ~~~~~~~~~~~~~~~~~~~~~~~~ 237 | .. autoclass:: flask_track_usage.storage.mongo.MongoEngineStorage 238 | :members: 239 | :inherited-members: 240 | 241 | output.OutputWriter 242 | ~~~~~~~~~~~~~~~~~~~ 243 | .. autoclass:: flask_track_usage.storage.output.OutputWriter 244 | :members: 245 | :inherited-members: 246 | 247 | redis_db.RedisStorage 248 | ~~~~~~~~~~~~~~~~~~~~~ 249 | .. autoclass:: flask_track_usage.storage.redis_db.RedisStorage 250 | :members: 251 | :inherited-members: 252 | 253 | sql.SQLStorage 254 | ~~~~~~~~~~~~~~ 255 | .. warning:: 256 | This storage is not backwards compatible with sql.SQLStorage 1.0.x 257 | 258 | .. autoclass:: flask_track_usage.storage.sql.SQLStorage 259 | :members: 260 | :inherited-members: 261 | 262 | Retrieving Log Data 263 | ------------------- 264 | All storage backends, other than printer.PrintStorage, provide get_usage. 265 | 266 | .. autoclass:: flask_track_usage.storage.Storage 267 | :members: get_usage 268 | 269 | Results that are returned from all instances of get_usage should **always** look like this: 270 | 271 | .. code-block:: python 272 | 273 | [ 274 | { 275 | 'url': str, 276 | 'user_agent': { 277 | 'browser': str, 278 | 'language': str, 279 | 'platform': str, 280 | 'version': str, 281 | }, 282 | 'blueprint': str, 283 | 'view_args': str(dict) or None, 284 | 'status': int, 285 | 'remote_addr': str, 286 | 'xforwardedfor': str, 287 | 'authorization': bool 288 | 'ip_info': str or None, 289 | 'path': str, 290 | 'speed': float, 291 | 'date': datetime, 292 | 'username': str, 293 | 'track_var': str(dict) or None, 294 | }, 295 | { 296 | .... 297 | } 298 | ] 299 | 300 | .. versionchanged:: 1.1.0 301 | xforwardfor item added directly after remote_addr 302 | 303 | Hooks 304 | ----- 305 | The basic function of the library simply logs on unit of information per request received. This keeps it simple and light. 306 | 307 | However, you can also add post-storage "hooks" that are called after the individual log is stored. In theory, anything could be triggered after the storage. 308 | 309 | .. code-block:: python 310 | 311 | # ... 312 | def helloWorld(*kwargs): 313 | print "hello world!" 314 | 315 | # Make an instance of the extension 316 | t = TrackUsage(app, [PrintWriter(hooks=[helloWorld])]) 317 | 318 | In this example, the helloWorld function would be called once each time PrintWriters output is invoked. The keyword parameters are those found in the `Retrieving Log Data`_ function. (see above) Some Storages/Writers also add more keys. 319 | 320 | This library has a list of standardized hooks that are used for log summarizing. They are documented in detail here: 321 | 322 | :doc:`hooks` 323 | Standard Summarization Hooks 324 | 325 | Not all Stores support all of these hooks. See the details for more information. Usage is fairly straightforward: 326 | 327 | .. code-block:: python 328 | 329 | from flask.ext.track_usage import TrackUsage 330 | from flask.ext.track_usage.storage.mongo import MongoEngineStorage 331 | from flask.ext.track_usage.summarization import sumUrl 332 | 333 | t = TrackUsage(app, [MongoEngineStorage(hooks=[sumUrl])]) 334 | 335 | .. include:: hooks.rst 336 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | six 3 | pymongo 4 | sqlalchemy 5 | alembic 6 | couchdb>=1.0 7 | mongoengine 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright (c) 2013-2018 Steve Milner 3 | # All rights reserved. 4 | # 5 | # Redistribution and use in source and binary forms, with or without 6 | # modification, are permitted provided that the following conditions are 7 | # met: 8 | # 9 | # (1) Redistributions of source code must retain the above copyright 10 | # notice, this list of conditions and the following disclaimer. 11 | # 12 | # (2) Redistributions in binary form must reproduce the above copyright 13 | # notice, this list of conditions and the following disclaimer in 14 | # the documentation and/or other materials provided with the 15 | # distribution. 16 | # 17 | # (3)The name of the author may not be used to 18 | # endorse or promote products derived from this software without 19 | # specific prior written permission. 20 | # 21 | # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 22 | # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 23 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | # DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, 25 | # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 26 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 27 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 28 | # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 29 | # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING 30 | # IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 31 | # POSSIBILITY OF SUCH DAMAGE. 32 | """ 33 | Setup script. 34 | """ 35 | 36 | from setuptools import setup 37 | 38 | setup( 39 | name='Flask-Track-Usage', 40 | version='2.0.0', 41 | url='https://github.com/ashcrow/flask-track-usage', 42 | license='MBSD', 43 | author='Steve Milner', 44 | description='Basic metrics tracking for the Flask framework.', 45 | long_description='Basic metrics tracking for the Flask framework.', 46 | packages=['flask_track_usage'], 47 | package_dir={'flask_track_usage': 'src/flask_track_usage'}, 48 | zip_safe=False, 49 | include_package_data=True, 50 | platforms='any', 51 | setup_requires=[ 52 | 'sphinx' 53 | ], 54 | install_requires=[ 55 | 'Flask' 56 | ], 57 | classifiers=[ 58 | 'Environment :: Web Environment', 59 | 'Intended Audience :: Developers', 60 | 'License :: OSI Approved :: BSD License', 61 | 'Operating System :: OS Independent', 62 | 'Programming Language :: Python', 63 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 64 | 'Topic :: Software Development :: Libraries :: Python Modules' 65 | ] 66 | ) 67 | -------------------------------------------------------------------------------- /src/flask_track_usage/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2018 Steve Milner 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are 6 | # met: 7 | # 8 | # (1) Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 11 | # (2) Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in 13 | # the documentation and/or other materials provided with the 14 | # distribution. 15 | # 16 | # (3)The name of the author may not be used to 17 | # endorse or promote products derived from this software without 18 | # specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 21 | # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | # DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, 24 | # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 27 | # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 28 | # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING 29 | # IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | # POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | Basic metrics tracking with Flask. 33 | """ 34 | 35 | import datetime 36 | import json 37 | import time 38 | from six.moves.urllib_parse import quote_plus 39 | from six.moves.urllib.request import urlopen 40 | 41 | import six 42 | 43 | from flask import _request_ctx_stack, g 44 | try: 45 | from flask_login import current_user 46 | except Exception: 47 | pass 48 | 49 | __version__ = '2.0.0' 50 | __author__ = 'Steve Milner' 51 | __license__ = 'MBSD' 52 | 53 | 54 | class TrackUsage(object): 55 | """ 56 | Tracks basic usage of Flask applications. 57 | """ 58 | 59 | def __init__(self, app=None, storage=None, _fake_time=None): 60 | """ 61 | Create the instance. 62 | 63 | :Parameters: 64 | - `app`: Optional app to use. 65 | - `storage`: If app is set, required list of storage callables. 66 | """ 67 | # 68 | # `_fake_time` is to force the time stamp of the request for testing 69 | # purposes. It is not normally used by end users. Must be a native 70 | # datetime object. 71 | # 72 | self._exclude_views = set() 73 | self._include_views = set() 74 | 75 | if callable(storage): 76 | storage = [storage] 77 | 78 | self._fake_time = _fake_time 79 | 80 | if app is not None and storage is not None: 81 | self.init_app(app, storage) 82 | 83 | def init_app(self, app, storage): 84 | """ 85 | Initialize the instance with the app. 86 | 87 | :Parameters: 88 | - `app`: Application to work with. 89 | - `storage`: The storage callable which will store result. 90 | """ 91 | self.app = app 92 | self._storages = storage 93 | self._use_freegeoip = app.config.get( 94 | 'TRACK_USAGE_USE_FREEGEOIP', False) 95 | self._freegeoip_endpoint = app.config.get( 96 | 'TRACK_USAGE_FREEGEOIP_ENDPOINT', 97 | "http://extreme-ip-lookup.com/json/{ip}" 98 | ) 99 | self._type = app.config.get( 100 | 'TRACK_USAGE_INCLUDE_OR_EXCLUDE_VIEWS', 'exclude') 101 | 102 | if self._type not in ('include', 'exclude'): 103 | raise NotImplementedError( 104 | 'You must set include or exclude type.') 105 | app.before_request(self.before_request) 106 | app.after_request(self.after_request) 107 | 108 | def before_request(self): 109 | """ 110 | Done before every request that is in scope. 111 | """ 112 | ctx = _request_ctx_stack.top 113 | view_func = self.app.view_functions.get(ctx.request.endpoint) 114 | if self._type == 'exclude': 115 | if view_func in self._exclude_views: 116 | return 117 | elif self._type == 'include': 118 | if view_func not in self._include_views: 119 | return 120 | else: 121 | raise NotImplementedError( 122 | 'You must set include or exclude type.') 123 | g.start_time = datetime.datetime.utcnow() 124 | if not hasattr(g, "track_var"): 125 | g.track_var = {} 126 | 127 | def after_request(self, response): 128 | """ 129 | The heavy lifter. This method collects the majority of data 130 | and passes it off for storage. 131 | 132 | :Parameters: 133 | - `response`: The response on it's way to the client. 134 | """ 135 | ctx = _request_ctx_stack.top 136 | view_func = self.app.view_functions.get(ctx.request.endpoint) 137 | if self._type == 'exclude': 138 | if view_func in self._exclude_views: 139 | return response 140 | elif self._type == 'include': 141 | if view_func not in self._include_views: 142 | return response 143 | else: 144 | raise NotImplementedError( 145 | 'You must set include or exclude type.') 146 | 147 | now = datetime.datetime.utcnow() 148 | speed = None 149 | try: 150 | speed = (now - g.start_time).total_seconds() 151 | except: 152 | # Older python versions don't have total_seconds() 153 | speed_result = (now - g.start_time) 154 | speed = float("%s.%s" % ( 155 | speed_result.seconds, speed_result.microseconds)) 156 | 157 | if self._fake_time: 158 | current_time = self._fake_time 159 | else: 160 | current_time = now 161 | 162 | data = { 163 | 'url': ctx.request.url, 164 | 'user_agent': ctx.request.user_agent, 165 | 'server_name': ctx.app.name, 166 | 'blueprint': ctx.request.blueprint, 167 | 'view_args': ctx.request.view_args, 168 | 'status': response.status_code, 169 | 'remote_addr': ctx.request.remote_addr, 170 | 'xforwardedfor': ctx.request.headers.get( 171 | 'X-Forwarded-For', None), 172 | 'authorization': bool(ctx.request.authorization), 173 | 'ip_info': None, 174 | 'path': ctx.request.path, 175 | 'speed': float(speed), 176 | 'date': int(time.mktime(current_time.timetuple())), 177 | 'content_length': response.content_length, 178 | 'request': "{} {} {}".format( 179 | ctx.request.method, 180 | ctx.request.url, 181 | ctx.request.environ.get('SERVER_PROTOCOL') 182 | ), 183 | 'url_args': dict( 184 | [(k, ctx.request.args[k]) for k in ctx.request.args] 185 | ), 186 | 'username': None, 187 | 'track_var': g.track_var 188 | } 189 | if ctx.request.authorization: 190 | data['username'] = str(ctx.request.authorization.username) 191 | elif getattr(self.app, 'login_manager', None) and current_user and not current_user.is_anonymous: 192 | data['username'] = str(current_user) 193 | if self._use_freegeoip: 194 | clean_ip = quote_plus(str(ctx.request.remote_addr)) 195 | if '{ip}' in self._freegeoip_endpoint: 196 | url = self._freegeoip_endpoint.format(ip=clean_ip) 197 | else: 198 | url = self._freegeoip_endpoint + clean_ip 199 | # seperate capture and conversion to aid in debugging 200 | text = urlopen(url).read() 201 | ip_info = json.loads(text) 202 | if url.startswith("http://extreme-ip-lookup.com/"): 203 | del ip_info["businessWebsite"] 204 | del ip_info["status"] 205 | data['ip_info'] = ip_info 206 | 207 | for storage in self._storages: 208 | storage(data) 209 | return response 210 | 211 | def exclude(self, view): 212 | """ 213 | Excludes a view from tracking if we are in exclude mode. 214 | 215 | :Parameters: 216 | - `view`: The view to exclude. 217 | """ 218 | self._exclude_views.add(view) 219 | 220 | def include(self, view): 221 | """ 222 | Includes a view from tracking if we are in include mode. 223 | 224 | :Parameters: 225 | - `view`: The view to include. 226 | """ 227 | self._include_views.add(view) 228 | 229 | def _modify_blueprint(self, blueprint, include_type): 230 | """ 231 | Modifies a blueprint to include or exclude views. 232 | 233 | :Parameters: 234 | - `blueprint`: Blueprint instance to include. 235 | - `include_type`: include or exlude. 236 | """ 237 | blueprint.before_request = self.before_request 238 | blueprint.after_request = self.after_request 239 | 240 | # Hack to grab views from blueprints since view_functions 241 | # is always empty 242 | class DeferredViewGrabber(object): 243 | views = [] 244 | 245 | def add_url_rule(self, rule, endpoint, view_func, **options): 246 | self.views.append(view_func) 247 | 248 | dvg = DeferredViewGrabber() 249 | [defered(dvg) for defered in blueprint.deferred_functions] 250 | 251 | for view in dvg.views: 252 | if include_type.lower() == 'include': 253 | self._include_views.add(view) 254 | elif include_type.lower() == 'exclude': 255 | self._exclude_views.add(view) 256 | else: 257 | raise NotImplementedError( 258 | 'You must set include or exclude type for the blueprint.') 259 | return blueprint 260 | 261 | def include_blueprint(self, blueprint): 262 | """ 263 | Includes an entire blueprint. 264 | 265 | :Parameters: 266 | - `blueprint`: Blueprint instance to include. 267 | """ 268 | self._modify_blueprint(blueprint, 'include') 269 | 270 | def exclude_blueprint(self, blueprint): 271 | """ 272 | Excludes an entire blueprint. 273 | 274 | :Parameters: 275 | - `blueprint`: Blueprint instance to exclude. 276 | """ 277 | self._modify_blueprint(blueprint, 'exclude') 278 | 279 | 280 | if __name__ == '__main__': 281 | # Example 282 | from flask import Flask 283 | app = Flask(__name__) 284 | 285 | # Set the configuration items manually for the example 286 | app.config['TRACK_USAGE_USE_FREEGEOIP'] = False 287 | app.config['TRACK_USAGE_INCLUDE_OR_EXCLUDE_VIEWS'] = 'include' 288 | 289 | # We will just print out the data for the example 290 | from flask_track_usage.storage.printer import PrintWriter 291 | from flask_track_usage.storage.output import OutputWriter 292 | 293 | # Make an instance of the extension and put two writers 294 | t = TrackUsage(app, [PrintWriter(), OutputWriter( 295 | transform=lambda s: "OUTPUT: " + str(s))]) 296 | 297 | # Include the view in the metrics 298 | @t.include 299 | @app.route('/') 300 | def index(): 301 | return "Hello" 302 | 303 | # Run the application! 304 | app.run(debug=True) 305 | -------------------------------------------------------------------------------- /src/flask_track_usage/storage/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2018 Steve Milner 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are 6 | # met: 7 | # 8 | # (1) Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 11 | # (2) Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in 13 | # the documentation and/or other materials provided with the 14 | # distribution. 15 | # 16 | # (3)The name of the author may not be used to 17 | # endorse or promote products derived from this software without 18 | # specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 21 | # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | # DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, 24 | # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 27 | # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 28 | # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING 29 | # IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | # POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | Simple storage callables package. 33 | """ 34 | 35 | import inspect 36 | 37 | 38 | class _BaseWritable(object): 39 | """ 40 | 41 | Base class for writable callables. 42 | """ 43 | 44 | def __init__(self, *args, **kwargs): 45 | """ 46 | Creates the instance and calls set_up. 47 | 48 | :Parameters: 49 | - `args`: All non-keyword arguments. 50 | - `kwargs`: All keyword arguments. 51 | """ 52 | # 53 | self.set_up(*args, **kwargs) 54 | # 55 | # instantiate each hook if not already instantiated 56 | kwargs["_parent_class_name"] = self.__class__.__name__ 57 | kwargs['_parent_self'] = self 58 | self._post_storage_hooks = [] 59 | for hook in kwargs.get("hooks", []): 60 | if inspect.isclass(hook): 61 | self._post_storage_hooks.append(hook(**kwargs)) 62 | else: 63 | self._post_storage_hooks.append(hook) 64 | # call setup for each hook 65 | for hook in self._post_storage_hooks: 66 | hook.set_up(**kwargs) 67 | self._temp_hooks = None 68 | 69 | def set_up(self, *args, **kwargs): 70 | """ 71 | Sets up the created instance. Should be overridden. 72 | 73 | :Parameters: 74 | - `args`: All non-keyword arguments. 75 | - `kwargs`: All keyword arguments. 76 | """ 77 | pass 78 | 79 | def store(self, data): 80 | """ 81 | Executed on "function call". Must be overridden. 82 | 83 | :Parameters: 84 | - `data`: Data to store. 85 | :Returns: 86 | A dictionary representing, at minimum, the original 'data'. But 87 | can also include information that will be of use to any hooks 88 | associated with that storage class. 89 | """ 90 | raise NotImplementedError('store must be implemented.') 91 | 92 | def get_sum( 93 | self, 94 | hook, 95 | start_date=None, 96 | end_date=None, 97 | limit=500, 98 | page=1, 99 | target=None 100 | ): 101 | """ 102 | Queries a subtending hook for summarization data. Can be overridden. 103 | 104 | :Parameters: 105 | - 'hook': the hook 'class' or it's name as a string 106 | - `start_date`: datetime.datetime representation of starting date 107 | - `end_date`: datetime.datetime representation of ending date 108 | - `limit`: The max amount of results to return 109 | - `page`: Result page number limited by `limit` number in a page 110 | - 'target': search string to limit results; meaning depend on hook 111 | 112 | .. versionchanged:: 2.0.0 113 | """ 114 | pass 115 | 116 | def __call__(self, data): 117 | """ 118 | Maps function call to store. 119 | 120 | :Parameters: 121 | - `data`: Data to store. 122 | """ 123 | self.store(data) 124 | data["_parent_class_name"] = self.__class__.__name__ 125 | data['_parent_self'] = self 126 | for hook in self._post_storage_hooks: 127 | hook(**data) 128 | return data 129 | 130 | 131 | class Writer(_BaseWritable): 132 | """ 133 | Write only classes used to store but not do metrics. 134 | """ 135 | pass 136 | 137 | 138 | class Storage(_BaseWritable): 139 | """ 140 | Subclass for a more intellegent storage callable. 141 | """ 142 | 143 | def _get_usage(self, start_date=None, end_date=None, limit=500, page=1): 144 | """ 145 | Implements simple usage information by criteria in a standard list form 146 | 147 | :Parameters: 148 | - `start_date`: datetime.datetime representation of starting date 149 | - `end_date`: datetime.datetime representation of ending date 150 | - `limit`: The max amount of results to return 151 | - `page`: Result page number limited by `limit` number in a page 152 | """ 153 | raise NotImplementedError('get_usage must be implemented.') 154 | 155 | def get_usage(self, start_date=None, end_date=None, limit=500, page=1): 156 | """ 157 | Returns simple usage information by criteria in a standard list form. 158 | 159 | .. note:: 160 | *limit* is the amount if items returned per *page*. 161 | If *page* is not incremented you will always receive the 162 | first *limit* amount of results. 163 | 164 | :Parameters: 165 | - `start_date`: datetime.datetime representation of starting date 166 | - `end_date`: datetime.datetime representation of ending date 167 | - `limit`: The max amount of results to return 168 | - `page`: Result page number limited by `limit` number in a page 169 | 170 | .. versionadded:: 1.0.0 171 | The *page* parameter. 172 | """ 173 | raw_data = self._get_usage(start_date, end_date, limit, page) 174 | if type(raw_data) != list: 175 | raise Exception( 176 | 'Container returned from _get_usage ' 177 | 'does not conform to the spec.') 178 | for item in raw_data: 179 | if type(item) != dict: 180 | raise Exception( 181 | 'An item returned from _get_usage ' 182 | 'does not conform to the spec.') 183 | return raw_data 184 | -------------------------------------------------------------------------------- /src/flask_track_usage/storage/couchdb.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2018 Steve Milner 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are 6 | # met: 7 | # 8 | # (1) Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 11 | # (2) Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in 13 | # the documentation and/or other materials provided with the 14 | # distribution. 15 | # 16 | # (3)The name of the author may not be used to 17 | # endorse or promote products derived from this software without 18 | # specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 21 | # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | # DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, 24 | # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 27 | # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 28 | # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING 29 | # IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | # POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | Simple couchdb storage. 33 | """ 34 | import json 35 | 36 | from flask_track_usage.storage import Storage 37 | 38 | from datetime import datetime 39 | try: 40 | from couchdb.mapping import ( 41 | Document, TextField, IntegerField, 42 | DateTimeField, FloatField, BooleanField, ViewField) 43 | 44 | class UsageData(Document): 45 | """ 46 | Document that represents the stored data. 47 | """ 48 | url = TextField() 49 | ua_browser = TextField() 50 | ua_language = TextField() 51 | ua_platform = TextField() 52 | ua_version = TextField() 53 | blueprint = TextField() 54 | view_args = TextField() 55 | status = IntegerField() 56 | remote_addr = TextField() 57 | authorization = BooleanField() 58 | ip_info = TextField() 59 | path = TextField() 60 | speed = FloatField() 61 | datetime = DateTimeField(default=datetime.now) 62 | username = TextField() 63 | track_var = TextField() 64 | by_date = ViewField('start-end', '''function(doc, req) { 65 | if (!doc._conflicts) { 66 | emit(doc.datetime, doc); 67 | } 68 | }''') 69 | 70 | except ImportError: 71 | pass 72 | 73 | 74 | class _CouchDBStorage(Storage): 75 | """ 76 | Parent storage class for CouchDB storage. 77 | """ 78 | 79 | def store(self, data): 80 | """ 81 | Executed on "function call". 82 | 83 | :Parameters: 84 | - `data`: Data to store. 85 | """ 86 | user_agent = data['user_agent'] 87 | utcdatetime = datetime.fromtimestamp(data['date']) 88 | usage_data = UsageData(url=data['url'], 89 | ua_browser=user_agent.browser, 90 | ua_language=user_agent.language, 91 | ua_platform=user_agent.platform, 92 | ua_version=user_agent.version, 93 | blueprint=data["blueprint"], 94 | view_args=json.dumps(data["view_args"], 95 | ensure_ascii=False), 96 | status=data["status"], 97 | remote_addr=data["remote_addr"], 98 | authorization=data["authorization"], 99 | ip_info=data["ip_info"], 100 | path=data["path"], 101 | speed=data["speed"], 102 | username=data["username"], 103 | track_var=data["track_var"], 104 | datetime=utcdatetime) 105 | usage_data.store(self.db) 106 | 107 | def _get_usage(self, start_date=None, end_date=None, limit=500, page=1): 108 | """ 109 | Implements the simple usage information by criteria in a standard form. 110 | 111 | :Parameters: 112 | - `start_date`: datetime.datetime representation of starting date 113 | - `end_date`: datetime.datetime representation of ending date 114 | - `limit`: The max amount of results to return 115 | - `page`: Result page number limited by `limit` number in a page 116 | """ 117 | UsageData.by_date.sync(self.db) 118 | data = self.db.query(UsageData.by_date.map_fun, 119 | startkey=str(start_date), endkey=str(end_date), 120 | limit=limit) 121 | return [row.value for row in data] 122 | 123 | 124 | class CouchDBStorage(_CouchDBStorage): 125 | """ 126 | Creates it's own connection for storage. 127 | 128 | .. versionadded:: 1.1.1 129 | """ 130 | 131 | def set_up(self, database, host='127.0.0.1', port=5984, 132 | protocol='http', username=None, password=None): 133 | """ 134 | Sets the collection. 135 | 136 | :Parameters: 137 | - `database`: Name of the database to use. 138 | - `collection`: Name of the collection to use. 139 | - `host`: Host to conenct to. Default: 127.0.0.1 140 | - `port`: Port to connect to. Default: 27017 141 | - `username`: Optional username to authenticate with. 142 | - `password`: Optional password to authenticate with. 143 | """ 144 | import couchdb 145 | from couchdb.http import PreconditionFailed 146 | self.connection = couchdb.Server("{0}://{1}:{2}".format(protocol, 147 | host, port)) 148 | try: 149 | self.db = self.connection.create(database) 150 | except PreconditionFailed as e: 151 | self.db = self.connection[database] 152 | print(e) 153 | -------------------------------------------------------------------------------- /src/flask_track_usage/storage/mongo.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2018 Steve Milner 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are 6 | # met: 7 | # 8 | # (1) Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 11 | # (2) Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in 13 | # the documentation and/or other materials provided with the 14 | # distribution. 15 | # 16 | # (3)The name of the author may not be used to 17 | # endorse or promote products derived from this software without 18 | # specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 21 | # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | # DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, 24 | # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 27 | # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 28 | # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING 29 | # IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | # POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | Simple mongodb storage. 33 | """ 34 | 35 | import datetime 36 | import inspect 37 | 38 | from flask_track_usage.storage import Storage 39 | 40 | 41 | class _MongoStorage(Storage): 42 | """ 43 | Parent storage class for Mongo storage. 44 | """ 45 | 46 | def store(self, data): 47 | """ 48 | Executed on "function call". 49 | 50 | :Parameters: 51 | - `data`: Data to store. 52 | 53 | .. versionchanged:: 1.1.0 54 | xforwardfor item added directly after remote_addr 55 | """ 56 | ua_dict = { 57 | 'browser': data['user_agent'].browser, 58 | 'language': data['user_agent'].language, 59 | 'platform': data['user_agent'].platform, 60 | 'version': data['user_agent'].version, 61 | } 62 | data['date'] = datetime.datetime.fromtimestamp(data['date']) 63 | data['user_agent'] = ua_dict 64 | print(self.collection.insert(data)) 65 | 66 | def _get_usage(self, start_date=None, end_date=None, limit=500, page=1): 67 | """ 68 | Implements the simple usage information by criteria in a standard form. 69 | 70 | :Parameters: 71 | - `start_date`: datetime.datetime representation of starting date 72 | - `end_date`: datetime.datetime representation of ending date 73 | - `limit`: The max amount of results to return 74 | - `page`: Result page number limited by `limit` number in a page 75 | 76 | .. versionchanged:: 1.1.0 77 | xforwardfor item added directly after remote_addr 78 | """ 79 | criteria = {} 80 | 81 | # Set up date based criteria 82 | if start_date or end_date: 83 | criteria['date'] = {} 84 | if start_date: 85 | criteria['date']['$gte'] = start_date 86 | if end_date: 87 | criteria['date']['$lte'] = end_date 88 | 89 | cursor = [] 90 | if limit: 91 | cursor = self.collection.find(criteria).skip( 92 | limit * (page - 1)).limit(limit) 93 | else: 94 | cursor = self.collection.find(criteria) 95 | return [x for x in cursor] 96 | 97 | 98 | class MongoPiggybackStorage(_MongoStorage): 99 | """ 100 | Uses a pymongo collection to store data. 101 | """ 102 | 103 | def set_up(self, collection, hooks=None): 104 | """ 105 | Sets the collection. 106 | 107 | :Parameters: 108 | - `collection`: A pymongo collection (not database or connection). 109 | """ 110 | self.collection = collection 111 | 112 | 113 | class MongoStorage(_MongoStorage): 114 | """ 115 | Creates it's own connection for storage. 116 | """ 117 | 118 | def set_up( 119 | self, database, collection, host='127.0.0.1', 120 | port=27017, username=None, password=None, hooks=None): 121 | """ 122 | Sets the collection. 123 | 124 | :Parameters: 125 | - `database`: Name of the database to use. 126 | - `collection`: Name of the collection to use. 127 | - `host`: Host to conenct to. Default: 127.0.0.1 128 | - `port`: Port to connect to. Default: 27017 129 | - `username`: Optional username to authenticate with. 130 | - `password`: Optional password to authenticate with. 131 | """ 132 | import pymongo 133 | self.connection = pymongo.MongoClient(host, port) 134 | self.db = getattr(self.connection, database) 135 | if username and password: 136 | self.db.authenticate(username, password) 137 | self.collection = getattr(self.db, collection) 138 | 139 | 140 | class MongoEngineStorage(_MongoStorage): 141 | """ 142 | Uses MongoEngine library to store data in MongoDB. 143 | 144 | The resulting collection is named `usageTracking`. 145 | 146 | Should you need to access the actual Document class that this storage uses, 147 | you can pull it from `collection` *instance* attribute. For example: :: 148 | 149 | trackerDoc = MongoEngineStorage().collection 150 | """ 151 | 152 | def set_up(self, doc=None, website=None, apache_log=False, hooks=None): 153 | import mongoengine as db 154 | """ 155 | Sets the general settings. 156 | 157 | :Parameters: 158 | - `doc`: optional alternate MongoEngine document class. 159 | - 'website': name for the website. Defaults to 'default'. Useful 160 | when multiple websites are saving data to the same collection. 161 | - 'apache_log': if set to True, then an attribute called 162 | 'apache_combined_log' is set that mimics a line from a traditional 163 | apache webserver web log text file. 164 | 165 | .. versionchanged:: 2.0.0 166 | """ 167 | 168 | class UserAgent(db.EmbeddedDocument): 169 | browser = db.StringField() 170 | language = db.StringField() 171 | platform = db.StringField() 172 | version = db.StringField() 173 | string = db.StringField() 174 | 175 | class UsageTracker(db.Document): 176 | date = db.DateTimeField( 177 | required=True, 178 | default=datetime.datetime.utcnow 179 | ) 180 | website = db.StringField(required=True, default="default") 181 | server_name = db.StringField(default="self") 182 | blueprint = db.StringField(default=None) 183 | view_args = db.DictField() 184 | ip_info = db.StringField() 185 | xforwardedfor = db.StringField() 186 | path = db.StringField() 187 | speed = db.FloatField() 188 | remote_addr = db.StringField() 189 | url = db.StringField() 190 | status = db.IntField() 191 | authorization = db.BooleanField() 192 | content_length = db.IntField() 193 | url_args = db.DictField() 194 | username = db.StringField() 195 | user_agent = db.EmbeddedDocumentField(UserAgent) 196 | track_var = db.DictField() 197 | apache_combined_log = db.StringField() 198 | meta = { 199 | 'collection': "usageTracking" 200 | } 201 | 202 | self.collection = doc or UsageTracker 203 | # self.user_agent = UserAgent 204 | self.website = website or 'default' 205 | self.apache_log = apache_log 206 | 207 | def store(self, data): 208 | doc = self.collection() 209 | doc.date = datetime.datetime.fromtimestamp(data['date']) 210 | doc.website = self.website 211 | doc.server_name = data['server_name'] 212 | doc.blueprint = data['blueprint'] 213 | doc.view_args = data['view_args'] 214 | doc.ip_info = data['ip_info'] 215 | doc.xforwardedfor = data['xforwardedfor'] 216 | doc.path = data['path'] 217 | doc.speed = data['speed'] 218 | doc.remote_addr = data['remote_addr'] 219 | doc.url = data['url'] 220 | doc.status = data['status'] 221 | doc.authorization = data['authorization'] 222 | doc.content_length = data['content_length'] 223 | doc.url_args = data['url_args'] 224 | doc.username = data['username'] 225 | doc.track_var = data['track_var'] 226 | # the following is VERY MUCH A HACK to allow a passed 'doc' on set_up 227 | ua = doc._fields['user_agent'].document_type_obj() 228 | ua.browser = data['user_agent'].browser 229 | if data['user_agent'].language: 230 | ua.language = data['user_agent'].language 231 | ua.platform = data['user_agent'].platform 232 | if data['user_agent'].version: 233 | ua.version = str(data['user_agent'].version) 234 | ua.string = data['user_agent'].string 235 | doc.user_agent = ua 236 | if self.apache_log: 237 | t = '{h} - {u} [{t}] "{r}" {s} {b} "{ref}" "{ua}"'.format( 238 | h=data['remote_addr'], 239 | u=data["username"] or '-', 240 | t=doc.date.strftime("%d/%b/%Y:%H:%M:%S %z"), 241 | r=data.get("request", '?'), 242 | s=data['status'], 243 | b=data['content_length'], 244 | ref=data['url'], 245 | ua=str(data['user_agent']) 246 | ) 247 | doc.apache_combined_log = t 248 | doc.save() 249 | data['mongoengine_document'] = doc 250 | return data 251 | 252 | def _get_usage(self, start_date=None, end_date=None, limit=500, page=1): 253 | """ 254 | Implements the simple usage information by criteria in a standard form. 255 | 256 | :Parameters: 257 | - `start_date`: datetime.datetime representation of starting date 258 | - `end_date`: datetime.datetime representation of ending date 259 | - `limit`: The max amount of results to return 260 | - `page`: Result page number limited by `limit` number in a page 261 | 262 | .. versionchanged:: 2.0.0 263 | """ 264 | query = {} 265 | if start_date: 266 | query["date__gte"] = start_date 267 | if end_date: 268 | query["date__lte"] = end_date 269 | if limit: 270 | first = limit * (page - 1) 271 | last = limit * page 272 | logs = self.collection.objects( 273 | **query 274 | ).order_by('-date')[first:last] 275 | else: 276 | logs = self.collection.objects(**query).order_by('-date') 277 | result = [log.to_mongo().to_dict() for log in logs] 278 | return result 279 | 280 | def get_sum( 281 | self, 282 | hook, 283 | start_date=None, 284 | end_date=None, 285 | limit=500, 286 | page=1, 287 | target=None 288 | ): 289 | """ 290 | Queries a subtending hook for summarization data. 291 | 292 | :Parameters: 293 | - 'hook': the hook 'class' or it's name as a string 294 | - `start_date`: datetime.datetime representation of starting date 295 | - `end_date`: datetime.datetime representation of ending date 296 | - `limit`: The max amount of results to return 297 | - `page`: Result page number limited by `limit` number in a page 298 | - 'target': search string to limit results; meaning depend on hook 299 | 300 | 301 | .. versionchanged:: 2.0.0 302 | """ 303 | if inspect.isclass(hook): 304 | hook_name = hook.__name__ 305 | else: 306 | hook_name = str(hook) 307 | for h in self._post_storage_hooks: 308 | if h.__class__.__name__ == hook_name: 309 | return h.get_sum( 310 | start_date=start_date, 311 | end_date=end_date, 312 | limit=limit, 313 | page=page, 314 | target=target, 315 | _parent_class_name=self.__class__.__name__, 316 | _parent_self=self 317 | ) 318 | raise NotImplementedError( 319 | 'Cannot find hook named "{}"'.format(hook_name) 320 | ) 321 | -------------------------------------------------------------------------------- /src/flask_track_usage/storage/output.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2018 Steve Milner 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are 6 | # met: 7 | # 8 | # (1) Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 11 | # (2) Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in 13 | # the documentation and/or other materials provided with the 14 | # distribution. 15 | # 16 | # (3)The name of the author may not be used to 17 | # endorse or promote products derived from this software without 18 | # specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 21 | # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | # DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, 24 | # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 27 | # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 28 | # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING 29 | # IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | # POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | Output writer. 33 | """ 34 | 35 | from flask_track_usage.storage import Writer 36 | 37 | 38 | class OutputWriter(Writer): 39 | """ 40 | Writes data to a provided file like object. 41 | """ 42 | 43 | def set_up(self, output=None, transform=None): 44 | """ 45 | Sets the file like object. 46 | 47 | .. note:: 48 | 49 | Make sure to pass in instances which allow multiple writes. 50 | 51 | :Parameters: 52 | - `output`: A file like object to use. Default: sys.stderr 53 | - `transform`: Optional function to modify the output before write. 54 | """ 55 | self.transform = transform 56 | if self.transform is None: 57 | self.transform = str 58 | 59 | if output is None: 60 | import sys 61 | output = sys.stderr 62 | 63 | if not hasattr(output, 'write'): 64 | raise TypeError('A file like object must have a write method') 65 | elif not hasattr(output, 'writable'): 66 | raise TypeError('A file like object must have a writable method') 67 | elif not output.writable(): 68 | raise ValueError('Provided file like object is not writable') 69 | 70 | self.flushable = False 71 | if hasattr(output, 'flush'): 72 | self.flushable = True 73 | 74 | self.output = output 75 | 76 | def store(self, data): 77 | """ 78 | Executed on "function call". 79 | 80 | :Parameters: 81 | - `data`: Data to store. 82 | """ 83 | self.output.write(self.transform(data)) 84 | if self.flushable: 85 | self.output.flush() 86 | -------------------------------------------------------------------------------- /src/flask_track_usage/storage/printer.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2018 Steve Milner 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are 6 | # met: 7 | # 8 | # (1) Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 11 | # (2) Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in 13 | # the documentation and/or other materials provided with the 14 | # distribution. 15 | # 16 | # (3)The name of the author may not be used to 17 | # endorse or promote products derived from this software without 18 | # specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 21 | # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | # DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, 24 | # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 27 | # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 28 | # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING 29 | # IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | # POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | Print writer. 33 | """ 34 | 35 | from flask_track_usage.storage import Writer 36 | 37 | 38 | class PrintWriter(Writer): 39 | """ 40 | Simply prints out the data it gets. Helpful for testing. 41 | """ 42 | 43 | def store(self, data): 44 | """ 45 | Executed on "function call". 46 | 47 | :Parameters: 48 | - `data`: Data to store. 49 | """ 50 | print(data) 51 | 52 | 53 | # For backwards compatability 54 | PrintStorage = PrintWriter 55 | -------------------------------------------------------------------------------- /src/flask_track_usage/storage/redis_db.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2018 Steve Milner 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are 6 | # met: 7 | # 8 | # (1) Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 11 | # (2) Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in 13 | # the documentation and/or other materials provided with the 14 | # distribution. 15 | # 16 | # (3)The name of the author may not be used to 17 | # endorse or promote products derived from this software without 18 | # specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 21 | # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | # DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, 24 | # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 27 | # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 28 | # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING 29 | # IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | # POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | Simple redis storage. 33 | """ 34 | import json 35 | 36 | from datetime import datetime 37 | from ast import literal_eval 38 | from flask_track_usage.storage import Storage 39 | 40 | 41 | class _RedisStorage(Storage): 42 | """ 43 | Parent storage class for Redis storage. 44 | """ 45 | 46 | def store(self, data): 47 | """ 48 | Executed on "function call". 49 | 50 | :Parameters: 51 | - `data`: Data to store. 52 | """ 53 | user_agent = data['user_agent'] 54 | utcdatetime = datetime.fromtimestamp(data['date']) 55 | d = { 56 | 'url': data['url'], 57 | 'ua_browser': user_agent.browser or "", 58 | 'ua_language': user_agent.language or "", 59 | 'ua_platform': user_agent.platform or "", 60 | 'ua_version': user_agent.version or "", 61 | 'blueprint': data["blueprint"] or "", 62 | 'view_args': json.dumps(data["view_args"], ensure_ascii=False), 63 | 'status': data["status"] or "", 64 | 'remote_addr': data["remote_addr"] or "", 65 | 'authorization': data["authorization"] or "", 66 | 'ip_info': data["ip_info"] or "", 67 | 'path': data["path"] or "", 68 | 'speed': data["speed"] or "", 69 | 'username': data["username"] or "", 70 | 'track_var': data["track_var"] or "", 71 | 'datetime': str(utcdatetime) or "" 72 | } 73 | struct_name = self._construct_struct_name(utcdatetime) 74 | # create a set which will be used as an index, in order not to use 75 | # redis> keys 76 | # Always try to add to avoid a network call 77 | self.db.sadd("usage_data_keys", struct_name) 78 | previous = len(self.db.hkeys(struct_name)) 79 | self.db.hset(struct_name, previous + 1, json.dumps(d)) 80 | 81 | def _get_usage(self, start_date=None, end_date=None, limit=500, page=1): 82 | """ 83 | Implements the simple usage information by criteria in a standard form. 84 | 85 | :Parameters: 86 | - `start_date`: datetime.datetime representation of starting date 87 | - `end_date`: datetime.datetime representation of ending date 88 | - `limit`: The max amount of results to return 89 | - `page`: Result page number limited by `limit` number in a page 90 | """ 91 | struct_name_start = self._construct_struct_name( 92 | start_date or datetime.now()) 93 | struct_name_end = self._construct_struct_name( 94 | end_date or datetime(1970, 1, 1, 0, 0, 0)) 95 | 96 | # make a pattern that looks like usage_data:20160* 97 | stop = self._pattern_stop(struct_name_start, struct_name_end) 98 | pattern = self._pattern(struct_name_start, stop) 99 | (response, keys) = self.db.sscan("usage_data_keys", 0, pattern, 100 | count=limit) 101 | 102 | data = [self.db.hgetall(key) for key in keys] 103 | # TODO: pipeline data request 104 | items = [] 105 | for d in data: 106 | for item in list(d.values()): 107 | try: 108 | # skip items when errors occur 109 | items.append(literal_eval(item)) 110 | except: 111 | continue 112 | return items 113 | 114 | @staticmethod 115 | def _construct_struct_name(date): 116 | """ 117 | Construct a name based on a given date, that will be used as a key 118 | identifier. 119 | 120 | :Parameters: 121 | - `date`: Date to use as part of the construct key. 122 | """ 123 | # Strip away the - from the date 124 | tmp = "".join(str(date.date()).rsplit("-")) 125 | 126 | # save the data in a hash set with this format: 127 | # usage_data:20180316 1 your-data 128 | return "usage_data:{0}".format(tmp) 129 | 130 | @staticmethod 131 | def _pattern_stop(date1, date2): 132 | """ 133 | Find where there is a difference in the pattern. 134 | 135 | :Parameters: 136 | - `date1`: First datetime instance to compare. 137 | - `date2`: Second datetime instance to compare. 138 | """ 139 | for i in range(len(date1)): 140 | if date1[i] != date2[i]: 141 | return i 142 | return -1 143 | 144 | @staticmethod 145 | def _pattern(date, stop): 146 | """Generate a pattern based on a date and when the date should stop, 147 | e.g. 201607*, in this case it excludes the day""" 148 | return "".join(date[:stop]) + "*" 149 | 150 | 151 | class RedisStorage(_RedisStorage): 152 | """ 153 | Creates it's own connection for storage. 154 | 155 | .. versionadded:: 1.1.1 156 | """ 157 | 158 | def set_up(self, host='127.0.0.1', port=6379, password=None, url=None): 159 | """ 160 | Sets up redis and checks that you have connected to it. 161 | 162 | :Parameters: 163 | - `host`: Host to conenct to. Default: 127.0.0.1 164 | - `port`: Port to connect to. Default: 27017 165 | - `password`: Optional password to authenticate with. 166 | """ 167 | from redis import Redis 168 | if url: 169 | self.db = Redis.from_url(url) 170 | else: 171 | self.db = Redis.from_url("redis://{0}:{1}".format(host, str(port))) 172 | assert self.db is not None 173 | assert self.db.ping() is True 174 | -------------------------------------------------------------------------------- /src/flask_track_usage/storage/sql.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014 Gouthaman Balaraman 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are 6 | # met: 7 | # 8 | # (1) Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 11 | # (2) Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in 13 | # the documentation and/or other materials provided with the 14 | # distribution. 15 | # 16 | # (3)The name of the author may not be used to 17 | # endorse or promote products derived from this software without 18 | # specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 21 | # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | # DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, 24 | # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 27 | # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 28 | # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING 29 | # IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | # POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | SQL storage based on SQLAlchemy 33 | """ 34 | 35 | from . import Storage 36 | import json 37 | import datetime 38 | 39 | 40 | class SQLStorage(Storage): 41 | """ 42 | Uses SQLAlchemy to connect to various databases such as SQLite, Oracle, 43 | MySQL, Postgres, etc. Please SQLAlchemy wrapper for full support and 44 | functionalities. 45 | 46 | .. versionadded:: 1.0.0 47 | SQLStorage was added. 48 | .. versionchanged:: 1.1.0 49 | Initialization no longer accepts a connection string. 50 | .. versionchanged:: 1.1.0 51 | A SQLAlchemy engine instance can optionally be passed in. 52 | .. versionchanged:: 1.1.0 53 | A SQLAlchemy metadata instance can optionally be passed in. 54 | """ 55 | 56 | def set_up(self, engine=None, metadata=None, table_name="flask_usage", 57 | db=None, hooks=None): 58 | """ 59 | Sets the SQLAlchemy database. There are two ways to initialize the 60 | SQLStorage: 1) by passing the SQLAlchemy `engine` and `metadata` 61 | instances or 2) by passing the Flask-SQLAlchemy's `SQLAlchemy` 62 | object `db`. 63 | 64 | :Parameters: 65 | - `engine`: The SQLAlchemy engine object 66 | - `metadata`: The SQLAlchemy MetaData object 67 | - `table_name`: Table name for storing the analytics. Defaults to \ 68 | `flask_usage`. Summary tables use this name as a \ 69 | prefix to their name. 70 | - `db`: Instead of providing the engine, one can optionally 71 | provide the Flask-SQLAlchemy's SQLALchemy object created as 72 | SQLAlchemy(app). 73 | 74 | .. versionchanged:: 1.1.0 75 | xforwardfor column added directly after remote_addr 76 | .. versionchanged:: 2.0.0 77 | table is created if it does not already exist 78 | added summary tables 79 | """ 80 | 81 | import sqlalchemy as sql 82 | if db: 83 | self._eng = db.engine 84 | self._metadata = db.metadata 85 | else: 86 | if engine is None: 87 | raise ValueError("Both db and engine args cannot be None") 88 | self._eng = engine 89 | self._metadata = metadata or sql.MetaData() 90 | self.table_name = table_name 91 | self.sum_tables = {} 92 | self._con = None 93 | with self._eng.begin() as self._con: 94 | if not self._con.dialect.has_table(self._con, table_name): 95 | self.track_table = sql.Table( 96 | table_name, self._metadata, 97 | sql.Column('id', sql.Integer, primary_key=True), 98 | sql.Column('url', sql.String(128)), 99 | sql.Column('ua_browser', sql.String(16)), 100 | sql.Column('ua_language', sql.String(16)), 101 | sql.Column('ua_platform', sql.String(16)), 102 | sql.Column('ua_version', sql.String(16)), 103 | sql.Column('blueprint', sql.String(16)), 104 | sql.Column('view_args', sql.String(64)), 105 | sql.Column('status', sql.Integer), 106 | sql.Column('remote_addr', sql.String(24)), 107 | sql.Column('xforwardedfor', sql.String(24)), 108 | sql.Column('authorization', sql.Boolean), 109 | sql.Column('ip_info', sql.String(1024)), 110 | sql.Column('path', sql.String(128)), 111 | sql.Column('speed', sql.Float), 112 | sql.Column('datetime', sql.DateTime), 113 | sql.Column('username', sql.String(128)), 114 | sql.Column('track_var', sql.String(128)) 115 | ) 116 | # Create the table if it does not exist 117 | self.track_table.create(bind=self._eng) 118 | else: 119 | self._metadata.reflect(bind=self._eng) 120 | self.track_table = self._metadata.tables[table_name] 121 | 122 | def store(self, data): 123 | """ 124 | Executed on "function call". 125 | 126 | :Parameters: 127 | - `data`: Data to store. 128 | 129 | .. versionchanged:: 1.1.0 130 | xforwardfor column added directly after remote_addr 131 | """ 132 | user_agent = data["user_agent"] 133 | utcdatetime = datetime.datetime.fromtimestamp(data['date']) 134 | if data["ip_info"]: 135 | t = {} 136 | for key in data["ip_info"]: 137 | t[key] = data["ip_info"][key] 138 | if not len(json.dumps(t)) < 1024: 139 | del t[key] 140 | break 141 | ip_info_str = json.dumps(t) 142 | else: 143 | ip_info_str = None 144 | with self._eng.begin() as con: 145 | stmt = self.track_table.insert().values( 146 | url=data['url'], 147 | ua_browser=user_agent.browser, 148 | ua_language=user_agent.language, 149 | ua_platform=user_agent.platform, 150 | ua_version=user_agent.version, 151 | blueprint=data["blueprint"], 152 | view_args=json.dumps( 153 | data["view_args"], ensure_ascii=False 154 | )[:64], 155 | status=data["status"], 156 | remote_addr=data["remote_addr"], 157 | xforwardedfor=data["xforwardedfor"], 158 | authorization=data["authorization"], 159 | ip_info=ip_info_str, 160 | path=data["path"], 161 | speed=data["speed"], 162 | datetime=utcdatetime, 163 | username=data["username"], 164 | track_var=json.dumps(data["track_var"], ensure_ascii=False) 165 | ) 166 | con.execute(stmt) 167 | return data 168 | 169 | def _get_usage(self, start_date=None, end_date=None, limit=500, page=1): 170 | """ 171 | This is what translates the raw data into the proper structure. 172 | 173 | :Parameters: 174 | - `start_date`: datetime.datetime representation of starting date 175 | - `end_date`: datetime.datetime representation of ending date 176 | - `limit`: The max amount of results to return 177 | - `page`: Result page number limited by `limit` number in a page 178 | 179 | .. versionchanged:: 1.1.0 180 | xforwardfor column added directly after remote_addr 181 | """ 182 | raw_data = self._get_raw(start_date, end_date, limit, page) 183 | usage_data = [ 184 | { 185 | 'url': r[1], 186 | 'user_agent': { 187 | 'browser': r[2], 188 | 'language': r[3], 189 | 'platform': r[4], 190 | 'version': r[5], 191 | }, 192 | 'blueprint': r[6], 193 | 'view_args': r[7] if r[7] != '{}' else None, 194 | 'status': int(r[8]), 195 | 'remote_addr': r[9], 196 | 'xforwardedfor': r[10], 197 | 'authorization': r[11], 198 | 'ip_info': r[12], 199 | 'path': r[13], 200 | 'speed': r[14], 201 | 'date': r[15], 202 | 'username': r[16], 203 | 'track_var': r[17] if r[17] != '{}' else None 204 | } for r in raw_data] 205 | return usage_data 206 | 207 | def _get_raw(self, start_date=None, end_date=None, limit=500, page=1): 208 | """ 209 | This is the raw getter from database 210 | 211 | :Parameters: 212 | - `start_date`: datetime.datetime representation of starting date 213 | - `end_date`: datetime.datetime representation of ending date 214 | - `limit`: The max amount of results to return 215 | - `page`: Result page number limited by `limit` number in a page 216 | 217 | .. versionchanged:: 1.1.0 218 | xforwardfor column added directly after remote_addr 219 | """ 220 | import sqlalchemy as sql 221 | page = max(1, page) # min bound 222 | if end_date is None: 223 | end_date = datetime.datetime.utcnow() 224 | if start_date is None: 225 | start_date = datetime.datetime(1970, 1, 1) 226 | with self._eng.begin() as con: 227 | _table = self.track_table 228 | stmt = sql.select([self.track_table]).where( 229 | _table.c.datetime.between(start_date, end_date)).limit( 230 | limit).offset( 231 | limit * (page - 1)).order_by( 232 | sql.desc(self.track_table.c.datetime)) 233 | res = con.execute(stmt) 234 | result = res.fetchall() 235 | return result 236 | -------------------------------------------------------------------------------- /src/flask_track_usage/summarization/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_track_usage.summarization import mongoenginestorage, sqlstorage 2 | 3 | """ 4 | Summarization routines. 5 | 6 | This file contains the generic summary functions. They are, essentially, stubs 7 | that search for the correct storage support based on the class information 8 | passed. 9 | """ 10 | 11 | 12 | def _set_up(sum_name, **kwargs): 13 | method_name = "{}_set_up".format(sum_name) 14 | if "_parent_class_name" not in kwargs: 15 | raise NotImplementedError( 16 | "{} can only be used as a Storage class hook.".format(method_name) 17 | ) 18 | try: 19 | lib_name = kwargs["_parent_class_name"].lower() 20 | library = globals()[lib_name] 21 | except KeyError: 22 | raise ImportError( 23 | "the {} class does not currently support" 24 | " summarization.".format(kwargs["_parent_class_name"]) 25 | ) 26 | try: 27 | method = getattr(library, method_name) 28 | except AttributeError: 29 | # not having *_set_up is fine: gracefully return 30 | return 31 | method(**kwargs) 32 | 33 | 34 | def _caller(method_name, **kwargs): 35 | if "_parent_class_name" not in kwargs: 36 | raise NotImplementedError( 37 | "{} can only be used as a Storage class hook.".format(method_name) 38 | ) 39 | try: 40 | lib_name = kwargs["_parent_class_name"].lower() 41 | library = globals()[lib_name] 42 | except KeyError: 43 | raise ImportError( 44 | "the {} class does not currently support" 45 | " summarization.".format(kwargs["_parent_class_name"]) 46 | ) 47 | try: 48 | method = getattr(library, method_name) 49 | except AttributeError: 50 | raise NotImplementedError( 51 | '{} not implemented for this Storage class.'.format(method_name) 52 | ) 53 | method(**kwargs) 54 | 55 | 56 | def _get_sum(sum_name, **kwargs): 57 | method_name = "{}_get_sum".format(sum_name) 58 | if "_parent_class_name" not in kwargs: 59 | raise NotImplementedError( 60 | "{} can only be used as a Storage class hook.".format(method_name) 61 | ) 62 | try: 63 | lib_name = kwargs["_parent_class_name"].lower() 64 | library = globals()[lib_name] 65 | except KeyError: 66 | raise ImportError( 67 | "the {} class does not currently support" 68 | " summarization.".format(kwargs["_parent_class_name"]) 69 | ) 70 | try: 71 | method = getattr(library, method_name) 72 | except AttributeError: 73 | raise NotImplementedError( 74 | '{}.get_sum missing for this Storage class.'.format( 75 | sum_name, method_name 76 | ) 77 | ) 78 | return method(**kwargs) 79 | 80 | 81 | class sumUrl(object): 82 | """ 83 | Traffic is summarized for each full URL seen. 84 | """ 85 | def __init__(self, *args, **kwargs): 86 | pass 87 | 88 | def __call__(self, **kwargs): 89 | _caller("sumUrl", **kwargs) 90 | 91 | def set_up(self, **kwargs): 92 | self.init_kwargs = kwargs 93 | _set_up("sumUrl", **kwargs) 94 | return 95 | 96 | def get_sum(self, **kwargs): 97 | return _get_sum("sumUrl", **kwargs) 98 | 99 | 100 | class sumRemote(object): 101 | """ 102 | Traffic is summarized for each remote IP address seen by the Flask server. 103 | """ 104 | def __init__(self, *args, **kwargs): 105 | pass 106 | 107 | def __call__(self, **kwargs): 108 | _caller("sumRemote", **kwargs) 109 | 110 | def set_up(self, **kwargs): 111 | self.init_kwargs = kwargs 112 | _set_up("sumRemote", **kwargs) 113 | return 114 | 115 | def get_sum(self, **kwargs): 116 | return _get_sum("sumRemote", **kwargs) 117 | 118 | 119 | class sumUserAgent(object): 120 | """ 121 | Traffic is summarized for each client (aka web browser) seen by the Flask 122 | server. 123 | """ 124 | def __init__(self, *args, **kwargs): 125 | pass 126 | 127 | def __call__(self, **kwargs): 128 | _caller("sumUserAgent", **kwargs) 129 | 130 | def set_up(self, **kwargs): 131 | self.init_kwargs = kwargs 132 | _set_up("sumUserAgent", **kwargs) 133 | return 134 | 135 | def get_sum(self, **kwargs): 136 | return _get_sum("sumUserAgent", **kwargs) 137 | 138 | 139 | class sumLanguage(object): 140 | """ 141 | Traffic is summarized for each language seen in the requests sent to the 142 | Flask server. 143 | """ 144 | def __init__(self, *args, **kwargs): 145 | pass 146 | 147 | def __call__(self, **kwargs): 148 | _caller("sumLanguage", **kwargs) 149 | 150 | def set_up(self, **kwargs): 151 | self.init_kwargs = kwargs 152 | _set_up("sumLanguage", **kwargs) 153 | return 154 | 155 | def get_sum(self, **kwargs): 156 | return _get_sum("sumLanguage", **kwargs) 157 | 158 | 159 | class sumServer(object): 160 | """ 161 | Traffic is summarized for all requests sent to the Flask server. This 162 | metric is mostly useful for diagnosing performance. 163 | """ 164 | def __init__(self, *args, **kwargs): 165 | pass 166 | 167 | def __call__(self, **kwargs): 168 | _caller("sumServer", **kwargs) 169 | 170 | def set_up(self, **kwargs): 171 | self.init_kwargs = kwargs 172 | _set_up("sumServer", **kwargs) 173 | return 174 | 175 | def get_sum(self, **kwargs): 176 | return _get_sum("sumServer", **kwargs) 177 | 178 | # # TBD 179 | # class sumVisitor(object): 180 | # """ 181 | # Traffic is summarized for each unique visitor of the Flask server. For 182 | # this 183 | # to function, the optional TRACK_USAGE_COOKIE function must be enabled in 184 | # config. 185 | 186 | # This metric is limited by the cookie technology. User behavior such as 187 | # switching browsers or turning on "anonymous mode" on a browser will make 188 | # them appear to be multiple users. 189 | # """ 190 | # def set_up(self, **kwargs): 191 | # self.init_kwargs = kwargs 192 | # _set_up("sumVisitor", **kwargs) 193 | # return 194 | 195 | # def __call__(self, **kwargs): 196 | # _caller("sumVisitor", **kwargs) 197 | 198 | 199 | # # TBD 200 | # class sumGeo(object): 201 | # """ 202 | # Traffic is summarized for the tracked geographies of remote IPs seen by 203 | # the 204 | # Flask server. For this to properly function, the optional 205 | # TRACK_USAGE_FREEGEOIP config must be enabled. While the geography 206 | # function 207 | # provides a great deal of information, only the country is used for this 208 | # summarization. 209 | # """ 210 | # def set_up(self, **kwargs): 211 | # self.init_kwargs = kwargs 212 | # _set_up("sumGeo", **kwargs) 213 | # return 214 | 215 | # def __call__(self, **kwargs): 216 | # _caller("sumGeo", **kwargs) 217 | 218 | # # TBD 219 | # class sumBasic(object): 220 | # """ 221 | # A shortcut that, in turn, calls sumUrl, sumRemote, sumUserAgent, 222 | # sumLanguage, and sumServer 223 | # """ 224 | # def set_up(self, **kwargs): 225 | # self.init_kwargs = kwargs 226 | # _set_up("sumUrl", **kwargs) 227 | # _set_up("sumRemote", **kwargs) 228 | # _set_up("sumUserAgent", **kwargs) 229 | # _set_up("sumLanguage", **kwargs) 230 | # _set_up("sumServer", **kwargs) 231 | # return 232 | 233 | # def __call__(self, **kwargs): 234 | # _caller("sumUrl", **kwargs) 235 | # _caller("sumRemote", **kwargs) 236 | # _caller("sumUserAgent", **kwargs) 237 | # _caller("sumLanguage", **kwargs) 238 | # _caller("sumServer", **kwargs) 239 | -------------------------------------------------------------------------------- /src/flask_track_usage/summarization/mongoenginestorage.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | try: 3 | import mongoengine as db 4 | MONGOENGINE_MISSING = False 5 | except ImportError: 6 | MONGOENGINE_MISSING = True 7 | 8 | 9 | def _check_environment(**kwargs): 10 | if MONGOENGINE_MISSING: 11 | return False 12 | if 'mongoengine_document' not in kwargs: 13 | return False 14 | return True 15 | 16 | 17 | def trim_times(date): 18 | hour = date.replace(minute=0, second=0, microsecond=0) 19 | day = date.replace(hour=0, minute=0, second=0, microsecond=0) 20 | month = date.replace(day=1, hour=0, minute=0, second=0, microsecond=0) 21 | return hour, day, month 22 | 23 | 24 | def trim_times_dict(date): 25 | h, d, m = trim_times(date) 26 | return {"hour": h, "day": d, "month": m} 27 | 28 | 29 | def increment(class_dict, src, dest, target_list): 30 | times = trim_times_dict(src.date) 31 | db_args = {} 32 | if dest: 33 | value = src 34 | for key in target_list: 35 | value = value[key] 36 | db_args[dest] = value 37 | for period in ["hour", "day", "month"]: 38 | doc = class_dict[period].objects(date=times[period], **db_args).first() 39 | if not doc: 40 | doc = class_dict[period]() 41 | doc.date = times[period] 42 | if dest: 43 | doc[dest] = value 44 | doc.hits += 1 45 | doc.transfer += src.content_length 46 | doc.save() 47 | 48 | 49 | def generic_get_sum( 50 | class_dict, 51 | key, 52 | start_date=None, 53 | end_date=None, 54 | limit=500, 55 | page=1, 56 | target=None, 57 | _parent_class_name=None, 58 | _parent_self=None 59 | ): 60 | # note: for mongoegine, we can ignore _parent* parms as the module 61 | # is global 62 | final = {} 63 | query = {} 64 | if start_date and not end_date: 65 | normal_startstop = trim_times_dict(start_date) 66 | else: 67 | if start_date: 68 | query["date__gte"] = start_date 69 | if end_date: 70 | query["date__lte"] = end_date 71 | if target is not None: 72 | query[key] = target 73 | for period in class_dict.keys(): 74 | if start_date and not end_date: 75 | query["date"] = normal_startstop[period] 76 | if limit: 77 | first = limit * (page - 1) 78 | last = limit * page 79 | logs = class_dict[period].objects( 80 | **query 81 | ).order_by('-date')[first:last] 82 | else: 83 | logs = class_dict[period].objects(**query).order_by('-date') 84 | final[period] = [log.to_mongo().to_dict() for log in logs] 85 | return final 86 | 87 | 88 | ###################################################### 89 | # 90 | # sumURL 91 | # 92 | ###################################################### 93 | 94 | if MONGOENGINE_MISSING: 95 | 96 | def sumUrl(**kwargs): 97 | raise NotImplementedError("MongoEngine library not installed") 98 | 99 | else: 100 | 101 | class UsageTrackerSumUrlHourly(db.Document): 102 | url = db.StringField() 103 | date = db.DateTimeField(required=True) 104 | hits = db.IntField(requried=True, default=0) 105 | transfer = db.IntField(required=True, default=0) 106 | meta = { 107 | 'collection': "usageTracking_sumUrl_hourly" 108 | } 109 | 110 | class UsageTrackerSumUrlDaily(db.Document): 111 | url = db.StringField() 112 | date = db.DateTimeField(required=True) 113 | hits = db.IntField(requried=True, default=0) 114 | transfer = db.IntField(required=True, default=0) 115 | meta = { 116 | 'collection': "usageTracking_sumUrl_daily" 117 | } 118 | 119 | class UsageTrackerSumUrlMonthly(db.Document): 120 | url = db.StringField() 121 | date = db.DateTimeField(required=True) 122 | hits = db.IntField(requried=True, default=0) 123 | transfer = db.IntField(required=True, default=0) 124 | meta = { 125 | 'collection': "usageTracking_sumUrl_monthly" 126 | } 127 | 128 | sumUrlClasses = { 129 | "hour": UsageTrackerSumUrlHourly, 130 | "day": UsageTrackerSumUrlDaily, 131 | "month": UsageTrackerSumUrlMonthly, 132 | } 133 | 134 | def sumUrl(**kwargs): 135 | if not _check_environment(**kwargs): 136 | return 137 | src = kwargs['mongoengine_document'] 138 | # 139 | increment(sumUrlClasses, src, "url", ["url"]) 140 | return 141 | 142 | def sumUrl_get_sum(**kwargs): 143 | return generic_get_sum(sumUrlClasses, "url", **kwargs) 144 | 145 | ###################################################### 146 | # 147 | # sumRemote 148 | # 149 | ###################################################### 150 | 151 | if MONGOENGINE_MISSING: 152 | 153 | def sumRemote(**kwargs): 154 | raise NotImplementedError("MongoEngine library not installed") 155 | 156 | else: 157 | 158 | class UsageTrackerSumRemoteHourly(db.Document): 159 | remote_addr = db.StringField() 160 | date = db.DateTimeField(required=True) 161 | hits = db.IntField(requried=True, default=0) 162 | transfer = db.IntField(required=True, default=0) 163 | meta = { 164 | 'collection': "usageTracking_sumRemote_hourly" 165 | } 166 | 167 | class UsageTrackerSumRemoteDaily(db.Document): 168 | remote_addr = db.StringField() 169 | date = db.DateTimeField(required=True) 170 | hits = db.IntField(requried=True, default=0) 171 | transfer = db.IntField(required=True, default=0) 172 | meta = { 173 | 'collection': "usageTracking_sumRemote_daily" 174 | } 175 | 176 | class UsageTrackerSumRemoteMonthly(db.Document): 177 | remote_addr = db.StringField() 178 | date = db.DateTimeField(required=True) 179 | hits = db.IntField(requried=True, default=0) 180 | transfer = db.IntField(required=True, default=0) 181 | meta = { 182 | 'collection': "usageTracking_sumRemote_monthly" 183 | } 184 | 185 | sumRemoteClasses = { 186 | "hour": UsageTrackerSumRemoteHourly, 187 | "day": UsageTrackerSumRemoteDaily, 188 | "month": UsageTrackerSumRemoteMonthly, 189 | } 190 | 191 | def sumRemote(**kwargs): 192 | if not _check_environment(**kwargs): 193 | return 194 | src = kwargs['mongoengine_document'] 195 | # 196 | increment(sumRemoteClasses, src, "remote_addr", ["remote_addr"]) 197 | return 198 | 199 | def sumRemote_get_sum(**kwargs): 200 | return generic_get_sum(sumRemoteClasses, "remote_addr", **kwargs) 201 | 202 | ###################################################### 203 | # 204 | # sumUserAgent 205 | # 206 | ###################################################### 207 | 208 | if MONGOENGINE_MISSING: 209 | 210 | def sumUserAgent(**kwargs): 211 | raise NotImplementedError("MongoEngine library not installed") 212 | 213 | else: 214 | 215 | class UsageTrackerSumUserAgentHourly(db.Document): 216 | user_agent_string = db.StringField() 217 | date = db.DateTimeField(required=True) 218 | hits = db.IntField(requried=True, default=0) 219 | transfer = db.IntField(required=True, default=0) 220 | meta = { 221 | 'collection': "usageTracking_sumUserAgent_hourly" 222 | } 223 | 224 | class UsageTrackerSumUserAgentDaily(db.Document): 225 | user_agent_string = db.StringField() 226 | date = db.DateTimeField(required=True) 227 | hits = db.IntField(requried=True, default=0) 228 | transfer = db.IntField(required=True, default=0) 229 | meta = { 230 | 'collection': "usageTracking_sumUserAgent_daily" 231 | } 232 | 233 | class UsageTrackerSumUserAgentMonthly(db.Document): 234 | user_agent_string = db.StringField() 235 | date = db.DateTimeField(required=True) 236 | hits = db.IntField(requried=True, default=0) 237 | transfer = db.IntField(required=True, default=0) 238 | meta = { 239 | 'collection': "usageTracking_sumUserAgent_monthly" 240 | } 241 | 242 | sumUserAgentClasses = { 243 | "hour": UsageTrackerSumUserAgentHourly, 244 | "day": UsageTrackerSumUserAgentDaily, 245 | "month": UsageTrackerSumUserAgentMonthly, 246 | } 247 | 248 | def sumUserAgent(**kwargs): 249 | if not _check_environment(**kwargs): 250 | return 251 | src = kwargs['mongoengine_document'] 252 | # 253 | increment( 254 | sumUserAgentClasses, 255 | src, 256 | "user_agent_string", 257 | ["user_agent", "string"] 258 | ) 259 | return 260 | 261 | def sumUserAgent_get_sum(**kwargs): 262 | return generic_get_sum( 263 | sumUserAgentClasses, 264 | "user_agent_string", 265 | **kwargs 266 | ) 267 | 268 | ###################################################### 269 | # 270 | # sumLanguage 271 | # 272 | ###################################################### 273 | 274 | if MONGOENGINE_MISSING: 275 | 276 | def sumLanguage(**kwargs): 277 | raise NotImplementedError("MongoEngine library not installed") 278 | 279 | else: 280 | 281 | class UsageTrackerSumLanguageHourly(db.Document): 282 | language = db.StringField(null=True) 283 | date = db.DateTimeField(required=True) 284 | hits = db.IntField(requried=True, default=0) 285 | transfer = db.IntField(required=True, default=0) 286 | meta = { 287 | 'collection': "usageTracking_sumLanguage_hourly" 288 | } 289 | 290 | class UsageTrackerSumLanguageDaily(db.Document): 291 | language = db.StringField(null=True) 292 | date = db.DateTimeField(required=True) 293 | hits = db.IntField(requried=True, default=0) 294 | transfer = db.IntField(required=True, default=0) 295 | meta = { 296 | 'collection': "usageTracking_sumLanguage_daily" 297 | } 298 | 299 | class UsageTrackerSumLanguageMonthly(db.Document): 300 | language = db.StringField(null=True) 301 | date = db.DateTimeField(required=True) 302 | hits = db.IntField(requried=True, default=0) 303 | transfer = db.IntField(required=True, default=0) 304 | meta = { 305 | 'collection': "usageTracking_sumLanguage_monthly" 306 | } 307 | 308 | sumLanguageClasses = { 309 | "hour": UsageTrackerSumLanguageHourly, 310 | "day": UsageTrackerSumLanguageDaily, 311 | "month": UsageTrackerSumLanguageMonthly, 312 | } 313 | 314 | def sumLanguage(**kwargs): 315 | if not _check_environment(**kwargs): 316 | return 317 | src = kwargs['mongoengine_document'] 318 | # 319 | if not src.user_agent.language: 320 | src.user_agent.language = "none" 321 | increment( 322 | sumLanguageClasses, 323 | src, 324 | "language", 325 | ["user_agent", "language"] 326 | ) 327 | return 328 | 329 | def sumLanguage_get_sum(**kwargs): 330 | return generic_get_sum(sumLanguageClasses, "language", **kwargs) 331 | 332 | ###################################################### 333 | # 334 | # sumServer 335 | # 336 | ###################################################### 337 | 338 | if MONGOENGINE_MISSING: 339 | 340 | def sumServer(**kwargs): 341 | raise NotImplementedError("MongoEngine library not installed") 342 | 343 | else: 344 | 345 | class UsageTrackerSumServerHourly(db.Document): 346 | server_name = db.StringField(default="self") 347 | date = db.DateTimeField(required=True) 348 | hits = db.IntField(requried=True, default=0) 349 | transfer = db.IntField(required=True, default=0) 350 | meta = { 351 | 'collection': "usageTracking_sumServer_hourly" 352 | } 353 | 354 | class UsageTrackerSumServerDaily(db.Document): 355 | server_name = db.StringField(default="self") 356 | date = db.DateTimeField(required=True) 357 | hits = db.IntField(requried=True, default=0) 358 | transfer = db.IntField(required=True, default=0) 359 | meta = { 360 | 'collection': "usageTracking_sumServer_daily" 361 | } 362 | 363 | class UsageTrackerSumServerMonthly(db.Document): 364 | server_name = db.StringField(default="self") 365 | date = db.DateTimeField(required=True) 366 | hits = db.IntField(requried=True, default=0) 367 | transfer = db.IntField(required=True, default=0) 368 | meta = { 369 | 'collection': "usageTracking_sumServer_monthly" 370 | } 371 | 372 | sumServerClasses = { 373 | "hour": UsageTrackerSumServerHourly, 374 | "day": UsageTrackerSumServerDaily, 375 | "month": UsageTrackerSumServerMonthly, 376 | } 377 | 378 | def sumServer(**kwargs): 379 | if not _check_environment(**kwargs): 380 | return 381 | src = kwargs['mongoengine_document'] 382 | # 383 | increment(sumServerClasses, src, "server_name", ["server_name"]) 384 | return 385 | 386 | def sumServer_get_sum(**kwargs): 387 | return generic_get_sum(sumServerClasses, "server_name", **kwargs) 388 | -------------------------------------------------------------------------------- /src/flask_track_usage/summarization/sqlstorage.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | try: 3 | import sqlalchemy as sql 4 | HAS_SQLALCHEMY = True 5 | except ImportError: 6 | HAS_SQLALCHEMY = False 7 | 8 | try: 9 | import psycopg2 10 | from sqlalchemy.dialects.postgresql import insert 11 | HAS_POSTGRES = True 12 | except ImportError: 13 | HAS_POSTGRES = False 14 | 15 | 16 | def _check_environment(**kwargs): 17 | if not HAS_SQLALCHEMY: 18 | return False 19 | return True 20 | 21 | 22 | def _check_postgresql(**kwargs): 23 | if not HAS_POSTGRES: 24 | return False 25 | if kwargs["_parent_self"]._eng.driver != 'psycopg2': 26 | return False 27 | return True 28 | 29 | 30 | def trim_times(unix_timestamp): 31 | date = datetime.datetime.fromtimestamp(unix_timestamp) 32 | hour = date.replace(minute=0, second=0, microsecond=0) 33 | day = date.replace(hour=0, minute=0, second=0, microsecond=0) 34 | month = date.replace(day=1, hour=0, minute=0, second=0, microsecond=0) 35 | return hour, day, month 36 | 37 | 38 | def increment(con, table, dt, data, **values): 39 | stmt = insert(table).values( 40 | date=dt, 41 | hits=1, 42 | transfer=data['content_length'], 43 | **values 44 | ).on_conflict_do_update( 45 | index_elements=['date'], 46 | set_=dict( 47 | hits=table.c.hits + 1, 48 | transfer=table.c.transfer + data['content_length'] 49 | ) 50 | ) 51 | con.execute(stmt) 52 | 53 | 54 | def create_tables(table_list, **kwargs): 55 | self = kwargs["_parent_self"] 56 | with self._eng.begin() as self._con: 57 | for base_sum_table_name in table_list: 58 | key_field, _ = base_sum_table_name.split("_") 59 | sum_table_name = "{}_{}".format( 60 | self.table_name, base_sum_table_name 61 | ) 62 | if not self._con.dialect.has_table(self._con, sum_table_name): 63 | self.sum_tables[base_sum_table_name] = sql.Table( 64 | sum_table_name, 65 | self._metadata, 66 | sql.Column('date', sql.DateTime, primary_key=True), 67 | sql.Column(key_field, sql.String(128)), 68 | sql.Column('hits', sql.Integer), 69 | sql.Column('transfer', sql.Integer) 70 | ) 71 | else: 72 | self._metadata.reflect(bind=self._eng) 73 | self.sum_tables[base_sum_table_name] = ( 74 | self._metadata.tables[sum_table_name]) 75 | 76 | 77 | ###################################################### 78 | # 79 | # sumURL 80 | # 81 | ###################################################### 82 | 83 | if not HAS_SQLALCHEMY: 84 | 85 | def sumUrl(**kwargs): 86 | raise NotImplementedError("SQLAlchemy library not installed") 87 | 88 | else: 89 | 90 | def sumUrl_set_up(*args, **kwargs): 91 | tables = ["url_hourly", "url_daily", "url_monthly"] 92 | create_tables(tables, **kwargs) 93 | 94 | def sumUrl(**kwargs): 95 | if not _check_environment(**kwargs): 96 | return 97 | if not _check_postgresql(**kwargs): 98 | raise NotImplementedError("Only PostgreSQL currently supported") 99 | return 100 | 101 | hour, day, month = trim_times(kwargs['date']) 102 | x = kwargs["_parent_self"] 103 | with x._eng.begin() as con: 104 | increment( 105 | con, 106 | x.sum_tables["url_hourly"], 107 | hour, 108 | kwargs, 109 | url=kwargs['url'] 110 | ) 111 | increment( 112 | con, 113 | x.sum_tables["url_daily"], 114 | day, 115 | kwargs, 116 | url=kwargs['url'] 117 | ) 118 | increment( 119 | con, 120 | x.sum_tables["url_monthly"], 121 | month, 122 | kwargs, 123 | url=kwargs['url'] 124 | ) 125 | 126 | return 127 | 128 | 129 | ###################################################### 130 | # 131 | # sumRemote 132 | # 133 | ###################################################### 134 | 135 | if not HAS_SQLALCHEMY: 136 | 137 | def sumRemote(**kwargs): 138 | raise NotImplementedError("SQLAlchemy library not installed") 139 | 140 | else: 141 | 142 | def sumRemote_set_up(*args, **kwargs): 143 | tables = ["remote_hourly", "remote_daily", "remote_monthly"] 144 | create_tables(tables, **kwargs) 145 | 146 | def sumRemote(**kwargs): 147 | if not _check_environment(**kwargs): 148 | return 149 | if not _check_postgresql(**kwargs): 150 | raise NotImplementedError("Only PostgreSQL currently supported") 151 | return 152 | 153 | hour, day, month = trim_times(kwargs['date']) 154 | x = kwargs["_parent_self"] 155 | with x._eng.begin() as con: 156 | increment( 157 | con, 158 | x.sum_tables["remote_hourly"], 159 | hour, 160 | kwargs, 161 | remote=kwargs['remote_addr'] 162 | ) 163 | increment( 164 | con, 165 | x.sum_tables["remote_daily"], 166 | day, 167 | kwargs, 168 | remote=kwargs['remote_addr'] 169 | ) 170 | increment( 171 | con, 172 | x.sum_tables["remote_monthly"], 173 | month, 174 | kwargs, 175 | remote=kwargs['remote_addr'] 176 | ) 177 | 178 | return 179 | 180 | 181 | ###################################################### 182 | # 183 | # sumUserAgent 184 | # 185 | ###################################################### 186 | 187 | if not HAS_SQLALCHEMY: 188 | 189 | def sumUserAgent(**kwargs): 190 | raise NotImplementedError("SQLAlchemy library not installed") 191 | 192 | else: 193 | 194 | def sumUserAgent_set_up(*args, **kwargs): 195 | tables = ["useragent_hourly", "useragent_daily", "useragent_monthly"] 196 | create_tables(tables, **kwargs) 197 | 198 | def sumUserAgent(**kwargs): 199 | if not _check_environment(**kwargs): 200 | return 201 | if not _check_postgresql(**kwargs): 202 | raise NotImplementedError("Only PostgreSQL currently supported") 203 | return 204 | 205 | hour, day, month = trim_times(kwargs['date']) 206 | x = kwargs["_parent_self"] 207 | with x._eng.begin() as con: 208 | increment( 209 | con, 210 | x.sum_tables["useragent_hourly"], 211 | hour, 212 | kwargs, 213 | useragent=str(kwargs['user_agent']) 214 | ) 215 | increment( 216 | con, 217 | x.sum_tables["useragent_daily"], 218 | day, 219 | kwargs, 220 | useragent=str(kwargs['user_agent']) 221 | ) 222 | increment( 223 | con, 224 | x.sum_tables["useragent_monthly"], 225 | month, 226 | kwargs, 227 | useragent=str(kwargs['user_agent']) 228 | ) 229 | 230 | return 231 | 232 | 233 | ###################################################### 234 | # 235 | # sumLanguage 236 | # 237 | ###################################################### 238 | 239 | if not HAS_SQLALCHEMY: 240 | 241 | def sumLanguage(**kwargs): 242 | raise NotImplementedError("SQLAlchemy library not installed") 243 | 244 | else: 245 | 246 | def sumLanguage_set_up(*args, **kwargs): 247 | tables = ["language_hourly", "language_daily", "language_monthly"] 248 | create_tables(tables, **kwargs) 249 | 250 | def sumLanguage(**kwargs): 251 | if not _check_environment(**kwargs): 252 | return 253 | if not _check_postgresql(**kwargs): 254 | raise NotImplementedError("Only PostgreSQL currently supported") 255 | return 256 | 257 | hour, day, month = trim_times(kwargs['date']) 258 | x = kwargs["_parent_self"] 259 | with x._eng.begin() as con: 260 | increment( 261 | con, 262 | x.sum_tables["language_hourly"], 263 | hour, 264 | kwargs, 265 | language=kwargs['user_agent'].language 266 | ) 267 | increment( 268 | con, 269 | x.sum_tables["language_daily"], 270 | day, 271 | kwargs, 272 | language=kwargs['user_agent'].language 273 | ) 274 | increment( 275 | con, 276 | x.sum_tables["language_monthly"], 277 | month, 278 | kwargs, 279 | language=kwargs['user_agent'].language 280 | ) 281 | 282 | return 283 | 284 | ###################################################### 285 | # 286 | # sumServer 287 | # 288 | ###################################################### 289 | 290 | if not HAS_SQLALCHEMY: 291 | 292 | def sumServer(**kwargs): 293 | raise NotImplementedError("SQLAlchemy library not installed") 294 | 295 | else: 296 | 297 | def sumServer_set_up(*args, **kwargs): 298 | tables = ["server_hourly", "server_daily", "server_monthly"] 299 | create_tables(tables, **kwargs) 300 | 301 | def sumServer(**kwargs): 302 | if not _check_environment(**kwargs): 303 | return 304 | if not _check_postgresql(**kwargs): 305 | raise NotImplementedError("Only PostgreSQL currently supported") 306 | return 307 | 308 | hour, day, month = trim_times(kwargs['date']) 309 | x = kwargs["_parent_self"] 310 | with x._eng.begin() as con: 311 | increment( 312 | con, 313 | x.sum_tables["server_hourly"], 314 | hour, 315 | kwargs, 316 | server=kwargs["server_name"] 317 | ) 318 | increment( 319 | con, 320 | x.sum_tables["server_daily"], 321 | day, 322 | kwargs, 323 | server=kwargs["server_name"] 324 | ) 325 | increment( 326 | con, 327 | x.sum_tables["server_monthly"], 328 | month, 329 | kwargs, 330 | server=kwargs["server_name"] 331 | ) 332 | 333 | return 334 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2018 Steve Milner 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are 6 | # met: 7 | # 8 | # (1) Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 11 | # (2) Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in 13 | # the documentation and/or other materials provided with the 14 | # distribution. 15 | # 16 | # (3)The name of the author may not be used to 17 | # endorse or promote products derived from this software without 18 | # specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 21 | # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | # DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, 24 | # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 27 | # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 28 | # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING 29 | # IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | # POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | Unittests for flask-track-usage. 33 | """ 34 | 35 | import unittest 36 | 37 | from flask import Flask 38 | 39 | 40 | class TestStorage(object): 41 | """ 42 | Test storage which just holds data in a list. 43 | """ 44 | data = [] 45 | 46 | def __call__(self, data): 47 | """ 48 | What's called on storing. 49 | 50 | :Parameters: 51 | - `data`: Item to store. 52 | """ 53 | self.data.append(data) 54 | 55 | get = data.pop 56 | 57 | 58 | class FlaskTrackUsageTestCase(unittest.TestCase): 59 | """ 60 | Master TestCase for unittesting Flask-TrackUsage. 61 | """ 62 | 63 | def setUp(self): 64 | """ 65 | Happens before every test. 66 | """ 67 | self.app = Flask(__name__) 68 | self.app.config['TESTING'] = True 69 | 70 | self.client = self.app.test_client() 71 | self.app.config['TRACK_USAGE_USE_FREEGEOIP'] = False 72 | self.app.config[ 73 | 'TRACK_USAGE_INCLUDE_OR_EXCLUDE_VIEWS'] = 'exclude' 74 | 75 | @self.app.route('/') 76 | def index(): 77 | return "Hello!" 78 | 79 | 80 | class FlaskTrackUsageTestCaseGeoIP(unittest.TestCase): 81 | """ 82 | Master TestCase for unittesting Flask-TrackUsage. 83 | """ 84 | 85 | def setUp(self): 86 | """ 87 | Happens before every test. 88 | """ 89 | self.app = Flask(__name__) 90 | self.app.config['TESTING'] = True 91 | 92 | self.client = self.app.test_client() 93 | self.app.config['TRACK_USAGE_USE_FREEGEOIP'] = True 94 | self.app.config[ 95 | 'TRACK_USAGE_INCLUDE_OR_EXCLUDE_VIEWS'] = 'exclude' 96 | 97 | @self.app.route('/') 98 | def index(): 99 | return "Hello!" 100 | -------------------------------------------------------------------------------- /test/test_blueprints_include_exclude.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2018 Steve Milner 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are 6 | # met: 7 | # 8 | # (1) Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 11 | # (2) Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in 13 | # the documentation and/or other materials provided with the 14 | # distribution. 15 | # 16 | # (3)The name of the author may not be used to 17 | # endorse or promote products derived from this software without 18 | # specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 21 | # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | # DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, 24 | # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 27 | # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 28 | # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING 29 | # IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | # POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | Test include/exclude functionality. 33 | """ 34 | 35 | from flask import Blueprint 36 | from flask_track_usage import TrackUsage 37 | 38 | from . import FlaskTrackUsageTestCase, TestStorage 39 | 40 | 41 | class TestBlueprintIncludeExclude(FlaskTrackUsageTestCase): 42 | """ 43 | Tests blueprint include/exclude functionality. 44 | """ 45 | 46 | def setUp(self): 47 | """ 48 | Set up an app to test with. 49 | """ 50 | FlaskTrackUsageTestCase.setUp(self) 51 | self.storage = TestStorage() 52 | self.blueprint = Blueprint('test_blueprint', __name__) 53 | 54 | @self.blueprint.route('/included') 55 | def included(): 56 | return "INCLUDED" 57 | 58 | @self.blueprint.route('/excluded') 59 | def excluded(): 60 | return "EXCLUDED" 61 | 62 | self.app.register_blueprint(self.blueprint) 63 | 64 | def test_raw_blueprint(self): 65 | """ 66 | Verify that raw blueprints don't get modified. 67 | """ 68 | self.app.config[ 69 | 'TRACK_USAGE_INCLUDE_OR_EXCLUDE_VIEWS'] = 'include' 70 | tu = TrackUsage(self.app, self.storage) 71 | 72 | # There should be no included/excluded views 73 | assert len(tu._include_views) == 0 74 | assert len(tu._exclude_views) == 0 75 | 76 | # There should be no storing of data at all 77 | for page in ('/', '/included', '/excluded'): 78 | self.client.get(page) 79 | with self.assertRaises(IndexError): 80 | self.storage.get() 81 | 82 | def test_include_blueprint(self): 83 | """ 84 | Verify that an entire blueprint can be included. 85 | """ 86 | self.app.config[ 87 | 'TRACK_USAGE_INCLUDE_OR_EXCLUDE_VIEWS'] = 'include' 88 | tu = TrackUsage(self.app, self.storage) 89 | tu.include_blueprint(self.blueprint) 90 | 91 | # There should be 2 included views, no excluded views 92 | assert len(tu._include_views) == 2 93 | assert len(tu._exclude_views) == 0 94 | 95 | # Both paged should store 96 | for page in ('/included', '/excluded'): 97 | self.client.get(page) 98 | assert type(self.storage.get()) is dict 99 | 100 | # But the index (outside of the blueprint) should not 101 | self.client.get('/') 102 | with self.assertRaises(IndexError): 103 | self.storage.get() 104 | 105 | def test_exclude_blueprint(self): 106 | """ 107 | Verify that an entire blueprint can be excluded. 108 | """ 109 | self.app.config[ 110 | 'TRACK_USAGE_INCLUDE_OR_EXCLUDE_VIEWS'] = 'exclude' 111 | tu = TrackUsage(self.app, self.storage) 112 | tu.exclude_blueprint(self.blueprint) 113 | 114 | # There should be 2 excluded views, 0 included views 115 | assert len(tu._include_views) == 0 116 | assert len(tu._exclude_views) == 2 117 | 118 | # Index should store something 119 | self.client.get('/') 120 | assert type(self.storage.get()) is dict 121 | 122 | # Both pages should not store anything 123 | for page in ('/included', '/excluded'): 124 | self.client.get(page) 125 | with self.assertRaises(IndexError): 126 | self.storage.get() 127 | -------------------------------------------------------------------------------- /test/test_data.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2018 Steve Milner 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are 6 | # met: 7 | # 8 | # (1) Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 11 | # (2) Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in 13 | # the documentation and/or other materials provided with the 14 | # distribution. 15 | # 16 | # (3)The name of the author may not be used to 17 | # endorse or promote products derived from this software without 18 | # specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 21 | # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | # DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, 24 | # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 27 | # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 28 | # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING 29 | # IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | # POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | Basic data tests. 33 | """ 34 | 35 | import datetime 36 | 37 | from flask_track_usage import TrackUsage 38 | 39 | from . import FlaskTrackUsageTestCase, TestStorage 40 | 41 | 42 | class TestData(FlaskTrackUsageTestCase): 43 | """ 44 | Tests specific to expected data. 45 | """ 46 | 47 | def setUp(self): 48 | """ 49 | Set up an app to test with. 50 | """ 51 | FlaskTrackUsageTestCase.setUp(self) 52 | self.storage = TestStorage() 53 | self.track_usage = TrackUsage(self.app, self.storage) 54 | 55 | def test_expected_data(self): 56 | """ 57 | Test that the data is in the expected formart. 58 | """ 59 | self.client.get('/') 60 | result = self.storage.get() 61 | self.assertEqual(result.__class__, dict) 62 | self.assertIsNone(result['blueprint']) 63 | self.assertIsNone(result['ip_info']) 64 | self.assertEqual(result['status'], 200) 65 | self.assertEqual(result['remote_addr'], '127.0.0.1') 66 | self.assertEqual(result['speed'].__class__, float) 67 | self.assertEqual(result['view_args'], {}) 68 | self.assertEqual(result['url'], 'http://localhost/') 69 | self.assertEqual(result['path'], '/') 70 | self.assertEqual(result['authorization'], False) 71 | self.assertTrue(result['user_agent'].string.startswith('werkzeug')) 72 | self.assertEqual(type(result['date']), int) 73 | self.assertTrue(datetime.datetime.fromtimestamp(result['date'])) 74 | -------------------------------------------------------------------------------- /test/test_include_exclude.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2018 Steve Milner 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are 6 | # met: 7 | # 8 | # (1) Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 11 | # (2) Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in 13 | # the documentation and/or other materials provided with the 14 | # distribution. 15 | # 16 | # (3)The name of the author may not be used to 17 | # endorse or promote products derived from this software without 18 | # specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 21 | # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | # DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, 24 | # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 27 | # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 28 | # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING 29 | # IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | # POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | Test include/exclude functionality. 33 | """ 34 | 35 | from flask_track_usage import TrackUsage 36 | 37 | from . import FlaskTrackUsageTestCase, TestStorage 38 | 39 | 40 | class TestIncludeExclude(FlaskTrackUsageTestCase): 41 | """ 42 | Tests include/exclude functionality. 43 | """ 44 | 45 | def setUp(self): 46 | """ 47 | Set up an app to test with. 48 | """ 49 | FlaskTrackUsageTestCase.setUp(self) 50 | self.storage = TestStorage() 51 | 52 | def test_neither_include_nor_exclude(self): 53 | """ 54 | Verify that we fail when we don't state include or exclude. 55 | """ 56 | self.app.config[ 57 | 'TRACK_USAGE_INCLUDE_OR_EXCLUDE_VIEWS'] = '' 58 | with self.assertRaises(NotImplementedError): 59 | TrackUsage(self.app, self.storage) 60 | 61 | def test_late_neither_include_nor_exclude(self): 62 | """ 63 | Make sure that if someone attempts to change the type to something 64 | unsupported we fail 65 | """ 66 | self.app.config[ 67 | 'TRACK_USAGE_INCLUDE_OR_EXCLUDE_VIEWS'] = 'include' 68 | self.track_usage = TrackUsage(self.app, self.storage) 69 | self.track_usage._type = '' 70 | with self.assertRaises(NotImplementedError): 71 | self.client.get('/') 72 | 73 | def test_include_type(self): 74 | """ 75 | Test that include only covers what is included. 76 | """ 77 | self.app.config[ 78 | 'TRACK_USAGE_INCLUDE_OR_EXCLUDE_VIEWS'] = 'include' 79 | self.track_usage = TrackUsage(self.app, self.storage) 80 | 81 | @self.track_usage.include 82 | @self.app.route('/included') 83 | def included(): 84 | return "INCLUDED" 85 | 86 | # /includeds hould give results 87 | self.client.get('/included') 88 | assert type(self.storage.get()) is dict 89 | # / should not give results as it is not included 90 | self.client.get('/') 91 | with self.assertRaises(IndexError): 92 | self.storage.get() 93 | 94 | def test_exclude_type(self): 95 | """ 96 | Test that exclude covers anything not excluded. 97 | """ 98 | self.app.config[ 99 | 'TRACK_USAGE_INCLUDE_OR_EXCLUDE_VIEWS'] = 'exclude' 100 | self.track_usage = TrackUsage(self.app, self.storage) 101 | 102 | @self.track_usage.exclude 103 | @self.app.route('/excluded') 104 | def excluded(): 105 | return "INCLUDED" 106 | 107 | # / hould give results 108 | self.client.get('/') 109 | assert type(self.storage.get()) is dict 110 | # /excluded should not give results as it is excluded 111 | self.client.get('/excluded') 112 | with self.assertRaises(IndexError): 113 | self.storage.get() 114 | -------------------------------------------------------------------------------- /test/test_storage_mongo.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2018 Steve Milner 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are 6 | # met: 7 | # 8 | # (1) Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 11 | # (2) Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in 13 | # the documentation and/or other materials provided with the 14 | # distribution. 15 | # 16 | # (3)The name of the author may not be used to 17 | # endorse or promote products derived from this software without 18 | # specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 21 | # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | # DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, 24 | # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 27 | # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 28 | # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING 29 | # IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | # POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | Tests mongodb based storage. 33 | """ 34 | 35 | import datetime 36 | import unittest 37 | 38 | COLLECTION = False 39 | HAS_PYMONGO = False 40 | HAS_MONGOENGINE = False 41 | 42 | try: 43 | import pymongo 44 | HAS_PYMONGO = True 45 | DB = 'test' 46 | COLL_NAME = 'flask_track_usage' 47 | COLLECTION = getattr(getattr(pymongo.MongoClient(), DB), COLL_NAME) 48 | except ImportError: 49 | HAS_PYMONGO = False 50 | except pymongo.errors.ConnectionFailure: 51 | COLLECTION = False 52 | 53 | try: 54 | import mongoengine 55 | HAS_MONGOENGINE = True 56 | try: 57 | mongoengine.connect(db="mongoenginetest") 58 | except: 59 | print('Can not connect to mongoengine database.') 60 | HAS_MONGOENGINE = False 61 | except ImportError: 62 | pass 63 | 64 | from flask_track_usage import TrackUsage 65 | from flask_track_usage.storage.mongo import ( 66 | MongoPiggybackStorage, 67 | MongoStorage, 68 | MongoEngineStorage 69 | ) 70 | 71 | from . import FlaskTrackUsageTestCase 72 | 73 | 74 | @unittest.skipUnless(HAS_PYMONGO, "Requires pymongo") 75 | @unittest.skipUnless(COLLECTION, "Requires a running test MongoDB") 76 | class TestMongoPiggybaclStorage(FlaskTrackUsageTestCase): 77 | """ 78 | Tests MongoDB storage while using a piggybacked connection. 79 | """ 80 | 81 | def setUp(self): 82 | """ 83 | Set up an app to test with. 84 | """ 85 | FlaskTrackUsageTestCase.setUp(self) 86 | self.storage = MongoPiggybackStorage(collection=COLLECTION) 87 | # Clean out the storage 88 | self.storage.collection.drop() 89 | self.track_usage = TrackUsage(self.app, self.storage) 90 | 91 | def test_mongo_piggyback_storage(self): 92 | """ 93 | Test MongoPiggybackStorage stores the data the way we expect. 94 | """ 95 | self.client.get('/') 96 | result = self.storage.collection.find_one() 97 | assert result['blueprint'] is None 98 | assert result['ip_info'] is None 99 | assert result['status'] == 200 100 | self.assertTrue(result['remote_addr']) # Should be set with modern versions of Flask 101 | assert result['speed'].__class__ is float 102 | assert result['view_args'] == {} 103 | assert result['url'] == 'http://localhost/' 104 | assert result['authorization'] is False 105 | assert result['user_agent']['browser'] is None # because of testing 106 | assert result['user_agent']['platform'] is None # because of testing 107 | assert result['user_agent']['language'] is None # because of testing 108 | assert result['user_agent']['version'] is None # because of testing 109 | assert result['path'] == '/' 110 | assert type(result['date']) is datetime.datetime 111 | 112 | 113 | @unittest.skipUnless(HAS_PYMONGO, "Requires pymongo") 114 | @unittest.skipUnless(COLLECTION, "Requires a running test MongoDB") 115 | class TestMongoStorage(FlaskTrackUsageTestCase): 116 | """ 117 | Tests MongoDB storage while using it's own connection. 118 | """ 119 | 120 | def setUp(self): 121 | """ 122 | Set up an app to test with. 123 | """ 124 | FlaskTrackUsageTestCase.setUp(self) 125 | self.storage = MongoStorage( 126 | database=DB, 127 | collection=COLL_NAME 128 | ) 129 | # Clean out the storage 130 | self.storage.collection.drop() 131 | self.track_usage = TrackUsage(self.app, self.storage) 132 | 133 | def test_mongo_storage_data(self): 134 | """ 135 | Test that data is stored in MongoDB and retrieved correctly. 136 | """ 137 | self.client.get('/') 138 | result = self.storage.collection.find_one() 139 | assert result['blueprint'] is None 140 | assert result['ip_info'] is None 141 | assert result['status'] == 200 142 | self.assertTrue(result['remote_addr']) # Should be set with modern versions of Flask 143 | assert result['speed'].__class__ is float 144 | assert result['view_args'] == {} 145 | assert result['url'] == 'http://localhost/' 146 | assert result['authorization'] is False 147 | assert result['user_agent']['browser'] is None # because of testing 148 | assert result['user_agent']['platform'] is None # because of testing 149 | assert result['user_agent']['language'] is None # because of testing 150 | assert result['user_agent']['version'] is None # because of testing 151 | assert result['path'] == '/' 152 | assert type(result['date']) is datetime.datetime 153 | 154 | def test_mongo_storage_get_usage(self): 155 | """ 156 | Verify we can get usage information in expected ways. 157 | """ 158 | # Make 3 requests to make sure we have enough records 159 | self.client.get('/') 160 | self.client.get('/') 161 | self.client.get('/') 162 | 163 | # Limit tests 164 | assert len(self.storage.get_usage()) == 3 165 | assert len(self.storage.get_usage(limit=2)) == 2 166 | assert len(self.storage.get_usage(limit=1)) == 1 167 | 168 | # Page tests 169 | assert len(self.storage.get_usage(limit=2, page=1)) == 2 170 | assert len(self.storage.get_usage(limit=2, page=2)) == 1 171 | 172 | # timing tests 173 | now = datetime.datetime.utcnow() 174 | assert len(self.storage.get_usage(start_date=now)) == 0 175 | assert len(self.storage.get_usage(end_date=now)) == 3 176 | assert len(self.storage.get_usage(end_date=now, limit=2)) == 2 177 | 178 | 179 | @unittest.skipUnless(HAS_MONGOENGINE, "Requires MongoEngine") 180 | @unittest.skipUnless(COLLECTION, "Requires a running test MongoDB") 181 | class TestMongoEngineStorage(FlaskTrackUsageTestCase): 182 | """ 183 | Tests MongoEngine storage. 184 | """ 185 | 186 | def setUp(self): 187 | """ 188 | Set up an app to test with. 189 | """ 190 | FlaskTrackUsageTestCase.setUp(self) 191 | self.storage = MongoEngineStorage() 192 | # Clean out the storage 193 | self.storage.collection.drop_collection() 194 | self.track_usage = TrackUsage(self.app, self.storage) 195 | 196 | def test_mongoengine_storage(self): 197 | """ 198 | Test MongoEngineStorages stores the data the way we expect. 199 | """ 200 | self.client.get('/') 201 | doc = self.storage.collection.objects.first() 202 | assert doc.blueprint is None 203 | assert doc.ip_info is None 204 | assert doc.status == 200 205 | self.assertTrue(doc.remote_addr) # Should be set with modern versions of Flask 206 | assert doc.speed.__class__ is float 207 | assert doc.view_args == {} 208 | assert doc.url_args == {} 209 | assert doc.url == 'http://localhost/' 210 | assert doc.authorization is False 211 | assert doc.user_agent.browser is None # because of testing 212 | assert doc.user_agent.platform is None # because of testing 213 | assert doc.user_agent.language is None # because of testing 214 | assert doc.user_agent.version is None # because of testing 215 | assert doc.content_length == 6 216 | assert doc.path == '/' 217 | assert type(doc.date) is datetime.datetime 218 | 219 | -------------------------------------------------------------------------------- /test/test_storage_sqlalchemy.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Created on Mon Mar 24 21:31:41 2014 4 | 5 | @author: Goutham 6 | """ 7 | 8 | try: 9 | import sqlalchemy as sql 10 | HAS_SQLALCHEMY = True 11 | except ImportError: 12 | HAS_SQLALCHEMY = False 13 | 14 | try: 15 | import psycopg2 16 | HAS_POSTGRES = True 17 | except ImportError: 18 | HAS_POSTGRES = False 19 | 20 | try: 21 | import _mysql 22 | HAS_MYSQL = True 23 | except ImportError: 24 | HAS_MYSQL = False 25 | 26 | import datetime 27 | import unittest 28 | import json 29 | from flask import Blueprint 30 | from test import FlaskTrackUsageTestCase, FlaskTrackUsageTestCaseGeoIP 31 | from flask_track_usage import TrackUsage 32 | from flask_track_usage.storage.sql import SQLStorage 33 | 34 | 35 | @unittest.skipUnless(HAS_SQLALCHEMY, "Requires SQLAlchemy") 36 | class TestSQLiteStorage(FlaskTrackUsageTestCase): 37 | 38 | def _create_storage(self): 39 | engine = sql.create_engine("sqlite://") 40 | metadata = sql.MetaData(bind=engine) 41 | self.storage = SQLStorage( 42 | engine=engine, 43 | metadata=metadata, 44 | table_name=self.given_table_name 45 | ) 46 | metadata.create_all() 47 | 48 | def tearDown(self): 49 | meta = sql.MetaData() 50 | meta.reflect(bind=self.storage._eng) 51 | for table in reversed(meta.sorted_tables): 52 | self.storage._eng.execute(table.delete()) 53 | 54 | def setUp(self): 55 | self.given_table_name = 'my_usage' 56 | FlaskTrackUsageTestCase.setUp(self) 57 | self.blueprint = Blueprint('blueprint', __name__) 58 | 59 | @self.blueprint.route('/blueprint') 60 | def blueprint(): 61 | return "blueprint" 62 | self.app.register_blueprint(self.blueprint) 63 | 64 | self._create_storage() 65 | 66 | self.track_usage = TrackUsage(self.app, self.storage) 67 | self.track_usage.include_blueprint(self.blueprint) 68 | 69 | def test_table_name(self): 70 | meta = sql.MetaData() 71 | meta.reflect(bind=self.storage._eng) 72 | print(self.given_table_name, list(meta.tables.keys())[0]) 73 | self.assertIn(self.given_table_name, meta.tables.keys()) 74 | 75 | def test_storage_data_basic(self): 76 | self.client.get('/') 77 | con = self.storage._eng.connect() 78 | s = sql.select([self.storage.track_table]) 79 | result = con.execute(s).fetchone() 80 | #assert result[0] == 1 # first row 81 | assert result[1] == u'http://localhost/' 82 | assert result[2] is None 83 | assert result[3] is None 84 | assert result[4] is None 85 | assert result[5] is None 86 | assert result[6] is None 87 | assert result[8] == 200 88 | self.assertTrue(result[9]) 89 | assert result[10] == None 90 | assert result[11] == False 91 | assert result[12] is None 92 | assert result[13] == '/' 93 | assert result[14].__class__ is float 94 | assert type(result[15]) is datetime.datetime 95 | 96 | def test_storage_data_blueprint(self): 97 | self.client.get('/blueprint') 98 | con = self.storage._eng.connect() 99 | s = sql.select([self.storage.track_table]) 100 | result = con.execute(s).fetchone() 101 | assert result[1] == u'http://localhost/blueprint' 102 | assert result[2] is None 103 | assert result[3] is None 104 | assert result[4] is None 105 | assert result[5] is None 106 | assert result[6] == 'blueprint' 107 | assert result[8] == 200 108 | self.assertTrue(result[9]) 109 | assert result[10] is None 110 | assert result[11] == False 111 | assert result[12] is None 112 | assert result[13] == '/blueprint' 113 | assert result[14].__class__ is float 114 | assert type(result[15]) is datetime.datetime 115 | 116 | def test_storage__get_raw(self): 117 | # First check no blueprint case get_usage is correct 118 | self.client.get('/') 119 | result = self.storage._get_raw()[0] 120 | assert result[1] == u'http://localhost/' 121 | assert result[2] is None 122 | assert result[3] is None 123 | assert result[4] is None 124 | assert result[5] is None 125 | assert result[6] is None 126 | assert result[8] == 200 127 | self.assertTrue(result[9]) 128 | assert result[10] is None 129 | assert result[11] == False 130 | assert result[12] is None 131 | assert result[13] == '/' 132 | assert result[14].__class__ is float 133 | assert type(result[15]) is datetime.datetime 134 | 135 | # Next check with blueprint the get_usage is correct 136 | self.client.get('/blueprint') 137 | rows = self.storage._get_raw() 138 | print(rows[1]) 139 | result = rows[1]# if rows[0][6] is None else rows[0] 140 | #assert result[0] == 2 # first row 141 | assert result[1] == u'http://localhost/blueprint' 142 | assert result[2] is None 143 | assert result[3] is None 144 | assert result[4] is None 145 | assert result[5] is None 146 | assert result[6] == 'blueprint' 147 | assert result[8] == 200 148 | self.assertTrue(result[9]) 149 | assert result[10] is None 150 | assert result[11] == False 151 | assert result[12] is None 152 | assert result[13] == '/blueprint' 153 | assert result[14].__class__ is float 154 | assert type(result[15]) is datetime.datetime 155 | 156 | # third get 157 | self.client.get('/') 158 | 159 | # Limit tests 160 | assert len(self.storage._get_raw()) == 3 161 | assert len(self.storage._get_raw(limit=2)) == 2 162 | assert len(self.storage._get_raw(limit=1)) == 1 163 | 164 | # timing tests 165 | # give a 5 second lag since datetime stored is second precision 166 | 167 | now = datetime.datetime.utcnow() + datetime.timedelta(0, 5) 168 | assert len(self.storage._get_raw(start_date=now)) == 0 169 | assert len(self.storage._get_raw(end_date=now)) == 3 170 | assert len(self.storage._get_raw(end_date=now, limit=2)) == 2 171 | 172 | def test_storage__get_usage(self): 173 | self.client.get('/') 174 | result = self.storage._get_raw()[0] 175 | result2 = self.storage._get_usage()[0] 176 | #assert result[0] == 1 # first row 177 | assert result[1] == result2['url'] 178 | assert result[2] == result2['user_agent']['browser'] 179 | assert result[3] == result2['user_agent']['language'] 180 | assert result[4] == result2['user_agent']['platform'] 181 | assert result[5] == result2['user_agent']['version'] 182 | assert result[6] == result2['blueprint'] 183 | assert result[8] == result2['status'] 184 | assert result[9] == result2['remote_addr'] 185 | assert result[10] == result2['xforwardedfor'] 186 | assert result[11] == result2['authorization'] 187 | assert result[12] == result2['ip_info'] 188 | assert result[13] == result2['path'] 189 | assert result[14] == result2['speed'] 190 | assert result[15] == result2['date'] 191 | assert result[16] == result2['username'] 192 | track_var = result[17] if result[17] != '{}' else None 193 | assert track_var == result2['track_var'] 194 | 195 | def test_storage_get_usage(self): 196 | self.client.get('/') 197 | result = self.storage._get_raw()[0] 198 | result2 = self.storage.get_usage()[0] 199 | #assert result[0] == 1 # first row 200 | assert result[1] == result2['url'] 201 | assert result[2] == result2['user_agent']['browser'] 202 | assert result[3] == result2['user_agent']['language'] 203 | assert result[4] == result2['user_agent']['platform'] 204 | assert result[5] == result2['user_agent']['version'] 205 | assert result[6] == result2['blueprint'] 206 | assert result[8] == result2['status'] 207 | assert result[9] == result2['remote_addr'] 208 | assert result[10] == result2['xforwardedfor'] 209 | assert result[11] == result2['authorization'] 210 | assert result[12] == result2['ip_info'] 211 | assert result[13] == result2['path'] 212 | assert result[14] == result2['speed'] 213 | assert result[15] == result2['date'] 214 | assert result[16] == result2['username'] 215 | track_var = result[17] if result[17] != '{}' else None 216 | assert track_var == result2['track_var'] 217 | 218 | def test_storage_get_usage_pagination(self): 219 | # test pagination 220 | for i in range(100): 221 | self.client.get('/') 222 | 223 | limit = 10 224 | num_pages = 10 225 | for page in range(1, num_pages + 1): 226 | result = self.storage._get_usage(limit=limit, page=page) 227 | assert len(result) == limit 228 | 229 | # actual api test 230 | result = self.storage._get_raw(limit=100) # raw data 231 | result2 = self.storage.get_usage(limit=100) # dict data 232 | for i in range(100): 233 | assert result[i][1] == result2[i]['url'] 234 | assert result[i][2] == result2[i]['user_agent']['browser'] 235 | assert result[i][3] == result2[i]['user_agent']['language'] 236 | assert result[i][4] == result2[i]['user_agent']['platform'] 237 | assert result[i][5] == result2[i]['user_agent']['version'] 238 | assert result[i][6] == result2[i]['blueprint'] 239 | assert result[i][8] == result2[i]['status'] 240 | assert result[i][9] == result2[i]['remote_addr'] 241 | assert result[i][10] == result2[i]['xforwardedfor'] 242 | assert result[i][11] == result2[i]['authorization'] 243 | assert result[i][12] == result2[i]['ip_info'] 244 | assert result[i][13] == result2[i]['path'] 245 | assert result[i][14] == result2[i]['speed'] 246 | assert result[i][15] == result2[i]['date'] 247 | assert result[i][16] == result2[i]['username'] 248 | track_var = result[i][17] if result[i][17] != '{}' else None 249 | assert track_var == result2[i]['track_var'] 250 | 251 | 252 | 253 | 254 | 255 | 256 | @unittest.skipUnless(HAS_POSTGRES, "Requires psycopg2 Postgres package") 257 | @unittest.skipUnless((HAS_SQLALCHEMY), "Requires SQLAlchemy") 258 | class TestPostgresStorage(TestSQLiteStorage): 259 | 260 | def _create_storage(self): 261 | engine = sql.create_engine( 262 | "postgresql+psycopg2://postgres:@localhost/track_usage_test") 263 | metadata = sql.MetaData(bind=engine) 264 | self.storage = SQLStorage( 265 | engine=engine, 266 | metadata=metadata, 267 | table_name=self.given_table_name 268 | ) 269 | metadata.create_all() 270 | 271 | 272 | @unittest.skipUnless(HAS_MYSQL, "Requires mysql-python package") 273 | @unittest.skipUnless((HAS_SQLALCHEMY), "Requires SQLAlchemy") 274 | class TestMySQLStorage(TestSQLiteStorage): 275 | 276 | def _create_storage(self): 277 | engine = sql.create_engine( 278 | "mysql+mysqldb://travis:@localhost/track_usage_test") 279 | metadata = sql.MetaData(bind=engine) 280 | self.storage = SQLStorage( 281 | engine=engine, 282 | metadata=metadata, 283 | table_name=self.given_table_name 284 | ) 285 | metadata.create_all() 286 | 287 | 288 | 289 | @unittest.skipUnless(HAS_SQLALCHEMY, "Requires SQLAlchemy") 290 | class TestFreeGeoIP(FlaskTrackUsageTestCaseGeoIP): 291 | 292 | def _create_storage(self): 293 | engine = sql.create_engine("sqlite://") 294 | metadata = sql.MetaData(bind=engine) 295 | self.storage = SQLStorage( 296 | engine=engine, 297 | metadata=metadata, 298 | table_name=self.given_table_name 299 | ) 300 | metadata.create_all() 301 | 302 | def tearDown(self): 303 | meta = sql.MetaData() 304 | meta.reflect(bind=self.storage._eng) 305 | for table in reversed(meta.sorted_tables): 306 | self.storage._eng.execute(table.delete()) 307 | 308 | def setUp(self): 309 | self.given_table_name = 'my_usage' 310 | FlaskTrackUsageTestCaseGeoIP.setUp(self) 311 | self.blueprint = Blueprint('blueprint', __name__) 312 | 313 | @self.blueprint.route('/blueprint') 314 | def blueprint(): 315 | return "blueprint" 316 | self.app.register_blueprint(self.blueprint) 317 | 318 | self._create_storage() 319 | 320 | self.track_usage = TrackUsage(self.app, self.storage) 321 | self.track_usage.include_blueprint(self.blueprint) 322 | 323 | 324 | def test_storage_freegeoip(self): 325 | self.client.get('/') 326 | con = self.storage._eng.connect() 327 | s = sql.select([self.storage.track_table]) 328 | result = con.execute(s).fetchone() 329 | j = json.loads(result[12]) 330 | assert j["ipType"] == "Residential" 331 | -------------------------------------------------------------------------------- /test/test_summarize_mongoengine.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2018 Steve Milner 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are 6 | # met: 7 | # 8 | # (1) Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 11 | # (2) Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in 13 | # the documentation and/or other materials provided with the 14 | # distribution. 15 | # 16 | # (3)The name of the author may not be used to 17 | # endorse or promote products derived from this software without 18 | # specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 21 | # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | # DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, 24 | # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 27 | # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 28 | # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING 29 | # IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | # POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | Tests mongodb based storage. 33 | """ 34 | 35 | import datetime 36 | import unittest 37 | import json 38 | 39 | 40 | HAS_MONGOENGINE = False 41 | 42 | try: 43 | import mongoengine 44 | import pprint 45 | HAS_MONGOENGINE = True 46 | try: 47 | mongoengine.connect(db="mongoenginetest") 48 | except: 49 | print('Can not connect to mongoengine database.') 50 | HAS_MONGOENGINE = False 51 | except ImportError: 52 | pass 53 | 54 | from flask_track_usage import TrackUsage 55 | from flask_track_usage.storage.mongo import MongoEngineStorage 56 | from flask_track_usage.summarization import ( 57 | sumUrl, 58 | sumRemote, 59 | sumUserAgent, 60 | sumLanguage, 61 | sumServer, 62 | ) 63 | 64 | if HAS_MONGOENGINE: 65 | from flask_track_usage.summarization.mongoenginestorage import ( 66 | UsageTrackerSumUrlHourly, 67 | UsageTrackerSumUrlDaily, 68 | UsageTrackerSumUrlMonthly, 69 | UsageTrackerSumRemoteHourly, 70 | UsageTrackerSumRemoteDaily, 71 | UsageTrackerSumRemoteMonthly, 72 | UsageTrackerSumUserAgentHourly, 73 | UsageTrackerSumUserAgentDaily, 74 | UsageTrackerSumUserAgentMonthly, 75 | UsageTrackerSumLanguageHourly, 76 | UsageTrackerSumLanguageDaily, 77 | UsageTrackerSumLanguageMonthly, 78 | UsageTrackerSumServerHourly, 79 | UsageTrackerSumServerDaily, 80 | UsageTrackerSumServerMonthly, 81 | ) 82 | 83 | from . import FlaskTrackUsageTestCase 84 | 85 | 86 | @unittest.skipUnless(HAS_MONGOENGINE, "Requires MongoEngine") 87 | class TestMongoEngineSummarizeBasic(FlaskTrackUsageTestCase): 88 | """ 89 | Tests MongoEngine summaries. 90 | """ 91 | 92 | def setUp(self): 93 | """ 94 | Set up an app to test with. 95 | """ 96 | FlaskTrackUsageTestCase.setUp(self) 97 | self.storage = MongoEngineStorage(hooks=[ 98 | sumUrl, 99 | sumRemote, 100 | sumUserAgent, 101 | sumLanguage, 102 | sumServer 103 | ]) 104 | self.track_usage = TrackUsage(self.app, self.storage) 105 | # Clean out the summary 106 | UsageTrackerSumUrlHourly.drop_collection() 107 | UsageTrackerSumUrlDaily.drop_collection() 108 | UsageTrackerSumUrlMonthly.drop_collection() 109 | UsageTrackerSumRemoteHourly.drop_collection() 110 | UsageTrackerSumRemoteDaily.drop_collection() 111 | UsageTrackerSumRemoteMonthly.drop_collection() 112 | UsageTrackerSumUserAgentHourly.drop_collection() 113 | UsageTrackerSumUserAgentDaily.drop_collection() 114 | UsageTrackerSumUserAgentMonthly.drop_collection() 115 | UsageTrackerSumLanguageHourly.drop_collection() 116 | UsageTrackerSumLanguageDaily.drop_collection() 117 | UsageTrackerSumLanguageMonthly.drop_collection() 118 | UsageTrackerSumServerHourly.drop_collection() 119 | UsageTrackerSumServerDaily.drop_collection() 120 | UsageTrackerSumServerMonthly.drop_collection() 121 | # trigger one timed summary 122 | self.now = datetime.datetime.utcnow() 123 | self.hour = self.now.replace(minute=0, second=0, microsecond=0) 124 | self.day = self.now.replace(hour=0, minute=0, second=0, microsecond=0) 125 | self.month = self.now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) 126 | self.client.get('/') 127 | 128 | def test_mongoengine_url_summary(self): 129 | """ 130 | Test MongoEngine url summarization. 131 | """ 132 | hour_doc = UsageTrackerSumUrlHourly.objects.first() 133 | assert hour_doc.url == 'http://localhost/' 134 | assert hour_doc.date == self.hour 135 | assert hour_doc.hits == 1 136 | assert hour_doc.transfer > 1 137 | day_doc = UsageTrackerSumUrlDaily.objects.first() 138 | assert day_doc.url == 'http://localhost/' 139 | assert day_doc.date == self.day 140 | assert day_doc.hits == 1 141 | assert day_doc.transfer > 1 142 | month_doc = UsageTrackerSumUrlMonthly.objects.first() 143 | assert month_doc.url == 'http://localhost/' 144 | assert month_doc.date == self.month 145 | assert month_doc.hits == 1 146 | assert month_doc.transfer > 1 147 | 148 | def test_mongoengine_remote_summary(self): 149 | """ 150 | Test MongoEngine remote IP summarization. 151 | """ 152 | hour_doc = UsageTrackerSumRemoteHourly.objects.first() 153 | assert hour_doc.remote_addr == '127.0.0.1' 154 | assert hour_doc.date == self.hour 155 | assert hour_doc.hits == 1 156 | assert hour_doc.transfer > 1 157 | day_doc = UsageTrackerSumRemoteDaily.objects.first() 158 | assert day_doc.remote_addr == '127.0.0.1' 159 | assert day_doc.date == self.day 160 | assert day_doc.hits == 1 161 | assert day_doc.transfer > 1 162 | month_doc = UsageTrackerSumRemoteMonthly.objects.first() 163 | assert month_doc.remote_addr == '127.0.0.1' 164 | assert month_doc.date == self.month 165 | assert month_doc.hits == 1 166 | assert month_doc.transfer > 1 167 | 168 | def test_mongoengine_user_agent_summary(self): 169 | """ 170 | Test MongoEngine User Agent summarization. 171 | """ 172 | hour_doc = UsageTrackerSumUserAgentHourly.objects.first() 173 | assert hour_doc.user_agent_string.startswith("werkzeug/") 174 | assert hour_doc.date == self.hour 175 | assert hour_doc.hits == 1 176 | assert hour_doc.transfer > 1 177 | day_doc = UsageTrackerSumUserAgentDaily.objects.first() 178 | assert day_doc.user_agent_string.startswith("werkzeug/") 179 | assert day_doc.date == self.day 180 | assert day_doc.hits == 1 181 | assert day_doc.transfer > 1 182 | month_doc = UsageTrackerSumUserAgentMonthly.objects.first() 183 | assert month_doc.user_agent_string.startswith("werkzeug/") 184 | assert month_doc.date == self.month 185 | assert month_doc.hits == 1 186 | assert month_doc.transfer > 1 187 | 188 | def test_mongoengine_language_summary(self): 189 | """ 190 | Test MongoEngine Language summarization. 191 | """ 192 | hour_doc = UsageTrackerSumLanguageHourly.objects.first() 193 | assert hour_doc.language == 'none' 194 | assert hour_doc.date == self.hour 195 | assert hour_doc.hits == 1 196 | assert hour_doc.transfer > 1 197 | day_doc = UsageTrackerSumLanguageDaily.objects.first() 198 | assert day_doc.language == 'none' 199 | assert day_doc.date == self.day 200 | assert day_doc.hits == 1 201 | assert day_doc.transfer > 1 202 | month_doc = UsageTrackerSumLanguageMonthly.objects.first() 203 | assert month_doc.language == 'none' 204 | assert month_doc.date == self.month 205 | assert month_doc.hits == 1 206 | assert month_doc.transfer > 1 207 | 208 | def test_mongoengine_server_summary(self): 209 | """ 210 | Test MongoEngine server summarization. 211 | """ 212 | hour_doc = UsageTrackerSumServerHourly.objects.first() 213 | assert hour_doc.server_name == self.app.name 214 | assert hour_doc.date == self.hour 215 | assert hour_doc.hits == 1 216 | assert hour_doc.transfer > 1 217 | day_doc = UsageTrackerSumServerDaily.objects.first() 218 | assert day_doc.server_name == self.app.name 219 | assert day_doc.date == self.day 220 | assert day_doc.hits == 1 221 | assert day_doc.transfer > 1 222 | month_doc = UsageTrackerSumServerMonthly.objects.first() 223 | assert month_doc.server_name == self.app.name 224 | assert month_doc.date == self.month 225 | assert month_doc.hits == 1 226 | assert month_doc.transfer > 1 227 | 228 | 229 | @unittest.skipUnless(HAS_MONGOENGINE, "Requires MongoEngine") 230 | class TestMongoEngineSummarizeGetSum(FlaskTrackUsageTestCase): 231 | """ 232 | Tests query of MongoEngine summaries. 233 | """ 234 | 235 | def setUp(self): 236 | """ 237 | Set up an app to test with. 238 | """ 239 | self.fake_time1 = datetime.datetime(2018, 4, 15, 8, 45, 12) # Apr 15, 2018 at 8:45:12 AM UTC 240 | self.fake_hour1 = datetime.datetime(2018, 4, 15, 8, 0, 0) # Apr 15, 2018 at 8:00:00 AM UTC 241 | self.fake_day1 = datetime.datetime(2018, 4, 15, 0, 0, 0) # Apr 15, 2018 at 0:00:00 AM UTC 242 | self.fake_month1 = datetime.datetime(2018, 4, 1, 0, 0, 0) # Apr 1, 2018 at 0:00:00 AM UTC 243 | 244 | self.fake_time2 = datetime.datetime(2018, 4, 15, 9, 45, 12) # Apr 15, 2018 at 9:45:12 AM UTC 245 | self.fake_hour2 = datetime.datetime(2018, 4, 15, 9, 0, 0) # Apr 15, 2018 at 9:00:00 AM UTC 246 | self.fake_day2 = datetime.datetime(2018, 4, 15, 0, 0, 0) # Apr 15, 2018 at 0:00:00 AM UTC 247 | self.fake_month2 = datetime.datetime(2018, 4, 1, 0, 0, 0) # Apr 1, 2018 at 0:00:00 AM UTC 248 | 249 | self.fake_time3 = datetime.datetime(2018, 4, 16, 9, 45, 12) # Apr 16, 2018 at 9:45:12 AM UTC 250 | self.fake_hour3 = datetime.datetime(2018, 4, 16, 9, 0, 0) # Apr 16, 2018 at 9:00:00 AM UTC 251 | self.fake_day3 = datetime.datetime(2018, 4, 16, 0, 0, 0) # Apr 16, 2018 at 0:00:00 AM UTC 252 | self.fake_month3 = datetime.datetime(2018, 4, 1, 0, 0, 0) # Apr 1, 2018 at 0:00:00 AM UTC 253 | 254 | self.fake_time4 = datetime.datetime(2018, 5, 10, 9, 45, 12) # May 10, 2018 at 9:45:12 AM UTC 255 | self.fake_hour4 = datetime.datetime(2018, 5, 10, 9, 0, 0) # May 10, 2018 at 9:00:00 AM UTC 256 | self.fake_day4 = datetime.datetime(2018, 5, 10, 0, 0, 0) # May 10, 2018 at 0:00:00 AM UTC 257 | self.fake_month4 = datetime.datetime(2018, 5, 1, 0, 0, 0) # May 1, 2018 at 0:00:00 AM UTC 258 | 259 | FlaskTrackUsageTestCase.setUp(self) 260 | self.storage = MongoEngineStorage(hooks=[ 261 | sumUrl, 262 | sumRemote, 263 | sumUserAgent, 264 | sumLanguage, 265 | sumServer 266 | ]) 267 | self.track_usage = TrackUsage( 268 | self.app, 269 | self.storage, 270 | _fake_time = self.fake_time1 271 | ) 272 | # Clean out the summary 273 | UsageTrackerSumUrlHourly.drop_collection() 274 | UsageTrackerSumUrlDaily.drop_collection() 275 | UsageTrackerSumUrlMonthly.drop_collection() 276 | UsageTrackerSumRemoteHourly.drop_collection() 277 | UsageTrackerSumRemoteDaily.drop_collection() 278 | UsageTrackerSumRemoteMonthly.drop_collection() 279 | UsageTrackerSumUserAgentHourly.drop_collection() 280 | UsageTrackerSumUserAgentDaily.drop_collection() 281 | UsageTrackerSumUserAgentMonthly.drop_collection() 282 | UsageTrackerSumLanguageHourly.drop_collection() 283 | UsageTrackerSumLanguageDaily.drop_collection() 284 | UsageTrackerSumLanguageMonthly.drop_collection() 285 | UsageTrackerSumServerHourly.drop_collection() 286 | UsageTrackerSumServerDaily.drop_collection() 287 | UsageTrackerSumServerMonthly.drop_collection() 288 | 289 | # generate four entries at different times 290 | # 291 | self.client.get('/') 292 | self.track_usage._fake_time = self.fake_time2 293 | self.client.get('/') 294 | self.track_usage._fake_time = self.fake_time3 295 | self.client.get('/') 296 | self.track_usage._fake_time = self.fake_time4 297 | self.client.get('/') 298 | 299 | def test_mongoengine_get_summary_url(self): 300 | """ 301 | Test MongoEngine url summarization. 302 | """ 303 | result = self.storage.get_sum( 304 | sumUrl, 305 | start_date=self.fake_hour1, 306 | target='http://localhost/' 307 | ) 308 | # print(pprint.pprint(result)) 309 | assert len(result["hour"]) == 1 310 | assert len(result["day"]) == 1 311 | assert len(result["month"]) == 1 312 | assert result["hour"][0]['hits'] == 1 313 | assert result["day"][0]['hits'] == 2 314 | assert result["month"][0]['hits'] == 3 315 | 316 | result = self.storage.get_sum( 317 | "sumUrl", 318 | start_date=self.fake_hour4, 319 | ) 320 | assert len(result["hour"]) == 1 321 | assert len(result["day"]) == 1 322 | assert len(result["month"]) == 1 323 | assert result["hour"][0]['hits'] == 1 324 | assert result["day"][0]['hits'] == 1 325 | assert result["month"][0]['hits'] == 1 326 | 327 | def test_mongoengine_get_summary_remote(self): 328 | """ 329 | Test MongoEngine url summarization. 330 | """ 331 | result = self.storage.get_sum( 332 | sumRemote, 333 | start_date=self.fake_hour1, 334 | target='127.0.0.1' 335 | ) 336 | # print(pprint.pprint(result)) 337 | assert len(result["hour"]) == 1 338 | assert len(result["day"]) == 1 339 | assert len(result["month"]) == 1 340 | assert result["hour"][0]['hits'] == 1 341 | assert result["day"][0]['hits'] == 2 342 | assert result["month"][0]['hits'] == 3 343 | 344 | result = self.storage.get_sum( 345 | "sumRemote", 346 | start_date=self.fake_hour4, 347 | ) 348 | assert len(result["hour"]) == 1 349 | assert len(result["day"]) == 1 350 | assert len(result["month"]) == 1 351 | assert result["hour"][0]['hits'] == 1 352 | assert result["day"][0]['hits'] == 1 353 | assert result["month"][0]['hits'] == 1 354 | 355 | def test_mongoengine_get_summary_useragent(self): 356 | """ 357 | Test MongoEngine url summarization. 358 | """ 359 | result = self.storage.get_sum( 360 | sumUserAgent, 361 | start_date=self.fake_hour1 362 | ) 363 | # print(pprint.pprint(result)) 364 | assert len(result["hour"]) == 1 365 | assert len(result["day"]) == 1 366 | assert len(result["month"]) == 1 367 | assert result["hour"][0]['hits'] == 1 368 | assert result["day"][0]['hits'] == 2 369 | assert result["month"][0]['hits'] == 3 370 | 371 | result = self.storage.get_sum( 372 | "sumUserAgent", 373 | start_date=self.fake_hour4, 374 | ) 375 | assert len(result["hour"]) == 1 376 | assert len(result["day"]) == 1 377 | assert len(result["month"]) == 1 378 | assert result["hour"][0]['hits'] == 1 379 | assert result["day"][0]['hits'] == 1 380 | assert result["month"][0]['hits'] == 1 381 | 382 | def test_mongoengine_get_summary_language(self): 383 | """ 384 | Test MongoEngine url summarization. 385 | """ 386 | result = self.storage.get_sum( 387 | sumLanguage, 388 | start_date=self.fake_hour1, 389 | target="none" 390 | ) 391 | # print(pprint.pprint(result)) 392 | assert len(result["hour"]) == 1 393 | assert len(result["day"]) == 1 394 | assert len(result["month"]) == 1 395 | assert result["hour"][0]['hits'] == 1 396 | assert result["day"][0]['hits'] == 2 397 | assert result["month"][0]['hits'] == 3 398 | 399 | result = self.storage.get_sum( 400 | "sumLanguage", 401 | start_date=self.fake_hour4, 402 | ) 403 | assert len(result["hour"]) == 1 404 | assert len(result["day"]) == 1 405 | assert len(result["month"]) == 1 406 | assert result["hour"][0]['hits'] == 1 407 | assert result["day"][0]['hits'] == 1 408 | assert result["month"][0]['hits'] == 1 409 | 410 | def test_mongoengine_get_summary_server(self): 411 | """ 412 | Test MongoEngine url summarization. 413 | """ 414 | result = self.storage.get_sum( 415 | sumServer, 416 | start_date=self.fake_hour1, 417 | target=self.app.name 418 | ) 419 | # print(pprint.pprint(result)) 420 | assert len(result["hour"]) == 1 421 | assert len(result["day"]) == 1 422 | assert len(result["month"]) == 1 423 | assert result["hour"][0]['hits'] == 1 424 | assert result["day"][0]['hits'] == 2 425 | assert result["month"][0]['hits'] == 3 426 | 427 | result = self.storage.get_sum( 428 | "sumServer", 429 | start_date=self.fake_hour4, 430 | ) 431 | assert len(result["hour"]) == 1 432 | assert len(result["day"]) == 1 433 | assert len(result["month"]) == 1 434 | assert result["hour"][0]['hits'] == 1 435 | assert result["day"][0]['hits'] == 1 436 | assert result["month"][0]['hits'] == 1 437 | -------------------------------------------------------------------------------- /test/test_summarize_sqlalchemy.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2018 Steve Milner 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are 6 | # met: 7 | # 8 | # (1) Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 11 | # (2) Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in 13 | # the documentation and/or other materials provided with the 14 | # distribution. 15 | # 16 | # (3)The name of the author may not be used to 17 | # endorse or promote products derived from this software without 18 | # specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 21 | # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | # DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, 24 | # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 27 | # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 28 | # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING 29 | # IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | # POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | Tests sql based summarization. 33 | """ 34 | 35 | import datetime 36 | import unittest 37 | 38 | 39 | try: 40 | import sqlalchemy as sql 41 | HAS_SQLALCHEMY = True 42 | except ImportError: 43 | HAS_SQLALCHEMY = False 44 | 45 | try: 46 | import psycopg2 47 | HAS_POSTGRES = True 48 | except ImportError: 49 | HAS_POSTGRES = False 50 | 51 | 52 | import datetime 53 | import unittest 54 | from flask import Blueprint 55 | from test import FlaskTrackUsageTestCase 56 | from flask_track_usage import TrackUsage 57 | from flask_track_usage.storage.sql import SQLStorage 58 | from flask_track_usage.summarization import ( 59 | sumUrl, 60 | sumRemote, 61 | sumUserAgent, 62 | sumLanguage, 63 | sumServer, 64 | ) 65 | 66 | 67 | 68 | @unittest.skipUnless(HAS_SQLALCHEMY, "Requires SQLAlchemy") 69 | @unittest.skipUnless(HAS_POSTGRES, "Requires psycopg2 Postgres package") 70 | class TestPostgreStorage(FlaskTrackUsageTestCase): 71 | 72 | def _create_storage(self): 73 | engine = sql.create_engine( 74 | "postgresql+psycopg2://postgres:@localhost/track_usage_test") 75 | metadata = sql.MetaData(bind=engine) 76 | self.storage = SQLStorage( 77 | engine=engine, 78 | metadata=metadata, 79 | table_name=self.given_table_name, 80 | hooks=[ 81 | sumUrl, 82 | sumRemote, 83 | sumUserAgent, 84 | sumLanguage, 85 | sumServer 86 | ] 87 | ) 88 | metadata.create_all() 89 | 90 | def setUp(self): 91 | self.given_table_name = 'my_usage' 92 | FlaskTrackUsageTestCase.setUp(self) 93 | self.blueprint = Blueprint('blueprint', __name__) 94 | 95 | @self.blueprint.route('/blueprint') 96 | def blueprint(): 97 | return "blueprint" 98 | self.app.register_blueprint(self.blueprint) 99 | 100 | self._create_storage() 101 | 102 | self.fake_time = datetime.datetime(2018, 4, 15, 9, 45, 12) # Apr 15, 2018 at 9:45:12 AM UTC 103 | self.fake_hour = datetime.datetime(2018, 4, 15, 9, 0, 0) # Apr 15, 2018 at 9:00:00 AM UTC 104 | self.fake_day = datetime.datetime(2018, 4, 15, 0, 0, 0) # Apr 15, 2018 at 0:00:00 AM UTC 105 | self.fake_month = datetime.datetime(2018, 4, 1, 0, 0, 0) # Apr 1, 2018 at 0:00:00 AM UTC 106 | 107 | self.track_usage = TrackUsage( 108 | self.app, 109 | self.storage, 110 | _fake_time=self.fake_time 111 | ) 112 | self.track_usage.include_blueprint(self.blueprint) 113 | 114 | def tearDown(self): 115 | meta = sql.MetaData() 116 | meta.reflect(bind=self.storage._eng) 117 | for table in reversed(meta.sorted_tables): 118 | self.storage._eng.execute(table.delete()) 119 | 120 | def test_table_names(self): 121 | meta = sql.MetaData() 122 | meta.reflect(bind=self.storage._eng) 123 | print(meta.tables.keys()) 124 | self.assertIn('my_usage_language_hourly', meta.tables.keys()) 125 | self.assertIn('my_usage_remote_monthly', meta.tables.keys()) 126 | self.assertIn('my_usage_language_monthly', meta.tables.keys()) 127 | self.assertIn('my_usage_url_monthly', meta.tables.keys()) 128 | self.assertIn('my_usage_useragent_hourly', meta.tables.keys()) 129 | self.assertIn('my_usage_server_hourly', meta.tables.keys()) 130 | self.assertIn('my_usage_remote_hourly', meta.tables.keys()) 131 | self.assertIn('my_usage_remote_daily', meta.tables.keys()) 132 | self.assertIn('my_usage_language_daily', meta.tables.keys()) 133 | self.assertIn('my_usage_url_hourly', meta.tables.keys()) 134 | self.assertIn('my_usage_useragent_monthly', meta.tables.keys()) 135 | self.assertIn('my_usage_useragent_daily', meta.tables.keys()) 136 | self.assertIn('my_usage_url_daily', meta.tables.keys()) 137 | self.assertIn('my_usage_server_daily', meta.tables.keys()) 138 | self.assertIn('my_usage_server_monthly', meta.tables.keys()) 139 | 140 | def test_basic_suite(self): 141 | self.client.get('/') # call 3 times to make sure upsert works 142 | self.client.get('/') 143 | self.client.get('/') 144 | con = self.storage._eng.connect() 145 | 146 | # URL 147 | 148 | table = self.storage.sum_tables["url_hourly"] 149 | s = sql \ 150 | .select([table]) \ 151 | .where(table.c.date==self.fake_hour) 152 | result = con.execute(s).fetchone() 153 | assert result is not None 154 | assert result[0] == self.fake_hour 155 | assert result[1] == u'http://localhost/' 156 | assert result[2] == 3 157 | assert result[3] == 18 158 | 159 | table = self.storage.sum_tables["url_daily"] 160 | s = sql \ 161 | .select([table]) \ 162 | .where(table.c.date==self.fake_day) 163 | result = con.execute(s).fetchone() 164 | assert result is not None 165 | assert result[0] == self.fake_day 166 | assert result[1] == u'http://localhost/' 167 | assert result[2] == 3 168 | assert result[3] == 18 169 | 170 | table = self.storage.sum_tables["url_monthly"] 171 | s = sql \ 172 | .select([table]) \ 173 | .where(table.c.date==self.fake_month) 174 | result = con.execute(s).fetchone() 175 | assert result is not None 176 | assert result[0] == self.fake_month 177 | assert result[1] == u'http://localhost/' 178 | assert result[2] == 3 179 | assert result[3] == 18 180 | 181 | # REMOTE IP 182 | 183 | table = self.storage.sum_tables["remote_hourly"] 184 | s = sql \ 185 | .select([table]) \ 186 | .where(table.c.date==self.fake_hour) 187 | result = con.execute(s).fetchone() 188 | assert result is not None 189 | assert result[0] == self.fake_hour 190 | assert result[1] == "127.0.0.1" 191 | assert result[2] == 3 192 | assert result[3] == 18 193 | 194 | table = self.storage.sum_tables["remote_daily"] 195 | s = sql \ 196 | .select([table]) \ 197 | .where(table.c.date==self.fake_day) 198 | result = con.execute(s).fetchone() 199 | assert result is not None 200 | assert result[0] == self.fake_day 201 | assert result[1] == "127.0.0.1" 202 | assert result[2] == 3 203 | assert result[3] == 18 204 | 205 | table = self.storage.sum_tables["remote_monthly"] 206 | s = sql \ 207 | .select([table]) \ 208 | .where(table.c.date==self.fake_month) 209 | result = con.execute(s).fetchone() 210 | assert result is not None 211 | assert result[0] == self.fake_month 212 | assert result[1] == "127.0.0.1" 213 | assert result[2] == 3 214 | assert result[3] == 18 215 | 216 | # USER AGENT 217 | 218 | table = self.storage.sum_tables["useragent_hourly"] 219 | s = sql \ 220 | .select([table]) \ 221 | .where(table.c.date==self.fake_hour) 222 | result = con.execute(s).fetchone() 223 | assert result is not None 224 | assert result[0] == self.fake_hour 225 | assert result[1].startswith("werkzeug/") 226 | assert result[2] == 3 227 | assert result[3] == 18 228 | 229 | table = self.storage.sum_tables["useragent_daily"] 230 | s = sql \ 231 | .select([table]) \ 232 | .where(table.c.date==self.fake_day) 233 | result = con.execute(s).fetchone() 234 | assert result is not None 235 | assert result[0] == self.fake_day 236 | assert result[1].startswith("werkzeug/") 237 | assert result[2] == 3 238 | assert result[3] == 18 239 | 240 | table = self.storage.sum_tables["useragent_monthly"] 241 | s = sql \ 242 | .select([table]) \ 243 | .where(table.c.date==self.fake_month) 244 | result = con.execute(s).fetchone() 245 | assert result is not None 246 | assert result[0] == self.fake_month 247 | assert result[1].startswith("werkzeug/") 248 | assert result[2] == 3 249 | assert result[3] == 18 250 | 251 | # LANGUAGE 252 | 253 | table = self.storage.sum_tables["language_hourly"] 254 | s = sql \ 255 | .select([table]) \ 256 | .where(table.c.date==self.fake_hour) 257 | result = con.execute(s).fetchone() 258 | assert result is not None 259 | assert result[0] == self.fake_hour 260 | assert result[1] is None # the werkzeug test client does not have a language 261 | assert result[2] == 3 262 | assert result[3] == 18 263 | 264 | table = self.storage.sum_tables["language_daily"] 265 | s = sql \ 266 | .select([table]) \ 267 | .where(table.c.date==self.fake_day) 268 | result = con.execute(s).fetchone() 269 | assert result is not None 270 | assert result[0] == self.fake_day 271 | assert result[1] is None 272 | assert result[2] == 3 273 | assert result[3] == 18 274 | 275 | 276 | table = self.storage.sum_tables["language_monthly"] 277 | s = sql \ 278 | .select([table]) \ 279 | .where(table.c.date==self.fake_month) 280 | result = con.execute(s).fetchone() 281 | assert result is not None 282 | assert result[0] == self.fake_month 283 | assert result[1] is None 284 | assert result[2] == 3 285 | assert result[3] == 18 286 | 287 | # WHOLE SERVER 288 | 289 | table = self.storage.sum_tables["server_hourly"] 290 | s = sql \ 291 | .select([table]) \ 292 | .where(table.c.date==self.fake_hour) 293 | result = con.execute(s).fetchone() 294 | assert result is not None 295 | assert result[0] == self.fake_hour 296 | assert result[1] == self.app.name 297 | assert result[2] == 3 298 | assert result[3] == 18 299 | 300 | 301 | table = self.storage.sum_tables["server_daily"] 302 | s = sql \ 303 | .select([table]) \ 304 | .where(table.c.date==self.fake_day) 305 | result = con.execute(s).fetchone() 306 | assert result is not None 307 | assert result[0] == self.fake_day 308 | assert result[1] == self.app.name 309 | assert result[2] == 3 310 | assert result[3] == 18 311 | 312 | table = self.storage.sum_tables["server_monthly"] 313 | s = sql \ 314 | .select([table]) \ 315 | .where(table.c.date==self.fake_month) 316 | result = con.execute(s).fetchone() 317 | assert result is not None 318 | assert result[0] == self.fake_month 319 | assert result[1] == self.app.name 320 | assert result[2] == 3 321 | assert result[3] == 18 322 | --------------------------------------------------------------------------------