├── .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 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
My Tracker
57 |
71 |
72 |
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 |
{% for error in field.errors %}{{ error|e }} {% endfor %}
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 |
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 | Time
33 | Url
34 | Event
35 | IP Address
36 | Location
37 |
38 |
39 | {{ visit.date }}
40 | {{ visit.url }}
41 | {{ visit.event }}
42 | {{ visit.ip_address }}
43 | {{ visit.location }}
44 |
45 |
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 |
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 |
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 |
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 |
14 |
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 |
14 |
30 |
31 |
--------------------------------------------------------------------------------
/docs/shell.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
shell.py
6 |
7 |
8 |
9 |
10 |
11 |
14 |
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
--------------------------------------------------------------------------------