[
  {
    "path": ".coveragerc",
    "content": "[run]\ninclude =\n    safety/models.py\n    safety/utils.py\n    safety/views.py\n"
  },
  {
    "path": ".editorconfig",
    "content": "# editorconfig.org\nroot = true\n\n[*]\ncharset                  = utf-8\nend_of_line              = lf\nindent_size              = 4\nindent_style             = space\ninsert_final_newline     = true\ntrim_trailing_whitespace = true\n\n[Makefile]\nindent_style = tab\nindent_size = 4\n"
  },
  {
    "path": ".gitignore",
    "content": "__pycache__/\n*.py[cod]\n*.so\n.Python\nenv/\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\n*.egg-info/\n.installed.cfg\n*.egg\n*.manifest\n*.spec\npip-log.txt\npip-delete-this-directory.txt\nhtmlcov/\n.tox/\n.coverage\n.cache\nnosetests.xml\ncoverage.xml\n*.mo\n*.pot\n*.log\ndocs/_build/\ntarget/\n.vagrant\n.venv\n.env\n*.sqlite3\n*.db\n*.mmdb\n*.gz\n*.dat\n"
  },
  {
    "path": ".travis.yml",
    "content": "language: python\nenv:\n    - TOXENV=py27-django18\n    - TOXENV=py34-django18\n    - TOXENV=py35-django18\n    - TOXENV=py27-django19\n    - TOXENV=py34-django19\n    - TOXENV=py35-django19\nbefore_install:\n    - sudo apt-get -qq update\n    - sudo apt-get install -y libgeoip-dev\ninstall:\n    - travis_retry pip install tox\nscript:\n    - travis_retry tox\n"
  },
  {
    "path": "AUTHORS",
    "content": "Gilles Fabio <gilles.fabio@gmail.com>\nLouise Grandjonc <louise.grandjonc@gmail.com>\nFlorent Messa <florent.messa@gmail.com>\n"
  },
  {
    "path": "LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2016 Ulule\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "Makefile",
    "content": "devenv:\n\tvirtualenv -p python2.7 `pwd`/.venv\n\t. .venv/bin/activate && pip install -r requirements/development.txt\n\nclean:\n\t@(rm -rvf .venv .tox .coverage build django-safety* *.egg-info)\n\npep8:\n\t@(flake8 safety --ignore=E501,E127,E128,E124)\n\ntest:\n\t@(py.test -s --cov-report term --cov-config .coveragerc --cov=safety --color=yes safety/tests)\n\ngeoip:\n\t@(wget http://geolite.maxmind.com/download/geoip/database/GeoLiteCountry/GeoIP.dat.gz)\n\t@(wget http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.mmdb.gz)\n\t@(wget http://geolite.maxmind.com/download/geoip/database/GeoLiteCity.dat.gz)\n\t@(gunzip -d GeoIP.dat.gz && mv GeoIP.dat data/geoip/)\n\t@(gunzip -d GeoLite2-City.mmdb.gz && mv GeoLite2-City.mmdb data/geoip2/)\n\t@(gunzip -d GeoLiteCity.dat.gz && mv GeoLiteCity.dat data/geoip/)\n\nmigrate:\n\t@(ENV=example python manage.py makemigrations safety)\n\nexample-clean:\n\t@(rm -rf example.db)\n\nexample-migrate:\n\t@(ENV=example python manage.py migrate)\n\nexample-user:\n\t@(ENV=example python manage.py createsuperuser --username='johndoe' --email='johndoe@example.com')\n\nexample-serve:\n\t@(ENV=example python manage.py runserver)\n\ndelpyc:\n\t@(find . -name '*.pyc' -delete)\n\nrelease:\n\t@(python setup.py sdist register upload -s)\n"
  },
  {
    "path": "README.rst",
    "content": "django-safety\n=============\n\n.. image:: https://secure.travis-ci.org/ulule/django-safety.png?branch=master\n    :alt: Build Status\n    :target: http://travis-ci.org/ulule/django-safety\n\n\n**Generic Django application for safer user accounts.**\n\nFeatures\n--------\n\nSessions\n~~~~~~~~\n\n* User can see all active sessions\n* User can disable a given active session\n* User can disable all active sessions\n\nForce password change\n~~~~~~~~~~~~~~~~~~~~~\n\n* Administrators can require a password change for any user\n\nWorkflows\n---------\n\nSessions\n~~~~~~~~\n\n1. User logs in\n2. We connect the logic to the ``user_logged_in`` signal\n3. We create a new ``safety.models.Session`` instance\n4. User can see the list of her sessions (with IP, last activity and device information)\n5. User can delete a given session in the list\n6. We delete both the related ``safety.models.Session`` instance and related session in store\n7. User can delete all active sessions excepted the current one\n8. We proceed the same way: deleting instances and related sessions from store\n9. User logs out\n10. We connect the logic to the ``user_logged_out`` signal\n11. We delete the related ``safety.models.Session`` instance\n\nForce password change\n~~~~~~~~~~~~~~~~~~~~~\n\n1. Administrator creates a ``PasswordChange`` instance and sets ``required`` to ``True``\n2. When user logs in, it will be redirected to password change form\n3. Until the user does not change its password, it is not authorized to go elsewhere\n4. User changes its password\n5. It is now authorized to go elsewhere\n\nInstallation\n------------\n\nInstalling prerequisites\n~~~~~~~~~~~~~~~~~~~~~~~~\n\nGeoIP library must be installed on your server.\n\nOn OS X with Homebrew:\n\n.. code-block:: bash\n\n    brew install geoip\n\nYou also need the GeoIP databases.\n\nFor Django >= 1.9, download City and Country databases as binary (not CSV):\n\nhttp://dev.maxmind.com/geoip/geoip2/geolite2/\n\nFor Django 1.8, download City and Country legacy databases as binary (not CSV):\n\nhttp://dev.maxmind.com/geoip/legacy/geolite/\n\nCreate a directory wherever you want and uncompress these archives this\ndirectory. Once done, set ``GEOIP_PATH`` setting pointing to this directory:\n\n.. code-block:: python\n\n    GEOIP_PATH = '/absolute/path/to/maxmind/db/directory'\n\nInstalling django-safety\n~~~~~~~~~~~~~~~~~~~~~~~~\n\nInstall\n\n.. code-block:: bash\n\n    $ pip install django-safety\n\nIn your ``settings.py``, add ``safety`` to ``INSTALLED_APPS``:\n\n.. code-block:: python\n\n    INSTALLED_APPS = (\n        # Your other apps here.\n        'safety',\n    )\n\nIn your ``urls.py``, include ``safety.urls`` under ``safety`` namespace.\n\n.. code-block:: python\n\n    urlpatterns = [\n        # Your other URLs here.\n        url(r'^security/', include('safety.urls', namespace='safety')),\n    ]\n\nSynchronize the database:\n\n.. code-block:: bash\n\n    $ python manage.py migrate safety\n\nGreat. The session feature is ready.\n\nIf you want to enable the \"force password change\" feature, read the next.\n\nEnabling \"force password change\" feature\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nTo enable this feature, you have two choices:\n\n* You want to protect only specific views? Use ``password_change_required()`` decorator\n* You want to protect your whole application? Use ``PasswordChangeMiddleware`` middleware\n\nThe decorator works as any Django view decorator.\n\n.. code-block:: python\n\n    #\n    # In your urls.py\n    #\n\n    from safety.decorators import password_change_required\n    from .views import protect_me\n\n    urlpatterns = [\n        # Other URLs here.\n        url(r'^protect-me/$', password_change_required(protect_me)),\n    ]\n\n    #\n    # Or in your views.py (it's up to you)\n    #\n    from django.shortcuts import render\n    from safety.decorators import password_change_required\n\n    @password_change_required\n    def protect_me(request):\n        return render(request, 'protect_me.html')\n\nThe middleware works as any Django middleware.\n\nAdd ``safety.middleware.PasswordChangeMiddleware`` middleware in your ``settings.py``:\n\n.. code-block:: python\n\n    MIDDLEWARE_CLASSES = [\n        'django.middleware.security.SecurityMiddleware',\n        'django.contrib.sessions.middleware.SessionMiddleware',\n        'django.middleware.common.CommonMiddleware',\n        'django.middleware.csrf.CsrfViewMiddleware',\n        'django.contrib.auth.middleware.AuthenticationMiddleware',\n        'django.contrib.auth.middleware.SessionAuthenticationMiddleware',\n        'django.contrib.messages.middleware.MessageMiddleware',\n        'django.middleware.clickjacking.XFrameOptionsMiddleware',\n        'safety.middleware.PasswordChangeMiddleware',\n    ]\n\nDone.\n\nSettings\n--------\n\n+-------------------------------------------+---------------------------------------------------------------------+\n| Setting                                   | Description                                                         |\n+===========================================+=====================================================================+\n| ``SAFETY_LOGIN_REQUIRED_MIXIN_CLASS``     | The Python path to your own \"login required\" mixin class.           |\n|                                           | Defaults to ``safety.mixins.LoginRequiredMixin``.                   |\n+-------------------------------------------+---------------------------------------------------------------------+\n| ``SAFETY_IP_RESOLVER``                    | The Python path to your own IP resolver callable.                   |\n|                                           | Defaults to ``safety.resolvers.remote_addr_ip``.                    |\n+-------------------------------------------+---------------------------------------------------------------------+\n| ``SAFETY_DEVICE_RESOLVER``                | The Python path to your own device resolver callable.               |\n|                                           | Defaults to ``safety.resolvers.device``.                            |\n+-------------------------------------------+---------------------------------------------------------------------+\n| ``SAFETY_LOCATION_RESOLVER``              | The Python path to your own location resolver callable.             |\n|                                           | Defaults to ``safety.resolvers.location``.                          |\n+-------------------------------------------+---------------------------------------------------------------------+\n\nDevelopment\n-----------\n\n.. code-block:: bash\n\n    # Install pip and virtualenv\n    $ sudo easy_install pip\n    $ sudo pip install virtualenv\n\n    # Clone repository\n    $ git clone https://github.com/ulule/django-safety.git\n\n    # Setup your development environment\n    $ cd django-safety\n    $ make devenv\n    $ source .venv/bin/activate\n\n    # Download GeoIP databases\n    $ make geoip\n\n    # Launch test suite\n    $ make test\n\n    # Launch test suite with tox to check compatibility\n    $ tox\n\n    # Run the example project (default user username is \"johndoe\")\n    $ make example-migrate\n    $ make example-user\n    $ make example-serve\n\nContribute\n----------\n\n1. Create an issue (**before** submitting pull requests)\n2. Submit your bug or feature request\n3. You want to fix or code it yourself? Great! Fork the project\n4. Create a branch, always add tests and make sure they all pass with ``tox``\n5. Submit a pull request\n\nCompatibility\n-------------\n\n- python 2.7: Django 1.8, 1.9\n- Python 3.4: Django 1.8, 1.9\n- Python 3.5: Django 1.8, 1.9\n"
  },
  {
    "path": "data/.gitkeep",
    "content": ""
  },
  {
    "path": "data/geoip/.gitkeep",
    "content": ""
  },
  {
    "path": "data/geoip2/.gitkeep",
    "content": ""
  },
  {
    "path": "example/__init__.py",
    "content": ""
  },
  {
    "path": "example/models.py",
    "content": ""
  },
  {
    "path": "example/settings.py",
    "content": "import os\n\nimport django\n\n\nBASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\n\nSECRET_KEY = 'zn=kw41f9nhe1lse8minnu0s-@7b+q(exccs5d-1vil$^ees&#'\nDEBUG = True\nALLOWED_HOSTS = []\n\nINSTALLED_APPS = [\n    'django.contrib.admin',\n    'django.contrib.auth',\n    'django.contrib.contenttypes',\n    'django.contrib.sessions',\n    'django.contrib.messages',\n    'django.contrib.staticfiles',\n\n    'example',\n\n    'safety',\n]\n\nMIDDLEWARE_CLASSES = [\n    'django.middleware.security.SecurityMiddleware',\n    'django.contrib.sessions.middleware.SessionMiddleware',\n    'django.middleware.common.CommonMiddleware',\n    'django.middleware.csrf.CsrfViewMiddleware',\n    'django.contrib.auth.middleware.AuthenticationMiddleware',\n    'django.contrib.auth.middleware.SessionAuthenticationMiddleware',\n    'django.contrib.messages.middleware.MessageMiddleware',\n    'django.middleware.clickjacking.XFrameOptionsMiddleware',\n    'safety.middleware.PasswordChangeMiddleware',\n]\n\nROOT_URLCONF = 'example.urls'\n\nTEMPLATES = [\n    {\n        'BACKEND': 'django.template.backends.django.DjangoTemplates',\n        'DIRS': [],\n        'APP_DIRS': True,\n        'OPTIONS': {\n            'context_processors': [\n                'django.template.context_processors.debug',\n                'django.template.context_processors.request',\n                'django.contrib.auth.context_processors.auth',\n                'django.contrib.messages.context_processors.messages',\n            ],\n        },\n    },\n]\n\nDATABASES = {\n    'default': {\n        'ENGINE': 'django.db.backends.sqlite3',\n        'NAME': os.path.join(BASE_DIR, 'example.db'),\n    }\n}\n\nAUTH_PASSWORD_VALIDATORS = [\n    {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},\n    {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},\n    {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},\n    {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},\n]\n\nLANGUAGE_CODE = 'en-us'\nTIME_ZONE = 'UTC'\nUSE_I18N = True\nUSE_L10N = True\nUSE_TZ = True\n\nSTATIC_URL = '/static/'\n\nAUTH_USER_MODEL = 'auth.User'\nLOGIN_URL = '/admin/login/'\n\nEMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'\n\nGEOIP_DIR_PATH = os.path.join(BASE_DIR, 'data', 'geoip')\nGEOIP2_DIR_PATH = os.path.join(BASE_DIR, 'data', 'geoip2')\nGEOIP_PATH = GEOIP2_DIR_PATH if django.VERSION >= (1, 9) else GEOIP_DIR_PATH\n"
  },
  {
    "path": "example/templates/base.html",
    "content": "<!DOCTYPE html>\n<html>\n    <head>\n        <title>{% block title %}{% endblock %}</title>\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <link href=\"//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.0.2/css/bootstrap.min.css\" rel=\"stylesheet\" media=\"screen\">\n        <!--[if lt IE 9]>\n            <script src=\"//cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7/html5shiv.js\"></script>\n            <script src=\"//cdnjs.cloudflare.com/ajax/libs/respond.js/1.3.0/respond.js\"></script>\n        <![endif]-->\n    </head>\n    <body>\n        {% block content_wrapper %}\n        <div class=\"container\">\n            <header>\n                <h3>Hello, {{ user }}!</h3>\n                <p>\n                    <a href=\"{% url 'home' %}\">home</a> —\n                    {% if request.user.is_authenticated %}<a href=\"{% url 'update-password' %}\">update password</a> —{% endif %}\n                    <a href=\"{% url 'admin:index' %}\">admin</a> —\n                    <a href=\"{% url 'safety:session_list' %}\">active sessions</a>\n                </p>\n            </header>\n            <hr>\n            {% block content %}{% endblock %}\n        </div>\n        {% endblock %}\n        <script src=\"//cdnjs.cloudflare.com/ajax/libs/jquery/2.0.3/jquery.min.js\"></script>\n        <script src=\"//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.0.2/js/bootstrap.min.js\"></script>\n    </body>\n</html>\n"
  },
  {
    "path": "example/templates/home.html",
    "content": "{% extends \"base.html\" %}\n\n{% block content %}\n    <p>I'm the homepage.</p>\n{% endblock content %}\n"
  },
  {
    "path": "example/templates/safety/password_change/base.html",
    "content": "{% extends \"base.html\" %}\n"
  },
  {
    "path": "example/templates/safety/session_list.html",
    "content": "{% extends \"base.html\" %}\n{% load i18n %}\n\n{% block content %}\n    <h1>{% trans \"Active Sessions\" %}</h1>\n    <table class=\"table\">\n        <thead>\n            <tr>\n                <th>{% trans \"Last Activity\" %}</th>\n                <th>{% trans \"Location\" %}</th>\n                <th>{% trans \"Device\" %}</th>\n                <th>{% trans \"End Session\" %}</th>\n            </tr>\n        </thead>\n        {% for object in object_list %}\n        <tr {% if object.session_key == request.session.session_key %}class=\"active\"{% endif %}>\n            <td>\n                {% if object.session_key == request.session.session_key %}\n                    {% blocktrans with time=object.last_activity|timesince %}{{ time }} ago (this session){% endblocktrans %}\n                {% else %}\n                    {% blocktrans with time=object.last_activity|timesince %}{{ time }} ago{% endblocktrans %}\n                {% endif %}\n            </td>\n            <td>{{ object.location }}</td>\n            <td>{{ object.device }}</td>\n            <td>\n                <form method=\"post\" action=\"{% url 'safety:session_delete' object.pk %}\">\n                    {% csrf_token %}\n                    {% if object.session_key == request.session.session_key %}\n                        <button type=\"submit\" class=\"btn btn-xs btn-link\">{% trans \"End Session\" %}</button>\n                    {% else %}\n                        <button type=\"submit\" class=\"btn btn-xs btn-warning\">{% trans \"End Session\" %}</button>\n                    {% endif %}\n                </form>\n            </td>\n        </tr>\n        {% endfor %}\n    </table>\n\n    {% if object_list.count > 1 %}\n        <form method=\"post\" action=\"{% url 'safety:session_delete_other' %}\">\n            {% csrf_token %}\n            <p>\n               {% blocktrans %}You can also end all other sessions but the current.\n               This will log you out on all other devices.{% endblocktrans %}\n           </p>\n           <button type=\"submit\" class=\"btn btn-default btn-warning\">{% trans \"End All Other Sessions\" %}</button>\n        </form>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "example/urls.py",
    "content": "# -*- coding: utf-8 -*-\nfrom django.conf.urls import url, include\nfrom django.contrib import admin\nfrom django.core.urlresolvers import reverse\nfrom django.shortcuts import redirect, render\n\nfrom safety.models import PasswordChange\n\n\ndef home(request):\n    return render(request, 'home.html')\n\n\ndef update_password(request):\n    if request.user.is_authenticated():\n        pr, created = PasswordChange.objects.get_or_create_for_user(request.user)\n        pr.required = True\n        pr.save()\n    return redirect(reverse('home'))\n\n\nurlpatterns = [\n    url(r'^admin/', admin.site.urls),\n    url(r'^account/', include('django.contrib.auth.urls')),\n    url(r'^security/', include('safety.urls', namespace='safety')),\n    url(r'^update-password/$', update_password, name='update-password'),\n    url(r'^$', home, name='home'),\n]\n"
  },
  {
    "path": "manage.py",
    "content": "#!/usr/bin/env python\nimport os\nimport sys\n\nSUPPORTED_ENVS = (\n    'test',\n    'example',\n)\n\nSETTINGS_MODULES = {\n    'test': 'safety.tests.settings',\n    'example': 'example.settings'\n}\n\nENV = os.environ.get('ENV', 'test')\nENV = ENV.lower()\n\nif ENV not in SUPPORTED_ENVS:\n    raise Exception('Unsupported environment: %s' % ENV)\n\nif __name__ == \"__main__\":\n    os.environ.setdefault('DJANGO_SETTINGS_MODULE', SETTINGS_MODULES[ENV])\n    from django.core.management import execute_from_command_line\n    execute_from_command_line(sys.argv)\n"
  },
  {
    "path": "pytest.ini",
    "content": "[pytest]\nDJANGO_SETTINGS_MODULE=safety.tests.settings\n"
  },
  {
    "path": "requirements/base.txt",
    "content": "six\nua-parser\ngeoip2\n"
  },
  {
    "path": "requirements/development.txt",
    "content": "-r base.txt\n-r test.txt\n\nDjango\nflake8\ntox\n"
  },
  {
    "path": "requirements/test.txt",
    "content": "pytest\npytest-cov\npytest-django\nexam\n"
  },
  {
    "path": "requirements/tox.txt",
    "content": "six\nua-parser\n\n-r test.txt\n"
  },
  {
    "path": "safety/__init__.py",
    "content": "# -*- coding: utf-8 -*-\nversion = (0, 1, 0)\n\n__version__ = '.'.join(map(str, version))\n\ndefault_app_config = 'safety.apps.SafetyConfig'\n\n__all__ = [\n    'default_app_config',\n    'version',\n]\n"
  },
  {
    "path": "safety/admin.py",
    "content": "# -*- coding: utf-8 -*-\nfrom django.contrib import admin\nfrom django.utils.timezone import now\n\nfrom .models import (\n    PasswordChange,\n    Session,\n)\n\n\nclass PasswordChangeAdmin(admin.ModelAdmin):\n    list_display = ('user', 'last_change_date', 'required')\n    raw_id_fields = ('user',)\n\n\nclass SessionAdmin(admin.ModelAdmin):\n    list_display = ('user', 'ip', 'last_activity', 'location', 'device', 'is_valid')\n    raw_id_fields = ('user',)\n\n    def is_valid(self, obj):\n        return obj.expiration_date > now()\n    is_valid.boolean = True\n\n\nadmin.site.register(PasswordChange, PasswordChangeAdmin)\nadmin.site.register(Session, SessionAdmin)\n"
  },
  {
    "path": "safety/app_settings.py",
    "content": "# -*- coding: utf-8 -*-\nfrom django.conf import settings\n\n\n# The Python path to the login_required mixin class applied to views.\nLOGIN_REQUIRED_MIXIN_CLASS = getattr(\n    settings,\n    'SAFETY_LOGIN_REQUIRED_MIXIN_CLASS',\n    'safety.mixins.LoginRequiredMixin')\n\n\n# Path to callable that handles the IP resolving.\n# Takes a django.http.HttpRequest instance and returns a string.\nIP_RESOLVER = getattr(\n    settings,\n    'SAFETY_IP_RESOLVER',\n    'safety.resolvers.remote_addr_ip')\n\n\n# Path to callable that handles the device resolving.\n# Takes a django.http.HttpRequest instance and returns a string.\nDEVICE_RESOLVER = getattr(\n    settings,\n    'SAFETY_DEVICE_RESOLVER',\n    'safety.resolvers.device')\n\n\n# Path to callable that handles the location resolving.\n# Takes a django.http.HttpRequest instance and returns a string.\nLOCATION_RESOLVER = getattr(\n    settings,\n    'SAFETY_LOCATION_RESOLVER',\n    'safety.resolvers.location')\n"
  },
  {
    "path": "safety/apps.py",
    "content": "# -*- coding: utf-8 -*-\nfrom django.apps import AppConfig\n\n\nclass SafetyConfig(AppConfig):\n    name = 'safety'\n    verbose_name = 'Safety'\n\n    def ready(self):\n        super(SafetyConfig, self).ready()\n"
  },
  {
    "path": "safety/compat.py",
    "content": "# -*- coding: utf-8 -*-\nimport django\n\n\nif django.VERSION >= (1, 9):\n    from importlib import import_module  # noqa\n\n    from django.contrib.gis.geoip2 import HAS_GEOIP2\n\n    if HAS_GEOIP2:\n        from django.contrib.gis.geoip2 import GeoIP2 as GeoIP  # noqa\n    else:\n        from django.contrib.gis.geoip import GeoIP  # noqa\n\nelse:\n    from django.utils.importlib import import_module  # noqa\n    from django.contrib.gis.geoip import GeoIP  # noqa\n"
  },
  {
    "path": "safety/decorators.py",
    "content": "# -*- coding: utf-8 -*-\nfrom functools import wraps\n\nfrom django.core.urlresolvers import reverse\nfrom django.shortcuts import redirect\n\nfrom .models import (\n    PasswordChange,\n    Session,\n)\n\n\ndef password_change_required(func):\n    @wraps(func)\n    def inner(request, *args, **kwargs):\n        if not request.user.is_authenticated():\n            return func(request, *args, **kwargs)\n\n        required = PasswordChange.objects.is_required_for_user(request.user)\n        url = reverse('safety:password_change')\n        is_excluded_url = request.path.startswith(url)\n\n        if required and not is_excluded_url:\n            return redirect(url)\n\n        return func(request, *args, **kwargs)\n    return inner\n"
  },
  {
    "path": "safety/forms.py",
    "content": "# -*- coding: utf-8 -*-\nfrom django import forms\nfrom django.contrib.auth.forms import PasswordChangeForm as BasePasswordChangeForm\nfrom django.utils.translation import ugettext_lazy as _\n\nfrom .models import PasswordChange\n\n\nclass PasswordChangeForm(BasePasswordChangeForm):\n    error_messages = dict(BasePasswordChangeForm.error_messages, **{\n        'same_password': _(\"Your old password and the new one are the same. You must use a different password.\"),\n    })\n\n    def clean_new_password2(self):\n        password2 = super(PasswordChangeForm, self).clean_new_password2()\n\n        if self.user.check_password(password2):\n            raise forms.ValidationError(\n                self.error_messages['same_password'],\n                code='same_password')\n\n        return password2\n\n    def save(self, *args, **kwargs):\n        pc, created = PasswordChange.objects.get_or_create_for_user(self.user)\n        pc.required = False\n        pc.save()\n        super(PasswordChangeForm, self).save(*args, **kwargs)\n"
  },
  {
    "path": "safety/management/__init__.py",
    "content": ""
  },
  {
    "path": "safety/management/commands/__init__.py",
    "content": ""
  },
  {
    "path": "safety/management/commands/clean_safety_sessions.py",
    "content": "# -*- coding: utf-8 -*-\nfrom django.core.management.base import BaseCommand\n\nfrom django.utils.timezone import now\nfrom safety.models import Session\n\n\nclass Command(BaseCommand):\n    def handle(self, *args, **options):\n        Session.objects.filter(expiration_date__lt=now()).delete()\n        self.stdout.write(self.style.SUCCESS('Deleted expired sessions'))\n"
  },
  {
    "path": "safety/managers.py",
    "content": "# -*- coding: utf-8 -*-\nfrom django.db import models, transaction, IntegrityError\nfrom django.utils.timezone import now\n\nfrom . import app_settings\nfrom . import utils\n\n\nclass PasswordChangeManager(models.Manager):\n    def get_or_create_for_user(self, user):\n        return self.get_or_create(user=user)\n\n    def is_required_for_user(self, user):\n        obj, created = self.get_or_create_for_user(user=user)\n        return obj.required\n\n\nclass SessionManager(models.Manager):\n    def active(self, user=None):\n        qs = self.filter(expiration_date__gt=now())\n        if user is not None:\n            qs = qs.filter(user=user)\n        return qs.order_by('-last_activity')\n\n    def create_session(self, request, user):\n        ip = utils.resolve(app_settings.IP_RESOLVER, request)\n        device = utils.resolve(app_settings.DEVICE_RESOLVER, request)\n        location = utils.resolve(app_settings.LOCATION_RESOLVER, request)\n\n        user_agent = request.META.get('HTTP_USER_AGENT', '')\n        user_agent = user_agent[:200] if user_agent else user_agent\n\n        try:\n            with transaction.atomic():\n                obj = self.create(\n                    user=user,\n                    session_key=request.session.session_key,\n                    ip=ip,\n                    user_agent=user_agent,\n                    device=device,\n                    location=location,\n                    expiration_date=request.session.get_expiry_date(),\n                    last_activity=now())\n        except IntegrityError:\n            obj = self.get(\n                user=user,\n                session_key=request.session.session_key)\n            obj.last_activity = now()\n            obj.save()\n\n        return obj\n"
  },
  {
    "path": "safety/middleware.py",
    "content": "# -*- coding: utf-8 -*-\nfrom .decorators import password_change_required\n\n\nclass PasswordChangeMiddleware(object):\n    def process_view(self, request, view_func, view_args, view_kwargs):\n        return password_change_required(view_func)(request, *view_args, **view_kwargs)\n"
  },
  {
    "path": "safety/migrations/0001_initial.py",
    "content": "# -*- coding: utf-8 -*-\n# Generated by Django 1.9.2 on 2016-03-16 12:51\nfrom __future__ import unicode_literals\n\nfrom django.conf import settings\nfrom django.db import migrations, models\nimport django.db.models.deletion\n\n\nclass Migration(migrations.Migration):\n\n    initial = True\n\n    dependencies = [\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name='PasswordChange',\n            fields=[\n                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),\n                ('required', models.BooleanField(db_index=True, default=False, verbose_name='required')),\n                ('last_change_date', models.DateTimeField(blank=True, null=True, verbose_name='last change date')),\n                ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='safety_password_change', to=settings.AUTH_USER_MODEL, verbose_name='user')),\n            ],\n            options={\n                'verbose_name': 'password change',\n                'verbose_name_plural': 'password changes',\n            },\n        ),\n        migrations.CreateModel(\n            name='Session',\n            fields=[\n                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),\n                ('session_key', models.CharField(max_length=40, unique=True, verbose_name='session key')),\n                ('ip', models.GenericIPAddressField(verbose_name='IP')),\n                ('user_agent', models.CharField(max_length=200, verbose_name='user agent')),\n                ('location', models.CharField(max_length=255, verbose_name='location')),\n                ('device', models.CharField(max_length=200, verbose_name='device')),\n                ('expiration_date', models.DateTimeField(db_index=True, verbose_name='expiration date')),\n                ('last_activity', models.DateTimeField(verbose_name='last activity')),\n                ('active', models.BooleanField(default=True)),\n                ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user')),\n            ],\n            options={\n                'verbose_name': 'session',\n                'verbose_name_plural': 'sessions',\n            },\n        ),\n    ]\n"
  },
  {
    "path": "safety/migrations/__init__.py",
    "content": ""
  },
  {
    "path": "safety/mixins.py",
    "content": "# -*- coding: utf-8 -*-\nfrom django.contrib.auth.decorators import login_required\nfrom django.utils.decorators import method_decorator\n\nfrom .models import Session\n\n\nclass SessionMixin(object):\n    def get_queryset(self):\n        return Session.objects.active(user=self.request.user)\n\n\nclass LoginRequiredMixin(object):\n    @method_decorator(login_required)\n    def dispatch(self, request, *args, **kwargs):\n        return super(LoginRequiredMixin, self).dispatch(request, *args, **kwargs)\n"
  },
  {
    "path": "safety/models.py",
    "content": "# -*- coding: utf-8 -*-\nfrom django.conf import settings\n\nfrom django.contrib.auth.signals import (\n    user_logged_in,\n    user_logged_out,\n)\n\nfrom django.db.models.signals import post_delete\nfrom django.db import models\nfrom django.utils.encoding import python_2_unicode_compatible\nfrom django.utils.translation import ugettext_lazy as _\n\nfrom . import managers\n\n\n@python_2_unicode_compatible\nclass PasswordChange(models.Model):\n    user = models.OneToOneField(\n        getattr(settings, 'AUTH_USER_MODEL', 'auth.User'),\n        verbose_name=_('user'),\n        related_name='safety_password_change')\n\n    required = models.BooleanField(verbose_name=_('required'), db_index=True, default=False)\n    last_change_date = models.DateTimeField(verbose_name=_('last change date'), null=True, blank=True)\n\n    objects = managers.PasswordChangeManager()\n\n    class Meta:\n        verbose_name = _('password change')\n        verbose_name_plural = _('password changes')\n\n    def __str__(self):\n        return '%s - %s' % (self.user, self.last_change_date)\n\n\n@python_2_unicode_compatible\nclass Session(models.Model):\n    user = models.ForeignKey(getattr(settings, 'AUTH_USER_MODEL', 'auth.User'), verbose_name=_('user'), null=True)\n    session_key = models.CharField(verbose_name=_('session key'), max_length=40, unique=True)\n    ip = models.GenericIPAddressField(verbose_name=_('IP'))\n    user_agent = models.CharField(verbose_name=_('user agent'), max_length=200)\n    location = models.CharField(verbose_name=_('location'), max_length=255)\n    device = models.CharField(verbose_name=_('device'), max_length=200)\n    expiration_date = models.DateTimeField(verbose_name=_('expiration date'), db_index=True)\n    last_activity = models.DateTimeField(verbose_name=_('last activity'))\n    active = models.BooleanField(default=True)\n\n    objects = managers.SessionManager()\n\n    class Meta:\n        verbose_name = _('session')\n        verbose_name_plural = _('sessions')\n\n    def __str__(self):\n        return '%s (%s)' % (self.user, self.device)\n\n    def delete_store_session(self):\n        from .utils import get_session_store\n\n        store = get_session_store()\n\n        if store.exists(session_key=self.session_key):\n            store.delete(session_key=self.session_key)\n\n\n# -----------------------------------------------------------------------------\n# Signal handlers\n# -----------------------------------------------------------------------------\n\n# Connected to user_logged_in\ndef create_session(sender, request, user, **kwargs):\n    Session.objects.create_session(request, user)\n\n\n# Connected to user_logged_out\ndef deactivate_session(sender, request, user, **kwargs):\n    try:\n        key = request.session.session_key\n        instance = Session.objects.get(user=user, session_key=key)\n        instance.active = False\n        instance.save()\n    except Session.DoesNotExist:\n        pass\n\n\n# Connected to post_delete for Session model\ndef post_delete_session(sender, instance, **kwargs):\n    instance.delete_store_session()\n\n\nuser_logged_in.connect(create_session)\nuser_logged_out.connect(deactivate_session)\npost_delete.connect(post_delete_session, sender=Session)\n"
  },
  {
    "path": "safety/resolvers.py",
    "content": "# -*- coding: utf-8 -*-\nfrom django.utils.translation import ugettext\n\nfrom ua_parser import user_agent_parser\n\nfrom . import app_settings\nfrom . import utils\nfrom .compat import GeoIP\n\n\ndef remote_addr_ip(request):\n    \"\"\"\n    This is for the development setup.\n    \"\"\"\n    return request.META.get('REMOTE_ADDR') or None\n\n\ndef x_forwarded_ip(request):\n    \"\"\"\n    Amazon ELB stores the IP in the HTTP_X_FORWARDED_FOR META attribute.\n    It is realiably the first one of the IP adresses sent and can be\n    trusted (eg.: cannot be spoofed) Warning: This might not be true for\n    other load balancers\n    This function assumes that your Nginx is configured with:\n    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n    \"\"\"\n    ip_address_list = request.META.get('HTTP_X_FORWARDED_FOR')\n\n    if ip_address_list:\n        ip_address_list = ip_address_list.split(',')\n        return ip_address_list[0]\n\n\ndef real_ip(request):\n    \"\"\"\n    Behind a Wsgi (Nginx) server.\n    \"\"\"\n    return request.META.get('HTTP_X_REAL_IP') or None\n\n\ndef device(request):\n    \"\"\"\n    Default device resolver using ua-parser.\n    \"\"\"\n    ua = request.META.get('HTTP_USER_AGENT', '')\n    parsed = user_agent_parser.Parse(ua)\n\n    ua_parsed = (ugettext('unknown browser'), parsed['user_agent'])\n    os_parsed = (ugettext('unknown system'), parsed['os'])\n\n    infos = []\n    for unknown, dct in (ua_parsed, os_parsed):\n        d = dct.copy()\n\n        family = d.pop('family')\n        if family is None or family == 'Other':\n            infos.append(unknown)\n            continue\n\n        version = '.'.join([d.get(k) for k in ('major', 'minor', 'patch') if d.get(k) is not None])\n        if version:\n            family = '%s %s' % (family, version)\n        infos.append(family)\n\n    return ' - '. join(infos)\n\n\ndef location(request):\n    \"\"\"\n    Transform an IP address into an approximate location.\n    \"\"\"\n    ip = utils.resolve(app_settings.IP_RESOLVER, request)\n\n    try:\n        location = GeoIP() and GeoIP().city(ip)\n    except:\n        # Handle 127.0.0.1 and not found IPs\n        return ugettext('unknown')\n\n    if location and location['country_name']:\n        if location['city']:\n            return '%s, %s' % (location['city'], location['country_name'])\n        else:\n            return location['country_name']\n\n    return ugettext('unknown')\n"
  },
  {
    "path": "safety/templates/safety/password_change/base.html",
    "content": "{% block title %}\n    {{ title }}\n{% endblock %}\n\n{% block content %}\n{% endblock %}\n"
  },
  {
    "path": "safety/templates/safety/password_change/done.html",
    "content": "{% extends \"safety/password_change/base.html\" %}\n{% load i18n %}\n\n{% block title %}{{ title }}{% endblock %}\n\n{% block content %}\n<p>{% trans 'Your password was changed.' %}</p>\n{% endblock %}\n"
  },
  {
    "path": "safety/templates/safety/password_change/form.html",
    "content": "{% extends \"safety/password_change/base.html\" %}\n{% load i18n %}\n\n{% block title %}{{ title }}{% endblock %}\n\n{% block content %}\n<div id=\"safety-password-change-form\">\n    <form method=\"post\">{% csrf_token %}\n        <div>\n            {% if form.errors %}\n                <p class=\"error\">\n                {% if form.errors.items|length == 1 %}{% trans \"Please correct the error below.\" %}{% else %}{% trans \"Please correct the errors below.\" %}{% endif %}\n                </p>\n            {% endif %}\n            <p>{% trans \"Please enter your old password, for security's sake, and then enter your new password twice so we can verify you typed it in correctly.\" %}</p>\n            <fieldset>\n                <div class=\"form-row\">\n                    {{ form.old_password.errors }}\n                    {{ form.old_password.label_tag }} {{ form.old_password }}\n                </div>\n                <div class=\"form-row\">\n                    {{ form.new_password1.errors }}\n                    {{ form.new_password1.label_tag }} {{ form.new_password1 }}\n                    {% if form.new_password1.help_text %}\n                    <p class=\"help\">{{ form.new_password1.help_text|safe }}</p>\n                    {% endif %}\n                </div>\n                <div class=\"form-row\">\n                    {{ form.new_password2.errors }}\n                    {{ form.new_password2.label_tag }} {{ form.new_password2 }}\n                    {% if form.new_password2.help_text %}\n                    <p class=\"help\">{{ form.new_password2.help_text|safe }}</p>\n                    {% endif %}\n                </div>\n            </fieldset>\n            <div class=\"submit-row\">\n                <input type=\"submit\" value=\"{% trans 'Change my password' %}\" class=\"default\" />\n            </div>\n        </div>\n    </form>\n</div>\n{% endblock %}\n"
  },
  {
    "path": "safety/tests/__init__.py",
    "content": ""
  },
  {
    "path": "safety/tests/base.py",
    "content": "# -*- coding: utf-8 -*-\nfrom django.contrib.auth.models import User\nfrom django.core.urlresolvers import reverse\nfrom django.test import TestCase\nfrom django.utils.timezone import now\n\nfrom exam.cases import Exam\nfrom exam.decorators import fixture\n\nfrom safety.models import Session\nfrom safety.utils import get_session_store\n\n\nclass Fixtures(Exam):\n    UA = ('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) '\n          'AppleWebKit/536.26.17 (KHTML, like Gecko) Version/6.0.2 '\n          'Safari/536.26.17')\n\n    DEVICE = 'Safari 6.0.2 - Mac OS X 10.8.2'\n    LOCATION = 'Mountain View, United States'\n    REMOTE_ADDR = '66.249.64.0'\n    USER_PASSWORD = 'secret'\n\n    @fixture\n    def user(self):\n        return User.objects.create_superuser(\n            username='johndoe',\n            email='johndoe@example.com',\n            password=self.USER_PASSWORD)\n\n    def reload(self):\n        self.user = User.objects.get(pk=self.user.pk)\n\n\nclass BaseTestCase(Fixtures, TestCase):\n    def login_user(self, password=None):\n        admin_login_url = reverse('admin:login')\n        password = password or self.USER_PASSWORD\n\n        self.client.post(\n            admin_login_url,\n            data={\n                'username': self.user.username,\n                'password': password,\n                'next': '/admin/',\n            },\n            REMOTE_ADDR=self.REMOTE_ADDR,\n            HTTP_USER_AGENT=self.UA)\n\n    def create_fake_sessions(self):\n        store = get_session_store()\n\n        sessions = []\n        for x in range(10):\n            store.create()\n            session = Session.objects.create(\n                user=self.user,\n                session_key=store.session_key,\n                ip=self.REMOTE_ADDR,\n                location=self.LOCATION,\n                device=self.DEVICE,\n                user_agent=self.UA,\n                last_activity=now(),\n                expiration_date=store.get_expiry_date())\n            sessions.append(session)\n\n        return sessions\n"
  },
  {
    "path": "safety/tests/models.py",
    "content": ""
  },
  {
    "path": "safety/tests/settings.py",
    "content": "import os\n\nimport django\n\n\nBASE_DIR = os.path.dirname(os.path.dirname((os.path.dirname(os.path.abspath(__file__)))))\n\nSECRET_KEY = 'zn=kw41f9nhe1lse8minnu0s-@7b+q(exccs5d-1vil$^ees&#'\nDEBUG = True\nALLOWED_HOSTS = []\n\nINSTALLED_APPS = [\n    'django.contrib.admin',\n    'django.contrib.auth',\n    'django.contrib.contenttypes',\n    'django.contrib.sessions',\n    'django.contrib.messages',\n    'django.contrib.staticfiles',\n\n    'example',\n    'safety',\n    'safety.tests',\n]\n\nMIDDLEWARE_CLASSES = [\n    'django.middleware.security.SecurityMiddleware',\n    'django.contrib.sessions.middleware.SessionMiddleware',\n    'django.middleware.common.CommonMiddleware',\n    'django.middleware.csrf.CsrfViewMiddleware',\n    'django.contrib.auth.middleware.AuthenticationMiddleware',\n    'django.contrib.auth.middleware.SessionAuthenticationMiddleware',\n    'django.contrib.messages.middleware.MessageMiddleware',\n    'django.middleware.clickjacking.XFrameOptionsMiddleware',\n    'safety.middleware.PasswordChangeMiddleware',\n]\n\nROOT_URLCONF = 'safety.tests.urls'\n\nTEMPLATES = [\n    {\n        'BACKEND': 'django.template.backends.django.DjangoTemplates',\n        'DIRS': [],\n        'APP_DIRS': True,\n        'OPTIONS': {\n            'context_processors': [\n                'django.template.context_processors.debug',\n                'django.template.context_processors.request',\n                'django.contrib.auth.context_processors.auth',\n                'django.contrib.messages.context_processors.messages',\n            ],\n        },\n    },\n]\n\nDATABASES = {\n    'default': {\n        'ENGINE': 'django.db.backends.sqlite3',\n        'NAME': os.path.join(BASE_DIR, 'test.db'),\n    }\n}\n\nAUTH_PASSWORD_VALIDATORS = [\n    {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},\n    {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},\n    {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},\n    {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},\n]\n\nLANGUAGE_CODE = 'en-us'\nTIME_ZONE = 'UTC'\nUSE_I18N = True\nUSE_L10N = True\nUSE_TZ = True\n\nSTATIC_URL = '/static/'\n\nAUTH_USER_MODEL = 'auth.User'\nLOGIN_URL = '/admin/login/'\n\nGEOIP_DIR_PATH = os.path.join(BASE_DIR, 'data', 'geoip')\nGEOIP2_DIR_PATH = os.path.join(BASE_DIR, 'data', 'geoip2')\nGEOIP_PATH = os.getenv('GEOIP_PATH', GEOIP2_DIR_PATH) if django.VERSION >= (1, 9) else GEOIP_DIR_PATH\n"
  },
  {
    "path": "safety/tests/test_models.py",
    "content": "# -*- coding: utf-8 -*-\nfrom .base import BaseTestCase\n\nfrom safety.models import (\n    PasswordChange,\n    Session,\n)\n\n\nclass ModelsTest(BaseTestCase):\n    def test_session(self):\n        self.assertEqual(Session.objects.count(), 0)\n        self.login_user()\n        self.assertEqual(Session.objects.count(), 1)\n        session = Session.objects.first()\n        self.assertTrue(session.active)\n        self.client.logout()\n        self.assertEqual(Session.objects.count(), 1)\n        session = Session.objects.first()\n        self.assertFalse(session.active)\n\n    def test_password_change(self):\n        obj, created = PasswordChange.objects.get_or_create_for_user(self.user)\n        self.assertTrue(created)\n        self.assertEqual(obj.user, self.user)\n        self.assertIsNone(obj.last_change_date)\n        self.assertFalse(obj.required)\n"
  },
  {
    "path": "safety/tests/test_views.py",
    "content": "# -*- coding: utf-8 -*-\nfrom django.core.urlresolvers import reverse\n\nfrom safety.forms import PasswordChangeForm\nfrom safety.models import PasswordChange, Session\n\nfrom .base import BaseTestCase\n\n\nclass SessionsViewsTest(BaseTestCase):\n    def tearDown(self, *args, **kwargs):\n        self.client.logout()\n\n    def test_session_list_view(self):\n        self.login_user()\n\n        r = self.client.get(reverse('safety:session_list'))\n        self.assertEqual(r.status_code, 200)\n\n        objects = r.context['object_list']\n        obj = r.context['object_list'][0]\n\n        self.assertEqual(len(objects), 1)\n        self.assertEqual(obj.user, self.user)\n        self.assertTrue(obj.session_key)\n        self.assertTrue(obj.active)\n        self.assertEqual(obj.ip, self.REMOTE_ADDR)\n        self.assertEqual(obj.device, self.DEVICE)\n        self.assertEqual(obj.location, self.LOCATION)\n\n    def test_session_delete_view(self):\n        admin_login_url = reverse('admin:login')\n        session_list_url = reverse('safety:session_list')\n\n        # Let's delete our current session.\n        # We must be redirected to login page.\n\n        self.login_user()\n        session = Session.objects.first()\n\n        r = self.client.post(reverse('safety:session_delete', kwargs={'pk': session.pk}), follow=True)\n        self.assertRedirects(r, '%s?next=%s' % (admin_login_url, session_list_url))\n        self.assertEqual(Session.objects.count(), 0)\n\n        self.client.logout()\n\n        # Let's delete an other session, not our current one.\n        # We must be redirected to session list.\n\n        fake_sessions = self.create_fake_sessions()\n        self.assertEqual(Session.objects.count(), 10)\n\n        self.login_user()\n\n        sessions_count = Session.objects.count()\n        self.assertEqual(sessions_count, 11)\n\n        for session in fake_sessions:\n            r = self.client.post(reverse('safety:session_delete', kwargs={'pk': session.pk}), follow=True)\n            self.assertRedirects(r, session_list_url)\n\n        self.assertEqual(Session.objects.count(), 1)\n\n    def test_session_delete_other_view(self):\n        session_list_url = reverse('safety:session_list')\n\n        fake_sessions = self.create_fake_sessions()\n        self.assertEqual(Session.objects.count(), 10)\n\n        self.login_user()\n        self.assertEqual(Session.objects.count(), 11)\n\n        r = self.client.post(reverse('safety:session_delete_other'), follow=True)\n        self.assertRedirects(r, session_list_url)\n        self.assertEqual(Session.objects.count(), 1)\n        current_session = Session.objects.get(session_key=self.client.session.session_key)\n\n        r = self.client.get(reverse('safety:session_list'))\n        self.assertEqual(r.status_code, 200)\n        self.assertTrue(self.client.session.exists(session_key=current_session.session_key))\n\n        for session in fake_sessions:\n            self.assertFalse(self.client.session.exists(session_key=session.session_key))\n\n\nclass PasswordChangeViewsTest(BaseTestCase):\n    def test_password_change(self):\n        self.login_user()\n\n        r = self.client.get(reverse('safety:password_change'))\n        self.assertTemplateUsed(r, 'safety/password_change/form.html')\n        self.assertEqual(r.status_code, 200)\n\n        self.client.logout()\n\n    def test_password_change_done(self):\n        self.login_user()\n\n        r = self.client.get(reverse('safety:password_change_done'))\n        self.assertTemplateUsed(r, 'safety/password_change/done.html')\n        self.assertEqual(r.status_code, 200)\n\n        self.client.logout()\n\n    def test_workflow(self):\n        change_form_url = reverse('safety:password_change')\n\n        # We don't have any PasswordChange instance yet.\n        # Because we just plugged the app.\n        self.assertEqual(PasswordChange.objects.count(), 0)\n\n        # Let's login user.\n        self.login_user()\n\n        # We still have no instance.\n        self.assertEqual(PasswordChange.objects.count(), 0)\n\n        # Let's create an instance for a given user.\n        # By default, \"required\" field is set to False.\n        # So nothing changes.\n        pr, created = PasswordChange.objects.get_or_create_for_user(self.user)\n        self.assertTrue(created)\n        self.assertFalse(pr.required)\n\n        # Let's logout user and login again, than go elsewhere\n        # on the site. As we don't set required to True yet,\n        # nothing changes again.\n        self.client.logout()\n        self.login_user()\n\n        r = self.client.get(reverse('home'))\n        self.assertEqual(r.status_code, 200)\n\n        r = self.client.get(reverse('admin:index'))\n        self.assertEqual(r.status_code, 200)\n\n        # Time to force user to reset its password.\n        pr.required = True\n        pr.save()\n\n        # Now, it should be effictive because we use the decorator\n        # (the middleware or the decorator provides the same behavior).\n        # So we must be redirected to password change form. No other choice.\n        r = self.client.get(reverse('home'))\n        self.assertRedirects(r, change_form_url)\n\n        r = self.client.get(reverse('admin:index'))\n        self.assertRedirects(r, change_form_url)\n\n        # Now, ok. User is stuck. Time to change password.\n        # Let's change password!\n\n        r = self.client.post(\n            reverse('safety:password_change'),\n            data={\n                'old_password': self.USER_PASSWORD,\n                'new_password1': 'superpassword',\n                'new_password2': 'superpassword',\n            })\n\n        self.assertRedirects(r, reverse('safety:password_change_done'))\n\n        # Done. New password.\n        #\n        # A few days later, we required user to change its password again.\n        # But! We do not accept the old one again. User must provide a\n        # different new password. Let's check it.\n        pr, created = PasswordChange.objects.get_or_create_for_user(self.user)\n        self.assertFalse(created)\n\n        # As we changed our last password, required field must be now set to False.\n        self.assertFalse(pr.required)\n\n        # Let's switch.\n        pr.required = True\n        pr.save()\n\n        # Login with new password.\n        self.login_user(password='superpassword')\n\n        # Boom! Redirected.\n        r = self.client.get(reverse('home'))\n        self.assertRedirects(r, change_form_url)\n\n        # Let's reload our fixture.\n        self.reload()\n\n        # Let's try to reset our password with the same as old one.\n        r = self.client.post(\n            reverse('safety:password_change'),\n            data={\n                'old_password': 'superpassword',\n                'new_password1': 'superpassword',\n                'new_password2': 'superpassword',\n            })\n\n        # Boom! Error. Not authorized.\n        self.assertFormError(r, 'form', 'new_password2', PasswordChangeForm.error_messages['same_password'])\n\n        # OK. Choose a new one.\n        r = self.client.post(\n            reverse('safety:password_change'),\n            data={\n                'old_password': 'superpassword',\n                'new_password1': 'ournewpassword',\n                'new_password2': 'ournewpassword',\n            })\n\n        # Everything is OK.\n        self.assertRedirects(r, reverse('safety:password_change_done'))\n\n        # Login with new password.\n        self.login_user(password='ournewpassword')\n\n        # No redirect. Everything is OK.\n        r = self.client.get(reverse('home'))\n        self.assertEqual(r.status_code, 200)\n"
  },
  {
    "path": "safety/tests/urls.py",
    "content": "# -*- coding: utf-8 -*-\nfrom django.conf.urls import include, url\nfrom django.contrib import admin\nfrom django.core.urlresolvers import reverse\nfrom django.shortcuts import render, redirect\n\nfrom safety.models import PasswordChange\n\n\ndef home(request):\n    return render(request, 'home.html')\n\n\ndef update_password(request):\n    if request.user.is_authenticated():\n        pr, created = PasswordChange.objects.get_or_create_for_user(request.user)\n        pr.required = True\n        pr.save()\n    return redirect(reverse('home'))\n\n\nurlpatterns = [\n    url(r'^admin/', include(admin.site.urls)),\n    url(r'^account/', include('django.contrib.auth.urls')),\n    url(r'^security/', include('safety.urls', namespace='safety')),\n    url(r'^update-password/$', update_password, name='update-password'),\n    url(r'^$', home, name='home'),\n]\n"
  },
  {
    "path": "safety/urls.py",
    "content": "# -*- coding: utf-8 -*-\nfrom django.conf.urls import url\n\nfrom . import views\n\n\nurlpatterns = [\n    url(\n        r'^sessions/$',\n        views.SessionListView.as_view(),\n        name='session_list'),\n\n    url(\n        r'^sessions/other/delete/$',\n        views.SessionDeleteOtherView.as_view(),\n        name='session_delete_other'),\n\n    url(\n        r'^sessions/(?P<pk>\\w+)/delete/$',\n        views.SessionDeleteView.as_view(),\n        name='session_delete'),\n\n    url(\n        r'^password-change/$',\n        views.password_change,\n        name='password_change'),\n\n    url(\n        r'^password-change/done/$',\n        views.password_change_done,\n        name='password_change_done'),\n]\n"
  },
  {
    "path": "safety/utils.py",
    "content": "# -*- coding: utf-8 -*-\nfrom django.conf import settings\n\nfrom .compat import import_module\n\n\ndef get_session_store():\n    mod = getattr(settings, 'SESSION_ENGINE', 'django.contrib.sessions.backends.db')\n    engine = import_module(mod)\n    store = engine.SessionStore()\n    return store\n\n\ndef resolve(path, request):\n    obj = import_from_path(path)\n    return obj(request)\n\n\ndef import_from_path(path):\n    try:\n        module_path, attr = path.rsplit('.', 1)\n        module = import_module(module_path)\n        obj = getattr(module, attr)\n    except Exception as e:\n        raise e\n    return obj\n"
  },
  {
    "path": "safety/views.py",
    "content": "# -*- coding: utf-8 -*-\n\ntry:\n    from urllib.parse import urlparse, urlunparse\nexcept ImportError:  # pragma: no cover\n    # Python 2 fallback\n    from urlparse import urlparse, urlunparse  # noqa\n\nfrom django.contrib.auth import views as auth_views\nfrom django.contrib.auth.decorators import login_required\nfrom django.contrib.auth.tokens import default_token_generator\nfrom django.core.urlresolvers import reverse, reverse_lazy\nfrom django.views.decorators.cache import never_cache\nfrom django.views.decorators.csrf import csrf_protect\nfrom django.views.decorators.debug import sensitive_post_parameters\nfrom django.views.generic import ListView, DeleteView, View\nfrom django.views.generic.edit import DeletionMixin\n\nfrom . import app_settings\nfrom . import forms\nfrom . import utils\n\nfrom .mixins import SessionMixin\n\n\nLoginRequiredMixin = utils.import_from_path(\n    app_settings.LOGIN_REQUIRED_MIXIN_CLASS)\n\n\n# -----------------------------------------------------------------------------\n# Sessions\n# -----------------------------------------------------------------------------\n\nclass SessionListView(LoginRequiredMixin, SessionMixin, ListView):\n    pass\n\n\nclass SessionDeleteView(LoginRequiredMixin, SessionMixin, DeleteView):\n    def get_success_url(self):\n        return str(reverse_lazy('safety:session_list'))\n\n\nclass SessionDeleteOtherView(LoginRequiredMixin, SessionMixin, DeletionMixin, View):\n    def get_object(self):\n        qs = super(SessionDeleteOtherView, self).get_queryset()\n        qs = qs.exclude(session_key=self.request.session.session_key)\n        return qs\n\n    def get_success_url(self):\n        return str(reverse_lazy('safety:session_list'))\n\n\n# -----------------------------------------------------------------------------\n# Password Change\n# -----------------------------------------------------------------------------\n\n@sensitive_post_parameters()\n@csrf_protect\n@login_required\ndef password_change(request):\n    return auth_views.password_change(\n        request=request,\n        template_name='safety/password_change/form.html',\n        post_change_redirect=reverse('safety:password_change_done'),\n        password_change_form=forms.PasswordChangeForm)\n\n\n@login_required\ndef password_change_done(request):\n    return auth_views.password_change_done(\n        request=request,\n        template_name='safety/password_change/done.html')\n"
  },
  {
    "path": "setup.py",
    "content": "# -*- coding: utf-8 -*-\nimport os\nimport sys\n\nfrom setuptools import setup, find_packages\n\nroot = os.path.abspath(os.path.dirname(__file__))\nwith open(os.path.join(root, 'README.rst')) as f:\n    readme = f.read()\n\nversion = __import__('safety').__version__\n\ninstall_requires = [\n    'six',\n    'ua-parser',\n    'geoip2'\n]\n\nsetup(\n    name='django-safety',\n    version=version,\n    description='Generic Django application for safer user accounts',\n    long_description=readme,\n    author='Gilles Fabio',\n    author_email='gilles.fabio@gmail.com',\n    url='http://github.com/ulule/django-safety',\n    packages=find_packages(),\n    zip_safe=False,\n    include_package_data=True,\n    install_requires=install_requires,\n    classifiers=[\n        'Environment :: Web Environment',\n        'Framework :: Django',\n        'Intended Audience :: Developers',\n        'License :: OSI Approved :: MIT License',\n        'Operating System :: OS Independent',\n        'Programming Language :: Python',\n        'Programming Language :: Python :: 2.7',\n        'Programming Language :: Python :: 3',\n        'Programming Language :: Python :: 3.4',\n        'Programming Language :: Python :: 3.5',\n    ]\n)\n"
  },
  {
    "path": "tox.ini",
    "content": "[tox]\nskipsdist = True\nenvlist =\n    {py27,py34,py35}-django{18,19}\n    {py27,py34,py35}-django19nogeoip2\n\n[testenv]\nbasepython =\n    py27: python2.7\n    py34: python3.4\n    py35: python3.5\n\ndeps =\n    -r{toxinidir}/requirements/tox.txt\n    {py27,py34,py35}-django18: Django>=1.8,<1.9\n    {py27,py34,py35}-django19: Django>=1.9,<1.10\n    {py27,py34,py35}-django19: geoip2\n    {py27,py34,py35}-django19nogeoip2: Django>=1.9,<1.10\n\nsetenv =\n    PYTHONPATH = {toxinidir}\nwhitelist_externals =\n    make\nchangedir = {toxinidir}\ncommands =\n    make geoip\n    make test\n\n[testenv:py27-django19nogeoip2]\nsetenv =\n    GEOIP_PATH = {toxinidir}/data/geoip\n\n[testenv:py34-django19nogeoip2]\nsetenv =\n    GEOIP_PATH = {toxinidir}/data/geoip\n\n[testenv:py35-django19nogeoip2]\nsetenv =\n    GEOIP_PATH = {toxinidir}/data/geoip\n"
  }
]