├── .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 └── tour ├── __init__.py ├── api.py ├── apps.py ├── filters.py ├── migrations ├── 0001_initial.py └── __init__.py ├── models.py ├── serializers.py ├── static └── tour │ ├── .editorconfig │ ├── .jscs.json │ ├── .jshintrc │ ├── .yo-rc.json │ ├── build │ ├── tour.css │ └── tour.js │ ├── gruntfile.js │ ├── package.json │ ├── src │ └── tour │ │ ├── tests │ │ └── tour_tests.js │ │ └── tour.js │ └── style │ └── tour.styl ├── templates └── tour │ └── tour_navigation.html ├── templatetags ├── __init__.py └── tour_tags.py ├── tests ├── __init__.py ├── api_tests.py ├── mocks.py ├── model_tests.py ├── serializer_tests.py ├── templatetag_tests.py ├── tour_tests.py ├── url_tests.py └── view_tests.py ├── tours.py ├── urls.py ├── version.py └── views.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | omit = 4 | */migrations/* 5 | tour/version.py 6 | tour/apps.py 7 | source = tour 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 | # Pycharm 9 | .idea 10 | 11 | # Coverage files 12 | .coverage 13 | 14 | # Setuptools distribution folder. 15 | /dist/ 16 | /build/ 17 | 18 | # Python egg metadata, regenerated from source files by setuptools. 19 | /*.egg-info 20 | /*.egg 21 | .eggs/ 22 | 23 | # Virtual environment 24 | env/ 25 | venv/ 26 | 27 | # Js Stuff 28 | node_modules/ 29 | npm-debug.log 30 | .grunt/ 31 | .tmp/ 32 | _SpecRunner.html 33 | html-report/ 34 | 35 | .yo-rc.json 36 | 37 | .coverage 38 | htmlcov 39 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - '2.7' 5 | - '3.4' 6 | env: 7 | global: 8 | - DB=postgres 9 | - NOSE_NOLOGCAPTURE=1 10 | matrix: 11 | - DJANGO=">=1.7,<1.8" 12 | - DJANGO=">=1.8,<1.9" 13 | install: 14 | - pip install -q coverage flake8 Django$DJANGO django-nose>=1.4 15 | before_script: 16 | - psql -c 'CREATE DATABASE tour;' -U postgres 17 | script: 18 | - flake8 . 19 | - (cd tour/static/tour && 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-tour/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-tour.git 15 | cd django-tour 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 tour.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 tour/version.py 61 | * Add a change note in README.md 62 | * Git tag the version 63 | * Upload to pypi: 64 | ```bash 65 | pip install wheel 66 | python setup.py sdist bdist_wheel upload 67 | ``` 68 | 69 | ## Vulnerability Reporting 70 | 71 | For any security issues, please do NOT file an issue or pull request on GitHub! 72 | Please contact [security@ambition.com](mailto:security@ambition.com) with the 73 | GPG key provided on [Ambition's website](http://ambition.com/security/). 74 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | Wes Okes (wes.okes@gmail.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 LICENSE 3 | recursive-include tour/static/tour/build * 4 | recursive-include tour/templates * 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/ambitioninc/django-tour.png)](https://travis-ci.org/ambitioninc/django-tour) 2 | ## Django Tour 3 | 4 | Django Tour is a `django>=1.6` app that helps navigate a user through a series of pages and ensures that 5 | each step is successfully completed. A template tag is available to show the user the current progress 6 | through the tour by showing a simple UI. This UI can be styled and modified to suit different display scenarios. 7 | A single tour can be assigned to any number of users, and the completion of the steps can be per user or shared. 8 | 9 | ## Table of Contents 10 | 11 | 1. [Installation] (#installation) 12 | 1. [Creating a Tour] (#creating-a-tour) 13 | 1. [Displaying the Navigation] (#displaying-the-navigation) 14 | 1. [Changes](#changes) 15 | 16 | ## Installation 17 | To install Django Tour: 18 | 19 | ```shell 20 | pip install git+https://github.com/ambitioninc/django-tour.git 21 | ``` 22 | 23 | Add Django Tour to your `INSTALLED_APPS` to get started: 24 | 25 | settings.py 26 | 27 | ```python 28 | # Simply add 'tour' to your installed apps. 29 | # Django Tour relies on several basic django apps. 30 | INSTALLED_APPS = ( 31 | 'django.contrib.auth', 32 | 'django.contrib.admin', 33 | 'django.contrib.sites', 34 | 'django.contrib.sessions', 35 | 'django.contrib.messages', 36 | 'django.contrib.staticfiles', 37 | 'django.contrib.contenttypes', 38 | 'tour', 39 | ) 40 | ``` 41 | 42 | Make sure Django's CsrfViewMiddleware is enabled: 43 | 44 | settings.py 45 | 46 | ```python 47 | MIDDLEWARE_CLASSES = ( 48 | 'django.middleware.csrf.CsrfViewMiddleware', 49 | ) 50 | ``` 51 | 52 | Add Django Tour's urls to your project: 53 | 54 | urls.py 55 | 56 | ```python 57 | from django.conf.urls import include, patterns, url 58 | 59 | urlpatterns = patterns( 60 | url(r'^tour/', include('tour.urls')), 61 | ) 62 | ``` 63 | 64 | ## Creating a Tour 65 | 66 | Any app that wants to define a tour should first create a tours.py file. This is where all of the custom 67 | logic will be contained. Start off by defining the steps needed for the tour; these steps should inherit from 68 | `BaseStep`. 69 | 70 | ##### `step_class` 71 | The full python path to the class 72 | 73 | ##### `name` 74 | The display name that will be used for this step of the tour. 75 | 76 | 77 | ```python 78 | from tour.tours import BaseStep, BaseTour 79 | 80 | 81 | class FirstStep(BaseStep): 82 | step_class = 'path.to.FirstStep' 83 | name = 'First Step' 84 | 85 | @classmethod 86 | def get_url(cls): 87 | return reverse('example.first_step') 88 | 89 | def is_complete(self, user=None): 90 | return some_method(user) 91 | 92 | 93 | class SecondStep(BaseStep): 94 | step_class = 'path.to.SecondStep' 95 | name = 'Second Step' 96 | 97 | @classmethod 98 | def get_url(cls): 99 | return reverse('example.second_step') 100 | 101 | def is_complete(self, user=None): 102 | return some_other_method(user) 103 | ``` 104 | 105 | Next, set up the tour class to contain these steps. The tour should inherit from `BaseTour` and a few attributes 106 | need to be set. 107 | 108 | ##### `tour_class` 109 | The python path to the tour class 110 | 111 | ##### `name` 112 | The display name that will be used in the tour UI 113 | 114 | ##### `steps` 115 | A list of step classes in the order they need to be completed 116 | 117 | ##### `complete_url` 118 | The url that will be returned when calling `get_next_url` after the tour is considered complete 119 | 120 | ```python 121 | class ExampleTour(BaseTour): 122 | tour_class = 'path.to.ExampleTour' 123 | name = 'Example Tour' 124 | complete_url = '/page/finished/' 125 | steps = [ 126 | FirstStep, 127 | SecondStep, 128 | ] 129 | ``` 130 | 131 | It is up to your application code to determine when a user should be assigned a tour. 132 | 133 | ```python 134 | from django.contrib.auth.models import User 135 | 136 | from path.to import ExampleTour 137 | 138 | 139 | user = User.objects.get(id=1) 140 | ExampleTour.add_user(user) 141 | ``` 142 | 143 | This will create a `TourStatus` instance linking `user` to the `ExampleTour` with `complete` set to False. The 144 | `add_user` method will automatically call `ExampleTour.create()` if there isn't already a tour record. The 145 | `create` method takes care of making records for each of the steps as well. 146 | 147 | ## Displaying the Navigation 148 | 149 | In your django template all you need to do is load the tour tags with `{% load tour_tags %}` then put the 150 | `{% tour_navigation %}` tag where it should appear. When the user loads the template, a check will be performed 151 | to see if the user has any incomplete tours. If there is a tour, the navigation will be displayed. 152 | 153 | If it makes sense to always display the tour navigation even after the final step is complete, then pass the 154 | always_show argument to the tour tag `{% tour_navigation always_show=True %}` 155 | 156 | ## Restricting View Access 157 | 158 | If the order of step completion is important for a tour, the view mixin `TourStepMixin` can be added to any 159 | django view that is part of the tour. The step is identified by the url of the view and if the user 160 | tries to access a page out of order, they will be redirected to the first incomplete step of the tour. 161 | Once a tour has been completed, the user will also be prevented from visiting other steps that inherit 162 | form the `TourStepMixin` in the tour. 163 | 164 | ```python 165 | class MyView(TourStepMixin, TemplateView): 166 | """ view config """ 167 | ``` 168 | 169 | # Changes 170 | 171 | - 0.6.4 172 | - Updated to be DRF 3.1 compatible 173 | - Dropped Django 1.6 support 174 | - 0.6.3 175 | - Updated to `Tour` and `TourStatus` models Foreign Key to `settings.AUTH_USER_MODEL` 176 | - 0.6.2 177 | - Added Django 1.7 support 178 | -------------------------------------------------------------------------------- /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 | from settings import configure_settings 9 | 10 | # Configure the default settings and setup django 11 | configure_settings() 12 | django.setup() 13 | 14 | # Django nose must be imported here since it depends on the settings being configured 15 | from django_nose import NoseTestSuiteRunner 16 | 17 | 18 | def run_tests(*test_args, **kwargs): 19 | if not test_args: 20 | test_args = ['tour'] 21 | 22 | kwargs.setdefault('interactive', False) 23 | 24 | test_runner = NoseTestSuiteRunner(**kwargs) 25 | 26 | failures = test_runner.run_tests(test_args) 27 | sys.exit(failures) 28 | 29 | 30 | if __name__ == '__main__': 31 | parser = OptionParser() 32 | parser.add_option('--verbosity', dest='verbosity', action='store', default=1, type=int) 33 | (options, args) = parser.parse_args() 34 | 35 | run_tests(*args, **options.__dict__) 36 | -------------------------------------------------------------------------------- /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': 'tour', 23 | } 24 | else: 25 | raise RuntimeError('Unsupported test DB {0}'.format(test_db)) 26 | 27 | settings.configure( 28 | MIDDLEWARE_CLASSES=(), 29 | DATABASES={ 30 | 'default': db_config, 31 | }, 32 | INSTALLED_APPS=( 33 | 'django.contrib.auth', 34 | 'django.contrib.contenttypes', 35 | 'django.contrib.sessions', 36 | 'django.contrib.admin', 37 | 'tour', 38 | 'tour.tests', 39 | ), 40 | ROOT_URLCONF='tour.urls', 41 | DEBUG=False, 42 | ) 43 | -------------------------------------------------------------------------------- /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 multiprocessing to avoid this bug (http://bugs.python.org/issue15881#msg170215) 2 | import multiprocessing 3 | assert multiprocessing 4 | import re 5 | from setuptools import setup, find_packages 6 | 7 | 8 | def get_version(): 9 | """ 10 | Extracts the version number from the version.py file. 11 | """ 12 | VERSION_FILE = 'tour/version.py' 13 | mo = re.search(r'^__version__ = [\'"]([^\'"]*)[\'"]', open(VERSION_FILE, 'rt').read(), re.M) 14 | if mo: 15 | return mo.group(1) 16 | else: 17 | raise RuntimeError('Unable to find version string in {0}.'.format(VERSION_FILE)) 18 | 19 | 20 | setup( 21 | name='django-tour', 22 | version=get_version(), 23 | description='Require the django user to complete a series of steps with custom logic', 24 | long_description=open('README.md').read(), 25 | url='https://github.com/ambitioninc/django-tour', 26 | author='Wes Okes', 27 | author_email='wes.okes@gmail.com', 28 | keywords='', 29 | packages=find_packages(), 30 | classifiers=[ 31 | 'Programming Language :: Python', 32 | 'Programming Language :: Python :: 2.7', 33 | 'Programming Language :: Python :: 3.4', 34 | 'Intended Audience :: Developers', 35 | 'License :: OSI Approved :: MIT License', 36 | 'Operating System :: OS Independent', 37 | 'Framework :: Django', 38 | 'Framework :: Django :: 1.7', 39 | 'Framework :: Django :: 1.8', 40 | ], 41 | license='MIT', 42 | install_requires=[ 43 | 'Django>=1.7', 44 | 'djangorestframework>=2.3.13', 45 | 'django-manager-utils>=0.8.2', 46 | 'django_filter>=0.7', 47 | ], 48 | tests_require=[ 49 | 'psycopg2', 50 | 'django-nose>=1.4', 51 | 'mock==1.0.1', 52 | 'django_dynamic_fixture', 53 | ], 54 | test_suite='run_tests.run_tests', 55 | include_package_data=True, 56 | ) 57 | -------------------------------------------------------------------------------- /tour/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .version import __version__ 3 | 4 | default_app_config = 'tour.apps.TourAppConfig' 5 | -------------------------------------------------------------------------------- /tour/api.py: -------------------------------------------------------------------------------- 1 | from rest_framework.authentication import SessionAuthentication 2 | from rest_framework.generics import ListAPIView 3 | from rest_framework.permissions import IsAuthenticated 4 | from tour.filters import TourFilter 5 | from tour.models import Tour 6 | from tour.serializers import TourSerializer 7 | 8 | 9 | class TourApiView(ListAPIView): 10 | serializer_class = TourSerializer 11 | filter_class = TourFilter 12 | permission_classes = (IsAuthenticated,) 13 | authentication_classes = (SessionAuthentication,) 14 | 15 | def get_queryset(self): 16 | Tour.objects.complete_tours(self.request.user) 17 | return Tour.objects.filter(tourstatus__user=self.request.user, tourstatus__complete=False) 18 | -------------------------------------------------------------------------------- /tour/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TourAppConfig(AppConfig): 5 | name = 'tour' 6 | verbose_name = 'Django Tour' 7 | -------------------------------------------------------------------------------- /tour/filters.py: -------------------------------------------------------------------------------- 1 | import django_filters 2 | 3 | from tour.models import Tour 4 | 5 | 6 | class TourFilter(django_filters.FilterSet): 7 | 8 | class Meta: 9 | model = Tour 10 | fields = ('name',) 11 | -------------------------------------------------------------------------------- /tour/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='Step', 17 | fields=[ 18 | ('id', models.AutoField(serialize=False, verbose_name='ID', primary_key=True, auto_created=True)), 19 | ('name', models.CharField(max_length=128, unique=True)), 20 | ('display_name', models.CharField(max_length=128)), 21 | ('url', models.CharField(max_length=128, blank=True, null=True)), 22 | ('step_class', models.CharField(max_length=128, unique=True)), 23 | ('sort_order', models.IntegerField(default=0)), 24 | ('parent_step', models.ForeignKey(related_name='steps', null=True, to='tour.Step')), 25 | ], 26 | options={ 27 | }, 28 | bases=(models.Model,), 29 | ), 30 | migrations.CreateModel( 31 | name='Tour', 32 | fields=[ 33 | ('id', models.AutoField(serialize=False, verbose_name='ID', primary_key=True, auto_created=True)), 34 | ('name', models.CharField(max_length=128, unique=True)), 35 | ('display_name', models.CharField(max_length=128)), 36 | ('tour_class', models.CharField(max_length=128, unique=True)), 37 | ('complete_url', models.CharField(max_length=128, blank=True, null=True, default=None)), 38 | ], 39 | options={ 40 | }, 41 | bases=(models.Model,), 42 | ), 43 | migrations.CreateModel( 44 | name='TourStatus', 45 | fields=[ 46 | ('id', models.AutoField(serialize=False, verbose_name='ID', primary_key=True, auto_created=True)), 47 | ('complete', models.BooleanField(default=False)), 48 | ('create_time', models.DateTimeField(auto_now_add=True)), 49 | ('complete_time', models.DateTimeField(blank=True, null=True, default=None)), 50 | ('tour', models.ForeignKey(to='tour.Tour')), 51 | ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), 52 | ], 53 | options={ 54 | }, 55 | bases=(models.Model,), 56 | ), 57 | migrations.AddField( 58 | model_name='tour', 59 | name='users', 60 | field=models.ManyToManyField(to=settings.AUTH_USER_MODEL, through='tour.TourStatus'), 61 | preserve_default=True, 62 | ), 63 | migrations.AddField( 64 | model_name='step', 65 | name='tour', 66 | field=models.ForeignKey(to='tour.Tour', related_name='steps'), 67 | preserve_default=True, 68 | ), 69 | ] 70 | -------------------------------------------------------------------------------- /tour/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ambitioninc/django-tour/f0181d71ebd6c66e11dd921ad5e602192fc621cc/tour/migrations/__init__.py -------------------------------------------------------------------------------- /tour/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | from django.utils.module_loading import import_by_path 4 | from manager_utils import ManagerUtilsManager 5 | import six 6 | 7 | 8 | class TourManager(ManagerUtilsManager): 9 | """ 10 | Provides extra functionality for the Tour model 11 | """ 12 | def complete_tours(self, user): 13 | """ 14 | Marks any completed tours as complete 15 | """ 16 | if not user.pk: 17 | return None 18 | tours = self.filter(tourstatus__user=user, tourstatus__complete=False) 19 | for tour in tours: 20 | tour_class = tour.load_tour_class() 21 | if tour_class.is_complete(user): 22 | tour_class.mark_complete(user) 23 | 24 | def get_for_user(self, user): 25 | """ 26 | Checks if a tour exists for a user and returns the tour instance 27 | """ 28 | if not user.pk: 29 | return None 30 | self.complete_tours(user) 31 | return self.filter(tourstatus__user=user, tourstatus__complete=False).first() 32 | 33 | def get_recent_tour(self, user): 34 | if not user.pk: 35 | return None 36 | return self.filter(tourstatus__user=user).order_by( 37 | 'tourstatus__complete', '-tourstatus__complete_time').first() 38 | 39 | def get_next_url(self, user): 40 | """ 41 | Convenience method to get the next url for the specified user 42 | """ 43 | if not user.pk: 44 | return None 45 | tour = self.get_for_user(user) 46 | if not tour: 47 | tour = self.get_recent_tour(user) 48 | if tour: 49 | return tour.load_tour_class().get_next_url(user) 50 | return None 51 | 52 | 53 | @six.python_2_unicode_compatible 54 | class Tour(models.Model): 55 | """ 56 | Container object for tour steps. Provides functionality for loading the tour logic class 57 | and fetching the steps in the correct order. 58 | """ 59 | name = models.CharField(max_length=128, unique=True) 60 | display_name = models.CharField(max_length=128) 61 | tour_class = models.CharField(max_length=128, unique=True) 62 | users = models.ManyToManyField(settings.AUTH_USER_MODEL, through='TourStatus') 63 | complete_url = models.CharField(max_length=128, blank=True, null=True, default=None) 64 | 65 | objects = TourManager() 66 | 67 | def load_tour_class(self): 68 | """ 69 | Imports and returns the tour class. 70 | :return: The tour class instance determined by `tour_class` 71 | :rtype: BaseTour 72 | """ 73 | return import_by_path(self.tour_class)(self) 74 | 75 | def __str__(self): 76 | return '{0}'.format(self.display_name) 77 | 78 | 79 | @six.python_2_unicode_compatible 80 | class Step(models.Model): 81 | """ 82 | Represents one step of the tour that must be completed. The custom logic is implemented 83 | in the class specified in step_class 84 | """ 85 | name = models.CharField(max_length=128, unique=True) 86 | display_name = models.CharField(max_length=128) 87 | url = models.CharField(max_length=128, null=True, blank=True) 88 | tour = models.ForeignKey(Tour, related_name='steps') 89 | parent_step = models.ForeignKey('self', null=True, related_name='steps') 90 | step_class = models.CharField(max_length=128, unique=True) 91 | sort_order = models.IntegerField(default=0) 92 | 93 | objects = ManagerUtilsManager() 94 | 95 | def load_step_class(self): 96 | """ 97 | Imports and returns the step class. 98 | """ 99 | return import_by_path(self.step_class)(self) 100 | 101 | def __str__(self): 102 | return '{0}'.format(self.display_name) 103 | 104 | 105 | class TourStatus(models.Model): 106 | """ 107 | This is the model that represents the relationship between a user and a tour. Keeps 108 | track of whether the tour has been completed by a user. 109 | """ 110 | tour = models.ForeignKey(Tour) 111 | user = models.ForeignKey(settings.AUTH_USER_MODEL) 112 | complete = models.BooleanField(default=False) 113 | create_time = models.DateTimeField(auto_now_add=True) 114 | complete_time = models.DateTimeField(null=True, blank=True, default=None) 115 | 116 | objects = ManagerUtilsManager() 117 | -------------------------------------------------------------------------------- /tour/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from tour.models import Tour, Step 3 | 4 | 5 | class TourSerializer(serializers.ModelSerializer): 6 | steps = serializers.SerializerMethodField() 7 | 8 | class Meta: 9 | model = Tour 10 | fields = ('name', 'display_name', 'complete_url', 'steps') 11 | 12 | def get_steps(self, tour): 13 | return [ 14 | StepSerializer(child_step, context=self.context).data 15 | for child_step in tour.load_tour_class().get_steps(0) 16 | ] 17 | 18 | 19 | class StepSerializer(serializers.ModelSerializer): 20 | steps = serializers.SerializerMethodField() 21 | complete = serializers.SerializerMethodField() 22 | 23 | class Meta: 24 | model = Step 25 | fields = ('name', 'display_name', 'url', 'sort_order', 'steps', 'complete') 26 | 27 | def get_steps(self, step): 28 | return [ 29 | StepSerializer(child_step, context=self.context).data 30 | for child_step in step.load_step_class().get_steps(0) 31 | ] 32 | 33 | def get_complete(self, step): 34 | if 'request' in self.context: 35 | return step.load_step_class().is_complete(self.context['request'].user) 36 | return False 37 | -------------------------------------------------------------------------------- /tour/static/tour/.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | indent_style = space 10 | 11 | [*.json] 12 | indent_size = 2 13 | 14 | [*.js] 15 | indent_size = 4 16 | 17 | [*.html] 18 | indent_size = 4 19 | 20 | [*.css] 21 | indent_size = 4 22 | 23 | [*.styl] 24 | indent_size = 4 25 | -------------------------------------------------------------------------------- /tour/static/tour/.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 | "disallowLeftStickedOperators": ["?", "-", "/", "*", "=", "==", "===", "!=", "!==", ">", ">=", "<", "<="], 11 | "disallowRightStickedOperators": ["?", "/", "*", ":", "=", "==", "===", "!=", "!==", ">", ">=", "<", "<="], 12 | "requireRightStickedOperators": ["!"], 13 | "requireLeftStickedOperators": [","], 14 | "disallowKeywords": ["with"], 15 | "disallowMultipleLineBreaks": true, 16 | "disallowKeywordsOnNewLine": ["else"], 17 | "requireLineFeedAtFileEnd": true 18 | } 19 | -------------------------------------------------------------------------------- /tour/static/tour/.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 | "xdescribe": true, 23 | "xit": true, 24 | "xexpect": true 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tour/static/tour/.yo-rc.json: -------------------------------------------------------------------------------- 1 | { 2 | "generator-ambition": {} 3 | } -------------------------------------------------------------------------------- /tour/static/tour/build/tour.css: -------------------------------------------------------------------------------- 1 | .tour-wrap{padding:20px;}.tour-wrap .hidden{visibility:hidden}.tour-wrap .tour-name{margin:0 0 24px 0;font-weight:bold;font-size:16px}.tour-wrap .tour-bar-wrap{position:relative;height:20px;}.tour-wrap .tour-bar-wrap .tour-bar{background-color:#d9dde2;height:5px;font-size:1px;overflow:hidden;position:relative;top:-3px;}.tour-wrap .tour-bar-wrap .tour-bar .completed{width:0;height:100%;background-color:#534472}.tour-wrap .tour-bar-wrap a.step-circle{position:absolute;top:-6.5px;color:#ccc;-webkit-border-radius:50%;border-radius:50%;width:13px;height:13px;background-color:#d9dde2;margin-left:-6.5px;}.tour-wrap .tour-bar-wrap a.step-circle.available:hover{border:3px solid #4195c2;margin-left:-9.5px;top:-9.5px}.tour-wrap .tour-bar-wrap a.step-circle.complete:hover,.tour-wrap .tour-bar-wrap a.step-circle.current:hover{border:3px solid #534472;margin-left:-9.5px;top:-9.5px}.tour-wrap .tour-bar-wrap a.step-circle.current.available{background-color:#534472}.tour-wrap .tour-bar-wrap a.step-circle.incomplete.unavailable{cursor:default}.tour-wrap .tour-bar-wrap a.step-circle.complete{background-color:#534472}.tour-wrap .tour-bar-wrap a.step-circle .step-name{visibility:hidden;position:absolute;top:-43px;padding:10px;-webkit-border-radius:6px;border-radius:6px;background-color:#4195c2;color:#fff;white-space:nowrap;font-family:'HalisRLight',helvetica,arial,verdana,sans-serif;text-transform:uppercase;font-size:10px;font-weight:bold;}.tour-wrap .tour-bar-wrap a.step-circle .step-name:after{top:100%;left:50%;border:solid transparent;content:" ";height:0;width:0;position:absolute;pointer-events:none;background-color:transparent;border-top-color:#4195c2;border-width:6px;margin-left:-6px}.tour-wrap .tour-bar-wrap a.step-circle:hover .step-name{visibility:visible}.tour-wrap .tour-bar-wrap a.step-circle:first-child{margin-left:0;}.tour-wrap .tour-bar-wrap a.step-circle:first-child.available:hover,.tour-wrap .tour-bar-wrap a.step-circle:first-child.complete:hover,.tour-wrap .tour-bar-wrap a.step-circle:first-child.current:hover{margin-left:0}.tour-wrap .tour-bar-wrap a.step-circle:first-child .step-name{left:0;-webkit-border-radius:6px 6px 6px 0;border-radius:6px 6px 6px 0;}.tour-wrap .tour-bar-wrap a.step-circle:first-child .step-name:after{left:0;margin-left:0}.tour-wrap .tour-bar-wrap a.step-circle:last-child{margin-left:-13px;}.tour-wrap .tour-bar-wrap a.step-circle:last-child.available:hover,.tour-wrap .tour-bar-wrap a.step-circle:last-child.complete:hover,.tour-wrap .tour-bar-wrap a.step-circle:last-child.current:hover{margin-left:-19px}.tour-wrap .tour-bar-wrap a.step-circle:last-child .step-name{right:0;-webkit-border-radius:6px 6px 0 6px;border-radius:6px 6px 0 6px;}.tour-wrap .tour-bar-wrap a.step-circle:last-child .step-name:after{right:0;left:auto;margin-left:0} -------------------------------------------------------------------------------- /tour/static/tour/build/tour.js: -------------------------------------------------------------------------------- 1 | !function(){"use strict";window.DjangoTour=function(){},window.DjangoTour.prototype={positionTourElements:function(a){var b=a.getElementsByClassName("step-circle"),c=a.getElementsByClassName("step-name"),d=0,e=b.length;if(0!==e){var f=0;e>1&&(f=100/(e-1));for(var g=null,h=0;e>h;h++){if(b[h].style.left=f*h+"%",d=b[h].offsetWidth,h>0&&e-1>h){var i=-(c[h].offsetWidth/2)+b[h].offsetWidth/2;c[h].style.marginLeft=i+"px"}for(var j={},k=b[h].className.split(" "),l=0;l0&&!g&&(g=b[h])}var m=a.getElementsByClassName("completed")[0],n=0;g&&(n=parseFloat(g.style.left),m.style.width=n+"%");var o=a.getElementsByClassName("tour-bar-wrap")[0];o.className=o.className.replace("hidden","")}},run:function(){for(var a=document.getElementsByClassName("tour-wrap"),b=0;b', 14 | '
', 15 | ' Example Tour', 16 | '
', 17 | '
', 18 | '
', 19 | '
', 20 | '
', 21 | '
', 22 | '', 23 | ].join('\n'); 24 | var container = document.createElement('div'); 25 | container.innerHTML = tourHtml; 26 | document.body.appendChild(container); 27 | 28 | var tour = new window.DjangoTour(); 29 | tour.run(); 30 | 31 | document.body.removeChild(container); 32 | }); 33 | 34 | it('should handle no complete steps', function() { 35 | var tourHtml = [ 36 | '
', 37 | '
', 38 | ' Example Tour', 39 | '
', 40 | '
', 41 | '
', 42 | '
', 43 | '
', 44 | ' ', 45 | ' Step One', 46 | ' ', 47 | '
', 48 | '
', 49 | ].join('\n'); 50 | var container = document.createElement('div'); 51 | container.innerHTML = tourHtml; 52 | document.body.appendChild(container); 53 | 54 | var tour = new window.DjangoTour(); 55 | tour.run(); 56 | 57 | var wrap = document.getElementsByClassName('tour-wrap')[0]; 58 | var completeBar = wrap.getElementsByClassName('completed')[0]; 59 | expect(completeBar.style.width).toBe('0%'); 60 | 61 | document.body.removeChild(container); 62 | }); 63 | 64 | it('should handle no available steps', function() { 65 | // This is mostly for coverage 66 | var tourHtml = [ 67 | '
', 68 | '
', 69 | ' Example Tour', 70 | '
', 71 | '
', 72 | '
', 73 | '
', 74 | '
', 75 | ' ', 76 | ' Step One', 77 | ' ', 78 | '
', 79 | '
', 80 | ].join('\n'); 81 | var container = document.createElement('div'); 82 | container.innerHTML = tourHtml; 83 | document.body.appendChild(container); 84 | 85 | var tour = new window.DjangoTour(); 86 | tour.run(); 87 | 88 | var wrap = document.getElementsByClassName('tour-wrap')[0]; 89 | var completeBar = wrap.getElementsByClassName('completed')[0]; 90 | expect(completeBar.style.width).toBe(''); 91 | 92 | document.body.removeChild(container); 93 | }); 94 | 95 | it('should handle current complete step', function() { 96 | var tourHtml = [ 97 | '
', 98 | '
', 99 | ' Example Tour', 100 | '
', 101 | '
', 102 | '
', 103 | '
', 104 | '
', 105 | ' ', 106 | ' Step One', 107 | ' ', 108 | ' ', 109 | ' Step Two', 110 | ' ', 111 | ' ', 112 | ' Step Three', 113 | ' ', 114 | ' ', 115 | ' Step Four', 116 | ' ', 117 | '
', 118 | '
', 119 | ].join('\n'); 120 | var container = document.createElement('div'); 121 | container.innerHTML = tourHtml; 122 | document.body.appendChild(container); 123 | 124 | var tour = new window.DjangoTour(); 125 | tour.run(); 126 | 127 | var wrap = document.getElementsByClassName('tour-wrap')[0]; 128 | var completeBar = wrap.getElementsByClassName('completed')[0]; 129 | expect(completeBar.style.width).toBe('0%'); 130 | document.body.removeChild(container); 131 | }); 132 | 133 | it('should handle current incomplete step', function() { 134 | var tourHtml = [ 135 | '
', 136 | '
', 137 | ' Example Tour', 138 | '
', 139 | '
', 140 | '
', 141 | '
', 142 | '
', 143 | ' ', 144 | ' Step One', 145 | ' ', 146 | ' ', 147 | ' Step Two', 148 | ' ', 149 | ' ', 150 | ' Step Three', 151 | ' ', 152 | '
', 153 | '
', 154 | ].join('\n'); 155 | var container = document.createElement('div'); 156 | container.innerHTML = tourHtml; 157 | document.body.appendChild(container); 158 | 159 | var tour = new window.DjangoTour(); 160 | tour.run(); 161 | 162 | var wrap = document.getElementsByClassName('tour-wrap')[0]; 163 | var completeBar = wrap.getElementsByClassName('completed')[0]; 164 | expect(completeBar.style.width).toBe('50%'); 165 | document.body.removeChild(container); 166 | }); 167 | 168 | it('should handle no current step', function() { 169 | var tourHtml = [ 170 | '
', 171 | '
', 172 | ' Example Tour', 173 | '
', 174 | '
', 175 | '
', 176 | '
', 177 | '
', 178 | ' ', 179 | ' Step One', 180 | ' ', 181 | ' ', 182 | ' Step Two', 183 | ' ', 184 | ' ', 185 | ' Step Three', 186 | ' ', 187 | '
', 188 | '
', 189 | ].join('\n'); 190 | var container = document.createElement('div'); 191 | container.innerHTML = tourHtml; 192 | document.body.appendChild(container); 193 | 194 | var tour = new window.DjangoTour(); 195 | tour.run(); 196 | 197 | var wrap = document.getElementsByClassName('tour-wrap')[0]; 198 | var completeBar = wrap.getElementsByClassName('completed')[0]; 199 | expect(completeBar.style.width).toBe('50%'); 200 | document.body.removeChild(container); 201 | }); 202 | 203 | }); 204 | -------------------------------------------------------------------------------- /tour/static/tour/src/tour/tour.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | /** 5 | * @constructor 6 | * There are currently no options to configure, so the constructor is empty 7 | */ 8 | 9 | window.DjangoTour = function DjangoTour() {}; 10 | 11 | window.DjangoTour.prototype = { 12 | 13 | /** 14 | * @method positionTourElements 15 | * Calculates and sets the x offset values for each circle element and step name element. Marks 16 | * a current step by setting a class and then shows the element. 17 | * @param {HTMLElement} tourWrap - The tour dom element containing all of the step data 18 | */ 19 | positionTourElements: function positionTourElements(tourWrap) { 20 | // Query for the circle and name elements 21 | var stepCircles = tourWrap.getElementsByClassName('step-circle'); 22 | var stepNames = tourWrap.getElementsByClassName('step-name'); 23 | var circleWidth = 0; 24 | var numSteps = stepCircles.length; 25 | if (numSteps === 0) { 26 | return; 27 | } 28 | 29 | // Determine percentage offsets for circles 30 | var increment = 0; 31 | if (numSteps > 1) { 32 | increment = 100.0 / (numSteps - 1); 33 | } 34 | 35 | // Loop through each step to find the current one and calculate offsets 36 | var currentStep = null; 37 | for (var i = 0; i < numSteps; i++) { 38 | stepCircles[i].style.left = (increment * i) + '%'; 39 | circleWidth = stepCircles[i].offsetWidth; 40 | 41 | // Set the offset of all steps that are not the first or last 42 | if (i > 0 && i < numSteps - 1) { 43 | var offset = -(stepNames[i].offsetWidth / 2) + (stepCircles[i].offsetWidth / 2); 44 | stepNames[i].style.marginLeft = offset + 'px'; 45 | } 46 | 47 | // Build a map of class names to determine which is the current step 48 | var classMap = {}; 49 | var classNames = stepCircles[i].className.split(' '); 50 | for (var j = 0; j < classNames.length; j++) { 51 | classMap[classNames[j]] = true; 52 | } 53 | 54 | // Check if this is the current step 55 | if ('current' in classMap && 'available' in classMap) { 56 | currentStep = stepCircles[i]; 57 | } else if ('incomplete' in classMap && 'available' in classMap && i > 0 && !currentStep) { 58 | currentStep = stepCircles[i]; 59 | } 60 | } 61 | 62 | // Find the current step 63 | var completedDiv = tourWrap.getElementsByClassName('completed')[0], 64 | left = 0; 65 | 66 | // Set the width of the current progress bar 67 | if (currentStep) { 68 | left = parseFloat(currentStep.style.left); 69 | completedDiv.style.width = left + '%'; 70 | } 71 | 72 | // Unhide the bar 73 | var barWrap = tourWrap.getElementsByClassName('tour-bar-wrap')[0]; 74 | barWrap.className = barWrap.className.replace('hidden', ''); 75 | }, 76 | 77 | /** 78 | * @method run 79 | * Gets all of the tour elements on the page and calls positionTourElements for each container 80 | */ 81 | run: function run() { 82 | var tourWraps = document.getElementsByClassName('tour-wrap'); 83 | for (var i = 0; i < tourWraps.length; i++) { 84 | this.positionTourElements(tourWraps[i]); 85 | } 86 | } 87 | }; 88 | })(); 89 | -------------------------------------------------------------------------------- /tour/static/tour/style/tour.styl: -------------------------------------------------------------------------------- 1 | circle-size = 13px; 2 | circle-size-half = 6.5px; 3 | circle-size-hover = 19px; 4 | circle-size-hover-half = 9.5px; 5 | 6 | 7 | .tour-wrap { 8 | padding: 20px; 9 | 10 | .hidden { 11 | visibility: hidden; 12 | } 13 | 14 | .tour-name { 15 | margin: 0 0 24px 0; 16 | font-weight: bold; 17 | font-size: 16px; 18 | } 19 | 20 | .tour-bar-wrap { 21 | position: relative; 22 | height: 20px; 23 | 24 | .tour-bar { 25 | background-color: #D9DDE2; 26 | height: 5px; 27 | font-size: 1px; 28 | overflow: hidden; 29 | position: relative; 30 | top: -3px; 31 | 32 | .completed { 33 | width: 0; 34 | height: 100%; 35 | background-color: #534472; 36 | } 37 | } 38 | 39 | a.step-circle { 40 | position: absolute; 41 | top: -(circle-size-half); 42 | color: #ccc; 43 | border-radius: 50%; 44 | width: circle-size; 45 | height: circle-size; 46 | background-color: #D9DDE2; 47 | margin-left: -(circle-size-half); 48 | 49 | &.available { 50 | &:hover { 51 | border: 3px solid #4195C2; 52 | margin-left: -(circle-size-hover-half) 53 | top: -(circle-size-hover-half); 54 | } 55 | } 56 | 57 | &.complete, &.current { 58 | &:hover { 59 | border: 3px solid #534472; 60 | margin-left: -(circle-size-hover-half) 61 | top: -(circle-size-hover-half); 62 | } 63 | } 64 | 65 | &.current.available { 66 | background-color: #534472; 67 | } 68 | 69 | &.incomplete { 70 | &.unavailable { 71 | cursor: default; 72 | } 73 | } 74 | 75 | &.complete { 76 | background-color: #534472; 77 | } 78 | 79 | .step-name { 80 | visibility: hidden; 81 | position: absolute; 82 | top: -43px; 83 | padding: 10px; 84 | border-radius: 6px; 85 | background-color: #4195C2; 86 | color: white; 87 | white-space: nowrap; 88 | font-family: 'HalisRLight',helvetica,arial,verdana,sans-serif; 89 | text-transform: uppercase; 90 | font-size: 10px; 91 | font-weight: bold; 92 | 93 | &:after { 94 | top: 100%; 95 | left: 50%; 96 | border: solid transparent; 97 | content: " "; 98 | height: 0; 99 | width: 0; 100 | position: absolute; 101 | pointer-events: none; 102 | background-color: transparent; 103 | border-top-color: #4195C2; 104 | border-width: 6px; 105 | margin-left: -6px; 106 | } 107 | } 108 | 109 | &:hover { 110 | .step-name { 111 | visibility: visible; 112 | } 113 | } 114 | 115 | &:first-child { 116 | margin-left: 0; 117 | 118 | &.available, &.complete, &.current { 119 | &:hover { 120 | margin-left: 0; 121 | } 122 | } 123 | 124 | .step-name { 125 | left: 0; 126 | border-radius: 6px 6px 6px 0; 127 | 128 | &:after { 129 | left: 0; 130 | margin-left: 0; 131 | } 132 | } 133 | } 134 | 135 | &:last-child { 136 | margin-left: -(circle-size); 137 | 138 | &.available, &.complete, &.current { 139 | &:hover { 140 | margin-left: -(circle-size-hover); 141 | } 142 | } 143 | 144 | .step-name { 145 | right: 0; 146 | border-radius: 6px 6px 0 6px; 147 | 148 | &:after { 149 | right: 0; 150 | left: auto; 151 | margin-left: 0; 152 | } 153 | } 154 | } 155 | 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /tour/templates/tour/tour_navigation.html: -------------------------------------------------------------------------------- 1 | {% if tour %} 2 |
3 |
4 | {{ tour.display_name }} 5 |
6 | 18 |
19 | 20 | 21 | 24 | {% endif %} 25 | -------------------------------------------------------------------------------- /tour/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ambitioninc/django-tour/f0181d71ebd6c66e11dd921ad5e602192fc621cc/tour/templatetags/__init__.py -------------------------------------------------------------------------------- /tour/templatetags/tour_tags.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | from django import template 4 | from django.template.loader import get_template 5 | 6 | from tour.models import Tour 7 | from tour.serializers import TourSerializer 8 | 9 | 10 | register = template.Library() 11 | 12 | 13 | class TourNavNode(template.Node): 14 | def __init__(self, always_show=False): 15 | self.always_show = always_show 16 | 17 | def get_tour(self, request): 18 | # Check for any tours 19 | tour = Tour.objects.get_for_user(request.user) 20 | 21 | if not tour and self.always_show: 22 | tour = Tour.objects.get_recent_tour(request.user) 23 | if self.always_show: 24 | mutable_get = deepcopy(request.GET) 25 | mutable_get['always_show'] = True 26 | request.GET = mutable_get 27 | return tour 28 | 29 | def get_tour_dict(self, tour, context): 30 | if not tour: 31 | return None 32 | 33 | tour_dict = TourSerializer(tour, context=context).data 34 | 35 | # Set the step css classes 36 | previous_steps_complete = True 37 | is_after_current = False 38 | for step_dict in tour_dict['steps']: 39 | classes = [] 40 | if step_dict['url'] == context['request'].path: 41 | classes.append('current') 42 | step_dict['current'] = True 43 | tour_dict['display_name'] = step_dict['display_name'] 44 | is_after_current = True 45 | if not previous_steps_complete: 46 | classes.append('incomplete') 47 | classes.append('unavailable') 48 | step_dict['url'] = '#' 49 | elif not step_dict['complete']: 50 | classes.append('incomplete') 51 | classes.append('available') 52 | previous_steps_complete = False 53 | elif is_after_current: 54 | classes.append('available') 55 | else: 56 | classes.append('complete') 57 | classes.append('available') 58 | step_dict['cls'] = ' '.join(classes) 59 | 60 | return tour_dict 61 | 62 | def render(self, context): 63 | if 'request' in context and hasattr(context['request'], 'user'): 64 | # Make sure this isn't the anonymous user 65 | if not context['request'].user.id: 66 | return '' 67 | 68 | tour = self.get_tour(context['request']) 69 | context['tour'] = self.get_tour_dict(tour, context) 70 | 71 | # Load the tour template and render it 72 | tour_template = get_template('tour/tour_navigation.html') 73 | return tour_template.render(context) 74 | return '' 75 | 76 | 77 | @register.simple_tag(takes_context=True) 78 | def tour_navigation(context, **kwargs): 79 | """ 80 | Tag to render the tour nav node 81 | """ 82 | return TourNavNode(always_show=kwargs.get('always_show', False)).render(context) 83 | -------------------------------------------------------------------------------- /tour/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ambitioninc/django-tour/f0181d71ebd6c66e11dd921ad5e602192fc621cc/tour/tests/__init__.py -------------------------------------------------------------------------------- /tour/tests/api_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 | from tour.models import Tour 6 | from tour.api import TourApiView 7 | 8 | 9 | class TourApiViewTest(TestCase): 10 | 11 | def test_get_queryset(self): 12 | """ 13 | Should return a queryset for tours 14 | """ 15 | view = TourApiView() 16 | view.request = Mock(user=G(User, id=1)) 17 | self.assertEqual(Tour, view.get_queryset().model) 18 | -------------------------------------------------------------------------------- /tour/tests/mocks.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.views.generic import View 3 | from tour.tours import BaseStep, BaseTour 4 | from tour.views import TourStepMixin 5 | 6 | 7 | mock_null_value = None 8 | 9 | 10 | class MockView(TourStepMixin, View): 11 | def get(self, request): 12 | return HttpResponse('ok') 13 | 14 | 15 | class MockStep1(BaseStep): 16 | pass 17 | 18 | 19 | class MockStep2(BaseStep): 20 | pass 21 | 22 | 23 | class MockStep3(BaseStep): 24 | pass 25 | 26 | 27 | class MockStep4(BaseStep): 28 | pass 29 | 30 | 31 | class MockTour(BaseTour): 32 | pass 33 | 34 | 35 | class MockTour2(BaseTour): 36 | pass 37 | -------------------------------------------------------------------------------- /tour/tests/model_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 patch 5 | from tour.models import Tour, TourStatus, Step 6 | from tour.tests.tour_tests import BaseTourTest 7 | 8 | 9 | class TourManagerTest(BaseTourTest): 10 | """ 11 | Tests the methods of the TourManager 12 | """ 13 | @patch('tour.tests.mocks.MockStep1.is_complete', spec_set=True) 14 | def test_complete_tours(self, mock_step1_is_complete): 15 | """ 16 | Verifies that any completed tours get marked as complete 17 | :type mock_step1_is_complete: Mock 18 | """ 19 | mock_step1_is_complete.return_value = False 20 | self.tour1.steps.add(self.step1) 21 | 22 | # add user to tours 23 | self.tour1.load_tour_class().add_user(self.test_user) 24 | self.tour2.load_tour_class().add_user(self.test_user) 25 | 26 | Tour.objects.complete_tours(self.test_user) 27 | self.assertEqual(1, TourStatus.objects.filter(complete=True).count()) 28 | self.assertEqual(1, TourStatus.objects.filter(complete=False).count()) 29 | 30 | def test_complete_tours_no_user(self): 31 | """ 32 | Makes sure None is returned if the user is anonymous 33 | """ 34 | self.assertIsNone(Tour.objects.complete_tours(User())) 35 | 36 | @patch('tour.tests.mocks.MockStep1.is_complete', spec_set=True) 37 | def test_get_for_user(self, mock_step1_is_complete): 38 | """ 39 | Verifies that an incomplete tour class is fetched for a user 40 | :type mock_step1_is_complete: Mock 41 | """ 42 | mock_step1_is_complete.return_value = False 43 | self.tour1.steps.add(self.step1) 44 | self.tour2.steps.add(self.step2) 45 | 46 | # add users to tours 47 | self.tour1.load_tour_class().add_user(self.test_user) 48 | self.tour1.load_tour_class().add_user(self.test_user2) 49 | self.tour2.load_tour_class().add_user(self.test_user) 50 | self.tour2.load_tour_class().add_user(self.test_user2) 51 | 52 | # tour 2 will be completed, so check for tour 1 53 | self.assertEqual(self.tour1, Tour.objects.get_for_user(self.test_user)) 54 | 55 | def test_get_for_user_no_user(self): 56 | """ 57 | Makes sure None is returned if the user is anonymous 58 | """ 59 | self.assertIsNone(Tour.objects.get_for_user(User())) 60 | 61 | def test_get_for_user_empty(self): 62 | """ 63 | Verifies that None is returned if no incomplete tours 64 | """ 65 | self.tour1.steps.add(self.step1) 66 | 67 | # add user to tour 68 | self.tour1.load_tour_class().add_user(self.test_user) 69 | self.tour1.load_tour_class().add_user(self.test_user2) 70 | self.tour2.load_tour_class().add_user(self.test_user) 71 | self.tour2.load_tour_class().add_user(self.test_user2) 72 | 73 | # all tours should be completed 74 | self.assertIsNone(Tour.objects.get_for_user(self.test_user)) 75 | 76 | def test_get_recent_tour_complete(self): 77 | """ 78 | Makes sure that a recently completed tour is returned. Verifies most recently completed tour 79 | is returned 80 | """ 81 | # add user to tour 82 | self.tour1.load_tour_class().add_user(self.test_user) 83 | self.tour1.load_tour_class().add_user(self.test_user2) 84 | self.tour2.load_tour_class().add_user(self.test_user) 85 | self.tour2.load_tour_class().add_user(self.test_user2) 86 | 87 | # complete tours 88 | self.tour2.load_tour_class().mark_complete(self.test_user) 89 | self.tour1.load_tour_class().mark_complete(self.test_user) 90 | self.tour1.load_tour_class().mark_complete(self.test_user2) 91 | 92 | # make sure complete 93 | self.assertEqual(3, TourStatus.objects.filter(complete=True).count()) 94 | 95 | # check that correct tour is returned 96 | self.assertEqual(self.tour1, Tour.objects.get_recent_tour(self.test_user)) 97 | 98 | def test_get_recent_tour_no_user(self): 99 | """ 100 | Makes sure None is returned if the user is anonymous 101 | """ 102 | self.assertIsNone(Tour.objects.get_recent_tour(User())) 103 | 104 | def test_get_recent_tour_incomplete(self): 105 | """ 106 | Makes sure that an incomplete tour is returned before a complete tour 107 | """ 108 | # add user to tour 109 | self.tour1.load_tour_class().add_user(self.test_user) 110 | self.tour1.load_tour_class().add_user(self.test_user2) 111 | self.tour2.load_tour_class().add_user(self.test_user) 112 | self.tour2.load_tour_class().add_user(self.test_user2) 113 | 114 | # complete tours 115 | self.tour1.load_tour_class().mark_complete(self.test_user) 116 | self.tour2.load_tour_class().mark_complete(self.test_user) 117 | self.tour1.load_tour_class().mark_complete(self.test_user2) 118 | 119 | # make sure complete 120 | self.assertEqual(3, TourStatus.objects.filter(complete=True).count()) 121 | 122 | # check that correct tour is returned 123 | self.assertEqual(self.tour2, Tour.objects.get_recent_tour(self.test_user2)) 124 | 125 | def test_get_recent_tour_none(self): 126 | """ 127 | Makes sure None is returned if there is no completed tour 128 | """ 129 | self.assertIsNone(Tour.objects.get_recent_tour(self.test_user)) 130 | 131 | @patch('tour.tests.mocks.MockStep1.is_complete', spec_set=True) 132 | @patch('tour.models.TourManager.get_for_user') 133 | def test_get_next_url_current_tour(self, mock_get_for_user, mock_step1_is_complete): 134 | """ 135 | Verifies that the next url of a tour will be returned 136 | :type mock_get_for_user: Mock 137 | :type mock_step1_is_complete: Mock 138 | """ 139 | mock_get_for_user.return_value = self.tour1 140 | mock_step1_is_complete.return_value = False 141 | 142 | self.tour1.steps.add(self.step1) 143 | 144 | # add user to tour 145 | self.tour1.load_tour_class().add_user(self.test_user) 146 | self.tour1.load_tour_class().add_user(self.test_user2) 147 | self.tour2.load_tour_class().add_user(self.test_user) 148 | self.tour2.load_tour_class().add_user(self.test_user2) 149 | 150 | self.assertEqual(self.step1.url, Tour.objects.get_next_url(self.test_user)) 151 | self.assertEqual(1, mock_get_for_user.call_count) 152 | 153 | @patch('tour.tests.mocks.MockStep1.is_complete', spec_set=True) 154 | @patch('tour.models.TourManager.get_recent_tour') 155 | @patch('tour.models.TourManager.get_for_user') 156 | def test_get_next_url_recent_tour(self, mock_get_for_user, mock_get_recent_tour, mock_step1_is_complete): 157 | """ 158 | Verifies that the next url of a recent tour will be returned 159 | :type mock_get_for_user: Mock 160 | :type mock_get_recent_tour: Mock 161 | :type mock_step1_is_complete: Mock 162 | """ 163 | mock_get_for_user.return_value = None 164 | mock_get_recent_tour.return_value = self.tour2 165 | mock_step1_is_complete.return_value = False 166 | 167 | self.tour2.steps.add(self.step1) 168 | 169 | # add user to tour 170 | self.tour1.load_tour_class().add_user(self.test_user) 171 | self.tour1.load_tour_class().add_user(self.test_user2) 172 | self.tour2.load_tour_class().add_user(self.test_user) 173 | self.tour2.load_tour_class().add_user(self.test_user2) 174 | 175 | self.assertEqual(self.step1.url, Tour.objects.get_next_url(self.test_user)) 176 | self.assertEqual(1, mock_get_for_user.call_count) 177 | self.assertEqual(1, mock_get_recent_tour.call_count) 178 | 179 | def test_get_next_url_no_user(self): 180 | """ 181 | Makes sure None is returned if the user is anonymous 182 | """ 183 | self.assertIsNone(Tour.objects.get_next_url(User())) 184 | 185 | def test_get_next_url_none(self): 186 | """ 187 | Verifies that None is returned if no tours exist for a user 188 | """ 189 | self.assertIsNone(Tour.objects.get_next_url(self.test_user)) 190 | 191 | 192 | class TourTest(TestCase): 193 | 194 | def test_str(self): 195 | """ 196 | Tests the tour str method 197 | """ 198 | tour = G(Tour, display_name='test1') 199 | self.assertEqual('test1', str(tour)) 200 | 201 | 202 | class StepTest(TestCase): 203 | 204 | def test_str(self): 205 | """ 206 | Tests the step str method 207 | """ 208 | step = G(Step, display_name='test1') 209 | self.assertEqual('test1', str(step)) 210 | -------------------------------------------------------------------------------- /tour/tests/serializer_tests.py: -------------------------------------------------------------------------------- 1 | from tour.serializers import TourSerializer, StepSerializer 2 | from tour.tests.tour_tests import BaseTourTest 3 | 4 | 5 | class SerializerTest(BaseTourTest): 6 | """ 7 | Tests the serializers contained in the tour app 8 | """ 9 | def test_step_serializer(self): 10 | """ 11 | Tests the Step model serialization 12 | """ 13 | self.tour1.steps.add(self.step1, self.step2) 14 | self.step1.steps.add(self.step3, self.step4) 15 | self.assertEqual(StepSerializer(self.step1).data, { 16 | 'name': 'mock1', 17 | 'display_name': 'Mock Step 1', 18 | 'url': 'mock1', 19 | 'sort_order': 0, 20 | 'complete': False, 21 | 'steps': [{ 22 | 'name': 'mock3', 23 | 'display_name': 'Mock Step 3', 24 | 'url': 'mock3', 25 | 'sort_order': 2, 26 | 'steps': [], 27 | 'complete': False, 28 | }, { 29 | 'name': 'mock4', 30 | 'display_name': 'Mock Step 4', 31 | 'url': 'mock4', 32 | 'sort_order': 3, 33 | 'steps': [], 34 | 'complete': False, 35 | }] 36 | }) 37 | 38 | def test_tour_serializer(self): 39 | """ 40 | Tests the Tour model serialization 41 | """ 42 | self.tour1.steps.add(self.step1, self.step2) 43 | self.step1.steps.add(self.step3, self.step4) 44 | self.assertEqual(TourSerializer(self.tour1).data, { 45 | 'name': 'tour1', 46 | 'display_name': 'Mock Tour', 47 | 'complete_url': 'mock_complete1', 48 | 'steps': [{ 49 | 'name': 'mock1', 50 | 'display_name': 'Mock Step 1', 51 | 'url': 'mock1', 52 | 'sort_order': 0, 53 | 'complete': False, 54 | 'steps': [{ 55 | 'name': 'mock3', 56 | 'display_name': 'Mock Step 3', 57 | 'url': 'mock3', 58 | 'sort_order': 2, 59 | 'steps': [], 60 | 'complete': False, 61 | }, { 62 | 'name': 'mock4', 63 | 'display_name': 'Mock Step 4', 64 | 'url': 'mock4', 65 | 'sort_order': 3, 66 | 'steps': [], 67 | 'complete': False, 68 | }] 69 | }, { 70 | 'name': 'mock2', 71 | 'display_name': 'Mock Step 2', 72 | 'url': 'mock2', 73 | 'sort_order': 1, 74 | 'steps': [], 75 | 'complete': False, 76 | }] 77 | }) 78 | -------------------------------------------------------------------------------- /tour/tests/templatetag_tests.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.template import Template, Context 3 | from mock import Mock, patch 4 | 5 | from tour.tests.tour_tests import BaseTourTest 6 | 7 | 8 | class TemplateTagTest(BaseTourTest): 9 | """ 10 | Tests the functionality of the template tag 11 | """ 12 | 13 | def setUp(self): 14 | super(TemplateTagTest, self).setUp() 15 | self.test_template = Template('{% load tour_tags %}{% tour_navigation %}') 16 | self.tour1.steps.add(self.step1, self.step2, self.step3, self.step4) 17 | 18 | def test_template_no_user(self): 19 | """ 20 | Verifies that the tour template does not get rendered without a user 21 | """ 22 | context = Context({ 23 | 'request': Mock( 24 | user=User(), 25 | path='/mock/path', 26 | method='get', 27 | GET={}, 28 | ), 29 | }) 30 | self.assertEqual('', self.test_template.render(context)) 31 | 32 | def test_template_no_tour(self): 33 | """ 34 | Verifies that the tour template does not get rendered if a user doesn't have a tour 35 | """ 36 | self.login_user1() 37 | context = Context({ 38 | 'request': Mock( 39 | user=self.test_user, 40 | path='/mock/path', 41 | method='get', 42 | GET={}, 43 | ), 44 | }) 45 | self.assertEqual('', self.test_template.render(context).strip()) 46 | 47 | @patch('tour.tests.mocks.MockStep1.is_complete', spec_set=True) 48 | def test_user_with_tour(self, mock_step1_is_complete): 49 | """ 50 | Verifies that the tour template gets rendered if a user has a tour 51 | :type mock_step1_is_complete: Mock 52 | """ 53 | mock_step1_is_complete.return_value = False 54 | 55 | self.login_user1() 56 | self.tour1.load_tour_class().add_user(self.test_user) 57 | context = Context({ 58 | 'request': Mock( 59 | user=self.test_user, 60 | path='/mock/path', 61 | method='get', 62 | GET={}, 63 | ), 64 | }) 65 | self.assertTrue('tour-wrap' in self.test_template.render(context)) 66 | 67 | @patch('tour.tests.mocks.MockStep1.is_complete', spec_set=True) 68 | def test_current_class(self, mock_step1_is_complete): 69 | """ 70 | Verify that the current class gets applied 71 | :type mock_step1_is_complete: Mock 72 | """ 73 | mock_step1_is_complete.return_value = False 74 | 75 | self.login_user1() 76 | self.tour1.load_tour_class().add_user(self.test_user) 77 | context = Context({ 78 | 'request': Mock( 79 | user=self.test_user, 80 | path='mock1', 81 | method='get', 82 | GET={}, 83 | ), 84 | }) 85 | self.assertTrue('current' in self.test_template.render(context)) 86 | 87 | @patch('tour.tests.mocks.MockStep2.is_complete', spec_set=True) 88 | def test_complete_class(self, mock_step2_is_complete): 89 | """ 90 | Verify that the complete class gets applied 91 | :type mock_step2_is_complete: Mock 92 | """ 93 | mock_step2_is_complete.return_value = False 94 | 95 | self.login_user1() 96 | self.tour1.load_tour_class().add_user(self.test_user) 97 | context = Context({ 98 | 'request': Mock( 99 | user=self.test_user, 100 | path='/mock/path', 101 | method='get', 102 | GET={}, 103 | ), 104 | }) 105 | self.assertTrue('complete' in self.test_template.render(context)) 106 | 107 | def test_complete_tour(self): 108 | """ 109 | Make sure no tour gets rendered when it is complete 110 | """ 111 | self.login_user1() 112 | self.tour1.load_tour_class().add_user(self.test_user) 113 | context = Context({ 114 | 'request': Mock( 115 | user=self.test_user, 116 | path='/mock/path', 117 | method='get', 118 | GET={}, 119 | ), 120 | }) 121 | self.assertTrue('tour-wrap' not in self.test_template.render(context)) 122 | 123 | def test_always_display(self): 124 | """ 125 | Makes sure that the tour does get displayed if the always_show flag is on 126 | """ 127 | self.test_template = Template('{% load tour_tags %}{% tour_navigation always_show=True %}') 128 | 129 | self.login_user1() 130 | self.tour1.load_tour_class().add_user(self.test_user) 131 | context = Context({ 132 | 'request': Mock( 133 | user=self.test_user, 134 | path='/mock/path', 135 | method='get', 136 | GET={}, 137 | ), 138 | }) 139 | self.assertTrue('tour-wrap' in self.test_template.render(context)) 140 | 141 | def test_missing_request(self): 142 | """ 143 | Verify no errors for missing request object 144 | """ 145 | context = Context({}) 146 | self.assertEqual('', self.test_template.render(context)) 147 | 148 | @patch('tour.tests.mocks.MockStep2.is_complete', spec_set=True) 149 | @patch('tour.tests.mocks.MockStep1.is_complete', spec_set=True) 150 | def test_step_classes(self, mock_step1_is_complete, mock_step2_is_complete): 151 | """ 152 | Test that the second step has an available class but not a complete class 153 | :type mock_step1_is_complete: Mock 154 | :type mock_step2_is_complete: Mock 155 | """ 156 | mock_step1_is_complete.return_value = True 157 | mock_step2_is_complete.return_value = False 158 | self.test_template = Template('{% load tour_tags %}{% tour_navigation always_show=True %}') 159 | 160 | self.login_user1() 161 | self.tour1.load_tour_class().add_user(self.test_user) 162 | context = Context({ 163 | 'request': Mock( 164 | user=self.test_user, 165 | path='/mock/path', 166 | method='get', 167 | GET={}, 168 | ), 169 | }) 170 | rendered_content = self.render_and_clean(self.test_template, context) 171 | 172 | # test incomplete unavailable 173 | expected_str = '' 174 | self.assertTrue(expected_str in rendered_content) 175 | 176 | # test incomplete available 177 | expected_str = '' 178 | self.assertTrue(expected_str in rendered_content) 179 | 180 | # complete the second step 181 | mock_step2_is_complete.return_value = True 182 | 183 | # request the first page 184 | context = Context({ 185 | 'request': Mock( 186 | user=self.test_user, 187 | path='mock1', 188 | method='get', 189 | GET={}, 190 | ), 191 | }) 192 | rendered_content = self.render_and_clean(self.test_template, context) 193 | 194 | # test available 195 | expected_str = '' 196 | self.assertTrue(expected_str in rendered_content) 197 | 198 | # test current complete available 199 | expected_str = '' 200 | self.assertTrue(expected_str in rendered_content) 201 | 202 | @patch('tour.tests.mocks.MockStep1.is_complete', spec_set=True) 203 | def test_step_display_name(self, mock_step1_is_complete): 204 | """ 205 | Makes sure the appropriate title gets displayed for the tour title 206 | :type mock_step1_is_complete: Mock 207 | """ 208 | mock_step1_is_complete.return_value = False 209 | 210 | self.login_user1() 211 | self.tour1.load_tour_class().add_user(self.test_user) 212 | context = Context({ 213 | 'request': Mock( 214 | user=self.test_user, 215 | path='mock1', 216 | method='get', 217 | GET={}, 218 | ), 219 | }) 220 | rendered_content = self.render_and_clean(self.test_template, context) 221 | 222 | # Make sure the current step is displayed 223 | expected_html = '
{0}
'.format(self.step1.display_name) 224 | self.assertTrue(expected_html in rendered_content) 225 | 226 | @patch('tour.tests.mocks.MockStep1.is_complete', spec_set=True) 227 | def test_tour_display_name(self, mock_step1_is_complete): 228 | """ 229 | Makes sure the appropriate title gets displayed for the tour title 230 | :type mock_step1_is_complete: Mock 231 | """ 232 | mock_step1_is_complete.return_value = False 233 | 234 | self.login_user1() 235 | self.tour1.load_tour_class().add_user(self.test_user) 236 | 237 | # Make sure the tour title is displayed 238 | context = Context({ 239 | 'request': Mock( 240 | user=self.test_user, 241 | path='mock0', 242 | method='get', 243 | GET={}, 244 | ), 245 | }) 246 | rendered_content = self.render_and_clean(self.test_template, context) 247 | expected_html = '
{0}
'.format(self.tour1.display_name) 248 | self.assertTrue(expected_html in rendered_content) 249 | 250 | def render_and_clean(self, template, context): 251 | # render the template 252 | rendered_content = template.render(context).strip() 253 | # remove tabs 254 | rendered_content = rendered_content.replace(' ', '') 255 | # remove new lines 256 | rendered_content = rendered_content.replace('\n', '') 257 | return rendered_content 258 | -------------------------------------------------------------------------------- /tour/tests/tour_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 patch 5 | 6 | from tour.models import Tour, Step, TourStatus 7 | 8 | 9 | class BaseTourTest(TestCase): 10 | """ 11 | Provides basic setup for tour tests like creating users 12 | """ 13 | def setUp(self): 14 | super(BaseTourTest, self).setUp() 15 | 16 | self.test_user = User.objects.create_user('test', 'test@gmail.com', 'test') 17 | self.test_user2 = User.objects.create_user('test2', 'test2@gmail.com', 'test2') 18 | 19 | self.tour1 = G( 20 | Tour, display_name='Mock Tour', name='tour1', complete_url='mock_complete1', 21 | tour_class='tour.tests.mocks.MockTour') 22 | self.tour2 = G( 23 | Tour, display_name='Mock Tour 2', name='tour2', complete_url='mock_complete2', 24 | tour_class='tour.tests.mocks.MockTour2') 25 | self.step1 = G( 26 | Step, step_class='tour.tests.mocks.MockStep1', display_name='Mock Step 1', name='mock1', 27 | url='mock1', parent_step=None, sort_order=0) 28 | self.step2 = G( 29 | Step, step_class='tour.tests.mocks.MockStep2', display_name='Mock Step 2', name='mock2', 30 | url='mock2', parent_step=None, sort_order=1) 31 | self.step3 = G( 32 | Step, step_class='tour.tests.mocks.MockStep3', display_name='Mock Step 3', name='mock3', 33 | url='mock3', parent_step=None, sort_order=2) 34 | self.step4 = G( 35 | Step, step_class='tour.tests.mocks.MockStep4', display_name='Mock Step 4', name='mock4', 36 | url='mock4', parent_step=None, sort_order=3) 37 | self.step5 = G( 38 | Step, step_class='tour.tours.BaseStep', display_name='Mock Step 5', name='mock5', 39 | url=None, parent_step=None, sort_order=4) 40 | 41 | def login_user1(self): 42 | self.client.login(username='test', password='test') 43 | 44 | 45 | class TourTest(BaseTourTest): 46 | """ 47 | Tests the functionality of the BaseTour class 48 | """ 49 | def test_init(self): 50 | """ 51 | Verifies that the tour object is properly set when loaded 52 | """ 53 | self.assertEqual(self.tour1.load_tour_class().tour, self.tour1) 54 | 55 | def test_get_steps_flat(self): 56 | """ 57 | Verifies that the steps are loaded in the correct order 58 | """ 59 | self.step1.sort_order = 1 60 | self.step1.save() 61 | self.step2.sort_order = 0 62 | self.step2.save() 63 | 64 | self.tour1.steps.add(self.step1, self.step2) 65 | expected_steps = [self.step2, self.step1] 66 | self.assertEqual(expected_steps, self.tour1.load_tour_class().get_steps()) 67 | 68 | def test_get_steps_nested(self): 69 | """ 70 | Verifies that the nested steps are loaded correctly 71 | """ 72 | self.tour1.steps.add(self.step1, self.step2) 73 | self.step1.steps.add(self.step3, self.step4) 74 | 75 | self.step3.sort_order = 1 76 | self.step3.save() 77 | self.step4.sort_order = 0 78 | self.step4.save() 79 | 80 | expected_steps = [self.step1, self.step4, self.step3, self.step2] 81 | self.assertEqual(expected_steps, self.tour1.load_tour_class().get_steps()) 82 | 83 | def test_get_url_list(self): 84 | """ 85 | Verifies that the tour returns the correct step url list 86 | """ 87 | self.tour1.steps.add(self.step1, self.step5, self.step2) 88 | expected_url_list = ['mock1', 'mock2'] 89 | self.assertEqual(expected_url_list, self.tour1.load_tour_class().get_url_list()) 90 | 91 | def test_add_user(self): 92 | """ 93 | Verifies that a user is linked to a tour properly and that the correct tour is returned 94 | """ 95 | # add user to tour 96 | tour_status = self.tour1.load_tour_class().add_user(self.test_user) 97 | 98 | # try to add again and make sure it returns the same status 99 | self.assertEqual(tour_status, self.tour1.load_tour_class().add_user(self.test_user)) 100 | 101 | # make sure only one status 102 | self.assertEqual(1, TourStatus.objects.count()) 103 | 104 | # mark status as complete 105 | tour_status.complete = True 106 | tour_status.save() 107 | 108 | # make sure another tour is created 109 | self.tour1.load_tour_class().add_user(self.test_user) 110 | self.assertEqual(2, TourStatus.objects.count()) 111 | self.assertEqual(1, TourStatus.objects.filter(complete=False).count()) 112 | 113 | def test_mark_complete(self): 114 | """ 115 | Verifies that a tour status record will be marked as complete for a user 116 | """ 117 | # add multiple users to multiple tours 118 | tour1_class = self.tour1.load_tour_class() 119 | tour2_class = self.tour2.load_tour_class() 120 | tour1_class.add_user(self.test_user) 121 | tour1_class.add_user(self.test_user2) 122 | tour2_class.add_user(self.test_user) 123 | tour2_class.add_user(self.test_user2) 124 | 125 | # make sure there are 4 records 126 | self.assertEqual(4, TourStatus.objects.count()) 127 | # complete the tour for user1 128 | self.assertTrue(tour1_class.mark_complete(self.test_user)) 129 | # make sure it is complete 130 | self.assertEqual(1, TourStatus.objects.filter(complete=True).count()) 131 | # try to complete the same tour 132 | self.assertFalse(tour1_class.mark_complete(self.test_user)) 133 | # add the user to the tour again 134 | tour1_class.add_user(self.test_user) 135 | # make sure there are 5 records 136 | self.assertEqual(5, TourStatus.objects.count()) 137 | 138 | @patch('tour.tests.mocks.MockStep4.is_complete', spec_set=True) 139 | @patch('tour.tests.mocks.MockStep3.is_complete', spec_set=True) 140 | @patch('tour.tests.mocks.MockStep2.is_complete', spec_set=True) 141 | @patch('tour.tests.mocks.MockStep1.is_complete', spec_set=True) 142 | def test_get_current_step( 143 | self, mock_step1_is_complete, mock_step2_is_complete, mock_step3_is_complete, mock_step4_is_complete): 144 | """ 145 | Verifies that the tour class returns the first incomplete step 146 | :type mock_step1_is_complete: Mock 147 | :type mock_step2_is_complete: Mock 148 | :type mock_step3_is_complete: Mock 149 | :type mock_step4_is_complete: Mock 150 | """ 151 | mock_step1_is_complete.return_value = False 152 | mock_step2_is_complete.return_value = False 153 | mock_step3_is_complete.return_value = False 154 | mock_step4_is_complete.return_value = False 155 | 156 | self.tour1.steps.add(self.step1, self.step2) 157 | self.step1.steps.add(self.step3, self.step4) 158 | tour1_class = self.tour1.load_tour_class() 159 | 160 | self.assertEqual(self.step1, tour1_class.get_current_step(self.test_user)) 161 | 162 | mock_step1_is_complete.return_value = True 163 | mock_step3_is_complete.return_value = True 164 | self.assertEqual(self.step4, tour1_class.get_current_step(self.test_user)) 165 | 166 | mock_step4_is_complete.return_value = True 167 | mock_step2_is_complete.return_value = True 168 | self.assertIsNone(tour1_class.get_current_step(self.test_user)) 169 | 170 | @patch('tour.tests.mocks.MockStep4.is_complete', spec_set=True) 171 | @patch('tour.tests.mocks.MockStep3.is_complete', spec_set=True) 172 | @patch('tour.tests.mocks.MockStep2.is_complete', spec_set=True) 173 | @patch('tour.tests.mocks.MockStep1.is_complete', spec_set=True) 174 | def test_get_next_url( 175 | self, mock_step1_is_complete, mock_step2_is_complete, mock_step3_is_complete, mock_step4_is_complete): 176 | """ 177 | Verifies that the url is returned for the current step 178 | :type mock_step1_is_complete: Mock 179 | :type mock_step2_is_complete: Mock 180 | :type mock_step3_is_complete: Mock 181 | :type mock_step4_is_complete: Mock 182 | """ 183 | mock_step1_is_complete.return_value = False 184 | mock_step2_is_complete.return_value = False 185 | mock_step3_is_complete.return_value = False 186 | mock_step4_is_complete.return_value = False 187 | 188 | self.step5.sort_order = 1 189 | self.step5.save() 190 | self.step2.sort_order = 3 191 | self.step2.save() 192 | 193 | self.tour1.steps.add(self.step1, self.step2, self.step5) 194 | self.step5.steps.add(self.step3, self.step4) 195 | tour1_class = self.tour1.load_tour_class() 196 | 197 | self.assertEqual('mock1', tour1_class.get_next_url(self.test_user)) 198 | 199 | mock_step1_is_complete.return_value = True 200 | self.assertEqual('mock3', tour1_class.get_next_url(self.test_user)) 201 | 202 | mock_step3_is_complete.return_value = True 203 | self.assertEqual('mock4', tour1_class.get_next_url(self.test_user)) 204 | 205 | mock_step4_is_complete.return_value = True 206 | self.assertEqual('mock2', tour1_class.get_next_url(self.test_user)) 207 | 208 | mock_step2_is_complete.return_value = True 209 | self.assertEqual('mock_complete1', tour1_class.get_next_url(self.test_user)) 210 | 211 | @patch('tour.tests.mocks.MockStep1.is_complete', spec_set=True) 212 | def test_is_complete(self, mock_step1_is_complete): 213 | """ 214 | Verifies that a tour returns true when complete and false when incomplete 215 | :type mock_step1_is_complete: Mock 216 | """ 217 | mock_step1_is_complete.return_value = False 218 | 219 | self.tour1.steps.add(self.step1) 220 | tour1_class = self.tour1.load_tour_class() 221 | 222 | self.assertFalse(tour1_class.is_complete(self.test_user)) 223 | 224 | mock_step1_is_complete.return_value = True 225 | self.assertTrue(tour1_class.is_complete(self.test_user)) 226 | 227 | 228 | class StepTest(BaseTourTest): 229 | """ 230 | Tests the functionality of the BaseStep class 231 | """ 232 | def test_init(self): 233 | """ 234 | Verifies that the step object is properly set when loaded 235 | """ 236 | self.assertEqual(self.step1.load_step_class().step, self.step1) 237 | 238 | def test_is_complete(self): 239 | """ 240 | Verifies that a step returns true by default 241 | """ 242 | step1_class = self.step1.load_step_class() 243 | self.assertTrue(step1_class.is_complete(self.test_user)) 244 | 245 | def test_get_steps_flat(self): 246 | """ 247 | Verifies that the steps are loaded in the correct order 248 | """ 249 | self.step1.steps.add(self.step2, self.step3) 250 | expected_steps = [self.step2, self.step3] 251 | self.assertEqual(expected_steps, self.step1.load_step_class().get_steps()) 252 | 253 | def test_get_steps_nested(self): 254 | """ 255 | Verifies that the nested steps are loaded correctly 256 | """ 257 | self.step1.steps.add(self.step2) 258 | self.step2.steps.add(self.step3, self.step4) 259 | expected_steps = [self.step2, self.step3, self.step4] 260 | self.assertEqual(expected_steps, self.step1.load_step_class().get_steps()) 261 | -------------------------------------------------------------------------------- /tour/tests/url_tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from tour.urls import urlpatterns 3 | 4 | 5 | class UrlsTest(TestCase): 6 | def test_that_urls_are_defined(self): 7 | """ 8 | Should have several urls defined. 9 | """ 10 | self.assertEqual(len(urlpatterns), 1) 11 | -------------------------------------------------------------------------------- /tour/tests/view_tests.py: -------------------------------------------------------------------------------- 1 | from mock import Mock, patch 2 | from tour.tests.mocks import MockView 3 | from tour.tests.tour_tests import BaseTourTest 4 | 5 | 6 | class ViewTest(BaseTourTest): 7 | """ 8 | Tests the functionality of all tour views and mixins 9 | """ 10 | def setUp(self): 11 | super(ViewTest, self).setUp() 12 | self.tour1.steps.add(self.step1, self.step2, self.step3) 13 | 14 | def test_no_tour(self): 15 | """ 16 | Verify that the user isn't redirected with no tour 17 | """ 18 | mock_request = Mock(user=self.test_user, path='mock2', method='get', GET={}) 19 | mock_view = MockView(request=mock_request) 20 | response = mock_view.dispatch(mock_request) 21 | self.assertEqual(200, response.status_code) 22 | 23 | @patch('tour.tests.mocks.MockStep1.is_complete', spec_set=True) 24 | def test_request_unrelated_page(self, mock_step1_is_complete): 25 | """ 26 | Verifies that user can go to a non-tour page 27 | :type mock_step1_is_complete: Mock 28 | """ 29 | mock_step1_is_complete.return_value = False 30 | 31 | self.tour1.load_tour_class().add_user(self.test_user) 32 | 33 | # request page that isn't in the tour before tour is complete 34 | mock_request = Mock(user=self.test_user, path='mock-fake', method='get', GET={}) 35 | mock_view = MockView(request=mock_request) 36 | response = mock_view.dispatch(mock_request) 37 | self.assertEqual(200, response.status_code) 38 | 39 | @patch('tour.tests.mocks.MockStep1.is_complete', spec_set=True) 40 | def test_request_first_step(self, mock_step1_is_complete): 41 | """ 42 | Request the first step of the tour and verify that the page is accessible 43 | :type mock_step1_is_complete: Mock 44 | """ 45 | mock_step1_is_complete.return_value = False 46 | 47 | self.tour1.load_tour_class().add_user(self.test_user) 48 | mock_request = Mock(user=self.test_user, path='mock1', method='get', GET={}) 49 | mock_view = MockView(request=mock_request) 50 | response = mock_view.dispatch(mock_request) 51 | self.assertEqual(200, response.status_code) 52 | 53 | @patch('tour.tests.mocks.MockStep1.is_complete', spec_set=True) 54 | def test_redirect_from_future_step(self, mock_step1_is_complete): 55 | """ 56 | Try to access the second step of the tour before the first is complete. The user should be 57 | redirected to the first step 58 | :type mock_step1_is_complete: Mock 59 | """ 60 | mock_step1_is_complete.return_value = False 61 | 62 | # do request to second step when we should be on first 63 | self.tour1.load_tour_class().add_user(self.test_user) 64 | mock_request = Mock(user=self.test_user, path='mock2', method='get', GET={}) 65 | mock_view = MockView(request=mock_request) 66 | response = mock_view.dispatch(mock_request) 67 | self.assertEqual(302, response.status_code) 68 | self.assertEqual('mock1', response.url) 69 | 70 | def test_tour_complete_url_redirect(self): 71 | """ 72 | Verifies that a user can't go to steps out of order and can't go to other steps 73 | after the tour is complete 74 | """ 75 | # complete tour and try to go to first step 76 | self.tour1.load_tour_class().add_user(self.test_user) 77 | mock_request = Mock(user=self.test_user, path='mock1', method='get', GET={}) 78 | mock_view = MockView(request=mock_request) 79 | response = mock_view.dispatch(mock_request) 80 | self.assertEqual(302, response.status_code) 81 | self.assertEqual('mock_complete1', response.url) 82 | 83 | def test_tour_complete_unrelated_page(self): 84 | """ 85 | Verifies that the user can access a non-tour page after finishing a tour 86 | """ 87 | # request page that isn't in the tour when tour is complete 88 | self.tour1.load_tour_class().add_user(self.test_user) 89 | mock_request = Mock(user=self.test_user, path='mock-fake', method='get', GET={}) 90 | mock_view = MockView(request=mock_request) 91 | response = mock_view.dispatch(mock_request) 92 | self.assertEqual(200, response.status_code) 93 | -------------------------------------------------------------------------------- /tour/tours.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from tour.models import TourStatus 4 | 5 | 6 | class BaseStep(object): 7 | """ 8 | Base step class that handles the creation of step records and determines when the step is complete 9 | """ 10 | def __init__(self, step): 11 | self.step = step 12 | 13 | def is_complete(self, user=None): 14 | """ 15 | This is meant to be implemented in subclasses. This checks conditions to determine if the step is complete 16 | """ 17 | return True 18 | 19 | def get_steps(self, depth=-1): 20 | """ 21 | Returns the steps in order based on if there is a parent or not 22 | TODO: optimize this 23 | """ 24 | all_steps = [] 25 | steps = self.step.steps.filter(parent_step=self.step).order_by('sort_order') 26 | for step in steps: 27 | all_steps.append(step) 28 | if depth != 0: 29 | all_steps.extend(step.load_step_class().get_steps(depth=depth - 1)) 30 | return all_steps 31 | 32 | 33 | class BaseTour(object): 34 | """ 35 | Base tour class that handles the creation of tour records and determines when tours are complete. 36 | """ 37 | def __init__(self, tour): 38 | # self.current_step_class = None 39 | self.tour = tour 40 | 41 | def get_steps(self, depth=-1): 42 | """ 43 | Returns the steps in order based on if there is a parent or not 44 | TODO: optimize this 45 | """ 46 | all_steps = [] 47 | steps = self.tour.steps.filter(parent_step=None).order_by('sort_order') 48 | for step in steps: 49 | all_steps.append(step) 50 | if depth != 0: 51 | all_steps.extend(step.load_step_class().get_steps(depth=depth - 1)) 52 | return all_steps 53 | 54 | def get_url_list(self): 55 | """ 56 | Returns a flattened list of urls of all steps contained in the tour. 57 | """ 58 | return [step.url for step in self.get_steps() if step.url] 59 | 60 | def add_user(self, user): 61 | """ 62 | Adds a relationship record for the user 63 | """ 64 | instance, created = TourStatus.objects.get_or_create(tour=self.tour, user=user, complete=False) 65 | return instance 66 | 67 | def mark_complete(self, user): 68 | """ 69 | Marks the tour status record as complete 70 | """ 71 | tour_status = self.tour.tourstatus_set.all().filter(tour=self.tour, user=user, complete=False).first() 72 | if tour_status: 73 | tour_status.complete = True 74 | tour_status.complete_time = datetime.datetime.utcnow() 75 | tour_status.save() 76 | return True 77 | return False 78 | 79 | def get_current_step(self, user): 80 | """ 81 | Finds the first incomplete steps and returns it 82 | :param user: The django user to find the current step for 83 | :type user: User 84 | :return: The first incomplete step 85 | :rtype: Step 86 | """ 87 | for step in self.get_steps(): 88 | if not step.load_step_class().is_complete(user): 89 | return step 90 | return None 91 | 92 | def get_next_url(self, user): 93 | """ 94 | Gets the next url based on the current step. 95 | """ 96 | current_step = self.get_current_step(user) 97 | return current_step.url if current_step else self.tour.complete_url 98 | 99 | def is_complete(self, user): 100 | """ 101 | Checks the state of the steps to see if they are all complete 102 | """ 103 | return False if self.get_current_step(user) else True 104 | -------------------------------------------------------------------------------- /tour/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, url 2 | from tour import api 3 | 4 | 5 | urlpatterns = patterns( 6 | '', 7 | url(r'^api/tour/$', api.TourApiView.as_view(), name='tour.tour_api'), 8 | ) 9 | -------------------------------------------------------------------------------- /tour/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.7.0' 2 | -------------------------------------------------------------------------------- /tour/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponseRedirect 2 | from tour.models import Tour 3 | 4 | 5 | class TourStepMixin(object): 6 | """ 7 | Provides a method for requiring navigation through the tour steps in order 8 | """ 9 | def tour_should_redirect(self, user, tour_class, current_index, next_index): 10 | # check if tour is incomplete 11 | if tour_class.get_current_step(user): 12 | # check if the current step is later in the tour than the expected step 13 | if current_index >= 0 and next_index >= 0: 14 | if current_index > next_index: 15 | return True 16 | else: 17 | # tour is complete, make sure we don't go backwards 18 | # check that the current page is a step of the tour and check if the current page 19 | # is the next page of the tour (which should be the last page) 20 | if current_index >= 0 and current_index != next_index: 21 | return True 22 | return False 23 | 24 | def get_user_tour(self, request): 25 | # Get the tour class for the user 26 | tour = Tour.objects.get_for_user(request.user) 27 | if tour is None: 28 | tour = Tour.objects.get_recent_tour(request.user) 29 | return tour 30 | 31 | def get_tour_redirect_url(self, request): 32 | tour = self.get_user_tour(request) 33 | if not tour: 34 | return None 35 | 36 | # Determine the current step and expected step indices 37 | tour_class = tour.load_tour_class() 38 | next_url = tour_class.get_next_url(request.user) 39 | url_list = tour_class.get_url_list() 40 | 41 | current_index = -1 42 | next_index = -1 43 | if request.path in url_list: 44 | current_index = url_list.index(request.path) 45 | if next_url in url_list: 46 | next_index = url_list.index(next_url) 47 | should_redirct = self.tour_should_redirect(request.user, tour_class, current_index, next_index) 48 | if should_redirct: 49 | return next_url 50 | return None 51 | 52 | def dispatch(self, request, *args, **kwargs): 53 | redirect_url = self.get_tour_redirect_url(request) 54 | if redirect_url: 55 | return HttpResponseRedirect(redirect_url) 56 | 57 | return super(TourStepMixin, self).dispatch(request, *args, **kwargs) 58 | --------------------------------------------------------------------------------