diff --git a/.gitignore b/.gitignore index e69de29b..be6f6b87 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,8 @@ +.*.sw* +*.log +src/greenmine/settings/local.py +src/database.sqlite +src/logs +src/media +*.pyc +*.mo diff --git a/doc/.gitignore b/doc/.gitignore new file mode 100755 index 00000000..378eac25 --- /dev/null +++ b/doc/.gitignore @@ -0,0 +1 @@ +build diff --git a/doc/Makefile b/doc/Makefile new file mode 100755 index 00000000..d7e64f5f --- /dev/null +++ b/doc/Makefile @@ -0,0 +1,153 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = build + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + -rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Green-Mine.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Green-Mine.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/Green-Mine" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Green-Mine" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." diff --git a/doc/source/_templates/layout.html b/doc/source/_templates/layout.html new file mode 100755 index 00000000..276e8956 --- /dev/null +++ b/doc/source/_templates/layout.html @@ -0,0 +1,39 @@ +{% extends "!layout.html" %} + +{% block body %} +
+ + {% if version == "0.7" or version == "0.8" %} +

+ This document is for Celery's development version, which can be + significantly different from previous releases. Get old docs here: + + 2.5. +

+ {% else %} +

+ This document describes stdnet {{ version }}. For development docs, + go here. +

+ {% endif %} + +
+ {{ body }} +{% endblock %} + +{% block footer %} +{{ super() }} + +{% endblock %} \ No newline at end of file diff --git a/doc/source/_templates/sidebarintro.html b/doc/source/_templates/sidebarintro.html new file mode 100755 index 00000000..f4cdf0fe --- /dev/null +++ b/doc/source/_templates/sidebarintro.html @@ -0,0 +1,9 @@ +

Green-Mine

+

+ Green-Mine is a project managment web application + build on top of django (1.4). +

+

Useful Links

+ diff --git a/doc/source/_templates/sidebarlogo.html b/doc/source/_templates/sidebarlogo.html new file mode 100755 index 00000000..77a3eb09 --- /dev/null +++ b/doc/source/_templates/sidebarlogo.html @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/doc/source/_theme/celery/static/celery.css_t b/doc/source/_theme/celery/static/celery.css_t new file mode 100755 index 00000000..4151fe6b --- /dev/null +++ b/doc/source/_theme/celery/static/celery.css_t @@ -0,0 +1,394 @@ +/* + * celery.css_t + * ~~~~~~~~~~~~ + * + * :copyright: Copyright 2010 by Armin Ronacher. + * :license: BSD, see LICENSE for details. + */ + +{% set page_width = 940 %} +{% set sidebar_width = 220 %} +{% set body_font_stack = 'Optima, Segoe, "Segoe UI", Candara, Calibri, Arial, sans-serif' %} +{% set headline_font_stack = 'Futura, "Trebuchet MS", Arial, sans-serif' %} +{% set code_font_stack = "'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace" %} + +@import url("basic.css"); + +/* -- page layout ----------------------------------------------------------- */ + +body { + font-family: {{ body_font_stack }}; + font-size: 17px; + background-color: white; + color: #000; + margin: 30px 0 0 0; + padding: 0; +} + +div.document { + width: {{ page_width }}px; + margin: 0 auto; +} + +div.related { + width: {{ page_width - 20 }}px; + padding: 5px 10px; + background: #F2FCEE; + margin: 15px auto 15px auto; +} + +div.documentwrapper { + float: left; + width: 100%; +} + +div.bodywrapper { + margin: 0 0 0 {{ sidebar_width }}px; +} + +div.sphinxsidebar { + width: {{ sidebar_width }}px; +} + +hr { + border: 1px solid #B1B4B6; +} + +div.body { + background-color: #ffffff; + color: #3E4349; + padding: 0 30px 0 30px; +} + +img.celerylogo { + padding: 0 0 10px 10px; + float: right; +} + +div.footer { + width: {{ page_width - 15 }}px; + margin: 10px auto 30px auto; + padding-right: 15px; + font-size: 14px; + color: #888; + text-align: right; +} + +div.footer a { + color: #888; +} + +div.sphinxsidebar a { + color: #444; + text-decoration: none; + border-bottom: 1px dashed #DCF0D5; +} + +div.sphinxsidebar a:hover { + border-bottom: 1px solid #999; +} + +div.sphinxsidebar { + font-size: 14px; + line-height: 1.5; +} + +div.sphinxsidebarwrapper { + padding: 7px 10px; +} + +div.sphinxsidebarwrapper p.logo { + padding: 0 0 20px 0; + margin: 0; +} + +div.sphinxsidebar h3, +div.sphinxsidebar h4 { + font-family: {{ headline_font_stack }}; + color: #444; + font-size: 24px; + font-weight: normal; + margin: 0 0 5px 0; + padding: 0; +} + +div.sphinxsidebar h4 { + font-size: 20px; +} + +div.sphinxsidebar h3 a { + color: #444; +} + +div.sphinxsidebar p.logo a, +div.sphinxsidebar h3 a, +div.sphinxsidebar p.logo a:hover, +div.sphinxsidebar h3 a:hover { + border: none; +} + +div.sphinxsidebar p { + color: #555; + margin: 10px 0; +} + +div.sphinxsidebar ul { + margin: 10px 0; + padding: 0; + color: #000; +} + +div.sphinxsidebar input { + border: 1px solid #ccc; + font-family: {{ body_font_stack }}; + font-size: 1em; +} + +/* -- body styles ----------------------------------------------------------- */ + +a { + color: #348613; + text-decoration: underline; +} + +a:hover { + color: #59B833; + text-decoration: underline; +} + +div.body h1, +div.body h2, +div.body h3, +div.body h4, +div.body h5, +div.body h6 { + font-family: {{ headline_font_stack }}; + font-weight: normal; + margin: 30px 0px 10px 0px; + padding: 0; +} + +div.body h1 { margin-top: 0; padding-top: 0; font-size: 200%; } +div.body h2 { font-size: 180%; } +div.body h3 { font-size: 150%; } +div.body h4 { font-size: 130%; } +div.body h5 { font-size: 100%; } +div.body h6 { font-size: 100%; } + +div.body h1 a.toc-backref, +div.body h2 a.toc-backref, +div.body h3 a.toc-backref, +div.body h4 a.toc-backref, +div.body h5 a.toc-backref, +div.body h6 a.toc-backref { + color: inherit!important; + text-decoration: none; +} + +a.headerlink { + color: #ddd; + padding: 0 4px; + text-decoration: none; +} + +a.headerlink:hover { + color: #444; + background: #eaeaea; +} + +div.body p, div.body dd, div.body li { + line-height: 1.4em; +} + +div.admonition { + background: #fafafa; + margin: 20px -30px; + padding: 10px 30px; + border-top: 1px solid #ccc; + border-bottom: 1px solid #ccc; +} + +div.admonition p.admonition-title { + font-family: {{ headline_font_stack }}; + font-weight: normal; + font-size: 24px; + margin: 0 0 10px 0; + padding: 0; + line-height: 1; +} + +div.admonition p.last { + margin-bottom: 0; +} + +div.highlight{ + background-color: white; +} + +dt:target, .highlight { + background: #FAF3E8; +} + +div.note { + background-color: #eee; + border: 1px solid #ccc; +} + +div.seealso { + background-color: #ffc; + border: 1px solid #ff6; +} + +div.topic { + background-color: #eee; +} + +div.warning { + background-color: #ffe4e4; + border: 1px solid #f66; +} + +p.admonition-title { + display: inline; +} + +p.admonition-title:after { + content: ":"; +} + +pre, tt { + font-family: {{ code_font_stack }}; + font-size: 0.9em; +} + +img.screenshot { +} + +tt.descname, tt.descclassname { + font-size: 0.95em; +} + +tt.descname { + padding-right: 0.08em; +} + +img.screenshot { + -moz-box-shadow: 2px 2px 4px #eee; + -webkit-box-shadow: 2px 2px 4px #eee; + box-shadow: 2px 2px 4px #eee; +} + +table.docutils { + border: 1px solid #888; + -moz-box-shadow: 2px 2px 4px #eee; + -webkit-box-shadow: 2px 2px 4px #eee; + box-shadow: 2px 2px 4px #eee; +} + +table.docutils td, table.docutils th { + border: 1px solid #888; + padding: 0.25em 0.7em; +} + +table.field-list, table.footnote { + border: none; + -moz-box-shadow: none; + -webkit-box-shadow: none; + box-shadow: none; +} + +table.footnote { + margin: 15px 0; + width: 100%; + border: 1px solid #eee; + background: #fdfdfd; + font-size: 0.9em; +} + +table.footnote + table.footnote { + margin-top: -15px; + border-top: none; +} + +table.field-list th { + padding: 0 0.8em 0 0; +} + +table.field-list td { + padding: 0; +} + +table.footnote td.label { + width: 0px; + padding: 0.3em 0 0.3em 0.5em; +} + +table.footnote td { + padding: 0.3em 0.5em; +} + +dl { + margin: 0; + padding: 0; +} + +dl dd { + margin-left: 30px; +} + +blockquote { + margin: 0 0 0 30px; + padding: 0; +} + +ul { + margin: 10px 0 10px 30px; + padding: 0; +} + +pre { + background: #F0FFEB; + padding: 7px 10px; + margin: 15px 0; + border: 1px solid #C7ECB8; + border-radius: 2px; + -moz-border-radius: 2px; + -webkit-border-radius: 2px; + line-height: 1.3em; +} + +tt { + background: #F0FFEB; + color: #222; + /* padding: 1px 2px; */ +} + +tt.xref, a tt { + background: #F0FFEB; + border-bottom: 1px solid white; +} + +a.reference { + text-decoration: none; + border-bottom: 1px dashed #DCF0D5; +} + +a.reference:hover { + border-bottom: 1px solid #6D4100; +} + +a.footnote-reference { + text-decoration: none; + font-size: 0.7em; + vertical-align: top; + border-bottom: 1px dashed #DCF0D5; +} + +a.footnote-reference:hover { + border-bottom: 1px solid #6D4100; +} + +a:hover tt { + background: #EEE; +} + diff --git a/doc/source/_theme/celery/theme.conf b/doc/source/_theme/celery/theme.conf new file mode 100755 index 00000000..537f3779 --- /dev/null +++ b/doc/source/_theme/celery/theme.conf @@ -0,0 +1,5 @@ +[theme] +inherit = basic +stylesheet = celery.css + +[options] \ No newline at end of file diff --git a/doc/source/conf.py b/doc/source/conf.py new file mode 100755 index 00000000..93619bf7 --- /dev/null +++ b/doc/source/conf.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- +# +# Green-Mine documentation build configuration file, created by +# sphinx-quickstart on Sun May 6 14:00:36 2012. +# +# This file is execfile()d with the current directory set to its containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys, os + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ----------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = [] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'greenmine' +copyright = u'2012, Andrei Antoukh' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '0.0.10' +# The full version, including alpha/beta/rc tags. +release = '0.0.10' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = [] + +# The reST default role (used for this markup: `text`) to use for all documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + + +# -- Options for HTML output --------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'default' +html_theme = 'celery' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] +html_theme_path = ["_theme"] + + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} +html_sidebars = { + 'index': ['sidebarlogo.html', 'sidebarintro.html', + 'sourcelink.html', 'searchbox.html'], + '**': ['sidebarlogo.html', 'localtoc.html', 'relations.html', + 'sourcelink.html', 'searchbox.html'], +} + + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'greenminedoc' + + +# -- Options for LaTeX output -------------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass [howto/manual]). +latex_documents = [ + ('index', 'greenmine.tex', u'greenmine documentation', + u'Andrei Antoukh', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output -------------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'green-mine', u'greenmine documentation', + [u'Andrei Antoukh'], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------------ + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ('index', 'greenmine', u'greenmine documentation', + u'Andrei Antoukh', 'greenmine', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' diff --git a/doc/source/index.rst b/doc/source/index.rst new file mode 100755 index 00000000..99b43eb5 --- /dev/null +++ b/doc/source/index.rst @@ -0,0 +1,42 @@ +========== +Green-Mine +========== + +.. rubric:: Project management web application build on top of Django. + +Currently there is no stable version, but the project is already usable. All contributions and bug fixes is welcome. + + +First steps +=========== + +**From scratch:** +:ref:`Overview and Installation ` + +**Tutorials:** TODO + +**Miscellaneous:** +:ref:`Contributing ` | +:ref:`Tests ` | +:ref:`Changelog ` | +:ref:`License ` + + +Contents: +========= + +.. toctree:: + :maxdepth: 1 + + overview.rst + settings.rst + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/doc/source/overview.rst b/doc/source/overview.rst new file mode 100755 index 00000000..ea0f79c2 --- /dev/null +++ b/doc/source/overview.rst @@ -0,0 +1,77 @@ +.. _intro-overview: + +======== +Overview +======== + +Requirements +============ + +* python 2.6 or 2.7 +* django-superview >= 0.2 +* psycopg2 >= 2.4 (if postgresql is used) +* pyzmq >= 2.2 (for async mailserver) +* sphinx >= 1.1.3 (for build this documentation) +* django >= 1.4 (builtin) +* markdown >= 2.1 (for markdown wiki) +* docutils >= 0.7 (for restructuredtext wiki) + +Philosophy +========== + +TODO + +Installing +========== + +TODO + +Version Check +============= + +TODO + +.. _runtests: + +Running tests +============= + +Requirements for running tests: same as standard requierements. + +To run tests, open a shell on a package directory and type:: + + python manage.py test -v2 greenmine + +To access coverage of tests you need to install the coverage_ package and run the tests using:: + + coverage run --omit=extern manage.py test -v2 greenmine + +and to check out the coverage report:: + + coverage html + + +.. _contributing: + +Contributing +============ + +Develpment of Green-Mine happens at github: https://github.com/niwibe/Green-Mine + +We very much welcome your contribution of course. To do so, simply follow these guidelines: + +1. Fork ``greenmine`` on github. +2. Create feature branch. Example: ``git checkout -b my_new_feature`` +3. Push your changes. Example: ``git push -u origin my_new_feature`` +4. Send me a pull-request. + +.. _license: + +License +======= + +This software is licensed under the New BSD_ License. See the LICENSE +file in the top distribution directory for the full license text. + +.. _coverage: http://nedbatchelder.com/code/coverage/ +.. _BSD: http://www.opensource.org/licenses/bsd-license.php diff --git a/doc/source/settings.rst b/doc/source/settings.rst new file mode 100755 index 00000000..57fbd6b5 --- /dev/null +++ b/doc/source/settings.rst @@ -0,0 +1,22 @@ +Settings introduced by greenmine. +================================= + +Default settings +---------------- + +The setting instance contains few default parameters used in throughout +the library. This parameters can be changed by the user by simply +overriding them. + +.. attribute:: settings.HOST + + Set a full host name, this is used for making urls for email + notifications. In the future, it will be automatic. + + Default: ``"http://localhost:8000"`` (ready for developers) + +.. attribute:: settings.DISABLE_REGISTRATION + + Set this, disables user registration. + + Default: ``False`` diff --git a/greenmine/__init__.py b/greenmine/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/greenmine/base/__init__.py b/greenmine/base/__init__.py new file mode 100644 index 00000000..faaaf799 --- /dev/null +++ b/greenmine/base/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + + diff --git a/greenmine/base/models.py b/greenmine/base/models.py new file mode 100644 index 00000000..a74563c6 --- /dev/null +++ b/greenmine/base/models.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- + +from django.db.models import signals +from django.dispatch import receiver +from django.db import models +from django.utils.timezone import now + +from ..scrum.models import Project, UserStory, Task + +import uuid + +# Centralized uuid attachment and ref generation +@receiver(signals.pre_save) +def attach_uuid(sender, instance, **kwargs): + fields = sender._meta.init_name_map() + #fields = sender._meta.get_all_field_names() + + if "modified_date" in fields: + instance.modified_date = now() + + # if sender class does not have uuid field. + if "uuid" in fields: + if not instance.uuid: + instance.uuid = unicode(uuid.uuid1()) + + +# Centraliced reference assignation. +@receiver(signals.pre_save, sender=Task) +@receiver(signals.pre_save, sender=UserStory) +def attach_unique_reference(sender, instance, **kwargs): + project = Project.objects.select_for_update().filter(pk=instance.project_id).get() + if isinstance(instance, Task): + project.last_task_ref += 1 + instance.ref = project.last_task_ref + else: + project.last_us_ref += 1 + instance.ref = project.last_us_ref + + project.save() diff --git a/greenmine/base/tests.py b/greenmine/base/tests.py new file mode 100644 index 00000000..23e48285 --- /dev/null +++ b/greenmine/base/tests.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- + +from django.conf import settings +from django.test import TestCase +from django.core import mail +from django.core.urlresolvers import reverse + +from django.contrib.auth.models import User +from ..models import * + +from django.utils import timezone +import datetime +import json + +from greenqueue import send_task + + +class LowLevelEmailTests(TestCase): + def setUp(self): + mail.outbox = [] + + def test_send_one_mail(self): + send_task("send-mail", args = ["subject", "template", ["hola@niwi.be"]]) + self.assertEqual(len(mail.outbox), 1) + + def test_send_bulk_mail(self): + send_task("send-bulk-mail", args = [[ + ('s1', 't1', ['hola@niwi.be']), + ('s2', 't2', ['hola@niwi.be']), + ]]) + + self.assertEqual(len(mail.outbox), 2) + + +class UserMailTests(TestCase): + def setUp(self): + self.user1 = User.objects.create( + username = 'test1', + email = 'test1@test.com', + is_active = True, + is_staff = True, + is_superuser = True, + ) + + self.user2 = User.objects.create( + username = 'test2', + email = 'test2@test.com', + is_active = True, + is_staff = False, + is_superuser = False, + ) + + self.user1.set_password("test") + self.user2.set_password("test") + + self.user1.save() + self.user2.save() + + mail.outbox = [] + + def test_remember_password(self): + url = reverse("remember-password") + + post_params = {'email': 'test2@test.com'} + response = self.client.post(url, post_params, follow=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(mail.outbox), 1) + + jdata = json.loads(response.content) + self.assertIn("valid", jdata) + self.assertTrue(jdata['valid']) + + def test_remember_password_not_exists(self): + url = reverse("remember-password") + + post_params = {'email': 'test2@testa.com'} + response = self.client.post(url, post_params, follow=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(mail.outbox), 0) + + jdata = json.loads(response.content) + self.assertIn("valid", jdata) + self.assertFalse(jdata['valid']) + + def test_send_recovery_password_by_staff(self): + url = reverse("users-recovery-password", args=[self.user2.pk]) + + ok = self.client.login(username="test1", password="test") + self.assertTrue(ok) + + # pre test + self.assertTrue(self.user2.is_active) + self.assertEqual(self.user2.get_profile().token, None) + + response = self.client.get(url, follow=True) + self.assertEqual(response.status_code, 200) + + # expected redirect + self.assertEqual(response.redirect_chain, [('http://testserver/users/2/edit/', 302)]) + + # test mail sending + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].subject, "Greenmine: password recovery.") + + # test user model modification + self.user2 = User.objects.get(pk=self.user2.pk) + self.assertTrue(self.user2.is_active) + self.assertFalse(self.user2.has_usable_password()) + self.assertNotEqual(self.user2.get_profile().token, None) + + url = reverse('password-recovery', args=[self.user2.get_profile().token]) + + post_params = { + 'password': '123123', + 'password2': '123123', + } + response = self.client.post(url, post_params, follow=True) + + self.assertEqual(response.status_code, 200) + + # expected redirect + self.assertEqual(response.redirect_chain, [('http://testserver/login/', 302)]) + + ok = self.client.login(username="test2", password="123123") + self.assertTrue(ok) + diff --git a/greenmine/documents/__init__.py b/greenmine/documents/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/greenmine/documents/models.py b/greenmine/documents/models.py new file mode 100644 index 00000000..80abd124 --- /dev/null +++ b/greenmine/documents/models.py @@ -0,0 +1,39 @@ +# -* coding: utf-8 -*- +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from django.contrib.auth.models import User + +from greenmine.wiki.fields import WikiField +from greenmine.core.utils.slug import slugify_uniquely as slugify +from greenmine.taggit.managers import TaggableManager + + +class Document(models.Model): + title = models.CharField(max_length=150) + slug = models.SlugField(unique=True, max_length=200, blank=True) + description = WikiField(blank=True) + + created_date = models.DateTimeField(auto_now_add=True) + modified_date = models.DateTimeField(auto_now_add=True) + + project = models.ForeignKey('scrum.Project', related_name='documents') + owner = models.ForeignKey('auth.User', related_name='documents') + attached_file = models.FileField(upload_to="documents", + max_length=1000, null=True, blank=True) + + tags = TaggableManager() + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.title, self.__class__) + super(Document, self).save(*args, **kwargs) + + @models.permalink + def get_delete_url(self): + return ('documents-delete', (), + {'pslug': self.project.slug, 'docid': self.pk}) + + @models.permalink + def get_absolute_url(self): + return self.attached_file.url diff --git a/greenmine/documents/search_indexes.py b/greenmine/documents/search_indexes.py new file mode 100644 index 00000000..acb57125 --- /dev/null +++ b/greenmine/documents/search_indexes.py @@ -0,0 +1,13 @@ +# -* coding: utf-8 -*- +from haystack import indexes +from .models import Document + + +class DocumentIndex(indexes.RealTimeSearchIndex, indexes.Indexable): + text = indexes.CharField(document=True, use_template=True, template_name='search/indexes/document_text.txt') + + def get_model(self): + return Document + + def index_queryset(self): + return self.get_model().objects.all() diff --git a/greenmine/documents/templates/search/indexes/document_text.txt b/greenmine/documents/templates/search/indexes/document_text.txt new file mode 100644 index 00000000..e183459a --- /dev/null +++ b/greenmine/documents/templates/search/indexes/document_text.txt @@ -0,0 +1,8 @@ +{{ object.title }} +{{ object.slug }} +{{ object.description }} +{{ object.created_date }} +{{ object.modified_date }} +{{ object.project }} +{{ object.owner }} +{{ object.attached_file }} diff --git a/greenmine/documents/tests/__init__.py b/greenmine/documents/tests/__init__.py new file mode 100644 index 00000000..0cdc012c --- /dev/null +++ b/greenmine/documents/tests/__init__.py @@ -0,0 +1,2 @@ +i# -*- coding: utf-8 -*- +from .documents import * diff --git a/greenmine/documents/tests/documents.py b/greenmine/documents/tests/documents.py new file mode 100644 index 00000000..48800275 --- /dev/null +++ b/greenmine/documents/tests/documents.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- + +from django.test import TestCase +from django.core import mail +from django.core.urlresolvers import reverse +import json + +from django.contrib.auth.models import User +from ..models import * + + diff --git a/greenmine/profile/__init__.py b/greenmine/profile/__init__.py new file mode 100644 index 00000000..faaaf799 --- /dev/null +++ b/greenmine/profile/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + + diff --git a/greenmine/profile/fixtures/initial_data.json b/greenmine/profile/fixtures/initial_data.json new file mode 100644 index 00000000..731e5959 --- /dev/null +++ b/greenmine/profile/fixtures/initial_data.json @@ -0,0 +1,95 @@ +[ + { + "pk": 1, + "model": "profile.role", + "fields": { + "name": "Developer", + "slug": "developer", + "project_view": true, + "project_edit": false, + "project_delete": false, + "userstory_view": true, + "userstory_create": true, + "userstory_edit": true, + "userstory_delete": true, + "milestone_view": true, + "milestone_create": true, + "milestone_edit": true, + "milestone_delete": true, + "task_view": true, + "task_create": true, + "task_edit": true, + "task_delete": true, + "wiki_view": true, + "wiki_create": true, + "wiki_edit": true, + "wiki_delete": true, + "question_view": true, + "question_create": true, + "question_edit": true, + "question_delete": true + } + }, + { + "pk": 2, + "model": "profile.role", + "fields": { + "name": "Product Owner", + "slug": "product-owner", + "project_view": true, + "project_edit": false, + "project_delete": false, + "userstory_view": true, + "userstory_create": true, + "userstory_edit": true, + "userstory_delete": true, + "milestone_view": true, + "milestone_create": false, + "milestone_edit": false, + "milestone_delete": false, + "task_view": true, + "task_create": false, + "task_edit": false, + "task_delete": false, + "wiki_view": true, + "wiki_create": true, + "wiki_edit": true, + "wiki_delete": true, + "question_view": true, + "question_create": true, + "question_edit": true, + "question_delete": true + } + }, + { + "pk": 3, + "model": "profile.role", + "fields": { + "name": "Observer", + "slug": "observer", + "project_view": true, + "project_edit": false, + "project_delete": false, + "userstory_view": true, + "userstory_create": false, + "userstory_edit": false, + "userstory_delete": false, + "milestone_view": true, + "milestone_create": false, + "milestone_edit": false, + "milestone_delete": false, + "task_view": true, + "task_create": false, + "task_edit": false, + "task_delete": false, + "wiki_view": true, + "wiki_create": false, + "wiki_edit": false, + "wiki_delete": false, + "question_view": true, + "question_create": false, + "question_edit": false, + "question_delete": false + } + } +] diff --git a/greenmine/profile/models.py b/greenmine/profile/models.py new file mode 100644 index 00000000..72c2b68b --- /dev/null +++ b/greenmine/profile/models.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- + +from django.conf import settings +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import ugettext + +from django.utils import timezone +from django.core.files.storage import FileSystemStorage +from django.contrib.contenttypes.models import ContentType +from django.contrib.auth.models import User + +from greenmine.core.fields import DictField, ListField +from greenmine.wiki.fields import WikiField +from greenmine.core.utils import iter_points + +import datetime +import re + + +class Profile(models.Model): + user = models.OneToOneField("auth.User", related_name='profile') + description = WikiField(blank=True) + photo = models.FileField(upload_to="files/msg", + max_length=500, null=True, blank=True) + + default_language = models.CharField(max_length=20, + null=True, blank=True, default=None) + default_timezone = models.CharField(max_length=20, + null=True, blank=True, default=None) + token = models.CharField(max_length=200, unique=True, + null=True, blank=True, default=None) + colorize_tags = models.BooleanField(default=False) + + +class Role(models.Model): + name = models.CharField(max_length=200) + slug = models.SlugField(max_length=250, unique=True, blank=True) + + project_view = models.BooleanField(default=True) + project_edit = models.BooleanField(default=False) + project_delete = models.BooleanField(default=False) + userstory_view = models.BooleanField(default=True) + userstory_create = models.BooleanField(default=False) + userstory_edit = models.BooleanField(default=False) + userstory_delete = models.BooleanField(default=False) + milestone_view = models.BooleanField(default=True) + milestone_create = models.BooleanField(default=False) + milestone_edit = models.BooleanField(default=False) + milestone_delete = models.BooleanField(default=False) + task_view = models.BooleanField(default=True) + task_create = models.BooleanField(default=False) + task_edit = models.BooleanField(default=False) + task_delete = models.BooleanField(default=False) + wiki_view = models.BooleanField(default=True) + wiki_create = models.BooleanField(default=False) + wiki_edit = models.BooleanField(default=False) + wiki_delete = models.BooleanField(default=False) + question_view = models.BooleanField(default=True) + question_create = models.BooleanField(default=True) + question_edit = models.BooleanField(default=True) + question_delete = models.BooleanField(default=False) + document_view = models.BooleanField(default=True) + document_create = models.BooleanField(default=True) + document_edit = models.BooleanField(default=True) + document_delete = models.BooleanField(default=True) + + +from . import sigdispatch diff --git a/greenmine/profile/sigdispatch.py b/greenmine/profile/sigdispatch.py new file mode 100644 index 00000000..e3ddec02 --- /dev/null +++ b/greenmine/profile/sigdispatch.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import + +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.contrib.auth.models import User + +from .models import Profile + +@receiver(post_save, sender=User) +def user_post_save(sender, instance, created, **kwargs): + """ + Create void user profile if instance is a new user. + """ + if created and not Profile.objects.filter(user=instance).exists(): + Profile.objects.create(user=instance) diff --git a/greenmine/questions/__init__.py b/greenmine/questions/__init__.py new file mode 100644 index 00000000..40a96afc --- /dev/null +++ b/greenmine/questions/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/greenmine/questions/models.py b/greenmine/questions/models.py new file mode 100644 index 00000000..14931565 --- /dev/null +++ b/greenmine/questions/models.py @@ -0,0 +1,65 @@ +from django.db import models +from greenmine.core.utils.slug import slugify_uniquely +from greenmine.wiki.fields import WikiField +from greenmine.taggit.managers import TaggableManager + + +class Question(models.Model): + subject = models.CharField(max_length=150) + slug = models.SlugField(unique=True, max_length=250, blank=True) + content = WikiField(blank=True) + closed = models.BooleanField(default=False) + attached_file = models.FileField(upload_to="messages", + max_length=500, null=True, blank=True) + + project = models.ForeignKey('scrum.Project', related_name='questions') + milestone = models.ForeignKey('scrum.Milestone', related_name='questions', + null=True, default=None, blank=True) + + assigned_to = models.ForeignKey("auth.User") + created_date = models.DateTimeField(auto_now_add=True) + modified_date = models.DateTimeField(auto_now_add=True) + owner = models.ForeignKey('auth.User', related_name='questions') + + + watchers = models.ManyToManyField('auth.User', + related_name='question_watch', null=True, blank=True) + + tags = TaggableManager() + + @models.permalink + def get_absolute_url(self): + return self.get_view_url() + + @models.permalink + def get_view_url(self): + return ('questions-view', (), + {'pslug': self.project.slug, 'qslug': self.slug}) + + @models.permalink + def get_edit_url(self): + return ('questions-edit', (), + {'pslug': self.project.slug, 'qslug': self.slug}) + + @models.permalink + def get_delete_url(self): + return ('questions-delete', (), + {'pslug': self.project.slug, 'qslug': self.slug}) + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify_uniquely(self.subject, self.__class__) + super(Question, self).save(*args, **kwargs) + + +class QuestionResponse(models.Model): + content = WikiField() + created_date = models.DateTimeField(auto_now_add=True) + modified_date = models.DateTimeField(auto_now_add=True) + attached_file = models.FileField(upload_to="messages", + max_length=500, null=True, blank=True) + + question = models.ForeignKey('Question', related_name='responses') + owner = models.ForeignKey('auth.User', related_name='questions_responses') + + diff --git a/greenmine/questions/search_indexes.py b/greenmine/questions/search_indexes.py new file mode 100644 index 00000000..6ba50536 --- /dev/null +++ b/greenmine/questions/search_indexes.py @@ -0,0 +1,13 @@ +# -* coding: utf-8 -*- +from haystack import indexes +from .models import Question + + +class QuestionIndex(indexes.RealTimeSearchIndex, indexes.Indexable): + text = indexes.CharField(document=True, use_template=True, template_name='search/indexes/question_text.txt') + + def get_model(self): + return Question + + def index_queryset(self): + return self.get_model().objects.all() diff --git a/greenmine/questions/templates/search/indexes/question_text.txt b/greenmine/questions/templates/search/indexes/question_text.txt new file mode 100644 index 00000000..5cb45124 --- /dev/null +++ b/greenmine/questions/templates/search/indexes/question_text.txt @@ -0,0 +1,21 @@ +{{ object.subject }} +{{ object.slug }} +{{ object.content }} +{{ object.attached_file }} +{{ object.project }} +{{ object.milestone }} +{{ object.assigned_to }} +{{ object.created_date }} +{{ object.modified_date }} +{{ object.owner }} +{% for watcher in object.watchers.all %} + {{ watcher }} +{% endfor %} +{% for response in object.responses.all %} + {{ response.content }} + {{ response.created_date }} + {{ response.modified_date }} + {{ response.attached_file }} + {{ response.owner }} +{% endfor %} + diff --git a/greenmine/scrum/__init__.py b/greenmine/scrum/__init__.py new file mode 100644 index 00000000..faaaf799 --- /dev/null +++ b/greenmine/scrum/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + + diff --git a/greenmine/scrum/choices.py b/greenmine/scrum/choices.py new file mode 100644 index 00000000..afc78a42 --- /dev/null +++ b/greenmine/scrum/choices.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- + +from django.utils.translation import ugettext_lazy as _ + +from .utils import SCRUM_STATES + +ORG_ROLE_CHOICES = ( + ('owner', _(u'Owner')), + ('developer', _(u'Developer')), +) + +MARKUP_TYPE = ( + ('md', _(u'Markdown')), + ('rst', _('Restructured Text')), +) + +US_STATUS_CHOICES = SCRUM_STATES.get_us_choices() + +TASK_PRIORITY_CHOICES = ( + (1, _(u'Low')), + (3, _(u'Normal')), + (5, _(u'High')), +) + +TASK_SEVERITY_CHOICES = ( + (1, _(u'Wishlist')), + (2, _(u'Minor')), + (3, _(u'Normal')), + (4, _(u'Important')), + (5, _(u'Critical')), +) + +TASK_TYPE_CHOICES = ( + ('bug', _(u'Bug')), + ('task', _(u'Task')), +) + +TASK_STATUS_CHOICES = SCRUM_STATES.get_task_choices() + +POINTS_CHOICES = ( + (-1, u'?'), + (0, u'0'), + (-2, u'1/2'), + (1, u'1'), + (2, u'2'), + (3, u'3'), + (5, u'5'), + (8, u'8'), + (10, u'10'), + (15, u'15'), + (20, u'20'), + (40, u'40'), +) + + +TASK_COMMENT = 1 +TASK_STATUS_CHANGE = 2 +TASK_PRIORITY_CHANGE = 3 +TASK_ASSIGNATION_CHANGE = 4 + +TASK_CHANGE_CHOICES = ( + (TASK_COMMENT, _(u"Task comment")), + (TASK_STATUS_CHANGE, _(u"Task status change")), + (TASK_PRIORITY_CHANGE, _(u"Task prioriy change")), + (TASK_ASSIGNATION_CHANGE, _(u"Task assignation change")), +) diff --git a/greenmine/scrum/fixtures/development_users.json b/greenmine/scrum/fixtures/development_users.json new file mode 100644 index 00000000..70a0b22e --- /dev/null +++ b/greenmine/scrum/fixtures/development_users.json @@ -0,0 +1,38 @@ +[ + { + "pk": 1, + "model": "auth.user", + "fields": { + "username": "andrei", + "first_name": "Andrei", + "last_name": "Antoukh", + "is_active": true, + "is_superuser": true, + "is_staff": true, + "last_login": "2011-08-18T19:53:26Z", + "groups": [], + "user_permissions": [], + "password": "sha1$DytEojXM0V24$3af4d8cda73cd08525871de8c9b622a39a6faed0", + "email": "niwi@niwi.be", + "date_joined": "2011-08-18T19:53:26Z" + } + }, + { + "pk": 2, + "model": "auth.user", + "fields": { + "username": "juanfran", + "first_name": "Juan Fran", + "last_name": "Alcantara", + "is_active": true, + "is_superuser": true, + "is_staff": true, + "last_login": "2011-08-18T19:53:26Z", + "groups": [], + "user_permissions": [], + "password": "sha1$DytEojXM0V24$3af4d8cda73cd08525871de8c9b622a39a6faed0", + "email": "juanfran@niwi.be", + "date_joined": "2011-08-18T19:53:26Z" + } + } +] diff --git a/greenmine/scrum/management/__init__.py b/greenmine/scrum/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/greenmine/scrum/management/commands/__init__.py b/greenmine/scrum/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/greenmine/scrum/management/commands/sample_data.py b/greenmine/scrum/management/commands/sample_data.py new file mode 100644 index 00000000..e8eb779a --- /dev/null +++ b/greenmine/scrum/management/commands/sample_data.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- + +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from django.db.utils import IntegrityError +from django.core import management +from django.contrib.webdesign import lorem_ipsum +from django.utils.timezone import now + +import random, sys, datetime + +from greenmine.base.models import * +from greenmine.scrum.models import * + +subjects = [ + "Fixing templates for Django 1.2.", + "get_actions() does not check for 'delete_selected' in actions", + "Experimental: modular file types", + "Add setting to allow regular users to create folders at the root level.", + "add tests for bulk operations", + "create testsuite with matrix builds", + "Lighttpd support", + "Lighttpd x-sendfile support", + "Added file copying and processing of images (resizing)", + "Exception is thrown if trying to add a folder with existing name", + "Feature/improved image admin", + "Support for bulk actions", +] + +class Command(BaseCommand): + @transaction.commit_on_success + def handle(self, *args, **options): + from django.core import management + management.call_command('loaddata', 'development_users') + users_counter = 0 + + def create_user(counter): + user = User.objects.create( + username = 'foouser%d' % (counter), + first_name = 'foouser%d' % (counter), + email = 'foouser%d@foodomain.com' % (counter), + ) + return user + + # projects + for x in xrange(3): + # create project + project = Project.objects.create( + name = 'Project Example %s' % (x), + description = 'Project example %s description' % (x), + owner = random.choice(list(User.objects.all()[:1])), + public = True, + ) + + project.add_user(project.owner, "developer") + + extras = project.get_extras() + extras.show_burndown = True + extras.show_sprint_burndown = True + extras.sprints = 4 + extras.save() + + # add random participants to project + participants = [] + for t in xrange(random.randint(1, 2)): + participant = create_user(users_counter) + participants.append(participant) + + project.add_user(participant, "developer") + users_counter += 1 + + now_date = now() - datetime.timedelta(30) + + # create random milestones + for y in xrange(2): + milestone = Milestone.objects.create( + project = project, + name = 'Sprint %s' % (y), + owner = project.owner, + created_date = now_date, + modified_date = now_date, + estimated_start = now_date, + estimated_finish = now_date + datetime.timedelta(15) + ) + + now_date = now_date + datetime.timedelta(15) + + # create uss asociated to milestones + for z in xrange(5): + us = UserStory.objects.create( + subject = lorem_ipsum.words(random.randint(4,9), common=False), + priority = 6, + points = 3, + project = project, + owner = random.choice(participants), + description = lorem_ipsum.words(30, common=False), + milestone = milestone, + status = 'completed', + ) + for tag in lorem_ipsum.words(random.randint(1,5), common=True).split(" "): + us.tags.add(tag) + + for w in xrange(3): + task = Task.objects.create( + subject = "Task %s" % (w), + description = lorem_ipsum.words(30, common=False), + project = project, + owner = random.choice(participants), + milestone = milestone, + user_story = us, + status = 'completed', + ) + + # created unassociated uss. + for y in xrange(10): + us = UserStory.objects.create( + subject = lorem_ipsum.words(random.randint(4,9), common=False), + priority = 3, + points = 3, + status = 'open', + owner = random.choice(participants), + description = lorem_ipsum.words(30, common=False), + milestone = None, + project = project, + ) + + for tag in lorem_ipsum.words(random.randint(1,5), common=True).split(" "): + us.tags.add(tag) + + # create bugs. + for y in xrange(20): + bug = Task.objects.create( + project = project, + type = "bug", + severity = random.randint(1,5), + subject = lorem_ipsum.words(random.randint(1,5), common=False), + description = lorem_ipsum.words(random.randint(1,15), common=False), + owner = project.owner, + ) + + for tag in lorem_ipsum.words(random.randint(1,5), common=True).split(" "): + bug.tags.add(tag) + diff --git a/greenmine/scrum/models.py b/greenmine/scrum/models.py new file mode 100644 index 00000000..9be01f8b --- /dev/null +++ b/greenmine/scrum/models.py @@ -0,0 +1,809 @@ +# -*- coding: utf-8 -*- + +from django.conf import settings +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import ugettext + +from django.utils import timezone +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes import generic +from django.contrib.auth.models import User +from django.contrib.auth.models import UserManager + +from greenmine.core.utils.slug import slugify_uniquely, ref_uniquely +from greenmine.core.fields import DictField, ListField +from greenmine.wiki.fields import WikiField +from greenmine.core.utils import iter_points +from greenmine.taggit.managers import TaggableManager +import reversion + +from .choices import * + +import datetime +import re + +from .utils import SCRUM_STATES + +class ProjectManager(models.Manager): + def get_by_natural_key(self, slug): + return self.get(slug=slug) + + def can_view(self, user): + queryset = ProjectUserRole.objects.filter(user=user)\ + .values_list('project', flat=True) + return Project.objects.filter(pk__in=queryset) + + +class ProjectExtras(models.Model): + task_parser_re = models.CharField(max_length=1000, blank=True, null=True, default=None) + sprints = models.IntegerField(default=1, blank=True, null=True) + show_burndown = models.BooleanField(default=False, blank=True) + show_burnup = models.BooleanField(default=False, blank=True) + show_sprint_burndown = models.BooleanField(default=False, blank=True) + total_story_points = models.FloatField(default=None, null=True) + + def get_task_parse_re(self): + re_str = settings.DEFAULT_TASK_PARSER_RE + if self.task_parser_re: + re_str = self.task_parser_re + return re.compile(re_str, flags=re.U+re.M) + + def parse_ustext(self, text): + rx = self.get_task_parse_re() + texts = rx.findall(text) + for text in texts: + yield text + + +class Project(models.Model): + uuid = models.CharField(max_length=40, unique=True, blank=True) + name = models.CharField(max_length=250, unique=True) + slug = models.SlugField(max_length=250, unique=True, blank=True) + description = WikiField(blank=False) + + created_date = models.DateTimeField(auto_now_add=True) + modified_date = models.DateTimeField(auto_now_add=True) + + owner = models.ForeignKey("auth.User", related_name="projects") + participants = models.ManyToManyField('auth.User', + related_name="projects_participant", through="ProjectUserRole", + null=True, blank=True) + + public = models.BooleanField(default=True) + markup = models.CharField(max_length=10, choices=MARKUP_TYPE, default='md') + extras = models.OneToOneField("ProjectExtras", related_name="project", null=True, default=None) + + last_us_ref = models.BigIntegerField(null=True, default=0) + last_task_ref = models.BigIntegerField(null=True, default=0) + + objects = ProjectManager() + + def __unicode__(self): + return self.name + + def get_extras(self): + if self.extras is None: + self.extras = ProjectExtras.objects.create() + self.__class__.objects.filter(pk=self.pk).update(extras=self.extras) + + return self.extras + + def natural_key(self): + return (self.slug,) + + @property + def unasociated_user_stories(self): + return self.user_stories.filter(milestone__isnull=True) + + @property + def all_participants(self): + qs = ProjectUserRole.objects.filter(project=self) + return User.objects.filter(id__in=qs.values_list('user__pk', flat=True)) + + @property + def default_milestone(self): + return self.milestones.order_by('-created_date')[0] + + def __repr__(self): + return u"" % (self.slug) + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify_uniquely(self.name, self.__class__) + else: + self.modified_date = timezone.now() + + super(Project, self).save(*args, **kwargs) + + def add_user(self, user, role): + from greenmine.core import permissions + return ProjectUserRole.objects.create( + project = self, + user = user, + role = permissions.get_role(role), + ) + + + """ Permalinks """ + @models.permalink + def get_absolute_url(self): + return ('project-backlog', (), + {'pslug': self.slug}) + + @models.permalink + def get_dashboard_url(self): + return ('dashboard', (), {'pslug': self.slug}) + + @models.permalink + def get_backlog_url(self): + return ('project-backlog', (), + {'pslug': self.slug}) + + @models.permalink + def get_backlog_stats_url(self): + return ('project-backlog-stats', (), + {'pslug': self.slug}) + + @models.permalink + def get_backlog_left_block_url(self): + return ('project-backlog-left-block', (), + {'pslug': self.slug}) + + @models.permalink + def get_backlog_right_block_url(self): + return ('project-backlog-right-block', (), + {'pslug': self.slug}) + + @models.permalink + def get_backlog_burndown_url(self): + return ('project-backlog-burndown', (), + {'pslug': self.slug}) + + @models.permalink + def get_backlog_burnup_url(self): + return ('project-backlog-burnup', (), + {'pslug': self.slug}) + + @models.permalink + def get_milestone_create_url(self): + return ('milestone-create', (), + {'pslug': self.slug}) + + @models.permalink + def get_userstory_create_url(self): + return ('user-story-create', (), {'pslug': self.slug}) + + @models.permalink + def get_edit_url(self): + return ('project-edit', (), {'pslug': self.slug}) + + @models.permalink + def get_delete_url(self): + return ('project-delete', (), {'pslug': self.slug}) + + @models.permalink + def get_export_url(self): + return ('project-export-settings', (), {'pslug': self.slug}) + + @models.permalink + def get_export_now_url(self): + return ('project-export-settings-now', (), {'pslug': self.slug}) + + @models.permalink + def get_export_rehash_url(self): + return ('project-export-settings-rehash', (), {'pslug': self.slug}) + + @models.permalink + def get_issues_url(self): + return ('issues-list', (), {'pslug': self.slug}) + + @models.permalink + def get_settings_url(self): + return ('project-personal-settings', (), {'pslug': self.slug}) + + @models.permalink + def get_general_settings_url(self): + return ('project-general-settings', (), {'pslug': self.slug}) + + @models.permalink + def get_questions_url(self): + return ('questions', (), {'pslug': self.slug}) + + @models.permalink + def get_questions_create_url(self): + return ('questions-create', (), {'pslug': self.slug}) + + @models.permalink + def get_documents_url(self): + return ('documents', (), {'pslug': self.slug}) + + @models.permalink + def get_wiki_url(self): + return ('wiki-page', (), {'pslug': self.slug, 'wslug': 'index'}) + + +class ProjectUserRole(models.Model): + project = models.ForeignKey("Project", related_name="user_roles") + user = models.ForeignKey("auth.User", related_name="user_roles") + role = models.ForeignKey("profile.Role", related_name="user_roles") + + mail_milestone_created = models.BooleanField(default=True) + mail_milestone_modified = models.BooleanField(default=False) + mail_milestone_deleted = models.BooleanField(default=False) + mail_userstory_created = models.BooleanField(default=True) + mail_userstory_modified = models.BooleanField(default=False) + mail_userstory_deleted = models.BooleanField(default=False) + mail_task_created = models.BooleanField(default=True) + mail_task_assigned = models.BooleanField(default=False) + mail_task_deleted = models.BooleanField(default=False) + mail_question_created = models.BooleanField(default=False) + mail_question_assigned = models.BooleanField(default=True) + mail_question_deleted = models.BooleanField(default=False) + mail_document_created = models.BooleanField(default=True) + mail_document_deleted = models.BooleanField(default=False) + mail_wiki_created = models.BooleanField(default=False) + mail_wiki_modified = models.BooleanField(default=False) + mail_wiki_deleted = models.BooleanField(default=False) + + def __repr__(self): + return u"" % (self.id) + + class Meta: + unique_together = ('project', 'user') + + +class MilestoneManager(models.Manager): + def get_by_natural_key(self, name, project): + return self.get(name=name, project__slug=project) + + +class Milestone(models.Model): + uuid = models.CharField(max_length=40, unique=True, blank=True) + name = models.CharField(max_length=200, db_index=True) + owner = models.ForeignKey('auth.User', related_name="milestones") + project = models.ForeignKey('Project', related_name="milestones") + + estimated_start = models.DateField(null=True, default=None) + estimated_finish = models.DateField(null=True, default=None) + + created_date = models.DateTimeField(auto_now_add=True) + modified_date = models.DateTimeField(auto_now_add=True) + closed = models.BooleanField(default=False) + + disponibility = models.FloatField(null=True, default=0.0) + objects = MilestoneManager() + + class Meta: + ordering = ['-created_date'] + unique_together = ('name', 'project') + + @property + def total_points(self): + """ + Get total story points for this milestone. + """ + + total = sum(iter_points(self.user_stories.all())) + return "{0:.1f}".format(total) + + def get_points_done_at_date(self, date): + """ + Get completed story points for this milestone before the date. + """ + total = 0.0 + + for item in self.user_stories.filter(status__in=SCRUM_STATES.get_finished_us_states()): + if item.tasks.filter(finished_date__lt=date).count() > 0: + if item.points == -1: + continue + + if item.points == -2: + total += 0.5 + continue + + total += item.points + + return "{0:.1f}".format(total) + + @property + def completed_points(self): + """ + Get a total of completed points. + """ + + queryset = self.user_stories.filter(status__in=SCRUM_STATES.get_finished_us_states()) + total = sum(iter_points(queryset)) + return "{0:.1f}".format(total) + + @property + def percentage_completed(self): + return "{0:.1f}".format( + (float(self.completed_points) * 100) / float(self.total_points) + ) + + @models.permalink + def get_absolute_url(self): + return ('dashboard', (), + {'pslug': self.project.slug, 'mid': self.id}) + + @models.permalink + def get_edit_url(self): + return ('milestone-edit', (), + {'pslug': self.project.slug, 'mid': self.id}) + + @models.permalink + def get_delete_url(self): + return ('milestone-delete', (), + {'pslug': self.project.slug, 'mid': self.id}) + + @models.permalink + def get_dashboard_url(self): + return ('dashboard', (), + {'pslug': self.project.slug, 'mid': self.id}) + + @models.permalink + def get_stats_url(self): + return ('dashboard-api-stats', (), + {'pslug': self.project.slug, 'mid': self.id}) + + @models.permalink + def get_user_story_create_url(self): + return ('user-story-create', (), + {'pslug': self.project.slug, 'mid': self.id}) + + @models.permalink + def get_ml_detail_url(self): + return ('milestone-dashboard', (), + {'pslug': self.project.slug, 'mid': self.id}) + + @models.permalink + def get_create_task_url(self): + # TODO: deprecated + return ('api:task-create', (), + {'pslug': self.project.slug, 'mid': self.id}) + + @models.permalink + def get_stats_api_url(self): + return ('api:stats-milestone', (), + {'pslug': self.project.slug, 'mid': self.id}) + + @models.permalink + def get_tasks_url(self): + return ('tasks-view', (), + {'pslug': self.project.slug, 'mid': self.id}) + + @models.permalink + def get_tasks_url_filter_by_task(self): + return ('tasks-view', (), + {'pslug': self.project.slug, 'mid': self.id, 'filter_by':'task'}) + + @models.permalink + def get_tasks_url_filter_by_bug(self): + return ('tasks-view', (), + {'pslug': self.project.slug, 'mid': self.id, 'filter_by':'bug'}) + + @models.permalink + def get_task_create_url(self): + return ('task-create', (), + {'pslug': self.project.slug, 'mid': self.id}) + + class Meta(object): + unique_together = ('name', 'project') + + def natural_key(self): + return (self.name,) + self.project.natural_key() + + natural_key.dependencies = ['greenmine.Project'] + + def __unicode__(self): + return self.name + + def __repr__(self): + return u"" % (self.id) + + def save(self, *args, **kwargs): + if self.id: + self.modified_date = timezone.now() + + super(Milestone, self).save(*args, **kwargs) + + +class UserStory(models.Model): + uuid = models.CharField(max_length=40, unique=True, blank=True) + ref = models.CharField(max_length=200, db_index=True, null=True, default=None) + milestone = models.ForeignKey("Milestone", blank=True, + related_name="user_stories", null=True, default=None) + project = models.ForeignKey("Project", related_name="user_stories") + owner = models.ForeignKey("auth.User", null=True, + default=None, related_name="user_stories") + priority = models.IntegerField(default=1) + points = models.IntegerField(choices=POINTS_CHOICES, default=-1) + status = models.CharField(max_length=50, + choices=SCRUM_STATES.get_us_choices(), db_index=True, default="open") + + tags = TaggableManager() + + created_date = models.DateTimeField(auto_now_add=True) + modified_date = models.DateTimeField(auto_now_add=True) + tested = models.BooleanField(default=False) + + subject = models.CharField(max_length=500) + description = WikiField() + finish_date = models.DateTimeField(null=True, blank=True) + + watchers = models.ManyToManyField('auth.User', + related_name='us_watch', null=True) + + client_requirement = models.BooleanField(default=False) + team_requirement = models.BooleanField(default=False) + + class Meta: + unique_together = ('ref', 'project') + + def to_dict(self): + return { + "id": self.pk, + "ref": self.ref, + "subject": self.subject, + "viewUrl": self.get_view_url(), + "pointsDisplay": self.get_points_display(), + "tags": [ {'id': tag.id, 'name': tag.name} for tag in self.tags.all() ] + } + + def __repr__(self): + return u"" % (self.id) + + def __unicode__(self): + return u"{0} ({1})".format(self.subject, self.ref) + + def save(self, *args, **kwargs): + if self.id: + self.modified_date = timezone.now() + if not self.ref: + self.ref = ref_uniquely(self.project, self.__class__) + + super(UserStory, self).save(*args, **kwargs) + + @models.permalink + def get_absolute_url(self): + return ('user-story', (), + {'pslug': self.project.slug, 'iref': self.ref}) + + @models.permalink + def get_assign_url(self): + return ('assign-us', (), + {'pslug': self.project.slug, 'iref': self.ref}) + + @models.permalink + def get_unassign_url(self): + return ('unassign-us', (), + {'pslug': self.project.slug, 'iref': self.ref}) + + @models.permalink + def get_drop_api_url(self): + # TODO: check if this url is used. + return ('api:user-story-drop', (), + {'pslug': self.project.slug, 'iref': self.ref}) + + @models.permalink + def get_view_url(self): + return ('user-story', (), + {'pslug': self.project.slug, 'iref': self.ref}) + + @models.permalink + def get_edit_url(self): + return ('user-story-edit', (), + {'pslug': self.project.slug, 'iref': self.ref}) + + @models.permalink + def get_edit_inline_url(self): + return ('user-story-edit-inline', (), + {'pslug': self.project.slug, 'iref': self.ref}) + + @models.permalink + def get_delete_url(self): + return ('user-story-delete', (), + {'pslug': self.project.slug, 'iref': self.ref}) + + @models.permalink + def get_task_create_url(self): + return ('task-create', (), + {'pslug': self.project.slug, 'usref': self.ref}) + + """ Propertys """ + def update_status(self): + tasks = self.tasks.all() + used_states = [] + + for task in tasks: + used_states.append(task.fake_status) + used_states = set(used_states) + + all_completed = True + for state in SCRUM_STATES.ordered_us_states(): + for task_state in used_states: + if task_state == state: + self.status = state + self.save() + return None + return None + + @property + def tasks_new(self): + return self.tasks.filter(status__in=SCRUM_STATES.get_task_states_for_us_state('open')) + + @property + def tasks_progress(self): + return self.tasks.filter(status__in=SCRUM_STATES.get_task_states_for_us_state('progress')) + + @property + def tasks_completed(self): + return self.tasks.filter(status__in=SCRUM_STATES.get_task_states_for_us_state('completed')) + + @property + def tasks_closed(self): + return self.tasks.filter(status__in=SCRUM_STATES.get_task_states_for_us_state('closed')) + + +class Change(models.Model): + change_type = models.IntegerField(choices=TASK_CHANGE_CHOICES) + owner = models.ForeignKey('auth.User', related_name='changes') + created_date = models.DateTimeField(auto_now_add=True) + + project = models.ForeignKey("Project", related_name="changes") + + content_type = models.ForeignKey(ContentType) + object_id = models.PositiveIntegerField() + content_object = generic.GenericForeignKey('content_type', 'object_id') + + data = DictField() + + +class ChangeAttachment(models.Model): + change = models.ForeignKey("Change", related_name="attachments") + owner = models.ForeignKey("auth.User", related_name="change_attachments") + + created_date = models.DateTimeField(auto_now_add=True) + attached_file = models.FileField(upload_to="files/msg", + max_length=500, null=True, blank=True) + + +class TaskQuerySet(models.query.QuerySet): + def _add_categories(self, section_dict, category_id, category_element, selected): + section_dict[category_id] = section_dict.get(category_id, { + 'element': unicode(category_element), + 'count': 0, + 'id': category_id, + 'selected': selected, + }) + section_dict[category_id]['count'] += 1 + + def _get_category(self, section_dict, order_by='element', reverse=False): + values = section_dict.values() + values = sorted(values, key=lambda entry: unicode(entry[order_by])) + if reverse: + values.reverse() + return values + + def _get_filter_and_build_filter_dict(self, queryset, milestone_id, status_id, tags_ids, assigned_to_id, severity_id): + task_list = list(queryset) + milestones = {} + status = {} + tags = {} + assigned_to = {} + severity = {} + + for task in task_list: + if task.milestone: + selected = milestone_id and task.milestone.id == milestone_id + self._add_categories(milestones, task.milestone.id, task.milestone.name, selected) + + selected = status_id and task.status == status_id + self._add_categories(status, task.status, task.get_status_display(), selected) + + for tag in task.tags.all(): + selected = tags_ids and tag.id in tags_ids + self._add_categories(tags, tag.id, tag.name, selected) + + if task.assigned_to: + selected = assigned_to_id and task.assigned_to.id == assigned_to_id + self._add_categories(assigned_to, task.assigned_to.id, task.assigned_to.first_name, selected) + + selected = severity_id and task.severity == int(severity_id) + self._add_categories(severity, task.severity, task.get_severity_display(), selected) + + return{ + 'list': task_list, + 'filters' : { + 'milestones' : self._get_category(milestones), + 'status' : self._get_category(status), + 'tags' : self._get_category(tags), + 'assigned_to' : self._get_category(assigned_to), + 'severity' : self._get_category(severity), + } + } + + def filter_and_build_filter_dict(self, milestone=None, status=None, tags=None, assigned_to=None, severity=None): + + queryset = self + if milestone: + queryset = queryset.filter(milestone = milestone) + + if status: + queryset = queryset.filter(status = status) + + if tags: + for tag in tags: + queryset = queryset.filter(tags__in=[tag]) + + if assigned_to: + queryset = queryset.filter(assigned_to = assigned_to) + + if severity: + queryset = queryset.filter(severity = severity) + + + milestone_id = milestone and milestone.id + status_id = status + tags_ids = tags and tags.values_list('id', flat=True) + assigned_to_id = assigned_to and assigned_to.id + severity_id = severity + + return self._get_filter_and_build_filter_dict(queryset, milestone_id, status_id, tags_ids, assigned_to_id, severity_id) + +class TaskManager(models.Manager): + def get_query_set(self): + return TaskQuerySet(self.model) + + +class Task(models.Model): + uuid = models.CharField(max_length=40, unique=True, blank=True) + user_story = models.ForeignKey('UserStory', related_name='tasks', null=True, blank=True) + last_user_story = models.ForeignKey('UserStory', null=True, blank=True) + ref = models.CharField(max_length=200, db_index=True, null=True, default=None) + status = models.CharField(max_length=50, + choices=TASK_STATUS_CHOICES, default='open') + owner = models.ForeignKey("auth.User", null=True, + default=None, related_name="tasks") + + severity = models.IntegerField(choices=TASK_SEVERITY_CHOICES, default=3) + priority = models.IntegerField(choices=TASK_PRIORITY_CHOICES, default=3) + milestone = models.ForeignKey('Milestone', related_name='tasks', + null=True, default=None, blank=True) + + project = models.ForeignKey('Project', related_name='tasks') + type = models.CharField(max_length=10, + choices=TASK_TYPE_CHOICES, default='task') + + created_date = models.DateTimeField(auto_now_add=True) + modified_date = models.DateTimeField(auto_now_add=True) + finished_date = models.DateTimeField(null=True, blank=True) + last_status = models.CharField(max_length=50, + choices=TASK_STATUS_CHOICES, null=True, blank=True) + + subject = models.CharField(max_length=500) + description = WikiField(blank=True) + assigned_to = models.ForeignKey('auth.User', + related_name='user_storys_assigned_to_me', + blank=True, null=True, default=None) + + watchers = models.ManyToManyField('auth.User', + related_name='task_watch', null=True) + + changes = generic.GenericRelation(Change) + + tags = TaggableManager() + + objects = TaskManager() + + class Meta: + unique_together = ('ref', 'project') + + def __unicode__(self): + return self.subject + + @property + def fake_status(self): + return SCRUM_STATES.get_us_state_for_task_state(self.status) + + @models.permalink + def get_absolute_url(self): + if self.type == "bug": + return ('issues-view', (), {'pslug':self.project.slug, 'tref': self.ref}) + else: + return ('tasks-view', (), {'pslug':self.project.slug, 'tref': self.ref}) + + @models.permalink + def get_edit_url(self): + if self.type == 'bug': + return ('issues-edit', (), + {'pslug': self.project.slug, 'tref': self.ref}) + else: + #TODO: make this url + return ('issues-edit', (), + {'pslug': self.project.slug, 'tref': self.ref}) + #return ('tasks-edit', (), + #{'pslug': self.project.slug, 'tref': self.ref}) + + @models.permalink + def get_view_url(self): + if self.type == "bug": + return ('issues-view', (), {'pslug':self.project.slug, 'tref': self.ref}) + else: + return ('tasks-view', (), {'pslug':self.project.slug, 'tref': self.ref}) + + @models.permalink + def get_delete_url(self): + if self.type == "bug": + return ('issues-delete', (), {'pslug':self.project.slug, 'tref': self.ref}) + else: + return ('tasks-delete', (), {'pslug':self.project.slug, 'tref': self.ref}) + + def save(self, *args, **kwargs): + last_user_story = None + if self.last_user_story != self.user_story: + last_user_story = self.last_user_story + self.last_user_story = self.user_story + + if self.id: + self.modified_date = timezone.now() + # Store information about close date of a task + if self.last_status != self.status: + if self.last_status in SCRUM_STATES.get_finished_task_states(): + if self.status in SCRUM_STATES.get_unfinished_task_states(): + self.finished_date = None + elif self.last_status in SCRUM_STATES.get_unfinished_task_states(): + if self.status in SCRUM_STATES.get_finished_task_states(): + self.finished_date = timezone.now() + self.last_status = self.status + + if not self.ref: + self.ref = ref_uniquely(self.project, self.__class__) + + super(Task, self).save(*args, **kwargs) + + if last_user_story: + last_user_story.update_status() + + if self.user_story: + self.user_story.update_status() + + + def to_dict(self): + self_dict = { + 'id': self.pk, + 'editUrl': self.get_edit_url(), + 'viewUrl': self.get_view_url(), + 'deleteUrl': self.get_delete_url(), + 'subject': self.subject, + 'type': self.get_type_display(), + 'statusDisplay': self.get_status_display(), + 'status': self.status, + 'fakeStatus': self.fake_status, + 'us': self.user_story and self.user_story.pk or None, + 'assignedTo': self.assigned_to and self.assigned_to.pk or None, + 'tags': [tag.to_dict() for tag in self.tags.all()], + 'priority': self.priority, + 'priorityDisplay': self.get_priority_display(), + 'severity': self.severity, + 'severityDisplay': self.get_severity_display(), + } + + if self_dict['assignedTo']: + self_dict['assignedToDisplay'] = self.assigned_to.get_full_name() + else: + self_dict['assignedToDisplay'] = ugettext("Unassigned") + return self_dict + + +reversion.register(ProjectExtras) +reversion.register(Project) +reversion.register(ProjectUserRole) +reversion.register(Milestone) +reversion.register(UserStory) +reversion.register(Change) +reversion.register(ChangeAttachment) +reversion.register(Task) + +from . import sigdispatch diff --git a/greenmine/scrum/search_indexes.py b/greenmine/scrum/search_indexes.py new file mode 100644 index 00000000..0e146568 --- /dev/null +++ b/greenmine/scrum/search_indexes.py @@ -0,0 +1,23 @@ +# -* coding: utf-8 -*- +from haystack import indexes +from .models import Project, Milestone, UserStory, Task + + +class UserStoryIndex(indexes.SearchIndex, indexes.Indexable): + text = indexes.CharField(document=True, use_template=True, template_name='search/indexes/userstory_text.txt') + + def get_model(self): + return UserStory + + def index_queryset(self): + return self.get_model().objects.all() + + +class TaskIndex(indexes.SearchIndex, indexes.Indexable): + text = indexes.CharField(document=True, use_template=True, template_name='search/indexes/task_text.txt') + + def get_model(self): + return Task + + def index_queryset(self): + return self.get_model().objects.all() diff --git a/greenmine/scrum/sigdispatch.py b/greenmine/scrum/sigdispatch.py new file mode 100644 index 00000000..2263fb3e --- /dev/null +++ b/greenmine/scrum/sigdispatch.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- + +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.contrib.auth.models import User +from django.utils import timezone + +from greenmine.profile.models import Profile +from greenmine.scrum.models import UserStory, Task, ProjectUserRole +from greenmine.core.utils import normalize_tagname +from greenmine.core import signals +from greenmine.core.utils.auth import set_token + +from greenqueue import send_task +from django.conf import settings +from django.utils.translation import ugettext +from django.template.loader import render_to_string + +@receiver(signals.mail_new_user) +def mail_new_user(sender, user, **kwargs): + template = render_to_string("email/new.user.html", { + "user": user, + "token": set_token(user), + 'current_host': settings.HOST, + }) + + subject = ugettext("Greenmine: wellcome!") + send_task("send-mail", args = [subject, template, [user.email]]) + +@receiver(signals.mail_recovery_password) +def mail_recovery_password(sender, user, **kwargs): + template = render_to_string("email/forgot.password.html", { + "user": user, + "token": set_token(user), + "current_host": settings.HOST, + }) + subject = ugettext("Greenmine: password recovery.") + send_task("send-mail", args = [subject, template, [user.email]]) + + +@receiver(signals.mail_milestone_created) +def mail_milestone_created(sender, milestone, user, **kwargs): + participants_ids = ProjectUserRole.objects\ + .filter(user=user, mail_milestone_created=True, project=milestone.project)\ + .values_list('user__pk', flat=True) + + participants = User.objects.filter(pk__in=participants_ids) + + emails_list = [] + subject = ugettext("Greenmine: sprint created") + for person in participants: + template = render_to_string("email/milestone.created.html", { + "person": person, + "current_host": settings.HOST, + "milestone": milestone, + "user": user, + }) + + emails_list.append([subject, template, [person.email]]) + + send_task("send-bulk-mail", args=[emails_list]) + +@receiver(signals.mail_userstory_created) +def mail_userstory_created(sender, us, user, **kwargs): + participants_ids = ProjectUserRole.objects\ + .filter(user=user, mail_userstory_created=True, project=us.project)\ + .values_list('user__pk', flat=True) + + participants = User.objects.filter(pk__in=participants_ids) + + emails_list = [] + subject = ugettext("Greenmine: user story created") + + for person in participants: + template = render_to_string("email/userstory.created.html", { + "person": person, + "current_host": settings.HOST, + "us": us, + "user": user, + }) + + emails_list.append([subject, template, [person.email]]) + + send_task("send-bulk-mail", args=[emails_list]) + + +@receiver(signals.mail_task_created) +def mail_task_created(sender, task, user, **kwargs): + participants_ids = ProjectUserRole.objects\ + .filter(user=user, mail_task_created=True, project=task.project)\ + .values_list('user__pk', flat=True) + + participants = User.objects.filter(pk__in=participants_ids) + + emails_list = [] + subject = ugettext("Greenmine: task created") + + for person in participants: + template = render_to_string("email/task.created.html", { + "person": person, + "current_host": settings.HOST, + "task": task, + "user": user, + }) + + emails_list.append([subject, template, [person.email]]) + + send_task("send-bulk-mail", args=[emails_list]) + + +@receiver(signals.mail_task_assigned) +def mail_task_assigned(sender, task, user, **kwargs): + template = render_to_string("email/task.assigned.html", { + "person": task.assigned_to, + "task": task, + "user": user, + "current_host": settings.HOST, + }) + + subject = ugettext("Greenmine: task assigned") + send_task("send-mail", args = [subject, template, [task.assigned_to.email]]) diff --git a/greenmine/scrum/templates/search/indexes/task_text.txt b/greenmine/scrum/templates/search/indexes/task_text.txt new file mode 100644 index 00000000..ecc07ceb --- /dev/null +++ b/greenmine/scrum/templates/search/indexes/task_text.txt @@ -0,0 +1,21 @@ +{{ object.uuid }} +{{ object.user_story }} +{{ object.ref }} +{{ object.status }} +{{ object.owner }} +{{ object.milestone }} +{{ object.project }} +{{ object.type }} +{{ object.created_date }} +{{ object.modified_date }} +{{ object.finished_date }} +{{ object.last_status }} +{{ object.subject }} +{{ object.description }} +{{ object.assigned_to }} +{% for watcher in object.watchers.all %} + {{ watcher }} +{% endfor %} +{% for tag in object.tags.all %} + {{ tag }} +{% endfor %} diff --git a/greenmine/scrum/templates/search/indexes/userstory_text.txt b/greenmine/scrum/templates/search/indexes/userstory_text.txt new file mode 100644 index 00000000..ca0c04a6 --- /dev/null +++ b/greenmine/scrum/templates/search/indexes/userstory_text.txt @@ -0,0 +1,17 @@ +{{ object.uuid }} +{{ object.ref }} +{{ object.milestone }} +{{ object.project }} +{{ object.owner }} +{{ object.status }} +{% for tag in object.tags.all %} + {{ tag }} +{% endfor %} +{{ object.created_date }} +{{ object.modified_date }} +{{ object.subject }} +{{ object.description }} +{{ object.finish_date }} +{% for watcher in object.watchers.all %} + {{ watcher }} +{% endfor %} diff --git a/greenmine/scrum/utils.py b/greenmine/scrum/utils.py new file mode 100644 index 00000000..7a72fcde --- /dev/null +++ b/greenmine/scrum/utils.py @@ -0,0 +1,64 @@ +from django.conf import settings + +__all__ = ('SCRUM_STATES',) + +class GmScrumStates(object): + def __init__(self): + self._states = settings.GM_SCRUM_STATES + + def get_task_choices(self): + task_choices = [] + for us_state in self._states.values(): + task_choices += us_state['task_states'] + return task_choices + + def get_us_choices(self): + us_choices = [] + for key, value in self._states.iteritems(): + us_choices.append((key, value['name'])) + return us_choices + + def get_finished_task_states(self): + finished_task_states = [] + for us_state in self._states.values(): + if us_state['is_finished']: + finished_task_states += us_state['task_states'] + return [ x[0] for x in finished_task_states ] + + def get_unfinished_task_states(self): + unfinished_task_states = [] + for us_state in self._states.values(): + if not us_state['is_finished']: + unfinished_task_states += us_state['task_states'] + return [ x[0] for x in unfinished_task_states ] + + def get_finished_us_states(self): + finished_us_states = [] + for key, value in self._states.iteritems(): + if value['is_finished']: + finished_us_states.append(key) + return finished_us_states + + def get_unfinished_us_states(self): + finished_us_states = [] + for key, value in self._states.iteritems(): + if not value['is_finished']: + finished_us_states.append(key) + return finished_us_states + + def get_us_state_for_task_state(self, state): + for key, value in self._states.iteritems(): + if state in [ x[0] for x in value['task_states'] ]: + return key + return None + + def get_task_states_for_us_state(self, state): + if state in self._states.keys(): + return [ x[0] for x in self._states[state]['task_states'] ] + return None + + def ordered_us_states(self): + ordered = sorted([ (value['order'], key) for key, value in self._states.iteritems() ]) + return [ x[1] for x in ordered ] + +SCRUM_STATES = GmScrumStates() diff --git a/greenmine/settings/__init__.py b/greenmine/settings/__init__.py new file mode 100644 index 00000000..2201e479 --- /dev/null +++ b/greenmine/settings/__init__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import +import os + +if "GREENMINE_ENVIRON" in os.environ: + if os.environ["GREENMINE_ENVIRON"] in ('production', 'development', 'local'): + print "importing %s" % os.environ["GREENMINE_ENVIRON"] + eval("from .%s import *" % (os.environ["GREENMINE_ENVIRON"])) + +else: + try: + print "Trying import local.py settings..." + from .local import * + except ImportError: + print "Trying import development.py settings..." + from .development import * diff --git a/greenmine/settings/appdefaults.py b/greenmine/settings/appdefaults.py new file mode 100644 index 00000000..ece98343 --- /dev/null +++ b/greenmine/settings/appdefaults.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- + +GM_SCRUM_STATES = { + 'open': { + 'name': 'Open', + 'is_finished': False, + 'order': 10, + 'task_states': [ + ('open', 'Open'), + ] + }, + 'progress': { + 'name': 'In progress', + 'is_finished': False, + 'order': 20, + 'task_states': [ + ('progress', 'In progress'), + ('needs_info', 'Needs info'), + ('posponed', 'Posponed'), + ] + }, + 'completed': { + 'name': 'Ready for test', + 'is_finished': True, + 'order': 30, + 'task_states': [ + ('completed', 'Ready for test'), + ('workaround', 'Workaround'), + ] + }, + 'closed': { + 'name': 'Closed', + 'is_finished': True, + 'order': 40, + 'task_states': [ + ('closed', 'Closed'), + ] + }, +} diff --git a/greenmine/settings/common.py b/greenmine/settings/common.py new file mode 100644 index 00000000..60c0f127 --- /dev/null +++ b/greenmine/settings/common.py @@ -0,0 +1,320 @@ +# -*- coding: utf-8 -*- + +from django.utils.translation import ugettext_lazy as _ +import os.path, sys, os + +PROJECT_ROOT = os.path.abspath( + os.path.join(os.path.dirname(os.path.realpath(__file__)), '..') +) + +OUT_PROJECT_ROOT = os.path.abspath( + os.path.join(PROJECT_ROOT, "..") +) + + +LOGS_PATH = os.path.join(OUT_PROJECT_ROOT, 'logs') +BACKUP_PATH = os.path.join(OUT_PROJECT_ROOT, 'exports') + +if not os.path.exists(LOGS_PATH): + os.mkdir(LOGS_PATH) + +if not os.path.exists(BACKUP_PATH): + os.mkdir(BACKUP_PATH) + +ADMINS = ( + ('Andrei Antoukh', 'niwi@niwi.be'), +) + +LANGUAGES = ( + ('es', _('Spanish')), + ('en', _('English')), + ('ru', _('Russian')), +) + +if 'test' in sys.argv: + if "settings" not in ",".join(sys.argv): + print ("Not settings specified. \nTry: python manage.py test " + "--settings=greenmine.settings.testing -v2 scrum") + sys.exit(0) + +MANAGERS = ADMINS + +DISABLE_REGISTRATION = False +DEFAULT_TASK_PARSER_RE = "^\s*Task\:(.+)$" + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'. + 'NAME': os.path.join(OUT_PROJECT_ROOT, 'database.sqlite'), # Or path to database file if using sqlite3. + 'OPTIONS': {'timeout': 20} + } +} + +# CACHE CONFIG +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'unique-snowflake' + } +} + +PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.BCryptPasswordHasher', + 'django.contrib.auth.hashers.PBKDF2PasswordHasher', + 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', + 'django.contrib.auth.hashers.SHA1PasswordHasher', + 'django.contrib.auth.hashers.MD5PasswordHasher', + 'django.contrib.auth.hashers.CryptPasswordHasher', +] + +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTOCOL', 'https') +SEND_BROKEN_LINK_EMAILS = True +IGNORABLE_404_ENDS = ('.php', '.cgi') +IGNORABLE_404_STARTS = ('/phpmyadmin/',) + +TIME_ZONE = 'Europe/Madrid' +LANGUAGE_CODE = 'en' +USE_I18N = True +USE_L10N = True +LOGIN_URL='/auth/login/' +USE_TZ = True + +#SESSION BACKEND +#SESSION_ENGINE='django.contrib.sessions.backends.db' +SESSION_ENGINE='django.contrib.sessions.backends.cache' +#SESSION_EXPIRE_AT_BROWSER_CLOSE = False +#SESSION_SAVE_EVERY_REQUEST = True +SESSION_COOKIE_AGE = 1209600 # (2 weeks) + +HOST = 'http://localhost:8000' + +# MAIL OPTIONS +#EMAIL_USE_TLS = False +#EMAIL_HOST = 'localhost' +#EMAIL_HOST_USER = 'user' +#EMAIL_HOST_PASSWORD = 'password' +#EMAIL_PORT = 25 +DEFAULT_FROM_EMAIL = "niwi@niwi.be" +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +#EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' +#EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' + + +GREENQUEUE_BACKEND = 'greenqueue.backends.sync.SyncService' +GREENQUEUE_WORKER_MANAGER = 'greenqueue.worker.sync.SyncManager' + +GREENQUEUE_TASK_MODULES = [ + 'greenmine.core.mail.async_tasks', +] + + +SV_CSS_MENU_ACTIVE = 'selected' +SV_CONTEXT_VARNAME = 'menu' + +# Message System +#MESSAGE_STORAGE = 'django.contrib.messages.storage.cookie.CookieStorage' +MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage' + +# Absolute filesystem path to the directory that will hold user-uploaded files. +# Example: "/home/media/media.lawrence.com/media/" +MEDIA_ROOT = os.path.join(OUT_PROJECT_ROOT, 'media') + +# URL that handles the media served from MEDIA_ROOT. Make sure to use a +# trailing slash. +# Examples: "http://media.lawrence.com/media/", "http://example.com/media/" +MEDIA_URL = '/media/' + +# Absolute path to the directory static files should be collected to. +# Don't put anything in this directory yourself; store your static files +# in apps' "static/" subdirectories and in STATICFILES_DIRS. +# Example: "/home/media/media.lawrence.com/static/" +STATIC_ROOT = os.path.join(OUT_PROJECT_ROOT, 'static') + +# URL prefix for static files. +# Example: "http://media.lawrence.com/static/" +STATIC_URL = '/static/' + +# URL prefix for admin static files -- CSS, JavaScript and images. +# Make sure to use a trailing slash. +# Examples: "http://foo.com/static/admin/", "/static/admin/". +ADMIN_MEDIA_PREFIX = '/static/admin/' + + +# Additional locations of static files +STATICFILES_DIRS = ( + # Put strings here, like "/home/html/static" or "C:/www/django/static". + # Don't forget to use absolute paths, not relative paths. +) + +LOCALE_PATHS = ( + os.path.join(PROJECT_ROOT, 'locale'), +) + +STATICFILES_FINDERS = [ + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', +] + +SECRET_KEY = 'aw3+t2r(8(0kkrhg8)gx6i96v5^kv%6cfep9wxfom0%7dy0m9e' + +TEMPLATE_LOADERS = [ + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', +] + +MIDDLEWARE_CLASSES = [ + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.locale.LocaleMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'greenmine.core.middleware.PermissionMiddleware', + 'django.middleware.transaction.TransactionMiddleware', + 'reversion.middleware.RevisionMiddleware', +] + +TEMPLATE_CONTEXT_PROCESSORS = [ + "django.contrib.auth.context_processors.auth", + "django.core.context_processors.debug", + "django.core.context_processors.i18n", + "django.core.context_processors.media", + "django.core.context_processors.static", + "django.core.context_processors.tz", + "django.contrib.messages.context_processors.messages", + "greenmine.core.context.main", +] + +ROOT_URLCONF = 'greenmine.urls' + +TEMPLATE_DIRS = [ + os.path.join(PROJECT_ROOT, "templates"), +] + +INSTALLED_APPS = [ + # Django base applications + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + 'greenmine.base', + 'greenmine.profile', + 'greenmine.scrum', + 'greenmine.wiki', + 'greenmine.documents', + 'greenmine.taggit', + 'greenmine.questions', + 'greenmine.search', + + 'django_gravatar', + 'rawinclude', + 'greenqueue', + 'south', + 'superview', + 'haystack', + 'reversion', +] + +WSGI_APPLICATION = 'greenmine.wsgi.application' + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': True, + 'filters': { + 'require_debug_false': { + '()': 'django.utils.log.RequireDebugFalse' + } + }, + 'formatters': { + 'simple': { + 'format': '%(levelname)s:%(asctime)s:%(module)s %(message)s' + }, + 'null': { + 'format': '%(message)s', + }, + }, + 'handlers': { + 'null': { + 'level':'DEBUG', + 'class':'django.utils.log.NullHandler', + }, + 'fileout': { + 'level':'DEBUG', + 'class':'logging.FileHandler', + 'filename': os.path.join(LOGS_PATH, 'greenmine.log'), + 'formatter': 'simple', + }, + 'queryhandler': { + 'level':'DEBUG', + 'class':'logging.FileHandler', + 'filename': os.path.join(LOGS_PATH, 'greenmine-querys.log'), + }, + 'console':{ + 'level':'DEBUG', + 'class':'logging.StreamHandler', + 'formatter': 'null', + }, + 'mail_admins': { + 'level': 'ERROR', + 'filters': ['require_debug_false'], + 'class': 'django.utils.log.AdminEmailHandler', + } + }, + 'loggers': { + 'django': { + 'handlers':['null'], + 'propagate': True, + 'level':'INFO', + }, + 'django.request': { + 'handlers': ['mail_admins', 'console'], + 'level': 'ERROR', + 'propagate': False, + }, + 'django.db.backends':{ + 'handlers': ['queryhandler'], + 'level': 'DEBUG', + 'propagate': False, + }, + 'main': { + 'handlers': ['console'], + 'level': 'DEBUG', + 'propagate': False, + }, + 'asyncmail': { + 'handlers': ['console'], + 'level':'INFO', + 'propagate': False, + }, + 'greenqueue': { + 'handlers': ['console'], + 'level': 'DEBUG', + 'propagate': False, + }, + } +} + + +AUTH_PROFILE_MODULE = 'profile.Profile' +FORMAT_MODULE_PATH = 'greenmine.core.formats' +DATE_INPUT_FORMATS = ( + '%Y-%m-%d', '%m/%d/%Y', '%d/%m/%Y', '%b %d %Y', + '%b %d, %Y', '%d %b %Y', '%d %b, %Y', '%B %d %Y', + '%B %d, %Y', '%d %B %Y', '%d %B, %Y' +) + + +HAYSTACK_CONNECTIONS = { + 'default': { + 'ENGINE': 'haystack.backends.whoosh_backend.WhooshEngine', + 'PATH': os.path.join(os.path.dirname(__file__), '../search/index'), + }, +} + +HAYSTACK_DEFAULT_OPERATOR = 'AND' + + +from .appdefaults import * diff --git a/greenmine/settings/development.py b/greenmine/settings/development.py new file mode 100644 index 00000000..21753b37 --- /dev/null +++ b/greenmine/settings/development.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- + +from .common import * + +DEBUG = True +TEMPLATE_DEBUG = DEBUG +USE_ETAGS = False + +SESSION_ENGINE='django.contrib.sessions.backends.db' + +TEMPLATE_CONTEXT_PROCESSORS += [ + "django.core.context_processors.debug", +] diff --git a/greenmine/settings/local.py.example b/greenmine/settings/local.py.example new file mode 100644 index 00000000..6029de21 --- /dev/null +++ b/greenmine/settings/local.py.example @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from .development import * + +#DATABASES = { +# 'default': { +# 'ENGINE': 'django.db.backends.postgresql_psycopg2', +# 'NAME': 'greenmine', +# 'USER': 'greenmine', +# 'PASSWORD': '', +# 'HOST': '', +# 'PORT': '', +# } +#} +# +#HOST="http://greenmine.projects.kaleidos.net" +# +#MEDIA_ROOT = '/home/greenmine/media' +#STATIC_ROOT = '/home/greenmine/static' + +#EMAIL_USE_TLS = False +#EMAIL_HOST = 'localhost' +#EMAIL_HOST_USER = 'user' +#EMAIL_HOST_PASSWORD = 'password' +#EMAIL_PORT = 25 +#DEFAULT_FROM_EMAIL = "niwi@niwi.be" +#EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' + +# GMAIL SETTINGS EXAMPLE +#EMAIL_USE_TLS = True +#EMAIL_HOST = 'smtp.gmail.com' +#EMAIL_HOST_USER = 'youremail@gmail.com' +#EMAIL_HOST_PASSWORD = 'yourpassword' +#EMAIL_PORT = 587 diff --git a/greenmine/settings/production.py b/greenmine/settings/production.py new file mode 100644 index 00000000..de4182e6 --- /dev/null +++ b/greenmine/settings/production.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- + +from .common import * + +DEBUG = False +TEMPLATE_DEBUG = DEBUG +USE_ETAGS = True + +MIDDLEWARE_CLASSES += [ + 'django.middleware.http.ConditionalGetMiddleware', + 'django.middleware.gzip.GZipMiddleware', +] + +LOGGING['loggers']['django.db.backends']['handlers'] = ['null'] +LOGGING['loggers']['django.request']['handlers'] = ['mail_admins'] diff --git a/greenmine/settings/testing.py b/greenmine/settings/testing.py new file mode 100644 index 00000000..7329b7b8 --- /dev/null +++ b/greenmine/settings/testing.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- + +from .development import * + +GREENQUEUE_BACKEND = 'greenqueue.backends.sync.SyncService' +GREENQUEUE_WORKER_MANAGER = 'greenqueue.worker.sync.SyncManager' + +INSTALLED_APPS.append('greenmine.taggit.tests') diff --git a/greenmine/taggit/__init__.py b/greenmine/taggit/__init__.py new file mode 100644 index 00000000..49d36f50 --- /dev/null +++ b/greenmine/taggit/__init__.py @@ -0,0 +1 @@ +VERSION = (0, 9, 3) diff --git a/greenmine/taggit/admin.py b/greenmine/taggit/admin.py new file mode 100644 index 00000000..45d0bfd2 --- /dev/null +++ b/greenmine/taggit/admin.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +from django.contrib import admin + +from .models import Tag, TaggedItem + + +class TaggedItemInline(admin.StackedInline): + model = TaggedItem + +class TagAdmin(admin.ModelAdmin): + list_display = ["name"] + inlines = [ + TaggedItemInline + ] + + +admin.site.register(Tag, TagAdmin) diff --git a/greenmine/taggit/locale/de/LC_MESSAGES/django.po b/greenmine/taggit/locale/de/LC_MESSAGES/django.po new file mode 100644 index 00000000..98ecdac5 --- /dev/null +++ b/greenmine/taggit/locale/de/LC_MESSAGES/django.po @@ -0,0 +1,67 @@ +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: django-taggit\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2010-09-07 09:26-0700\n" +"PO-Revision-Date: 2010-09-07 09:26-0700\n" +"Last-Translator: Jannis Leidel \n" +"Language-Team: German \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" + +#: forms.py:20 +msgid "Please provide a comma-separated list of tags." +msgstr "Bitte eine durch Komma getrennte Schlagwortliste eingeben." + +#: managers.py:39 managers.py:83 models.py:50 +msgid "Tags" +msgstr "Schlagwörter" + +#: managers.py:84 +msgid "A comma-separated list of tags." +msgstr "Eine durch Komma getrennte Schlagwortliste." + +#: models.py:10 +msgid "Name" +msgstr "Name" + +#: models.py:11 +msgid "Slug" +msgstr "Kürzel" + +#: models.py:49 +msgid "Tag" +msgstr "Schlagwort" + +#: models.py:56 +#, python-format +msgid "%(object)s tagged with %(tag)s" +msgstr "%(object)s verschlagwortet mit %(tag)s" + +#: models.py:100 +msgid "Object id" +msgstr "Objekt-ID" + +#: models.py:104 models.py:110 +msgid "Content type" +msgstr "Inhaltstyp" + +#: models.py:138 +msgid "Tagged Item" +msgstr "Verschlagwortetes Objekt" + +#: models.py:139 +msgid "Tagged Items" +msgstr "Verschlagwortete Objekte" + +#: contrib/suggest/models.py:57 +msgid "" +"Enter a valid Regular Expression. To make it case-insensitive include \"(?i)" +"\" in your expression." +msgstr "" +"Bitte einen regulären Ausdruck eingeben. Fügen Sie \"(?i) \" dem " +"Ausdruck hinzu, um nicht zwischen Groß- und Kleinschreibung zu " +"unterscheiden." diff --git a/greenmine/taggit/locale/en/LC_MESSAGES/django.po b/greenmine/taggit/locale/en/LC_MESSAGES/django.po new file mode 100644 index 00000000..c5642c7d --- /dev/null +++ b/greenmine/taggit/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,68 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2010-09-07 09:45-0700\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: forms.py:20 +msgid "Please provide a comma-separated list of tags." +msgstr "" + +#: managers.py:39 managers.py:83 models.py:50 +msgid "Tags" +msgstr "" + +#: managers.py:84 +msgid "A comma-separated list of tags." +msgstr "" + +#: models.py:10 +msgid "Name" +msgstr "" + +#: models.py:11 +msgid "Slug" +msgstr "" + +#: models.py:49 +msgid "Tag" +msgstr "" + +#: models.py:56 +#, python-format +msgid "%(object)s tagged with %(tag)s" +msgstr "" + +#: models.py:100 +msgid "Object id" +msgstr "" + +#: models.py:104 models.py:110 +msgid "Content type" +msgstr "" + +#: models.py:138 +msgid "Tagged Item" +msgstr "" + +#: models.py:139 +msgid "Tagged Items" +msgstr "" + +#: contrib/suggest/models.py:57 +msgid "" +"Enter a valid Regular Expression. To make it case-insensitive include \"(?i)" +"\" in your expression." +msgstr "" diff --git a/greenmine/taggit/locale/he/LC_MESSAGES/django.po b/greenmine/taggit/locale/he/LC_MESSAGES/django.po new file mode 100644 index 00000000..e27a878f --- /dev/null +++ b/greenmine/taggit/locale/he/LC_MESSAGES/django.po @@ -0,0 +1,69 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: Django Taggit\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2010-06-26 12:47-0500\n" +"PO-Revision-Date: 2010-06-26 12:54-0600\n" +"Last-Translator: Alex \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: forms.py:20 +msgid "Please provide a comma-separated list of tags." +msgstr "נא לספק רשימה של תגים מופרדת עם פסיקים." + +#: managers.py:41 +#: managers.py:113 +#: models.py:18 +msgid "Tags" +msgstr "תגיות" + +#: managers.py:114 +msgid "A comma-separated list of tags." +msgstr "רשימה של תגים מופרדת עם פסיקים." + +#: models.py:10 +msgid "Name" +msgstr "שם" + +#: models.py:11 +msgid "Slug" +msgstr "" + +#: models.py:17 +msgid "Tag" +msgstr "תג" + +#: models.py:56 +#, python-format +msgid "%(object)s tagged with %(tag)s" +msgstr "%(object)s מתויג עם %(tag)s" + +#: models.py:86 +msgid "Object id" +msgstr "" + +#: models.py:87 +msgid "Content type" +msgstr "" + +#: models.py:92 +msgid "Tagged Item" +msgstr "" + +#: models.py:93 +msgid "Tagged Items" +msgstr "" + +#: contrib/suggest/models.py:57 +msgid "Enter a valid Regular Expression. To make it case-insensitive include \"(?i)\" in your expression." +msgstr "" + diff --git a/greenmine/taggit/locale/nl/LC_MESSAGES/django.po b/greenmine/taggit/locale/nl/LC_MESSAGES/django.po new file mode 100644 index 00000000..7871b0bd --- /dev/null +++ b/greenmine/taggit/locale/nl/LC_MESSAGES/django.po @@ -0,0 +1,64 @@ +msgid "" +msgstr "" +"Project-Id-Version: django-taggit\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2010-09-07 09:45-0700\n" +"PO-Revision-Date: 2010-09-07 23:04+0100\n" +"Last-Translator: Jeffrey Gelens \n" +"Language-Team: Dutch\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: forms.py:20 +msgid "Please provide a comma-separated list of tags." +msgstr "Geef een door komma gescheiden lijst van tags." + +#: managers.py:39 +#: managers.py:83 +#: models.py:50 +msgid "Tags" +msgstr "Tags" + +#: managers.py:84 +msgid "A comma-separated list of tags." +msgstr "Een door komma gescheiden lijst van tags." + +#: models.py:10 +msgid "Name" +msgstr "Naam" + +#: models.py:11 +msgid "Slug" +msgstr "Slug" + +#: models.py:49 +msgid "Tag" +msgstr "Tag" + +#: models.py:56 +#, python-format +msgid "%(object)s tagged with %(tag)s" +msgstr "%(object)s getagged met %(tag)s" + +#: models.py:100 +msgid "Object id" +msgstr "Object-id" + +#: models.py:104 +#: models.py:110 +msgid "Content type" +msgstr "Inhoudstype" + +#: models.py:138 +msgid "Tagged Item" +msgstr "Object getagged" + +#: models.py:139 +msgid "Tagged Items" +msgstr "Objecten getagged" + +#: contrib/suggest/models.py:57 +msgid "Enter a valid Regular Expression. To make it case-insensitive include \"(?i)\" in your expression." +msgstr "Voer een valide reguliere expressie in. Voeg \"(?i)\" aan de expressie toe om deze hoofdletter ongevoelig te maken." + diff --git a/greenmine/taggit/locale/ru/LC_MESSAGES/django.po b/greenmine/taggit/locale/ru/LC_MESSAGES/django.po new file mode 100644 index 00000000..42e3ebe7 --- /dev/null +++ b/greenmine/taggit/locale/ru/LC_MESSAGES/django.po @@ -0,0 +1,70 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: Django Taggit\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2010-06-11 11:28+0700\n" +"PO-Revision-Date: 2010-06-11 11:30+0700\n" +"Last-Translator: Igor 'idle sign' Starikov \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n" +"X-Poedit-Language: Russian\n" + +#: forms.py:20 +msgid "Please provide a comma-separated list of tags." +msgstr "Укажите метки через запятую." + +#: managers.py:41 +#: managers.py:101 +#: models.py:17 +msgid "Tags" +msgstr "Метки" + +#: managers.py:102 +msgid "A comma-separated list of tags." +msgstr "Список меток через запятую." + +#: models.py:9 +msgid "Name" +msgstr "Название" + +#: models.py:10 +msgid "Slug" +msgstr "Слаг" + +#: models.py:16 +msgid "Tag" +msgstr "Метка" + +#: models.py:55 +#, python-format +msgid "%(object)s tagged with %(tag)s" +msgstr "элемент «%(object)s» с меткой «%(tag)s»" + +#: models.py:82 +msgid "Object id" +msgstr "ID объекта" + +#: models.py:83 +msgid "Content type" +msgstr "Тип содержимого" + +#: models.py:87 +msgid "Tagged Item" +msgstr "Элемент с меткой" + +#: models.py:88 +msgid "Tagged Items" +msgstr "Элементы с меткой" + +#: contrib/suggest/models.py:57 +msgid "Enter a valid Regular Expression. To make it case-insensitive include \"(?i)\" in your expression." +msgstr "Введите регулярное выражение. Чтобы сделать его чувствительным к регистру укажите \"(?i)\"." + diff --git a/greenmine/taggit/managers.py b/greenmine/taggit/managers.py new file mode 100644 index 00000000..f1d2aff6 --- /dev/null +++ b/greenmine/taggit/managers.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +from django.contrib.contenttypes.generic import GenericRelation +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.db.models.fields.related import ManyToManyRel, RelatedField, add_lazy_relation +from django.db.models.related import RelatedObject +from django.utils.text import capfirst +from django.utils.translation import ugettext_lazy as _ + +from .forms import TagField +from .models import TaggedItem, GenericTaggedItemBase, Tag +from .utils import require_instance_manager + + +class TaggableRel(ManyToManyRel): + def __init__(self): + self.related_name = None + self.limit_choices_to = {} + self.symmetrical = True + self.multiple = True + self.through = None + + +class TaggableManager(RelatedField): + def __init__(self, verbose_name=_("Tags"), + help_text=_("A comma-separated list of tags."), through=None, blank=False): + self.through = through or TaggedItem + self.rel = TaggableRel() + self.verbose_name = verbose_name + self.help_text = help_text + self.blank = blank + self.editable = True + self.unique = False + self.creates_table = False + self.db_column = None + self.choices = None + self.serialize = False + self.null = True + self.creation_counter = models.Field.creation_counter + models.Field.creation_counter += 1 + + def __get__(self, instance, model): + if instance is not None and instance.pk is None: + raise ValueError("%s objects need to have a primary key value " + "before you can access their tags." % model.__name__) + manager = _TaggableManager( + through=self.through, model=model, instance=instance + ) + return manager + + def contribute_to_class(self, cls, name): + self.name = self.column = name + self.model = cls + cls._meta.add_field(self) + setattr(cls, name, self) + if not cls._meta.abstract: + if isinstance(self.through, basestring): + def resolve_related_class(field, model, cls): + self.through = model + self.post_through_setup(cls) + add_lazy_relation( + cls, self, self.through, resolve_related_class + ) + else: + self.post_through_setup(cls) + + def post_through_setup(self, cls): + self.use_gfk = ( + self.through is None or issubclass(self.through, GenericTaggedItemBase) + ) + self.rel.to = self.through._meta.get_field("tag").rel.to + if self.use_gfk: + tagged_items = GenericRelation(self.through) + tagged_items.contribute_to_class(cls, "tagged_items") + + def save_form_data(self, instance, value): + getattr(instance, self.name).set(*value) + + def formfield(self, form_class=TagField, **kwargs): + defaults = { + "label": capfirst(self.verbose_name), + "help_text": self.help_text, + "required": not self.blank + } + defaults.update(kwargs) + return form_class(**defaults) + + def value_from_object(self, instance): + if instance.pk: + return self.through.objects.filter(**self.through.lookup_kwargs(instance)) + return self.through.objects.none() + + def related_query_name(self): + return self.model._meta.module_name + + def m2m_reverse_name(self): + return self.through._meta.get_field_by_name("tag")[0].column + + def m2m_target_field_name(self): + return self.model._meta.pk.name + + def m2m_reverse_target_field_name(self): + return self.rel.to._meta.pk.name + + def m2m_column_name(self): + if self.use_gfk: + return self.through._meta.virtual_fields[0].fk_field + return self.through._meta.get_field('content_object').column + + def db_type(self, connection=None): + return None + + def m2m_db_table(self): + return self.through._meta.db_table + + def extra_filters(self, pieces, pos, negate): + if negate or not self.use_gfk: + return [] + prefix = "__".join(["tagged_items"] + pieces[:pos-2]) + cts = map(ContentType.objects.get_for_model, _get_subclasses(self.model)) + if len(cts) == 1: + return [("%s__content_type" % prefix, cts[0])] + return [("%s__content_type__in" % prefix, cts)] + + def bulk_related_objects(self, new_objs, using): + return [] + + +class _TaggableManager(models.Manager): + def __init__(self, through, model, instance): + self.through = through + self.model = model + self.instance = instance + + def get_query_set(self): + return self.through.tags_for(self.model, self.instance) + + def _lookup_kwargs(self): + return self.through.lookup_kwargs(self.instance) + + @require_instance_manager + def add(self, *tags): + str_tags = set([ + t + for t in tags + if not isinstance(t, self.through.tag_model()) + ]) + tag_objs = set(tags) - str_tags + # If str_tags has 0 elements Django actually optimizes that to not do a + # query. Malcolm is very smart. + existing = self.through.tag_model().objects.filter( + name__in=str_tags + ) + tag_objs.update(existing) + + for new_tag in str_tags - set(t.name for t in existing): + tag_objs.add(self.through.tag_model().objects.create(name=new_tag)) + + for tag in tag_objs: + self.through.objects.get_or_create(tag=tag, **self._lookup_kwargs()) + + @require_instance_manager + def set(self, *tags): + self.clear() + self.add(*tags) + + @require_instance_manager + def remove(self, *tags): + self.through.objects.filter(**self._lookup_kwargs()).filter( + tag__name__in=tags).delete() + + @require_instance_manager + def clear(self): + self.through.objects.filter(**self._lookup_kwargs()).delete() + + def most_common(self): + return self.get_query_set().annotate( + num_times=models.Count(self.through.tag_relname()) + ).order_by('-num_times') + + @require_instance_manager + def similar_objects(self): + lookup_kwargs = self._lookup_kwargs() + lookup_keys = sorted(lookup_kwargs) + qs = self.through.objects.values(*lookup_kwargs.keys()) + qs = qs.annotate(n=models.Count('pk')) + qs = qs.exclude(**lookup_kwargs) + qs = qs.filter(tag__in=self.all()) + qs = qs.order_by('-n') + + # TODO: This all feels like a bit of a hack. + items = {} + if len(lookup_keys) == 1: + # Can we do this without a second query by using a select_related() + # somehow? + f = self.through._meta.get_field_by_name(lookup_keys[0])[0] + objs = f.rel.to._default_manager.filter(**{ + "%s__in" % f.rel.field_name: [r["content_object"] for r in qs] + }) + for obj in objs: + items[(getattr(obj, f.rel.field_name),)] = obj + else: + preload = {} + for result in qs: + preload.setdefault(result['content_type'], set()) + preload[result["content_type"]].add(result["object_id"]) + + for ct, obj_ids in preload.iteritems(): + ct = ContentType.objects.get_for_id(ct) + for obj in ct.model_class()._default_manager.filter(pk__in=obj_ids): + items[(ct.pk, obj.pk)] = obj + + results = [] + for result in qs: + obj = items[ + tuple(result[k] for k in lookup_keys) + ] + obj.similar_tags = result["n"] + results.append(obj) + return results + + +def _get_subclasses(model): + subclasses = [model] + for f in model._meta.get_all_field_names(): + field = model._meta.get_field_by_name(f)[0] + if (isinstance(field, RelatedObject) and + getattr(field.field.rel, "parent_link", None)): + subclasses.extend(_get_subclasses(field.model)) + return subclasses diff --git a/greenmine/taggit/models.py b/greenmine/taggit/models.py new file mode 100644 index 00000000..e5e5d65e --- /dev/null +++ b/greenmine/taggit/models.py @@ -0,0 +1,250 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +import django +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.generic import GenericForeignKey +from django.db import connection, models, IntegrityError, transaction +from django.db.models.query import QuerySet +from django.template.defaultfilters import slugify as default_slugify +from django.utils.translation import ugettext_lazy as _, ugettext +qn = connection.ops.quote_name + +class TagBase(models.Model): + name = models.CharField(verbose_name=_('Name'), max_length=100) + slug = models.SlugField(verbose_name=_('Slug'), unique=True, max_length=100) + + def __unicode__(self): + return self.name + + class Meta: + abstract = True + + def save(self, *args, **kwargs): + if not self.pk and not self.slug: + self.slug = self.slugify(self.name) + if django.VERSION >= (1, 2): + from django.db import router + using = kwargs.get("using") or router.db_for_write( + type(self), instance=self) + # Make sure we write to the same db for all attempted writes, + # with a multi-master setup, theoretically we could try to + # write and rollback on different DBs + kwargs["using"] = using + trans_kwargs = {"using": using} + else: + trans_kwargs = {} + i = 0 + while True: + i += 1 + try: + sid = transaction.savepoint(**trans_kwargs) + res = super(TagBase, self).save(*args, **kwargs) + transaction.savepoint_commit(sid, **trans_kwargs) + return res + except IntegrityError: + transaction.savepoint_rollback(sid, **trans_kwargs) + self.slug = self.slugify(self.name, i) + else: + return super(TagBase, self).save(*args, **kwargs) + + def slugify(self, tag, i=None): + slug = default_slugify(tag) + if i is not None: + slug += "_%d" % i + return slug + + +class TagManager(models.Manager): + def tags_for_queryset(self, queryset, counts=True, min_count=None): + """ + Obtain a list of tags associated with instances of a model + contained in the given queryset. + + If ``counts`` is True, a ``count`` attribute will be added to + each tag, indicating how many times it has been used against + the Model class in question. + + If ``min_count`` is given, only tags which have a ``count`` + greater than or equal to ``min_count`` will be returned. + Passing a value for ``min_count`` implies ``counts=True``. + """ + + compiler = queryset.query.get_compiler(using='default') + extra_joins = ' '.join(compiler.get_from_clause()[0][1:]) + where, params = queryset.query.where.as_sql( + compiler.quote_name_unless_alias, compiler.connection + ) + + if where: + extra_criteria = 'AND %s' % where + else: + extra_criteria = '' + return self._get_usage(queryset.model, counts, min_count, extra_joins, extra_criteria, params) + + + + def _get_usage(self, model, counts=False, min_count=None, extra_joins=None, extra_criteria=None, params=None): + """ + Perform the custom SQL query for ``usage_for_model`` and + ``usage_for_queryset``. + """ + if min_count is not None: counts = True + + model_table = qn(model._meta.db_table) + model_pk = '%s.%s' % (model_table, qn(model._meta.pk.column)) + + query = """ + SELECT DISTINCT %(tag)s.id, %(tag)s.name%(count_sql)s + FROM + %(tag)s + INNER JOIN %(tagged_item_alias)s + ON %(tag)s.id = %(tagged_item)s.tag_id + INNER JOIN %(model)s + ON %(tagged_item)s.object_id = %(model_pk)s + %%s + WHERE %(tagged_item)s.content_type_id = %(content_type_id)s + %%s + GROUP BY %(tag)s.id, %(tag)s.name + %%s + ORDER BY %(tag)s.name ASC""" % { + 'tag': qn(Tag._meta.db_table), + 'count_sql': counts and (', COUNT(%s)' % model_pk) or '', + 'tagged_item_alias': qn(TaggedItem._meta.db_table) + " tagged_item_alias", + 'tagged_item': "tagged_item_alias", + 'model': model_table, + 'model_pk': model_pk, + 'content_type_id': ContentType.objects.get_for_model(model).pk, + } + + min_count_sql = '' + if min_count is not None: + min_count_sql = 'HAVING COUNT(%s) >= %%s' % model_pk + params.append(min_count) + + cursor = connection.cursor() + + cursor.execute(query % (extra_joins, extra_criteria, min_count_sql), params) + tags = [] + for row in cursor.fetchall(): + t = Tag(*row[:2]) + if counts: + t.count = row[2] + tags.append(t) + + return tags + + +class Tag(TagBase): + objects = TagManager() + + class Meta: + verbose_name = _("Tag") + verbose_name_plural = _("Tags") + + def to_dict(self): + self_dict = { + 'id': self.pk, + 'name': self.name, + } + + return self_dict + + +class ItemBase(models.Model): + def __unicode__(self): + return ugettext("%(object)s tagged with %(tag)s") % { + "object": self.content_object, + "tag": self.tag + } + + class Meta: + abstract = True + + @classmethod + def tag_model(cls): + return cls._meta.get_field_by_name("tag")[0].rel.to + + @classmethod + def tag_relname(cls): + return cls._meta.get_field_by_name('tag')[0].rel.related_name + + @classmethod + def lookup_kwargs(cls, instance): + return { + 'content_object': instance + } + + @classmethod + def bulk_lookup_kwargs(cls, instances): + return { + "content_object__in": instances, + } + + +class TaggedItemBase(ItemBase): + tag = models.ForeignKey(Tag, related_name="%(app_label)s_%(class)s_items") + + class Meta: + abstract = True + + @classmethod + def tags_for(cls, model, instance=None): + if instance is not None: + return cls.tag_model().objects.filter(**{ + '%s__content_object' % cls.tag_relname(): instance + }) + return cls.tag_model().objects.filter(**{ + '%s__content_object__isnull' % cls.tag_relname(): False + }).distinct() + + +class GenericTaggedItemBase(ItemBase): + object_id = models.IntegerField(verbose_name=_('Object id'), db_index=True) + if django.VERSION < (1, 2): + content_type = models.ForeignKey( + ContentType, + verbose_name=_('Content type'), + related_name="%(class)s_tagged_items" + ) + else: + content_type = models.ForeignKey( + ContentType, + verbose_name=_('Content type'), + related_name="%(app_label)s_%(class)s_tagged_items" + ) + content_object = GenericForeignKey() + + class Meta: + abstract=True + + @classmethod + def lookup_kwargs(cls, instance): + return { + 'object_id': instance.pk, + 'content_type': ContentType.objects.get_for_model(instance) + } + + @classmethod + def bulk_lookup_kwargs(cls, instances): + # TODO: instances[0], can we assume there are instances. + return { + "object_id__in": [instance.pk for instance in instances], + "content_type": ContentType.objects.get_for_model(instances[0]), + } + + @classmethod + def tags_for(cls, model, instance=None): + ct = ContentType.objects.get_for_model(model) + kwargs = { + "%s__content_type" % cls.tag_relname(): ct + } + if instance is not None: + kwargs["%s__object_id" % cls.tag_relname()] = instance.pk + return cls.tag_model().objects.filter(**kwargs).distinct() + + +class TaggedItem(GenericTaggedItemBase, TaggedItemBase): + class Meta: + verbose_name = _("Tagged Item") + verbose_name_plural = _("Tagged Items") diff --git a/greenmine/taggit/utils.py b/greenmine/taggit/utils.py new file mode 100644 index 00000000..0b0813a2 --- /dev/null +++ b/greenmine/taggit/utils.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +from django.utils.encoding import force_unicode +from django.utils.functional import wraps +from django.db.models import Count + +from .models import TaggedItem + + +def get_tags_for_queryset(queryset, tags_attribute='tags'): + """ + Given a queryset and a taggint atributte returns a list with the form + [{'count': number_of_tagged_items, 'tags_attribute': 'id_of the tag'}] + [{'count': 3, 'tags': 1}, {'count': 2, 'tags': 2}, {'count': 1, 'tags': 3}] + """ + return queryset.values(tags_attribute).annotate(count=Count(tags_attribute)).order_by('-count') + + +def parse_tags(tagstring): + """ + Parses tag input, with multiple word input being activated and + delineated by commas and double quotes. Quotes take precedence, so + they may contain commas. + + Returns a sorted list of unique tag names. + + Ported from Jonathan Buchanan's `django-tagging + `_ + """ + if not tagstring: + return [] + + tagstring = force_unicode(tagstring) + + # Special case - if there are no commas or double quotes in the + # input, we don't *do* a recall... I mean, we know we only need to + # split on spaces. + if u',' not in tagstring and u'"' not in tagstring: + words = list(set(split_strip(tagstring, u' '))) + words.sort() + return words + + words = [] + buffer = [] + # Defer splitting of non-quoted sections until we know if there are + # any unquoted commas. + to_be_split = [] + saw_loose_comma = False + open_quote = False + i = iter(tagstring) + try: + while True: + c = i.next() + if c == u'"': + if buffer: + to_be_split.append(u''.join(buffer)) + buffer = [] + # Find the matching quote + open_quote = True + c = i.next() + while c != u'"': + buffer.append(c) + c = i.next() + if buffer: + word = u''.join(buffer).strip() + if word: + words.append(word) + buffer = [] + open_quote = False + else: + if not saw_loose_comma and c == u',': + saw_loose_comma = True + buffer.append(c) + except StopIteration: + # If we were parsing an open quote which was never closed treat + # the buffer as unquoted. + if buffer: + if open_quote and u',' in buffer: + saw_loose_comma = True + to_be_split.append(u''.join(buffer)) + if to_be_split: + if saw_loose_comma: + delimiter = u',' + else: + delimiter = u' ' + for chunk in to_be_split: + words.extend(split_strip(chunk, delimiter)) + words = list(set(words)) + words.sort() + return words + + +def split_strip(string, delimiter=u','): + """ + Splits ``string`` on ``delimiter``, stripping each resulting string + and returning a list of non-empty strings. + + Ported from Jonathan Buchanan's `django-tagging + `_ + """ + if not string: + return [] + + words = [w.strip() for w in string.split(delimiter)] + return [w for w in words if w] + + +def edit_string_for_tags(tags): + """ + Given list of ``Tag`` instances, creates a string representation of + the list suitable for editing by the user, such that submitting the + given string representation back without changing it will give the + same list of tags. + + Tag names which contain commas will be double quoted. + + If any tag name which isn't being quoted contains whitespace, the + resulting string of tag names will be comma-delimited, otherwise + it will be space-delimited. + + Ported from Jonathan Buchanan's `django-tagging + `_ + """ + names = [] + for tag in tags: + name = tag.name + if u',' in name or u' ' in name: + names.append('"%s"' % name) + else: + names.append(name) + return u', '.join(sorted(names)) + + +def require_instance_manager(func): + @wraps(func) + def inner(self, *args, **kwargs): + if self.instance is None: + raise TypeError("Can't call %s with a non-instance manager" % func.__name__) + return func(self, *args, **kwargs) + return inner diff --git a/greenmine/urls.py b/greenmine/urls.py new file mode 100644 index 00000000..43f621d4 --- /dev/null +++ b/greenmine/urls.py @@ -0,0 +1,12 @@ +from django.conf.urls import patterns, include, url + +from django.contrib import admin +admin.autodiscover() + +urlpatterns = patterns('', + # Examples: + # url(r'^$', 'greenmine.views.home', name='home'), + # url(r'^greenmine/', include('greenmine.foo.urls')), + + url(r'^admin/', include(admin.site.urls)), +) diff --git a/greenmine/wiki/__init__.py b/greenmine/wiki/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/greenmine/wiki/models.py b/greenmine/wiki/models.py new file mode 100644 index 00000000..be95efa2 --- /dev/null +++ b/greenmine/wiki/models.py @@ -0,0 +1,62 @@ +from django.db import models +from .fields import WikiField + +class WikiPage(models.Model): + project = models.ForeignKey('scrum.Project', related_name='wiki_pages') + slug = models.SlugField(max_length=500, db_index=True) + content = WikiField(blank=False, null=True) + owner = models.ForeignKey("auth.User", related_name="wiki_pages", null=True) + + watchers = models.ManyToManyField('auth.User', + related_name='wikipage_watchers', null=True) + + created_date = models.DateTimeField(auto_now_add=True) + + @models.permalink + def get_absolute_url(self): + return ('wiki-page', (), + {'pslug': self.project.slug, 'wslug': self.slug}) + + @models.permalink + def get_view_url(self): + return ('wiki-page', (), + {'pslug': self.project.slug, 'wslug': self.slug}) + + @models.permalink + def get_edit_url(self): + return ('wiki-page-edit', (), + {'pslug': self.project.slug, 'wslug': self.slug}) + + @models.permalink + def get_delete_url(self): + return ('wiki-page-delete', (), + {'pslug': self.project.slug, 'wslug': self.slug}) + + @models.permalink + def get_history_view_url(self): + return ('wiki-page-history', (), + {'pslug': self.project.slug, 'wslug': self.slug}) + + + +class WikiPageHistory(models.Model): + wikipage = models.ForeignKey("WikiPage", related_name="history_entries") + content = WikiField(blank=True, null=True) + created_date = models.DateTimeField() + owner = models.ForeignKey("auth.User", related_name="wiki_page_historys") + + # TODO: fix this permalink. this implementation is bad for performance. + + @models.permalink + def get_history_view_url(self): + return ('wiki-page-history-view', (), + {'pslug': self.wikipage.project.slug, 'wslug': self.wikipage.slug, 'hpk': self.pk}) + + +class WikiPageAttachment(models.Model): + wikipage = models.ForeignKey('WikiPage', related_name='attachments') + owner = models.ForeignKey("auth.User", related_name="wikifiles") + created_date = models.DateTimeField(auto_now_add=True) + modified_date = models.DateTimeField(auto_now_add=True) + attached_file = models.FileField(upload_to="files/wiki", + max_length=500, null=True, blank=True) diff --git a/greenmine/wiki/search_indexes.py b/greenmine/wiki/search_indexes.py new file mode 100644 index 00000000..bf1063e2 --- /dev/null +++ b/greenmine/wiki/search_indexes.py @@ -0,0 +1,13 @@ +# -* coding: utf-8 -*- +from haystack import indexes +from .models import WikiPage + + +class WikiPageIndex(indexes.RealTimeSearchIndex, indexes.Indexable): + text = indexes.CharField(document=True, use_template=True, template_name='search/indexes/wikipage_text.txt') + + def get_model(self): + return WikiPage + + def index_queryset(self): + return self.get_model().objects.all() diff --git a/greenmine/wiki/templates/search/indexes/wikipage_text.txt b/greenmine/wiki/templates/search/indexes/wikipage_text.txt new file mode 100644 index 00000000..9f060bd5 --- /dev/null +++ b/greenmine/wiki/templates/search/indexes/wikipage_text.txt @@ -0,0 +1,8 @@ +{{ object.project }} +{{ object.slug }} +{{ object.content }} +{{ object.owner }} +{{ object.created_date }} +{% for watcher in object.watchers.all %} + {{ watcher }} +{% endfor %} diff --git a/greenmine/wsgi.py b/greenmine/wsgi.py new file mode 100644 index 00000000..15644300 --- /dev/null +++ b/greenmine/wsgi.py @@ -0,0 +1,32 @@ +""" +WSGI config for greenmine project. + +This module contains the WSGI application used by Django's development server +and any production WSGI deployments. It should expose a module-level variable +named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover +this application via the ``WSGI_APPLICATION`` setting. + +Usually you will have the standard Django WSGI application here, but it also +might make sense to replace the whole Django WSGI application with a custom one +that later delegates to the Django one. For example, you could introduce WSGI +middleware here, or combine a Django application with an application of another +framework. + +""" +import os + +# We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks +# if running multiple sites in the same mod_wsgi process. To fix this, use +# mod_wsgi daemon mode with each site in its own daemon process, or use +# os.environ["DJANGO_SETTINGS_MODULE"] = "greenmine.settings" +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "greenmine.settings") + +# This application object is used by any WSGI server configured to use this +# file. This includes Django's development server, if the WSGI_APPLICATION +# setting points here. +from django.core.wsgi import get_wsgi_application +application = get_wsgi_application() + +# Apply WSGI middleware here. +# from helloworld.wsgi import HelloWorldApplication +# application = HelloWorldApplication(application) diff --git a/manage.py b/manage.py new file mode 100644 index 00000000..dfd6c3c6 --- /dev/null +++ b/manage.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "greenmine.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..a2cfee46 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +django +django-grappelli +django-tastypie