├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── conf.py ├── example_app.rst ├── index.rst ├── make.bat ├── quickstart.rst ├── upgrade_v2.rst └── userflow.rst ├── example ├── example │ ├── __init__.py │ ├── settings.py │ ├── templates │ │ ├── base.html │ │ ├── hunger │ │ │ ├── invalid.html │ │ │ ├── invite_email.email │ │ │ ├── invite_email.html │ │ │ ├── invite_email.txt │ │ │ ├── invite_email_subject.txt │ │ │ ├── invite_sent.html │ │ │ ├── not_in_beta.html │ │ │ ├── request_invite.html │ │ │ └── verified.html │ │ ├── profile.html │ │ └── registration │ │ │ ├── activate.html │ │ │ ├── activation_complete.html │ │ │ ├── activation_email.txt │ │ │ ├── activation_email_subject.txt │ │ │ ├── form.html │ │ │ ├── login.html │ │ │ ├── registration_complete.html │ │ │ └── registration_form.html │ ├── urls.py │ ├── views.py │ └── wsgi.py ├── manage.py └── requirements.txt ├── hunger ├── __init__.py ├── admin.py ├── email.py ├── forms.py ├── middleware.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── templates │ └── hunger │ │ ├── beta_confirm.email │ │ ├── beta_confirm.html │ │ ├── beta_confirm.txt │ │ ├── invalid.html │ │ ├── invite_email.email │ │ ├── invite_email.html │ │ ├── invite_email.txt │ │ └── invite_email_subject.txt ├── urls.py ├── utils.py └── views.py ├── pylintrc ├── runtests.py ├── setup.py ├── tests ├── __init__.py ├── always_allow_views.py ├── models.py ├── templates │ ├── default.html │ ├── hunger │ │ ├── invite_sent.html │ │ └── verified.html │ └── registration │ │ └── login.html ├── tests.py ├── urls.py └── views.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | syntax: glob 2 | # This line is a comment, and will be skipped. 3 | # Empty lines are skipped too. 4 | 5 | # Backup files left behind by the Emacs editor. 6 | *~ 7 | 8 | *.egg 9 | hunger.egg* 10 | 11 | # Lock files used by the Emacs editor. 12 | # Notice that the "#" character is quoted with a backslash. 13 | # This prevents it from being interpreted as starting a comment. 14 | .\#* 15 | 16 | # Temporary files used by the vim editor. 17 | .*.swp 18 | 19 | # A hidden file created by the Mac OS X Finder. 20 | .DS_Store 21 | 22 | #python compiler 23 | *.pyc 24 | 25 | #pid files 26 | *.pid 27 | 28 | # textmate projects # 29 | *.tmproj 30 | 31 | #temporary! textmate files 32 | ._*.* 33 | ._* 34 | 35 | # IntelliJ 36 | .idea/* 37 | *.iml 38 | 39 | #directories 40 | /media/admin 41 | /media/uploads/* 42 | /media/admin 43 | /media/c/.sass-cache 44 | /media/stats* 45 | .sass-cache 46 | /media/js/file-uploader 47 | /django_hunger.egg-info 48 | 49 | build/* 50 | _build 51 | .build 52 | dist/* 53 | 54 | .tox/ 55 | .coverage 56 | 57 | media/c/m.scss 58 | 59 | media/c/m.scss 60 | 61 | *.sqlite 62 | @* 63 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.4" 5 | env: 6 | - DJANGO=1.6.10 7 | - DJANGO=1.7.4 8 | install: 9 | - pip install -q Django==$DJANGO 10 | - pip install coveralls 11 | script: 12 | - coverage run --source=hunger setup.py test 13 | after_success: 14 | - coveralls 15 | matrix: 16 | exclude: 17 | - python: "3.4" 18 | env: DJANGO=1.5.12 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2011 by Joshua Karjala-Svendsen 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | 21 | Copyright (c) 2011 by Joshua Karjala-Svendsen 22 | All rights reserved. 23 | 24 | 25 | 26 | django-privatebeta 27 | 28 | Copyright (c) 2009, Pragmatic Badger, LLC. 29 | All rights reserved. 30 | 31 | Redistribution and use in source and binary forms, with or without modification, 32 | are permitted provided that the following conditions are met: 33 | 34 | 1. Redistributions of source code must retain the above copyright notice, 35 | this list of conditions and the following disclaimer. 36 | 37 | 2. Redistributions in binary form must reproduce the above copyright 38 | notice, this list of conditions and the following disclaimer in the 39 | documentation and/or other materials provided with the distribution. 40 | 41 | 3. Neither the name of Pragmatic Badger, LLC. nor the names of its 42 | contributors may be used to endorse or promote products derived from 43 | this software without specific prior written permission. 44 | 45 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 46 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 47 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 48 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 49 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 50 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 51 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 52 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 53 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 54 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | recursive-include hunger/templates * -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Hunger 2 | ====== 3 | 4 | .. image:: https://secure.travis-ci.org/joshuakarjala/django-hunger.png?branch=master 5 | :target: http://travis-ci.org/joshuakarjala/django-hunger 6 | 7 | Django-hunger provides a flexible private beta phase for Django 8 | projects. 9 | 10 | 11 | Features and Design Decisions 12 | ----------------------------- 13 | 14 | - Three ways to get into the beta. 15 | 16 | #. Users self-signup for the beta. An admin can choose to invite 17 | them at any time. 18 | #. An admin can grant in-beta users with a limited number of 19 | invites to invite their friends. 20 | #. An admin can create a limited number public beta code that 21 | anybody can use to join the beta. Useful for 22 | press releases. 23 | 24 | - Hunger is a post-registration app, meaning the intended behavior 25 | is to let users sign up freely, but restrict the rest of the site to 26 | beta participants. This makes it easy to integrate with social login 27 | and user management apps. 28 | 29 | - Email as the method of choice for communication. Emails are used to 30 | send people their invites. 31 | 32 | - Flexible design with many entry points for customization of default 33 | behavior. 34 | 35 | - TODO: Tracking and user analytics for the beta phase. Want to know 36 | which users are the most excited about your site? Find out by 37 | analyzing the invite graph. 38 | 39 | 40 | Documentation 41 | ------------- 42 | 43 | Check out the full documentation at http://django-hunger.readthedocs.org/. 44 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-hunger.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-hunger.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-hunger" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-hunger" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-hunger documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Mar 30 18:57:24 2012. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import os 15 | import pkg_resources 16 | import sys 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | #sys.path.insert(0, os.path.abspath('.')) 22 | 23 | # -- General configuration ----------------------------------------------------- 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | #needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be extensions 29 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 30 | extensions = ['sphinx.ext.intersphinx', 'sphinx.ext.viewcode'] 31 | 32 | # Add any paths that contain templates here, relative to this directory. 33 | templates_path = ['_templates'] 34 | 35 | # The suffix of source filenames. 36 | source_suffix = '.rst' 37 | 38 | # The encoding of source files. 39 | #source_encoding = 'utf-8-sig' 40 | 41 | # The master toctree document. 42 | master_doc = 'index' 43 | 44 | # General information about the project. 45 | project = u'django-hunger' 46 | copyright = u'2013, Joshua Karjala, Simon Ye' 47 | 48 | # The version info for the project you're documenting, acts as replacement for 49 | # |version| and |release|, also used in various other places throughout the 50 | # built documents. 51 | # 52 | # The short X.Y version. 53 | version = pkg_resources.get_distribution('hunger').version 54 | # The full version, including alpha/beta/rc tags. 55 | release = version 56 | 57 | # The language for content autogenerated by Sphinx. Refer to documentation 58 | # for a list of supported languages. 59 | #language = None 60 | 61 | # There are two options for replacing |today|: either, you set today to some 62 | # non-false value, then it is used: 63 | #today = '' 64 | # Else, today_fmt is used as the format for a strftime call. 65 | #today_fmt = '%B %d, %Y' 66 | 67 | # List of patterns, relative to source directory, that match files and 68 | # directories to ignore when looking for source files. 69 | exclude_patterns = ['_build'] 70 | 71 | # The reST default role (used for this markup: `text`) to use for all documents. 72 | #default_role = None 73 | 74 | # If true, '()' will be appended to :func: etc. cross-reference text. 75 | #add_function_parentheses = True 76 | 77 | # If true, the current module name will be prepended to all description 78 | # unit titles (such as .. function::). 79 | #add_module_names = True 80 | 81 | # If true, sectionauthor and moduleauthor directives will be shown in the 82 | # output. They are ignored by default. 83 | #show_authors = False 84 | 85 | # The name of the Pygments (syntax highlighting) style to use. 86 | pygments_style = 'sphinx' 87 | 88 | # A list of ignored prefixes for module index sorting. 89 | #modindex_common_prefix = [] 90 | 91 | 92 | # -- Options for HTML output --------------------------------------------------- 93 | 94 | # The theme to use for HTML and HTML Help pages. See the documentation for 95 | # a list of builtin themes. 96 | html_theme = 'default' 97 | 98 | # Theme options are theme-specific and customize the look and feel of a theme 99 | # further. For a list of options available for each theme, see the 100 | # documentation. 101 | #html_theme_options = {} 102 | 103 | # Add any paths that contain custom themes here, relative to this directory. 104 | #html_theme_path = [] 105 | 106 | # The name for this set of Sphinx documents. If None, it defaults to 107 | # " v documentation". 108 | #html_title = None 109 | 110 | # A shorter title for the navigation bar. Default is the same as html_title. 111 | #html_short_title = None 112 | 113 | # The name of an image file (relative to this directory) to place at the top 114 | # of the sidebar. 115 | #html_logo = None 116 | 117 | # The name of an image file (within the static path) to use as favicon of the 118 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 119 | # pixels large. 120 | #html_favicon = None 121 | 122 | # Add any paths that contain custom static files (such as style sheets) here, 123 | # relative to this directory. They are copied after the builtin static files, 124 | # so a file named "default.css" will overwrite the builtin "default.css". 125 | html_static_path = ['_static'] 126 | 127 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 128 | # using the given strftime format. 129 | #html_last_updated_fmt = '%b %d, %Y' 130 | 131 | # If true, SmartyPants will be used to convert quotes and dashes to 132 | # typographically correct entities. 133 | #html_use_smartypants = True 134 | 135 | # Custom sidebar templates, maps document names to template names. 136 | #html_sidebars = {} 137 | 138 | # Additional templates that should be rendered to pages, maps page names to 139 | # template names. 140 | #html_additional_pages = {} 141 | 142 | # If false, no module index is generated. 143 | #html_domain_indices = True 144 | 145 | # If false, no index is generated. 146 | #html_use_index = True 147 | 148 | # If true, the index is split into individual pages for each letter. 149 | #html_split_index = False 150 | 151 | # If true, links to the reST sources are added to the pages. 152 | #html_show_sourcelink = True 153 | 154 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 155 | #html_show_sphinx = True 156 | 157 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 158 | #html_show_copyright = True 159 | 160 | # If true, an OpenSearch description file will be output, and all pages will 161 | # contain a tag referring to it. The value of this option must be the 162 | # base URL from which the finished HTML is served. 163 | #html_use_opensearch = '' 164 | 165 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 166 | #html_file_suffix = None 167 | 168 | # Output file base name for HTML help builder. 169 | htmlhelp_basename = 'django-hungerdoc' 170 | 171 | 172 | # -- Options for LaTeX output -------------------------------------------------- 173 | 174 | latex_elements = { 175 | # The paper size ('letterpaper' or 'a4paper'). 176 | #'papersize': 'letterpaper', 177 | 178 | # The font size ('10pt', '11pt' or '12pt'). 179 | #'pointsize': '10pt', 180 | 181 | # Additional stuff for the LaTeX preamble. 182 | #'preamble': '', 183 | } 184 | 185 | # Grouping the document tree into LaTeX files. List of tuples 186 | # (source start file, target name, title, author, documentclass [howto/manual]). 187 | latex_documents = [ 188 | ('index', 'django-hunger.tex', u'django-hunger Documentation', 189 | u'Joshua Karjala, Simon Ye', 'manual'), 190 | ] 191 | 192 | # The name of an image file (relative to this directory) to place at the top of 193 | # the title page. 194 | #latex_logo = None 195 | 196 | # For "manual" documents, if this is true, then toplevel headings are parts, 197 | # not chapters. 198 | #latex_use_parts = False 199 | 200 | # If true, show page references after internal links. 201 | #latex_show_pagerefs = False 202 | 203 | # If true, show URL addresses after external links. 204 | #latex_show_urls = False 205 | 206 | # Documents to append as an appendix to all manuals. 207 | #latex_appendices = [] 208 | 209 | # If false, no module index is generated. 210 | #latex_domain_indices = True 211 | 212 | 213 | # -- Options for manual page output -------------------------------------------- 214 | 215 | # One entry per manual page. List of tuples 216 | # (source start file, name, description, authors, manual section). 217 | man_pages = [ 218 | ('index', 'django-hunger', u'django-hunger Documentation', 219 | [u'Joshua Karjala, Simon Ye'], 1) 220 | ] 221 | 222 | # If true, show URL addresses after external links. 223 | #man_show_urls = False 224 | 225 | 226 | # -- Options for Texinfo output ------------------------------------------------ 227 | 228 | # Grouping the document tree into Texinfo files. List of tuples 229 | # (source start file, target name, title, author, 230 | # dir menu entry, description, category) 231 | texinfo_documents = [ 232 | ('index', 'django-hunger', u'django-hunger Documentation', 233 | u'Joshua Karjala, Simon Ye', 'django-hunger', 'One line description of project.', 234 | 'Miscellaneous'), 235 | ] 236 | 237 | # Documents to append as an appendix to all manuals. 238 | #texinfo_appendices = [] 239 | 240 | # If false, no module index is generated. 241 | #texinfo_domain_indices = True 242 | 243 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 244 | #texinfo_show_urls = 'footnote' 245 | 246 | 247 | # Example configuration for intersphinx: refer to the Python standard library. 248 | intersphinx_mapping = {'http://docs.python.org/': None} 249 | -------------------------------------------------------------------------------- /docs/example_app.rst: -------------------------------------------------------------------------------- 1 | Trying the Example App 2 | ====================== 3 | 4 | Clone the repo and run the included example django project:: 5 | 6 | git clone git://github.com/joshuakarjala/django-hunger.git 7 | cd django-hunger/example 8 | pip install -r requirements.txt 9 | python manage.py syncdb 10 | python manage.py runserver 11 | 12 | Guide 13 | ----- 14 | 15 | The example app utilizes a basic configuration with 16 | `django-registration 17 | `_ for 18 | verifying emails. Therefore the list of views in 19 | ``HUNGER_ALWAYS_ALLOW_VIEWS`` utlizes the ``registration_*`` views 20 | instead of ``django.contrib.auth.views`` for registration. 21 | 22 | Note that the email backend being used in the example is the console 23 | backend, meaning that all emails are printed to the console. 24 | 25 | Once the example project is running, registering an ordinary user will 26 | result in the creation of the account + activation through email. 27 | After registration, the standard user does not have beta access and 28 | will be restricted. 29 | 30 | To grant beta access to the created user, simply sign into the admin 31 | site at ``/admin/``, click on ``Invitations``, and invite the 32 | invitation corresponding to the registered user via the admin actions 33 | menu. 34 | 35 | Signing in as the original user will result in being able to access 36 | protected beta content. 37 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | django-hunger Documentation 2 | ========================================= 3 | 4 | Contents: 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | quickstart 10 | userflow 11 | internals 12 | settings 13 | example_app 14 | upgrade_v2 15 | 16 | 17 | Overview 18 | -------- 19 | 20 | - Three ways to get into the beta. 21 | 22 | #. Users self-signup for the beta. An admin can choose to invite 23 | them at any time. 24 | #. An admin can grant in-beta users with a limited number of 25 | invites to invite their friends. 26 | #. An admin can create a limited number public beta code that 27 | anybody can use to join the beta. Useful for 28 | press releases. 29 | 30 | - Hunger is a post-registration app, meaning the intended behavior 31 | is to let users sign up freely, but restrict the rest of the site to 32 | beta participants. This makes it easy to integrate with social login 33 | and user management apps. 34 | 35 | - Email as the method of choice for communication. Emails are used to 36 | send people their invites. 37 | 38 | - Flexible design with many entry points for customization of default 39 | behavior. 40 | 41 | - TODO: Tracking and user analytics for the beta phase. Want to know 42 | which users are the most excited about your site? Find out by 43 | analyzing the invite graph. 44 | 45 | 46 | Quickstart 47 | ---------- 48 | 49 | To get started, see :ref:`ref-quickstart`. 50 | 51 | 52 | Indices and tables 53 | ================== 54 | 55 | * :ref:`genindex` 56 | * :ref:`modindex` 57 | * :ref:`search` 58 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-hunger.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-hunger.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | .. _ref-quickstart: 2 | 3 | ========== 4 | Quickstart 5 | ========== 6 | 7 | Installation and Basic Configuration 8 | ------------------------------------ 9 | 10 | - Install ``django-hunger`` using ``pip`` or ``easy_install``. 11 | - Add ``hunger`` to ``INSTALLED_APPS`` in settings.py. 12 | - Add ``hunger.middleware.BetaMiddleware`` to ``MIDDLEWARE_CLASSES``. 13 | - Add ``url(r'hunger', include('hunger.urls'))`` to your urlconf. 14 | - List views that all users may see at any time regardless of beta 15 | status in the setting ``HUNGER_ALWAYS_ALLOW_VIEWS`` and 16 | ``HUNGER_ALWAYS_ALLOW_MODULES``. The views setting accepts views by 17 | function name or urlpattern name, while the modules accepts a string 18 | module name where all included views are always allowed. Typically, 19 | you at least want to let people register, login, and recover 20 | password, so include the auth urls:: 21 | 22 | HUNGER_ALWAYS_ALLOW_MODULES = [ 23 | 'django.contrib.auth.views' 24 | ] 25 | 26 | - Create template ``hunger/not_in_beta.html``, which is the page a 27 | user is redirected to when trying to access protected content when 28 | not in the beta. 29 | - Create template ``hunger/verified.html``, which is the page 30 | after the user successfully has joined and verified their in-beta 31 | status. 32 | - Create template ``hunger/invalid.html``, which is the page a user 33 | sees when they try to use a code but it is invalid. It could be a 34 | code that doesn't exist or one that has run out of invites. 35 | - [Optional] Create template ``hunger/invite_sent.html``, which is the 36 | page a user sees after sending invites to other people. You don't 37 | need this if you only want to allow admins and staff to send 38 | invites. 39 | - Create the email template ``hunger/invite_email.[html/txt]`` and 40 | email subject template ``hunger/invite_email_subject.txt``. These 41 | templates are rendered like Django templates with a simple context 42 | described in ``hunger/email.py``. To ensure the best user 43 | experience, you should provide both the html and txt email 44 | templates. Example versions of these templates are in 45 | ``example/example/templates/hunger/``. 46 | 47 | 48 | Advanced Configuration 49 | ---------------------- 50 | 51 | The basic configuration basically involves creating a bunch of static 52 | templates for various pre-configured views. For a more advanced 53 | configuration, you can let hunger use your own urls, views, and 54 | templates using custom redirect settings. All redirect targets must be 55 | valid targets for the built-in ``django.shortcuts.redirect`` function. 56 | 57 | - ``HUNGER_REDIRECT`` for users accessing protected content while not 58 | in the beta. 59 | - ``HUNGER_VERIFIED_REDIRECT`` for the page a user sees after 60 | successfully joining and verifying in-beta status. 61 | - ``HUNGER_INVITE_SENT_REDIRECT`` for the page a user sees after 62 | sending invites to others. 63 | - ``HUNGER_EMAIL_TEMPLATES_DIR`` for a different directory holding the 64 | invite email templates. 65 | - ``HUNGER_EMAIL_INVITE_FUNCTION`` for a different function to call 66 | when sending email invites. Takes precedence over 67 | ``HUNGER_EMAIL_TEMPLATES_DIR``. 68 | 69 | 70 | Integration with django_templated_email 71 | --------------------------------------- 72 | 73 | If `django_templated_email `_ 74 | is installed, you can use a customized ``*.email`` template for beta invites. 75 | 76 | And create the following template:: 77 | 78 | /templates/hunger/invite_email.email 79 | -------------------------------------------------------------------------------- /docs/upgrade_v2.rst: -------------------------------------------------------------------------------- 1 | Upgrading to Version 2 2 | ====================== 3 | 4 | Version 2 of Hunger is very different from v1. In v1 of the app, the 5 | private beta phase was a pre-registration phase, meaning that it 6 | operated by limiting access to registration until a user is invited to 7 | the beta. In v2, the private beta becase a post-registration phase, 8 | meaning that all users are allowed to register, but limiting access to 9 | the rest of the site. 10 | 11 | Due to the drastic differences between the two versions, there will be 12 | no upgrade path between the two versions. However, for all future 13 | upgrades, South migrations will be provided. 14 | -------------------------------------------------------------------------------- /docs/userflow.rst: -------------------------------------------------------------------------------- 1 | .. _ref-userflow: 2 | 3 | ========= 4 | User Flow 5 | ========= 6 | 7 | 8 | Cases 9 | ----- 10 | 11 | #. User is unregistered 12 | 13 | * Can only visit views listed in ``HUNGER_ALWAYS_ALLOW_VIEWS`` or 14 | views in modules listed in ``HUNGER_ALWAYS_ALLOW_MODULES``. The 15 | views list may refer to the view by the view's function name or 16 | urlpattern name. 17 | 18 | * All other urls redirect to ``HUNGER_REDIRECT``. 19 | * If registration view is listed above, then the user can register 20 | for an account that doesn't yet have beta access. 21 | * If invited by a friend by receiving an email with a beta invite 22 | link, then the code is stored in a cookie. When the user 23 | registers, then they are automatically placed into the beta. 24 | * If registering via a public beta code, the code is similarly 25 | placed in a cookie, where later registration will place the user 26 | automatically in the beta and the invitation code count 27 | decrements by 1. 28 | 29 | #. User is registered but not in beta 30 | 31 | * An admin can invite that specific user to the beta through the 32 | Django admin interface. 33 | * The user can be invited by another beta who has beta access, 34 | provided that the inviter has enough invitations to send the 35 | invitation. The user clicks a link. 36 | * The user can join the beta themselves by using a public beta code 37 | as long as the code has enough uses left. 38 | * Upon being verified as in-beta, the user is redirected to 39 | ``HUNGER_VERIFIED_REDIRECT``. 40 | 41 | #. User is registered and in beta 42 | 43 | * An admin can dispense invitations so that users can invite their 44 | friends. 45 | * Invitations in hand, the user invites friends via either email or 46 | their username if applicable. 47 | * Otherwise, allow user to invite friends at-will. 48 | -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuakarjala/django-hunger/712684dd6ff8b776db6c3245ec0a134e9cd7a66b/example/example/__init__.py -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for example project. 2 | 3 | DEBUG = True 4 | TEMPLATE_DEBUG = DEBUG 5 | 6 | ADMINS = ( 7 | # ('Your Name', 'your_email@example.com'), 8 | ) 9 | 10 | MANAGERS = ADMINS 11 | 12 | DATABASES = { 13 | 'default': { 14 | 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'. 15 | 'NAME': 'example.sqlite', # Or path to database file if using sqlite3. 16 | 'USER': '', # Not used with sqlite3. 17 | 'PASSWORD': '', # Not used with sqlite3. 18 | 'HOST': '', # Set to empty string for localhost. Not used with sqlite3. 19 | 'PORT': '', # Set to empty string for default. Not used with sqlite3. 20 | } 21 | } 22 | 23 | # Local time zone for this installation. Choices can be found here: 24 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 25 | # although not all choices may be available on all operating systems. 26 | # In a Windows environment this must be set to your system time zone. 27 | TIME_ZONE = 'America/Chicago' 28 | 29 | # Language code for this installation. All choices can be found here: 30 | # http://www.i18nguy.com/unicode/language-identifiers.html 31 | LANGUAGE_CODE = 'en-us' 32 | 33 | SITE_ID = 1 34 | 35 | # If you set this to False, Django will make some optimizations so as not 36 | # to load the internationalization machinery. 37 | USE_I18N = True 38 | 39 | # If you set this to False, Django will not format dates, numbers and 40 | # calendars according to the current locale. 41 | USE_L10N = True 42 | 43 | # If you set this to False, Django will not use timezone-aware datetimes. 44 | USE_TZ = True 45 | 46 | # Absolute filesystem path to the directory that will hold user-uploaded files. 47 | # Example: "/home/media/media.lawrence.com/media/" 48 | MEDIA_ROOT = '' 49 | 50 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 51 | # trailing slash. 52 | # Examples: "http://media.lawrence.com/media/", "http://example.com/media/" 53 | MEDIA_URL = '' 54 | 55 | # Absolute path to the directory static files should be collected to. 56 | # Don't put anything in this directory yourself; store your static files 57 | # in apps' "static/" subdirectories and in STATICFILES_DIRS. 58 | # Example: "/home/media/media.lawrence.com/static/" 59 | STATIC_ROOT = '' 60 | 61 | # URL prefix for static files. 62 | # Example: "http://media.lawrence.com/static/" 63 | STATIC_URL = '/static/' 64 | 65 | # Additional locations of static files 66 | STATICFILES_DIRS = ( 67 | # Put strings here, like "/home/html/static" or "C:/www/django/static". 68 | # Always use forward slashes, even on Windows. 69 | # Don't forget to use absolute paths, not relative paths. 70 | ) 71 | 72 | # List of finder classes that know how to find static files in 73 | # various locations. 74 | STATICFILES_FINDERS = ( 75 | 'django.contrib.staticfiles.finders.FileSystemFinder', 76 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 77 | # 'django.contrib.staticfiles.finders.DefaultStorageFinder', 78 | ) 79 | 80 | # Make this unique, and don't share it with anybody. 81 | SECRET_KEY = 'y#rmfqpl68yg!=!ue7^(y^^sdbfrph-p*$oc0398$m@ayff@c6' 82 | 83 | # List of callables that know how to import templates from various sources. 84 | TEMPLATE_LOADERS = ( 85 | 'django.template.loaders.filesystem.Loader', 86 | 'django.template.loaders.app_directories.Loader', 87 | # 'django.template.loaders.eggs.Loader', 88 | ) 89 | 90 | MIDDLEWARE_CLASSES = ( 91 | 'django.middleware.common.CommonMiddleware', 92 | 'django.contrib.sessions.middleware.SessionMiddleware', 93 | 'django.middleware.csrf.CsrfViewMiddleware', 94 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 95 | 'django.contrib.messages.middleware.MessageMiddleware', 96 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 97 | 'hunger.middleware.BetaMiddleware', 98 | ) 99 | 100 | ROOT_URLCONF = 'example.urls' 101 | 102 | # Python dotted path to the WSGI application used by Django's runserver. 103 | WSGI_APPLICATION = 'example.wsgi.application' 104 | 105 | TEMPLATE_DIRS = ( 106 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". 107 | # Always use forward slashes, even on Windows. 108 | # Don't forget to use absolute paths, not relative paths. 109 | ) 110 | 111 | # One-week activation window; you may, of course, use a different value. 112 | ACCOUNT_ACTIVATION_DAYS = 7 113 | 114 | EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 115 | 116 | HUNGER_ALWAYS_ALLOW_VIEWS = ( 117 | 'registration_activation_complete', 118 | 'registration_activate', 119 | 'registration_complete', 120 | 'registration_disallowed', 121 | 'registration_register', 122 | 'home', 123 | ) 124 | 125 | 126 | INSTALLED_APPS = ( 127 | 'django.contrib.auth', 128 | 'django.contrib.contenttypes', 129 | 'django.contrib.sessions', 130 | 'django.contrib.sites', 131 | 'django.contrib.messages', 132 | 'django.contrib.staticfiles', 133 | 'django.contrib.admin', 134 | 'django.contrib.admindocs', 135 | 'registration', 136 | 'hunger', 137 | 'example', 138 | ) 139 | 140 | # A sample logging configuration. The only tangible logging 141 | # performed by this configuration is to send an email to 142 | # the site admins on every HTTP 500 error when DEBUG=False. 143 | # See http://docs.djangoproject.com/en/dev/topics/logging for 144 | # more details on how to customize your logging configuration. 145 | LOGGING = { 146 | 'version': 1, 147 | 'disable_existing_loggers': False, 148 | 'filters': { 149 | 'require_debug_false': { 150 | '()': 'django.utils.log.RequireDebugFalse' 151 | } 152 | }, 153 | 'handlers': { 154 | 'mail_admins': { 155 | 'level': 'ERROR', 156 | 'filters': ['require_debug_false'], 157 | 'class': 'django.utils.log.AdminEmailHandler' 158 | } 159 | }, 160 | 'loggers': { 161 | 'django.request': { 162 | 'handlers': ['mail_admins'], 163 | 'level': 'ERROR', 164 | 'propagate': True, 165 | }, 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /example/example/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | Hunger Demo 14 | 15 | 16 | 17 | 43 |
44 | {% block content %} 45 |

46 | Django Hunger Demo 47 |

48 |
49 |

50 | Welcome to the Django Hunger demo app! This example project will demonstrate a basic configuration of django-registration with django-hunger. 51 |

52 |
53 |
54 |

55 | Invite someone to the beta 56 |

57 |
58 | {% endblock %} 59 |
60 | 61 | 62 | -------------------------------------------------------------------------------- /example/example/templates/hunger/invalid.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |
5 |

You have an invalid invite code

6 |
7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /example/example/templates/hunger/invite_email.email: -------------------------------------------------------------------------------- 1 | {% block subject %}Here is your invite{% endblock %} 2 | 3 | {% block plain %} 4 | Visit {{ invite_url }} to join the private beta. 5 | {% endblock %} 6 | 7 | {% block html %} 8 | Visit {{ invite_url }} to join the private beta. 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /example/example/templates/hunger/invite_email.html: -------------------------------------------------------------------------------- 1 | Visit {{ invite_url }} to join the private beta. 2 | -------------------------------------------------------------------------------- /example/example/templates/hunger/invite_email.txt: -------------------------------------------------------------------------------- 1 | Visit {{ invite_url }} to join the private beta. 2 | -------------------------------------------------------------------------------- /example/example/templates/hunger/invite_email_subject.txt: -------------------------------------------------------------------------------- 1 | Here is your invite 2 | -------------------------------------------------------------------------------- /example/example/templates/hunger/invite_sent.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |
5 |

Invite has been sent!

6 |
7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /example/example/templates/hunger/not_in_beta.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load url from future %} 3 | 4 | {% block content %} 5 |
6 |

Sorry

7 |

Sorry you're not in the beta yet!

8 |
9 | 10 | 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /example/example/templates/hunger/request_invite.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |
5 |

Invite someone else to the beta!

6 |
7 | {% csrf_token %} 8 | {{ form }} 9 | 10 |
11 |
12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /example/example/templates/hunger/verified.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |
5 |

Your invite code has been verified!

6 |
7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /example/example/templates/profile.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |
5 |

Dashboard

6 |
7 | 8 |
9 |
10 | If you are seeing this, then you are part of the beta. Congratulations! 11 |
12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /example/example/templates/registration/activate.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load url from future %} 3 | 4 | {% block content %} 5 |

Account Activation Error

6 |

There was a problem activating your account. For more help please email support@example.com. Your activation key was: {{ activation_key }}

7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /example/example/templates/registration/activation_complete.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load url from future %} 3 | 4 | {% block content %} 5 |

Account Successfully Activated

6 |

Your account has been activated. Click here to return home.

7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /example/example/templates/registration/activation_email.txt: -------------------------------------------------------------------------------- 1 | Click here to activate your account on Django Hunger Example: {{ site.domain }}/accounts/activate/{{ activation_key }}/ 2 | 3 | This link will expire in {{ expiration_days }} days. 4 | -------------------------------------------------------------------------------- /example/example/templates/registration/activation_email_subject.txt: -------------------------------------------------------------------------------- 1 | Activation Link for Django Hunger Example 2 | -------------------------------------------------------------------------------- /example/example/templates/registration/form.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |

5 |

Registration

6 |
7 | 8 |
9 |
10 | {% csrf_token %} 11 | {{ form }} 12 | 13 | 14 |
15 |
16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /example/example/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |
5 |

Login

6 |
7 | 8 |
9 |
10 |
11 | {% csrf_token %} 12 | {{ form }} 13 |
14 | 15 | 16 | or register here 17 |
18 | 21 |
22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /example/example/templates/registration/registration_complete.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load url from future %} 3 | 4 | {% block content %} 5 |

Registration Complete

6 |

An account activation email will be sent shortly. Please check your email to activate your account.

7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /example/example/templates/registration/registration_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |
5 |

Registration

6 |
7 | 8 |
9 |
10 | {% csrf_token %} 11 | {{ form }} 12 |
13 | 14 | 15 |
16 |
17 | 18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, include, url 2 | 3 | from django.contrib import admin 4 | admin.autodiscover() 5 | 6 | urlpatterns = patterns( 7 | '', 8 | url(r'^admin/doc/', include('django.contrib.admindocs.urls')), 9 | url(r'^admin/', include(admin.site.urls)), 10 | url(r'', include('django.contrib.auth.urls')), 11 | url(r'^accounts/', include('registration.backends.default.urls')), 12 | url(r'^$', 'example.views.home', name='home'), 13 | url(r'^hunger/', include('hunger.urls')), 14 | url(r'^accounts/profile/$', 'example.views.profile', name='profile'), 15 | ) 16 | -------------------------------------------------------------------------------- /example/example/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | 4 | def home(request): 5 | return render(request, 'base.html') 6 | 7 | 8 | def nonbeta(request): 9 | return render(request, 'nonbeta.html') 10 | 11 | 12 | def profile(request): 13 | return render(request, 'profile.html') 14 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | This module contains the WSGI application used by Django's development server 5 | and any production WSGI deployments. It should expose a module-level variable 6 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 7 | this application via the ``WSGI_APPLICATION`` setting. 8 | 9 | Usually you will have the standard Django WSGI application here, but it also 10 | might make sense to replace the whole Django WSGI application with a custom one 11 | that later delegates to the Django one. For example, you could introduce WSGI 12 | middleware here, or combine a Django application with an application of another 13 | framework. 14 | 15 | """ 16 | import os 17 | 18 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 19 | 20 | # This application object is used by any WSGI server configured to use this 21 | # file. This includes Django's development server, if the WSGI_APPLICATION 22 | # setting points here. 23 | from django.core.wsgi import get_wsgi_application 24 | application = get_wsgi_application() 25 | 26 | # Apply WSGI middleware here. 27 | # from helloworld.wsgi import HelloWorldApplication 28 | # application = HelloWorldApplication(application) 29 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /example/requirements.txt: -------------------------------------------------------------------------------- 1 | Django==1.7.2 2 | django-hunger==2.1.1 3 | django-registration-redux==1.1 4 | -------------------------------------------------------------------------------- /hunger/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Hunger is a Django app to manage a private beta phase. 3 | """ 4 | import pkg_resources 5 | 6 | __version__ = pkg_resources.get_distribution('django_hunger').version 7 | 8 | VERSION = __version__ 9 | -------------------------------------------------------------------------------- /hunger/admin.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | import csv 3 | from datetime import datetime 4 | from django.contrib import admin 5 | from django.http import HttpResponse 6 | from hunger.models import InvitationCode, Invitation 7 | from hunger.utils import now 8 | from hunger.forms import InvitationCodeAdminForm 9 | 10 | CSV_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" 11 | 12 | 13 | def export_email(modeladmin, request, queryset): 14 | response = HttpResponse(mimetype='text/csv') 15 | response['Content-Disposition'] = 'attachment; filename=email.csv' 16 | writer = csv.writer(response) 17 | 18 | writer.writerow(['email', 'created', 'invited', 'used']) 19 | 20 | for obj in queryset: 21 | code = obj.code 22 | email = obj.user.email 23 | used = obj.used 24 | invited = obj.invited 25 | created = datetime.strftime(code.created, CSV_DATE_FORMAT) 26 | try: 27 | invited = datetime.strftime(obj.invited, CSV_DATE_FORMAT) 28 | except TypeError: 29 | invited = '' 30 | try: 31 | used = datetime.strftime(obj.used, CSV_DATE_FORMAT) 32 | except TypeError: 33 | used = '' 34 | 35 | if email: 36 | row = [email, created, invited, used] 37 | writer.writerow(row) 38 | # Return CSV file to browser as download 39 | return response 40 | 41 | 42 | def send_invite(modeladmin, request, queryset): 43 | for obj in queryset: 44 | if not obj.invited: 45 | obj.invited = now() 46 | obj.save(send_email=True, request=request) 47 | 48 | 49 | def resend_invite(modeladmin, request, queryset): 50 | for obj in queryset: 51 | if obj.invited: 52 | obj.save(send_email=True, request=request) 53 | 54 | 55 | class InvitationAdmin(admin.ModelAdmin): 56 | list_display = ('user', 'code', 'used', 'invited') 57 | list_filter = ('code',) 58 | search_fields = ['user__username', 'user__email'] 59 | actions = [send_invite, resend_invite, export_email] 60 | 61 | 62 | class InvitationCodeAdmin(admin.ModelAdmin): 63 | """Admin for invitation code""" 64 | form = InvitationCodeAdminForm 65 | list_display = ('code', 'num_invites', 'owner', ) 66 | filter_horizontal = ('invited_users', ) 67 | search_fields = ['created_by__email', 'owner__username'] 68 | 69 | 70 | admin.site.register(Invitation, InvitationAdmin) 71 | admin.site.register(InvitationCode, InvitationCodeAdmin) 72 | -------------------------------------------------------------------------------- /hunger/email.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | import os.path 3 | from django.conf import settings 4 | from django.core.mail import EmailMultiAlternatives 5 | from django.core.urlresolvers import reverse 6 | from django.template.loader import get_template 7 | from django.template import RequestContext 8 | from hunger.utils import setting 9 | 10 | try: 11 | from templated_email import send_templated_mail 12 | templated_email_available = True 13 | except ImportError: 14 | templated_email_available = False 15 | 16 | 17 | def beta_invite(email, request, code=None, **kwargs): 18 | """ 19 | Email for sending out the invitation code to the user. 20 | Invitation URL is added to the context, so it can be rendered with standard 21 | django template engine. 22 | """ 23 | context_dict = kwargs.copy() 24 | if code: 25 | invite_url = request.build_absolute_uri( 26 | reverse('hunger-verify', args=[code])) 27 | else: 28 | invite_url = setting('HUNGER_VERIFIED_REDIRECT') 29 | context_dict.setdefault('invite_url', invite_url) 30 | 31 | context = RequestContext(request, context_dict) 32 | 33 | templates_folder = setting('HUNGER_EMAIL_TEMPLATES_DIR') 34 | templates_folder = os.path.join(templates_folder, '') 35 | from_email = kwargs.get('from_email', 36 | getattr(settings, 'DEFAULT_FROM_EMAIL')) 37 | if templates_folder == 'hunger': 38 | file_extension = 'email' 39 | else: 40 | file_extension = None 41 | 42 | if templated_email_available: 43 | send_templated_mail( 44 | template_name='invite_email', 45 | from_email=from_email, 46 | recipient_list=[email], 47 | context=context_dict, 48 | template_dir=templates_folder, 49 | file_extension=file_extension, 50 | ) 51 | else: 52 | plaintext = get_template(os.path.join(templates_folder, 53 | 'invite_email.txt')) 54 | invite_path = os.path.join(templates_folder, 'invite_email.html') 55 | html = get_template(invite_path) 56 | 57 | subject_path = os.path.join(templates_folder, 58 | 'invite_email_subject.txt') 59 | subject = get_template(subject_path).render(context).strip() 60 | to = email 61 | text_content = plaintext.render(context) 62 | html_content = html.render(context) 63 | msg = EmailMultiAlternatives(subject, text_content, from_email, [to], 64 | headers={'From': '%s' % from_email}) 65 | msg.attach_alternative(html_content, "text/html") 66 | msg.send() 67 | -------------------------------------------------------------------------------- /hunger/forms.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from django import forms 3 | from hunger.models import Invitation, InvitationCode 4 | 5 | 6 | class InviteSendForm(forms.ModelForm): 7 | class Meta: 8 | model = Invitation 9 | fields = ('email',) 10 | 11 | def __init__(self, *args, **kwargs): 12 | super(InviteSendForm, self).__init__(*args, **kwargs) 13 | 14 | # When sending an invitation, the email address is required 15 | self.fields['email'].required = True 16 | 17 | 18 | class InvitationCodeAdminForm(forms.ModelForm): 19 | code = forms.CharField( 20 | initial=lambda: InvitationCode.generate_invite_code()) 21 | 22 | class Meta: 23 | model = InvitationCode 24 | exclude = () 25 | -------------------------------------------------------------------------------- /hunger/middleware.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from django.utils import six 3 | from django.conf import settings 4 | from django.core.urlresolvers import reverse, resolve 5 | from django.shortcuts import redirect 6 | from django.db.models import Q 7 | from hunger.models import InvitationCode, Invitation 8 | from hunger.utils import setting, now 9 | 10 | 11 | class BetaMiddleware(object): 12 | """ 13 | Add this to your ``MIDDLEWARE_CLASSES`` make all views except for 14 | those in the account application require that a user be logged in. 15 | This can be a quick and easy way to restrict views on your site, 16 | particularly if you remove the ability to create accounts. 17 | 18 | **Settings:** 19 | 20 | ``HUNGER_ENABLE_BETA`` 21 | Whether or not the beta middleware should be used. If set to 22 | `False` the BetaMiddleware middleware will be ignored and the 23 | request will be returned. This is useful if you want to 24 | disable privatebeta on a development machine. Default is 25 | `True`. 26 | 27 | ``HUNGER_ALWAYS_ALLOW_VIEWS`` 28 | A list of full view names that should always pass through. 29 | 30 | ``HUNGER_ALWAYS_ALLOW_MODULES`` 31 | A list of modules that should always pass through. All 32 | views in ``django.contrib.auth.views``, ``django.views.static`` 33 | and ``hunger.views`` will pass through. 34 | 35 | ``HUNGER_REDIRECT`` 36 | The redirect when not in beta. 37 | """ 38 | 39 | def __init__(self): 40 | self.enable_beta = setting('HUNGER_ENABLE') 41 | 42 | self.always_allow_views = setting('HUNGER_ALWAYS_ALLOW_VIEWS') 43 | self.always_allow_modules = setting('HUNGER_ALWAYS_ALLOW_MODULES') 44 | self.redirect = setting('HUNGER_REDIRECT') 45 | self.allow_flatpages = setting('HUNGER_ALLOW_FLATPAGES') 46 | 47 | def process_view(self, request, view_func, view_args, view_kwargs): 48 | if not self.enable_beta: 49 | return 50 | 51 | if (request.path in self.allow_flatpages or 52 | (getattr(settings, 'APPEND_SLASH', True) and 53 | '%s/' % request.path in self.allow_flatpages)): 54 | from django.contrib.flatpages.views import flatpage 55 | return flatpage(request, request.path_info) 56 | 57 | whitelisted_modules = ['django.contrib.auth.views', 58 | 'django.contrib.admin.sites', 59 | 'django.views.static', 60 | 'django.contrib.staticfiles.views'] 61 | 62 | # All hunger views, except NotBetaView, are off limits until in beta 63 | whitelisted_views = ['hunger.views.NotBetaView', 64 | 'hunger.views.verify_invite', 65 | 'hunger.views.InvalidView'] 66 | 67 | short_name = view_func.__class__.__name__ 68 | if short_name == 'function': 69 | short_name = view_func.__name__ 70 | view_name = self._get_view_name(request) 71 | 72 | full_view_name = '%s.%s' % (view_func.__module__, short_name) 73 | 74 | if self.always_allow_modules: 75 | whitelisted_modules += self.always_allow_modules 76 | 77 | if '%s' % view_func.__module__ in whitelisted_modules: 78 | return 79 | 80 | if self.always_allow_views: 81 | whitelisted_views += self.always_allow_views 82 | 83 | if (full_view_name in whitelisted_views or 84 | view_name in whitelisted_views): 85 | return 86 | 87 | if not request.user.is_authenticated(): 88 | # Ask anonymous user to log in if trying to access in-beta view 89 | return redirect(setting('LOGIN_URL')) 90 | 91 | if request.user.is_staff: 92 | return 93 | 94 | # Prevent queries by caching in_beta status in session 95 | if request.session.get('hunger_in_beta'): 96 | return 97 | 98 | cookie_code = request.COOKIES.get('hunger_code') 99 | invitations = Invitation.objects.filter( 100 | Q(user=request.user) | 101 | Q(email=request.user.email) 102 | ).select_related('code') 103 | 104 | # User already in the beta - cache in_beta in session 105 | if any([i.used for i in invitations if i.invited]): 106 | request.session['hunger_in_beta'] = True 107 | return 108 | 109 | # User has been invited - use the invitation and place in beta. 110 | activates = [i for i in invitations if i.invited and not i.used] 111 | 112 | # Check for matching cookie code if available. 113 | if cookie_code: 114 | for invitation in activates: 115 | if invitation.code and invitation.code.code == cookie_code: 116 | # Invitation may be attached to email 117 | invitation.user = request.user 118 | invitation.used = now() 119 | invitation.save() 120 | request.session['hunger_in_beta'] = True 121 | request._hunger_delete_cookie = True 122 | return 123 | 124 | # No cookie - let's just choose the first invitation if it exists 125 | if activates: 126 | invitation = activates[0] 127 | # Invitation may be attached to email 128 | invitation.user = request.user 129 | invitation.used = now() 130 | invitation.save() 131 | request.session['hunger_in_beta'] = True 132 | return 133 | 134 | if not cookie_code: 135 | if not invitations: 136 | invitation = Invitation(user=request.user) 137 | invitation.save() 138 | return redirect(self.redirect) 139 | 140 | # No invitation, all we have is this cookie code 141 | try: 142 | code = InvitationCode.objects.get(code=cookie_code, 143 | num_invites__gt=0) 144 | except InvitationCode.DoesNotExist: 145 | request._hunger_delete_cookie = True 146 | return redirect(reverse('hunger-invalid', args=(cookie_code,))) 147 | 148 | right_now = now() 149 | if code.private: 150 | # User is trying to use a valid private code, but has no 151 | # authority to use it (neither via username nor email) 152 | request._hunger_delete_cookie = True 153 | return redirect(reverse('hunger-invalid', args=(cookie_code,))) 154 | else: 155 | invitation = Invitation(user=request.user, 156 | code=code, 157 | invited=right_now, 158 | used=right_now) 159 | code.num_invites -= 1 160 | invitation.save() 161 | code.save() 162 | return 163 | 164 | def process_response(self, request, response): 165 | if getattr(request, '_hunger_delete_cookie', False): 166 | if six.PY3: 167 | code = 'hunger_code' 168 | else: 169 | code = u'hunger_code'.encode('utf-8') 170 | response.delete_cookie(code) 171 | return response 172 | 173 | @staticmethod 174 | def _get_view_name(request): 175 | """Return the urlpattern name.""" 176 | if hasattr(request, 'resolver_match'): 177 | # Django >= 1.5 178 | return request.resolver_match.view_name 179 | 180 | match = resolve(request.path) 181 | return match.url_name 182 | -------------------------------------------------------------------------------- /hunger/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='Invitation', 17 | fields=[ 18 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 19 | ('email', models.EmailField(max_length=75, null=True, verbose_name='Email', blank=True)), 20 | ('used', models.DateTimeField(null=True, verbose_name='Used', blank=True)), 21 | ('invited', models.DateTimeField(null=True, verbose_name='Invited', blank=True)), 22 | ('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')), 23 | ], 24 | options={ 25 | }, 26 | bases=(models.Model,), 27 | ), 28 | migrations.CreateModel( 29 | name='InvitationCode', 30 | fields=[ 31 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 32 | ('code', models.CharField(unique=True, max_length=30, verbose_name='Invitation code')), 33 | ('private', models.BooleanField(default=True)), 34 | ('max_invites', models.PositiveIntegerField(default=1, verbose_name='Max number of invitations')), 35 | ('num_invites', models.PositiveIntegerField(default=1, verbose_name='Remaining invitations')), 36 | ('invited_users', models.ManyToManyField(related_name='invitations', through='hunger.Invitation', to=settings.AUTH_USER_MODEL)), 37 | ('owner', models.ForeignKey(related_name='created_invitations', blank=True, to=settings.AUTH_USER_MODEL, null=True)), 38 | ], 39 | options={ 40 | }, 41 | bases=(models.Model,), 42 | ), 43 | migrations.AddField( 44 | model_name='invitation', 45 | name='code', 46 | field=models.ForeignKey(blank=True, to='hunger.InvitationCode', null=True), 47 | preserve_default=True, 48 | ), 49 | migrations.AddField( 50 | model_name='invitation', 51 | name='user', 52 | field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True), 53 | preserve_default=True, 54 | ), 55 | migrations.AlterUniqueTogether( 56 | name='invitation', 57 | unique_together=set([('user', 'code')]), 58 | ), 59 | ] 60 | -------------------------------------------------------------------------------- /hunger/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuakarjala/django-hunger/712684dd6ff8b776db6c3245ec0a134e9cd7a66b/hunger/migrations/__init__.py -------------------------------------------------------------------------------- /hunger/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | import importlib 3 | import random 4 | import string 5 | from django.db import models 6 | from django.conf import settings 7 | from django.utils.translation import ugettext_lazy as _ 8 | from hunger.utils import setting 9 | 10 | User = settings.AUTH_USER_MODEL 11 | 12 | 13 | class Invitation(models.Model): 14 | user = models.ForeignKey(User, blank=True, null=True) 15 | email = models.EmailField(_('Email'), blank=True, null=True) 16 | code = models.ForeignKey('InvitationCode', blank=True, null=True) 17 | used = models.DateTimeField(_('Used'), blank=True, null=True) 18 | invited = models.DateTimeField(_('Invited'), blank=True, null=True) 19 | created = models.DateTimeField(_('Created'), auto_now_add=True) 20 | 21 | def save(self, *args, **kwargs): 22 | send_email = kwargs.pop('send_email', False) 23 | request = kwargs.pop('request', None) 24 | if send_email and self.invited and not self.used: 25 | send_invitation(self, request=request, user=self.user) 26 | 27 | super(Invitation, self).save(*args, **kwargs) 28 | 29 | class Meta: 30 | unique_together = (('user', 'code'),) 31 | 32 | 33 | def send_invitation(invitation, **kwargs): 34 | """Send invitation code to user. 35 | 36 | Invitation could be InvitationCode or Invitation. 37 | """ 38 | email = invitation.user.email if invitation.user else invitation.email 39 | code = invitation.code.code if invitation.code else None 40 | bits = setting('HUNGER_EMAIL_INVITE_FUNCTION').rsplit('.', 1) 41 | module_name, func_name = bits 42 | module = importlib.import_module(module_name) 43 | func = getattr(module, func_name) 44 | func(email, code=code, **kwargs) 45 | 46 | 47 | class InvitationCode(models.Model): 48 | code = models.CharField(_('Invitation code'), max_length=30, unique=True) 49 | private = models.BooleanField(default=True) 50 | max_invites = models.PositiveIntegerField( 51 | _('Max number of invitations'), default=1) 52 | num_invites = models.PositiveIntegerField( 53 | _('Remaining invitations'), default=1) 54 | invited_users = models.ManyToManyField( 55 | User, related_name='invitations', through='Invitation') 56 | owner = models.ForeignKey(User, related_name='created_invitations', 57 | blank=True, null=True) 58 | 59 | def __unicode__(self): 60 | return self.code 61 | 62 | def remaining_invites(self): 63 | """The number of invites remaining for this code.""" 64 | return max([0, self.max_invites - self.invited_users.count()]) 65 | 66 | @classmethod 67 | def generate_invite_code(self): 68 | return ''.join(random.choice(string.ascii_letters) for i in range(16)) 69 | 70 | def save(self, *args, **kwargs): 71 | if not self.code: 72 | self.code = self.generate_invite_code() 73 | # self.num_invites = self.max_invites - self.invited_users.count() 74 | super(InvitationCode, self).save(*args, **kwargs) 75 | -------------------------------------------------------------------------------- /hunger/templates/hunger/beta_confirm.email: -------------------------------------------------------------------------------- 1 | {% block subject %}You requested an invite!{% endblock %} 2 | 3 | {% block plain %} 4 | We have received your request for invite to the private beta. 5 | We'll contact you as soon as we are ready! 6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /hunger/templates/hunger/beta_confirm.html: -------------------------------------------------------------------------------- 1 | We have received your request for invite to the private beta. 2 | We'll contact you as soon as we are ready! 3 | -------------------------------------------------------------------------------- /hunger/templates/hunger/beta_confirm.txt: -------------------------------------------------------------------------------- 1 | We have received your request for invite to the private beta. 2 | We'll contact you as soon as we are ready! 3 | -------------------------------------------------------------------------------- /hunger/templates/hunger/invalid.html: -------------------------------------------------------------------------------- 1 | You have an invalid Invite Code. -------------------------------------------------------------------------------- /hunger/templates/hunger/invite_email.email: -------------------------------------------------------------------------------- 1 | {% block subject %}Here is your invite{% endblock %} 2 | 3 | {% block plain %} 4 | Visit {{ invite_url }} to join the private beta. 5 | {% endblock %} 6 | 7 | {% block html %} 8 | Visit {{ invite_url }} to join the private beta. 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /hunger/templates/hunger/invite_email.html: -------------------------------------------------------------------------------- 1 | Visit {{ invite_url }} to join the private beta. 2 | -------------------------------------------------------------------------------- /hunger/templates/hunger/invite_email.txt: -------------------------------------------------------------------------------- 1 | Visit {{ invite_url }} to join the private beta. 2 | -------------------------------------------------------------------------------- /hunger/templates/hunger/invite_email_subject.txt: -------------------------------------------------------------------------------- 1 | Here is your invite -------------------------------------------------------------------------------- /hunger/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from django.conf.urls import patterns, url 3 | from .views import (InviteView, VerifiedView, InvalidView, NotBetaView, 4 | InviteSentView) 5 | 6 | 7 | urlpatterns = patterns( 8 | '', 9 | url(r'^verify/(\w+)/$', 'hunger.views.verify_invite', 10 | name='hunger-verify'), 11 | url(r'^invite/$', InviteView.as_view(), name='hunger-invite'), 12 | url(r'^sent/$', InviteSentView.as_view(), name='hunger-invite-sent'), 13 | url(r'^not-in-beta/$', NotBetaView.as_view(), name='hunger-not-in-beta'), 14 | url(r'^verified/$', VerifiedView.as_view(), name='hunger-verified'), 15 | url(r'^invalid/(?P\w+)/$', InvalidView.as_view(), 16 | name='hunger-invalid'), 17 | ) 18 | -------------------------------------------------------------------------------- /hunger/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | import datetime 3 | from django.conf import settings 4 | from django.core.urlresolvers import reverse_lazy 5 | from django.contrib.auth.models import User 6 | 7 | 8 | DEFAULT_SETTINGS = { 9 | 'AUTH_USER_MODEL': User, 10 | 'HUNGER_ENABLE': True, 11 | 'HUNGER_ALWAYS_ALLOW_VIEWS': [], 12 | 'HUNGER_ALWAYS_ALLOW_MODULES': [], 13 | 'HUNGER_REDIRECT': reverse_lazy('hunger-not-in-beta'), 14 | 'HUNGER_VERIFIED_REDIRECT': reverse_lazy('hunger-verified'), 15 | 'HUNGER_INVITE_SENT_REDIRECT': reverse_lazy('hunger-invite-sent'), 16 | 'HUNGER_ALLOW_FLATPAGES': [], 17 | 'HUNGER_EMAIL_TEMPLATES_DIR': 'hunger', 18 | 'HUNGER_EMAIL_INVITE_FUNCTION': 'hunger.email.beta_invite', 19 | } 20 | 21 | 22 | def setting(name): 23 | """Return setting value for given name or default value.""" 24 | try: 25 | setting = getattr(settings, name) 26 | except AttributeError: 27 | setting = DEFAULT_SETTINGS[name] 28 | return setting 29 | 30 | 31 | def now(): 32 | """Backwards compatible now function when USE_TZ=False.""" 33 | if getattr(settings, 'USE_TZ'): 34 | from django.utils import timezone 35 | return timezone.now() 36 | else: 37 | return datetime.datetime.now() 38 | -------------------------------------------------------------------------------- /hunger/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import redirect 2 | from hunger.models import InvitationCode 3 | from hunger.forms import InviteSendForm 4 | from hunger.utils import setting, now 5 | from django.views.generic.base import TemplateView 6 | from django.views.generic.edit import FormView 7 | 8 | 9 | class InviteView(FormView): 10 | """ 11 | Allow a user to send invites. 12 | """ 13 | template_name = 'hunger/request_invite.html' 14 | form_class = InviteSendForm 15 | success_url = setting('HUNGER_INVITE_SENT_REDIRECT') 16 | 17 | def form_valid(self, form): 18 | valid_code = InvitationCode.objects.get(owner=self.request.user, 19 | num_invites__gt=0) 20 | instance = form.save(commit=False) 21 | instance.code = valid_code 22 | instance.invited = now() 23 | instance.save(send_email=True, request=self.request) 24 | 25 | return super(InviteView, self).form_valid(form) 26 | 27 | 28 | class NotBetaView(TemplateView): 29 | """ 30 | Display a message to the user after the invite request is completed 31 | successfully. 32 | """ 33 | template_name = 'hunger/not_in_beta.html' 34 | 35 | 36 | class VerifiedView(TemplateView): 37 | """ 38 | Display a message to the user after the invite request is completed 39 | successfully. 40 | """ 41 | template_name = 'hunger/verified.html' 42 | 43 | 44 | class InvalidView(TemplateView): 45 | """ 46 | Display a message to the user that the invitation code is 47 | invalid or has already been used. 48 | """ 49 | template_name = 'hunger/invalid.html' 50 | 51 | 52 | class InviteSentView(TemplateView): 53 | """ 54 | Display a message to the user after sending out invitations to people. 55 | """ 56 | template_name = 'hunger/invite_sent.html' 57 | 58 | 59 | def verify_invite(request, code): 60 | """Verify new invitee by storing invite code for middleware to validate.""" 61 | response = redirect(setting('HUNGER_VERIFIED_REDIRECT')) 62 | response.set_cookie('hunger_code', code) 63 | return response 64 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | load-plugins=pylint_django 3 | 4 | [MESSAGES CONTROL] 5 | 6 | # Disable the message, report, category or checker with the given id(s). You 7 | # can either give multiple identifiers separated by comma (,) or put this 8 | # option multiple times (only on the command line, not in the configuration 9 | # file where it should appear only once).You can also use "--disable=all" to 10 | # disable everything first and then reenable specific checks. For example, if 11 | # you want to run only the similarities checker, you can use "--disable=all 12 | # --enable=similarities". If you want to run only the classes checker, but have 13 | # no Warning level messages displayed, use"--disable=all --enable=classes 14 | # --disable=W" 15 | disable=missing-docstring, no-member 16 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import unicode_literals 3 | import sys 4 | 5 | from django.conf import settings 6 | 7 | 8 | if not settings.configured: 9 | settings.configure( 10 | DATABASE_ENGINE='sqlite3', 11 | DATABASES={ 12 | 'default': { 13 | 'ENGINE': 'django.db.backends.sqlite3', 14 | 'TEST_NAME': 'hunger_tests.db', 15 | }, 16 | }, 17 | DATABASE_NAME='test_hunger', 18 | TEST_DATABASE_NAME='hunger_tests.db', 19 | INSTALLED_APPS=[ 20 | 'django.contrib.auth', 21 | 'django.contrib.admin', 22 | 'django.contrib.sessions', 23 | 'django.contrib.sites', 24 | 'django.contrib.contenttypes', 25 | 'hunger', 26 | 'tests', 27 | ], 28 | ROOT_URLCONF='', 29 | DEBUG=False, 30 | SITE_ID=1, 31 | 32 | MIDDLEWARE_CLASSES=( 33 | 'django.middleware.common.CommonMiddleware', 34 | 'django.contrib.sessions.middleware.SessionMiddleware', 35 | 'django.middleware.csrf.CsrfViewMiddleware', 36 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 37 | 'hunger.middleware.BetaMiddleware' 38 | ), 39 | HUNGER_REDIRECT='rejection', 40 | HUNGER_ALWAYS_ALLOW_VIEWS=[ 41 | 'tests.views.always_allow', 42 | 'tests.views.rejection', 43 | ], 44 | HUNGER_ALWAYS_ALLOW_MODULES=['tests.always_allow_views'], 45 | ) 46 | 47 | 48 | def runtests(): 49 | from django import VERSION 50 | if VERSION[1] >= 7: 51 | from django import setup 52 | setup() 53 | from django.test.utils import get_runner 54 | TestRunner = get_runner(settings) 55 | test_runner = TestRunner() 56 | failures = test_runner.run_tests(["tests"]) 57 | sys.exit(bool(failures)) 58 | 59 | 60 | if __name__ == '__main__': 61 | runtests() 62 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup, find_packages 3 | 4 | DESCRIPTION = "A Django app to manage a private beta phase." 5 | 6 | try: 7 | LONG_DESCRIPTION = open('README.rst').read() 8 | except: 9 | pass 10 | 11 | CLASSIFIERS = [ 12 | 'Development Status :: 5 - Production/Stable', 13 | 'Intended Audience :: Developers', 14 | 'License :: OSI Approved :: MIT License', 15 | 'Operating System :: OS Independent', 16 | 'Programming Language :: Python', 17 | 'Topic :: Software Development :: Libraries :: Python Modules', 18 | 'Framework :: Django', 19 | ] 20 | 21 | INSTALL_REQUIRES = [''] 22 | try: 23 | import importlib # pylint: disable=unused-import 24 | except ImportError: 25 | INSTALL_REQUIRES.append('importlib') 26 | 27 | TESTS_REQUIRE = [ 28 | 'Django>=1.3', 29 | ] 30 | 31 | setup( 32 | name='django-hunger', 33 | version='2.1.3.dev0', 34 | packages=find_packages(exclude=['tests', 'example']), 35 | author='Joshua Karjala-Svenden', 36 | author_email='joshua@fluxuries.com', 37 | url='https://github.com/joshuakarjala/django-hunger/', 38 | license='MIT', 39 | description=DESCRIPTION, 40 | long_description=LONG_DESCRIPTION, 41 | platforms=['any'], 42 | classifiers=CLASSIFIERS, 43 | install_requires=INSTALL_REQUIRES, 44 | tests_require=TESTS_REQUIRE, 45 | extras_require={'test': TESTS_REQUIRE}, 46 | test_suite='runtests.runtests', 47 | include_package_data=True, 48 | ) 49 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuakarjala/django-hunger/712684dd6ff8b776db6c3245ec0a134e9cd7a66b/tests/__init__.py -------------------------------------------------------------------------------- /tests/always_allow_views.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from django.shortcuts import render 3 | 4 | 5 | def allowed(request): 6 | return render(request, 'default.html') 7 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuakarjala/django-hunger/712684dd6ff8b776db6c3245ec0a134e9cd7a66b/tests/models.py -------------------------------------------------------------------------------- /tests/templates/default.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuakarjala/django-hunger/712684dd6ff8b776db6c3245ec0a134e9cd7a66b/tests/templates/default.html -------------------------------------------------------------------------------- /tests/templates/hunger/invite_sent.html: -------------------------------------------------------------------------------- 1 | Invite Sent! -------------------------------------------------------------------------------- /tests/templates/hunger/verified.html: -------------------------------------------------------------------------------- 1 | Verified! -------------------------------------------------------------------------------- /tests/templates/registration/login.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuakarjala/django-hunger/712684dd6ff8b776db6c3245ec0a134e9cd7a66b/tests/templates/registration/login.html -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from django.core.urlresolvers import reverse 3 | from django.contrib.auth.models import User 4 | from django.test import TestCase 5 | from hunger.utils import setting, now 6 | from hunger.models import Invitation, InvitationCode 7 | 8 | from django.test.utils import override_settings 9 | 10 | class BetaViewTests(TestCase): 11 | urls = 'tests.urls' 12 | 13 | redirect = setting('HUNGER_REDIRECT') 14 | 15 | def create_invite(self, email): 16 | code = InvitationCode(num_invites=0) 17 | code.save() 18 | invitation = Invitation(code=code, email=email, invited=now()) 19 | invitation.save() 20 | return invitation 21 | 22 | def create_code(self, private=True, email=''): 23 | code = InvitationCode(private=private) 24 | code.save() 25 | if private: 26 | invitation = Invitation(code=code, email=email, invited=now()) 27 | invitation.save() 28 | return code 29 | 30 | def setUp(self): 31 | """Creates a few basic users. 32 | 33 | Alice is registered but not in beta 34 | Bob is registered and in beta (self-signup) 35 | Charlie is in beta and has one invite 36 | """ 37 | self.alice = User.objects.create_user('alice', 'alice@example.com', 'secret') 38 | self.bob = User.objects.create_user('bob', 'bob@example.com', 'secret') 39 | right_now = now() 40 | invitation = Invitation(user=self.bob, invited=right_now, used=right_now) 41 | invitation.save() 42 | 43 | self.charlie = User.objects.create_user('charlie', 'charlie@example.com', 'secret') 44 | invitation = Invitation(user=self.charlie, invited=right_now, used=right_now) 45 | invitation.save() 46 | code = InvitationCode(owner=self.charlie) 47 | code.save() 48 | 49 | def test_always_allow_view(self): 50 | response = self.client.get(reverse('always_allow')) 51 | self.assertEqual(response.status_code, 200) 52 | self.assertEqual(response.templates[0].name, 'default.html') 53 | 54 | def test_always_allow_module(self): 55 | response = self.client.get(reverse('always_allow_module')) 56 | self.assertEqual(response.status_code, 200) 57 | 58 | def test_garden_when_not_invited(self): 59 | """ 60 | Confirm that an unauthenticated user is redirected to login. 61 | """ 62 | response = self.client.get(reverse('invited_only')) 63 | self.assertRedirects(response, setting('LOGIN_URL')) 64 | 65 | def test_using_invite(self): 66 | cary = User.objects.create_user('cary', 'cary@example.com', 'secret') 67 | self.client.login(username='cary', password='secret') 68 | response = self.client.get(reverse('invited_only')) 69 | self.assertRedirects(response, reverse(self.redirect)) 70 | 71 | response = self.client.get(reverse('invited_only')) 72 | self.assertRedirects(response, reverse(self.redirect)) 73 | invitation = Invitation.objects.get(user=cary) 74 | invitation.invited = now() 75 | invitation.save() 76 | response = self.client.get(reverse('invited_only')) 77 | self.assertEqual(response.status_code, 200) 78 | 79 | def test_user_invite(self): 80 | """ 81 | Confirm that one user can invite another to beta. 82 | """ 83 | self.client.login(username='charlie', password='secret') 84 | response = self.client.post(reverse('hunger-invite'), {'email': 'cary@example.com'}) 85 | self.assertRedirects(response, reverse('hunger-invite-sent')) 86 | self.client.logout() 87 | 88 | # @TODO: Replace with examining email body 89 | User.objects.create_user('cary', 'cary@example.com', 'secret') 90 | self.client.login(username='cary', password='secret') 91 | invitation = Invitation.objects.get(email='cary@example.com') 92 | response = self.client.get(reverse('hunger-verify', args=[invitation.code.code])) 93 | # Cary should be allowed to verify the code that belongs to her 94 | self.assertRedirects(response, reverse('hunger-verified')) 95 | self.client.logout() 96 | 97 | User.objects.create_user('dany', 'dany@example.com', 'secret') 98 | self.client.login(username='dany', password='secret') 99 | response = self.client.get(reverse('invited_only')) 100 | # Dany should be denied, since he has no connection with Cary 101 | self.assertRedirects(response, reverse('rejection')) 102 | 103 | def test_invite_non_user_with_email(self): 104 | """ 105 | Confirm that someone invited to beta can later register. 106 | """ 107 | self.create_invite(email='dany@example.com') 108 | User.objects.create_user('dany', 'dany@example.com', 'secret') 109 | self.client.login(username='dany', password='secret') 110 | response = self.client.get(reverse('invited_only')) 111 | self.assertEqual(response.status_code, 200) 112 | 113 | def test_invite_existing_user_with_email(self): 114 | """ 115 | Confirm that existing user can later be invited to beta. 116 | """ 117 | self.create_invite(email='alice@example.com') 118 | self.client.login(username='alice', password='secret') 119 | response = self.client.get(reverse('invited_only')) 120 | self.assertEqual(response.status_code, 200) 121 | 122 | def test_invite_non_user_without_email(self): 123 | """ 124 | Confirm that an unregistered user cannot join beta using a private 125 | InvitationCode meant for someone else. 126 | """ 127 | code = self.create_code(email='dany1@example.com') 128 | response = self.client.get(reverse('hunger-verify', 129 | args=[code.code]), follow=True) 130 | # Anonymous user cannot verify a private InvitationCode 131 | self.assertRedirects(response, setting('LOGIN_URL')) 132 | 133 | User.objects.create_user('dany', 'dany@example.com', 'secret') 134 | self.client.login(username='dany', password='secret') 135 | response = self.client.get(reverse('invited_only')) 136 | # Dany should be denied, since he has no connection with email account 137 | self.assertRedirects(response, reverse('hunger-invalid', args=[code.code])) 138 | 139 | def test_invite_non_user_public_invitation(self): 140 | """ 141 | Confirm that an unregistered user can join beta using a public 142 | InvitationCode. 143 | """ 144 | code = self.create_code(private=False) 145 | 146 | # Anonymous user can verify a public InvitationCode, but cannot 147 | # access pages behind beta until logged in. 148 | response = self.client.get(reverse('hunger-verify', 149 | args=[code.code]), follow=True) 150 | 151 | response = self.client.get(reverse('invited_only')) 152 | self.assertRedirects(response, setting('LOGIN_URL')) 153 | 154 | User.objects.create_user('dany', 'dany@example.com', 'secret') 155 | self.client.login(username='dany', password='secret') 156 | response = self.client.get(reverse('invited_only')) 157 | # Dany is allowed in beta since he used public code earlier in session 158 | self.assertEqual(response.status_code, 200) 159 | 160 | def test_invite_existing_user_without_email(self): 161 | """ 162 | Confirm that a registered user cannot join beta using a private 163 | InvitationCode meant for someone else. 164 | """ 165 | code = self.create_code(email='not_alice@example.com') 166 | response = self.client.get(reverse('hunger-verify', 167 | args=[code.code]), follow=True) 168 | # Anonymous user cannot verify a private InvitationCode 169 | self.assertRedirects(response, setting('LOGIN_URL')) 170 | 171 | self.client.login(username='alice', password='secret') 172 | response = self.client.get(reverse('invited_only')) 173 | # Alice should be denied, since she has no connection with email account 174 | self.assertRedirects(response, reverse('hunger-invalid', args=[code.code])) 175 | 176 | def test_invalid_code(self): 177 | """ 178 | Confirm that a registered user cannot join beta using a random code. 179 | """ 180 | invalid_code = 'XXXXinvalidcodeXXXX' 181 | self.client.login(username='alice', password='secret') 182 | response = self.client.get(reverse('hunger-verify', 183 | args=[invalid_code]), follow=True) 184 | self.assertRedirects(response, reverse('hunger-invalid', args=[invalid_code])) 185 | 186 | @override_settings(HUNGER_ENABLE=False) 187 | def test_settings(self): 188 | """ 189 | Confirm that settings override DEFAULT_SETTINGS 190 | """ 191 | self.assertEqual(False, setting('HUNGER_ENABLE')) 192 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from django.conf.urls import patterns, url, include 3 | 4 | 5 | urlpatterns = patterns( 6 | '', 7 | url(r'^invited-only/$', 'tests.views.invited_only', name='invited_only'), 8 | url(r'^always-allow/$', 'tests.views.always_allow', name='always_allow'), 9 | url(r'^always-allow-module/$', 'tests.always_allow_views.allowed', 10 | name='always_allow_module'), 11 | url(r'^not-allowed/$', 'tests.views.rejection', name='rejection'), 12 | url(r'^hunger/', include('hunger.urls')), 13 | url(r'^accounts/', include('django.contrib.auth.urls')) 14 | ) 15 | -------------------------------------------------------------------------------- /tests/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from django.shortcuts import render 3 | 4 | 5 | def always_allow(request): 6 | return render(request, 'default.html') 7 | 8 | 9 | def rejection(request): 10 | return render(request, 'default.html') 11 | 12 | 13 | def invited_only(request): 14 | return render(request, 'default.html') 15 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py34-django17, 4 | py27-django17, 5 | py34-django16, 6 | py27-django16 7 | 8 | [testenv] 9 | commands = python runtests.py 10 | 11 | [testenv:py34-django17] 12 | basepython = python3.4 13 | deps = Django==1.7.4 14 | 15 | [testenv:py27-django17] 16 | basepython = python2.7 17 | deps = Django==1.7.4 18 | 19 | [testenv:py34-django16] 20 | basepython = python3.4 21 | deps = Django==1.6.10 22 | 23 | [testenv:py27-django16] 24 | basepython = python2.7 25 | deps = Django==1.6.10 26 | 27 | [pep8] 28 | ignore= 29 | --------------------------------------------------------------------------------