Initial commit

This commit is contained in:
Marc 2014-05-08 16:59:35 +00:00
commit dddb11bf40
337 changed files with 81969 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
*.log
*.pot
*.pyc
*~
.svn
local_settings.py

83
INSTALL.md Normal file
View file

@ -0,0 +1,83 @@
Installation
============
Django-orchestra ships with a set of management commands for automating some of the installation steps.
These commands are meant to be run within a **clean** Debian-like distribution, you should be specially careful while following this guide on a customized system.
Django-orchestra can be installed on any Linux system, however it is **strongly recommended** to chose the reference platform for your deployment (Debian 7.0 wheezy and Python 2.7).
1. Create a system user for running Orchestra
```bash
adduser orchestra
# not required but it will be very handy
sudo adduser orchestra sudo
su - orchestra
```
2. Install django-orchestra's source code
```bash
sudo apt-get install python-pip
sudo pip install django-orchestra # ==dev if you want the in-devel version
```
3. Install requirements
```bash
sudo orchestra-admin install_requirements
```
4. Create a new project
```bash
cd ~orchestra
orchestra-admin startproject <project_name> # e.g. panel
cd <project_name>
```
5. Create and configure a Postgres database
```bash
sudo python manage.py setuppostgres
python manage.py syncdb
python manage.py migrate
```
6. Create a panel administrator
```bash
python manage.py createsuperuser
```
7. Configure celeryd
```bash
sudo python manage.py setupcelery --username orchestra
```
8. Configure the web server:
```bash
python manage.py collectstatic --noinput
sudo apt-get install nginx-full uwsgi uwsgi-plugin-python
sudo python manage.py setupnginx
```
9. Start all services:
```bash
sudo python manage.py startservices
```
Upgrade
=======
To upgrade your Orchestra installation to the last release you can use `upgradeorchestra` management command. Before rolling the upgrade it is strongly recommended to check the [release notes](http://django-orchestra.readthedocs.org/en/latest/).
```bash
sudo python manage.py upgradeorchestra
```
Current in *development* version (master branch) can be installed by
```bash
sudo python manage.py upgradeorchestra dev
```
Additionally the following command can be used in order to determine the currently installed version:
```bash
python manage.py orchestraversion
```

14
LICENSE Normal file
View file

@ -0,0 +1,14 @@
Copyright (C) 2013 Marc Aymerich
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.

8
MANIFEST.in Normal file
View file

@ -0,0 +1,8 @@
recursive-include orchestra *
recursive-exclude * __pycache__
recursive-exclude * *.py[co]
recursive-exclude * *~
recursive-exclude * *.save
recursive-exclude * *.svg

94
README.md Normal file
View file

@ -0,0 +1,94 @@
![](orchestra/static/orchestra/icons/Emblem-important.png) **This project is in early development stage**
Django Orchestra
================
Orchestra is a Django-based framework for building web hosting control panels.
* [Documentation](http://django-orchestra.readthedocs.org/)
* [Install and upgrade](INSTALL.md)
* [Roadmap](ROADMAP.md)
Motivation
----------
There are a lot of widely used open source hosting control panels, however, none of them seems apropiate when you already have an existing service infrastructure or simply you want your services to run on a particular architecture.
The goal of this project is to provide the tools for easily build a fully featured control panel that is not tied to any particular service architecture.
Overview
--------
Django-orchestra is mostly a bunch of [plugable applications](orchestra/apps) providing common functionalities, like service management, resource monitoring or billing.
The admin interface relies on [Django Admin](https://docs.djangoproject.com/en/dev/ref/contrib/admin/), but enhaced with [Django Admin Tools](https://bitbucket.org/izi/django-admin-tools) and [Django Fluent Dashboard](https://github.com/edoburu/django-fluent-dashboard). [Django REST Framework](http://www.django-rest-framework.org/) is used for the REST API, with it you can build your client-side custom user interface.
Every app is [reusable](https://docs.djangoproject.com/en/dev/intro/reusable-apps/), this means that you can add any Orchestra application into your Django project `INSTALLED_APPS` strigh away.
However, Orchestra also provides glue, tools and patterns that you may find very convinient to use. Checkout the [documentation](http://django-orchestra.readthedocs.org/) if you want to know more.
Development and Testing Setup
-----------------------------
If you are planing to do some development or perhaps just checking out this project, you may want to consider doing it under the following setup
1. Create a basic [LXC](http://linuxcontainers.org/) container, start it and get inside.
```bash
wget -O /tmp/create.sh \
https://raw2.github.com/glic3rinu/django-orchestra/master/scripts/container/create.sh
chmod +x /tmp/create.sh
sudo /tmp/create.sh
sudo lxc-start -n orchestra
```
2. Deploy Django-orchestra development environment inside the container
```bash
wget -O /tmp/deploy.sh \
https://raw2.github.com/glic3rinu/django-orchestra/master/scripts/container/deploy.sh
chmod +x /tmp/deploy.sh
cd /tmp/ # Moving away from /root before running deploy.sh
/tmp/deploy.sh
```
Django-orchestra source code should be now under `~orchestra/django-orchestra` and an Orchestra instance called _panel_ under `~orchestra/panel`
3. Nginx should be serving on port 80, but Django's development server can be used as well:
```bash
su - orchestra
cd panel
python manage.py runserver 0.0.0.0:8888
```
4. A convenient practice can be mounting `~orchestra` on your host machine so you can code with your favourite IDE, sshfs can be used for that
```bash
# On your host
mkdir ~<user>/orchestra
sshfs orchestra@<container-ip>: ~<user>/orchestra
```
5. To upgrade to current master just
```bash
cd ~orchestra/django-orchestra/
git pull origin master
sudo ~orchestra/django-orchestra/scripts/container/deploy.sh
```
License
-------
Copyright (C) 2013 Marc Aymerich
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Status API Training Shop Blog About

43
ROADMAP.md Normal file
View file

@ -0,0 +1,43 @@
# Roadmap
### 1.0a1 Milestone (first alpha release on May '14)
1. [x] Automated deployment of the development environment
2. [x] Automated installation and upgrading
2. [ ] Testing framework for running unittests and functional tests
2. [ ] Continuous integration environment
2. [x] Admin interface based on django.contrib.admin foundations
3. [x] REST API based on django-rest-framework foundations
2. [x] [Orchestra-orm](https://github.com/glic3rinu/orchestra-orm) a Python library for easily interacting with the REST API
3. [x] Service orchestration framework
4. [ ] Data model, input validation, admin and REST interfaces, permissions, unit and functional tests, service management, migration scripts and some documentation of:
1. [ ] Web applications and FTP accounts
2. [ ] Databases
1. [ ] Mail accounts, aliases, forwards
1. [ ] DNS
1. [ ] Mailing lists
1. [ ] Contact management and service contraction
1. [ ] Object level permissions system
1. [ ] Unittests of all the logic
2. [ ] Functional tests of all Admin and REST interations
1. [ ] Initial documentation
### 1.0b1 Milestone (first beta release on Jul '14)
1. [ ] Resource monitoring
1. [ ] Orders
2. [ ] Pricing
3. [ ] Billing
1. [ ] Payment gateways
2. [ ] Scheduling of service cancellations
1. [ ] Full documentation
### 1.0 Milestone (first stable release on Dec '14)
1. [ ] Stabilize data model, internal APIs and REST API
1. [ ] Integration with third-party service providers, e.g. Gandi
1. [ ] Support for additional services like VPS
2. [ ] Issue tracking system

37
TODO.md Normal file
View file

@ -0,0 +1,37 @@
TODO
====
* scape strings before executing scripts in order to prevent exploits: django templates automatically scapes things. Most important is to ensuer that all escape ' to &quot
* Optimize SSH: pool, `UseDNS no`
* Don't store passwords and other service parameters that can be changed by the services i.e. mailman, vps etc. Find an execution mechanism that trigger `change_password()`
* abort transaction on orchestration when `state == TIMEOUT` ?
* filter and other user.is_main refactoring
* use format_html_join for orchestration email alerts
* generic form for change and display passwords and crack change password form
* enforce an emergency email contact and account to contact contacts about problems when mailserver is down
* add `BackendLog` retry action
* move invoice contact to invoices app?
* wrapper around reverse('admin:....') `link()` and `link_factory()`
* PHPbBckendMiixin with get_php_ini
* Apache: `IncludeOptional /etc/apache2/extra-vhos[t]/account-site-custom.con[f]`
* rename account.user to primary_user
* webmail identities and addresses
* cached -> cached_property
* user.roles.mailbox its awful when combined with addresses:
* address.mailboxes filter by account is crap in admin and api
* address.mailboxes api needs a mailbox object endpoint (not nested user)
* Its not intuitive, users expect to create mailboxes, not users!
* Mailbox is something tangible, not a role!
* System user vs virtual user:
* system user automatically hast @domain.com address :(
* use Code: https://github.com/django/django/blob/master/django/forms/forms.py#L415 for domain.refresh_serial()
* Permissions .filter_queryset()
* git deploy in addition to FTP?
* env vars instead of multiple settings files: https://devcenter.heroku.com/articles/config-vars ?
* optional chroot shell?

View file

@ -0,0 +1,5 @@
This file is placed here by pip to indicate the source was put
here by pip.
Once this package is successfully installed this source code will be
deleted (unless you remove this file).

222
docs/API.rst Normal file
View file

@ -0,0 +1,222 @@
=================================
Orchestra REST API Specification
=================================
:Version: 0.1
Resources
---------
.. contents::
:local:
Panel [application/vnd.orchestra.Panel+json]
============================================
A Panel represents a user's view of all accessible resources.
A "Panel" resource model contains the following fields:
========================== ============ ========== ===========================
**Field name** **Type** **Occurs** **Description**
========================== ============ ========== ===========================
uri URI 1 A GET against this URI refreshes the client representation of the resources accessible to this user.
services Object[] 0..1 {'DNS': {'names': "names_URI", 'zones': "zones_URI}, {'Mail': {'Virtual_user': "virtual_user_URI" ....
accountancy Object[] 0..1
administration Object[] 0..1
========================== ============ ========== ===========================
Contact [application/vnd.orchestra.Contact+json]
================================================
A Contact represents
========================== ============ ========== ===========================
**Field name** **Type** **Occurs** **Description**
========================== ============ ========== ===========================
uri URI 1
name String 1
surname String 0..1
second_surname String 0..1
national_id String 1
type String 1
language String 1
address String 1
city String 1
zipcode Number 1
province String 1
country String 1
fax String 0..1
emails String[] 1
phones String[] 1
billing_contact Contact 0..1
technical_contact Contact 0..1
administrative_contact Contact 0..1
========================== ============ ========== ===========================
TODO: phone and emails for this contacts this contacts should be equal to Contact on Django models
User [application/vnd.orchestra.User+json]
==========================================
A User represents
========================== ============ ========== ===========================
**Field name** **Type** **Occurs** **Description**
========================== ============ ========== ===========================
username String
uri URI 1
contact Contact
password String
first_name String
last_name String
email_address String
active Boolean
staff_status Boolean
superuser_status Boolean
groups Group
user_permissions Permission[]
last_login String
date_joined String
system_user SystemUser
virtual_user VirtualUser
========================== ============ ========== ===========================
SystemUser [application/vnd.orchestra.SystemUser+json]
======================================================
========================== =========== ========== ===========================
**Field name** **Type** **Occurs** **Description**
========================== =========== ========== ===========================
user User
uri URI 1
user_shell String
user_uid Number
primary_group Group
homedir String
only_ftp Boolean
========================== =========== ========== ===========================
VirtualUser [application/vnd.orchestra.VirtualUser+json]
========================================================
========================== ============ ========== ===========================
**Field name** **Type** **Occurs** **Description**
========================== ============ ========== ===========================
user User
uri URI 1
emailname String
domain Name <VirtualDomain?>
home String
========================== ============ ========== ===========================
Zone [application/vnd.orchestra.Zone+json]
==========================================
========================== ============ ========== ===========================
**Field name** **Type** **Occurs** **Description**
========================== ============ ========== ===========================
origin String
uri URI 1
contact Contact
primary_ns String
hostmaster_email String
serial Number
slave_refresh Number
slave_retry Number
slave_expiration Number
min_caching_time Number
records Object[] Domain record i.e. {'name': ('type', 'value') }
========================== ============ ========== ===========================
Name [application/vnd.orchestra.Name+json]
==========================================
========================== ============ ========== ===========================
**Field name** **Type** **Occurs** **Description**
========================== ============ ========== ===========================
name String
extension String
uri URI 1
contact Contact
register_provider String
name_server Object[] Name server key/value i.e. {'ns1.pangea.org': '1.1.1.1'}
virtual_domain Boolean <TODO: is redundant with virtual domain type?>
virtual_domain_type String
zone Zone
========================== ============ ========== ===========================
VirtualHost [application/vnd.orchestra.VirtualHost+json]
========================================================
<TODO: REST and dynamic attributes (resources, contacts)>
A VirtualHost represents an Apache-like virtualhost configuration, which is useful for generating all the configuration files on the web server.
A VirtualHost resource model contains the following fields:
========================== ============ ========== ===========================
**Field name** **Type** **Occurs** **Description**
========================== ============ ========== ===========================
server_name String
uri URI
contact Contact
ip String
port Number
domains Name[]
document_root String
custom_directives String[]
fcgid_user String
fcgid_group string String
fcgid_directives Object Fcgid custom directives represented on a key/value pairs i.e. {'FcgidildeTimeout': 1202}
php_version String
php_directives Object PHP custom directives represented on key/value pairs i.e. {'display errors': 'True'}
resource_swap_current Number PHP custom directives represented on key/value pairs i.e. {'display errors': 'True'}
resource_swap_limit Number PHP custom directives represented on key/value pairs i.e. {'display errors': 'True'}
resource_cpu_current Number
resource_cpu_limit Number
========================== ============ ========== ===========================
Daemon [application/vnd.orchestra.Daemon+json]
==============================================
========================== ============ ========== ===========================
**Field name** **Type** **Occurs** **Description**
========================== ============ ========== ===========================
name String
uri URI 1
content_type String
active Boolean
save_template String
save_method String
delete_template String
delete_method String
daemon_instances Object[] {'host': 'expression'}
========================== ============ ========== ===========================
Monitor [application/vnd.orchestra.Monitor+json]
================================================
========================== ============ ========== ===========================
**Field name** **Type** **Occurs** **Description**
========================== ============ ========== ===========================
uri URI 1
daemon Daemon
resource String
monitoring_template String
monitoring method String
exceed_template String <TODO: rename on monitor django model>
exceed_method String
recover_template String
recover_method String
allow_limit Boolean
allow_unlimit Boolean
default_initial Number
block_size Number
algorithm String
period String
interval String 0..1
crontab String 0..1
========================== ============ ========== ===========================
#Layout inspired from http://kenai.com/projects/suncloudapis/pages/CloudAPISpecificationResourceModels

153
docs/Makefile Normal file
View file

@ -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) .
# the i18n builder cannot share the environment and doctrees with the others
I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
.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 <target>' where <target> 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/django-orchestra.qhcp"
@echo "To view the help file:"
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-orchestra.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/django-orchestra"
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-orchestra"
@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."

242
docs/conf.py Normal file
View file

@ -0,0 +1,242 @@
# -*- coding: utf-8 -*-
#
# django-orchestra documentation build configuration file, created by
# sphinx-quickstart on Wed Aug 8 11:07:40 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 = ['sphinx.ext.autodoc']
# 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'django-orchestra'
copyright = u'2012, Marc Aymerich'
# 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.1'
# The full version, including alpha/beta/rc tags.
release = '0.1'
# 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 = ['_build']
# 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'
# 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 = []
# The name for this set of Sphinx documents. If None, it defaults to
# "<project> v<release> 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 = {}
# 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 <link> 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 = 'django-orchestradoc'
# -- 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', 'django-orchestra.tex', u'django-orchestra Documentation',
u'Marc Aymerich', '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', 'django-orchestra', u'django-orchestra Documentation',
[u'Marc Aymerich'], 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', 'django-orchestra', u'django-orchestra Documentation',
u'Marc Aymerich', 'django-orchestra', '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'

21
docs/index.rst Normal file
View file

@ -0,0 +1,21 @@
.. django-orchestra documentation master file, created by
sphinx-quickstart on Wed Aug 8 11:07:40 2012.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Welcome to django-orchestra's documentation!
============================================
Contents:
.. toctree::
:maxdepth: 2
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

190
docs/make.bat Normal file
View file

@ -0,0 +1,190 @@
@ECHO OFF
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set BUILDDIR=_build
set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% .
set I18NSPHINXOPTS=%SPHINXOPTS% .
if NOT "%PAPER%" == "" (
set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
)
if "%1" == "" goto help
if "%1" == "help" (
:help
echo.Please use `make ^<target^>` where ^<target^> 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. text to make text files
echo. man to make manual pages
echo. texinfo to make Texinfo files
echo. gettext to make PO message catalogs
echo. changes to make an overview over 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
goto end
)
if "%1" == "clean" (
for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
del /q /s %BUILDDIR%\*
goto end
)
if "%1" == "html" (
%SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The HTML pages are in %BUILDDIR%/html.
goto end
)
if "%1" == "dirhtml" (
%SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
goto end
)
if "%1" == "singlehtml" (
%SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
goto end
)
if "%1" == "pickle" (
%SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can process the pickle files.
goto end
)
if "%1" == "json" (
%SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can process the JSON files.
goto end
)
if "%1" == "htmlhelp" (
%SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can run HTML Help Workshop with the ^
.hhp project file in %BUILDDIR%/htmlhelp.
goto end
)
if "%1" == "qthelp" (
%SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can run "qcollectiongenerator" with the ^
.qhcp project file in %BUILDDIR%/qthelp, like this:
echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-orchestra.qhcp
echo.To view the help file:
echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-orchestra.ghc
goto end
)
if "%1" == "devhelp" (
%SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
if errorlevel 1 exit /b 1
echo.
echo.Build finished.
goto end
)
if "%1" == "epub" (
%SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The epub file is in %BUILDDIR%/epub.
goto end
)
if "%1" == "latex" (
%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
if errorlevel 1 exit /b 1
echo.
echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
goto end
)
if "%1" == "text" (
%SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The text files are in %BUILDDIR%/text.
goto end
)
if "%1" == "man" (
%SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The manual pages are in %BUILDDIR%/man.
goto end
)
if "%1" == "texinfo" (
%SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
goto end
)
if "%1" == "gettext" (
%SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
goto end
)
if "%1" == "changes" (
%SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
if errorlevel 1 exit /b 1
echo.
echo.The overview file is in %BUILDDIR%/changes.
goto end
)
if "%1" == "linkcheck" (
%SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
if errorlevel 1 exit /b 1
echo.
echo.Link check complete; look for any errors in the above output ^
or in %BUILDDIR%/linkcheck/output.txt.
goto end
)
if "%1" == "doctest" (
%SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
if errorlevel 1 exit /b 1
echo.
echo.Testing of doctests in the sources finished, look at the ^
results in %BUILDDIR%/doctest/output.txt.
goto end
)
:end

23
orchestra/__init__.py Normal file
View file

@ -0,0 +1,23 @@
VERSION = (0, 0, 1, 'alpha', 1)
def get_version():
"Returns a PEP 386-compliant version number from VERSION."
assert len(VERSION) == 5
assert VERSION[3] in ('alpha', 'beta', 'rc', 'final')
# Now build the two parts of the version number:
# main = X.Y[.Z]
# sub = .devN - for pre-alpha releases
# | {a|b|c}N - for alpha, beta and rc releases
parts = 2 if VERSION[2] == 0 else 3
main = '.'.join(str(x) for x in VERSION[:parts])
sub = ''
if VERSION[3] != 'final':
mapping = {'alpha': 'a', 'beta': 'b', 'rc': 'c'}
sub = mapping[VERSION[3]] + str(VERSION[4])
return str(main + sub)

View file

@ -0,0 +1,2 @@
from options import *
from dashboard import *

View file

@ -0,0 +1,20 @@
from django.conf import settings
from orchestra.core import services
def generate_services_group():
models = []
for model, options in services.get().iteritems():
if options.get('menu', True):
models.append("%s.%s" % (model.__module__, model._meta.object_name))
settings.FLUENT_DASHBOARD_APP_GROUPS += (
('Services', {
'models': models,
'collapsible': True
}),
)
generate_services_group()

View file

@ -0,0 +1,55 @@
from functools import wraps
from django.contrib import messages
from django.contrib.admin import helpers
from django.template.response import TemplateResponse
from django.utils.decorators import available_attrs
from django.utils.encoding import force_text
def action_with_confirmation(action_name, extra_context={},
template='admin/controller/generic_confirmation.html'):
"""
Generic pattern for actions that needs confirmation step
If custom template is provided the form must contain:
<input type="hidden" name="post" value="generic_confirmation" />
"""
def decorator(func, extra_context=extra_context, template=template):
@wraps(func, assigned=available_attrs(func))
def inner(modeladmin, request, queryset):
# The user has already confirmed the action.
if request.POST.get('post') == "generic_confirmation":
stay = func(modeladmin, request, queryset)
if not stay:
return
opts = modeladmin.model._meta
app_label = opts.app_label
action_value = func.__name__
if len(queryset) == 1:
objects_name = force_text(opts.verbose_name)
else:
objects_name = force_text(opts.verbose_name_plural)
context = {
"title": "Are you sure?",
"content_message": "Are you sure you want to %s the selected %s?" %
(action_name, objects_name),
"action_name": action_name.capitalize(),
"action_value": action_value,
"deletable_objects": queryset,
'queryset': queryset,
"opts": opts,
"app_label": app_label,
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
}
context.update(extra_context)
# Display the confirmation page
return TemplateResponse(request, template,
context, current_app=modeladmin.admin_site.name)
return inner
return decorator

10
orchestra/admin/html.py Normal file
View file

@ -0,0 +1,10 @@
from django.utils.safestring import mark_safe
MONOSPACE_FONTS = ('Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,'
'Bitstream Vera Sans Mono,Courier New,monospace')
def monospace_format(text):
style="font-family:%s;padding-left:110px;" % MONOSPACE_FONTS
return mark_safe('<pre style="%s">%s</pre>' % (style, text))

96
orchestra/admin/menu.py Normal file
View file

@ -0,0 +1,96 @@
from admin_tools.menu import items, Menu
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
from orchestra.core import services
from orchestra.utils.apps import isinstalled
def api_link(context):
""" Dynamically generates API related URL """
if 'opts' in context:
opts = context['opts']
elif 'cl' in context:
opts = context['cl'].opts
else:
return reverse('api-root')
if 'object_id' in context:
object_id = context['object_id']
try:
return reverse('%s-detail' % opts.module_name, args=[object_id])
except:
return reverse('api-root')
try:
return reverse('%s-list' % opts.module_name)
except:
return reverse('api-root')
def get_services():
result = []
for model, options in services.get().iteritems():
if options.get('menu', True):
opts = model._meta
url = reverse('admin:%s_%s_changelist' % (opts.app_label, opts.model_name))
result.append(items.MenuItem(options.get('verbose_name_plural'), url))
return sorted(result, key=lambda i: i.title)
def get_accounts():
accounts = [
items.MenuItem(_("Accounts"), reverse('admin:accounts_account_changelist'))
]
if isinstalled('orchestra.apps.contacts'):
url = reverse('admin:contacts_contact_changelist')
accounts.append(items.MenuItem(_("Contacts"), url))
if isinstalled('orchestra.apps.users'):
url = reverse('admin:users_user_changelist')
users = [items.MenuItem(_("Users"), url)]
if isinstalled('rest_framework.authtoken'):
tokens = reverse('admin:authtoken_token_changelist')
users.append(items.MenuItem(_("Tokens"), tokens))
accounts.append(items.MenuItem(_("Users"), url, children=users))
return accounts
def get_administration():
administration = []
return administration
def get_administration_models():
administration_models = []
if isinstalled('orchestra.apps.orchestration'):
administration_models.append('orchestra.apps.orchestration.*')
if isinstalled('djcelery'):
administration_models.append('djcelery.*')
if isinstalled('orchestra.apps.issues'):
administration_models.append('orchestra.apps.issues.*')
return administration_models
class OrchestraMenu(Menu):
def init_with_context(self, context):
self.children += [
items.MenuItem(
_('Dashboard'),
reverse('admin:index')
),
items.Bookmarks(),
items.MenuItem(
_("Services"),
reverse('admin:index'),
children=get_services()
),
items.MenuItem(
_("Accounts"),
reverse('admin:accounts_account_changelist'),
children=get_accounts()
),
items.AppList(
_("Administration"),
models=get_administration_models(),
children=get_administration()
),
items.MenuItem("API", api_link(context))
]

View file

@ -0,0 +1,76 @@
from django import forms
from django.contrib import admin
from django.forms.models import BaseInlineFormSet
from .utils import set_default_filter
class ExtendedModelAdmin(admin.ModelAdmin):
add_fields = ()
add_fieldsets = ()
add_form = None
change_readonly_fields = ()
def get_readonly_fields(self, request, obj=None):
fields = super(ExtendedModelAdmin, self).get_readonly_fields(request, obj=obj)
if obj:
return fields + self.change_readonly_fields
return fields
def get_fieldsets(self, request, obj=None):
if not obj:
if self.add_fieldsets:
return self.add_fieldsets
elif self.add_fields:
return [(None, {'fields': self.add_fields})]
return super(ExtendedModelAdmin, self).get_fieldsets(request, obj=obj)
def get_inline_instances(self, request, obj=None):
""" add_inlines and inline.parent_object """
self.inlines = getattr(self, 'add_inlines', self.inlines)
if obj:
self.inlines = type(self).inlines
inlines = super(ExtendedModelAdmin, self).get_inline_instances(request, obj=obj)
for inline in inlines:
inline.parent_object = obj
return inlines
def get_form(self, request, obj=None, **kwargs):
""" Use special form during user creation """
defaults = {}
if obj is None and self.add_form:
defaults['form'] = self.add_form
defaults.update(kwargs)
return super(ExtendedModelAdmin, self).get_form(request, obj, **defaults)
class ChangeListDefaultFilter(object):
"""
Enables support for default filtering on admin change list pages
Your model admin class should define an default_changelist_filters attribute
default_changelist_filters = (('my_nodes', 'True'),)
"""
default_changelist_filters = ()
def changelist_view(self, request, extra_context=None):
""" Default filter as 'my_nodes=True' """
defaults = []
for queryarg, value in self.default_changelist_filters:
set_default_filter(queryarg, request, value)
defaults.append(queryarg)
# hack response cl context in order to hook default filter awaearness into search_form.html template
response = super(ChangeListDefaultFilter, self).changelist_view(request, extra_context=extra_context)
if hasattr(response, 'context_data') and 'cl' in response.context_data:
response.context_data['cl'].default_changelist_filters = defaults
return response
class AtLeastOneRequiredInlineFormSet(BaseInlineFormSet):
def clean(self):
"""Check that at least one service has been entered."""
super(AtLeastOneRequiredInlineFormSet, self).clean()
if any(self.errors):
return
if not any(cleaned_data and not cleaned_data.get('DELETE', False)
for cleaned_data in self.cleaned_data):
raise forms.ValidationError('At least one item required.')

133
orchestra/admin/utils.py Normal file
View file

@ -0,0 +1,133 @@
from functools import update_wrapper
from django.conf import settings
from django.contrib import admin
from django.core.urlresolvers import reverse
from django.db import models
from django.utils import importlib
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from orchestra.models.utils import get_field_value
from orchestra.utils.time import timesince, timeuntil
def get_modeladmin(model, import_module=True):
""" returns the modeladmin registred for model """
for k,v in admin.site._registry.iteritems():
if k is model:
return v
if import_module:
# Sometimes the admin module is not yet imported
app_label = model._meta.app_label
for app in settings.INSTALLED_APPS:
if app.endswith(app_label):
app_label = app
importlib.import_module('%s.%s' % (app_label, 'admin'))
return get_modeladmin(model, import_module=False)
def insertattr(model, name, value, weight=0):
""" Inserts attribute to a modeladmin """
is_model = models.Model in model.__mro__
modeladmin = get_modeladmin(model) if is_model else model
# Avoid inlines defined on parent class be shared between subclasses
# Seems that if we use tuples they are lost in some conditions like changing
# the tuple in modeladmin.__init__
if not getattr(modeladmin, name):
setattr(type(modeladmin), name, [])
inserted_attrs = getattr(modeladmin, '__inserted_attrs__', {})
if not name in inserted_attrs:
weights = {}
if hasattr(modeladmin, 'weights') and name in modeladmin.weights:
weights = modeladmin.weights.get(name)
inserted_attrs[name] = [ (attr, weights.get(attr, 0)) for attr in getattr(modeladmin, name) ]
inserted_attrs[name].append((value, weight))
inserted_attrs[name].sort(key=lambda a: a[1])
setattr(modeladmin, name, [ attr[0] for attr in inserted_attrs[name] ])
setattr(modeladmin, '__inserted_attrs__', inserted_attrs)
def wrap_admin_view(modeladmin, view):
""" Add admin authentication to view """
def wrapper(*args, **kwargs):
return modeladmin.admin_site.admin_view(view)(*args, **kwargs)
return update_wrapper(wrapper, view)
def set_default_filter(queryarg, request, value):
""" set default filters for changelist_view """
if queryarg not in request.GET:
q = request.GET.copy()
if callable(value):
value = value(request)
q[queryarg] = value
request.GET = q
request.META['QUERY_STRING'] = request.GET.urlencode()
def link(*args, **kwargs):
""" utility function for creating admin links """
field = args[0] if args else ''
order = kwargs.pop('order', field)
popup = kwargs.pop('popup', False)
def display_link(self, instance):
obj = getattr(instance, field, instance)
if not getattr(obj, 'pk', False):
return '---'
opts = obj._meta
view_name = 'admin:%s_%s_change' % (opts.app_label, opts.model_name)
url = reverse(view_name, args=(obj.pk,))
extra = ''
if popup:
extra = 'onclick="return showAddAnotherPopup(this);"'
return '<a href="%s" %s>%s</a>' % (url, extra, obj)
display_link.allow_tags = True
display_link.short_description = _(field)
display_link.admin_order_field = order
return display_link
def colored(field_name, colours, description='', verbose=False, bold=True):
""" returns a method that will render obj with colored html """
def colored_field(obj, field=field_name, colors=colours, verbose=verbose):
value = escape(get_field_value(obj, field))
color = colors.get(value, "black")
if verbose:
# Get the human-readable value of a choice field
value = getattr(obj, 'get_%s_display' % field)()
colored_value = '<span style="color: %s;">%s</span>' % (color, value)
if bold:
colored_value = '<b>%s</b>' % colored_value
return mark_safe(colored_value)
if not description:
description = field_name.split('__').pop().replace('_', ' ').capitalize()
colored_field.short_description = description
colored_field.allow_tags = True
colored_field.admin_order_field = field_name
return colored_field
def display_timesince(date, double=False):
"""
Format date for messages create_on: show a relative time
with contextual helper to show fulltime format.
"""
if not date:
return 'Never'
date_rel = timesince(date)
if not double:
date_rel = date_rel.split(',')[0]
date_rel += ' ago'
date_abs = date.strftime("%Y-%m-%d %H:%M:%S %Z")
return mark_safe("<span title='%s'>%s</span>" % (date_abs, date_rel))
def display_timeuntil(date):
date_rel = timeuntil(date) + ' left'
date_abs = date.strftime("%Y-%m-%d %H:%M:%S %Z")
return mark_safe("<span title='%s'>%s</span>" % (date_abs, date_rel))

View file

@ -0,0 +1,2 @@
from options import *
from actions import *

18
orchestra/api/actions.py Normal file
View file

@ -0,0 +1,18 @@
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.response import Response
from .serializers import SetPasswordSerializer
class SetPasswordApiMixin(object):
@action(serializer_class=SetPasswordSerializer)
def set_password(self, request, pk):
obj = self.get_object()
serializer = SetPasswordSerializer(data=request.DATA)
if serializer.is_valid():
obj.set_password(serializer.data['password'])
obj.save()
return Response({'status': 'password changed'})
else:
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

51
orchestra/api/fields.py Normal file
View file

@ -0,0 +1,51 @@
import json
from rest_framework import serializers, exceptions
class OptionField(serializers.WritableField):
"""
Dict-like representation of a OptionField
A bit hacky, objects get deleted on from_native method and Serializer will
need a custom override of restore_object method.
"""
def to_native(self, value):
""" dict-like representation of a Property Model"""
return dict((prop.name, prop.value) for prop in value.all())
def from_native(self, value):
""" Convert a dict-like representation back to a WebOptionField """
parent = self.parent
related_manager = getattr(parent.object, self.source or 'options', False)
properties = serializers.RelationsList()
if value:
model = getattr(parent.opts.model, self.source or 'options').related.model
if isinstance(value, basestring):
try:
value = json.loads(value)
except:
raise exceptions.ParseError("Malformed property: %s" % str(value))
if not related_manager:
# POST (new parent object)
return [ model(name=n, value=v) for n,v in value.iteritems() ]
# PUT
to_save = []
for (name, value) in value.iteritems():
try:
# Update existing property
prop = related_manager.get(name=name)
except model.DoesNotExist:
# Create a new one
prop = model(name=name, value=value)
else:
prop.value = value
to_save.append(prop.pk)
properties.append(prop)
# Discart old values
if related_manager:
properties._deleted = [] # Redefine class attribute
for obj in related_manager.all():
if not value or obj.pk not in to_save:
properties._deleted.append(obj)
return properties

52
orchestra/api/helpers.py Normal file
View file

@ -0,0 +1,52 @@
from django.core.urlresolvers import NoReverseMatch
from rest_framework.reverse import reverse
from rest_framework.routers import replace_methodname
def replace_collectionmethodname(format_string, methodname):
ret = replace_methodname(format_string, methodname)
ret = ret.replace('{collectionmethodname}', methodname)
return ret
def link_wrap(view, view_names):
def wrapper(self, request, view=view, *args, **kwargs):
""" wrapper function that inserts HTTP links on view """
links = []
for name in view_names:
try:
url = reverse(name, request=self.request)
except NoReverseMatch:
url = reverse(name, args, kwargs, request=request)
links.append('<%s>; rel="%s"' % (url, name))
response = view(self, request, *args, **kwargs)
response['Link'] = ', '.join(links)
return response
for attr in dir(view):
try:
setattr(wrapper, attr, getattr(view, attr))
except:
pass
return wrapper
def insert_links(viewset, base_name):
collection_links = ['api-root', '%s-list' % base_name]
object_links = ['api-root', '%s-list' % base_name, '%s-detail' % base_name]
exception_links = ['api-root']
list_links = ['api-root']
retrieve_links = ['api-root', '%s-list' % base_name]
# Determine any `@action` or `@link` decorated methods on the viewset
for methodname in dir(viewset):
method = getattr(viewset, methodname)
view_name = '%s-%s' % (base_name, methodname.replace('_', '-'))
if hasattr(method, 'collection_bind_to_methods'):
list_links.append(view_name)
retrieve_links.append(view_name)
setattr(viewset, methodname, link_wrap(method, collection_links))
elif hasattr(method, 'bind_to_methods'):
retrieve_links.append(view_name)
setattr(viewset, methodname, link_wrap(method, object_links))
viewset.handle_exception = link_wrap(viewset.handle_exception, exception_links)
viewset.list = link_wrap(viewset.list, list_links)
viewset.retrieve = link_wrap(viewset.retrieve, retrieve_links)

139
orchestra/api/options.py Normal file
View file

@ -0,0 +1,139 @@
from django import conf
from django.core.exceptions import ImproperlyConfigured
from rest_framework import views
from rest_framework.response import Response
from rest_framework.reverse import reverse
from rest_framework.routers import DefaultRouter, Route, flatten, replace_methodname
from .. import settings
from ..utils.apps import autodiscover as module_autodiscover
from .helpers import insert_links, replace_collectionmethodname
def collectionlink(**kwargs):
"""
Used to mark a method on a ViewSet collection that should be routed for GET requests.
"""
def decorator(func):
func.collection_bind_to_methods = ['get']
func.kwargs = kwargs
return func
return decorator
class LinkHeaderRouter(DefaultRouter):
def __init__(self, *args, **kwargs):
""" collection view method route """
super(LinkHeaderRouter, self).__init__(*args, **kwargs)
self.routes.insert(0, Route(
url=r'^{prefix}/{collectionmethodname}{trailing_slash}$',
mapping={
'{httpmethod}': '{collectionmethodname}',
},
name='{basename}-{methodnamehyphen}',
initkwargs={}
))
def get_routes(self, viewset):
""" allow links and actions to be bound to a collection view """
known_actions = flatten([route.mapping.values() for route in self.routes])
dynamic_routes = []
collection_dynamic_routes = []
for methodname in dir(viewset):
attr = getattr(viewset, methodname)
bind = getattr(attr, 'bind_to_methods', None)
httpmethods = getattr(attr, 'collection_bind_to_methods', bind)
if httpmethods:
if methodname in known_actions:
msg = ('Cannot use @action or @link decorator on method "%s" '
'as it is an existing route' % methodname)
raise ImproperlyConfigured(msg)
httpmethods = [method.lower() for method in httpmethods]
if bind:
dynamic_routes.append((httpmethods, methodname))
else:
collection_dynamic_routes.append((httpmethods, methodname))
ret = []
for route in self.routes:
# Dynamic routes (@link or @action decorator)
if route.mapping == {'{httpmethod}': '{methodname}'}:
replace = replace_methodname
routes = dynamic_routes
elif route.mapping == {'{httpmethod}': '{collectionmethodname}'}:
replace = replace_collectionmethodname
routes = collection_dynamic_routes
else:
ret.append(route)
continue
for httpmethods, methodname in routes:
initkwargs = route.initkwargs.copy()
initkwargs.update(getattr(viewset, methodname).kwargs)
ret.append(Route(
url=replace(route.url, methodname),
mapping={ httpmethod: methodname for httpmethod in httpmethods },
name=replace(route.name, methodname),
initkwargs=initkwargs,
))
return ret
def get_api_root_view(self):
""" returns the root view, with all the linked collections """
class APIRoot(views.APIView):
def get(instance, request, format=None):
root_url = reverse('api-root', request=request, format=format)
token_url = reverse('api-token-auth', request=request, format=format)
links = [
'<%s>; rel="%s"' % (root_url, 'api-root'),
'<%s>; rel="%s"' % (token_url, 'api-get-auth-token'),
]
if not request.user.is_anonymous():
list_name = '{basename}-list'
detail_name = '{basename}-detail'
for prefix, viewset, basename in self.registry:
singleton_pk = getattr(viewset, 'singleton_pk', False)
if singleton_pk:
url_name = detail_name.format(basename=basename)
kwargs = { 'pk': singleton_pk(viewset(), request) }
else:
url_name = list_name.format(basename=basename)
kwargs = {}
url = reverse(url_name, request=request, format=format, kwargs=kwargs)
links.append('<%s>; rel="%s"' % (url, url_name))
# Add user link
url_name = detail_name.format(basename='user')
kwargs = { 'pk': request.user.pk }
url = reverse(url_name, request=request, format=format, kwargs=kwargs)
links.append('<%s>; rel="%s"' % (url, url_name))
headers = { 'Link': ', '.join(links) }
content = {
name: getattr(settings, name, None)
for name in ['SITE_NAME', 'SITE_VERBOSE_NAME']
}
content['INSTALLED_APPS'] = conf.settings.INSTALLED_APPS
return Response(content, headers=headers)
return APIRoot.as_view()
def register(self, prefix, viewset, base_name=None):
""" inserts link headers on every viewset """
if base_name is None:
base_name = self.get_default_base_name(viewset)
insert_links(viewset, base_name)
self.registry.append((prefix, viewset, base_name))
def insert(self, prefix, name, field, **kwargs):
""" Dynamically add new fields to an existing serializer """
for _prefix, viewset, basename in self.registry:
if _prefix == prefix:
if viewset.serializer_class is None:
viewset.serializer_class = viewset().get_serializer_class()
viewset.serializer_class.Meta.fields += (name,)
viewset.serializer_class.base_fields.update({name: field(**kwargs)})
setattr(viewset, 'inserted', getattr(viewset, 'inserted', []))
viewset.inserted.append(name)
# Create a router and register our viewsets with it.
router = LinkHeaderRouter()
autodiscover = lambda: (module_autodiscover('api'), module_autodiscover('serializers'))

View file

@ -0,0 +1,33 @@
from django.forms import widgets
from django.utils.translation import ugettext, ugettext_lazy as _
from rest_framework import serializers
from ..core.validators import validate_password
class SetPasswordSerializer(serializers.Serializer):
password = serializers.CharField(max_length=128, label=_('Password'),
widget=widgets.PasswordInput, validators=[validate_password])
class MultiSelectField(serializers.ChoiceField):
widget = widgets.CheckboxSelectMultiple
def field_from_native(self, data, files, field_name, into):
""" convert multiselect data into comma separated string """
if field_name in data:
data = data.copy()
try:
# data is a querydict when using forms
data[field_name] = ','.join(data.getlist(field_name))
except AttributeError:
data[field_name] = ','.join(data[field_name])
return super(MultiSelectField, self).field_from_native(data, files, field_name, into)
def valid_value(self, value):
""" checks for each item if is a valid value """
for val in value.split(','):
valid = super(MultiSelectField, self).valid_value(val)
if not valid:
return False
return True

View file

View file

View file

@ -0,0 +1,212 @@
from django import forms
from django.conf.urls import patterns, url
from django.contrib import admin, messages
from django.contrib.admin.util import unquote
from django.core.urlresolvers import reverse
from django.http import HttpResponseRedirect
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ExtendedModelAdmin
from orchestra.admin.utils import wrap_admin_view, link
from orchestra.core import services
from .filters import HasMainUserListFilter
from .forms import AccountCreationForm, AccountChangeForm
from .models import Account
class AccountAdmin(ExtendedModelAdmin):
list_display = ('name', 'user_link', 'type', 'is_active')
list_filter = (
'type', 'is_active', HasMainUserListFilter
)
add_fieldsets = (
(_("User"), {
'fields': ('username', 'password1', 'password2',),
}),
(_("Account info"), {
'fields': (('type', 'language'), 'comments'),
}),
)
fieldsets = (
(_("User"), {
'fields': ('user_link', 'password',),
}),
(_("Account info"), {
'fields': (('type', 'language'), 'comments'),
}),
)
readonly_fields = ('user_link',)
search_fields = ('users__username',)
add_form = AccountCreationForm
form = AccountChangeForm
user_link = link('user', order='user__username')
def name(self, account):
return account.name
name.admin_order_field = 'user__username'
def formfield_for_dbfield(self, db_field, **kwargs):
""" Make value input widget bigger """
if db_field.name == 'comments':
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 4})
return super(AccountAdmin, self).formfield_for_dbfield(db_field, **kwargs)
def change_view(self, request, object_id, form_url='', extra_context=None):
if request.method == 'GET':
account = self.get_object(request, unquote(object_id))
if not account.is_active:
messages.warning(request, 'This account is disabled.')
context = {
'services': sorted(
[ model._meta for model in services.get().keys() ],
key=lambda i: i.verbose_name_plural.lower()
)
}
context.update(extra_context or {})
return super(AccountAdmin, self).change_view(request, object_id,
form_url=form_url, extra_context=context)
def save_model(self, request, obj, form, change):
""" Save user and account, they are interdependent """
obj.user.save()
obj.user_id = obj.user.pk
obj.save()
obj.user.account = obj
obj.user.save()
def queryset(self, request):
""" Select related for performance """
# TODO move invoicecontact to contacts
related = ('user', 'invoicecontact')
return super(AccountAdmin, self).queryset(request).select_related(*related)
admin.site.register(Account, AccountAdmin)
class AccountListAdmin(AccountAdmin):
""" Account list to allow account selection when creating new services """
list_display = ('select_account', 'type', 'user')
actions = None
search_fields = ['user__username',]
ordering = ('user__username',)
def select_account(self, instance):
context = {
'url': '../?account=' + str(instance.pk),
'name': instance.name
}
return '<a href="%(url)s">%(name)s</a>' % context
select_account.short_description = _("account")
select_account.allow_tags = True
select_account.order_admin_field = 'user__username'
def changelist_view(self, request, extra_context=None):
opts = self.model._meta
original_app_label = request.META['PATH_INFO'].split('/')[-5]
original_model = request.META['PATH_INFO'].split('/')[-4]
context = {
'title': _("Select account for adding a new %s") % (original_model),
'original_app_label': original_app_label,
'original_model': original_model,
}
context.update(extra_context or {})
return super(AccountListAdmin, self).changelist_view(request,
extra_context=context)
class AccountAdminMixin(object):
""" Provide basic account support to ModelAdmin and AdminInline classes """
readonly_fields = ('account_link',)
filter_by_account_fields = []
def account_link(self, instance):
account = instance.account if instance.pk else self.account
url = reverse('admin:accounts_account_change', args=(account.pk,))
return '<a href="%s">%s</a>' % (url, account.name)
account_link.short_description = _("account")
account_link.allow_tags = True
account_link.admin_order_field = 'account__user__username'
def queryset(self, request):
""" Select related for performance """
qs = super(AccountAdminMixin, self).queryset(request)
return qs.select_related('account__user')
def formfield_for_dbfield(self, db_field, **kwargs):
""" Improve performance of account field and filter by account """
if db_field.name == 'account':
qs = kwargs.get('queryset', db_field.rel.to.objects)
kwargs['queryset'] = qs.select_related('user')
formfield = super(AccountAdminMixin, self).formfield_for_dbfield(db_field, **kwargs)
if db_field.name in self.filter_by_account_fields:
if hasattr(self, 'account'):
# Hack widget render in order to append ?account=id to the add url
old_render = formfield.widget.render
def render(*args, **kwargs):
output = old_render(*args, **kwargs)
output = output.replace('/add/"', '/add/?account=%s"' % self.account.pk)
return mark_safe(output)
formfield.widget.render = render
# Filter related object by account
formfield.queryset = formfield.queryset.filter(account=self.account)
return formfield
class SelectAccountAdminMixin(AccountAdminMixin):
""" Provides support for accounts on ModelAdmin """
def get_readonly_fields(self, request, obj=None):
if obj:
self.account = obj.account
return super(AccountAdminMixin, self).get_readonly_fields(request, obj=obj)
def get_inline_instances(self, request, obj=None):
inlines = super(AccountAdminMixin, self).get_inline_instances(request, obj=obj)
if hasattr(self, 'account'):
account = self.account
else:
account = Account.objects.get(pk=request.GET['account'])
[ setattr(inline, 'account', account) for inline in inlines ]
return inlines
def get_urls(self):
""" Hooks select account url """
urls = super(AccountAdminMixin, self).get_urls()
admin_site = self.admin_site
opts = self.model._meta
info = opts.app_label, opts.module_name
account_list = AccountListAdmin(Account, admin_site).changelist_view
select_urls = patterns("",
url("/select-account/$",
wrap_admin_view(self, account_list),
name='%s_%s_select_account' % info),
)
return select_urls + urls
def add_view(self, request, form_url='', extra_context=None):
""" Redirects to select account view if required """
if request.user.is_superuser:
if 'account' in request.GET or Account.objects.count() == 1:
kwargs = {}
if 'account' in request.GET:
kwargs = dict(pk=request.GET['account'])
self.account = Account.objects.get(**kwargs)
opts = self.model._meta
context = {
'title': _("Add %s for %s") % (opts.verbose_name, self.account.name)
}
context.update(extra_context or {})
return super(AccountAdminMixin, self).add_view(request,
form_url=form_url, extra_context=context)
return HttpResponseRedirect('./select-account/')
def save_model(self, request, obj, form, change):
"""
Given a model instance save it to the database.
"""
if not change:
obj.account_id = self.account.pk
obj.save()

View file

@ -0,0 +1,25 @@
from rest_framework import viewsets
from orchestra.api import router
from .models import Account
from .serializers import AccountSerializer
class AccountApiMixin(object):
def get_queryset(self):
qs = super(AccountApiMixin, self).get_queryset()
return qs.filter(account=self.request.user.account_id)
class AccountViewSet(viewsets.ModelViewSet):
model = Account
serializer_class = AccountSerializer
singleton_pk = lambda _,request: request.user.account.pk
def get_queryset(self):
qs = super(AccountViewSet, self).get_queryset()
return qs.filter(id=self.request.user.account_id)
router.register(r'accounts', AccountViewSet)

View file

@ -0,0 +1,20 @@
from django.contrib.admin import SimpleListFilter
from django.utils.translation import ugettext_lazy as _
class HasMainUserListFilter(SimpleListFilter):
""" Filter Nodes by group according to request.user """
title = _("has main user")
parameter_name = 'mainuser'
def lookups(self, request, model_admin):
return (
('True', _("Yes")),
('False', _("No")),
)
def queryset(self, request, queryset):
if self.value() == 'True':
return queryset.filter(users__isnull=False).distinct()
if self.value() == 'False':
return queryset.filter(users__isnull=True).distinct()

View file

@ -0,0 +1,55 @@
from django import forms
from django.contrib import auth
from django.utils.translation import ugettext_lazy as _
from orchestra.core.validators import validate_password
from orchestra.forms.widgets import ReadOnlyWidget
User = auth.get_user_model()
class AccountCreationForm(auth.forms.UserCreationForm):
def __init__(self, *args, **kwargs):
super(AccountCreationForm, self).__init__(*args, **kwargs)
self.fields['password1'].validators.append(validate_password)
def clean_username(self):
# Since User.username is unique, this check is redundant,
# but it sets a nicer error message than the ORM. See #13147.
username = self.cleaned_data["username"]
try:
User._default_manager.get(username=username)
except User.DoesNotExist:
return username
raise forms.ValidationError(self.error_messages['duplicate_username'])
def save(self, commit=True):
account = super(auth.forms.UserCreationForm, self).save(commit=False)
user = User(username=self.cleaned_data['username'], is_admin=True)
user.set_password(self.cleaned_data['password1'])
user.account = account
account.user = user
if commit:
user.save()
account.save()
return account
class AccountChangeForm(forms.ModelForm):
username = forms.CharField()
password = auth.forms.ReadOnlyPasswordHashField(label=_("Password"),
help_text=_("Raw passwords are not stored, so there is no way to see "
"this user's password, but you can change the password "
"using <a href=\"password/\">this form</a>."))
def __init__(self, *args, **kwargs):
super(AccountChangeForm, self).__init__(*args, **kwargs)
account = kwargs.get('instance')
self.fields['username'].widget = ReadOnlyWidget(account.user.username)
self.fields['password'].initial = account.user.password
def clean_password(self):
# Regardless of what the user provides, return the initial value.
# This is done here, rather than on the field, because the
# field does not have access to the initial value
return self.fields['password'].initial

View file

@ -0,0 +1,36 @@
from optparse import make_option
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from orchestra.apps.accounts.models import Account
from orchestra.apps.users.models import User
class Command(BaseCommand):
def __init__(self, *args, **kwargs):
super(Command, self).__init__(*args, **kwargs)
self.option_list = BaseCommand.option_list + (
make_option('--noinput', action='store_false', dest='interactive',
default=True),
make_option('--username', action='store', dest='username'),
make_option('--password', action='store', dest='password'),
make_option('--email', action='store', dest='email'),
)
option_list = BaseCommand.option_list
help = 'Used to create an initial account and its user.'
@transaction.atomic
def handle(self, *args, **options):
interactive = options.get('interactive')
if not interactive:
email = options.get('email')
username = options.get('username')
password = options.get('password')
user = User.objects.create_superuser(username, email, password, account=account,
is_main=True)
account = Account.objects.create(user=user)
user.account = account
user.save()

View file

@ -0,0 +1,14 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.management.commands import createsuperuser
from orchestra.apps.accounts.models import Account
class Command(createsuperuser.Command):
def handle(self, *args, **options):
super(Command, self).handle(*args, **options)
users = get_user_model().objects.filter()
if len(users) == 1 and not Account.objects.all().exists():
user = users[0]
user.account = Account.objects.create(user=user)
user.save()

View file

@ -0,0 +1,25 @@
from django.db import models
from django.contrib.auth import get_user_model
from django.utils.translation import ugettext_lazy as _
from . import settings
class Account(models.Model):
user = models.OneToOneField(get_user_model(), related_name='accounts')
type = models.CharField(_("type"), max_length=32, choices=settings.ACCOUNTS_TYPES,
default=settings.ACCOUNTS_DEFAULT_TYPE)
language = models.CharField(_("language"), max_length=2,
choices=settings.ACCOUNTS_LANGUAGES,
default=settings.ACCOUNTS_DEFAULT_LANGUAGE)
register_date = models.DateTimeField(_("register date"), auto_now_add=True)
comments = models.TextField(_("comments"), max_length=256, blank=True)
is_active = models.BooleanField(default=True)
def __unicode__(self):
return self.name
@property
def name(self):
self._cached_name = getattr(self, '_cached_name', self.user.username)
return self._cached_name

View file

@ -0,0 +1,17 @@
from rest_framework import serializers
from .models import Account
class AccountSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Account
fields = (
'url', 'user', 'type', 'language', 'register_date', 'is_active'
)
class AccountSerializerMixin(object):
def save_object(self, obj, **kwargs):
obj.account = self.context['request'].user.account
super(AccountSerializerMixin, self).save_object(obj, **kwargs)

View file

@ -0,0 +1,20 @@
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
ACCOUNTS_TYPES = getattr(settings, 'ACCOUNTS_TYPES', (
('INDIVIDUAL', _("Individual")),
('ASSOCIATION', _("Association")),
('COMPANY', _("Company")),
('PUBLICBODY', _("Public body")),
))
ACCOUNTS_DEFAULT_TYPE = getattr(settings, 'ACCOUNTS_DEFAULT_TYPE', 'INDIVIDUAL')
ACCOUNTS_LANGUAGES = getattr(settings, 'ACCOUNTS_LANGUAGES', (
('en', _('English')),
))
ACCOUNTS_DEFAULT_LANGUAGE = getattr(settings, 'ACCOUNTS_DEFAULT_LANGUAGE', 'en')

View file

@ -0,0 +1,20 @@
{% extends "admin/change_form.html" %}
{% load i18n admin_urls admin_static admin_modify %}
{% block object-tools-items %}
{% for service in services %}
<li>
<a href="{% url service|admin_urlname:'changelist' %}?account={{ original.pk }}" class="historylink">{{ service.verbose_name_plural|capfirst }}</a>
</li>
{% endfor %}
<li>
<a href="disable/" class="historylink">{% trans "Disable" %}</a>
</li>
<li>
{% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %}
<a href="{% add_preserved_filters history_url %}" class="historylink">{% trans "History" %}</a>
</li>
{% if has_absolute_url %}<li><a href="{{ absolute_url }}" class="viewsitelink">{% trans "View on site" %}</a></li>{% endif%}
{% endblock %}

View file

View file

@ -0,0 +1,67 @@
from django import forms
from django.contrib import admin
from orchestra.admin import AtLeastOneRequiredInlineFormSet
from orchestra.admin.utils import insertattr
from orchestra.apps.accounts.admin import AccountAdmin, AccountAdminMixin
from .filters import HasInvoiceContactListFilter
from .models import Contact, InvoiceContact
class ContactAdmin(AccountAdminMixin, admin.ModelAdmin):
list_display = (
'short_name', 'full_name', 'email', 'phone', 'phone2', 'country',
'account_link'
)
list_filter = ('email_usage',)
search_fields = (
'contact__user__username', 'short_name', 'full_name', 'phone', 'phone2',
'email'
)
admin.site.register(Contact, ContactAdmin)
class InvoiceContactInline(admin.StackedInline):
model = InvoiceContact
fields = ('name', 'address', ('city', 'zipcode'), 'country', 'vat')
def formfield_for_dbfield(self, db_field, **kwargs):
""" Make value input widget bigger """
if db_field.name == 'address':
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 2})
return super(InvoiceContactInline, self).formfield_for_dbfield(db_field, **kwargs)
class ContactInline(InvoiceContactInline):
model = Contact
formset = AtLeastOneRequiredInlineFormSet
extra = 0
fields = (
'short_name', 'full_name', 'email', 'email_usage', ('phone', 'phone2'),
'address', ('city', 'zipcode'), 'country',
)
def get_extra(self, request, obj=None, **kwargs):
return 0 if obj and obj.contacts.exists() else 1
def has_invoice(account):
try:
account.invoicecontact.get()
except InvoiceContact.DoesNotExist:
return False
return True
has_invoice.boolean = True
has_invoice.admin_order_field = 'invoicecontact'
insertattr(AccountAdmin, 'inlines', ContactInline)
insertattr(AccountAdmin, 'inlines', InvoiceContactInline)
insertattr(AccountAdmin, 'list_display', has_invoice)
insertattr(AccountAdmin, 'list_filter', HasInvoiceContactListFilter)
for field in ('contacts__short_name', 'contacts__full_name', 'contacts__phone',
'contacts__phone2', 'contacts__email'):
insertattr(AccountAdmin, 'search_fields', field)

View file

@ -0,0 +1,21 @@
from rest_framework import viewsets
from orchestra.api import router
from orchestra.apps.accounts.api import AccountApiMixin
from .models import Contact, InvoiceContact
from .serializers import ContactSerializer, InvoiceContactSerializer
class ContactViewSet(AccountApiMixin, viewsets.ModelViewSet):
model = Contact
serializer_class = ContactSerializer
class InvoiceContactViewSet(AccountApiMixin, viewsets.ModelViewSet):
model = InvoiceContact
serializer_class = InvoiceContactSerializer
router.register(r'contacts', ContactViewSet)
router.register(r'invoicecontacts', InvoiceContactViewSet)

View file

@ -0,0 +1,20 @@
from django.contrib.admin import SimpleListFilter
from django.utils.translation import ugettext_lazy as _
class HasInvoiceContactListFilter(SimpleListFilter):
""" Filter Nodes by group according to request.user """
title = _("has invoice contact")
parameter_name = 'invoice'
def lookups(self, request, model_admin):
return (
('True', _("Yes")),
('False', _("No")),
)
def queryset(self, request, queryset):
if self.value() == 'True':
return queryset.filter(invoicecontact__isnull=False)
if self.value() == 'False':
return queryset.filter(invoicecontact__isnull=True)

View file

@ -0,0 +1,41 @@
from django.db import models
from django.utils.translation import ugettext_lazy as _
from orchestra.models.fields import MultiSelectField
from . import settings
class Contact(models.Model):
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
related_name='contacts', null=True)
short_name = models.CharField(_("short name"), max_length=128)
full_name = models.CharField(_("full name"), max_length=256, blank=True)
email = models.EmailField()
email_usage = MultiSelectField(_("email usage"), max_length=256, blank=True,
choices=settings.CONTACTS_EMAIL_USAGES,
default=settings.CONTACTS_DEFAULT_EMAIL_USAGES)
phone = models.CharField(_("Phone"), max_length=32, blank=True)
phone2 = models.CharField(_("Alternative Phone"), max_length=32, blank=True)
address = models.TextField(_("address"), blank=True)
city = models.CharField(_("city"), max_length=128, blank=True,
default=settings.CONTACTS_DEFAULT_CITY)
zipcode = models.PositiveIntegerField(_("zip code"), null=True, blank=True)
country = models.CharField(_("country"), max_length=20, blank=True,
default=settings.CONTACTS_DEFAULT_COUNTRY)
def __unicode__(self):
return self.short_name
class InvoiceContact(models.Model):
account = models.OneToOneField('accounts.Account', verbose_name=_("account"),
related_name='invoicecontact')
name = models.CharField(_("name"), max_length=256)
address = models.TextField(_("address"))
city = models.CharField(_("city"), max_length=128,
default=settings.CONTACTS_DEFAULT_CITY)
zipcode = models.PositiveIntegerField(_("zip code"))
country = models.CharField(_("country"), max_length=20,
default=settings.CONTACTS_DEFAULT_COUNTRY)
vat = models.CharField(_("VAT number"), max_length=64)

View file

@ -0,0 +1,23 @@
from rest_framework import serializers
from orchestra.api.serializers import MultiSelectField
from orchestra.apps.accounts.serializers import AccountSerializerMixin
from . import settings
from .models import Contact, InvoiceContact
class ContactSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer):
email_usage = MultiSelectField(choices=settings.CONTACTS_EMAIL_USAGES)
class Meta:
model = Contact
fields = (
'url', 'short_name', 'full_name', 'email', 'email_usage', 'phone',
'phone2', 'address', 'city', 'zipcode', 'country'
)
class InvoiceContactSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer):
class Meta:
model = InvoiceContact
fields = ('url', 'name', 'address', 'city', 'zipcode', 'country', 'vat')

View file

@ -0,0 +1,24 @@
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
CONTACTS_EMAIL_USAGES = getattr(settings, 'CONTACTS_EMAIL_USAGES', (
('SUPPORT', _("Support tickets")),
('ADMIN', _("Administrative")),
('BILL', _("Billing")),
('TECH', _("Technical")),
('ADDS', _("Announcements")),
('EMERGENCY', _("Emergency contact")),
))
CONTACTS_DEFAULT_EMAIL_USAGES = getattr(settings, 'CONTACTS_DEFAULT_EMAIL_USAGES',
('SUPPORT', 'ADMIN', 'BILL', 'TECH', 'ADDS', 'EMERGENCY')
)
CONTACTS_DEFAULT_CITY = getattr(settings, 'CONTACTS_DEFAULT_CITY', 'Barcelona')
CONTACTS_DEFAULT_PROVINCE = getattr(settings, 'CONTACTS_DEFAULT_PROVINCE', 'Barcelona')
CONTACTS_DEFAULT_COUNTRY = getattr(settings, 'CONTACTS_DEFAULT_COUNTRY', 'Spain')

View file

View file

@ -0,0 +1,135 @@
from django.db import models
from django.conf.urls import patterns
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.core.urlresolvers import reverse
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ExtendedModelAdmin
from orchestra.admin.utils import link
from orchestra.apps.accounts.admin import AccountAdminMixin, SelectAccountAdminMixin
from .forms import (DatabaseUserChangeForm, DatabaseUserCreationForm,
DatabaseCreationForm)
from .models import Database, Role, DatabaseUser
class UserInline(admin.TabularInline):
model = Role
verbose_name_plural = _("Users")
readonly_fields = ('user_link',)
extra = 0
user_link = link('user')
def formfield_for_dbfield(self, db_field, **kwargs):
""" Make value input widget bigger """
if db_field.name == 'user':
users = db_field.rel.to.objects.filter(type=self.parent_object.type)
kwargs['queryset'] = users.filter(account=self.account)
return super(UserInline, self).formfield_for_dbfield(db_field, **kwargs)
class PermissionInline(AccountAdminMixin, admin.TabularInline):
model = Role
verbose_name_plural = _("Permissions")
readonly_fields = ('database_link',)
extra = 0
filter_by_account_fields = ['database']
database_link = link('database', popup=True)
def formfield_for_dbfield(self, db_field, **kwargs):
""" Make value input widget bigger """
formfield = super(PermissionInline, self).formfield_for_dbfield(db_field, **kwargs)
if db_field.name == 'database':
# Hack widget render in order to append ?account=id to the add url
db_type = self.parent_object.type
old_render = formfield.widget.render
def render(*args, **kwargs):
output = old_render(*args, **kwargs)
output = output.replace('/add/?', '/add/?type=%s&' % db_type)
return mark_safe(output)
formfield.widget.render = render
formfield.queryset = formfield.queryset.filter(type=db_type)
return formfield
class DatabaseAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
list_display = ('name', 'type', 'account_link')
list_filter = ('type',)
search_fields = ['name', 'account__user__username']
inlines = [UserInline]
add_inlines = []
change_readonly_fields = ('name', 'type')
extra = 1
fieldsets = (
(None, {
'classes': ('extrapretty',),
'fields': ('account_link', 'name', 'type'),
}),
)
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('account_link', 'name', 'type')
}),
(_("Create new user"), {
'classes': ('wide',),
'fields': ('username', 'password1', 'password2'),
}),
(_("Use existing user"), {
'classes': ('wide',),
'fields': ('user',)
}),
)
add_form = DatabaseCreationForm
def save_model(self, request, obj, form, change):
super(DatabaseAdmin, self).save_model(request, obj, form, change)
if not change:
user = form.cleaned_data['user']
if not user:
user = DatabaseUser.objects.create(
username=form.cleaned_data['username'],
type=obj.type,
account_id = obj.account.pk,
)
user.set_password(form.cleaned_data["password1"])
user.save()
Role.objects.create(database=obj, user=user, is_owner=True)
class DatabaseUserAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
list_display = ('username', 'type', 'account_link')
list_filter = ('type',)
search_fields = ['username', 'account__user__username']
form = DatabaseUserChangeForm
add_form = DatabaseUserCreationForm
change_readonly_fields = ('username', 'type')
inlines = [PermissionInline]
add_inlines = []
fieldsets = (
(None, {
'classes': ('extrapretty',),
'fields': ('account_link', 'username', 'password', 'type')
}),
)
add_fieldsets = (
(None, {
'classes': ('extrapretty',),
'fields': ('account_link', 'username', 'password1', 'password2', 'type')
}),
)
def get_urls(self):
useradmin = UserAdmin(DatabaseUser, self.admin_site)
return patterns('',
(r'^(\d+)/password/$',
self.admin_site.admin_view(useradmin.user_change_password))
) + super(DatabaseUserAdmin, self).get_urls()
admin.site.register(Database, DatabaseAdmin)
admin.site.register(DatabaseUser, DatabaseUserAdmin)

View file

@ -0,0 +1,26 @@
from rest_framework import viewsets
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.response import Response
from orchestra.api import router, SetPasswordApiMixin
from orchestra.apps.accounts.api import AccountApiMixin
from .models import Database, DatabaseUser
from .serializers import DatabaseSerializer, DatabaseUserSerializer
class DatabaseViewSet(AccountApiMixin, viewsets.ModelViewSet):
model = Database
serializer_class = DatabaseSerializer
filter_fields = ('name',)
class DatabaseUserViewSet(AccountApiMixin, SetPasswordApiMixin, viewsets.ModelViewSet):
model = DatabaseUser
serializer_class = DatabaseUserSerializer
filter_fields = ('username',)
router.register(r'databases', DatabaseViewSet)
router.register(r'databaseusers', DatabaseUserViewSet)

View file

@ -0,0 +1,60 @@
from orchestra.apps.orchestration import ServiceBackend
from . import settings
class MySQLDBBackend(ServiceBackend):
verbose_name = "MySQL database"
model = 'databases.Database'
def save(self, database):
if database.type == database.MYSQL:
context = self.get_context(database)
self.append("mysql -e 'CREATE DATABASE `%(database)s`;'" % context)
self.append("mysql -e 'GRANT ALL PRIVILEGES ON `%(database)s`.* "
" TO \"%(owner)s\"@\"%(host)s\" WITH GRANT OPTION;'" % context)
def delete(self, database):
if database.type == database.MYSQL:
context = self.get_context(database)
self.append("mysql -e 'DROP DATABASE `%(database)s`;'" % context)
def commit(self):
self.append("mysql -e 'FLUSH PRIVILEGES;'")
def get_context(self, database):
return {
'owner': database.owner.username,
'database': database.name,
'host': settings.DATABASES_DEFAULT_HOST,
}
class MySQLUserBackend(ServiceBackend):
verbose_name = "MySQL user"
model = 'databases.DatabaseUser'
def save(self, database):
if database.type == database.MYSQL:
context = self.get_context(database)
self.append("mysql -e 'CREATE USER \"%(username)s\"@\"%(host)s\";'" % context)
self.append("mysql -e 'UPDATE mysql.user SET Password=\"%(password)s\" "
" WHERE User=\"%(username)s\";'" % context)
def delete(self, database):
if database.type == database.MYSQL:
context = self.get_context(database)
self.append("mysql -e 'DROP USER \"%(username)s\"@\"%(host)s\";'" % context)
def get_context(self, database):
return {
'username': database.username,
'password': database.password,
'host': settings.DATABASES_DEFAULT_HOST,
}
class MySQLPermissionBackend(ServiceBackend):
model = 'databases.UserDatabaseRelation'
verbose_name = "MySQL permission"

View file

@ -0,0 +1,135 @@
from django import forms
from django.contrib.auth.forms import UserCreationForm, ReadOnlyPasswordHashField
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from orchestra.core.validators import validate_password
from .models import DatabaseUser, Database, Role
class DatabaseUserCreationForm(forms.ModelForm):
password1 = forms.CharField(label=_("Password"), required=False,
widget=forms.PasswordInput, validators=[validate_password])
password2 = forms.CharField(label=_("Password confirmation"), required=False,
widget=forms.PasswordInput,
help_text=_("Enter the same password as above, for verification."))
class Meta:
model = DatabaseUser
fields = ('username', 'account', 'type')
def clean_password2(self):
password1 = self.cleaned_data.get("password1")
password2 = self.cleaned_data.get("password2")
if password1 and password2 and password1 != password2:
msg = _("The two password fields didn't match.")
raise forms.ValidationError(msg)
return password2
def save(self, commit=True):
user = super(DatabaseUserCreationForm, self).save(commit=False)
user.set_password(self.cleaned_data["password1"])
if commit:
user.save()
return user
class DatabaseCreationForm(DatabaseUserCreationForm):
username = forms.RegexField(label=_("Username"), max_length=30,
required=False, regex=r'^[\w.@+-]+$',
help_text=_("Required. 30 characters or fewer. Letters, digits and "
"@/./+/-/_ only."),
error_messages={
'invalid': _("This value may contain only letters, numbers and "
"@/./+/-/_ characters.")})
user = forms.ModelChoiceField(required=False, queryset=DatabaseUser.objects)
class Meta:
model = Database
fields = ('username', 'account', 'type')
def __init__(self, *args, **kwargs):
super(DatabaseCreationForm, self).__init__(*args, **kwargs)
account_id = self.initial.get('account', None)
if account_id:
qs = self.fields['user'].queryset.filter(account=account_id)
choices = [ (u.pk, "%s (%s)" % (u, u.get_type_display())) for u in qs ]
self.fields['user'].queryset = qs
self.fields['user'].choices = [(None, '--------'),] + choices
def clean_password2(self):
username = self.cleaned_data.get('username')
password1 = self.cleaned_data.get('password1')
password2 = self.cleaned_data.get('password2')
if username and not (password1 and password2):
raise forms.ValidationError(_("Missing password"))
if password1 and password2 and password1 != password2:
msg = _("The two password fields didn't match.")
raise forms.ValidationError(msg)
return password2
def clean_user(self):
user = self.cleaned_data.get('user')
if user and user.type != self.cleaned_data.get('type'):
msg = _("Database type and user type doesn't match")
raise forms.ValidationError(msg)
return user
def clean(self):
cleaned_data = super(DatabaseCreationForm, self).clean()
if 'user' in cleaned_data and 'username' in cleaned_data:
msg = _("Use existing user or create a new one?")
if cleaned_data['user'] and self.cleaned_data['username']:
raise forms.ValidationError(msg)
elif not (cleaned_data['username'] or cleaned_data['user']):
raise forms.ValidationError(msg)
return cleaned_data
def save(self, commit=True):
db = super(DatabaseUserCreationForm, self).save(commit=False)
user = self.cleaned_data['user']
if commit:
if not user:
user = DatabaseUser(
username=self.cleaned_data['username'],
type=self.cleaned_data['type'],
)
user.set_password(self.cleaned_data["password1"])
user.save()
role, __ = Role.objects.get_or_create(database=db, user=user)
return db
class ReadOnlySQLPasswordHashField(ReadOnlyPasswordHashField):
class ReadOnlyPasswordHashWidget(forms.Widget):
def render(self, name, value, attrs):
original = ReadOnlyPasswordHashField.widget().render(name, value, attrs)
if 'Invalid' not in original:
return original
encoded = value
final_attrs = self.build_attrs(attrs)
if not encoded:
summary = mark_safe("<strong>%s</strong>" % _("No password set."))
else:
size = len(value)
summary = value[:size/2] + '*'*(size-size/2)
summary = "<strong>hash</strong>: %s" % summary
if value.startswith('*'):
summary = "<strong>algorithm</strong>: sha1_bin_hex %s" % summary
return format_html("<div>%s</div>" % summary)
widget = ReadOnlyPasswordHashWidget
class DatabaseUserChangeForm(forms.ModelForm):
password = ReadOnlySQLPasswordHashField(label=_("Password"),
help_text=_("Raw passwords are not stored, so there is no way to see "
"this user's password, but you can change the password "
"using <a href=\"password/\">this form</a>."))
class Meta:
model = DatabaseUser
def clean_password(self):
return self.initial["password"]

View file

@ -0,0 +1,91 @@
import hashlib
from django.db import models
from django.utils.translation import ugettext_lazy as _
from orchestra.core import validators, services
from . import settings
class Database(models.Model):
""" Represents a basic database for a web application """
MYSQL = 'mysql'
POSTGRESQL = 'postgresql'
name = models.CharField(_("name"), max_length=128,
validators=[validators.validate_name])
users = models.ManyToManyField('databases.DatabaseUser', verbose_name=_("users"),
through='databases.Role', related_name='users')
type = models.CharField(_("type"), max_length=32,
choices=settings.DATABASES_TYPE_CHOICES,
default=settings.DATABASES_DEFAULT_TYPE)
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
related_name='databases')
class Meta:
unique_together = ('name', 'type')
def __unicode__(self):
return "%s" % self.name
@property
def owner(self):
self.users.get(is_owner=True)
class Role(models.Model):
database = models.ForeignKey(Database, verbose_name=_("database"),
related_name='roles')
user = models.ForeignKey('databases.DatabaseUser', verbose_name=_("user"),
related_name='roles')
is_owner = models.BooleanField(_("is owener"), default=False)
class Meta:
unique_together = ('database', 'user')
def __unicode__(self):
return "%s@%s" % (self.user, self.database)
def clean(self):
if self.user.type != self.database.type:
msg = _("Database and user type doesn't match")
raise validators.ValidationError(msg)
class DatabaseUser(models.Model):
MYSQL = 'mysql'
POSTGRESQL = 'postgresql'
username = models.CharField(_("username"), max_length=128,
validators=[validators.validate_name])
password = models.CharField(_("password"), max_length=128)
type = models.CharField(_("type"), max_length=32,
choices=settings.DATABASES_TYPE_CHOICES,
default=settings.DATABASES_DEFAULT_TYPE)
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
related_name='databaseusers')
class Meta:
verbose_name_plural = _("DB users")
unique_together = ('username', 'type')
def __unicode__(self):
return self.username
def get_username(self):
return self.username
def set_password(self, password):
if self.type == self.MYSQL:
# MySQL stores sha1(sha1(password).binary).hex
binary = hashlib.sha1(password).digest()
hexdigest = hashlib.sha1(binary).hexdigest()
password = '*%s' % hexdigest.upper()
self.password = password
else:
raise TypeError("Database type '%s' not supported" % self.type)
services.register(Database)
services.register(DatabaseUser, verbose_name_plural=_("Database users"))

View file

@ -0,0 +1,40 @@
from django.forms import widgets
from django.utils.translation import ugettext, ugettext_lazy as _
from rest_framework import serializers
from orchestra.apps.accounts.serializers import AccountSerializerMixin
from orchestra.core.validators import validate_password
from .models import Database, DatabaseUser, Role
class UserSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Role
fields = ('user', 'is_owner',)
class PermissionSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Role
fields = ('database', 'is_owner',)
class DatabaseSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer):
users = UserSerializer(source='roles', many=True)
class Meta:
model = Database
fields = ('url', 'name', 'type', 'users')
class DatabaseUserSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer):
password = serializers.CharField(max_length=128, label=_('Password'),
validators=[validate_password], write_only=True,
widget=widgets.PasswordInput)
permission = PermissionSerializer(source='roles', many=True)
class Meta:
model = DatabaseUser
fields = ('url', 'username', 'password', 'type', 'permission')
write_only_fields = ('username',)

View file

@ -0,0 +1,14 @@
from django.conf import settings
DATABASES_TYPE_CHOICES = getattr(settings, 'DATABASES_TYPE_CHOICES', (
('mysql', 'MySQL'),
('postgres', 'PostgreSQL'),
))
DATABASES_DEFAULT_TYPE = getattr(settings, 'DATABASES_DEFAULT_TYPE', 'mysql')
DATABASES_DEFAULT_HOST = getattr(settings, 'DATABASES_DEFAULT_HOST', 'localhost')

View file

View file

@ -0,0 +1,125 @@
from django import forms
from django.conf.urls import patterns, url
from django.contrib import admin
from django.contrib.admin.util import unquote
from django.core.urlresolvers import reverse
from django.db.models import F
from django.template.response import TemplateResponse
from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ChangeListDefaultFilter, ExtendedModelAdmin
from orchestra.admin.utils import wrap_admin_view, link
from orchestra.apps.accounts.admin import AccountAdminMixin
from orchestra.utils import apps
from .forms import RecordInlineFormSet, DomainAdminForm
from .filters import TopDomainListFilter
from .models import Domain, Record
class RecordInline(admin.TabularInline):
model = Record
formset = RecordInlineFormSet
verbose_name_plural = _("Extra records")
class Media:
css = {
'all': ('orchestra/css/hide-inline-id.css',)
}
def formfield_for_dbfield(self, db_field, **kwargs):
""" Make value input widget bigger """
if db_field.name == 'value':
kwargs['widget'] = forms.TextInput(attrs={'size':'100'})
return super(RecordInline, self).formfield_for_dbfield(db_field, **kwargs)
class DomainInline(admin.TabularInline):
model = Domain
fields = ('domain_link',)
readonly_fields = ('domain_link',)
extra = 0
verbose_name_plural = _("Subdomains")
domain_link = link()
domain_link.short_description = _("Name")
def has_add_permission(self, *args, **kwargs):
return False
class DomainAdmin(ChangeListDefaultFilter, AccountAdminMixin, ExtendedModelAdmin):
fields = ('name', 'account')
list_display = ('structured_name', 'is_top', 'websites', 'account_link')
inlines = [RecordInline, DomainInline]
list_filter = [TopDomainListFilter]
change_readonly_fields = ('name',)
search_fields = ['name', 'account__user__username']
default_changelist_filters = (('top_domain', 'True'),)
form = DomainAdminForm
def structured_name(self, domain):
if not self.is_top(domain):
return '&nbsp;'*4 + domain.name
return domain.name
structured_name.short_description = _("name")
structured_name.allow_tags = True
structured_name.admin_order_field = 'structured_name'
def is_top(self, domain):
return not bool(domain.top)
is_top.boolean = True
is_top.admin_order_field = 'top'
def websites(self, domain):
if apps.isinstalled('orchestra.apps.websites'):
webs = domain.websites.all()
if webs:
links = []
for web in webs:
url = reverse('admin:websites_website_change', args=(web.pk,))
links.append('<a href="%s">%s</a>' % (url, web.name))
return '<br>'.join(links)
return _("No website")
websites.admin_order_field = 'websites__name'
websites.short_description = _("Websites")
websites.allow_tags = True
def get_urls(self):
""" Returns the additional urls for the change view links """
urls = super(DomainAdmin, self).get_urls()
admin_site = self.admin_site
opts = self.model._meta
urls = patterns("",
url('^(\d+)/view-zone/$',
wrap_admin_view(self, self.view_zone_view),
name='domains_domain_view_zone')
) + urls
return urls
def view_zone_view(self, request, object_id):
zone = self.get_object(request, unquote(object_id))
context = {
'opts': self.model._meta,
'object': zone,
'title': _("%s zone content") % zone.origin.name
}
return TemplateResponse(request, 'admin/domains/domain/view_zone.html',
context)
def queryset(self, request):
""" Order by structured name and imporve performance """
qs = super(DomainAdmin, self).queryset(request)
qs = qs.select_related('top', 'account__user')
# qs = qs.select_related('top')
# For some reason if we do this we know for sure that join table will be called T4
__ = str(qs.query)
qs = qs.extra(
select={'structured_name': 'CONCAT(T4.name, domains_domain.name)'},
).order_by('structured_name')
if apps.isinstalled('orchestra.apps.websites'):
qs = qs.prefetch_related('websites')
return qs
admin.site.register(Domain, DomainAdmin)

View file

@ -0,0 +1,35 @@
from rest_framework import viewsets
from rest_framework.decorators import link
from rest_framework.response import Response
from orchestra.api import router, collectionlink
from orchestra.apps.accounts.api import AccountApiMixin
from . import settings
from .models import Domain
from .serializers import DomainSerializer
class DomainViewSet(AccountApiMixin, viewsets.ModelViewSet):
model = Domain
serializer_class = DomainSerializer
filter_fields = ('name',)
def get_queryset(self):
qs = super(DomainViewSet, self).get_queryset()
return qs.prefetch_related('records')
@collectionlink()
def configuration(self, request):
names = ['DOMAINS_DEFAULT_A', 'DOMAINS_DEFAULT_MX', 'DOMAINS_DEFAULT_NS']
return Response({
name: getattr(settings, name, None) for name in names
})
@link()
def view_zone(self, request, pk=None):
domain = self.get_object()
return Response({'zone': domain.render_zone()})
router.register(r'domains', DomainViewSet)

View file

@ -0,0 +1,104 @@
import os
from django.utils.translation import ugettext_lazy as _
from . import settings
from orchestra.apps.orchestration import ServiceBackend
class Bind9MasterDomainBackend(ServiceBackend):
verbose_name = _("Bind9 master domain")
model = 'domains.Domain'
related_models = (
('domains.Record', 'domain__origin'),
('domains.Domain', 'origin'),
)
@classmethod
def is_main(cls, obj):
""" work around Domain.top self relationship """
if super(Bind9MasterDomainBackend, cls).is_main(obj):
return not obj.top
def save(self, domain):
context = self.get_context(domain)
domain.refresh_serial()
context['zone'] = ';; %(banner)s\n' % context
context['zone'] += domain.render_zone()
self.append("{ echo -e '%(zone)s' | diff -N -I'^;;' %(zone_path)s - ; } ||"
" { echo -e '%(zone)s' > %(zone_path)s; UPDATED=1; }" % context)
self.update_conf(context)
def update_conf(self, context):
self.append("grep '\s*zone\s*\"%(name)s\"\s*{' %(conf_path)s > /dev/null ||"
" { echo -e '%(conf)s' >> %(conf_path)s; UPDATED=1; }" % context)
for subdomain in context['subdomains']:
context['name'] = subdomain.name
self.delete_conf(context)
def delete(self, domain):
context = self.get_context(domain)
self.append('rm -f %(zone_path)s;' % context)
self.delete_conf(context)
def delete_conf(self, context):
self.append('awk -v s=%(name)s \'BEGIN {'
' RS=""; s="zone \\""s"\\""'
'} $0!~s{ print $0"\\n" }\' %(conf_path)s > %(conf_path)s.tmp'
% context)
self.append('diff -I"^\s*//" %(conf_path)s.tmp %(conf_path)s || UPDATED=1' % context)
self.append('mv %(conf_path)s.tmp %(conf_path)s' % context)
def commit(self):
""" reload bind if needed """
self.append('[[ $UPDATED == 1 ]] && service bind9 reload')
def get_context(self, domain):
context = {
'name': domain.name,
'zone_path': settings.DOMAINS_ZONE_PATH % {'name': domain.name},
'subdomains': domain.get_subdomains(),
'banner': self.get_banner(),
}
context.update({
'conf_path': settings.DOMAINS_MASTERS_PATH,
'conf': 'zone "%(name)s" {\n'
' // %(banner)s\n'
' type master;\n'
' file "%(zone_path)s";\n'
'};\n' % context
})
return context
class Bind9SlaveDomainBackend(Bind9MasterDomainBackend):
verbose_name = _("Bind9 slave domain")
related_models = (('domains.Domain', 'origin'),)
def save(self, domain):
context = self.get_context(domain)
self.update_conf(context)
def delete(self, domain):
context = self.get_context(domain)
self.delete_conf(context)
def commit(self):
""" ideally slave should be restarted after master """
self.append('[[ $UPDATED == 1 ]] && { sleep 1 && service bind9 reload; } &')
def get_context(self, domain):
context = {
'name': domain.name,
'masters': '; '.join(settings.DOMAINS_MASTERS),
'subdomains': domain.get_subdomains()
}
context.update({
'conf_path': settings.DOMAINS_SLAVES_PATH,
'conf': 'zone "%(name)s" {\n'
' type slave;\n'
' file "%(name)s";\n'
' masters { %(masters)s; };\n'
'};\n' % context
})
return context

View file

@ -0,0 +1,35 @@
from django.contrib.admin import SimpleListFilter
from django.utils.encoding import force_text
from django.utils.translation import ugettext_lazy as _
class TopDomainListFilter(SimpleListFilter):
""" Filter Nodes by group according to request.user """
title = _("Top domains")
parameter_name = 'top_domain'
def lookups(self, request, model_admin):
return (
('True', _("Top domains")),
('False', _("All")),
)
def queryset(self, request, queryset):
if self.value() == 'True':
return queryset.filter(top__isnull=True)
def choices(self, cl):
""" Enable default selection different than All """
for lookup, title in self.lookup_choices:
title = title._proxy____args[0]
selected = self.value() == force_text(lookup)
if not selected and title == "Top domains" and self.value() is None:
selected = True
# end of workaround
yield {
'selected': selected,
'query_string': cl.get_query_string({
self.parameter_name: lookup,
}, []),
'display': title,
}

View file

@ -0,0 +1,56 @@
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import ugettext_lazy as _
from . import validators
from .helpers import domain_for_validation
from .models import Domain
class DomainAdminForm(forms.ModelForm):
def clean(self):
""" inherit related top domain account, when exists """
cleaned_data = super(DomainAdminForm, self).clean()
if not cleaned_data['account']:
domain = Domain(name=cleaned_data['name'])
top = domain.get_top()
if not top:
# Fake an account to make django validation happy
Account = self.fields['account']._queryset.model
cleaned_data['account'] = Account()
msg = _("An account should be provided for top domain names")
raise ValidationError(msg)
cleaned_data['account'] = top.account
return cleaned_data
class RecordInlineFormSet(forms.models.BaseInlineFormSet):
def clean(self):
""" Checks if everything is consistent """
if any(self.errors):
return
if self.instance.name:
records = []
for form in self.forms:
data = form.cleaned_data
if data and not data['DELETE']:
records.append(data)
domain = domain_for_validation(self.instance, records)
validators.validate_zone(domain.render_zone())
class DomainIterator(forms.models.ModelChoiceIterator):
""" Group ticket owner by superusers, ticket.group and regular users """
def __init__(self, *args, **kwargs):
self.account = kwargs.pop('account')
self.domains = kwargs.pop('domains')
super(forms.models.ModelChoiceIterator, self).__init__(*args, **kwargs)
def __iter__(self):
yield ('', '---------')
account_domains = self.domains.filter(account=self.account)
account_domains = account_domains.values_list('pk', 'name')
yield (_("Account"), list(account_domains))
domains = self.domains.exclude(account=self.account)
domains = domains.values_list('pk', 'name')
yield (_("Other"), list(domains))

View file

@ -0,0 +1,24 @@
import copy
from .models import Domain, Record
def domain_for_validation(instance, records):
""" Create a fake zone in order to generate the whole zone file and check it """
domain = copy.copy(instance)
if not domain.pk:
domain.top = domain.get_top()
def get_records():
for data in records:
yield Record(type=data['type'], value=data['value'])
domain.get_records = get_records
if domain.top:
subdomains = domain.get_topsubdomains().exclude(pk=instance.pk)
domain.top.get_subdomains = lambda: list(subdomains) + [domain]
elif not domain.pk:
subdomains = []
for subdomain in Domain.objects.filter(name__endswith=domain.name):
subdomain.top = domain
subdomains.append(subdomain)
domain.get_subdomains = lambda: subdomains
return domain

View file

@ -0,0 +1,174 @@
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import ugettext_lazy as _
from orchestra.core import services
from orchestra.core.validators import (validate_ipv4_address, validate_ipv6_address,
validate_hostname, validate_ascii)
from orchestra.utils.functional import cached
from . import settings, validators, utils
class Domain(models.Model):
name = models.CharField(_("name"), max_length=256, unique=True,
validators=[validate_hostname, validators.validate_allowed_domain])
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
related_name='domains', blank=True)
top = models.ForeignKey('domains.Domain', null=True, related_name='subdomains')
serial = models.IntegerField(_("serial"), default=utils.generate_zone_serial,
help_text=_("Serial number"))
def __unicode__(self):
return self.name
@property
@cached
def origin(self):
return self.top or self
def get_records(self):
""" proxy method, needed for input validation """
return self.records.all()
def get_topsubdomains(self):
""" proxy method, needed for input validation """
return self.origin.subdomains.all()
def get_subdomains(self):
return self.get_topsubdomains().filter(name__regex=r'.%s$' % self.name)
def render_zone(self):
origin = self.origin
zone = origin.render_records()
for subdomain in origin.get_topsubdomains():
zone += subdomain.render_records()
return zone
def refresh_serial(self):
""" Increases the domain serial number by one """
serial = utils.generate_zone_serial()
if serial <= self.serial:
num = int(str(self.serial)[8:]) + 1
if num >= 99:
raise ValueError('No more serial numbers for today')
serial = str(self.serial)[:8] + '%.2d' % num
serial = int(serial)
self.serial = serial
self.save()
def render_records(self):
types = {}
records = []
for record in self.get_records():
types[record.type] = True
if record.type == record.SOA:
# Update serial and insert at 0
value = record.value.split()
value[2] = str(self.serial)
records.insert(0, (record.SOA, ' '.join(value)))
else:
records.append((record.type, record.value))
if not self.top:
if Record.NS not in types:
for ns in settings.DOMAINS_DEFAULT_NS:
records.append((Record.NS, ns))
if Record.SOA not in types:
soa = [
"%s." % settings.DOMAINS_DEFAULT_NAME_SERVER,
utils.format_hostmaster(settings.DOMAINS_DEFAULT_HOSTMASTER),
str(self.serial),
settings.DOMAINS_DEFAULT_REFRESH,
settings.DOMAINS_DEFAULT_RETRY,
settings.DOMAINS_DEFAULT_EXPIRATION,
settings.DOMAINS_DEFAULT_MIN_CACHING_TIME
]
records.insert(0, (Record.SOA, ' '.join(soa)))
no_cname = Record.CNAME not in types
if Record.MX not in types and no_cname:
for mx in settings.DOMAINS_DEFAULT_MX:
records.append((Record.MX, mx))
if (Record.A not in types and Record.AAAA not in types) and no_cname:
records.append((Record.A, settings.DOMAINS_DEFAULT_A))
result = ''
for type, value in records:
name = '%s.%s' % (self.name, ' '*(37-len(self.name)))
type = '%s %s' % (type, ' '*(7-len(type)))
result += '%s IN %s %s\n' % (name, type, value)
return result
def save(self, *args, **kwargs):
""" create top relation """
update = False
if not self.pk:
top = self.get_top()
if top:
self.top = top
else:
update = True
super(Domain, self).save(*args, **kwargs)
if update:
domains = Domain.objects.exclude(pk=self.pk)
for domain in domains.filter(name__endswith=self.name):
domain.top = self
domain.save()
self.get_subdomains().update(account=self.account)
def get_top(self):
split = self.name.split('.')
top = None
for i in range(1, len(split)-1):
name = '.'.join(split[i:])
domain = Domain.objects.filter(name=name)
if domain:
top = domain.get()
return top
class Record(models.Model):
""" Represents a domain resource record """
MX = 'MX'
NS = 'NS'
CNAME = 'CNAME'
A = 'A'
AAAA = 'AAAA'
SRV = 'SRV'
TXT = 'TXT'
SOA = 'SOA'
TYPE_CHOICES = (
(MX, "MX"),
(NS, "NS"),
(CNAME, "CNAME"),
(A, _("A (IPv4 address)")),
(AAAA, _("AAAA (IPv6 address)")),
(SRV, "SRV"),
(TXT, "TXT"),
(SOA, "SOA"),
)
# TODO TTL
domain = models.ForeignKey(Domain, verbose_name=_("domain"), related_name='records')
type = models.CharField(max_length=32, choices=TYPE_CHOICES)
value = models.CharField(max_length=256)
def __unicode__(self):
return "%s IN %s %s" % (self.domain, self.type, self.value)
def clean(self):
""" validates record value based on its type """
# validate value
mapp = {
self.MX: validators.validate_mx_record,
self.NS: validators.validate_zone_label,
self.A: validate_ipv4_address,
self.AAAA: validate_ipv6_address,
self.CNAME: validators.validate_zone_label,
self.TXT: validate_ascii,
self.SRV: validators.validate_srv_record,
self.SOA: validators.validate_soa_record,
}
mapp[self.type](self.value)
services.register(Domain)

View file

@ -0,0 +1,40 @@
from django.core.exceptions import ValidationError
from rest_framework import serializers
from orchestra.apps.accounts.serializers import AccountSerializerMixin
from .helpers import domain_for_validation
from .models import Domain, Record
from . import validators
class RecordSerializer(serializers.ModelSerializer):
class Meta:
model = Record
fields = ('type', 'value')
def get_identity(self, data):
return data.get('value')
class DomainSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer):
""" Validates if this zone generates a correct zone file """
records = RecordSerializer(required=False, many=True, allow_add_remove=True)
class Meta:
model = Domain
fields = ('url', 'id', 'name', 'records')
def full_clean(self, instance):
""" Checks if everything is consistent """
instance = super(DomainSerializer, self).full_clean(instance)
if instance and instance.name:
records = self.init_data['records']
domain = domain_for_validation(instance, records)
try:
validators.validate_zone(domain.render_zone())
except ValidationError as err:
self._errors = { 'all': err.message }
return None
return instance

View file

@ -0,0 +1,51 @@
from django.conf import settings
DOMAINS_DEFAULT_NAME_SERVER = getattr(settings, 'DOMAINS_DEFAULT_NAME_SERVER',
'ns.example.com')
DOMAINS_DEFAULT_HOSTMASTER = getattr(settings, 'DOMAINS_DEFAULT_HOSTMASTER',
'hostmaster@example.com')
DOMAINS_DEFAULT_TTL = getattr(settings, 'DOMAINS_DEFAULT_TTL', '1h')
DOMAINS_DEFAULT_REFRESH = getattr(settings, 'DOMAINS_DEFAULT_REFRESH', '1d')
DOMAINS_DEFAULT_RETRY = getattr(settings, 'DOMAINS_DEFAULT_RETRY', '2h')
DOMAINS_DEFAULT_EXPIRATION = getattr(settings, 'DOMAINS_DEFAULT_EXPIRATION', '4w')
DOMAINS_DEFAULT_MIN_CACHING_TIME = getattr(settings, 'DOMAINS_DEFAULT_MIN_CACHING_TIME', '1h')
DOMAINS_ZONE_PATH = getattr(settings, 'DOMAINS_ZONE_PATH', '/etc/bind/master/%(name)s')
DOMAINS_MASTERS_PATH = getattr(settings, 'DOMAINS_MASTERS_PATH', '/etc/bind/named.conf.local')
DOMAINS_SLAVES_PATH = getattr(settings, 'DOMAINS_SLAVES_PATH', '/etc/bind/named.conf.local')
DOMAINS_MASTERS = getattr(settings, 'DOMAINS_MASTERS', ['10.0.3.13'])
DOMAINS_CHECKZONE_BIN_PATH = getattr(settings, 'DOMAINS_CHECKZONE_BIN_PATH',
'/usr/sbin/named-checkzone -i local')
DOMAINS_CHECKZONE_PATH = getattr(settings, 'DOMAINS_CHECKZONE_PATH', '/dev/shm')
DOMAINS_DEFAULT_A = getattr(settings, 'DOMAINS_DEFAULT_A', '10.0.3.13')
DOMAINS_DEFAULT_MX = getattr(settings, 'DOMAINS_DEFAULT_MX', (
'10 mail.orchestra.lan.',
'10 mail2.orchestra.lan.',
))
DOMAINS_DEFAULT_NS = getattr(settings, 'DOMAINS_DEFAULT_NS', (
'ns1.orchestra.lan.',
'ns2.orchestra.lan.',
))
DOMAINS_FORBIDDEN = getattr(settings, 'DOMAINS_FORBIDDEN',
# This setting prevents users from providing random domain names, i.e. google.com
# You can generate a 5K forbidden domains list from Alexa's top 1M
# wget http://s3.amazonaws.com/alexa-static/top-1m.csv.zip -O /tmp/top-1m.csv.zip
# unzip -p /tmp/top-1m.csv.zip | head -n 5000 | sed "s/^.*,//" > forbidden_domains.list
# '%(site_root)s/forbidden_domains.list')
'')

View file

@ -0,0 +1,15 @@
{% extends "admin/change_form.html" %}
{% load i18n admin_urls admin_static admin_modify %}
{% block object-tools-items %}
<li>
<a href="{% url 'admin:domains_domain_view_zone' original.pk %}" class="historylink">{% trans "View zone" %}</a>
</li>
<li>
{% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %}
<a href="{% add_preserved_filters history_url %}" class="historylink">{% trans "History" %}</a>
</li>
{% if has_absolute_url %}<li><a href="{{ absolute_url }}" class="viewsitelink">{% trans "View on site" %}</a></li>{% endif%}
{% endblock %}

View file

@ -0,0 +1,22 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls %}
{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} delete-confirmation{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_label|capfirst }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst|escape }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'change' object.pk|admin_urlquote %}">{{ object|truncatewords:"18" }}</a>
&rsaquo; {% trans 'View zone' %}
</div>
{% endblock %}
{% block content %}
<style> code,pre { font-size:1.1em; } </style>
<pre style="margin-left:20px;">
{{ object.render_zone }}
</pre>
{% endblock %}

View file

View file

@ -0,0 +1,299 @@
import functools
import os
import time
from selenium.webdriver.support.select import Select
from orchestra.apps.orchestration.models import Server, Route
from orchestra.utils.tests import BaseLiveServerTestCase, random_ascii
from orchestra.utils.system import run
from orchestra.apps.domains import settings, utils, backends
from orchestra.apps.domains.models import Domain, Record
run = functools.partial(run, display=False)
class DomainTestMixin(object):
def setUp(self):
super(DomainTestMixin, self).setUp()
self.MASTER_ADDR = os.environ['ORCHESTRA_DNS_MASTER_ADDR']
self.SLAVE_ADDR = os.environ['ORCHESTRA_DNS_SLAVE_ADDR']
self.domain_name = 'orchestra%s.lan' % random_ascii(10)
self.domain_records = (
(Record.MX, '10 mail.orchestra.lan.'),
(Record.MX, '20 mail2.orchestra.lan.'),
(Record.NS, 'ns1.%s.' % self.domain_name),
(Record.NS, 'ns2.%s.' % self.domain_name),
)
self.domain_update_records = (
(Record.MX, '30 mail3.orchestra.lan.'),
(Record.MX, '40 mail4.orchestra.lan.'),
(Record.NS, 'ns1.%s.' % self.domain_name),
(Record.NS, 'ns2.%s.' % self.domain_name),
)
self.subdomain1_name = 'ns1.%s' % self.domain_name
self.subdomain1_records = (
(Record.A, '%s' % self.SLAVE_ADDR),
)
self.subdomain2_name = 'ns2.%s' % self.domain_name
self.subdomain2_records = (
(Record.A, '%s' % self.MASTER_ADDR),
)
self.subdomain3_name = 'www.%s' % self.domain_name
self.subdomain3_records = (
(Record.CNAME, 'external.server.org.'),
)
self.second_domain_name = 'django%s.lan' % random_ascii(10)
def tearDown(self):
try:
self.delete(self.domain_name)
except Domain.DoesNotExist:
pass
super(DomainTestMixin, self).tearDown()
def add_route(self):
raise NotImplementedError
def add(self, domain_name, records):
raise NotImplementedError
def delete(self, domain_name, records):
raise NotImplementedError
def update(self, domain_name, records):
raise NotImplementedError
def validate_add(self, server_addr, domain_name):
context = {
'domain_name': domain_name,
'server_addr': server_addr
}
dig_soa = 'dig @%(server_addr)s %(domain_name)s SOA|grep "\sSOA\s"'
soa = run(dig_soa % context).stdout.split()
# testdomain.org. 3600 IN SOA ns.example.com. hostmaster.example.com. 2014021100 86400 7200 2419200 3600
self.assertEqual('%(domain_name)s.' % context, soa[0])
self.assertEqual('3600', soa[1])
self.assertEqual('IN', soa[2])
self.assertEqual('SOA', soa[3])
self.assertEqual('%s.' % settings.DOMAINS_DEFAULT_NAME_SERVER, soa[4])
hostmaster = utils.format_hostmaster(settings.DOMAINS_DEFAULT_HOSTMASTER)
self.assertEqual(hostmaster, soa[5])
dig_ns = 'dig @%(server_addr)s %(domain_name)s NS|grep "\sNS\s"'
name_servers = run(dig_ns % context).stdout
# testdomain.org. 3600 IN NS ns1.orchestra.lan.
ns_records = ['ns1.%s.' % self.domain_name, 'ns2.%s.' % self.domain_name]
self.assertEqual(2, len(name_servers.splitlines()))
for ns in name_servers.splitlines():
ns = ns.split()
# testdomain.org. 3600 IN NS ns1.orchestra.lan.
self.assertEqual('%(domain_name)s.' % context, ns[0])
self.assertEqual('3600', ns[1])
self.assertEqual('IN', ns[2])
self.assertEqual('NS', ns[3])
self.assertIn(ns[4], ns_records)
dig_mx = 'dig @%(server_addr)s %(domain_name)s MX|grep "\sMX\s"'
mail_servers = run(dig_mx % context).stdout
for mx in mail_servers.splitlines():
mx = mx.split()
# testdomain.org. 3600 IN NS ns1.orchestra.lan.
self.assertEqual('%(domain_name)s.' % context, mx[0])
self.assertEqual('3600', mx[1])
self.assertEqual('IN', mx[2])
self.assertEqual('MX', mx[3])
self.assertIn(mx[4], ['10', '20'])
self.assertIn(mx[5], ['mail2.orchestra.lan.', 'mail.orchestra.lan.'])
def validate_delete(self, server_addr, domain_name):
context = {
'domain_name': domain_name,
'server_addr': server_addr
}
dig_soa = 'dig @%(server_addr)s %(domain_name)s|grep "\sSOA\s"'
soa = run(dig_soa % context, error_codes=[0,1]).stdout
if soa:
soa = soa.split()
self.assertEqual('IN', soa[2])
self.assertEqual('SOA', soa[3])
self.assertNotEqual('%s.' % settings.DOMAINS_DEFAULT_NAME_SERVER, soa[4])
hostmaster = utils.format_hostmaster(settings.DOMAINS_DEFAULT_HOSTMASTER)
self.assertNotEqual(hostmaster, soa[5])
def validate_update(self, server_addr, domain_name):
domain = Domain.objects.get(name=domain_name)
context = {
'domain_name': domain_name,
'server_addr': server_addr
}
dig_soa = 'dig @%(server_addr)s %(domain_name)s SOA|grep "\sSOA\s"'
soa = run(dig_soa % context).stdout.split()
# testdomain.org. 3600 IN SOA ns.example.com. hostmaster.example.com. 2014021100 86400 7200 2419200 3600
self.assertEqual('%(domain_name)s.' % context, soa[0])
self.assertEqual('3600', soa[1])
self.assertEqual('IN', soa[2])
self.assertEqual('SOA', soa[3])
self.assertEqual('%s.' % settings.DOMAINS_DEFAULT_NAME_SERVER, soa[4])
hostmaster = utils.format_hostmaster(settings.DOMAINS_DEFAULT_HOSTMASTER)
self.assertEqual(hostmaster, soa[5])
dig_ns = 'dig @%(server_addr)s %(domain_name)s NS|grep "\sNS\s"'
name_servers = run(dig_ns % context).stdout
ns_records = ['ns1.%s.' % self.domain_name, 'ns2.%s.' % self.domain_name]
self.assertEqual(2, len(name_servers.splitlines()))
for ns in name_servers.splitlines():
ns = ns.split()
# testdomain.org. 3600 IN NS ns1.orchestra.lan.
self.assertEqual('%(domain_name)s.' % context, ns[0])
self.assertEqual('3600', ns[1])
self.assertEqual('IN', ns[2])
self.assertEqual('NS', ns[3])
self.assertIn(ns[4], ns_records)
dig_mx = 'dig @%(server_addr)s %(domain_name)s MX|grep "\sMX\s"'
mx = run(dig_mx % context).stdout.split()
# testdomain.org. 3600 IN MX 10 orchestra.lan.
self.assertEqual('%(domain_name)s.' % context, mx[0])
self.assertEqual('3600', mx[1])
self.assertEqual('IN', mx[2])
self.assertEqual('MX', mx[3])
self.assertIn(mx[4], ['30', '40'])
self.assertIn(mx[5], ['mail3.orchestra.lan.', 'mail4.orchestra.lan.'])
dig_cname = 'dig @%(server_addr)s www.%(domain_name)s CNAME|grep "\sCNAME\s"'
cname = run(dig_cname % context).stdout.split()
# testdomain.org. 3600 IN MX 10 orchestra.lan.
self.assertEqual('www.%(domain_name)s.' % context, cname[0])
self.assertEqual('3600', cname[1])
self.assertEqual('IN', cname[2])
self.assertEqual('CNAME', cname[3])
self.assertEqual('external.server.org.', cname[4])
def test_add(self):
self.add(self.subdomain1_name, self.subdomain1_records)
self.add(self.subdomain2_name, self.subdomain2_records)
self.add(self.domain_name, self.domain_records)
self.validate_add(self.MASTER_ADDR, self.domain_name)
self.validate_add(self.SLAVE_ADDR, self.domain_name)
def test_delete(self):
self.add(self.subdomain1_name, self.subdomain1_records)
self.add(self.subdomain2_name, self.subdomain2_records)
self.add(self.domain_name, self.domain_records)
self.delete(self.domain_name)
for name in [self.domain_name, self.subdomain1_name, self.subdomain2_name]:
self.validate_delete(self.MASTER_ADDR, name)
self.validate_delete(self.SLAVE_ADDR, name)
def test_update(self):
self.add(self.subdomain1_name, self.subdomain1_records)
self.add(self.subdomain2_name, self.subdomain2_records)
self.add(self.domain_name, self.domain_records)
self.update(self.domain_name, self.domain_update_records)
self.add(self.subdomain3_name, self.subdomain3_records)
self.validate_update(self.MASTER_ADDR, self.domain_name)
time.sleep(5)
self.validate_update(self.SLAVE_ADDR, self.domain_name)
def test_add_add_delete_delete(self):
self.add(self.subdomain1_name, self.subdomain1_records)
self.add(self.subdomain2_name, self.subdomain2_records)
self.add(self.domain_name, self.domain_records)
self.add(self.second_domain_name, self.domain_records)
self.delete(self.domain_name)
self.validate_add(self.MASTER_ADDR, self.second_domain_name)
self.validate_add(self.SLAVE_ADDR, self.second_domain_name)
self.delete(self.second_domain_name)
self.validate_delete(self.MASTER_ADDR, self.second_domain_name)
self.validate_delete(self.SLAVE_ADDR, self.second_domain_name)
class AdminDomainMixin(DomainTestMixin):
def setUp(self):
super(AdminDomainMixin, self).setUp()
self.add_route()
self.admin_login()
def _add_records(self, records):
self.selenium.find_element_by_link_text('Add another Record').click()
for i, record in zip(range(0, len(records)), records):
type, value = record
type_input = self.selenium.find_element_by_id('id_records-%d-type' % i)
type_select = Select(type_input)
type_select.select_by_value(type)
value_input = self.selenium.find_element_by_id('id_records-%d-value' % i)
value_input.clear()
value_input.send_keys(value)
return value_input
def add(self, domain_name, records):
url = self.live_server_url + '/admin/domains/domain/add/'
self.selenium.get(url)
name = self.selenium.find_element_by_id('id_name')
name.send_keys(domain_name)
value_input = self._add_records(records)
value_input.submit()
self.assertNotEqual(url, self.selenium.current_url)
def delete(self, domain_name):
domain = Domain.objects.get(name=domain_name)
url = self.live_server_url + '/admin/domains/domain/%d/delete/' % domain.pk
self.selenium.get(url)
form = self.selenium.find_element_by_name('post')
form.submit()
self.assertNotEqual(url, self.selenium.current_url)
def update(self, domain_name, records):
domain = Domain.objects.get(name=domain_name)
url = self.live_server_url + '/admin/domains/domain/%d/' % domain.pk
self.selenium.get(url)
value_input = self._add_records(records)
value_input.submit()
self.assertNotEqual(url, self.selenium.current_url)
class RESTDomainMixin(DomainTestMixin):
def setUp(self):
super(RESTDomainMixin, self).setUp()
self.rest_login()
self.add_route()
def add(self, domain_name, records):
records = [ dict(type=type, value=value) for type,value in records ]
self.rest.domains.create(name=domain_name, records=records)
def delete(self, domain_name):
domain = Domain.objects.get(name=domain_name)
domain = self.rest.domains.retrieve(id=domain.pk)
domain.delete()
def update(self, domain_name, records):
records = [ dict(type=type, value=value) for type,value in records ]
domains = self.rest.domains.retrieve(name=domain_name)
domain = domains.get()
domain.update(records=records)
class Bind9BackendMixin(object):
DEPENDENCIES = (
'orchestra.apps.orchestration',
)
def add_route(self):
master = Server.objects.create(name=self.MASTER_ADDR)
backend = backends.Bind9MasterDomainBackend.get_name()
Route.objects.create(backend=backend, match=True, host=master)
slave = Server.objects.create(name=self.SLAVE_ADDR)
backend = backends.Bind9SlaveDomainBackend.get_name()
Route.objects.create(backend=backend, match=True, host=slave)
class RESTBind9BackendDomainTest(Bind9BackendMixin, RESTDomainMixin, BaseLiveServerTestCase):
pass
class AdminBind9BackendDomainest(Bind9BackendMixin, AdminDomainMixin, BaseLiveServerTestCase):
pass

View file

@ -0,0 +1,18 @@
from django.db import IntegrityError, transaction
from django.test import TestCase
from ..models import Domain
class DomainTests(TestCase):
def setUp(self):
self.domain = Domain.objects.create(name='rostrepalid.org')
Domain.objects.create(name='www.rostrepalid.org')
Domain.objects.create(name='mail.rostrepalid.org')
def test_top_relation(self):
self.assertEqual(2, len(self.domain.subdomains.all()))
def test_render_zone(self):
print self.domain.render_zone()

View file

@ -0,0 +1,26 @@
import datetime
def generate_zone_serial():
today = datetime.date.today()
return int("%.4d%.2d%.2d%.2d" % (today.year, today.month, today.day, 0))
def format_hostmaster(hostmaster):
"""
The DNS encodes the <local-part> as a single label, and encodes the
<mail-domain> as a domain name. The single label from the <local-part>
is prefaced to the domain name from <mail-domain> to form the domain
name corresponding to the mailbox. Thus the mailbox HOSTMASTER@SRI-
NIC.ARPA is mapped into the domain name HOSTMASTER.SRI-NIC.ARPA. If the
<local-part> contains dots or other special characters, its
representation in a master file will require the use of backslash
quoting to ensure that the domain name is properly encoded. For
example, the mailbox Action.domains@ISI.EDU would be represented as
Action\.domains.ISI.EDU.
http://www.ietf.org/rfc/rfc1035.txt
"""
name, domain = hostmaster.split('@')
if '.' in name:
name = name.replace('.', '\.')
return "%s.%s." % (name, domain)

View file

@ -0,0 +1,108 @@
import os
import re
from django.core.exceptions import ValidationError
from django.utils.translation import ugettext_lazy as _
from orchestra.utils import paths
from orchestra.utils.system import run
from . import settings
def validate_allowed_domain(value):
context = {
'site_root': paths.get_site_root()
}
fname = settings.DOMAINS_FORBIDDEN
if fname:
fname = fname % context
with open(fname, 'r') as forbidden:
for domain in forbidden.readlines():
if re.match(r'^(.*\.)*%s$' % domain.strip(), value):
raise ValidationError(_("This domain name is not allowed"))
def validate_zone_interval(value):
try:
int(value)
except ValueError:
value, magnitude = value[:-1], value[-1]
if magnitude not in ('s', 'm', 'h', 'd', 'w') or not value.isdigit():
msg = _("%s is not an appropiate zone interval value") % value
raise ValidationError(msg)
def validate_zone_label(value):
"""
http://www.ietf.org/rfc/rfc1035.txt
The labels must follow the rules for ARPANET host names. They must
start with a letter, end with a letter or digit, and have as interior
characters only letters, digits, and hyphen. There are also some
restrictions on the length. Labels must be 63 characters or less.
"""
if not re.match(r'^[a-z][\.\-0-9a-z]*[\.0-9a-z]$', value):
msg = _("Labels must start with a letter, end with a letter or digit, "
"and have as interior characters only letters, digits, and hyphen")
raise ValidationError(msg)
if not value.endswith('.'):
msg = _("Use a fully expanded domain name ending with a dot")
raise ValidationError(msg)
if len(value) > 63:
raise ValidationError(_("Labels must be 63 characters or less"))
def validate_mx_record(value):
msg = _("%s is not an appropiate MX record value") % value
value = value.split()
if len(value) == 1:
value = value[0]
elif len(value) == 2:
try:
int(value[0])
except ValueError:
raise ValidationError(msg)
value = value[1]
elif len(value) > 2:
raise ValidationError(msg)
validate_zone_label(value)
def validate_srv_record(value):
# 1 0 9 server.example.com.
msg = _("%s is not an appropiate SRV record value") % value
value = value.split()
for i in [0,1,2]:
try:
int(value[i])
except ValueError:
raise ValidationError(msg)
validate_zone_label(value[-1])
def validate_soa_record(value):
# ns1.pangea.ORG. hostmaster.pangea.ORG. 2012010401 28800 7200 604800 86400
msg = _("%s is not an appropiate SRV record value") % value
values = value.split()
if len(values) != 7:
raise ValidationError(msg)
validate_zone_label(values[0])
validate_zone_label(values[1])
for value in values[2:]:
try:
int(value)
except ValueError:
raise ValidationError(msg)
def validate_zone(zone):
""" Ultimate zone file validation using named-checkzone """
zone_name = zone.split()[0][:-1]
path = os.path.join(settings.DOMAINS_CHECKZONE_PATH, zone_name)
with open(path, 'wb') as f:
f.write(zone)
checkzone = settings.DOMAINS_CHECKZONE_BIN_PATH
check = run(' '.join([checkzone, zone_name, path]), error_codes=[0,1], display=False)
if check.return_code == 1:
errors = re.compile(r'zone.*: (.*)').findall(check.stdout)[:-1]
raise ValidationError(', '.join(errors))

View file

@ -0,0 +1 @@
REQUIRED_APPS = ['slices']

View file

@ -0,0 +1,109 @@
import sys
from django.contrib import messages
from django.db import transaction
from orchestra.admin.decorators import action_with_confirmation
from .forms import ChangeReasonForm
from .helpers import markdown_formated_changes
from .models import Queue, Ticket
def change_ticket_state_factory(action, final_state):
context = {
'action': action,
'form': ChangeReasonForm()
}
@transaction.atomic
@action_with_confirmation(action, extra_context=context)
def change_ticket_state(modeladmin, request, queryset, action=action, final_state=final_state):
form = ChangeReasonForm(request.POST)
if form.is_valid():
reason = form.cleaned_data['reason']
for ticket in queryset:
if ticket.state != final_state:
changes = {'state': (ticket.state, final_state)}
is_read = ticket.is_read_by(request.user)
getattr(ticket, action)()
modeladmin.log_change(request, ticket, "Marked as %s" % final_state.lower())
content = markdown_formated_changes(changes)
content += reason
ticket.messages.create(content=content, author=request.user)
if is_read and not ticket.is_read_by(request.user):
ticket.mark_as_read_by(request.user)
msg = "%s selected tickets are now %s." % (queryset.count(), final_state.lower())
modeladmin.message_user(request, msg)
else:
context['form'] = form
# action_with_confirmation must display form validation errors
return True
change_ticket_state.url_name = action
change_ticket_state.verbose_name = u'%s\u2026' % action
change_ticket_state.short_description = '%s selected tickets' % action.capitalize()
change_ticket_state.description = 'Mark ticket as %s.' % final_state.lower()
change_ticket_state.__name__ = action
return change_ticket_state
action_map = {
Ticket.RESOLVED: 'resolve',
Ticket.REJECTED: 'reject',
Ticket.CLOSED: 'close' }
thismodule = sys.modules[__name__]
for state, name in action_map.items():
action = change_ticket_state_factory(name, state)
setattr(thismodule, '%s_tickets' % name, action)
@transaction.atomic
def take_tickets(modeladmin, request, queryset):
for ticket in queryset:
if ticket.owner != request.user:
changes = {'owner': (ticket.owner, request.user)}
is_read = ticket.is_read_by(request.user)
ticket.take(request.user)
modeladmin.log_change(request, ticket, "Taken")
content = markdown_formated_changes(changes)
ticket.messages.create(content=content, author=request.user)
if is_read and not ticket.is_read_by(request.user):
ticket.mark_as_read_by(request.user)
msg = "%s selected tickets are now owned by %s." % (queryset.count(), request.user)
modeladmin.message_user(request, msg)
take_tickets.url_name = 'take'
take_tickets.short_description = 'Take selected tickets'
take_tickets.description = 'Make yourself owner of the ticket.'
@transaction.atomic
def mark_as_unread(modeladmin, request, queryset):
""" Mark a tickets as unread """
for ticket in queryset:
ticket.mark_as_unread_by(request.user)
msg = "%s selected tickets have been marked as unread." % queryset.count()
modeladmin.message_user(request, msg)
@transaction.atomic
def mark_as_read(modeladmin, request, queryset):
""" Mark a tickets as unread """
for ticket in queryset:
ticket.mark_as_read_by(request.user)
msg = "%s selected tickets have been marked as read." % queryset.count()
modeladmin.message_user(request, msg)
@transaction.atomic
def set_default_queue(modeladmin, request, queryset):
""" Set a queue as default issues queue """
if queryset.count() != 1:
messages.warning(request, "Please, select only one queue.")
return
Queue.objects.filter(default=True).update(default=False)
queue = queryset.get()
queue.default = True
queue.save()
modeladmin.log_change(request, queue, "Chosen as default.")
messages.info(request, "Chosen '%s' as default queue." % queue)

View file

@ -0,0 +1,337 @@
from __future__ import absolute_import
from django import forms
from django.conf.urls import patterns
from django.contrib import admin
from django.core.urlresolvers import reverse
from django.db import models
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from django.utils.html import strip_tags
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from markdown import markdown
from orchestra.admin import ChangeListDefaultFilter, ExtendedModelAdmin#, ChangeViewActions
from orchestra.admin.utils import (link, colored, wrap_admin_view, display_timesince)
from .actions import (reject_tickets, resolve_tickets, take_tickets, close_tickets,
mark_as_unread, mark_as_read, set_default_queue)
from .filters import MyTicketsListFilter, TicketStateListFilter
from .forms import MessageInlineForm, TicketForm
from .helpers import get_ticket_changes, markdown_formated_changes, filter_actions
from .models import Ticket, Queue, Message
PRIORITY_COLORS = {
Ticket.HIGH: 'red',
Ticket.MEDIUM: 'darkorange',
Ticket.LOW: 'green',
}
STATE_COLORS = {
Ticket.NEW: 'grey',
Ticket.IN_PROGRESS: 'darkorange',
Ticket.FEEDBACK: 'purple',
Ticket.RESOLVED: 'green',
Ticket.REJECTED: 'firebrick',
Ticket.CLOSED: 'grey',
}
class MessageReadOnlyInline(admin.TabularInline):
model = Message
extra = 0
can_delete = False
fields = ['content_html']
readonly_fields = ['content_html']
class Media:
css = {
'all': ('orchestra/css/hide-inline-id.css',)
}
def content_html(self, obj):
context = {
'num': obj.num,
'time': display_timesince(obj.created_on),
'author': link('author')(self, obj),
}
summary = _("#%(num)i Updated by %(author)s about %(time)s") % context
header = '<strong style="color:#666;">%s</strong><hr />' % summary
content = markdown(obj.content)
content = content.replace('>\n', '>')
return header + content
content_html.short_description = _("Content")
content_html.allow_tags = True
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
class MessageInline(admin.TabularInline):
model = Message
extra = 1
max_num = 1
form = MessageInlineForm
can_delete = False
fields = ['content']
def get_formset(self, request, obj=None, **kwargs):
""" hook request.user on the inline form """
self.form.user = request.user
return super(MessageInline, self).get_formset(request, obj, **kwargs)
def queryset(self, request):
""" Don't show any message """
qs = super(MessageInline, self).queryset(request)
return qs.none()
class TicketInline(admin.TabularInline):
fields = [
'ticket_id', 'subject', 'creator_link', 'owner_link', 'colored_state',
'colored_priority', 'created', 'last_modified'
]
readonly_fields = [
'ticket_id', 'subject', 'creator_link', 'owner_link', 'colored_state',
'colored_priority', 'created', 'last_modified'
]
model = Ticket
extra = 0
max_num = 0
creator_link = link('creator')
owner_link = link('owner')
def ticket_id(self, instance):
return mark_safe('<b>%s</b>' % link()(self, instance))
ticket_id.short_description = '#'
def colored_state(self, instance):
return colored('state', STATE_COLORS, bold=False)(instance)
colored_state.short_description = _("State")
def colored_priority(self, instance):
return colored('priority', PRIORITY_COLORS, bold=False)(instance)
colored_priority.short_description = _("Priority")
def created(self, instance):
return display_timesince(instance.created_on)
def last_modified(self, instance):
return display_timesince(instance.last_modified_on)
class TicketAdmin(ChangeListDefaultFilter, ExtendedModelAdmin): #TODO ChangeViewActions,
list_display = [
'unbold_id', 'bold_subject', 'display_creator', 'display_owner',
'display_queue', 'display_priority', 'display_state', 'last_modified'
]
list_display_links = ('unbold_id', 'bold_subject')
list_filter = [
MyTicketsListFilter, 'queue__name', 'priority', TicketStateListFilter,
]
default_changelist_filters = (
('my_tickets', lambda r: 'True' if not r.user.is_superuser else 'False'),
('state', 'OPEN')
)
date_hierarchy = 'created_on'
search_fields = [
'id', 'subject', 'creator__username', 'creator__email', 'queue__name',
'owner__username'
]
actions = [
mark_as_unread, mark_as_read, 'delete_selected', reject_tickets,
resolve_tickets, close_tickets, take_tickets
]
sudo_actions = ['delete_selected']
change_view_actions = [
resolve_tickets, close_tickets, reject_tickets, take_tickets
]
# change_form_template = "admin/orchestra/change_form.html"
form = TicketForm
add_inlines = []
inlines = [ MessageReadOnlyInline, MessageInline ]
readonly_fields = (
'display_summary', 'display_queue', 'display_owner', 'display_state',
'display_priority'
)
readonly_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('display_summary',
('display_queue', 'display_owner'),
('display_state', 'display_priority'),
'display_description')
}),
)
fieldsets = readonly_fieldsets + (
('Update', {
'classes': ('collapse', 'wide'),
'fields': ('subject',
('queue', 'owner',),
('state', 'priority'),
'description')
}),
)
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('subject',
('queue', 'owner',),
('state', 'priority'),
'description')
}),
)
class Media:
css = {
'all': ('issues/css/ticket-admin.css',)
}
js = (
'issues/js/ticket-admin.js',
)
display_creator = link('creator')
display_queue = link('queue')
display_owner = link('owner')
def display_summary(self, ticket):
author_url = link('creator')(self, ticket)
created = display_timesince(ticket.created_on)
messages = ticket.messages.order_by('-created_on')
updated = ''
if messages:
updated_on = display_timesince(messages[0].created_on)
updated_by = link('author')(self, messages[0])
updated = '. Updated by %s about %s' % (updated_by, updated_on)
msg = '<h4>Added by %s about %s%s</h4>' % (author_url, created, updated)
return mark_safe(msg)
display_summary.short_description = 'Summary'
def display_priority(self, ticket):
""" State colored for change_form """
return colored('priority', PRIORITY_COLORS, bold=False, verbose=True)(ticket)
display_priority.short_description = _("Priority")
display_priority.admin_order_field = 'priority'
def display_state(self, ticket):
""" State colored for change_form """
return colored('state', STATE_COLORS, bold=False, verbose=True)(ticket)
display_state.short_description = _("State")
display_state.admin_order_field = 'state'
def unbold_id(self, ticket):
""" Unbold id if ticket is read """
if ticket.is_read_by(self.user):
return '<span style="font-weight:normal;font-size:11px;">%s</span>' % ticket.pk
return ticket.pk
unbold_id.allow_tags = True
unbold_id.short_description = "#"
unbold_id.admin_order_field = 'id'
def bold_subject(self, ticket):
""" Bold subject when tickets are unread for request.user """
if ticket.is_read_by(self.user):
return ticket.subject
return "<strong class='unread'>%s</strong>" % ticket.subject
bold_subject.allow_tags = True
bold_subject.short_description = _("Subject")
bold_subject.admin_order_field = 'subject'
def last_modified(self, instance):
return display_timesince(instance.last_modified_on)
last_modified.admin_order_field = 'last_modified_on'
def formfield_for_dbfield(self, db_field, **kwargs):
""" Make value input widget bigger """
if db_field.name == 'subject':
kwargs['widget'] = forms.TextInput(attrs={'size':'120'})
return super(TicketAdmin, self).formfield_for_dbfield(db_field, **kwargs)
def save_model(self, request, obj, *args, **kwargs):
""" Define creator for new tickets """
if not obj.pk:
obj.creator = request.user
super(TicketAdmin, self).save_model(request, obj, *args, **kwargs)
obj.mark_as_read_by(request.user)
def get_urls(self):
""" add markdown preview url """
urls = super(TicketAdmin, self).get_urls()
my_urls = patterns('',
(r'^preview/$', wrap_admin_view(self, self.message_preview_view))
)
return my_urls + urls
def add_view(self, request, form_url='', extra_context=None):
""" Do not sow message inlines """
return super(TicketAdmin, self).add_view(request, form_url, extra_context)
def change_view(self, request, object_id, form_url='', extra_context=None):
""" Change view actions based on ticket state """
ticket = get_object_or_404(Ticket, pk=object_id)
# Change view actions based on ticket state
self.change_view_actions = filter_actions(self, ticket, request)
if request.method == 'POST':
# Hack: Include the ticket changes on the request.POST
# other approaches get really messy
changes = get_ticket_changes(self, request, ticket)
if changes:
content = markdown_formated_changes(changes)
content += request.POST[u'messages-2-0-content']
request.POST[u'messages-2-0-content'] = content
ticket.mark_as_read_by(request.user)
context = {'title': "Issue #%i - %s" % (ticket.id, ticket.subject)}
context.update(extra_context or {})
return super(TicketAdmin, self).change_view(
request, object_id, form_url, extra_context=context)
def changelist_view(self, request, extra_context=None):
# Hook user for bold_subject
self.user = request.user
return super(TicketAdmin,self).changelist_view(request, extra_context=extra_context)
def message_preview_view(self, request):
""" markdown preview render via ajax """
data = request.POST.get("data")
data_formated = markdown(strip_tags(data))
return HttpResponse(data_formated)
class QueueAdmin(admin.ModelAdmin):
# TODO notify
list_display = [
'name', 'default', 'num_tickets'
]
actions = [set_default_queue]
inlines = [TicketInline]
ordering = ['name']
class Media:
css = {
'all': ('orchestra/css/hide-inline-id.css',)
}
def num_tickets(self, queue):
num = queue.tickets.count()
url = reverse('admin:issues_ticket_changelist')
url += '?my_tickets=False&queue=%i' % queue.pk
return mark_safe('<a href="%s">%d</a>' % (url, num))
num_tickets.short_description = _("Tickets")
num_tickets.admin_order_field = 'tickets__count'
def queryset(self, request):
qs = super(QueueAdmin, self).queryset(request)
qs = qs.annotate(models.Count('tickets'))
return qs
admin.site.register(Ticket, TicketAdmin)
admin.site.register(Queue, QueueAdmin)

View file

@ -0,0 +1,43 @@
from django.contrib.admin import SimpleListFilter
from .models import Ticket
class MyTicketsListFilter(SimpleListFilter):
""" Filter tickets by created_by according to request.user """
title = 'Tickets'
parameter_name = 'my_tickets'
def lookups(self, request, model_admin):
return (
('True', 'My Tickets'),
('False', 'All'),
)
def queryset(self, request, queryset):
if self.value() == 'True':
return queryset.involved_by(request.user)
class TicketStateListFilter(SimpleListFilter):
title = 'State'
parameter_name = 'state'
def lookups(self, request, model_admin):
return (
('OPEN', "Open"),
(Ticket.NEW, "New"),
(Ticket.IN_PROGRESS, "In Progress"),
(Ticket.RESOLVED, "Resolved"),
(Ticket.FEEDBACK, "Feedback"),
(Ticket.REJECTED, "Rejected"),
(Ticket.CLOSED, "Closed"),
('False', 'All'),
)
def queryset(self, request, queryset):
if self.value() == 'OPEN':
return queryset.exclude(state__in=[Ticket.CLOSED, Ticket.REJECTED])
elif self.value() == 'False':
return queryset
return queryset.filter(state=self.value())

View file

@ -0,0 +1,106 @@
from django import forms
from django.core.urlresolvers import reverse
from django.utils.html import strip_tags
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from markdown import markdown
from orchestra.apps.users.models import User
from orchestra.forms.widgets import ReadOnlyWidget
from .models import Queue, Ticket
class MarkDownWidget(forms.Textarea):
""" MarkDown textarea widget with syntax preview """
markdown_url = '/static/issues/markdown_syntax.html'
markdown_help_text = (
'<a href="%s" onclick=\'window.open("%s", "", "resizable=yes, '
'location=no, width=300, height=640, menubar=no, status=no, scrollbars=yes"); '
'return false;\'>markdown format</a>' % (markdown_url, markdown_url)
)
markdown_help_text = 'HTML not allowed, you can use %s' % markdown_help_text
def render(self, name, value, attrs):
widget_id = attrs['id'] if attrs and 'id' in attrs else 'id_%s' % name
textarea = super(MarkDownWidget, self).render(name, value, attrs)
preview = ('<a class="load-preview" href="#" data-field="{0}">preview</a>'\
'<div id="{0}-preview" class="content-preview"></div>'.format(widget_id))
return mark_safe('<p class="help">%s<br/>%s<br/>%s</p>' % (
self.markdown_help_text, textarea, preview))
class MessageInlineForm(forms.ModelForm):
""" Add message form """
created_on = forms.CharField(label="Created On", required=False)
content = forms.CharField(widget=MarkDownWidget(), required=False)
def __init__(self, *args, **kwargs):
super(MessageInlineForm, self).__init__(*args, **kwargs)
admin_link = reverse('admin:users_user_change', args=(self.user.pk,))
self.fields['created_on'].widget = ReadOnlyWidget('')
def clean_content(self):
""" clean HTML tags """
return strip_tags(self.cleaned_data['content'])
def save(self, *args, **kwargs):
if self.instance.pk is None:
self.instance.author = self.user
return super(MessageInlineForm, self).save(*args, **kwargs)
class UsersIterator(forms.models.ModelChoiceIterator):
""" Group ticket owner by superusers, ticket.group and regular users """
def __init__(self, *args, **kwargs):
self.ticket = kwargs.pop('ticket', False)
super(forms.models.ModelChoiceIterator, self).__init__(*args, **kwargs)
def __iter__(self):
yield ('', '---------')
users = User.objects.exclude(is_active=False).order_by('name')
superusers = users.filter(is_superuser=True)
if superusers:
yield ('Operators', list(superusers.values_list('pk', 'name')))
users = users.exclude(is_superuser=True)
if users:
yield ('Other', list(users.values_list('pk', 'name')))
class TicketForm(forms.ModelForm):
display_description = forms.CharField(label=_("Description"), required=False)
description = forms.CharField(widget=MarkDownWidget(attrs={'class':'vLargeTextField'}))
class Meta:
model = Ticket
def __init__(self, *args, **kwargs):
super(TicketForm, self).__init__(*args, **kwargs)
ticket = kwargs.get('instance', False)
users = self.fields['owner'].queryset
self.fields['owner'].queryset = users.filter(is_superuser=True)
if not ticket:
# Provide default ticket queue for new ticket
try:
self.initial['queue'] = Queue.objects.get(default=True).id
except Queue.DoesNotExist:
pass
else:
description = markdown(ticket.description)
# some hacks for better line breaking
description = description.replace('>\n', '#Ha9G9-?8')
description = description.replace('\n', '<br>')
description = description.replace('#Ha9G9-?8', '>\n')
description = '<div style="padding-left: 180px;">%s</div>' % description
widget = ReadOnlyWidget(description, description)
self.fields['display_description'].widget = widget
def clean_description(self):
""" clean HTML tags """
return strip_tags(self.cleaned_data['description'])
class ChangeReasonForm(forms.Form):
reason = forms.CharField(widget=forms.Textarea(attrs={'cols': '100', 'rows': '10'}),
required=False)

View file

@ -0,0 +1,36 @@
def filter_actions(modeladmin, ticket, request):
if not hasattr(modeladmin, 'change_view_actions_backup'):
modeladmin.change_view_actions_backup = list(modeladmin.change_view_actions)
actions = modeladmin.change_view_actions_backup
if ticket.state == modeladmin.model.CLOSED:
del_actions = actions
else:
from .actions import action_map
del_actions = [action_map.get(ticket.state, None)]
if ticket.owner == request.user:
del_actions.append('take')
exclude = lambda a: not (a == action or a.url_name == action)
for action in del_actions:
actions = filter(exclude, actions)
return actions
def markdown_formated_changes(changes):
markdown = ''
for name, values in changes.items():
context = (name.capitalize(), values[0], values[1])
markdown += '* **%s** changed from _%s_ to _%s_\n' % context
return markdown + '\n'
def get_ticket_changes(modeladmin, request, ticket):
ModelForm = modeladmin.get_form(request, ticket)
form = ModelForm(request.POST, request.FILES)
changes = {}
if form.is_valid():
for attr in ['state', 'priority', 'owner', 'queue']:
old_value = getattr(ticket, attr)
new_value = form.cleaned_data[attr]
if old_value != new_value:
changes[attr] = (old_value, new_value)
return changes

View file

@ -0,0 +1,185 @@
from django.contrib.auth import get_user_model
from django.db import models
from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
from orchestra.apps.contacts import settings as contacts_settings
from orchestra.models.fields import MultiSelectField
from orchestra.utils import send_email_template
from . import settings
class Queue(models.Model):
name = models.CharField(_("name"), max_length=128, unique=True)
default = models.BooleanField(_("default"), default=False)
notify = MultiSelectField(_("notify"), max_length=256, blank=True,
choices=contacts_settings.CONTACTS_EMAIL_USAGES,
default=contacts_settings.CONTACTS_DEFAULT_EMAIL_USAGES,
help_text=_("Contacts to notify by email"))
def __unicode__(self):
return self.name
def save(self, *args, **kwargs):
""" mark as default queue if needed """
existing_default = Queue.objects.filter(default=True)
if self.default:
existing_default.update(default=False)
elif not existing_default:
self.default = True
super(Queue, self).save(*args, **kwargs)
class Ticket(models.Model):
HIGH = 'HIGH'
MEDIUM = 'MEDIUM'
LOW = 'LOW'
PRIORITIES = (
(HIGH, 'High'),
(MEDIUM, 'Medium'),
(LOW, 'Low'),
)
NEW = 'NEW'
IN_PROGRESS = 'IN_PROGRESS'
RESOLVED = 'RESOLVED'
FEEDBACK = 'FEEDBACK'
REJECTED = 'REJECTED'
CLOSED = 'CLOSED'
STATES = (
(NEW, 'New'),
(IN_PROGRESS, 'In Progress'),
(RESOLVED, 'Resolved'),
(FEEDBACK, 'Feedback'),
(REJECTED, 'Rejected'),
(CLOSED, 'Closed'),
)
creator = models.ForeignKey(get_user_model(), verbose_name=_("created by"),
related_name='tickets_created')
owner = models.ForeignKey(get_user_model(), null=True, blank=True,
related_name='tickets_owned', verbose_name=_("assigned to"))
queue = models.ForeignKey(Queue, related_name='tickets', null=True, blank=True)
subject = models.CharField(_("subject"), max_length=256)
description = models.TextField(_("description"))
priority = models.CharField(_("priority"), max_length=32, choices=PRIORITIES,
default=MEDIUM)
state = models.CharField(_("state"), max_length=32, choices=STATES, default=NEW)
created_on = models.DateTimeField(_("created on"), auto_now_add=True)
last_modified_on = models.DateTimeField(_("last modified on"), auto_now=True)
cc = models.TextField("CC", help_text=_("emails to send a carbon copy to"),
blank=True)
class Meta:
ordering = ["-last_modified_on"]
def __unicode__(self):
return unicode(self.pk)
def get_notification_emails(self):
""" Get emails of the users related to the ticket """
emails = list(settings.ISSUES_SUPPORT_EMAILS)
emails.append(self.creator.email)
if self.owner:
emails.append(self.owner.email)
for contact in self.creator.account.contacts.all():
if self.queue and set(contact.email_usage).union(set(self.queue.nofify)):
emails.append(contact.email)
for message in self.messages.distinct('author'):
emails.append(message.author.email)
return set(emails + self.get_cc_emails())
def notify(self, message=None, content=None):
""" Send an email to ticket stakeholders notifying an state update """
emails = self.get_notification_emails()
template = 'issues/ticket_notification.mail'
html_template = 'issues/ticket_notification_html.mail'
context = {
'ticket': self,
'ticket_message': message
}
send_email_template(template, context, emails, html=html_template)
def save(self, *args, **kwargs):
""" notify stakeholders of new ticket """
new_issue = not self.pk
super(Ticket, self).save(*args, **kwargs)
if new_issue:
# PK should be available for rendering the template
self.notify()
def is_involved_by(self, user):
""" returns whether user has participated or is referenced on the ticket
as owner or member of the group
"""
return Ticket.objects.filter(pk=self.pk).involved_by(user).exists()
def is_visible_by(self, user):
""" returns whether ticket is visible by user """
return Ticket.objects.filter(pk=self.pk).visible_by(user).exists()
def get_cc_emails(self):
return self.cc.split(',') if self.cc else []
def mark_as_read_by(self, user):
TicketTracker.objects.get_or_create(ticket=self, user=user)
def mark_as_unread_by(self, user):
TicketTracker.objects.filter(ticket=self, user=user).delete()
def mark_as_unread(self):
TicketTracker.objects.filter(ticket=self).delete()
def is_read_by(self, user):
return TicketTracker.objects.filter(ticket=self, user=user).exists()
def reject(self):
self.state = Ticket.REJECTED
self.save()
def resolve(self):
self.state = Ticket.RESOLVED
self.save()
def close(self):
self.state = Ticket.CLOSED
self.save()
def take(self, user):
self.owner = user
self.save()
class Message(models.Model):
ticket = models.ForeignKey('issues.Ticket', verbose_name=_("ticket"),
related_name='messages')
author = models.ForeignKey(get_user_model(), verbose_name=_("author"),
related_name='ticket_messages')
content = models.TextField(_("content"))
created_on = models.DateTimeField(_("created on"), auto_now_add=True)
def __unicode__(self):
return u"#%i" % self.id
def save(self, *args, **kwargs):
""" notify stakeholders of ticket update """
if not self.pk:
self.ticket.mark_as_unread()
self.ticket.notify(message=self)
super(Message, self).save(*args, **kwargs)
@property
def num(self):
return self.ticket.messages.filter(id__lte=self.id).count()
class TicketTracker(models.Model):
""" Keeps track of user read tickets """
ticket = models.ForeignKey(Ticket, verbose_name=_("ticket"),
related_name='trackers')
user = models.ForeignKey(get_user_model(), verbose_name=_("user"),
related_name='ticket_trackers')
class Meta:
unique_together = (('ticket', 'user'),)

View file

@ -0,0 +1,7 @@
from django.conf import settings
ISSUES_SUPPORT_EMAILS = getattr(settings, 'ISSUES_SUPPORT_EMAILS', [])
ISSUES_NOTIFY_SUPERUSERS = getattr(settings, 'ISSUES_NOTIFY_SUPERUSERS', True)

View file

@ -0,0 +1,67 @@
fieldset .field-box {
float: left;
margin-right: 20px;
width: 300px;
}
hr {
background-color: #B6B6B6;
}
h4 {
color: #666;
}
form .field-display_description p, form .field-display_description ul {
margin-left: 0;
padding-left: 12px;
}
form .field-display_description ul {
margin-left: 24px;
}
ul li {
list-style-type: disc;
padding: 0;
}
/*** messages format ***/
#messages-group {
margin-bottom: 0;
}
#messages-2-group {
margin-top: 0;
}
#messages-2-group h2, #messages-2-group thead {
display: none;
}
#id_messages-2-0-content {
width: 99%;
}
/** ticket.description preview CSS overrides **/
.content-preview {
border: 1px solid #ccc;
padding: 2px 5px;
}
.aligned .content-preview p {
margin-left: 5px;
padding-left: 0;
}
.module .content-preview ol,
.module .content-preview ul {
margin-left: 5px;
}
/** unread messages admin changelist **/
strong.unread {
display: inline-block;
padding-left: 21px;
background: url(../images/unread_ticket.gif) no-repeat left;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 B

View file

@ -0,0 +1,16 @@
(function($) {
$(document).ready(function($) {
// load markdown preview
$('.load-preview').on("click", function() {
var field = '#' + $(this).attr('data-field'),
data = {
'data': $(field).val(),
'csrfmiddlewaretoken': $('[name=csrfmiddlewaretoken]',
'#ticket_form').val(),
},
preview = field + '-preview';
$(preview).load("/admin/issues/ticket/preview/", data);
return false;
});
});
})(django.jQuery);

View file

@ -0,0 +1,30 @@
(function($) {
$(document).ready(function($) {
// visibility helper show on hover
$v = $('#id_visibility');
$v_help = $('#ticket_form .field-box.field-visibility .help')
$v.hover(
function() { $v_help.show(); },
function() { $v_help.hide(); }
);
// show subject edit field on click
$('#subject-edit').click(function() {
$('.field-box.field-subject').show();
});
// load markdown preview
$('.load-preview').on("click", function() {
var field = '#' + $(this).attr('data-field'),
data = {
'data': $(field).val(),
'csrfmiddlewaretoken': $('[name=csrfmiddlewaretoken]',
'#ticket_form').val(),
},
preview = field + '-preview';
$(preview).load("/admin/issues/ticket/preview/", data);
return false;
});
});
})(django.jQuery);

View file

@ -0,0 +1,55 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="Content-Style-Type" content="text/css" />
<title>Markdown formatting</title>
<style type="text/css">
h1 { font-family: Verdana, sans-serif; font-size: 14px; text-align: center; color: #444; }
body { font-family: Verdana, sans-serif; font-size: 12px; color: #444; }
table th { padding-top: 1em; }
table td { vertical-align: top; background-color: #f5f5f5; height: 2em; vertical-align: middle;}
table td code { font-size: 1.2em; }
table td h1 { font-size: 1.8em; text-align: left; }
table td h2 { font-size: 1.4em; text-align: left; }
table td h3 { font-size: 1.2em; text-align: left; }
table td span { background-color: red; letter-spacing: 2px; }
</style>
</head>
<body>
<h1>Markdown Syntax Quick Reference</h1>
<table width="100%">
<tr><th colspan="2">Font Styles</th></tr>
<tr><th></th><td width="50%">**Strong**</td><td width="50%"><strong>Strong</strong></td></tr>
<tr><th></th><td>_Italic_</td><td><em>Italic</em></td></tr>
<tr><th></th><td>> Quote</td><td><cite>Quote</cite></td></tr>
<tr><th></th><td>&nbsp;&nbsp;&nbsp;&nbsp; 4 or more spaces</td><td><code>Code block</code></td></tr>
<tr><th colspan="2">Break Lines</th></tr>
<tr><th></th><td>end a line with 2 or more spaces<span>&nbsp;&nbsp;</span></td><td><code>first line <br/>new line</code></td></tr>
<tr><th></th><td>type an empty line<br/><span>&nbsp;</span><br/> (or containing only spaces)</td><td><code>first line <br/>new line</code></td></tr>
<tr><th colspan="2">Lists</th></tr>
<tr><th></th><td>* Item 1<br />* Item 2</td><td><ul><li>Item 1</li><li>Item 2</li></ul></td></tr>
<tr><th></th><td>1. Item 1<br />2. Item 2</td><td><ol><li>Item 1</li><li>Item 2</li></ol></td></tr>
<tr><th colspan="2">Headings</th></tr>
<tr><th></th><td># Title 1 #</td><td><h1>Title 1</h1></td></tr>
<tr><th></th><td>## Title ##</td><td><h2>Title 2</h2></td></tr>
<tr><th colspan="2">Links</th></tr>
<tr><th></th><td>&lt;http://foo.bar&gt</td><td><a href="#">http://foo.bar</a></td></tr>
<tr><th></th><td>[link](http://foo.bar/)</td><td><a href="#">link</a></td></tr>
<tr><th></th><td>[relative link](/about/)</td><td><a href="#">relative link</a></td></tr>
</table>
<p><a href="http://daringfireball.net/projects/markdown/syntax">
Full reference of markdown syntax</a>.
</p>
</body>
</html>

View file

@ -0,0 +1,36 @@
{% if subject %}
{% if not ticket_message %}
[{{ site.name }} - Issue #{{ ticket.pk }}] ({{ ticket.get_state_display }}) {{ ticket.subject }}
{% else %}
[{{ site.name }} - Issue #{{ ticket.pk }}] {% if '**State** changed' in ticket_message.content %}({{ ticket.get_state_display }}) {% endif %}{{ ticket.subject }}
{% endif %}
{% endif %}
{% if message %}
{% if not ticket_message %}
Issue #{{ ticket.id }} has been reported by {{ ticket.created_by }}.
{% else %}
Issue #{{ ticket.id }} has been updated by {{ ticket_message.author }}.
{% autoescape off %}
{{ ticket_message.content }}
{% endautoescape %}
{% endif %}
-----------------------------------------------------------------
Issue #{{ ticket.pk }}: {{ ticket.subject }}
* Author: {{ ticket.created_by }}
* Status: {{ ticket.get_state_display }}
* Priority: {{ ticket.get_priority_display }}
* Visibility: {{ ticket.get_visibility_display }}
* Group: {% if ticket.group %}{{ ticket.group }}{% endif %}
* Assigned to: {% if ticket.owner %}{{ ticket.owner }}{% endif %}
* Queue: {{ ticket.queue }}
{% autoescape off %}
{{ ticket.description }}
{% endautoescape %}
-----------------------------------------------------------------
You have received this notification because you have either subscribed to it, or are involved in it.
To change your notification preferences, please visit: {{ site.scheme }}://{{ site.domain }}{% url 'admin:issues_ticket_change' ticket.id %}
{% endif %}

View file

@ -0,0 +1,60 @@
{% load markdown %}
{% if message %}
<html>
<head>
<style>
body {
font-family: Verdana, sans-serif;
font-size: 0.8em;
color:#484848;
}
h1, h2, h3 { font-family: "Trebuchet MS", Verdana, sans-serif; margin: 0p=
x; }
h1 { font-size: 1.2em; }
h2, h3 { font-size: 1.1em; }
a, a:link, a:visited { color: #2A5685;}
a:hover, a:active { color: #c61a1a; }
a.wiki-anchor { display: none; }
hr {
width: 100%;
height: 1px;
background: #ccc;
border: 0;
}
.footer {
font-size: 0.8em;
font-style: italic;
}
</style>
</head>
<body>
{% if not ticket_message %}
Issue #{{ ticket.id }} has been reported by {{ ticket.created_by }}.
{% else %}
Issue #{{ ticket.id }} has been updated by {{ ticket_message.author }}.
{% autoescape off %}
{{ ticket_message.content|markdown }}
{% endautoescape %}
{% endif %}
<hr />
<h1><a href="http://{{ site.domain }}{% url 'admin:issues_ticket_change' ticket.id %}">Issue #{{ ticket.pk }}: {{ ticket.subject }}</a></h1>
<ul>
<li>Author: {{ ticket.created_by }}</li>
<li>Status: {{ ticket.get_state_display }}</li>
<li>Priority: {{ ticket.get_priority_display }}</li>
<li>Visibility: {{ ticket.get_visibility_display }}</li>
<li>Group: {% if ticket.group %}{{ ticket.group }}{% endif %}</li>
<li>Assigned to: {% if ticket.owner %}{{ ticket.owner }}{% endif %}</li>
<li>Queue: {{ ticket.queue }}</li>
</ul>
{% autoescape off %}
{{ ticket.description|markdown }}
{% endautoescape %}
<hr />
<span class="footer"><p>You have received this notification because you have either subscribed to it, or are involved in it.<br />
To change your notification preferences, please click here: <a class="external" href="{{ site.scheme }}://{{ site.domain }}{% url 'admin:issues_ticket_change' ticket.id %}">{{ site.scheme }}://{{ site.domain }}{% url 'admin:issues_ticket_change' ticket.id %}</a></p></span>
</body>
</html>
{% endif %}

View file

@ -0,0 +1,16 @@
"""
This file demonstrates writing tests using the unittest module. These will pass
when you run "manage.py test".
Replace this with more appropriate tests for your application.
"""
from django.test import TestCase
class SimpleTest(TestCase):
def test_basic_addition(self):
"""
Tests that 1 + 1 always equals 2.
"""
self.assertEqual(1 + 1, 2)

View file

View file

@ -0,0 +1,60 @@
from django.contrib import admin
from django.conf.urls import patterns
from django.contrib.auth.admin import UserAdmin
from django.utils.translation import ugettext, ugettext_lazy as _
from orchestra.admin import ExtendedModelAdmin
from orchestra.admin.utils import link
from orchestra.apps.accounts.admin import SelectAccountAdminMixin
from .forms import ListCreationForm, ListChangeForm
from .models import List
class ListAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
list_display = ('name', 'address_name', 'address_domain_link', 'account_link')
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('account_link', 'name',)
}),
(_("Address"), {
'classes': ('wide',),
'fields': (('address_name', 'address_domain'),)
}),
(_("Admin"), {
'classes': ('wide',),
'fields': ('admin_email', 'password1', 'password2'),
}),
)
fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('account_link', 'name',)
}),
(_("Address"), {
'classes': ('wide',),
'fields': (('address_name', 'address_domain'),)
}),
(_("Admin"), {
'classes': ('wide',),
'fields': ('admin_email', 'password',),
}),
)
readonly_fields = ('account_link',)
change_readonly_fields = ('name',)
form = ListChangeForm
add_form = ListCreationForm
filter_by_account_fields = ['address_domain']
address_domain_link = link('address_domain', order='address_domain__name')
def get_urls(self):
useradmin = UserAdmin(List, self.admin_site)
return patterns('',
(r'^(\d+)/password/$',
self.admin_site.admin_view(useradmin.user_change_password))
) + super(ListAdmin, self).get_urls()
admin.site.register(List, ListAdmin)

View file

@ -0,0 +1,16 @@
from rest_framework import viewsets
from orchestra.api import router, SetPasswordApiMixin
from orchestra.apps.accounts.api import AccountApiMixin
from .models import List
from .serializers import ListSerializer
class ListViewSet(AccountApiMixin, SetPasswordApiMixin, viewsets.ModelViewSet):
model = List
serializer_class = ListSerializer
filter_fields = ('name',)
router.register(r'lists', ListViewSet)

View file

@ -0,0 +1,11 @@
from django.template import Template, Context
from orchestra.apps.orchestration import ServiceBackend
class MailmanBackend(ServiceBackend):
verbose_name = "Mailman"
model = 'lists.List'
def save(self, mailinglist):
pass

View file

@ -0,0 +1,51 @@
from django import forms
from django.utils.translation import ugettext_lazy as _
from orchestra.core.validators import validate_password
from orchestra.forms.widgets import ReadOnlyWidget
class CleanAddressMixin(object):
def clean_address_domain(self):
name = self.cleaned_data.get('address_name')
domain = self.cleaned_data.get('address_domain')
if name and not domain:
msg = _("Domain should be selected for provided address name")
raise forms.ValidationError(msg)
elif not name and domain:
msg = _("Address name should be provided for this selected domain")
raise forms.ValidationError(msg)
return domain
class ListCreationForm(CleanAddressMixin, forms.ModelForm):
password1 = forms.CharField(label=_("Password"),
widget=forms.PasswordInput)
password2 = forms.CharField(label=_("Password confirmation"),
widget=forms.PasswordInput,
help_text=_("Enter the same password as above, for verification."))
def __init__(self, *args, **kwargs):
super(ListAdminForm, self).__init__(*args, **kwargs)
self.fields['password1'].validators.append(validate_password)
def clean_password2(self):
password1 = self.cleaned_data.get("password1")
password2 = self.cleaned_data.get("password2")
if password1 and password2 and password1 != password2:
msg = _("The two password fields didn't match.")
raise forms.ValidationError(msg)
return password2
def save(self, commit=True):
obj = super(ListAdminForm, self).save(commit=commit)
obj.set_password(self.cleaned_data["password1"])
return obj
class ListChangeForm(CleanAddressMixin, forms.ModelForm):
password = forms.CharField(label=_("Password"),
widget=ReadOnlyWidget('<strong>Unknown password</strong>'),
help_text=_("List passwords are not stored, so there is no way to see this "
"list's password, but you can change the password using "
"<a href=\"password/\">this form</a>."))

View file

@ -0,0 +1,35 @@
from django.db import models
from django.utils.translation import ugettext_lazy as _
from orchestra.core import services
from orchestra.core.validators import validate_name
from . import settings
class List(models.Model):
name = models.CharField(_("name"), max_length=128, unique=True,
validators=[validate_name])
address_name = models.CharField(_("address name"), max_length=128,
validators=[validate_name], blank=True)
address_domain = models.ForeignKey(settings.LISTS_DOMAIN_MODEL,
verbose_name=_("address domain"), blank=True, null=True)
admin_email = models.EmailField(_("admin email"),
help_text=_("Administration email address"))
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
related_name='lists')
class Meta:
unique_together = ('address_name', 'address_domain')
def __unicode__(self):
return "%s@%s" % (self.address_name, self.address_domain)
def get_username(self):
return self.name
def set_password(self, password):
self.password = password
services.register(List)

View file

@ -0,0 +1,11 @@
from rest_framework import serializers
from orchestra.apps.accounts.serializers import AccountSerializerMixin
from .models import List
class ListSerializer(AccountSerializerMixin, serializers.ModelSerializer):
class Meta:
model = List
fields = ('name', 'address_name', 'address_domain',)

View file

@ -0,0 +1,8 @@
from django.conf import settings
# Data access
LISTS_DOMAIN_MODEL = getattr(settings, 'LISTS_DOMAIN_MODEL', 'domains.Domain')
LISTS_DEFAULT_DOMAIN = getattr(settings, 'LIST_DEFAULT_DOMAIN', 'grups.orchestra.lan')

View file

@ -0,0 +1,88 @@
# Orchestration
This module handles the management of the services controlled by Orchestra.
Orchestration module has the following pieces:
* `Operation` encapsulates an operation, storing the related object, the action and the backend
* `OperationsMiddleware` collects and executes all save and delete operations, more on [next section](#operationsmiddleware)
* `manager` it manage the execution of the operations
* `backends` defines the logic that will be executed on the servers in order to control a particular service
* `router` determines in which server an operation should be executed
* `Server` defines a server hosting services
* `methods` script execution methods, e.g. SSH
* `ScriptLog` it logs the script execution
Routes
======
This application provides support for mapping services to server machines accross the network.
It supports _routing_ based on Python expression, which means that you can efectively
control services that are distributed accross several machines. For example, different
websites that are distributed accross _n_ web servers on a _shared hosting_
environment.
### OperationsMiddleware
When enabled, `middlewares.OperationsMiddleware` automatically executes the service backends when a change on the data model occurs. The main steps that performs are:
1. Collect all `save` and `delete` model signals triggered on each HTTP request
2. Find related backends using the routing backend
3. Generate a single script per server (_unit of work_)
4. Execute the scripts on the servers
### Service Management Properties
We can identify three different characteristics regarding service management:
* **Authority**: Whether or not Orchestra is the only source of the service configuration. When Orchestra is the authority then service configuration is _completely generated_ from the Orchestra database (or services are configured to read their configuration directly from Orchestra database). Otherwise Orchestra will execute small tasks translating model changes into configuration changes, allowing manual configurations to be preserved.
* **Flow**: _push_, when Orchestra drives the execution or _pull_, when external services connects to Orchestra.
* **Execution**: _synchronous_, when the execution blocks the HTTP request, or _asynchronous_ when it doesn't. Asynchronous execution means concurrency, and concurrency scalability.
_Sorry for the bad terminology, I was not able to find more appropriate terms on the literature._
### Registry vs Synchronization vs Task
From the above management properties we can extract three main service management strategies: (a) _registry based management_, (b) _synchronization based management_ and (c) _task based management_. Orchestra provides support for all of them, it is left to you to decide which one suits your requirements better.
Following a brief description and evaluation of the tradeoffs to help on your decision making.
#### a. Registry Based Management
When Orchestra acts as a pure **configuration registry (authority)**, doing nothing more than store service's configuration on the database. The configuration is **pulled** from Orchestra by the servers themselves, so it is **asynchronous** by nature.
This strategy considers two different implementations:
- The service is configured to read the configuration directly from Orchestra database (or REST API). This approach simplifies configuration management but also can make Orchestra a single point of failure on your architecture.
- A client-side application periodically fetches the service configuration from the Orchestra database and regenerates the service configuration files. This approach is very tolerant to failures, since the services will keep on working, and the new configuration will be applied after recovering. A delay may occur until the changes are applied to the services (_eventual consistency_), but it can be mitigated by notifying the application when a relevant change occur.
#### b. Synchronization Based Management
When Orchestra is the configuration **authority** and also _the responsible of applying the changes_ on the servers (**push** flow). The configuration files are **regenerated** every time by Orchestra, deleting any existing manual configuration. This model is very consistent since it only depends on the current state of the system (_stateless_). Therefore, it makes sense to execute the synchronization operation in **asynchronous** fashion.
In contrast to registry based management, synchronization management is _fully centralized_, all the management operations are driven by Orchestra so you don't need to install nor configure anything on your servers.
#### c. Task Based Management
This model refers when Orchestra is _not the only source of configuration_. Therefore, Orchestra translates isolated data model changes directly into localized changes on the service configuration, and executing them using a **push** strategy. For example `save()` or `delete()` object-level operations may have sibling configuration management operations. In contrast to synchronization, tasks are able to preserve configuration not performed by Orchestra.
This model is intuitive, efficient and also very consistent when tasks are execute **synchronously** with the request/response cycle. However, **asynchronous** task execution can have _consistency issues_; tasks have state, and this state can be lost when:
- A failure occur while applying some changes, e.g. network error or worker crash while deleting a service
- Scripts are executed out of order, e.g. create and delete a service is applied in inverse order
In general, _synchornous execution of tasks is preferred_ over asynchornous, unless response delays are not tolerable.
##### What state does actually mean?
Lets assume you have deleted a mailbox, and Orchestra has created an script that deletes that mailbox on the mail server. However a failure has occurred and the mailbox deletion task has been lost. Since the state has also been lost it is not easy to tell what to do now in order to maintain consistency.
### Additional Notes
* The script that manage the service needs to be idempotent, i.e. the outcome of running the script is always the same, no matter how many times it is executed.
* Renaming of attributes may lead to undesirable effects, e.g. changing a database name will create a new database rather than just changing its name.
* The system does not magically perform data migrations between servers when its _route_ has changed

Some files were not shown because too many files have changed in this diff Show more