├── .gitignore ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── requirements.txt ├── screenshot.png ├── setup.cfg ├── setup.py ├── tox.ini └── userlog ├── __init__.py ├── admin.py ├── apps.py ├── example_settings.py ├── example_urls.py ├── locale └── fr │ └── LC_MESSAGES │ ├── django.po │ └── djangojs.po ├── middleware.py ├── realtime.py ├── static └── userlog │ └── js │ └── live.js ├── templates └── userlog │ ├── live.html │ └── static.html ├── test_settings.py ├── tests.py ├── util.py └── views.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | .coverage 4 | .tox 5 | build 6 | db.sqlite3 7 | dist 8 | htmlcov 9 | MANIFEST 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Aymeric Augustin 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the {organization} nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: example realtime test coverage flake8 2 | 3 | example: 4 | DJANGO_SETTINGS_MODULE=userlog.example_settings \ 5 | django-admin runserver 6 | 7 | realtime: 8 | DJANGO_SETTINGS_MODULE=userlog.example_settings \ 9 | python -m userlog.realtime 10 | 11 | test: 12 | DJANGO_SETTINGS_MODULE=userlog.test_settings \ 13 | django-admin test userlog 14 | 15 | coverage: 16 | coverage erase 17 | DJANGO_SETTINGS_MODULE=userlog.test_settings \ 18 | coverage run --branch --source=userlog `which django-admin` test userlog 19 | coverage html 20 | 21 | flake8: 22 | flake8 userlog 23 | 24 | isort: 25 | isort --check-only --recursive userlog 26 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-userlog 2 | ============== 3 | 4 | Goals 5 | ----- 6 | 7 | django-userlog is a lightweight solution to see in real-time how a given user 8 | is browsing a Django website. The intended use case is live customer support. 9 | 10 | (Note that "synchronized browsing" is a much better solution. Modern live 11 | customer support solutions often provide this feature — if you can afford it!) 12 | 13 | Requirements 14 | ------------ 15 | 16 | This application requires Django ≥ 1.8 and Python ≥ 3.3 or 2.7. It uses a 17 | Redis server as storage backend. 18 | 19 | Your website must be using Django's auth framework. The target user must be 20 | authenticated and you must be able to obtain their username. How you achieve 21 | this depends a lot on your project and on how you interact with the user. 22 | 23 | Configuration 24 | ------------- 25 | 26 | Install django-userlog and its dependencies in your project's virtualenv:: 27 | 28 | pip install django-userlog 29 | pip install django-redis-cache hiredis redis 30 | 31 | In order to use the live logs, you need some extra dependencies:: 32 | 33 | pip install asyncio_redis websockets 34 | pip install asyncio # only for Python 3.3 35 | 36 | If your project is running on Python ≥ 3.3, install everything in the same 37 | virtualenv. If your project requires Python 2.7, either you can live with the 38 | static logs, or you can create a separate virtualenv with Python ≥ 3.3 for the 39 | websockets server that powers the live logs. 40 | 41 | Add `'userlog'` to your `INSTALLED_APPS` setting. 42 | 43 | Add `'userlog.middleware.UserLogMiddleware'` to your `MIDDLEWARE_CLASSES` 44 | setting. It should come before any middleware that may change the response. 45 | 46 | Configure a `'userlog'` cache with `django-redis-cache`_. (`django-redis`_ 47 | probably works too.) Define its `TIMEOUT` according to how long you want to 48 | preserve a user's log after his last request. You should select a dedicated 49 | Redis database or set a `KEY_PREFIX` to prevent clashes. Here's an example:: 50 | 51 | CACHES = { 52 | 'default': { 53 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 54 | }, 55 | 'userlog': { 56 | 'BACKEND': 'redis_cache.RedisCache', 57 | 'LOCATION': 'localhost:6379', 58 | 'TIMEOUT': 3600, 59 | 'KEY_PREFIX': 'userlog', 60 | }, 61 | } 62 | 63 | By default, django-userlog tracks no more than the latest 25 pages browsed by 64 | each user. You can adjust this value with the `USERLOG_MAX_SIZE` setting. 65 | 66 | If you don't intend to use the live logs, set `USERLOG_PUBLISH` to `False` to 67 | decrease the workload of the Redis server. 68 | 69 | You can exclude URLs from the logs by setting `USERLOG_IGNORE_URLS` to a list 70 | of regular expression patterns:: 71 | 72 | USERLOG_IGNORE_URLS = [ 73 | r'^/favicon\.ico$', 74 | ] 75 | 76 | In order to use the live logs, you must set the address of the websocket 77 | server:: 78 | 79 | USERLOG_WEBSOCKET_ADDRESS = 'ws://www.example.com:8080/' 80 | 81 | .. _django-redis-cache: https://github.com/sebleier/django-redis-cache 82 | .. _django-redis: https://github.com/niwibe/django-redis 83 | 84 | Then you must run the websocket server at this address. The easiest solution 85 | is to set the `DJANGO_SETTINGS_MODULE` environment variable and run the 86 | `userlog.realtime` module:: 87 | 88 | DJANGO_SETTINGS_MODULE=myproject.settings python -m userlog.realtime 89 | 90 | For more advanced use cases such as embedding the websocket server in an 91 | application or adding TLS, serve the `userlog.realtime.userlog` with the 92 | websockets_ library. 93 | 94 | .. _websockets: https://github.com/aaugustin/websockets 95 | 96 | Usage 97 | ----- 98 | 99 | Open the Django admin. In the user logs section, choose between static logs or 100 | live logs. Enter a username in the search field. That's it! 101 | 102 | .. image:: https://raw.githubusercontent.com/aaugustin/django-userlog/master/screenshot.png 103 | :width: 631 104 | :height: 518 105 | :align: center 106 | 107 | Currently, only superusers can view user logs, for lack of a better solution. 108 | 109 | FAQ 110 | --- 111 | 112 | Why use Django's caching infrastructure to connect to Redis? 113 | ............................................................ 114 | 115 | It's the easiest way to obtain a properly managed connection to Redis, 116 | including connection pooling. 117 | 118 | Hacking 119 | ------- 120 | 121 | If you want to suggest changes, please submit a pull request! 122 | 123 | This repository includes a sample project. To try it, clone the repository, 124 | create a virtualenv and run these commands:: 125 | 126 | pip install -r requirements.txt 127 | pip install -e . 128 | DJANGO_SETTINGS_MODULE=userlog.example_settings django-admin.py migrate 129 | DJANGO_SETTINGS_MODULE=userlog.example_settings django-admin.py runserver 130 | 131 | Once this basic setup is done, there's a shortcut to start the server:: 132 | 133 | make example 134 | 135 | And another one to start the websocket server:: 136 | 137 | make realtime 138 | 139 | Run the tests:: 140 | 141 | make test 142 | 143 | Compute test coverage:: 144 | 145 | make coverage 146 | 147 | Check your coding stye:: 148 | 149 | make flake8 150 | 151 | License 152 | ------- 153 | 154 | django-userlog is released under the BSD license, like Django itself. 155 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Regular stuff, should work on Python 2.7 2 | 3 | Django 4 | django-redis-cache # or django-redis (untested) 5 | hiredis 6 | redis 7 | 8 | # Real-time stuff, requires Python 3.3+ 9 | 10 | asyncio # Python 3.4 uses the stdlib version anyway 11 | asyncio_redis 12 | websockets 13 | 14 | # Development 15 | 16 | coverage 17 | flake8 18 | selenium 19 | tox 20 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaugustin/django-userlog/6cd34d0a319f6a954fec74420d0d391c32c46060/screenshot.png -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import codecs 4 | import os.path 5 | import re 6 | 7 | import setuptools 8 | 9 | root_dir = os.path.abspath(os.path.dirname(__file__)) 10 | 11 | with codecs.open(os.path.join(root_dir, 'userlog', '__init__.py'), encoding='utf-8') as f: 12 | version = re.search("^__version__ = '(.*)'$", f.read(), re.M).group(1) 13 | 14 | description = "Logs users' recent browsing history." 15 | 16 | with codecs.open(os.path.join(root_dir, 'README.rst'), encoding='utf-8') as f: 17 | long_description = f.read() 18 | 19 | setuptools.setup( 20 | name='django-userlog', 21 | version=version, 22 | description=description, 23 | long_description=long_description, 24 | url='https://github.com/aaugustin/django-userlog', 25 | author='Aymeric Augustin', 26 | author_email='aymeric.augustin@m4x.org', 27 | license='BSD', 28 | classifiers=[ 29 | 'Development Status :: 4 - Beta', 30 | 'Environment :: Web Environment', 31 | 'Framework :: Django', 32 | 'Framework :: Django :: 1.8', 33 | 'Framework :: Django :: 1.9', 34 | 'Framework :: Django :: 1.10', 35 | 'Intended Audience :: Developers', 36 | 'License :: OSI Approved :: BSD License', 37 | 'Operating System :: OS Independent', 38 | 'Programming Language :: Python', 39 | 'Programming Language :: Python :: 2', 40 | 'Programming Language :: Python :: 2.7', 41 | 'Programming Language :: Python :: 3', 42 | 'Programming Language :: Python :: 3.3', 43 | 'Programming Language :: Python :: 3.4', 44 | 'Programming Language :: Python :: 3.5', 45 | ], 46 | packages=[ 47 | 'userlog', 48 | ], 49 | package_data={ 50 | 'userlog': [ 51 | 'locale/*/LC_MESSAGES/*', 52 | 'templates/userlog/*', 53 | 'static/userlog/*/*', 54 | ], 55 | }, 56 | ) 57 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{27,33,34,35}-django18, 4 | py{27,34,35}-django19, 5 | py{27,34,35}-django110 6 | flake8 7 | isort 8 | 9 | [testenv] 10 | commands = make test 11 | deps = 12 | py33: asyncio 13 | py{33,34,35}: asyncio_redis 14 | django18: Django>=1.8,<1.9 15 | django19: Django>=1.9,<1.10 16 | django110: Django>=1.10,<1.11 17 | django-redis-cache 18 | hiredis 19 | redis 20 | selenium 21 | py{33,34,35}: websockets 22 | setenv = 23 | PYTHONPATH = {toxinidir} 24 | whitelist_externals = make 25 | 26 | [testenv:flake8] 27 | commands = make flake8 28 | deps = flake8 29 | 30 | [testenv:isort] 31 | commands = make isort 32 | deps = isort 33 | -------------------------------------------------------------------------------- /userlog/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'userlog.apps.UserLogConfig' 2 | 3 | __version__ = '0.2' 4 | -------------------------------------------------------------------------------- /userlog/admin.py: -------------------------------------------------------------------------------- 1 | """Steaming pile of hacks. Read at your own risks.""" 2 | 3 | from django.conf.urls import url 4 | from django.contrib import admin 5 | from django.utils.translation import ugettext_lazy as _ 6 | 7 | from .views import bigbrother, jsi18n, live, static 8 | 9 | 10 | class _meta: 11 | abstract = None 12 | app_label = 'userlog' 13 | swapped = None 14 | 15 | 16 | class LiveUserLogModel: 17 | 18 | class _meta(_meta): 19 | object_name = 'live' 20 | model_name = 'live' 21 | verbose_name = _("Live log") 22 | verbose_name_plural = _("Live logs") 23 | 24 | 25 | class LiveUserLogModelAdmin(admin.ModelAdmin): 26 | 27 | def get_urls(self): 28 | av = self.admin_site.admin_view 29 | return [ 30 | url(r'^$', av(live), name='userlog_live'), 31 | # Integrates into the admin index and app index. 32 | url(r'^$', av(live), name='userlog_live_changelist'), 33 | # Easter egg. 34 | url(r'^bigbrother/', av(bigbrother), name='userlog_bigbrother'), 35 | ] 36 | 37 | 38 | admin.site.register([LiveUserLogModel], LiveUserLogModelAdmin) 39 | 40 | 41 | class StaticUserLogModel: 42 | 43 | class _meta(_meta): 44 | object_name = 'static' 45 | model_name = 'static' 46 | verbose_name = _("Static log") 47 | verbose_name_plural = _("Static logs") 48 | 49 | 50 | class StaticUserLogModelAdmin(admin.ModelAdmin): 51 | 52 | def get_urls(self): 53 | av = self.admin_site.admin_view 54 | return [ 55 | url(r'^$', av(static), name='userlog_static'), 56 | # Integrates into the admin index and app index. 57 | url(r'^$', av(static), name='userlog_static_changelist'), 58 | # This needs to live somewhere. 59 | url(r'^jsi18n/$', av(jsi18n), name='userlog_jsi18n'), 60 | ] 61 | 62 | 63 | admin.site.register([StaticUserLogModel], StaticUserLogModelAdmin) 64 | -------------------------------------------------------------------------------- /userlog/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import ugettext_lazy as _ 3 | 4 | 5 | class UserLogConfig(AppConfig): 6 | name = 'userlog' 7 | verbose_name = _("User logs") 8 | -------------------------------------------------------------------------------- /userlog/example_settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for userlog example project. 3 | 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/dev/topics/settings/ 6 | 7 | For the full list of settings and their values, see 8 | https://docs.djangoproject.com/en/dev/ref/settings/ 9 | """ 10 | 11 | import os 12 | 13 | import django 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | # Quick-start development settings - unsuitable for production 19 | # See https://docs.djangoproject.com/en/dev/howto/deployment/checklist/ 20 | 21 | # SECURITY WARNING: keep the secret key used in production secret! 22 | SECRET_KEY = '9=vw9u7zi*e&zrj%)94v*(t1lnuu4v+u$$dl#))x+s!8!lzj(^' 23 | 24 | # SECURITY WARNING: don't run with debug turned on in production! 25 | DEBUG = True 26 | 27 | ALLOWED_HOSTS = [] 28 | 29 | # Application definition 30 | 31 | INSTALLED_APPS = ( 32 | 'django.contrib.admin', 33 | 'django.contrib.auth', 34 | 'django.contrib.contenttypes', 35 | 'django.contrib.humanize', 36 | 'django.contrib.sessions', 37 | 'django.contrib.messages', 38 | 'django.contrib.staticfiles', 39 | 'userlog', 40 | ) 41 | 42 | MIDDLEWARE = ( 43 | 'userlog.middleware.UserLogMiddleware', 44 | 'django.contrib.sessions.middleware.SessionMiddleware', 45 | 'django.middleware.common.CommonMiddleware', 46 | 'django.middleware.csrf.CsrfViewMiddleware', 47 | 'userlog.middleware.AdminAutoLoginMiddleware', 48 | 'django.contrib.messages.middleware.MessageMiddleware', 49 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 50 | 'django.middleware.security.SecurityMiddleware', 51 | ) 52 | 53 | if django.VERSION < (1, 10): 54 | MIDDLEWARE_CLASSES = MIDDLEWARE 55 | del MIDDLEWARE 56 | 57 | ROOT_URLCONF = 'userlog.example_urls' 58 | 59 | TEMPLATES = [ 60 | { 61 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 62 | 'DIRS': [], 63 | 'APP_DIRS': True, 64 | 'OPTIONS': { 65 | 'context_processors': [ 66 | 'django.template.context_processors.debug', 67 | 'django.template.context_processors.request', 68 | 'django.contrib.auth.context_processors.auth', 69 | 'django.contrib.messages.context_processors.messages', 70 | ], 71 | 'debug': True, 72 | }, 73 | }, 74 | ] 75 | 76 | # Cache 77 | # https://docs.djangoproject.com/en/dev/ref/settings/#caches 78 | 79 | CACHES = { 80 | 'default': { 81 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 82 | }, 83 | 'userlog': { 84 | 'BACKEND': 'redis_cache.RedisCache', 85 | 'LOCATION': 'localhost:6379', 86 | 'KEY_PREFIX': 'userlog', 87 | 'TIMEOUT': 3600, 88 | }, 89 | } 90 | 91 | # Database 92 | # https://docs.djangoproject.com/en/dev/ref/settings/#databases 93 | 94 | DATABASES = { 95 | 'default': { 96 | 'ENGINE': 'django.db.backends.sqlite3', 97 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 98 | } 99 | } 100 | 101 | # Internationalization 102 | # https://docs.djangoproject.com/en/dev/topics/i18n/ 103 | 104 | LANGUAGE_CODE = 'fr' 105 | 106 | TIME_ZONE = 'Europe/Paris' 107 | 108 | USE_I18N = True 109 | 110 | USE_L10N = True 111 | 112 | USE_TZ = True 113 | 114 | # Static files (CSS, JavaScript, Images) 115 | # https://docs.djangoproject.com/en/dev/howto/static-files/ 116 | 117 | STATIC_URL = '/static/' 118 | 119 | # userlog 120 | 121 | USERLOG_IGNORE_URLS = [ 122 | r'/jsi18n/', 123 | r'^/favicon\.ico$', 124 | ] 125 | -------------------------------------------------------------------------------- /userlog/example_urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include, url 2 | from django.contrib import admin 3 | 4 | urlpatterns = [ 5 | url(r'^', include(admin.site.urls)), 6 | ] 7 | -------------------------------------------------------------------------------- /userlog/locale/fr/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PACKAGE VERSION\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2014-07-05 22:00+0200\n" 11 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language-Team: LANGUAGE \n" 14 | "Language: \n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 19 | 20 | msgid "Live log" 21 | msgstr "Journal dynamique" 22 | 23 | msgid "Live logs" 24 | msgstr "Journaux dynamiques" 25 | 26 | msgid "Static log" 27 | msgstr "Journal statique" 28 | 29 | msgid "Static logs" 30 | msgstr "Journaux statiques" 31 | 32 | msgid "User logs" 33 | msgstr "Journaux utilisateurs" 34 | 35 | msgid "Search" 36 | msgstr "Rechercher" 37 | 38 | msgid "Time" 39 | msgstr "Heure" 40 | 41 | msgid "URL" 42 | msgstr "URL" 43 | 44 | msgid "Type" 45 | msgstr "Type" 46 | 47 | msgid "Result" 48 | msgstr "Résultat" 49 | 50 | msgid "Read" 51 | msgstr "Lecture" 52 | 53 | msgid "Write" 54 | msgstr "Écriture" 55 | 56 | msgid "Other" 57 | msgstr "Autre" 58 | 59 | msgid "Informational" 60 | msgstr "Information" 61 | 62 | msgid "Success" 63 | msgstr "Succès" 64 | 65 | msgid "Redirection" 66 | msgstr "Redirection" 67 | 68 | msgid "Client error" 69 | msgstr "Erreur du client" 70 | 71 | msgid "Server error" 72 | msgstr "Erreur du serveur" 73 | 74 | msgid "Non-standard" 75 | msgstr "Non-standard" 76 | 77 | msgid "User {} not found." 78 | msgstr "Utilisateur {} non trouvé." 79 | 80 | msgid "Logs found for {}." 81 | msgstr "Journal trouvé pour {}." 82 | 83 | msgid "No logs for {}." 84 | msgstr "Pas de journal pour {}." 85 | -------------------------------------------------------------------------------- /userlog/locale/fr/LC_MESSAGES/djangojs.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PACKAGE VERSION\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2014-07-05 22:00+0200\n" 11 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language-Team: LANGUAGE \n" 14 | "Language: \n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 19 | 20 | msgid "User" 21 | msgstr "Utilisateur" 22 | 23 | msgid "Time" 24 | msgstr "Heure" 25 | 26 | msgid "URL" 27 | msgstr "URL" 28 | 29 | msgid "Type" 30 | msgstr "Type" 31 | 32 | msgid "Result" 33 | msgstr "Résultat" 34 | 35 | msgid "Read" 36 | msgstr "Lecture" 37 | 38 | msgid "Write" 39 | msgstr "Écriture" 40 | 41 | msgid "Other" 42 | msgstr "Autre" 43 | 44 | msgid "Informational" 45 | msgstr "Information" 46 | 47 | msgid "Success" 48 | msgstr "Succès" 49 | 50 | msgid "Redirection" 51 | msgstr "Redirection" 52 | 53 | msgid "Client error" 54 | msgstr "Erreur du client" 55 | 56 | msgid "Server error" 57 | msgstr "Erreur du serveur" 58 | 59 | msgid "Non-standard" 60 | msgstr "Non-standard" 61 | -------------------------------------------------------------------------------- /userlog/middleware.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | 4 | from django.contrib.auth import get_user_model 5 | 6 | from .util import get_redis_client, get_userlog_settings 7 | 8 | try: 9 | from django.utils.deprecation import MiddlewareMixin 10 | except ImportError: 11 | MiddlewareMixin = object 12 | 13 | 14 | class UserLogMiddleware(MiddlewareMixin): 15 | 16 | def process_response(self, request, response): 17 | if not (hasattr(request, 'user') and request.user.is_authenticated()): 18 | return response 19 | 20 | options = get_userlog_settings() 21 | 22 | for pattern in options.ignore_urls: 23 | if pattern.search(request.path): 24 | return response 25 | 26 | log = self.get_log(request, response) 27 | raw_log = json.dumps(log).encode() 28 | log_key = 'log:{}'.format(request.user.get_username()) 29 | channel = 'userlog:{}'.format(log_key) 30 | 31 | redis = get_redis_client() 32 | pipe = redis.pipeline() 33 | pipe.lpush(log_key, raw_log) 34 | pipe.ltrim(log_key, 0, options.max_size) 35 | pipe.expire(log_key, options.timeout) 36 | if options.publish: 37 | pipe.publish(channel, raw_log) 38 | pipe.execute() 39 | 40 | return response 41 | 42 | def get_log(self, request, response): 43 | """ 44 | Return a dict of data to log for a given request and response. 45 | 46 | Override this method if you need to log a different set of values. 47 | """ 48 | return { 49 | 'method': request.method, 50 | 'path': request.get_full_path(), 51 | 'code': response.status_code, 52 | 'time': time.time(), 53 | } 54 | 55 | 56 | class AdminAutoLoginMiddleware(MiddlewareMixin): 57 | """ 58 | Automatically creates and logs in an "admin" user. 59 | 60 | Replaces ``django.contrib.auth.middleware.AuthenticationMiddleware``. 61 | 62 | Works with any admin-compliant user model i.e. subclass of AbstractUser. 63 | 64 | Designed for personal applications that only exist on your own computer. 65 | 66 | Used in the tests. 67 | """ 68 | 69 | USERNAME = 'admin' 70 | 71 | def process_request(self, request): 72 | User = get_user_model() 73 | username_condition = {User.USERNAME_FIELD: self.USERNAME} 74 | try: 75 | user = User.objects.get(**username_condition) 76 | except User.DoesNotExist: 77 | user = User(**username_condition) 78 | user.is_staff = True 79 | user.is_superuser = True 80 | user.set_unusable_password() 81 | user.save() 82 | request.user = user 83 | -------------------------------------------------------------------------------- /userlog/realtime.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | 5 | import asyncio_redis 6 | import django 7 | import websockets 8 | from django.conf import settings 9 | 10 | from .util import get_userlog_settings 11 | 12 | if settings.DEBUG: # pragma: no cover 13 | logger = logging.getLogger('websockets.server') 14 | logger.setLevel(logging.DEBUG) 15 | logger.addHandler(logging.StreamHandler()) 16 | 17 | 18 | @asyncio.coroutine 19 | def redis_connection(): 20 | userlog = settings.CACHES['userlog'] 21 | options = userlog.get('OPTIONS', {}) 22 | if ':' in userlog['LOCATION']: 23 | host, port = userlog['LOCATION'].rsplit(':', 1) 24 | port = int(port) 25 | else: 26 | host = userlog['LOCATION'] 27 | port = 0 28 | db = options.get('DB', 1) 29 | password = options.get('PASSWORD', None) 30 | redis = yield from asyncio_redis.Connection.create( 31 | host=host, port=port, password=password, db=db) 32 | return redis 33 | 34 | 35 | @asyncio.coroutine 36 | def userlog(websocket, uri): 37 | token = yield from websocket.recv() 38 | 39 | redis = yield from redis_connection() 40 | 41 | token_key = 'token:{}'.format(token) 42 | 43 | # Access control 44 | username = yield from redis.get(token_key) 45 | if username is None: 46 | return 47 | 48 | log_key = 'log:{}'.format(username) 49 | channel = 'userlog:{}'.format(log_key) 50 | 51 | try: 52 | if channel.endswith('*'): # logs for several users 53 | # Stream new lines 54 | subscriber = yield from redis.start_subscribe() 55 | yield from subscriber.psubscribe([channel]) 56 | while True: 57 | reply = yield from subscriber.next_published() 58 | data = json.loads(reply.value) 59 | data['username'] = reply.channel.rpartition(':')[2] 60 | line = json.dumps(data) 61 | try: 62 | yield from websocket.send(line) 63 | except websockets.ConnectionClosed: 64 | return 65 | 66 | else: # logs for a single user 67 | # Send backlock 68 | log = yield from redis.lrange(log_key, 0, -1) 69 | for item in reversed(list(log)): 70 | line = yield from item 71 | try: 72 | yield from websocket.send(line) 73 | except websockets.ConnectionClosed: 74 | return 75 | 76 | # Stream new lines 77 | subscriber = yield from redis.start_subscribe() 78 | yield from subscriber.subscribe([channel]) 79 | while True: 80 | reply = yield from subscriber.next_published() 81 | line = reply.value 82 | try: 83 | yield from websocket.send(line) 84 | except websockets.ConnectionClosed: 85 | return 86 | 87 | finally: 88 | redis.close() 89 | # Loop one more time to complete the cancellation of redis._reader_f, 90 | # which runs redis._reader_coroutine(), after redis.connection_lost(). 91 | yield from asyncio.sleep(0) 92 | 93 | 94 | if __name__ == '__main__': # pragma: no cover 95 | django.setup() 96 | 97 | uri = websockets.parse_uri(get_userlog_settings().websocket_address) 98 | if uri.secure: 99 | raise ValueError("SSL support requires explicit configuration") 100 | start_server = websockets.serve(userlog, uri.host, uri.port) 101 | asyncio.get_event_loop().run_until_complete(start_server) 102 | 103 | try: 104 | asyncio.get_event_loop().run_forever() 105 | except KeyboardInterrupt: 106 | pass 107 | -------------------------------------------------------------------------------- /userlog/static/userlog/js/live.js: -------------------------------------------------------------------------------- 1 | /* jslint browser: true, devel: true */ 2 | 3 | (function ($) { 4 | 5 | "use strict"; 6 | 7 | $.fn.userlog = function (options) { 8 | var ws = new WebSocket(options.wsuri), 9 | big_brother = options.big_brother, 10 | token = options.token, 11 | table = $(this), 12 | thead = table.find('thead'), 13 | tbody = table.find('tbody'), 14 | connected = false, 15 | row = 1; 16 | 17 | ws.onopen = function () { 18 | connected = true; 19 | 20 | ws.send(token); 21 | 22 | $('') 23 | .append( 24 | big_brother ? '' + gettext('User') + '' : '', 25 | '' + gettext('Time') + '', 26 | '' + gettext('URL') + '', 27 | '' + gettext('Type') + '', 28 | '' + gettext('Result') + '' 29 | ) 30 | .appendTo(thead); 31 | }; 32 | 33 | ws.onmessage = function (event) { 34 | var line = JSON.parse(event.data), 35 | time, 36 | url, 37 | type, 38 | result; 39 | 40 | time = new Date(line.time * 1000); 41 | try { 42 | // Time zone don't work well in JavaScript, don't bother. 43 | time = time.toLocaleString(options.locale); 44 | } catch (ignore) { 45 | // The default formatting will be used, it's good enough. 46 | } 47 | 48 | if (line.method === 'GET') { 49 | url = '' + line.path + ''; 50 | } else { 51 | url = line.path; 52 | } 53 | 54 | if (line.method === 'GET') { 55 | type = gettext('Read'); 56 | } else if (line.method === 'POST') { 57 | type = gettext('Write'); 58 | } else { 59 | type = gettext('Other'); 60 | } 61 | type += ' (' + line.method + ')'; 62 | 63 | result = line.code; 64 | 65 | if (line.code < 200) { 66 | result = gettext('Informational'); 67 | } else if (line.code < 300) { 68 | result = gettext('Success'); 69 | } else if (line.code < 400) { 70 | result = gettext('Redirection'); 71 | } else if (line.code < 500) { 72 | result = gettext('Client error'); 73 | } else if (line.code < 600) { 74 | result = gettext('Server error'); 75 | } else { 76 | result = gettext('Non-standard'); 77 | } 78 | result += ' (' + line.code + ')'; 79 | 80 | row = 3 - row; // 1 <-> 2 81 | $('') 82 | .addClass('row' + row) 83 | .hide() 84 | .append( 85 | big_brother ? '' + line.username + '' : '', 86 | '' + time + '', 87 | '' + url + '', 88 | '' + type + '', 89 | '' + result + '' 90 | ) 91 | .prependTo(tbody) 92 | .fadeIn(); 93 | }; 94 | 95 | ws.onclose = function () { 96 | if (connected) { 97 | // This will also happen when navigating away from the page. 98 | table.css({opacity: 0.5}); 99 | } else { 100 | alert("Failed to connect. Is the realtime endpoint running?"); 101 | } 102 | }; 103 | }; 104 | 105 | }(django.jQuery)); 106 | -------------------------------------------------------------------------------- /userlog/templates/userlog/live.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_list.html" %} 2 | {% load admin_static i18n %} 3 | 4 | {% block extrahead %} 5 | 6 | {{ block.super }} 7 | 8 | {% if token %} 9 | 10 | 22 | {% endif %} 23 | 24 | {% endblock %} 25 | 26 | {% block breadcrumbs %} 27 | 28 | 33 | 34 | {% endblock %} 35 | 36 | {% block content %} 37 | 38 | {% if fieldname %} 39 |
40 | 41 | 42 | 43 |
44 | {% endif %} 45 | 46 | {% if token %} 47 | 48 | 49 | 50 |
51 | {% endif %} 52 | 53 | {% endblock %} 54 | -------------------------------------------------------------------------------- /userlog/templates/userlog/static.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load humanize i18n %} 3 | 4 | {% block breadcrumbs %} 5 | 6 | 11 | 12 | {% endblock %} 13 | 14 | {% block content %} 15 | 16 |
17 | 18 | 19 | 20 |
21 | 22 | {% if log %} 23 | 24 | 25 | 26 | 29 | 32 | 35 | 38 | 39 | 40 | 41 | {% for line in log %} 42 | 43 | 46 | 53 | 63 | 79 | 80 | {% endfor %} 81 | 82 |
27 | {% trans 'Time' %} 28 | 30 | {% trans 'URL' %} 31 | 33 | {% trans 'Type' %} 34 | 36 | {% trans 'Result' %} 37 |
44 | {{ line.datetime|naturaltime }} 45 | 47 | {% if line.method == 'GET' %} 48 | {{ line.path }} 49 | {% else %} 50 | {{ line.path }} 51 | {% endif %} 52 | 54 | {% if line.method == 'GET' %} 55 | {% trans 'Read' %} 56 | {% elif line.method == 'POST' %} 57 | {% trans 'Write' %} 58 | {% else %} 59 | {% trans 'Other' %} 60 | {% endif %} 61 | ({{ line.method }}) 62 | 64 | {% if line.code < 200 %} 65 | {% trans 'Informational' %} 66 | {% elif line.code < 300 %} 67 | {% trans 'Success' %} 68 | {% elif line.code < 400 %} 69 | {% trans 'Redirection' %} 70 | {% elif line.code < 500 %} 71 | {% trans 'Client error' %} 72 | {% elif line.code < 600 %} 73 | {% trans 'Server error' %} 74 | {% else %} 75 | {% trans 'Non-standard' %} 76 | {% endif %} 77 | ({{ line.code }}) 78 |
83 | {% endif %} 84 | 85 | {% endblock %} 86 | -------------------------------------------------------------------------------- /userlog/test_settings.py: -------------------------------------------------------------------------------- 1 | from .example_settings import * # noqa 2 | 3 | CACHES['userlog']['LOCATION'] = os.path.join(BASE_DIR, 'redis.sock') # noqa 4 | -------------------------------------------------------------------------------- /userlog/tests.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from __future__ import unicode_literals 4 | 5 | import os 6 | import signal 7 | import subprocess 8 | import threading 9 | import unittest 10 | 11 | import django 12 | from django.conf import settings 13 | from django.contrib.auth.models import User 14 | from selenium.webdriver.support import expected_conditions as ec 15 | 16 | from . import util 17 | 18 | try: 19 | import asyncio 20 | except ImportError: 21 | asyncio = None 22 | else: 23 | import websockets 24 | 25 | try: 26 | from django.contrib.admin.tests import AdminSeleniumTestCase 27 | except ImportError: 28 | from django.contrib.admin.tests import AdminSeleniumWebDriverTestCase \ 29 | as AdminSeleniumTestCase 30 | 31 | 32 | if asyncio: 33 | from . import realtime 34 | 35 | 36 | class UserLogTestCaseBase(AdminSeleniumTestCase): 37 | 38 | available_apps = settings.INSTALLED_APPS 39 | browsers = ['firefox'] 40 | 41 | @classmethod 42 | def setUpClass(cls): 43 | cls.redis = subprocess.Popen( 44 | ['redis-server', '--port', '0', '--unixsocket', 'redis.sock'], 45 | stdout=subprocess.PIPE, stderr=subprocess.PIPE) 46 | 47 | os.environ['DJANGO_SELENIUM_TESTS'] = 'true' 48 | super(UserLogTestCaseBase, cls).setUpClass() 49 | 50 | @classmethod 51 | def tearDownClass(cls): 52 | super(UserLogTestCaseBase, cls).tearDownClass() 53 | 54 | cls.redis.send_signal(signal.SIGINT) 55 | cls.redis.wait() 56 | 57 | def tearDown(self): 58 | util.get_redis_client().flushdb() 59 | 60 | def search_username(self, username): 61 | self.selenium.find_element_by_name('username').send_keys(username) 62 | submit_xpath = '//input[@value="Rechercher"]' 63 | self.selenium.find_element_by_xpath(submit_xpath).click() 64 | 65 | def accept_alert(self, expected_text, timeout=1): 66 | self.wait_until(ec.alert_is_present(), timeout) 67 | alert = self.selenium.switch_to_alert() 68 | self.assertEqual(alert.text, expected_text) 69 | alert.accept() 70 | 71 | 72 | class UserLogTestCase(UserLogTestCaseBase): 73 | 74 | def test_live_bigbrother(self): 75 | self.selenium.get(self.live_server_url + '/userlog/live/bigbrother/') 76 | self.accept_alert("Failed to connect. " 77 | "Is the realtime endpoint running?") 78 | 79 | def test_live_logs(self): 80 | self.selenium.get(self.live_server_url + '/') 81 | 82 | link = self.selenium.find_element_by_css_selector('.model-live a') 83 | self.assertEqual(link.text, "Journaux dynamiques") 84 | link.click() 85 | 86 | self.search_username('admin') 87 | self.accept_alert("Failed to connect. " 88 | "Is the realtime endpoint running?") 89 | self.wait_for_text('li.info', "Journal trouvé pour admin.") 90 | 91 | self.search_username('autre') 92 | self.wait_for_text('li.error', "Utilisateur autre non trouvé.") 93 | 94 | User.objects.create(username='autre') 95 | self.search_username('autre') 96 | self.accept_alert("Failed to connect. " 97 | "Is the realtime endpoint running?") 98 | self.wait_for_text('li.info', "Journal trouvé pour autre.") 99 | 100 | def test_static_logs(self): 101 | self.selenium.get(self.live_server_url + '/') 102 | 103 | link = self.selenium.find_element_by_css_selector('.model-static a') 104 | self.assertEqual(link.text, "Journaux statiques") 105 | link.click() 106 | 107 | self.search_username('admin') 108 | self.wait_for_text('li.info', "Journal trouvé pour admin.") 109 | 110 | self.search_username('autre') 111 | self.wait_for_text('li.error', "Utilisateur autre non trouvé.") 112 | 113 | User.objects.create(username='autre') 114 | self.search_username('autre') 115 | self.wait_for_text('li.warning', "Pas de journal pour autre.") 116 | 117 | 118 | @unittest.skipUnless(asyncio, "Live tests require Python ≥ 3.3 and asyncio.") 119 | class UserLogRealTimeTestCase(UserLogTestCaseBase): 120 | 121 | # setUpClass & tearDownClass repeat code from UserLogTestCaseBase and 122 | # then call into AdminSeleniumTestCase because the shutdown sequence 123 | # must follow the dependency order: selenium -> websockets -> redis. 124 | 125 | @classmethod 126 | def setUpClass(cls): 127 | cls.redis = subprocess.Popen( 128 | ['redis-server', '--port', '0', '--unixsocket', 'redis.sock'], 129 | stdout=subprocess.PIPE, stderr=subprocess.PIPE) 130 | 131 | cls.realtime_thread = threading.Thread(target=cls.run_realtime) 132 | cls.realtime_thread.start() 133 | 134 | os.environ['DJANGO_SELENIUM_TESTS'] = 'true' 135 | super(UserLogTestCaseBase, cls).setUpClass() # call grand-parent 136 | 137 | @classmethod 138 | def run_realtime(cls): 139 | event_loop = asyncio.new_event_loop() 140 | asyncio.set_event_loop(event_loop) # required by asyncio_redis 141 | 142 | userlog_settings = util.get_userlog_settings() 143 | uri = websockets.parse_uri(userlog_settings.websocket_address) 144 | start_server = websockets.serve( 145 | realtime.userlog, uri.host, uri.port, loop=event_loop) 146 | 147 | stop_server = asyncio.Future(loop=event_loop) 148 | cls.stop_realtime_server = lambda: event_loop.call_soon_threadsafe( 149 | lambda: stop_server.set_result(True)) 150 | 151 | realtime_server = event_loop.run_until_complete(start_server) 152 | event_loop.run_until_complete(stop_server) 153 | realtime_server.close() 154 | event_loop.run_until_complete(realtime_server.wait_closed()) 155 | 156 | event_loop.close() 157 | 158 | @classmethod 159 | def tearDownClass(cls): 160 | super(UserLogTestCaseBase, cls).tearDownClass() # call grand-parent 161 | 162 | cls.stop_realtime_server() 163 | cls.realtime_thread.join() 164 | 165 | cls.redis.send_signal(signal.SIGINT) 166 | cls.redis.wait() 167 | 168 | def test_live_bigbrother(self): 169 | self.selenium.get(self.live_server_url + '/userlog/live/bigbrother/') 170 | 171 | # This is an indirect way to wait for the websocket connection. 172 | user_heading = "Utilisateur" 173 | if django.VERSION >= (1, 9): 174 | user_heading = user_heading.upper() 175 | self.wait_for_text('table#result_list thead tr th:nth-child(1)', 176 | user_heading) 177 | 178 | self.client.get('/non_existing/') 179 | 180 | self.wait_for_text('table#result_list tbody tr td:nth-child(1)', 181 | "admin") 182 | self.wait_for_text('table#result_list tbody tr td:nth-child(3)', 183 | "/non_existing/") 184 | self.wait_for_text('table#result_list tbody tr td:nth-child(4)', 185 | "Lecture (GET)") 186 | self.wait_for_text('table#result_list tbody tr td:nth-child(5)', 187 | "Erreur du client (404)") 188 | 189 | def test_live_logs(self): 190 | self.selenium.get(self.live_server_url + '/') 191 | 192 | link = self.selenium.find_element_by_css_selector('.model-live a') 193 | self.assertEqual(link.text, "Journaux dynamiques") 194 | link.click() 195 | 196 | self.search_username('admin') 197 | self.wait_for_text('li.info', "Journal trouvé pour admin.") 198 | 199 | self.wait_for_text('table#result_list tbody tr td:nth-child(2)', 200 | "/userlog/live/") 201 | self.wait_for_text('table#result_list tbody tr td:nth-child(3)', 202 | "Lecture (GET)") 203 | self.wait_for_text('table#result_list tbody tr td:nth-child(4)', 204 | "Succès (200)") 205 | 206 | self.client.get('/non_existing/') 207 | self.wait_for_text('table#result_list tbody tr td:nth-child(2)', 208 | "/non_existing/") 209 | self.wait_for_text('table#result_list tbody tr td:nth-child(3)', 210 | "Lecture (GET)") 211 | self.wait_for_text('table#result_list tbody tr td:nth-child(4)', 212 | "Erreur du client (404)") 213 | -------------------------------------------------------------------------------- /userlog/util.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import re 4 | from collections import namedtuple 5 | 6 | import redis 7 | from django.conf import settings 8 | from django.core.cache import caches 9 | from django.core.exceptions import ImproperlyConfigured 10 | from django.dispatch import receiver 11 | from django.test.signals import setting_changed 12 | from django.utils.crypto import get_random_string 13 | from django.utils.timezone import utc 14 | 15 | _client = None # Cached instance of the Redis client. 16 | 17 | _settings = None # Cached dict of userlog settings 18 | 19 | 20 | @receiver(setting_changed) 21 | def reset_caches(**kwargs): 22 | global _client, _settings 23 | if kwargs['setting'] == 'CACHES': 24 | _client = None 25 | _settings = None 26 | 27 | 28 | def get_redis_client(): 29 | global _client 30 | 31 | if _client is not None: 32 | return _client 33 | 34 | try: 35 | cache = caches['userlog'] 36 | except KeyError: 37 | raise ImproperlyConfigured("No 'userlog' cache found in CACHES.") 38 | 39 | try: 40 | try: 41 | _client = cache.client # django-redis 42 | except AttributeError: 43 | _client = cache.get_master_client() # django-redis-cache 44 | assert isinstance(_client, redis.StrictRedis) 45 | except (AssertionError, AttributeError): 46 | raise ImproperlyConfigured("'userlog' cache doesn't use Redis.") 47 | 48 | return _client 49 | 50 | 51 | def convert_timestamp(ts): 52 | if settings.USE_TZ: 53 | return datetime.datetime.utcfromtimestamp(ts).replace(tzinfo=utc) 54 | else: 55 | return datetime.datetime.fromtimestamp(ts) 56 | 57 | 58 | def get_log(username): 59 | """ 60 | Return a list of page views. 61 | 62 | Each item is a dict with `datetime`, `method`, `path` and `code` keys. 63 | """ 64 | redis = get_redis_client() 65 | log_key = 'log:{}'.format(username) 66 | raw_log = redis.lrange(log_key, 0, -1) 67 | log = [] 68 | for raw_item in raw_log: 69 | item = json.loads(raw_item.decode()) 70 | item['datetime'] = convert_timestamp(item.pop('time')) 71 | log.append(item) 72 | return log 73 | 74 | 75 | def get_token(username, length=20, timeout=20): 76 | """ 77 | Obtain an access token that can be passed to a websocket client. 78 | """ 79 | redis = get_redis_client() 80 | token = get_random_string(length) 81 | token_key = 'token:{}'.format(token) 82 | redis.set(token_key, username) 83 | redis.expire(token_key, timeout) 84 | return token 85 | 86 | 87 | UserLogSettings = namedtuple( 88 | 'UserLogSettings', 89 | ['timeout', 'max_size', 'publish', 'ignore_urls', 'websocket_address']) 90 | 91 | 92 | def get_userlog_settings(): 93 | global _settings 94 | 95 | if _settings is not None: 96 | return _settings 97 | 98 | def get_setting(name, default): 99 | return getattr(settings, 'USERLOG_' + name, default) 100 | 101 | # Coerce values into expected types in order to detect invalid settings. 102 | _settings = UserLogSettings( 103 | # Hardcode the default timeout because it isn't exposed by Django. 104 | timeout=int(settings.CACHES['userlog'].get('TIMEOUT', 300)), 105 | max_size=int(get_setting('MAX_SIZE', 25)), 106 | publish=bool(get_setting('PUBLISH', True)), 107 | ignore_urls=[re.compile(pattern) 108 | for pattern in get_setting('IGNORE_URLS', [])], 109 | websocket_address=str(get_setting('WEBSOCKET_ADDRESS', 110 | 'ws://localhost:8080/')), 111 | ) 112 | 113 | return _settings 114 | -------------------------------------------------------------------------------- /userlog/views.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.contrib import messages 3 | from django.contrib.auth import get_user_model 4 | from django.contrib.auth.decorators import user_passes_test 5 | from django.forms import Media 6 | from django.shortcuts import render 7 | from django.utils import timezone 8 | from django.utils.translation import ugettext as _ 9 | from django.views.decorators.http import last_modified 10 | from django.views.i18n import javascript_catalog 11 | 12 | from .util import get_log, get_token, get_userlog_settings 13 | 14 | LIVE_MEDIA = Media(js=[ 15 | 'admin/js/' + ( 16 | 'vendor/jquery/' if django.VERSION >= (1, 9) else '' 17 | ) + 'jquery.js', 18 | 'admin/js/jquery.init.js', 19 | 'userlog/js/live.js', 20 | ]) 21 | 22 | 23 | last_modified_date = timezone.now() 24 | 25 | 26 | @last_modified(lambda req, **kw: last_modified_date) 27 | def jsi18n(request): 28 | return javascript_catalog(request, 'djangojs', ['userlog']) 29 | 30 | 31 | @user_passes_test(lambda user: user.is_superuser) 32 | def bigbrother(request): 33 | return render(request, 'userlog/live.html', { 34 | 'title': _("Live logs"), 35 | 'token': get_token('*'), 36 | 'wsuri': get_userlog_settings().websocket_address, 37 | 'media': LIVE_MEDIA, 38 | }) 39 | 40 | 41 | @user_passes_test(lambda user: user.is_superuser) 42 | def live(request): 43 | User = get_user_model() 44 | username_field = User.USERNAME_FIELD 45 | 46 | token = None 47 | 48 | username = request.GET.get('username') 49 | if username: 50 | try: 51 | User.objects.get(**{username_field: username}) 52 | except User.DoesNotExist: 53 | messages.error(request, _("User {} not found.").format(username)) 54 | else: 55 | token = get_token(username) 56 | messages.info(request, _("Logs found for {}.").format(username)) 57 | 58 | return render(request, 'userlog/live.html', { 59 | 'title': _("Live log"), 60 | 'token': token, 61 | 'wsuri': get_userlog_settings().websocket_address, 62 | 'fieldname': User._meta.get_field(username_field).verbose_name, 63 | 'media': LIVE_MEDIA, 64 | }) 65 | 66 | 67 | @user_passes_test(lambda user: user.is_superuser) 68 | def static(request): 69 | User = get_user_model() 70 | username_field = User.USERNAME_FIELD 71 | 72 | log = None 73 | 74 | username = request.GET.get('username') 75 | if username: 76 | try: 77 | User.objects.get(**{username_field: username}) 78 | except User.DoesNotExist: 79 | messages.error(request, _("User {} not found.").format(username)) 80 | else: 81 | log = get_log(username) 82 | if log: 83 | messages.info(request, _("Logs found for {}.").format(username)) # noqa 84 | else: 85 | messages.warning(request, _("No logs for {}.").format(username)) # noqa 86 | 87 | return render(request, 'userlog/static.html', { 88 | 'title': _("Static log"), 89 | 'log': log, 90 | 'fieldname': User._meta.get_field(username_field).verbose_name, 91 | }) 92 | --------------------------------------------------------------------------------