├── .gitignore ├── README.md ├── app.db ├── app ├── __init__.py ├── bases.py ├── constants.py ├── mixins.py ├── templates │ ├── 400.html │ ├── 404.html │ ├── base.html │ ├── forms │ │ └── macros.html │ ├── generic.html │ ├── index.html │ ├── tracking │ │ └── index.html │ └── users │ │ ├── login.html │ │ └── register.html ├── tracking │ ├── __init__.py │ ├── constants.py │ ├── decorators.py │ ├── forms.py │ ├── geodata.py │ ├── models.py │ ├── tests.py │ └── views.py └── users │ ├── __init__.py │ ├── constants.py │ ├── decorators.py │ ├── forms.py │ ├── models.py │ ├── tests.py │ └── views.py ├── config.py ├── docs ├── config.html ├── pycco.css ├── run.html └── shell.html ├── license.txt ├── requirements.txt ├── run.py ├── screenshots ├── flask-tracking-1.png ├── flask-tracking-2.png └── flask-tracking-3.png ├── shell.py └── test.db /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | *.pyc 3 | .idea/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | flask-tracking 2 | ============== 3 | 4 | Flask Best Practices 5 | 6 | 1. [Part 1](http://www.realpython.com/blog/python/python-web-applications-with-flask-part-i/): set up a bare-bones application which enables sites to be added and visits recorded against them via a simple web interface or over HTTP. 7 | 2. [Part II](http://www.realpython.com/blog/python/python-web-applications-with-flask-part-ii/): add users, access control, and enable users to add visits from their own websites. Explore more best practices for writing templates, keeping our models and forms in sync, and handling static files. 8 | 3. [Part III](http://www.realpython.com/blog/python/python-web-applications-with-flask-part-iii/#.Uu-yMnddUp-) we'll explore writing tests for our application, logging, and debugging errors. 9 | 4. Part IV we'll do some Test Driven Development to enable our application to accept payments and display simple reports. 10 | 5. Part V we will write a RESTful JSON API for others to consume. 11 | 6. Part VI we will cover automating deployments (on Heroku) with Fabric and basic A/B Feature Testing. 12 | 7. Part VII we will cover preserving your application for the future with documentation, code coverage and quality metric tools. 13 | -------------------------------------------------------------------------------- /app.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjhea0/flask-tracking/e792016decbd6d548ae43a5d300c9e6ca5a425ee/app.db -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, request, render_template, send_from_directory 2 | from flask.ext.sqlalchemy import SQLAlchemy 3 | from flask.ext.heroku import Heroku 4 | from flask.ext.login import LoginManager, current_user 5 | import os 6 | 7 | app = Flask(__name__) #create our application object 8 | app.config.from_object('config.DebugConfiguration') #load our local config file 9 | 10 | heroku = Heroku(app) #create a heroku config object from our app object 11 | 12 | login_manager = LoginManager(app) #create a LoginManager Object from our app object 13 | 14 | db = SQLAlchemy(app) #create a db (SQLAlchemy) object from our app object 15 | 16 | #register the users module blueprint 17 | from app.users.views import mod as usersModule 18 | app.register_blueprint(usersModule) 19 | 20 | #add our view as the login view to finish configuring the LoginManager 21 | login_manager.login_view = "users.login_view" 22 | 23 | #register the tracking module blueprint 24 | from app.tracking.views import mod as trackingModule 25 | app.register_blueprint(trackingModule) 26 | 27 | #---------------------------------------- 28 | # controllers 29 | #---------------------------------------- 30 | 31 | 32 | @app.route('/favicon.ico') 33 | def favicon(): 34 | return send_from_directory(os.path.join(app.root_path, 'static'), 'ico/favicon.ico') 35 | 36 | 37 | @app.route("/") 38 | def index(): 39 | return render_template('index.html') 40 | 41 | 42 | @app.errorhandler(404) 43 | def page_not_found(e): 44 | return render_template('404.html'), 404 45 | 46 | 47 | @app.errorhandler(400) 48 | def key_error(e): 49 | app.logger.warning('Invalid request resulted in KeyError', exc_info=e) 50 | return render_template('400.html'), 400 51 | 52 | 53 | @app.errorhandler(500) 54 | def internal_server_error(e): 55 | app.logger.warning('An unhandled exception is being displayed to the end user', exc_info=e) 56 | return render_template('generic.html'), 500 57 | 58 | 59 | @app.errorhandler(Exception) 60 | def unhandled_exception(e): 61 | app.logger.error('An unhandled exception is being displayed to the end user', exc_info=e) 62 | return render_template('generic.html'), 500 63 | 64 | 65 | @app.before_request 66 | def log_entry(): 67 | app.logger.debug("Handling request") 68 | 69 | 70 | @app.teardown_request 71 | def log_exit(exc): 72 | app.logger.debug("Finished handling request", exc_info=exc) 73 | 74 | 75 | #---------------------------------------- 76 | # logging 77 | #---------------------------------------- 78 | 79 | import logging 80 | 81 | 82 | class ContextualFilter(logging.Filter): 83 | def filter(self, log_record): 84 | log_record.url = request.path 85 | log_record.method = request.method 86 | log_record.ip = request.environ.get("REMOTE_ADDR") 87 | log_record.user_id = -1 if current_user.is_anonymous() else current_user.get_id() 88 | 89 | return True 90 | 91 | context_provider = ContextualFilter() 92 | app.logger.addFilter(context_provider) 93 | del app.logger.handlers[:] 94 | 95 | handler = logging.StreamHandler() 96 | 97 | log_format = "%(asctime)s\t%(levelname)s\t%(user_id)s\t%(ip)s\t%(method)s\t%(url)s\t%(message)s" 98 | formatter = logging.Formatter(log_format) 99 | handler.setFormatter(formatter) 100 | 101 | app.logger.addHandler(handler) 102 | 103 | from logging import ERROR 104 | from logging.handlers import TimedRotatingFileHandler 105 | 106 | # Only set up a file handler if we know where to put the logs 107 | if app.config.get("ERROR_LOG_PATH"): 108 | 109 | # Create one file for each day. Delete logs over 7 days old. 110 | file_handler = TimedRotatingFileHandler(app.config["ERROR_LOG_PATH"], when="D", backupCount=7) 111 | 112 | # Use a multi-line format for this logger, for easier scanning 113 | file_formatter = logging.Formatter(''' 114 | Time: %(asctime)s 115 | Level: %(levelname)s 116 | Method: %(method)s 117 | Path: %(url)s 118 | IP: %(ip)s 119 | User ID: %(user_id)s 120 | 121 | Message: %(message)s 122 | 123 | ---------------------''') 124 | 125 | # Filter out all log messages that are lower than Error. 126 | file_handler.setLevel(ERROR) 127 | 128 | file_handler.addFormatter(file_formatter) 129 | app.logger.addHandler(file_handler) -------------------------------------------------------------------------------- /app/bases.py: -------------------------------------------------------------------------------- 1 | from flask.ext.testing import TestCase 2 | from app import app, db 3 | 4 | 5 | class BaseTestCase(TestCase): 6 | """A base test case for flask-tracking.""" 7 | 8 | def create_app(self): 9 | app.config.from_object('config.TestConfiguration') 10 | return app 11 | 12 | def setUp(self): 13 | db.create_all() 14 | 15 | def tearDown(self): 16 | db.session.remove() 17 | db.drop_all() 18 | -------------------------------------------------------------------------------- /app/constants.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjhea0/flask-tracking/e792016decbd6d548ae43a5d300c9e6ca5a425ee/app/constants.py -------------------------------------------------------------------------------- /app/mixins.py: -------------------------------------------------------------------------------- 1 | from app import db 2 | 3 | class CRUDMixin(object): 4 | __table_args__ = {'extend_existing': True} 5 | 6 | id = db.Column(db.Integer, primary_key=True) 7 | 8 | @classmethod 9 | def get_by_id(cls, id): 10 | if any( 11 | (isinstance(id, basestring) and id.isdigit(), 12 | isinstance(id, (int, float))), 13 | ): 14 | return cls.query.get(int(id)) 15 | return None 16 | 17 | @classmethod 18 | def create(cls, **kwargs): 19 | instance = cls(**kwargs) 20 | return instance.save() 21 | 22 | def update(self, commit=True, **kwargs): 23 | for attr, value in kwargs.iteritems(): 24 | setattr(self, attr, value) 25 | return commit and self.save() or self 26 | 27 | def save(self, commit=True): 28 | db.session.add(self) 29 | if commit: 30 | db.session.commit() 31 | return self 32 | 33 | def delete(self, commit=True): 34 | db.session.delete(self) 35 | return commit and db.session.commit() 36 | -------------------------------------------------------------------------------- /app/templates/400.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %} - Something's missing{% endblock %} 3 | {% block content %} 4 |

Something was missing from your request

5 |

Please check your input and try again

6 |

Alternately, return home

7 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %} - Page Not Found{% endblock %} 3 | {% block content %} 4 |

Page Not Found

5 |

home

6 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block head %} 5 | MyFlaskTracker{% block title %}{% endblock %} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 34 | 35 | 36 | 39 | 40 | 41 | 42 | 43 | {% endblock %} 44 | 45 | 46 | 47 | 48 | {% block navbar %} 49 | 73 | {% endblock %} 74 | 75 |
76 | {% for message in get_flashed_messages() %} 77 |
78 | 79 | {{message}} 80 | 83 |
84 | {% endfor %} 85 |
86 | {% block content %} 87 | {% endblock %} 88 |
89 | 90 |
91 | 92 | {% block footer %} 93 | 96 | {% endblock %} 97 | 98 |
99 | {% block js_footer %} 100 | 101 | {% endblock %} 102 | 103 | -------------------------------------------------------------------------------- /app/templates/forms/macros.html: -------------------------------------------------------------------------------- 1 | {% macro render_field(field) %} 2 |
3 | {{ field.label(class="control-label") }} 4 | {% if field.errors %} 5 | {% set css_class = 'has_error ' + kwargs.pop('class', '') %} 6 | {{ field(class=css_class, **kwargs) }} 7 | 8 | {% else %} 9 |
10 | {{ field(**kwargs) }} 11 |
12 | {% endif %} 13 |
14 | {% endmacro %} 15 | -------------------------------------------------------------------------------- /app/templates/generic.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %} - An error occurred{% endblock %} 3 | {% block content %} 4 |

Unhandled Error

5 |

An unhandled error occurred while processing your request. This is our fault, and we are looking into it.

6 |

With very many apologies, we suggest that you return to our home page and try again.

7 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %} - Home{% endblock %} 3 | {% block content %} 4 | {% if not current_user.is_authenticated() %} 5 |

You are not logged in. Please log in or sign up. Its Free!

6 | {% else %} 7 |

What is this?

8 |

This is a simple web tracking framework written in Flask and jQuery. Simply register a site and then embed the jQuery snippet in your website and then watch as it records visits to your site!

9 |

Github code 10 |

How to Use (requires jQuery)

11 |
12 |

jQuery Plugin

13 |
14 |

Simply include this jQuery plugin

15 |
(function( $ ) {
16 |   $.fn.davidTracker = function(options) {
17 |     var tracker = {};
18 |     tracker.settings = $.extend({
19 |       'visitor' : 'Anon User',
20 |       'event' : 'Started',
21 |       'data'  : 'None'
22 |       }, options);
23 |     $.ajax('http://flasktracking.herokuapp.com/visit/__site.id__/visited?event='+tracker.settings.event+'&data='+tracker.settings.data+'&visitor='+tracker.settings.visitor);
24 |     $('.trackable').each(function() {
25 |       $(this).click(function(){
26 |           $.ajax('http://flasktracking.herokuapp.com/visit/__site.id__/visited?event='+$(this).data('event')+'&data='+$(this).data('data')+'&visitor='+tracker.settings.visitor);
27 |         });
28 |     })
29 |     $(document).unload(function() {
30 |       $.ajax('http://flasktracking.herokuapp.com/visit/__site.id__/visited?event=Unload&data=None&visitor='+tracker.settings.visitor);
31 |     });
32 |     return tracker;
33 |   };
34 | })( jQuery );
35 |

Then instantiate a tracker object in your .ready() call. Make sure you feed it the right options.

36 |
$(document).ready(function() {
37 |       tracker = $().davidTracker({'visitor':'Visitor_name_goes_here', 'event':'default event','data':'any extra data'});
38 |     });
39 |

If you have a single page app and you need to track events such as clicks add a '.trackable' class

40 |
<a class="btn btn-primary trackable" data-event="You Clicked the Demo Button!" data-data="This is extra data">Click Me!</a>
41 | Click Me! 42 |
43 |
44 |
45 |
46 |
47 | 69 | {% endif %} 70 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/tracking/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %} - Sites{% endblock %} 3 | {% block content %} 4 | {% from "forms/macros.html" import render_field %} 5 |
6 |

Sites you are Tracking

7 | {% if sites %} 8 |
9 | {% for site in sites %} 10 |
11 |
12 |

13 | 14 |

15 |
16 |
17 |
18 | {% if site.visits %} 19 |
    20 | {% for visit in site.visits %} 21 |
  • 22 |
    23 |
    24 |
    25 |

    26 |

    {{ visit.date }}

    27 | 28 |
    29 |
    30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
    TimeUrlEventIP AddressLocation
    {{ visit.date }}{{ visit.url }}{{ visit.event }}{{ visit.ip_address }}{{ visit.location }}
    46 |
    47 |
    48 |
    49 |
  • 50 | {% endfor %} 51 |
52 | {% else %} 53 |

No Recoreded Visits Yet

54 | {% endif %} 55 |

Use this code to track this site:

56 |
(function( $ ) {
 57 |   $.fn.davidTracker = function(options) {
 58 |     var tracker = {};
 59 |     tracker.settings = $.extend({
 60 |       'visitor' : 'Anon User',
 61 |       'event' : 'Started',
 62 |       'data'  : 'None'
 63 |       }, options);
 64 |     $.ajax('http://flasktracking.herokuapp.com/visit/{{ site.id }}/visited?event='+tracker.settings.event+'&data='+tracker.settings.data+'&visitor='+tracker.settings.visitor);
 65 |     $('.trackable').each(function() {
 66 |       $(this).click(function(){
 67 |           $.ajax('http://flasktracking.herokuapp.com/visit/{{ site.id }}/visited?event='+$(this).data('event')+'&data='+$(this).data('data')+'&visitor='+tracker.settings.visitor);
 68 |         });
 69 |     })
 70 |     $(document).unload(function() {
 71 |       $.ajax('http://flasktracking.herokuapp.com/visit/{{ site.id }}/visited?event=Unload&data=None&visitor='+tracker.settings.visitor);
 72 |     });
 73 |     return tracker;
 74 |   };
 75 | })( jQuery );
76 |
77 |
78 |
79 | {% endfor %} 80 |
81 | {% else %} 82 |
83 |

No Sites Registered

84 |
85 | {% endif %} 86 |
87 | 91 |
92 | 93 | Register Site 94 | {{ form.csrf_token }} 95 | {{ render_field(form.base_url, class="input-xlarge",placeholder="username") }} 96 |
97 | 98 |
99 | 100 |
101 |
102 |
103 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/users/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %} - Log In{% endblock %} 3 | {% block content %} 4 | {% from "forms/macros.html" import render_field %} 5 |
6 | 7 | Log In 8 | {{ form.csrf_token }} 9 | {{ render_field(form.name, class="input-xlarge",placeholder="username") }} 10 | {{ render_field(form.password, class="input-xlarge", placeholder="password") }} 11 |
12 | 13 |
14 | 15 |
16 |
17 |
18 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/users/register.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %} - Log In{% endblock %} 3 | {% block content %} 4 | {% from "forms/macros.html" import render_field %} 5 |
6 | 7 | Sign Up 8 | {{ form.csrf_token }} 9 | {{ render_field(form.name, class="input-xlarge",placeholder="username") }} 10 | {{ render_field(form.email, class="input-xlarge", placeholder="email") }} 11 | {{ render_field(form.password, class="input-xlarge", placeholder="password") }} 12 | {{ render_field(form.conf_password, class="input-xlarge", placeholder="password") }} 13 |
14 | 15 |
16 | 17 |
18 |
19 |
20 | {% endblock %} -------------------------------------------------------------------------------- /app/tracking/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjhea0/flask-tracking/e792016decbd6d548ae43a5d300c9e6ca5a425ee/app/tracking/__init__.py -------------------------------------------------------------------------------- /app/tracking/constants.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjhea0/flask-tracking/e792016decbd6d548ae43a5d300c9e6ca5a425ee/app/tracking/constants.py -------------------------------------------------------------------------------- /app/tracking/decorators.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from flask import make_response, request, current_app 3 | from functools import update_wrapper 4 | 5 | 6 | def crossdomain(origin=None, methods=None, headers=None, 7 | max_age=21600, attach_to_all=True, 8 | automatic_options=True): 9 | if methods is not None: 10 | methods = ', '.join(sorted(x.upper() for x in methods)) 11 | if headers is not None and not isinstance(headers, basestring): 12 | headers = ', '.join(x.upper() for x in headers) 13 | if not isinstance(origin, basestring): 14 | origin = ', '.join(origin) 15 | if isinstance(max_age, timedelta): 16 | max_age = max_age.total_seconds() 17 | 18 | def get_methods(): 19 | if methods is not None: 20 | return methods 21 | 22 | options_resp = current_app.make_default_options_response() 23 | return options_resp.headers['allow'] 24 | 25 | def decorator(f): 26 | def wrapped_function(*args, **kwargs): 27 | if automatic_options and request.method == 'OPTIONS': 28 | resp = current_app.make_default_options_response() 29 | else: 30 | resp = make_response(f(*args, **kwargs)) 31 | if not attach_to_all and request.method != 'OPTIONS': 32 | return resp 33 | 34 | h = resp.headers 35 | 36 | h['Access-Control-Allow-Origin'] = origin 37 | h['Access-Control-Allow-Methods'] = get_methods() 38 | h['Access-Control-Max-Age'] = str(max_age) 39 | if headers is not None: 40 | h['Access-Control-Allow-Headers'] = headers 41 | return resp 42 | 43 | f.provide_automatic_options = False 44 | return update_wrapper(wrapped_function, f) 45 | return decorator -------------------------------------------------------------------------------- /app/tracking/forms.py: -------------------------------------------------------------------------------- 1 | from flask.ext.wtf import Form, TextField, PasswordField, BooleanField, RecaptchaField, fields, validators 2 | from flask.ext.wtf import Required, Email, EqualTo 3 | from app.users.models import User 4 | from app import db 5 | 6 | class RegisterSiteForm(Form): 7 | base_url = fields.TextField(validators=[validators.required()]) -------------------------------------------------------------------------------- /app/tracking/geodata.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Searches Geolocation of IP addresses using http://freegeoip.net/ 6 | It will fetch a csv and return a python dictionary 7 | 8 | sample usage: 9 | >>> from freegeoip import get_geodata 10 | >>> get_geodata("189.24.179.76") 11 | 12 | {'status': True, 'city': 'Niter\xc3\xb3i', 'countrycode': 'BR', 'ip': '189.24.179.76', 13 | 'zipcode': '', 'longitude': '-43.0944', 'countryname': 'Brazil', 'regioncode': '21', 14 | 'latitude': '-22.8844', 'regionname': 'Rio de Janeiro'} 15 | """ 16 | 17 | from urllib import urlopen 18 | from csv import reader 19 | import sys 20 | import re 21 | 22 | __author__="Victor Fontes Costa" 23 | __copyright__ = "Copyright (c) 2010, Victor Fontes - victorfontes.com" 24 | __license__ = "GPL" 25 | __version__ = "2.1" 26 | __maintainer__ = __author__ 27 | __email__ = "contato [a] victorfontes.com" 28 | __status__ = "Development" 29 | 30 | FREE_GEOIP_CSV_URL = "http://freegeoip.net/csv/%s" 31 | 32 | 33 | def valid_ip(ip): 34 | 35 | pattern = r"\b(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b" 36 | 37 | return re.match(pattern, ip) 38 | 39 | def __get_geodata_csv(ip): 40 | if not valid_ip(ip): 41 | raise Exception('Invalid IP format', 'You must enter a valid ip format: X.X.X.X') 42 | 43 | URL = FREE_GEOIP_CSV_URL % ip 44 | response_csv = reader(urlopen(URL)) 45 | csv_data = response_csv.next() 46 | 47 | return { 48 | "status": u"True" == csv_data[0], 49 | "ip":csv_data[1], 50 | "countrycode":csv_data[2], 51 | "countryname":csv_data[3], 52 | "regioncode":csv_data[4], 53 | "regionname":csv_data[5], 54 | "city":csv_data[6], 55 | "zipcode":csv_data[7], 56 | "latitude":csv_data[8], 57 | "longitude":csv_data[9] 58 | } 59 | 60 | def get_geodata(ip): 61 | return __get_geodata_csv(ip) 62 | 63 | if __name__ == "__main__": #code to execute if called from command-line 64 | intput_ip = sys.argv[1] 65 | geodata = get_geodata(intput_ip) 66 | print "IP: %s" % geodata["ip"] 67 | print "Country Code: %s" % geodata["countrycode"] 68 | print "Country Name: %s" % geodata["countryname"] 69 | print "Region Code: %s" % geodata["regioncode"] 70 | print "Region Name: %s" % geodata["regionname"] 71 | print "City: %s" % geodata["city"] 72 | print "Zip Code: %s" % geodata["zipcode"] 73 | print "Latitude: %s" % geodata["latitude"] 74 | print "Longitude: %s" % geodata["longitude"] -------------------------------------------------------------------------------- /app/tracking/models.py: -------------------------------------------------------------------------------- 1 | from app import db 2 | from app.mixins import CRUDMixin 3 | 4 | class Site(CRUDMixin, db.Model): 5 | __tablename__ = 'tracking_site' 6 | id = db.Column(db.Integer, primary_key=True) 7 | visits = db.relationship('Visit', backref='tracking_site', 8 | lazy='select') 9 | base_url = db.Column(db.Text) 10 | user_id = db.Column(db.Integer, db.ForeignKey('users_user.id')) 11 | 12 | 13 | def __init__(self, user_id=None, base_url=None): 14 | self.user_id = user_id 15 | self.base_url = base_url 16 | 17 | def __repr__(self): 18 | return '' % (self.base_url) 19 | 20 | 21 | class Visit(CRUDMixin, db.Model): 22 | __tablename__ = 'tracking_visit' 23 | id = db.Column(db.Integer, primary_key=True) 24 | browser = db.Column(db.Text) 25 | date = db.Column(db.DateTime) 26 | event = db.Column(db.Text) 27 | url = db.Column(db.Text) 28 | site_id = db.Column(db.Integer, db.ForeignKey('tracking_site.id')) 29 | ip_address = db.Column(db.Text) 30 | location = db.Column(db.Text) 31 | location_full = db.Column(db.Text) 32 | 33 | def __init__(self, browser=None, date=None, event=None, url=None, ip_address=None, location_full=None, location=None): 34 | self.browser = browser 35 | self.date = date 36 | self.event = event 37 | self.url = url 38 | self.ip_address = ip_address 39 | self.location_full = location_full 40 | self.location = location 41 | 42 | def __repr__(self): 43 | return '' % (self.url, self.date) -------------------------------------------------------------------------------- /app/tracking/tests.py: -------------------------------------------------------------------------------- 1 | from flask import url_for 2 | from mock import Mock, patch 3 | 4 | from app.bases import BaseTestCase 5 | from app.users.models import User 6 | from app.tracking.models import Site, Visit 7 | 8 | import app.tracking.views 9 | 10 | 11 | class TrackingViewsTests(BaseTestCase): 12 | def test_visitors_location_is_derived_from_ip(self): 13 | user = User.create(name="Joe", email="joe@joe.com", password="12345") 14 | site = Site.create(user_id=user.id) 15 | 16 | mock_geodata = Mock(name="get_geodata") 17 | mock_geodata.return_value = { 18 | 'city': 'Los Angeles', 19 | 'zipcode': '90001', 20 | 'latitude': '34.05', 21 | 'longitude': '-118.25' 22 | } 23 | 24 | url = url_for("tracking.register_visit", site_id=site.id) 25 | wsgi_environment = {"REMOTE_ADDR": "1.2.3.4"} 26 | 27 | with patch.object(app.tracking.views, "get_geodata", mock_geodata): 28 | with self.client: 29 | self.client.get(url, environ_overrides=wsgi_environment) 30 | 31 | visits = Visit.query.all() 32 | 33 | mock_geodata.assert_called_once_with("1.2.3.4") 34 | self.assertEquals(1, len(visits)) 35 | self.assertEquals("Los Angeles, 90001", visits[0].location) 36 | self.assertEquals("Los Angeles, 90001, 34.05, -118.25", 37 | visits[0].location_full) 38 | -------------------------------------------------------------------------------- /app/tracking/views.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, Response, render_template, flash, redirect, session, url_for, request, g 2 | from flask.ext.login import current_user, login_required 3 | from app import app, db, login_manager 4 | from app.tracking.models import Site, Visit 5 | from app.tracking.forms import RegisterSiteForm 6 | from datetime import datetime 7 | from app.tracking.geodata import get_geodata 8 | from app.tracking.decorators import crossdomain 9 | 10 | 11 | mod = Blueprint('tracking', __name__) 12 | 13 | 14 | @mod.route('/sites/', methods=('GET', 'POST')) 15 | @login_required 16 | def sites_view(): 17 | form = RegisterSiteForm(request.form) 18 | sites = current_user.sites.all() 19 | if form.validate_on_submit(): 20 | site = Site() 21 | form.populate_obj(site) 22 | site.user_id = current_user.id 23 | db.session.add(site) 24 | db.session.commit() 25 | return redirect('/sites/') 26 | return render_template('tracking/index.html', form=form, sites=sites) 27 | 28 | #http://proj1-6170.herokuapp.com/sites/<%= @current_user.id %>/visited?event='+tracker.settings.event+'&data='+tracker.settings.data+'&visitor='+tracker.settings.visitor 29 | @mod.route('/visit//visited', methods=('GET','POST')) 30 | @crossdomain(origin="*", methods=["POST", "GET, OPTIONS"], headers="Content-Type, Origin, Referer, User-Agent", max_age="3600") 31 | def register_visit(site_id): 32 | site = Site.get_by_id(site_id) 33 | if site: 34 | browser = request.headers.get('User-Agent') 35 | date = datetime.now() 36 | event = request.args.get('event') 37 | url = request.url 38 | ip_address = request.remote_addr 39 | geo = get_geodata(ip_address) 40 | location_full = ", ".join([geo['city'],geo['zipcode'],geo['latitude'],geo['longitude']]) 41 | location = ", ".join([geo['city'],geo['zipcode']]) 42 | visit = Visit(browser, date, event, url, ip_address, location_full, location) 43 | visit.site_id = site_id 44 | db.session.add(visit) 45 | db.session.commit() 46 | return Response("visit recorded", content_type="text/plain") 47 | 48 | # self, browser=None, date=None, event=None, url=None, ip_address=None, location_full=None -------------------------------------------------------------------------------- /app/users/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjhea0/flask-tracking/e792016decbd6d548ae43a5d300c9e6ca5a425ee/app/users/__init__.py -------------------------------------------------------------------------------- /app/users/constants.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjhea0/flask-tracking/e792016decbd6d548ae43a5d300c9e6ca5a425ee/app/users/constants.py -------------------------------------------------------------------------------- /app/users/decorators.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjhea0/flask-tracking/e792016decbd6d548ae43a5d300c9e6ca5a425ee/app/users/decorators.py -------------------------------------------------------------------------------- /app/users/forms.py: -------------------------------------------------------------------------------- 1 | from flask.ext.wtf import Form, fields, validators 2 | from flask.ext.wtf import Required, Email 3 | from app.users.models import User 4 | from app import db 5 | 6 | 7 | def validate_login(form, field): 8 | user = form.get_user() 9 | 10 | if user is None: 11 | raise validators.ValidationError('Invalid user') 12 | 13 | if user.password != form.password.data: 14 | raise validators.ValidationError('Invalid password') 15 | 16 | 17 | class LoginForm(Form): 18 | name = fields.TextField(validators=[Required()]) 19 | password = fields.PasswordField(validators=[Required(), validate_login]) 20 | 21 | def get_user(self): 22 | return db.session.query(User).filter_by(name=self.name.data).first() 23 | 24 | 25 | class RegistrationForm(Form): 26 | name = fields.TextField(validators=[Required()]) 27 | email = fields.TextField(validators=[Email()]) 28 | password = fields.PasswordField(validators=[Required()]) 29 | conf_password = fields.PasswordField(validators=[Required()]) 30 | 31 | def validate_login(self, field): 32 | if db.session.query(User).filter_by(username=self.username.data).count() > 0: 33 | raise validators.ValidationError('Duplicate username') 34 | -------------------------------------------------------------------------------- /app/users/models.py: -------------------------------------------------------------------------------- 1 | from app import db 2 | from app.mixins import CRUDMixin 3 | from flask.ext.login import UserMixin 4 | from app.tracking.models import Site 5 | 6 | class User(UserMixin, CRUDMixin, db.Model): 7 | __tablename__ = 'users_user' 8 | id = db.Column(db.Integer, primary_key=True) 9 | name = db.Column(db.String(50), unique=True) 10 | email = db.Column(db.String(120), unique=True) 11 | password = db.Column(db.String(120)) 12 | sites = db.relationship('Site', backref='site', 13 | lazy='dynamic') 14 | 15 | def __init__(self, name=None, email=None, password=None): 16 | self.name = name 17 | self.email = email 18 | self.password = password 19 | 20 | def __repr__(self): 21 | return '' % (self.name) -------------------------------------------------------------------------------- /app/users/tests.py: -------------------------------------------------------------------------------- 1 | from flask import url_for 2 | from flask.ext.login import current_user 3 | 4 | from app.bases import BaseTestCase 5 | from app.users.models import User 6 | 7 | 8 | class UserViewsTests(BaseTestCase): 9 | def test_users_can_login(self): 10 | User.create(name="Joe", email="joe@joes.com", password="12345") 11 | 12 | with self.client: 13 | response = self.client.post("/login/", data={"name": "Joe", "password": "12345"}) 14 | 15 | self.assert_redirects(response, url_for("index")) 16 | self.assertTrue(current_user.name == "Joe") 17 | self.assertFalse(current_user.is_anonymous()) 18 | 19 | def test_users_can_logout(self): 20 | User.create(name="Joe", email="joe@joes.com", password="12345") 21 | 22 | with self.client: 23 | self.client.post("/login/", data={"name": "Joe", "password": "12345"}) 24 | self.client.get("/logout/") 25 | 26 | self.assertTrue(current_user.is_anonymous()) 27 | 28 | def test_invalid_password_is_rejected(self): 29 | User.create(name="Joe", email="joe@joes.com", password="12345") 30 | 31 | with self.client: 32 | self.client.post("/login/", data={"name": "Joe", "password": "****"}) 33 | 34 | self.assertTrue(current_user.is_anonymous()) 35 | -------------------------------------------------------------------------------- /app/users/views.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, render_template, flash, redirect, session, url_for, request, g 2 | from flask.ext.login import login_user, logout_user, current_user, login_required 3 | from app import app, db, login_manager 4 | from forms import LoginForm, RegistrationForm 5 | from app.users.models import User 6 | 7 | mod = Blueprint('users', __name__) 8 | 9 | 10 | @login_manager.user_loader 11 | def load_user(user_id): 12 | return User.query.get(user_id) 13 | 14 | @mod.route('/login/', methods=('GET', 'POST')) 15 | def login_view(): 16 | form = LoginForm(request.form) 17 | if form.validate_on_submit(): 18 | user = form.get_user() 19 | login_user(user) 20 | flash("Logged in successfully.") 21 | return redirect(request.args.get("next") or url_for("index")) 22 | return render_template('users/login.html', form=form) 23 | 24 | @mod.route('/register/', methods=('GET', 'POST')) 25 | def register_view(): 26 | form = RegistrationForm(request.form) 27 | if form.validate_on_submit(): 28 | user = User() 29 | form.populate_obj(user) 30 | db.session.add(user) 31 | db.session.commit() 32 | login_user(user) 33 | return redirect(url_for('index')) 34 | return render_template('users/register.html', form=form) 35 | 36 | @login_required 37 | @mod.route('/logout/') 38 | def logout_view(): 39 | logout_user() 40 | return redirect(url_for('index')) -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | _basedir = os.path.abspath(os.path.dirname(__file__)) 4 | 5 | 6 | class BaseConfiguration(object): 7 | DEBUG = False 8 | TESTING = False 9 | 10 | ADMINS = frozenset(['youremail@yourdomain.com']) 11 | SECRET_KEY = 'SecretKeyForSessionSigning' 12 | 13 | THREADS_PER_PAGE = 8 14 | 15 | CSRF_ENABLED = True 16 | CSRF_SESSION_KEY = "somethingimpossibletoguess" 17 | 18 | RECAPTCHA_USE_SSL = False 19 | RECAPTCHA_PUBLIC_KEY = 'blahblahblahblahblahblahblahblahblah' 20 | RECAPTCHA_PRIVATE_KEY = 'blahblahblahblahblahblahprivate' 21 | RECAPTCHA_OPTIONS = {'theme': 'white'} 22 | 23 | DATABASE = 'app.db' 24 | 25 | DATABASE_PATH = os.path.join(_basedir, DATABASE) 26 | SQLALCHEMY_DATABASE_URI = 'sqlite:///' + DATABASE_PATH 27 | 28 | 29 | class TestConfiguration(BaseConfiguration): 30 | TESTING = True 31 | 32 | CSRF_ENABLED = False 33 | 34 | DATABASE = 'tests.db' 35 | DATABASE_PATH = os.path.join(_basedir, DATABASE) 36 | SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' # + DATABASE_PATH 37 | 38 | 39 | class DebugConfiguration(BaseConfiguration): 40 | DEBUG = True 41 | -------------------------------------------------------------------------------- /docs/config.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | config.py 6 | 7 | 8 | 9 |
10 |
11 |
12 |

config.py

13 |
14 |
15 |
16 |
17 |
18 | # 19 |
20 | 21 |
22 |
23 |
24 | 
25 | 
26 |
27 |
28 |
29 |
30 | 31 | -------------------------------------------------------------------------------- /docs/pycco.css: -------------------------------------------------------------------------------- 1 | /*--------------------- Layout and Typography ----------------------------*/ 2 | body { 3 | font-family: 'Palatino Linotype', 'Book Antiqua', Palatino, FreeSerif, serif; 4 | font-size: 16px; 5 | line-height: 24px; 6 | color: #252519; 7 | margin: 0; padding: 0; 8 | } 9 | a { 10 | color: #261a3b; 11 | } 12 | a:visited { 13 | color: #261a3b; 14 | } 15 | p { 16 | margin: 0 0 15px 0; 17 | } 18 | h1, h2, h3, h4, h5, h6 { 19 | margin: 40px 0 15px 0; 20 | } 21 | h2, h3, h4, h5, h6 { 22 | margin-top: 0; 23 | } 24 | #container, div.section { 25 | position: relative; 26 | } 27 | #background { 28 | position: fixed; 29 | top: 0; left: 580px; right: 0; bottom: 0; 30 | background: #f5f5ff; 31 | border-left: 1px solid #e5e5ee; 32 | z-index: -1; 33 | } 34 | #jump_to, #jump_page { 35 | background: white; 36 | -webkit-box-shadow: 0 0 25px #777; -moz-box-shadow: 0 0 25px #777; 37 | -webkit-border-bottom-left-radius: 5px; -moz-border-radius-bottomleft: 5px; 38 | font: 10px Arial; 39 | text-transform: uppercase; 40 | cursor: pointer; 41 | text-align: right; 42 | } 43 | #jump_to, #jump_wrapper { 44 | position: fixed; 45 | right: 0; top: 0; 46 | padding: 5px 10px; 47 | } 48 | #jump_wrapper { 49 | padding: 0; 50 | display: none; 51 | } 52 | #jump_to:hover #jump_wrapper { 53 | display: block; 54 | } 55 | #jump_page { 56 | padding: 5px 0 3px; 57 | margin: 0 0 25px 25px; 58 | } 59 | #jump_page .source { 60 | display: block; 61 | padding: 5px 10px; 62 | text-decoration: none; 63 | border-top: 1px solid #eee; 64 | } 65 | #jump_page .source:hover { 66 | background: #f5f5ff; 67 | } 68 | #jump_page .source:first-child { 69 | } 70 | div.docs { 71 | float: left; 72 | max-width: 500px; 73 | min-width: 500px; 74 | min-height: 5px; 75 | padding: 10px 25px 1px 50px; 76 | vertical-align: top; 77 | text-align: left; 78 | } 79 | .docs pre { 80 | margin: 15px 0 15px; 81 | padding-left: 15px; 82 | } 83 | .docs p tt, .docs p code { 84 | background: #f8f8ff; 85 | border: 1px solid #dedede; 86 | font-size: 12px; 87 | padding: 0 0.2em; 88 | } 89 | .octowrap { 90 | position: relative; 91 | } 92 | .octothorpe { 93 | font: 12px Arial; 94 | text-decoration: none; 95 | color: #454545; 96 | position: absolute; 97 | top: 3px; left: -20px; 98 | padding: 1px 2px; 99 | opacity: 0; 100 | -webkit-transition: opacity 0.2s linear; 101 | } 102 | div.docs:hover .octothorpe { 103 | opacity: 1; 104 | } 105 | div.code { 106 | margin-left: 580px; 107 | padding: 14px 15px 16px 50px; 108 | vertical-align: top; 109 | } 110 | .code pre, .docs p code { 111 | font-size: 12px; 112 | } 113 | pre, tt, code { 114 | line-height: 18px; 115 | font-family: Monaco, Consolas, "Lucida Console", monospace; 116 | margin: 0; padding: 0; 117 | } 118 | div.clearall { 119 | clear: both; 120 | } 121 | 122 | 123 | /*---------------------- Syntax Highlighting -----------------------------*/ 124 | td.linenos { background-color: #f0f0f0; padding-right: 10px; } 125 | span.lineno { background-color: #f0f0f0; padding: 0 5px 0 5px; } 126 | body .hll { background-color: #ffffcc } 127 | body .c { color: #408080; font-style: italic } /* Comment */ 128 | body .err { border: 1px solid #FF0000 } /* Error */ 129 | body .k { color: #954121 } /* Keyword */ 130 | body .o { color: #666666 } /* Operator */ 131 | body .cm { color: #408080; font-style: italic } /* Comment.Multiline */ 132 | body .cp { color: #BC7A00 } /* Comment.Preproc */ 133 | body .c1 { color: #408080; font-style: italic } /* Comment.Single */ 134 | body .cs { color: #408080; font-style: italic } /* Comment.Special */ 135 | body .gd { color: #A00000 } /* Generic.Deleted */ 136 | body .ge { font-style: italic } /* Generic.Emph */ 137 | body .gr { color: #FF0000 } /* Generic.Error */ 138 | body .gh { color: #000080; font-weight: bold } /* Generic.Heading */ 139 | body .gi { color: #00A000 } /* Generic.Inserted */ 140 | body .go { color: #808080 } /* Generic.Output */ 141 | body .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ 142 | body .gs { font-weight: bold } /* Generic.Strong */ 143 | body .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ 144 | body .gt { color: #0040D0 } /* Generic.Traceback */ 145 | body .kc { color: #954121 } /* Keyword.Constant */ 146 | body .kd { color: #954121; font-weight: bold } /* Keyword.Declaration */ 147 | body .kn { color: #954121; font-weight: bold } /* Keyword.Namespace */ 148 | body .kp { color: #954121 } /* Keyword.Pseudo */ 149 | body .kr { color: #954121; font-weight: bold } /* Keyword.Reserved */ 150 | body .kt { color: #B00040 } /* Keyword.Type */ 151 | body .m { color: #666666 } /* Literal.Number */ 152 | body .s { color: #219161 } /* Literal.String */ 153 | body .na { color: #7D9029 } /* Name.Attribute */ 154 | body .nb { color: #954121 } /* Name.Builtin */ 155 | body .nc { color: #0000FF; font-weight: bold } /* Name.Class */ 156 | body .no { color: #880000 } /* Name.Constant */ 157 | body .nd { color: #AA22FF } /* Name.Decorator */ 158 | body .ni { color: #999999; font-weight: bold } /* Name.Entity */ 159 | body .ne { color: #D2413A; font-weight: bold } /* Name.Exception */ 160 | body .nf { color: #0000FF } /* Name.Function */ 161 | body .nl { color: #A0A000 } /* Name.Label */ 162 | body .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ 163 | body .nt { color: #954121; font-weight: bold } /* Name.Tag */ 164 | body .nv { color: #19469D } /* Name.Variable */ 165 | body .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ 166 | body .w { color: #bbbbbb } /* Text.Whitespace */ 167 | body .mf { color: #666666 } /* Literal.Number.Float */ 168 | body .mh { color: #666666 } /* Literal.Number.Hex */ 169 | body .mi { color: #666666 } /* Literal.Number.Integer */ 170 | body .mo { color: #666666 } /* Literal.Number.Oct */ 171 | body .sb { color: #219161 } /* Literal.String.Backtick */ 172 | body .sc { color: #219161 } /* Literal.String.Char */ 173 | body .sd { color: #219161; font-style: italic } /* Literal.String.Doc */ 174 | body .s2 { color: #219161 } /* Literal.String.Double */ 175 | body .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */ 176 | body .sh { color: #219161 } /* Literal.String.Heredoc */ 177 | body .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */ 178 | body .sx { color: #954121 } /* Literal.String.Other */ 179 | body .sr { color: #BB6688 } /* Literal.String.Regex */ 180 | body .s1 { color: #219161 } /* Literal.String.Single */ 181 | body .ss { color: #19469D } /* Literal.String.Symbol */ 182 | body .bp { color: #954121 } /* Name.Builtin.Pseudo */ 183 | body .vc { color: #19469D } /* Name.Variable.Class */ 184 | body .vg { color: #19469D } /* Name.Variable.Global */ 185 | body .vi { color: #19469D } /* Name.Variable.Instance */ 186 | body .il { color: #666666 } /* Literal.Number.Integer.Long */ 187 | -------------------------------------------------------------------------------- /docs/run.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | run.py 6 | 7 | 8 | 9 |
10 |
11 |
12 |

run.py

13 |
14 |
15 |
16 |
17 |
18 | # 19 |
20 | 21 |
22 |
23 |
24 | 
25 | 
26 |
27 |
28 |
29 |
30 | 31 | -------------------------------------------------------------------------------- /docs/shell.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | shell.py 6 | 7 | 8 | 9 |
10 |
11 |
12 |

shell.py

13 |
14 |
15 |
16 |
17 |
18 | # 19 |
20 | 21 |
22 |
23 |
24 | 
25 | 
26 |
27 |
28 |
29 |
30 | 31 | -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2014 Michael Herman (michael@mherman.org), 4 | Sean Viera (vieira.sean@gmail.com), and 5 | Real Python (http://www.realpython.com) 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | THE SOFTWARE. 24 | 25 | If you publish this code as part of a lecture series or tutorial you must 26 | acknowledge the source and provide a link (https://github.com/mjhea0/flask-tracking). 27 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==0.10.1 2 | pycco 3 | Flask-Login==0.2.6 4 | Flask-SQLAlchemy==1.0 5 | Flask-WTF==0.8.4 6 | Jinja2==2.7.1 7 | MarkupSafe==0.18 8 | SQLAlchemy==0.8.2 9 | WTForms==1.0.4 10 | Werkzeug==0.9.3 11 | distribute==0.6.34 12 | flask-heroku==0.1.4 13 | itsdangerous==0.23 14 | psycopg2==2.5.1 15 | requests==1.2.3 16 | gunicorn==17.5 17 | wsgiref==0.1.2 18 | Flask-Testing==0.4 19 | mock==1.0.1 20 | ipdb==0.8 21 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | from app import app 2 | app.run(debug=True) 3 | -------------------------------------------------------------------------------- /screenshots/flask-tracking-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjhea0/flask-tracking/e792016decbd6d548ae43a5d300c9e6ca5a425ee/screenshots/flask-tracking-1.png -------------------------------------------------------------------------------- /screenshots/flask-tracking-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjhea0/flask-tracking/e792016decbd6d548ae43a5d300c9e6ca5a425ee/screenshots/flask-tracking-2.png -------------------------------------------------------------------------------- /screenshots/flask-tracking-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjhea0/flask-tracking/e792016decbd6d548ae43a5d300c9e6ca5a425ee/screenshots/flask-tracking-3.png -------------------------------------------------------------------------------- /shell.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import readline 4 | from pprint import pprint 5 | 6 | from flask import * 7 | from app import * 8 | 9 | os.environ['PYTHONINSPECT'] = 'True' 10 | -------------------------------------------------------------------------------- /test.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjhea0/flask-tracking/e792016decbd6d548ae43a5d300c9e6ca5a425ee/test.db --------------------------------------------------------------------------------