30 |
31 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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'))
--------------------------------------------------------------------------------
/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/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/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/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/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/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!