├── .coveragerc ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── CONTRIBUTORS ├── LICENSE ├── MANIFEST.in ├── README.md ├── manage.py ├── publish.py ├── run_tests.py ├── settings.py ├── setup.cfg ├── setup.py └── user_guide ├── __init__.py ├── admin.py ├── apps.py ├── migrations ├── 0001_initial.py └── __init__.py ├── models.py ├── static └── user_guide │ ├── .jscs.json │ ├── .jshintrc │ ├── Gruntfile.js │ ├── build │ ├── django-user-guide.css │ └── django-user-guide.js │ ├── package.json │ └── tests │ └── django-user-guide.js ├── templates └── user_guide │ └── window.html ├── templatetags ├── __init__.py └── user_guide_tags.py ├── tests ├── __init__.py ├── admin_tests.py ├── model_tests.py ├── no_admin_tests.py ├── templatetag_tests.py ├── urls_tests.py └── view_tests.py ├── urls.py ├── version.py └── views.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | omit = 4 | */migrations/* 5 | user_guide/version.py 6 | user_guide/apps.py 7 | source = user_guide 8 | [report] 9 | exclude_lines = 10 | # Have to re-enable the standard pragma 11 | pragma: no cover 12 | 13 | # Don't complain if tests don't hit defensive assertion code: 14 | raise NotImplementedError 15 | fail_under = 100 16 | show_missing = 1 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled python files 2 | *.pyc 3 | 4 | # Vim files 5 | *.swp 6 | *.swo 7 | 8 | # Coverage files 9 | .coverage 10 | 11 | # Setuptools distribution folder. 12 | /dist/ 13 | /build/ 14 | 15 | # Python egg metadata, regenerated from source files by setuptools. 16 | /*.egg-info 17 | /*.egg 18 | .eggs/ 19 | 20 | # Virtual environment 21 | env/ 22 | venv/ 23 | 24 | # Js Stuff 25 | node_modules/ 26 | npm-debug.log 27 | .grunt/ 28 | .tmp/ 29 | _SpecRunner.html 30 | html-report/ 31 | 32 | # Pycharm 33 | .idea/ 34 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - '2.7' 5 | - '3.4' 6 | - '3.5' 7 | env: 8 | global: 9 | - DB=postgres 10 | matrix: 11 | - DJANGO=">=1.8,<1.9" 12 | - DJANGO=">=1.9,<1.10" 13 | install: 14 | - pip install -q coverage flake8 Django$DJANGO django-nose>=1.4 15 | before_script: 16 | - flake8 . 17 | - psql -c 'CREATE DATABASE user_guide;' -U postgres 18 | script: 19 | - (cd user_guide/static/user_guide && npm install && npm test) 20 | - coverage run setup.py test 21 | - coverage report --fail-under=100 22 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | Contributions and issues are most welcome! All issues and pull requests are 3 | handled through GitHub on the 4 | [ambitioninc repository](https://github.com/ambitioninc/django-user-guide/issues). 5 | Also, please check for any existing issues before filing a new one. If you have 6 | a great idea but it involves big changes, please file a ticket before making a 7 | pull request! We want to make sure you don't spend your time coding something 8 | that might not fit the scope of the project. 9 | 10 | ## Running the tests 11 | 12 | To get the source source code and run the unit tests, run: 13 | ```bash 14 | git clone git://github.com/ambitioninc/django-user-guide.git 15 | cd django-user-guide 16 | virtualenv env 17 | . env/bin/activate 18 | python setup.py install 19 | coverage run setup.py test 20 | coverage report --fail-under=100 21 | ``` 22 | 23 | While 100% code coverage does not make a library bug-free, it significantly 24 | reduces the number of easily caught bugs! Please make sure coverage is at 100% 25 | before submitting a pull request! 26 | 27 | ## Code Quality 28 | 29 | For code quality, please run flake8: 30 | ```bash 31 | pip install flake8 32 | flake8 . 33 | ``` 34 | 35 | ## Code Styling 36 | Please arrange imports with the following style 37 | 38 | ```python 39 | # Standard library imports 40 | import os 41 | 42 | # Third party package imports 43 | from mock import patch 44 | from django.conf import settings 45 | 46 | # Local package imports 47 | from user_guide.version import __version__ 48 | ``` 49 | 50 | Please follow 51 | [Google's python style](http://google-styleguide.googlecode.com/svn/trunk/pyguide.html) 52 | guide wherever possible. 53 | 54 | 55 | 56 | ## Release Checklist 57 | 58 | Before a new release, please go through the following checklist: 59 | 60 | * Bump version in user_guide/version.py 61 | * Git tag the version 62 | * Upload to pypi: 63 | ```bash 64 | pip install wheel 65 | python setup.py sdist bdist_wheel upload 66 | ``` 67 | 68 | ## Vulnerability Reporting 69 | 70 | For any security issues, please do NOT file an issue or pull request on GitHub! 71 | Please contact [security@ambition.com](mailto:security@ambition.com) with the 72 | GPG key provided on [Ambition's website](http://ambition.com/security/). 73 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | Jeff McRiffey (jeff.mcriffey@tryambition.com) 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Ambition 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include CONTRIBUTING.md 3 | include LICENSE 4 | recursive-include user_guide/static/user_guide/build * 5 | recursive-include user_guide/templates * 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/ambitioninc/django-user-guide.png)](https://travis-ci.org/ambitioninc/django-user-guide) 2 | # Django User Guide 3 | 4 | 5 | Django User Guide is a `django>=1.6` app that shows configurable, self-contained HTML guides to users. Showing a guide to all of your users is as easy as 6 | creating a `Guide` object and linking them to your users. Use the convenient `{% user_guide %}` template tag where you want guides to appear and Django User Guide does the rest. When a user visits a page containing the template tag, they are greeted with relevant guides. Django User Guide decides what guide(s) a user needs to see and displays them in a modal window with controls for cycling through those guides. Django User Guide tracks plenty of meta-data: creation times, guide importance, if the guide has been finished by specific users, finished times, etc. 7 | 8 | ## Table of Contents 9 | 1. [Installation](#installation) 10 | 1. [Guide](#guide) 11 | 1. [GuideInfo](#guide-info) 12 | 1. [Settings](#settings) 13 | 1. [Finishing Criteria](#finishing-criteria) 14 | 1. [Putting It All Together](#putting-it-all-together) 15 | 16 | ## Installation 17 | To install Django User Guide: 18 | 19 | ```shell 20 | pip install git+https://github.com/ambitioninc/django-user-guide.git@0.1 21 | ``` 22 | 23 | Add Django User Guide to your `INSTALLED_APPS` to get started: 24 | 25 | settings.py 26 | 27 | ```python 28 | 29 | # Simply add 'user_guide' to your installed apps. 30 | # Django User Guide relies on several basic django apps. 31 | INSTALLED_APPS = ( 32 | 'django.contrib.auth', 33 | 'django.contrib.admin', 34 | 'django.contrib.sites', 35 | 'django.contrib.sessions', 36 | 'django.contrib.messages', 37 | 'django.contrib.staticfiles', 38 | 'django.contrib.contenttypes', 39 | 'user_guide', 40 | ) 41 | ``` 42 | 43 | Make sure Django's CsrfViewMiddleware is enabled: 44 | 45 | settings.py 46 | 47 | ```python 48 | MIDDLEWARE_CLASSES = ( 49 | 'django.middleware.csrf.CsrfViewMiddleware', 50 | ) 51 | ``` 52 | 53 | Add Django User Guide's urls to your project: 54 | 55 | urls.py 56 | 57 | ```python 58 | from django.conf.urls import include, patterns, url 59 | 60 | urlpatterns = patterns( 61 | url(r'^user-guide/', include('user_guide.urls')), 62 | ) 63 | ``` 64 | 65 | ## Guide 66 | 67 | First you will need to create one or more `Guide` objects. A `Guide` object consists of: 68 | 69 | #### guide_name (required, max_length=64, unique) 70 | 71 | This is a semantic, unique identifier for a `Guide`. Allows for easy identification and targeted filtering. 72 | 73 | #### html 74 | 75 | The markup for the `Guide`. Use this field to communicate with your users in a meaningful way. 76 | Note that the data in this field is output with `{% html|safe %}`, so it would be a bad idea to put untrusted data in it. The html field automatically replaces `{static}` within the html with the value of `settings.STATIC_URL` for convenience. 77 | 78 | #### guide_tag (default='all') 79 | 80 | A custom tag for grouping several guides together. Specifically designed to be used for filtering. If you had `my_guide_tag_list = ['welcome', 'onboarding']` in your context, you would use `{% user_guide guide_tags=my_guide_tag_list %}` to show users all guides with tags 'welcome' and 'onboard' specifically. 81 | 82 | #### guide_importance (default=0) 83 | 84 | A number representing the importance of the `Guide`. `Guide` objects with a higher `guide_importance` are shown first. `Guide` objects are always sorted by `guide_importance`, then `creation_time`. 85 | 86 | #### guide_type (default='Window') 87 | 88 | The rendering type for the `Guide`. Only a modal window is currently supported. Future support for positioned coach-marks and other elements is planned. 89 | 90 | #### creation_time (auto_now_add=True) 91 | 92 | Stores the current datetime when a `Guide` is created. 93 | 94 | 95 | ## Guide Usage 96 | 97 | ```python 98 | from user_guide.models import Guide 99 | 100 | Guide.objects.create( 101 | html='
Hello Guide!
', 102 | guide_name='First Guide', 103 | guide_tag='onboarding', 104 | guide_importance=5 105 | ) 106 | ``` 107 | 108 | ## GuideInfo 109 | 110 | The next step is creating `GuideInfo` objects. These are used to connect a `Guide` to a `User`. A `GuideInfo` object consists of: 111 | 112 | #### user (required) 113 | 114 | The `User` that should see a `Guide`. Any number of `User` objects can be pointed to a `Guide`. 115 | 116 | #### guide (required) 117 | 118 | The `Guide` to show a `User`. Any number of `Guide` objects can be tied to a `User`. 119 | 120 | #### is_finished (default=False) 121 | 122 | Marked true when the `User` has completed some [finishing criteria](#finishing-criteria). By default, users are only shown `Guide` objects with `is_finished=False`. 123 | 124 | #### finished_time 125 | 126 | When the [finishing criteria](#finishing-criteria) is met, the value of `datetime.utcnow()` is stored. 127 | 128 | ## GuideInfo Usage 129 | 130 | ```python 131 | from django.contrib.auth.models import User 132 | 133 | from user_guide.models import Guide, GuideInfo 134 | 135 | # Show the guide with the name 'First Guide' to the given user 136 | guide = Guide.objects.get(guide_name='First Guide') 137 | user = User.objects.get(id=1) 138 | 139 | GuideInfo.objects.create(guide=guide, user=user) 140 | ``` 141 | 142 | ## Settings 143 | 144 | Django User Guide has several configurations that can finely tune your user guide experience. 145 | 146 | #### USER_GUIDE_SHOW_MAX (default=10) 147 | 148 | The maximum number of guides to show for a single page load. If a user had 20 possible guides and `USER_GUIDE_SHOW_MAX` was set to 5, only the first 5 (based on `guide_importance` and `creation_time`) guides would be shown. 149 | 150 | #### USER_GUIDE_CSS_URL (default=None) 151 | 152 | The path to a custom style sheet for Django User Guides. Added as a `link` tag immediately after the [django-user-guide.css](user_guide/static/user_guide/build/django-user-guide.css) source. If omitted, no extra style sheets are included. See [django-user-guide.css](user_guide/static/user_guide/build/django-user-guide.css) for class names to override. 153 | 154 | #### USER_GUIDE_JS_URL (default=None) 155 | 156 | The path to a custom script for Django User Guides. Added as a `script` tag immediately after the [django-user-guide.js](user_guide/static/user_guide/build/django-user-guide.js) source. If omitted, no extra scripts are included. See [django-user-guide.js](user_guide/static/user_guide/build/django-user-guide.js) for methods to override. 157 | 158 | #### USER_GUIDE_USE_COOKIES (default=False) 159 | 160 | True to use cookies instead of marking the guides as seen in the database. Useful for showing guides to multiple shared Django users. 161 | 162 | ## Settings Usage 163 | 164 | settings.py 165 | 166 | ```python 167 | # Django User Guide settings 168 | USER_GUIDE_SHOW_MAX = 5 169 | USER_GUIDE_USE_COOKIES = True 170 | USER_GUIDE_CSS_URL = 'absolute/path/to/style.css' 171 | USER_GUIDE_JS_URL = 'absolute/path/to/script.js' 172 | ``` 173 | 174 | ## Finishing criteria 175 | 176 | Finishing criteria are rules to marking a guide as finished. By default, they only need to press the 'next' or 'done' button on a guide. This behavior can be overridden by creating a custom script and adding it to the USER_GUIDE_JS_URL setting. The custom script only needs to override the `window.DjangoUserGuide.isFinished` method. 177 | 178 | custom-script.js 179 | 180 | ```js 181 | /** 182 | * @override isFinished 183 | * Only allows guides to be marked finished on Mondays. 184 | * @param {HTMLDivElement} item - The item to check. 185 | * @returns {Boolean} 186 | */ 187 | window.DjangoUserGuide.prototype.isFinished = function isFinished(item) { 188 | if ((new Date()).getDay() === 1) { 189 | return true; 190 | } 191 | return false; 192 | }; 193 | ``` 194 | 195 | settings.py 196 | 197 | ```python 198 | USER_GUIDE_JS_URL = 'path/to/custom-script.js' 199 | ``` 200 | 201 | ## Putting It All Together 202 | 203 | Assuming you have created some `Guide` and `GuideInfo` objects, this is how you would 204 | show your users their relevant guides. 205 | 206 | views.py 207 | 208 | ```python 209 | from django.views.generic import TemplateView 210 | 211 | class CoolView(TemplateView): 212 | template_name = 'cool_project/cool_template.html' 213 | 214 | def get_context_data(self, **kwargs): 215 | context = super(CoolView, self).get_context_data(**kwargs) 216 | context['cool_guide_tags'] = ['general', 'welcome', 'onboarding'] 217 | return context 218 | ``` 219 | 220 | templates/cool_project/cool_template.html 221 | 222 | ```html 223 | 224 | 225 | 226 | 227 | Hello User Guides 228 | 229 | 230 | {% load user_guide_tags %} 231 | {% user_guide guide_tags=cool_guide_tags %} 232 |

Hello User Guides!

233 | 234 | 235 | ``` 236 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | 4 | from settings import configure_settings 5 | 6 | 7 | if __name__ == '__main__': 8 | configure_settings() 9 | 10 | from django.core.management import execute_from_command_line 11 | 12 | execute_from_command_line(sys.argv) 13 | -------------------------------------------------------------------------------- /publish.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | subprocess.call(['pip', 'install', 'wheel']) 4 | subprocess.call(['python', 'setup.py', 'clean', '--all']) 5 | subprocess.call(['python', 'setup.py', 'register', 'sdist', 'bdist_wheel', 'upload']) 6 | -------------------------------------------------------------------------------- /run_tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides the ability to run test on a standalone Django app. 3 | """ 4 | import sys 5 | from optparse import OptionParser 6 | 7 | import django 8 | 9 | from settings import configure_settings 10 | 11 | # Configure the default settings and setup django 12 | configure_settings() 13 | django.setup() 14 | 15 | # Django nose must be imported here since it depends on the settings being configured 16 | from django_nose import NoseTestSuiteRunner 17 | 18 | 19 | def run_tests(*test_args, **kwargs): 20 | if not test_args: 21 | test_args = ['user_guide'] 22 | 23 | kwargs.setdefault('interactive', False) 24 | 25 | test_runner = NoseTestSuiteRunner(**kwargs) 26 | 27 | failures = test_runner.run_tests(test_args) 28 | sys.exit(failures) 29 | 30 | 31 | if __name__ == '__main__': 32 | parser = OptionParser() 33 | parser.add_option('--verbosity', dest='verbosity', action='store', default=1, type=int) 34 | (options, args) = parser.parse_args() 35 | 36 | run_tests(*args, **options.__dict__) 37 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.conf import settings 4 | 5 | 6 | def configure_settings(): 7 | if not settings.configured: 8 | # Determine the database settings depending on if a test_db var is set in CI mode or not 9 | test_db = os.environ.get('DB', None) 10 | if test_db is None: 11 | db_config = { 12 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 13 | 'NAME': 'ambition_dev', 14 | 'USER': 'ambition_dev', 15 | 'PASSWORD': 'ambition_dev', 16 | 'HOST': 'localhost' 17 | } 18 | elif test_db == 'postgres': 19 | db_config = { 20 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 21 | 'USER': 'postgres', 22 | 'NAME': 'user_guide', 23 | } 24 | else: 25 | raise RuntimeError('Unsupported test DB {0}'.format(test_db)) 26 | 27 | settings.configure( 28 | DATABASES={ 29 | 'default': db_config, 30 | }, 31 | MIDDLEWARE_CLASSES=( 32 | 'django.middleware.csrf.CsrfViewMiddleware', 33 | ), 34 | INSTALLED_APPS=( 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.admin', 39 | 'user_guide', 40 | 'user_guide.tests', 41 | ), 42 | ROOT_URLCONF='user_guide.urls', 43 | DEBUG=False, 44 | USER_GUIDE_SHOW_MAX=5, 45 | USER_GUIDE_CSS_URL='custom-style.css', 46 | USER_GUIDE_JS_URL='custom-script.js', 47 | STATIC_URL='/static/', 48 | SECRET_KEY='somethignmadeup', 49 | TEST_RUNNER='django_nose.NoseTestSuiteRunner', 50 | NOSE_ARGS=['--nocapture', '--nologcapture', '--verbosity=1'], 51 | ) 52 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | exclude = build,docs,venv,env,*.egg,migrations,south_migrations 4 | max-complexity = 10 5 | ignore = E402 6 | 7 | [bdist_wheel] 8 | universal = 1 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | from setuptools import setup, find_packages 3 | 4 | # import multiprocessing to avoid this bug (http://bugs.python.org/issue15881#msg170215) 5 | import multiprocessing 6 | assert multiprocessing 7 | 8 | 9 | def get_version(): 10 | """ 11 | Extracts the version number from the version.py file. 12 | """ 13 | VERSION_FILE = 'user_guide/version.py' 14 | mo = re.search(r'^__version__ = [\'"]([^\'"]*)[\'"]', open(VERSION_FILE, 'rt').read(), re.M) 15 | if mo: 16 | return mo.group(1) 17 | else: 18 | raise RuntimeError('Unable to find version string in {0}.'.format(VERSION_FILE)) 19 | 20 | 21 | setup( 22 | name='django-user-guide', 23 | version=get_version(), 24 | description='Show configurable HTML guides to users.', 25 | long_description=open('README.md').read(), 26 | url='https://github.com/ambitioninc/django-user-guide', 27 | author='Jeff McRiffey', 28 | author_email='jeff.mcriffey@ambition.com', 29 | packages=find_packages(), 30 | classifiers=[ 31 | 'Programming Language :: Python', 32 | 'Programming Language :: Python :: 2.7', 33 | 'Programming Language :: Python :: 3.4', 34 | 'Programming Language :: Python :: 3.5', 35 | 'Intended Audience :: Developers', 36 | 'License :: OSI Approved :: MIT License', 37 | 'Operating System :: OS Independent', 38 | 'Framework :: Django', 39 | 'Framework :: Django :: 1.8', 40 | 'Framework :: Django :: 1.9', 41 | ], 42 | license='MIT', 43 | install_requires=[ 44 | 'Django>=1.8', 45 | ], 46 | tests_require=[ 47 | 'psycopg2', 48 | 'django-nose', 49 | 'django-dynamic-fixture', 50 | 'mock>=1.0.1', 51 | 'freezegun' 52 | ], 53 | test_suite='run_tests.run_tests', 54 | include_package_data=True, 55 | ) 56 | -------------------------------------------------------------------------------- /user_guide/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .version import __version__ 3 | 4 | default_app_config = 'user_guide.apps.UserGuideConfig' 5 | -------------------------------------------------------------------------------- /user_guide/admin.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | from user_guide.models import Guide, GuideInfo 4 | 5 | if 'django.contrib.admin' in settings.INSTALLED_APPS: 6 | from django.contrib import admin 7 | 8 | class GuideAdmin(admin.ModelAdmin): 9 | list_display = ('guide_name', 'guide_tag', 'guide_importance', 'creation_time') 10 | 11 | class GuideInfoAdmin(admin.ModelAdmin): 12 | list_display = ('user', 'guide_name', 'is_finished', 'finished_time') 13 | 14 | def guide_name(self, obj): 15 | return obj.guide.guide_name 16 | 17 | admin.site.register(Guide, GuideAdmin) 18 | admin.site.register(GuideInfo, GuideInfoAdmin) 19 | -------------------------------------------------------------------------------- /user_guide/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class UserGuideConfig(AppConfig): 5 | name = 'user_guide' 6 | verbose_name = 'Django User Guide' 7 | -------------------------------------------------------------------------------- /user_guide/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from django.conf import settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Guide', 17 | fields=[ 18 | ('id', models.AutoField(primary_key=True, auto_created=True, verbose_name='ID', serialize=False)), 19 | ('html', models.TextField()), 20 | ('guide_type', models.CharField(max_length=16, default='WINDOW', choices=[('WINDOW', 'Window')])), 21 | ('guide_name', models.CharField(unique=True, max_length=64)), 22 | ('guide_tag', models.TextField(default='all')), 23 | ('guide_importance', models.IntegerField(default=0)), 24 | ('creation_time', models.DateTimeField(auto_now_add=True)), 25 | ], 26 | options={ 27 | }, 28 | bases=(models.Model,), 29 | ), 30 | migrations.CreateModel( 31 | name='GuideInfo', 32 | fields=[ 33 | ('id', models.AutoField(primary_key=True, auto_created=True, verbose_name='ID', serialize=False)), 34 | ('is_finished', models.BooleanField(default=False)), 35 | ('finished_time', models.DateTimeField(null=True, blank=True)), 36 | ('guide', models.ForeignKey(to='user_guide.Guide')), 37 | ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), 38 | ], 39 | options={ 40 | 'ordering': ['-guide__guide_importance', 'guide__creation_time'], 41 | }, 42 | bases=(models.Model,), 43 | ), 44 | migrations.AlterUniqueTogether( 45 | name='guideinfo', 46 | unique_together=set([('user', 'guide')]), 47 | ), 48 | ] 49 | -------------------------------------------------------------------------------- /user_guide/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ambitioninc/django-user-guide/803258e4e31a1db351d5c050ce655c354e4d7888/user_guide/migrations/__init__.py -------------------------------------------------------------------------------- /user_guide/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | import six 4 | 5 | 6 | @six.python_2_unicode_compatible 7 | class Guide(models.Model): 8 | """ 9 | Describes a guide to be tied to any number of users. 10 | """ 11 | # The html that should be rendered in a guide. 12 | html = models.TextField() 13 | # The type of guide to render. The only guide type currently supported is 'Window.' 14 | guide_type = models.CharField(max_length=16, choices=(('WINDOW', 'Window'),), default='WINDOW') 15 | # The name of the guide. Mainly for display purposes. 16 | guide_name = models.CharField(max_length=64, unique=True) 17 | # A tag for the given guide. For filtering purposes. 18 | guide_tag = models.TextField(default='all') 19 | # An ordering parameter for the guide. To show a guide first, give it a larger guide_importance. 20 | guide_importance = models.IntegerField(default=0) 21 | # The creation time of the guide. 22 | creation_time = models.DateTimeField(auto_now_add=True) 23 | 24 | def __str__(self): 25 | return str(self.guide_name) 26 | 27 | 28 | class GuideInfo(models.Model): 29 | """ 30 | Ties a guide to a user. 31 | """ 32 | # The user that should see this guide. 33 | user = models.ForeignKey(settings.AUTH_USER_MODEL) 34 | # The guide that should be shown to the user. 35 | guide = models.ForeignKey(Guide) 36 | # Has the guide been seen by a user? 37 | is_finished = models.BooleanField(default=False) 38 | # Save the finished time for convenience 39 | finished_time = models.DateTimeField(null=True, blank=True) 40 | 41 | class Meta: 42 | unique_together = ('user', 'guide') 43 | ordering = ['-guide__guide_importance', 'guide__creation_time'] 44 | -------------------------------------------------------------------------------- /user_guide/static/user_guide/.jscs.json: -------------------------------------------------------------------------------- 1 | { 2 | "requireCurlyBraces": ["if", "else", "for", "while", "do"], 3 | "requireSpaceAfterKeywords": ["if", "else", "for", "while", "do", "switch", "return"], 4 | "requireSpacesInFunctionExpression": { 5 | "beforeOpeningCurlyBrace": true 6 | }, 7 | "disallowSpacesInFunctionExpression": { 8 | "beforeOpeningRoundBrace": true 9 | }, 10 | "requireMultipleVarDecl": true, 11 | "disallowLeftStickedOperators": ["?", "/", "*", "=", "==", "===", "!=", "!==", ">", ">=", "<", "<="], 12 | "disallowRightStickedOperators": ["?", "/", "*", ":", "=", "==", "===", "!=", "!==", ">", ">=", "<", "<="], 13 | "requireRightStickedOperators": ["!"], 14 | "requireLeftStickedOperators": [","], 15 | "disallowKeywords": ["with"], 16 | "disallowMultipleLineBreaks": true, 17 | "disallowKeywordsOnNewLine": ["else"], 18 | "requireLineFeedAtFileEnd": true 19 | } 20 | -------------------------------------------------------------------------------- /user_guide/static/user_guide/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "camelcase": true, 3 | "indent": 4, 4 | "trailing": true, 5 | "quotmark": "single", 6 | "maxlen": 120, 7 | "unused": true, 8 | "undef": true, 9 | "sub": true, 10 | "browser": true, 11 | "node": true, 12 | "jquery": true, 13 | "globals": { 14 | "jasmine": true, 15 | "runs": true, 16 | "waitsFor": true, 17 | "it": true, 18 | "describe": true, 19 | "beforeEach": true, 20 | "afterEach": true, 21 | "expect": true, 22 | "spyOn": true, 23 | "xdescribe": true, 24 | "xit": true, 25 | "xexpect": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /user_guide/static/user_guide/Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(grunt) { 2 | var jsFiles = ['build/*.js'], 3 | testFiles = ['tests/*.js'], 4 | lintFiles = ['Gruntfile.js'].concat(jsFiles, testFiles), 5 | npmTasks = [ 6 | 'grunt-contrib-jshint', 7 | 'grunt-jscs-checker', 8 | 'grunt-contrib-jasmine' 9 | ]; 10 | 11 | grunt.initConfig({ 12 | pkg: grunt.file.readJSON('package.json'), 13 | jshint: { 14 | all: lintFiles, 15 | options: { 16 | jshintrc: '.jshintrc' 17 | } 18 | }, 19 | jscs: { 20 | all: lintFiles, 21 | options: { 22 | config: '.jscs.json' 23 | } 24 | }, 25 | jasmine: { 26 | src: jsFiles, 27 | options: { 28 | specs: testFiles, 29 | styles: ['build/django-user-guide.css'], 30 | template: require('grunt-template-jasmine-istanbul'), 31 | templateOptions: { 32 | coverage: '.tmp/coverage.json', 33 | report: [{type: 'text-summary'}, {type: 'html'}], 34 | thresholds: { 35 | lines: 100, 36 | statements: 100, 37 | branches: 100, 38 | functions: 100 39 | } 40 | } 41 | } 42 | } 43 | }); 44 | 45 | npmTasks.forEach(function(task) { 46 | grunt.loadNpmTasks(task); 47 | }); 48 | 49 | grunt.registerTask('test', ['jshint', 'jscs', 'jasmine']); 50 | }; 51 | -------------------------------------------------------------------------------- /user_guide/static/user_guide/build/django-user-guide.css: -------------------------------------------------------------------------------- 1 | .django-user-guide { 2 | display: none; 3 | position: absolute; 4 | top: 0; 5 | left: 0; 6 | height: 100%; 7 | width: 100%; 8 | z-index: 99999; 9 | } 10 | 11 | .django-user-guide-mask { 12 | position: absolute; 13 | top: 0; 14 | left: 0; 15 | height: 100%; 16 | width: 100%; 17 | background-color: rgba(0, 0, 0, 0.5); 18 | } 19 | 20 | .django-user-guide-item { 21 | position: absolute; 22 | top: 20px; 23 | display: none; 24 | } 25 | 26 | .django-user-guide-window { 27 | border-radius: 5px; 28 | background-color: #fff; 29 | position: absolute; 30 | height: 350px; 31 | width: 350px; 32 | margin: auto; 33 | top: 100px; 34 | left: 0; 35 | right: 0; 36 | padding: 20px; 37 | } 38 | 39 | .django-user-guide-close-div { 40 | border-radius: 5px; 41 | height: 20px; 42 | width: 20px; 43 | position: absolute; 44 | right: 10px; 45 | top: 10px; 46 | font-family: sans-serif; 47 | font-size: 20px; 48 | text-align: center; 49 | cursor: pointer; 50 | } 51 | 52 | .django-user-guide-window-nav { 53 | padding-top: 20px; 54 | width: 100%; 55 | } 56 | 57 | .django-user-guide-html-wrapper { 58 | position: relative; 59 | width: 100%; 60 | height: 100%; 61 | } 62 | 63 | .django-user-guide-btn { 64 | display: none; 65 | } 66 | 67 | .django-user-guide-counter { 68 | font-style: italic; 69 | position: absolute; 70 | bottom: 20px; 71 | left: 20px; 72 | } 73 | -------------------------------------------------------------------------------- /user_guide/static/user_guide/build/django-user-guide.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | /** 5 | * @constructor 6 | * Sets the passed csrf token name from the template. 7 | */ 8 | window.DjangoUserGuide = function DjangoUserGuide(config) { 9 | config = config || {}; 10 | this.useCookies = config.useCookies; 11 | this.finishedItems = {}; 12 | this.itemIndex = 0; 13 | }; 14 | 15 | window.DjangoUserGuide.prototype = { 16 | 17 | /** 18 | * @method getGuide 19 | * Gets the entire user guide div. 20 | * @returns {HTMLDivElement} 21 | */ 22 | getGuide: function getGuide() { 23 | if (!this.guide) { 24 | this.guide = document.querySelector('.django-user-guide'); 25 | } 26 | return this.guide; 27 | }, 28 | 29 | /** 30 | * @method getGuideMask 31 | * Gets the guide mask. 32 | * @returns {HTMLDivElement} 33 | */ 34 | getGuideMask: function getGuideMask() { 35 | if (!this.guideMask) { 36 | this.guideMask = document.querySelector('.django-user-guide-mask'); 37 | } 38 | return this.guideMask; 39 | }, 40 | 41 | /** 42 | * @method getItems 43 | * Gets the guide's html guide items. 44 | * @returns {HTMLDivElement[]} 45 | */ 46 | getItems: function getItems() { 47 | if (!this.items) { 48 | var self = this, 49 | items = Array.prototype.slice.call( 50 | document.querySelectorAll('.django-user-guide-item') 51 | ); 52 | 53 | if (self.useCookies) { //only show items that do not have a cookie 54 | self.items = []; 55 | 56 | items.forEach(function(item) { 57 | var guideId = item.getAttribute('data-guide'), 58 | cookie = self.getCookie('django-user-guide-' + guideId); 59 | 60 | if (!cookie) { 61 | self.items.push(item); 62 | } 63 | }); 64 | } else { //show all the items 65 | self.items = items; 66 | } 67 | } 68 | return this.items; 69 | }, 70 | 71 | /** 72 | * @method getBackBtn 73 | * Gets the guide's back button. 74 | * @returns {HTMLButtonElement} 75 | */ 76 | getBackBtn: function getBackBtn() { 77 | if (!this.backBtn) { 78 | this.backBtn = document.querySelector('.django-user-guide-back-btn'); 79 | } 80 | return this.backBtn; 81 | }, 82 | 83 | /** 84 | * @method getNextBtn 85 | * Gets the guide's next button. 86 | * @returns {HTMLButtonElement} 87 | */ 88 | getNextBtn: function getNextBtn() { 89 | if (!this.nextBtn) { 90 | this.nextBtn = document.querySelector('.django-user-guide-next-btn'); 91 | } 92 | return this.nextBtn; 93 | }, 94 | 95 | /** 96 | * @method getDoneBtn 97 | * Gets the guide's done button. 98 | * @returns {HTMLButtonElement} 99 | */ 100 | getDoneBtn: function getNextBtn() { 101 | if (!this.doneBtn) { 102 | this.doneBtn = document.querySelector('.django-user-guide-done-btn'); 103 | } 104 | return this.doneBtn; 105 | }, 106 | 107 | /** 108 | * @method getCloseDiv 109 | * Gets the guide's close div. 110 | * @returns {HTMLDivElement} 111 | */ 112 | getCloseDiv: function getCloseDiv() { 113 | if (!this.closeDiv) { 114 | this.closeDiv = document.querySelector('.django-user-guide-close-div'); 115 | } 116 | return this.closeDiv; 117 | }, 118 | 119 | /** 120 | * @method getCounterSpan 121 | * Gets the guide's counter span. 122 | * @returns {HTMLSpanElement} 123 | */ 124 | getCounterSpan: function getCounterSpan() { 125 | if (!this.counterDiv) { 126 | this.counterDiv = document.querySelector('.django-user-guide-counter span'); 127 | } 128 | return this.counterDiv; 129 | }, 130 | 131 | /** 132 | * @method getCookie 133 | * Gets the cookie value for a given cookie name. 134 | * @param {String} name - The name of the cookie to get. 135 | */ 136 | getCookie: function getCookie(name) { 137 | return document.cookie.match(new RegExp(name + '=([^;]*)')); 138 | }, 139 | 140 | /** 141 | * @method getCsrfToken 142 | * Gets the csrf token as set by the cookie. 143 | * @returns {String} 144 | */ 145 | getCsrfToken: function getCsrfToken() { 146 | var input = this.getGuide().querySelector('input[name=csrfmiddlewaretoken]'); 147 | return input ? input.value : ''; 148 | }, 149 | 150 | /** 151 | * @type {Object} 152 | * Objects that should be shown inline-block instead of block. 153 | * Add more items here as needed. 154 | */ 155 | inlineBlockItems: { 156 | 'BUTTON': true 157 | }, 158 | 159 | /** 160 | * @method hideEl 161 | * Hides an item. 162 | * @param {HTMLElement} item - The item to hide. 163 | */ 164 | hideEl: function hideEl(item) { 165 | item.style.display = 'none'; 166 | }, 167 | 168 | /** 169 | * @method showEl 170 | * Shows an item. Sets the display property to 'block' unless it appears in {@link inlineBlockItems}. 171 | * @param {HTMLElement} item - The item to show. 172 | */ 173 | showEl: function showEl(item) { 174 | if (this.inlineBlockItems.hasOwnProperty(item.tagName)) { 175 | item.style.display = 'inline-block'; 176 | } else { 177 | item.style.display = 'block'; 178 | } 179 | }, 180 | 181 | /** 182 | * @method showHideBtns 183 | * Decides which buttons should be visible, then shows/hides them accordingly. 184 | */ 185 | showHideBtns: function showHideBtns() { 186 | if (!this.getItems()[this.itemIndex + 1]) { //we have reached the end 187 | 188 | //there might not be a previous guide 189 | if (this.getItems().length > 1) { 190 | this.showEl(this.getBackBtn()); 191 | } else { 192 | this.hideEl(this.getBackBtn()); 193 | } 194 | 195 | this.hideEl(this.getNextBtn()); 196 | this.showEl(this.getDoneBtn()); 197 | } else if (!this.getItems()[this.itemIndex - 1]) { //we are at the start 198 | this.hideEl(this.getBackBtn()); 199 | this.hideEl(this.getDoneBtn()); 200 | this.showEl(this.getNextBtn()); 201 | } else { //we are in the middle 202 | this.hideEl(this.getDoneBtn()); 203 | this.showEl(this.getBackBtn()); 204 | this.showEl(this.getNextBtn()); 205 | } 206 | }, 207 | 208 | /** 209 | * @method showNextGuide 210 | * Shows the next guide in the list of {@link items}. 211 | */ 212 | showNextGuide: function showNextGuide() { 213 | var curr = this.getItems()[this.itemIndex], 214 | next = this.getItems()[this.itemIndex + 1]; 215 | 216 | if (curr && next) { 217 | this.updateItemIndex(1); 218 | this.hideEl(curr); 219 | this.showEl(next); 220 | this.showHideBtns(); 221 | } 222 | }, 223 | 224 | /** 225 | * @method showPrevGuide 226 | * Shows the previous guide in the list of {@link items}. 227 | */ 228 | showPrevGuide: function showPrevGuide() { 229 | var curr = this.getItems()[this.itemIndex], 230 | prev = this.getItems()[this.itemIndex - 1]; 231 | 232 | if (curr && prev) { 233 | this.updateItemIndex(-1); 234 | this.hideEl(curr); 235 | this.showEl(prev); 236 | this.showHideBtns(); 237 | } 238 | }, 239 | 240 | /** 241 | * @method updateItemIndex 242 | * Updates the item index and refreshes the tool tip numbers. 243 | * @param {Number} num - The number to incread the {@link itemIndex} by. 244 | */ 245 | updateItemIndex: function updateItemIndex(num) { 246 | this.itemIndex += num; 247 | 248 | this.getCounterSpan().innerHTML = 'Tip ' + (this.itemIndex + 1) + ' of ' + (this.getItems().length); 249 | }, 250 | 251 | /** 252 | * @method post 253 | * Makes a POST request to the given url, with the given data. 254 | * @param {String} url - The url to POST. 255 | * @param {Object} data - The data to POST. 256 | */ 257 | post: function post(url, data) { 258 | var req = new XMLHttpRequest(), 259 | csrfToken = this.getCsrfToken(), 260 | encoded = Object.keys(data).map(function(key) { 261 | return encodeURIComponent(key) + '=' + encodeURIComponent(data[key]); 262 | }).join('&'); 263 | 264 | //open the request 265 | req.open('POST', url, true); 266 | 267 | if (csrfToken) { //see if the csrf token should be set 268 | req.setRequestHeader('X-CSRFToken', csrfToken); 269 | } 270 | 271 | //send the data 272 | req.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8'); 273 | req.send(encoded); 274 | }, 275 | 276 | /** 277 | * @method saveCookie 278 | * Saves a cookie for the given user guide. 279 | * @param {String} name - The name of the cookie to save. 280 | * @param {Boolean} value - The value of the cookie to save. 281 | */ 282 | saveCookie: function saveCookie(name, value) { 283 | document.cookie = name + '=' + value + ';path=/;'; 284 | }, 285 | 286 | /** 287 | * @method isFinished 288 | * Describes if a particular guide has been finished. Always returns true by default. 289 | * Override this method for custom finish criteria logic. 290 | * Return true to allow the {@link finishedItem} method to proceed. 291 | * @param {HTMLDivElement} item - The item to check. 292 | * @returns {Boolean} 293 | */ 294 | isFinished: function isFinished() { 295 | return true; 296 | }, 297 | 298 | /** 299 | * @method finishItem 300 | * Marks an item finished and calls {@link post}. 301 | * @param {HTMLDivElement} item - The item to mark finished. 302 | */ 303 | finishItem: function finishItem(item) { 304 | var guideId = +(item ? item.getAttribute('data-guide') : null); 305 | 306 | if (guideId && !this.finishedItems[guideId] && this.isFinished(item)) { 307 | this.finishedItems[guideId] = true; 308 | 309 | if (this.useCookies) { //save a cookie for the finished guide 310 | this.saveCookie('django-user-guide-' + guideId, 'true'); 311 | } else { //make a post request to mark the guide finished 312 | this.post('/user-guide/seen/', { 313 | 'id': guideId, 314 | 'is_finished': true 315 | }); 316 | } 317 | } 318 | 319 | return item; 320 | }, 321 | 322 | /** 323 | * @method show 324 | * Shows the entire guide. 325 | */ 326 | show: function show() { 327 | if (this.getItems().length) { //we have some guides 328 | this.addListeners(); 329 | this.updateItemIndex(0); 330 | this.showEl(this.getGuide()); 331 | this.showEl(this.getItems()[0]); 332 | this.showHideBtns(); 333 | } 334 | }, 335 | 336 | /** 337 | * @method addListeners 338 | * Adds listeners to the various guide components. 339 | */ 340 | addListeners: function addListeners() { 341 | this.getBackBtn().onclick = this.onBackClick.bind(this); 342 | this.getNextBtn().onclick = this.onNextClick.bind(this); 343 | this.getDoneBtn().onclick = this.onDoneClick.bind(this); 344 | this.getCloseDiv().onclick = this.onCloseClick.bind(this); 345 | this.getGuideMask().onclick = this.onMaskClick.bind(this); 346 | }, 347 | 348 | /** 349 | * @method onCloseClick 350 | * Handler for clicking on the guide mask. 351 | */ 352 | onMaskClick: function onMaskClick(evt) { 353 | if (evt.target.className === 'django-user-guide-mask') { 354 | this.hideEl(this.getGuide()); 355 | } 356 | evt.stopPropagation(); 357 | }, 358 | 359 | /** 360 | * @method onCloseClick 361 | * Handler for closing the guide window. 362 | */ 363 | onCloseClick: function onCloseClick() { 364 | this.hideEl(this.getGuide()); 365 | }, 366 | 367 | /** 368 | * @method onDoneClick 369 | * Handler for finishing the guide window. 370 | */ 371 | onDoneClick: function onDoneClick() { 372 | this.finishItem(this.getItems()[this.itemIndex]); 373 | this.hideEl(this.getGuide()); 374 | }, 375 | 376 | /** 377 | * @method onNextClick 378 | * Handler for showing the next guide. 379 | */ 380 | onNextClick: function onNextClick() { 381 | this.finishItem(this.getItems()[this.itemIndex]); 382 | this.showNextGuide(); 383 | }, 384 | 385 | /** 386 | * @method onBackClick 387 | * Handler for showing the previous guide. 388 | */ 389 | onBackClick: function onBackClick() { 390 | this.showPrevGuide(); 391 | }, 392 | 393 | /** 394 | * @method run 395 | * Runs the entire process for showing the guide window. 396 | */ 397 | run: function run() { 398 | this.show(); 399 | } 400 | }; 401 | })(); 402 | -------------------------------------------------------------------------------- /user_guide/static/user_guide/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "user_guide", 3 | "version": "0.0.1", 4 | "description": "Static files for django-user-guide.", 5 | "main": "Gruntfile.js", 6 | "scripts": { 7 | "test": "grunt test" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/ambitioninc/django-user-guide.git" 12 | }, 13 | "keywords": [ 14 | "django" 15 | ], 16 | "author": "Jeff McRiffey", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/ambitioninc/django-user-guide/issues" 20 | }, 21 | "homepage": "https://github.com/ambitioninc/django-user-guide", 22 | "devDependencies": { 23 | "grunt-cli": "^0.1.13", 24 | "grunt": "^0.4.4", 25 | "grunt-contrib-jasmine": "^0.6.3", 26 | "grunt-contrib-jshint": "^0.9.2", 27 | "grunt-jscs-checker": "^0.4.1", 28 | "grunt-template-jasmine-istanbul": "^0.3.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /user_guide/static/user_guide/tests/django-user-guide.js: -------------------------------------------------------------------------------- 1 | describe('DjangoUserGuide', function() { 2 | function appendDom(el) { 3 | document.body.appendChild(el); 4 | } 5 | 6 | function removeDom(el) { 7 | document.body.removeChild(el); 8 | } 9 | 10 | function createDom(html) { 11 | var el = document.createElement('div'); 12 | el.innerHTML = html; 13 | return el; 14 | } 15 | 16 | function queryAllDom(selector) { 17 | return document.body.querySelectorAll(selector); 18 | } 19 | 20 | function getRenderedStyle(el, prop) { 21 | return getComputedStyle(el).getPropertyValue(prop); 22 | } 23 | 24 | function getFakeEvt(className) { 25 | return { 26 | target: { 27 | className: className || '' 28 | }, 29 | stopPropagation: function() {} 30 | }; 31 | } 32 | 33 | var guideHtml = [ 34 | '
', 35 | '
', 36 | '
', 37 | '
x
', 38 | '
', 39 | '
', 40 | '
', 41 | ' Tip 1 of 1', 42 | '
', 43 | '
', 44 | ' ', 45 | ' ', 46 | ' ', 47 | '
', 48 | '
', 49 | '
', 50 | '
' 51 | ].join('\n'), 52 | guideDom; 53 | 54 | beforeEach(function() { 55 | guideDom = createDom(guideHtml); 56 | appendDom(guideDom); 57 | }); 58 | 59 | afterEach(function() { 60 | removeDom(guideDom); 61 | }); 62 | 63 | it('should handle many items', function() { 64 | var dug = new window.DjangoUserGuide({ 65 | csrfCookieName: 'csrf-token-custom' 66 | }), 67 | items = null, 68 | btns = null, 69 | cont = null, 70 | counter = null, 71 | guides = [ 72 | '

Hello guide 1

', 73 | '

Hello guide 2

', 74 | '

Hello guide 3

', 75 | ].join('\n'); 76 | 77 | //set a custom cookie 78 | document.cookie = 'csrf-token-custom=123456789;path=/;'; 79 | 80 | //add the guide items to the dom 81 | document.querySelector('.django-user-guide-html-wrapper').innerHTML = guides; 82 | 83 | //mock async methods 84 | spyOn(dug, 'post'); 85 | 86 | //run the user guide 87 | dug.run(); 88 | 89 | //examine the result 90 | items = queryAllDom('.django-user-guide-item'); 91 | btns = queryAllDom('button'); 92 | cont = queryAllDom('.django-user-guide'); 93 | counter = queryAllDom('.django-user-guide-counter span')[0]; 94 | 95 | expect(getRenderedStyle(items[0], 'display')).toBe('block'); //should show the first item 96 | expect(getRenderedStyle(items[1], 'display')).toBe('none'); //should NOT show the second item 97 | expect(getRenderedStyle(items[2], 'display')).toBe('none'); //should NOT show the third item 98 | expect(getRenderedStyle(btns[0], 'display')).toBe('none'); //should NOT show the back button 99 | expect(getRenderedStyle(btns[1], 'display')).toBe('inline-block'); //should show the next button 100 | expect(getRenderedStyle(btns[2], 'display')).toBe('none'); //should NOT show the done button 101 | expect(counter.innerHTML).toBe('Tip 1 of 3'); 102 | 103 | //click the next button 104 | dug.onNextClick(); 105 | 106 | expect(dug.post).toHaveBeenCalledWith( 107 | '/user-guide/seen/', {'is_finished': true, id: 1} 108 | ); //should make a PUT request 109 | expect(getRenderedStyle(items[0], 'display')).toBe('none'); //should NOT show the first item 110 | expect(getRenderedStyle(items[1], 'display')).toBe('block'); //should show the second item 111 | expect(getRenderedStyle(items[2], 'display')).toBe('none'); //should NOT show the third item 112 | expect(getRenderedStyle(btns[0], 'display')).toBe('inline-block'); //should show the back button 113 | expect(getRenderedStyle(btns[1], 'display')).toBe('inline-block'); //should show the next button 114 | expect(getRenderedStyle(btns[2], 'display')).toBe('none'); //should NOT show the done button 115 | expect(counter.innerHTML).toBe('Tip 2 of 3'); 116 | 117 | //click the next button 118 | dug.onNextClick(); 119 | 120 | expect(dug.post).toHaveBeenCalledWith( 121 | '/user-guide/seen/', {'is_finished': true, id: 2} 122 | ); //should make a PUT request 123 | expect(getRenderedStyle(items[0], 'display')).toBe('none'); //should NOT show the first item 124 | expect(getRenderedStyle(items[1], 'display')).toBe('none'); //should NOT show the second item 125 | expect(getRenderedStyle(items[2], 'display')).toBe('block'); //should show the third item 126 | expect(getRenderedStyle(btns[0], 'display')).toBe('inline-block'); //should show the back button 127 | expect(getRenderedStyle(btns[1], 'display')).toBe('none'); //should NOT show the next button 128 | expect(getRenderedStyle(btns[2], 'display')).toBe('inline-block'); //should show the done button 129 | expect(counter.innerHTML).toBe('Tip 3 of 3'); 130 | 131 | //click the back button 132 | dug.onBackClick(); 133 | 134 | expect(getRenderedStyle(items[0], 'display')).toBe('none'); //should NOT show the first item 135 | expect(getRenderedStyle(items[1], 'display')).toBe('block'); //should show the second item 136 | expect(getRenderedStyle(items[2], 'display')).toBe('none'); //should NOT show the third item 137 | expect(getRenderedStyle(btns[0], 'display')).toBe('inline-block'); //should show the back button 138 | expect(getRenderedStyle(btns[1], 'display')).toBe('inline-block'); //should show the next button 139 | expect(getRenderedStyle(btns[2], 'display')).toBe('none'); //should NOT show the done button 140 | expect(counter.innerHTML).toBe('Tip 2 of 3'); 141 | 142 | //close the window 143 | dug.onCloseClick(); 144 | expect(getRenderedStyle(cont[0], 'display')).toBe('none'); 145 | }); 146 | 147 | it('should handle one item', function() { 148 | var dug = new window.DjangoUserGuide(), 149 | items = null, 150 | btns = null, 151 | cont = null, 152 | counter = null, 153 | guides = [ 154 | '

Hello guide 23

', 155 | ].join('\n'); 156 | 157 | //add the guide items to the dom 158 | document.querySelector('.django-user-guide-html-wrapper').innerHTML = guides; 159 | 160 | //mock async methods 161 | spyOn(dug, 'post'); 162 | 163 | //run the user guide 164 | dug.run(); 165 | 166 | //examine the result 167 | items = queryAllDom('.django-user-guide-item'); 168 | btns = queryAllDom('button'); 169 | cont = queryAllDom('.django-user-guide'); 170 | counter = queryAllDom('.django-user-guide-counter span')[0]; 171 | 172 | expect(getRenderedStyle(items[0], 'display')).toBe('block'); //should show the first item 173 | expect(getRenderedStyle(btns[2], 'display')).toBe('inline-block'); //should show the done button 174 | expect(counter.innerHTML).toBe('Tip 1 of 1'); 175 | 176 | //click done on the window 177 | dug.onDoneClick(); 178 | expect(dug.post).toHaveBeenCalledWith( 179 | '/user-guide/seen/', {'is_finished': true, id: 23} 180 | ); //should make a PUT request 181 | expect(getRenderedStyle(cont[0], 'display')).toBe('none'); 182 | }); 183 | 184 | it('should handle cookies instead of posts', function() { 185 | var dug = new window.DjangoUserGuide({ 186 | useCookies: true 187 | }), 188 | items = null, 189 | btns = null, 190 | cont = null, 191 | counter = null, 192 | guides = [ 193 | '

Hello guide 23

', 194 | '

Hello guide 24

', 195 | ].join('\n'); 196 | 197 | //add the guide items to the dom 198 | document.querySelector('.django-user-guide-html-wrapper').innerHTML = guides; 199 | 200 | //mock async methods 201 | spyOn(dug, 'post'); 202 | 203 | //run the user guide 204 | dug.run(); 205 | 206 | //examine the result 207 | items = queryAllDom('.django-user-guide-item'); 208 | btns = queryAllDom('button'); 209 | cont = queryAllDom('.django-user-guide'); 210 | counter = queryAllDom('.django-user-guide-counter span')[0]; 211 | 212 | expect(getRenderedStyle(items[0], 'display')).toBe('block'); //should show the first item 213 | expect(getRenderedStyle(btns[1], 'display')).toBe('inline-block'); //should show the next button 214 | expect(getRenderedStyle(btns[2], 'display')).toBe('none'); //should not show the done button 215 | expect(counter.innerHTML).toBe('Tip 1 of 2'); 216 | 217 | //click on the next button 218 | dug.onNextClick(); 219 | expect(dug.post).not.toHaveBeenCalled(); //should NOT make a PUT request 220 | expect(dug.getCookie('django-user-guide-23')).not.toBeNull(); //should have set a cookie for guide 23 221 | 222 | //click done on the window 223 | dug.onDoneClick(); 224 | expect(dug.post).not.toHaveBeenCalled(); //should NOT make a PUT request 225 | expect(dug.getCookie('django-user-guide-24')).not.toBeNull(); //should have set a cookie for guide 24 226 | expect(getRenderedStyle(cont[0], 'display')).toBe('none'); 227 | 228 | //a new user guide using cookies should get no items 229 | dug = new window.DjangoUserGuide({ 230 | useCookies: true 231 | }); 232 | expect(dug.getItems().length).toBe(0); 233 | 234 | //a new user guide not using cookies should get items 235 | dug = new window.DjangoUserGuide({ 236 | useCookies: false 237 | }); 238 | expect(dug.getItems().length).toBe(2); 239 | 240 | //clear the cookies 241 | document.cookie = 'django-user-guide-23=;path=/;expires=Thu, 01 Jan 1970 00:00:01 GMT;'; 242 | document.cookie = 'django-user-guide-24=;path=/;expires=Thu, 01 Jan 1970 00:00:01 GMT;'; 243 | 244 | //a new user guide using cookies should get 2 items 245 | dug = new window.DjangoUserGuide({ 246 | useCookies: true 247 | }); 248 | expect(dug.getItems().length).toBe(2); 249 | 250 | //clean up the the cookies 251 | document.cookie = 'django-user-guide-23=;path=/;expires=Thu, 01 Jan 1970 00:00:01 GMT;'; 252 | document.cookie = 'django-user-guide-24=;path=/;expires=Thu, 01 Jan 1970 00:00:01 GMT;'; 253 | }); 254 | 255 | it('should handle no items', function() { 256 | var dug = new window.DjangoUserGuide(), 257 | cont = null; 258 | 259 | //mock async methods 260 | spyOn(dug, 'post'); 261 | 262 | //run the user guide 263 | dug.run(); 264 | 265 | //examine the result 266 | cont = queryAllDom('.django-user-guide'); 267 | 268 | expect(getRenderedStyle(cont[0], 'display')).toBe('none'); //should not show the guide 269 | 270 | //buttons exist, but don't do anything 271 | expect(dug.getBackBtn()).not.toBeUndefined(); 272 | expect(dug.getNextBtn()).not.toBeUndefined(); 273 | expect(dug.getDoneBtn()).not.toBeUndefined(); 274 | expect(dug.getCloseDiv()).not.toBeUndefined(); 275 | expect(dug.getGuideMask()).not.toBeUndefined(); 276 | expect(getRenderedStyle(dug.getBackBtn(), 'display')).toBe('none'); 277 | expect(getRenderedStyle(dug.getDoneBtn(), 'display')).toBe('none'); 278 | expect(getRenderedStyle(dug.getNextBtn(), 'display')).toBe('none'); 279 | expect(getRenderedStyle(dug.getGuideMask(), 'display')).toBe('block'); //hidden by parent 280 | expect(getRenderedStyle(dug.getCloseDiv(), 'display')).toBe('block'); //hidden by parent 281 | 282 | //none of the events should cause trouble 283 | dug.onBackClick(); 284 | dug.onNextClick(); 285 | dug.onDoneClick(); 286 | dug.onCloseClick(); 287 | dug.onMaskClick(getFakeEvt()); 288 | dug.onMaskClick(getFakeEvt('django-user-guide-mask')); 289 | 290 | expect(getRenderedStyle(cont[0], 'display')).toBe('none'); //should still be hidden 291 | }); 292 | 293 | it('should return empty csrf token', function() { 294 | var dug = new window.DjangoUserGuide(); 295 | 296 | expect(dug.getCsrfToken()).toBe(''); 297 | }); 298 | 299 | it('should post data to a given url', function() { 300 | var dug = new window.DjangoUserGuide(), 301 | sendData = { 302 | id: 1, 303 | 'is_finished': true 304 | }, 305 | input = ''; 306 | 307 | //mock async methods and setRequestHeader 308 | spyOn(XMLHttpRequest.prototype, 'send'); 309 | spyOn(XMLHttpRequest.prototype, 'setRequestHeader'); 310 | 311 | dug.post('/', sendData); 312 | expect(XMLHttpRequest.prototype.setRequestHeader).toHaveBeenCalledWith( 313 | 'Content-Type', 314 | 'application/x-www-form-urlencoded; charset=UTF-8' 315 | ); 316 | expect(XMLHttpRequest.prototype.send).toHaveBeenCalledWith('id=1&is_finished=true'); 317 | 318 | //make sure a csrf token can be set 319 | dug = new window.DjangoUserGuide(); 320 | 321 | //add the csrf token to the html 322 | document.querySelector('.django-user-guide-html-wrapper').innerHTML = input; 323 | 324 | dug.post('/', sendData); 325 | expect(XMLHttpRequest.prototype.setRequestHeader).toHaveBeenCalledWith('X-CSRFToken', '1234'); 326 | expect(XMLHttpRequest.prototype.send).toHaveBeenCalledWith('id=1&is_finished=true'); 327 | }); 328 | }); 329 | -------------------------------------------------------------------------------- /user_guide/templates/user_guide/window.html: -------------------------------------------------------------------------------- 1 |
2 | {{ csrf_node }} 3 | 4 | 5 | {% if custom_css_href %} 6 | 7 | {% endif %} 8 | 9 |
10 |
11 |
x
12 | 13 |
14 | {{ html|safe }} 15 |
16 | 17 |
18 | Tip 1 of 1 19 |
20 |
21 | 22 | 23 | 24 |
25 |
26 |
27 | 28 | 29 | 30 | {% if custom_js_src %} 31 | 32 | {% endif %} 33 | 34 | 40 |
41 | -------------------------------------------------------------------------------- /user_guide/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ambitioninc/django-user-guide/803258e4e31a1db351d5c050ce655c354e4d7888/user_guide/templatetags/__init__.py -------------------------------------------------------------------------------- /user_guide/templatetags/user_guide_tags.py: -------------------------------------------------------------------------------- 1 | """ 2 | Template tag for displaying user guides. 3 | """ 4 | import re 5 | 6 | from django import template 7 | from django.conf import settings 8 | from django.template import loader 9 | from django.template.defaulttags import CsrfTokenNode 10 | 11 | from user_guide.models import GuideInfo 12 | 13 | 14 | register = template.Library() 15 | 16 | # The maximum number of guides to show per page 17 | USER_GUIDE_SHOW_MAX = getattr(settings, 'USER_GUIDE_SHOW_MAX', 10) 18 | 19 | # Use cookies to determine if guides should be shown 20 | USER_GUIDE_USE_COOKIES = getattr(settings, 'USER_GUIDE_USE_COOKIES', False) 21 | 22 | # The url to any custom CSS 23 | USER_GUIDE_CSS_URL = getattr( 24 | settings, 25 | 'USER_GUIDE_CSS_URL', 26 | None 27 | ) 28 | 29 | # The url to any custom JS 30 | USER_GUIDE_JS_URL = getattr( 31 | settings, 32 | 'USER_GUIDE_JS_URL', 33 | None 34 | ) 35 | 36 | 37 | @register.simple_tag(takes_context=True) 38 | def user_guide(context, *args, **kwargs): 39 | """ 40 | Creates html items for all appropriate user guides. 41 | 42 | Kwargs: 43 | guide_name: A string name of a specific guide. 44 | guide_tags: An array of string guide tags. 45 | limit: An integer maxmimum number of guides to show at a single time. 46 | 47 | Returns: 48 | An html string containing the user guide scaffolding and any guide html. 49 | """ 50 | user = context['request'].user if 'request' in context and hasattr(context['request'], 'user') else None 51 | 52 | if user and user.is_authenticated(): # No one is logged in 53 | limit = kwargs.get('limit', USER_GUIDE_SHOW_MAX) 54 | filters = { 55 | 'user': user, 56 | 'is_finished': False 57 | } 58 | 59 | # Handle special filters 60 | if kwargs.get('guide_name'): 61 | filters['guide__guide_name'] = kwargs.get('guide_name') 62 | if kwargs.get('guide_tags'): 63 | filters['guide__guide_tag__in'] = kwargs.get('guide_tags') 64 | 65 | # Set the html 66 | html = ''.join(( 67 | '
{1}
'.format( 68 | guide_info.id, 69 | guide_info.guide.html 70 | ) for guide_info in GuideInfo.objects.select_related('guide').filter(**filters).only('guide')[:limit] 71 | )) 72 | 73 | # Return the rendered template with the guide html 74 | return loader.render_to_string('user_guide/window.html', { 75 | 'html': re.sub(r'\{\s*static\s*\}', settings.STATIC_URL, html), 76 | 'css_href': '{0}user_guide/build/django-user-guide.css'.format(settings.STATIC_URL), 77 | 'js_src': '{0}user_guide/build/django-user-guide.js'.format(settings.STATIC_URL), 78 | 'custom_css_href': USER_GUIDE_CSS_URL, 79 | 'custom_js_src': USER_GUIDE_JS_URL, 80 | 'use_cookies': str(USER_GUIDE_USE_COOKIES).lower(), 81 | 'csrf_node': CsrfTokenNode().render(context) 82 | }) 83 | else: 84 | return '' 85 | -------------------------------------------------------------------------------- /user_guide/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ambitioninc/django-user-guide/803258e4e31a1db351d5c050ce655c354e4d7888/user_guide/tests/__init__.py -------------------------------------------------------------------------------- /user_guide/tests/admin_tests.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin.sites import AdminSite 2 | from django.test import TestCase 3 | from django_dynamic_fixture import G, F 4 | 5 | from user_guide.admin import GuideAdmin, GuideInfoAdmin 6 | from user_guide.models import Guide, GuideInfo 7 | 8 | 9 | class AdminTest(TestCase): 10 | def setUp(self): 11 | super(AdminTest, self).setUp() 12 | self.site = AdminSite() 13 | 14 | def test_user_guide_admin(self): 15 | guide_admin = GuideAdmin(Guide, self.site) 16 | self.assertEqual(guide_admin.list_display, ('guide_name', 'guide_tag', 'guide_importance', 'creation_time')) 17 | 18 | def test_user_guide_info_admin(self): 19 | guide_info_admin = GuideInfoAdmin(GuideInfo, self.site) 20 | guide_info_obj = G(GuideInfo, guide=F(guide_name='test_name')) 21 | self.assertEqual(guide_info_admin.list_display, ('user', 'guide_name', 'is_finished', 'finished_time')) 22 | self.assertEqual(guide_info_admin.guide_name(guide_info_obj), 'test_name') 23 | -------------------------------------------------------------------------------- /user_guide/tests/model_tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django_dynamic_fixture import G 3 | 4 | from user_guide.models import Guide 5 | 6 | 7 | class GuideTest(TestCase): 8 | 9 | def test_guide_unicode(self): 10 | guide_obj = G(Guide, guide_name='test_name') 11 | self.assertEqual(str(guide_obj), 'test_name') 12 | -------------------------------------------------------------------------------- /user_guide/tests/no_admin_tests.py: -------------------------------------------------------------------------------- 1 | import six 2 | if six.PY3: # pragma: no cover 3 | from importlib import reload 4 | 5 | from django.conf import settings 6 | from django.test import TestCase 7 | 8 | from user_guide import admin 9 | 10 | 11 | class NoAdminTest(TestCase): 12 | """ 13 | Tests loading of the admin module when django.contrib.admin is not installed. 14 | """ 15 | def test_no_admin(self): 16 | with self.settings(INSTALLED_APPS=[app for app in settings.INSTALLED_APPS if app != 'django.contrib.admin']): 17 | reload(admin) 18 | self.assertIsNotNone(admin) 19 | -------------------------------------------------------------------------------- /user_guide/tests/templatetag_tests.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.http import HttpRequest 3 | from django.template import Context, Template 4 | from django.test import TestCase 5 | 6 | from user_guide.models import Guide, GuideInfo 7 | 8 | 9 | class TemplateTagTest(TestCase): 10 | def setUp(self): 11 | super(TemplateTagTest, self).setUp() 12 | 13 | self.users = [ 14 | User.objects.create_user( 15 | username='test{0}@test.com'.format(i), 16 | email='test{0}@test.com'.format(i), 17 | password='test' 18 | ) 19 | for i in range(0, 5) 20 | ] 21 | 22 | self.guides = [ 23 | Guide.objects.create( 24 | html='
Hello test {0}!
'.format(i), 25 | guide_name='Test Guide {0}'.format(i), 26 | guide_type='Window', 27 | guide_tag='tag{0}'.format(i), 28 | guide_importance=i 29 | ) 30 | for i in range(0, 10) 31 | ] 32 | 33 | def test_user_guide_tags_no_user(self): 34 | t = Template('{% load user_guide_tags %}{% user_guide %}') 35 | c = Context({}) 36 | self.assertEqual('', t.render(c)) 37 | 38 | def test_user_guide_tags_no_filters(self): 39 | t = Template('{% load user_guide_tags %}{% user_guide %}') 40 | r = HttpRequest() 41 | r.user = self.users[0] 42 | c = Context({ 43 | 'request': r, 44 | 'csrf_token': '1234' 45 | }) 46 | 47 | # Create an info for each guide 48 | guide_infos = [GuideInfo.objects.create(user=self.users[0], guide=guide) for guide in self.guides] 49 | 50 | # Render the template 51 | rendered = t.render(c) 52 | 53 | # Make sure the correct guides show up, guide_order should apply here 54 | self.assertTrue('' in rendered) 55 | self.assertTrue('Hello test 9!' in rendered) 56 | self.assertTrue('data-guide="{0}"'.format(guide_infos[9].id) in rendered) 57 | self.assertTrue('Hello test 8!' in rendered) 58 | self.assertTrue('data-guide="{0}"'.format(guide_infos[8].id) in rendered) 59 | self.assertTrue('Hello test 7!' in rendered) 60 | self.assertTrue('data-guide="{0}"'.format(guide_infos[7].id) in rendered) 61 | self.assertTrue('Hello test 6!' in rendered) 62 | self.assertTrue('data-guide="{0}"'.format(guide_infos[6].id) in rendered) 63 | self.assertTrue('Hello test 5!' in rendered) 64 | self.assertTrue('data-guide="{0}"'.format(guide_infos[5].id) in rendered) 65 | self.assertTrue('Hello test 4!' not in rendered) # Should not have rendered 6 guides 66 | self.assertTrue('Hello test 3!' not in rendered) # Should not have rendered 7 guides 67 | self.assertTrue('Hello test 2!' not in rendered) # Should not have rendered 8 guides 68 | self.assertTrue('Hello test 1!' not in rendered) # Should not have rendered 9 guides 69 | self.assertTrue('Hello test 0!' not in rendered) # Should not have rendered 10 guides 70 | self.assertTrue('django-user-guide.css' in rendered) # Should have django-user-guide style sheet 71 | self.assertTrue('django-user-guide.js' in rendered) # Should have django-user-guide script 72 | self.assertTrue('custom-style.css' in rendered) # Should have custom style sheet 73 | self.assertTrue('custom-script.js' in rendered) # Should have custom script 74 | self.assertTrue('' in rendered) # Should have expanded {static} 75 | 76 | def test_user_guide_tags_guide_name_filter(self): 77 | t = Template('{% load user_guide_tags %}{% user_guide guide_name=guide_name %}') 78 | r = HttpRequest() 79 | r.user = self.users[0] 80 | c = Context({ 81 | 'request': r, 82 | 'guide_name': 'Test Guide 1' 83 | }) 84 | 85 | # create a guide info 86 | GuideInfo.objects.create(user=self.users[0], guide=self.guides[1]) 87 | 88 | # render the template 89 | rendered = t.render(c) 90 | 91 | self.assertTrue('
Hello test 1!' in rendered) 92 | 93 | def test_user_guide_tags_guide_tag_filter(self): 94 | t = Template('{% load user_guide_tags %}{% user_guide guide_tags=guide_tags %}') 95 | r = HttpRequest() 96 | r.user = self.users[0] 97 | c = Context({ 98 | 'request': r, 99 | 'guide_tags': ['tag0', 'tag1'], 100 | 'csrf_token': '1234' 101 | }) 102 | 103 | # create a few guide infos 104 | GuideInfo.objects.create(user=self.users[0], guide=self.guides[0]) 105 | GuideInfo.objects.create(user=self.users[0], guide=self.guides[1]) 106 | 107 | # render the template 108 | rendered = t.render(c) 109 | 110 | self.assertTrue('' in rendered) 111 | self.assertTrue('
Hello test 0!' in rendered) 112 | self.assertTrue('
Hello test 1!' in rendered) 113 | -------------------------------------------------------------------------------- /user_guide/tests/urls_tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from user_guide import urls 4 | 5 | 6 | class UrlsTest(TestCase): 7 | def test_that_urls_are_defined(self): 8 | """ 9 | Should have several urls defined. 10 | """ 11 | self.assertEqual(len(urls.urlpatterns), 1) 12 | self.assertEqual(urls.urlpatterns[0].name, 'user_guide.seen') 13 | -------------------------------------------------------------------------------- /user_guide/tests/view_tests.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.test import TestCase 3 | from django_dynamic_fixture import G 4 | from mock import Mock 5 | 6 | from user_guide import models, views 7 | 8 | 9 | class GuideSeenTest(TestCase): 10 | 11 | def test_post(self): 12 | user = G(User) 13 | guide_info = G(models.GuideInfo, user=user) 14 | request = Mock(user=user, POST={ 15 | 'id': guide_info.id, 16 | 'is_finished': True 17 | }) 18 | view = views.GuideSeenView() 19 | 20 | self.assertEqual(view.post(request).status_code, 200) 21 | self.assertTrue(models.GuideInfo.objects.get(id=guide_info.id).is_finished) 22 | 23 | def test_post_wrong_user(self): 24 | user1 = G(User) 25 | user2 = G(User) 26 | guide_info = G(models.GuideInfo, user=user1) 27 | request = Mock(user=user2, POST={ 28 | 'id': guide_info.id, 29 | 'is_finished': True 30 | }) 31 | view = views.GuideSeenView() 32 | 33 | self.assertEqual(view.post(request).status_code, 200) 34 | self.assertFalse(models.GuideInfo.objects.get(id=guide_info.id).is_finished) 35 | -------------------------------------------------------------------------------- /user_guide/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, url 2 | 3 | from user_guide import views 4 | 5 | 6 | urlpatterns = patterns( 7 | '', 8 | url(r'^seen/?$', views.GuideSeenView.as_view(), name='user_guide.seen') 9 | ) 10 | -------------------------------------------------------------------------------- /user_guide/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.11.0' 2 | -------------------------------------------------------------------------------- /user_guide/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.views.generic import View 3 | 4 | from user_guide import models 5 | 6 | 7 | class GuideSeenView(View): 8 | def post(self, request): 9 | guide_id = request.POST.get('id', 0) 10 | is_finished = request.POST.get('is_finished', False) 11 | guide_info = models.GuideInfo.objects.get(id=guide_id) 12 | 13 | if guide_info and guide_info.user.id == request.user.id and is_finished: 14 | guide_info.is_finished = True 15 | guide_info.save() 16 | 17 | return HttpResponse(status=200) 18 | --------------------------------------------------------------------------------