commit dddb11bf404ab95ba9b81eb3fc79a1feda967b55 Author: Marc Date: Thu May 8 16:59:35 2014 +0000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..3a8423d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.log +*.pot +*.pyc +*~ +.svn +local_settings.py diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 00000000..8afa9103 --- /dev/null +++ b/INSTALL.md @@ -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 # e.g. panel + cd + ``` + +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 +``` + diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..c684ec22 --- /dev/null +++ b/LICENSE @@ -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 . diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..b6c1788d --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,8 @@ +recursive-include orchestra * + +recursive-exclude * __pycache__ +recursive-exclude * *.py[co] +recursive-exclude * *~ +recursive-exclude * *.save +recursive-exclude * *.svg + diff --git a/README.md b/README.md new file mode 100644 index 00000000..b268ef80 --- /dev/null +++ b/README.md @@ -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 ~/orchestra + sshfs orchestra@: ~/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 . +Status API Training Shop Blog About diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..4f44b598 --- /dev/null +++ b/ROADMAP.md @@ -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 diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..40ba2b24 --- /dev/null +++ b/TODO.md @@ -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 " +* 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? diff --git a/build/pip-delete-this-directory.txt b/build/pip-delete-this-directory.txt new file mode 100644 index 00000000..c8883ea9 --- /dev/null +++ b/build/pip-delete-this-directory.txt @@ -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). diff --git a/docs/API.rst b/docs/API.rst new file mode 100644 index 00000000..228cf72e --- /dev/null +++ b/docs/API.rst @@ -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 +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 +virtual_domain_type String +zone Zone +========================== ============ ========== =========================== + +VirtualHost [application/vnd.orchestra.VirtualHost+json] +======================================================== + +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 +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 diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..2626a9cf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,153 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# 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 ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + -rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/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." diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..7b99e9cd --- /dev/null +++ b/docs/conf.py @@ -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 +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = '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' diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..4a15192a --- /dev/null +++ b/docs/index.rst @@ -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` + diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..83d5e83c --- /dev/null +++ b/docs/make.bat @@ -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 ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. 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 diff --git a/orchestra/__init__.py b/orchestra/__init__.py new file mode 100644 index 00000000..892726ce --- /dev/null +++ b/orchestra/__init__.py @@ -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) diff --git a/orchestra/admin/__init__.py b/orchestra/admin/__init__.py new file mode 100644 index 00000000..89dd130c --- /dev/null +++ b/orchestra/admin/__init__.py @@ -0,0 +1,2 @@ +from options import * +from dashboard import * diff --git a/orchestra/admin/dashboard.py b/orchestra/admin/dashboard.py new file mode 100644 index 00000000..fa6afc23 --- /dev/null +++ b/orchestra/admin/dashboard.py @@ -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() diff --git a/orchestra/admin/decorators.py b/orchestra/admin/decorators.py new file mode 100644 index 00000000..61ec215a --- /dev/null +++ b/orchestra/admin/decorators.py @@ -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: + + """ + 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 + diff --git a/orchestra/admin/html.py b/orchestra/admin/html.py new file mode 100644 index 00000000..c36322c6 --- /dev/null +++ b/orchestra/admin/html.py @@ -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('
%s
' % (style, text)) diff --git a/orchestra/admin/menu.py b/orchestra/admin/menu.py new file mode 100644 index 00000000..df218af8 --- /dev/null +++ b/orchestra/admin/menu.py @@ -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)) + ] diff --git a/orchestra/admin/options.py b/orchestra/admin/options.py new file mode 100644 index 00000000..bcd70904 --- /dev/null +++ b/orchestra/admin/options.py @@ -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.') diff --git a/orchestra/admin/utils.py b/orchestra/admin/utils.py new file mode 100644 index 00000000..8a888973 --- /dev/null +++ b/orchestra/admin/utils.py @@ -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 '%s' % (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 = '%s' % (color, value) + if bold: + colored_value = '%s' % 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("%s" % (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("%s" % (date_abs, date_rel)) diff --git a/orchestra/api/__init__.py b/orchestra/api/__init__.py new file mode 100644 index 00000000..c5895bfd --- /dev/null +++ b/orchestra/api/__init__.py @@ -0,0 +1,2 @@ +from options import * +from actions import * diff --git a/orchestra/api/actions.py b/orchestra/api/actions.py new file mode 100644 index 00000000..0a02be68 --- /dev/null +++ b/orchestra/api/actions.py @@ -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) diff --git a/orchestra/api/fields.py b/orchestra/api/fields.py new file mode 100644 index 00000000..16d42f80 --- /dev/null +++ b/orchestra/api/fields.py @@ -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 diff --git a/orchestra/api/helpers.py b/orchestra/api/helpers.py new file mode 100644 index 00000000..80b7c6a9 --- /dev/null +++ b/orchestra/api/helpers.py @@ -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) diff --git a/orchestra/api/options.py b/orchestra/api/options.py new file mode 100644 index 00000000..30c71a21 --- /dev/null +++ b/orchestra/api/options.py @@ -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')) diff --git a/orchestra/api/serializers.py b/orchestra/api/serializers.py new file mode 100644 index 00000000..817431d5 --- /dev/null +++ b/orchestra/api/serializers.py @@ -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 diff --git a/orchestra/apps/__init__.py b/orchestra/apps/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/accounts/__init__.py b/orchestra/apps/accounts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/accounts/admin.py b/orchestra/apps/accounts/admin.py new file mode 100644 index 00000000..6076dbb7 --- /dev/null +++ b/orchestra/apps/accounts/admin.py @@ -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 '%(name)s' % 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 '%s' % (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() diff --git a/orchestra/apps/accounts/api.py b/orchestra/apps/accounts/api.py new file mode 100644 index 00000000..e1b363d4 --- /dev/null +++ b/orchestra/apps/accounts/api.py @@ -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) diff --git a/orchestra/apps/accounts/filters.py b/orchestra/apps/accounts/filters.py new file mode 100644 index 00000000..84a27831 --- /dev/null +++ b/orchestra/apps/accounts/filters.py @@ -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() diff --git a/orchestra/apps/accounts/forms.py b/orchestra/apps/accounts/forms.py new file mode 100644 index 00000000..bb3eabad --- /dev/null +++ b/orchestra/apps/accounts/forms.py @@ -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 this form.")) + + 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 diff --git a/orchestra/apps/accounts/management/__init__.py b/orchestra/apps/accounts/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/accounts/management/commands/__init__.py b/orchestra/apps/accounts/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/accounts/management/commands/createinitialaccount.py b/orchestra/apps/accounts/management/commands/createinitialaccount.py new file mode 100644 index 00000000..135f7de8 --- /dev/null +++ b/orchestra/apps/accounts/management/commands/createinitialaccount.py @@ -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() + diff --git a/orchestra/apps/accounts/management/commands/createsuperuser.py b/orchestra/apps/accounts/management/commands/createsuperuser.py new file mode 100644 index 00000000..20d05244 --- /dev/null +++ b/orchestra/apps/accounts/management/commands/createsuperuser.py @@ -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() diff --git a/orchestra/apps/accounts/models.py b/orchestra/apps/accounts/models.py new file mode 100644 index 00000000..9fa42335 --- /dev/null +++ b/orchestra/apps/accounts/models.py @@ -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 diff --git a/orchestra/apps/accounts/serializers.py b/orchestra/apps/accounts/serializers.py new file mode 100644 index 00000000..8da396ba --- /dev/null +++ b/orchestra/apps/accounts/serializers.py @@ -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) diff --git a/orchestra/apps/accounts/settings.py b/orchestra/apps/accounts/settings.py new file mode 100644 index 00000000..5d82ed2e --- /dev/null +++ b/orchestra/apps/accounts/settings.py @@ -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') diff --git a/orchestra/apps/accounts/templates/admin/accounts/account/change_form.html b/orchestra/apps/accounts/templates/admin/accounts/account/change_form.html new file mode 100644 index 00000000..52598a41 --- /dev/null +++ b/orchestra/apps/accounts/templates/admin/accounts/account/change_form.html @@ -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 %} +
  • + {{ service.verbose_name_plural|capfirst }} +
  • + {% endfor %} +
  • + {% trans "Disable" %} +
  • +
  • + {% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %} + {% trans "History" %} +
  • +{% if has_absolute_url %}
  • {% trans "View on site" %}
  • {% endif%} +{% endblock %} + diff --git a/orchestra/apps/contacts/__init__.py b/orchestra/apps/contacts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/contacts/admin.py b/orchestra/apps/contacts/admin.py new file mode 100644 index 00000000..d3620143 --- /dev/null +++ b/orchestra/apps/contacts/admin.py @@ -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) diff --git a/orchestra/apps/contacts/api.py b/orchestra/apps/contacts/api.py new file mode 100644 index 00000000..29332da6 --- /dev/null +++ b/orchestra/apps/contacts/api.py @@ -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) diff --git a/orchestra/apps/contacts/filters.py b/orchestra/apps/contacts/filters.py new file mode 100644 index 00000000..eade8b81 --- /dev/null +++ b/orchestra/apps/contacts/filters.py @@ -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) diff --git a/orchestra/apps/contacts/models.py b/orchestra/apps/contacts/models.py new file mode 100644 index 00000000..517165ad --- /dev/null +++ b/orchestra/apps/contacts/models.py @@ -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) diff --git a/orchestra/apps/contacts/serializers.py b/orchestra/apps/contacts/serializers.py new file mode 100644 index 00000000..f0449823 --- /dev/null +++ b/orchestra/apps/contacts/serializers.py @@ -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') diff --git a/orchestra/apps/contacts/settings.py b/orchestra/apps/contacts/settings.py new file mode 100644 index 00000000..653d9b36 --- /dev/null +++ b/orchestra/apps/contacts/settings.py @@ -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') diff --git a/orchestra/apps/databases/__init__.py b/orchestra/apps/databases/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/databases/admin.py b/orchestra/apps/databases/admin.py new file mode 100644 index 00000000..b3771b59 --- /dev/null +++ b/orchestra/apps/databases/admin.py @@ -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) diff --git a/orchestra/apps/databases/api.py b/orchestra/apps/databases/api.py new file mode 100644 index 00000000..2cec89c4 --- /dev/null +++ b/orchestra/apps/databases/api.py @@ -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) diff --git a/orchestra/apps/databases/backends.py b/orchestra/apps/databases/backends.py new file mode 100644 index 00000000..90598afc --- /dev/null +++ b/orchestra/apps/databases/backends.py @@ -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" + diff --git a/orchestra/apps/databases/forms.py b/orchestra/apps/databases/forms.py new file mode 100644 index 00000000..1688da05 --- /dev/null +++ b/orchestra/apps/databases/forms.py @@ -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("%s" % _("No password set.")) + else: + size = len(value) + summary = value[:size/2] + '*'*(size-size/2) + summary = "hash: %s" % summary + if value.startswith('*'): + summary = "algorithm: sha1_bin_hex %s" % summary + return format_html("
    %s
    " % 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 this form.")) + + class Meta: + model = DatabaseUser + + def clean_password(self): + return self.initial["password"] diff --git a/orchestra/apps/databases/models.py b/orchestra/apps/databases/models.py new file mode 100644 index 00000000..bf9ba555 --- /dev/null +++ b/orchestra/apps/databases/models.py @@ -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")) diff --git a/orchestra/apps/databases/serializers.py b/orchestra/apps/databases/serializers.py new file mode 100644 index 00000000..5dbad818 --- /dev/null +++ b/orchestra/apps/databases/serializers.py @@ -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',) diff --git a/orchestra/apps/databases/settings.py b/orchestra/apps/databases/settings.py new file mode 100644 index 00000000..9fa3d7f4 --- /dev/null +++ b/orchestra/apps/databases/settings.py @@ -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') diff --git a/orchestra/apps/domains/__init__.py b/orchestra/apps/domains/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/domains/admin.py b/orchestra/apps/domains/admin.py new file mode 100644 index 00000000..0bbc4e20 --- /dev/null +++ b/orchestra/apps/domains/admin.py @@ -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 ' '*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('%s' % (url, web.name)) + return '
    '.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) diff --git a/orchestra/apps/domains/api.py b/orchestra/apps/domains/api.py new file mode 100644 index 00000000..989a77bc --- /dev/null +++ b/orchestra/apps/domains/api.py @@ -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) diff --git a/orchestra/apps/domains/backends.py b/orchestra/apps/domains/backends.py new file mode 100644 index 00000000..aea7206b --- /dev/null +++ b/orchestra/apps/domains/backends.py @@ -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 diff --git a/orchestra/apps/domains/filters.py b/orchestra/apps/domains/filters.py new file mode 100644 index 00000000..71d933f5 --- /dev/null +++ b/orchestra/apps/domains/filters.py @@ -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, + } diff --git a/orchestra/apps/domains/forms.py b/orchestra/apps/domains/forms.py new file mode 100644 index 00000000..19e09927 --- /dev/null +++ b/orchestra/apps/domains/forms.py @@ -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)) diff --git a/orchestra/apps/domains/helpers.py b/orchestra/apps/domains/helpers.py new file mode 100644 index 00000000..2e6eac91 --- /dev/null +++ b/orchestra/apps/domains/helpers.py @@ -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 diff --git a/orchestra/apps/domains/models.py b/orchestra/apps/domains/models.py new file mode 100644 index 00000000..21aef911 --- /dev/null +++ b/orchestra/apps/domains/models.py @@ -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) diff --git a/orchestra/apps/domains/serializers.py b/orchestra/apps/domains/serializers.py new file mode 100644 index 00000000..75550f38 --- /dev/null +++ b/orchestra/apps/domains/serializers.py @@ -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 + diff --git a/orchestra/apps/domains/settings.py b/orchestra/apps/domains/settings.py new file mode 100644 index 00000000..3c31f571 --- /dev/null +++ b/orchestra/apps/domains/settings.py @@ -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') + '') diff --git a/orchestra/apps/domains/templates/admin/domains/domain/change_form.html b/orchestra/apps/domains/templates/admin/domains/domain/change_form.html new file mode 100644 index 00000000..c14c9254 --- /dev/null +++ b/orchestra/apps/domains/templates/admin/domains/domain/change_form.html @@ -0,0 +1,15 @@ +{% extends "admin/change_form.html" %} +{% load i18n admin_urls admin_static admin_modify %} + + +{% block object-tools-items %} +
  • + {% trans "View zone" %} +
  • +
  • + {% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %} + {% trans "History" %} +
  • +{% if has_absolute_url %}
  • {% trans "View on site" %}
  • {% endif%} +{% endblock %} + diff --git a/orchestra/apps/domains/templates/admin/domains/domain/view_zone.html b/orchestra/apps/domains/templates/admin/domains/domain/view_zone.html new file mode 100644 index 00000000..838e1073 --- /dev/null +++ b/orchestra/apps/domains/templates/admin/domains/domain/view_zone.html @@ -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 %} + +{% endblock %} + +{% block content %} + +
    +{{ object.render_zone }}
    +
    +{% endblock %} + diff --git a/orchestra/apps/domains/tests/__init__.py b/orchestra/apps/domains/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/domains/tests/functional_tests/__init__.py b/orchestra/apps/domains/tests/functional_tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/domains/tests/functional_tests/tests.py b/orchestra/apps/domains/tests/functional_tests/tests.py new file mode 100644 index 00000000..b9d341d9 --- /dev/null +++ b/orchestra/apps/domains/tests/functional_tests/tests.py @@ -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 diff --git a/orchestra/apps/domains/tests/test_domains.py b/orchestra/apps/domains/tests/test_domains.py new file mode 100644 index 00000000..e370bda3 --- /dev/null +++ b/orchestra/apps/domains/tests/test_domains.py @@ -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() + diff --git a/orchestra/apps/domains/utils.py b/orchestra/apps/domains/utils.py new file mode 100644 index 00000000..a5fdcb49 --- /dev/null +++ b/orchestra/apps/domains/utils.py @@ -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 as a single label, and encodes the + as a domain name. The single label from the + is prefaced to the domain name from 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 + 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) diff --git a/orchestra/apps/domains/validators.py b/orchestra/apps/domains/validators.py new file mode 100644 index 00000000..891476f3 --- /dev/null +++ b/orchestra/apps/domains/validators.py @@ -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)) diff --git a/orchestra/apps/issues/__init__.py b/orchestra/apps/issues/__init__.py new file mode 100644 index 00000000..edea65c4 --- /dev/null +++ b/orchestra/apps/issues/__init__.py @@ -0,0 +1 @@ +REQUIRED_APPS = ['slices'] diff --git a/orchestra/apps/issues/actions.py b/orchestra/apps/issues/actions.py new file mode 100644 index 00000000..64ac89ed --- /dev/null +++ b/orchestra/apps/issues/actions.py @@ -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) diff --git a/orchestra/apps/issues/admin.py b/orchestra/apps/issues/admin.py new file mode 100644 index 00000000..7d50bdc1 --- /dev/null +++ b/orchestra/apps/issues/admin.py @@ -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 = '%s
    ' % 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('%s' % 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 = '

    Added by %s about %s%s

    ' % (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 '%s' % 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 "%s" % 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('%d' % (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) diff --git a/orchestra/apps/issues/filters.py b/orchestra/apps/issues/filters.py new file mode 100644 index 00000000..8782574d --- /dev/null +++ b/orchestra/apps/issues/filters.py @@ -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()) diff --git a/orchestra/apps/issues/forms.py b/orchestra/apps/issues/forms.py new file mode 100644 index 00000000..42984681 --- /dev/null +++ b/orchestra/apps/issues/forms.py @@ -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 = ( + 'markdown format' % (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 = ('preview'\ + '
    '.format(widget_id)) + return mark_safe('

    %s
    %s
    %s

    ' % ( + 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', '
    ') + description = description.replace('#Ha9G9-?8', '>\n') + description = '
    %s
    ' % 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) diff --git a/orchestra/apps/issues/helpers.py b/orchestra/apps/issues/helpers.py new file mode 100644 index 00000000..d15f4325 --- /dev/null +++ b/orchestra/apps/issues/helpers.py @@ -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 diff --git a/orchestra/apps/issues/models.py b/orchestra/apps/issues/models.py new file mode 100644 index 00000000..73a2a31f --- /dev/null +++ b/orchestra/apps/issues/models.py @@ -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'),) diff --git a/orchestra/apps/issues/settings.py b/orchestra/apps/issues/settings.py new file mode 100644 index 00000000..642dd767 --- /dev/null +++ b/orchestra/apps/issues/settings.py @@ -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) diff --git a/orchestra/apps/issues/static/issues/css/ticket-admin.css b/orchestra/apps/issues/static/issues/css/ticket-admin.css new file mode 100644 index 00000000..b52da12a --- /dev/null +++ b/orchestra/apps/issues/static/issues/css/ticket-admin.css @@ -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; +} + diff --git a/orchestra/apps/issues/static/issues/images/btn_edit.gif b/orchestra/apps/issues/static/issues/images/btn_edit.gif new file mode 100644 index 00000000..1a6f83c5 Binary files /dev/null and b/orchestra/apps/issues/static/issues/images/btn_edit.gif differ diff --git a/orchestra/apps/issues/static/issues/images/unread_ticket.gif b/orchestra/apps/issues/static/issues/images/unread_ticket.gif new file mode 100644 index 00000000..62bc6ffd Binary files /dev/null and b/orchestra/apps/issues/static/issues/images/unread_ticket.gif differ diff --git a/orchestra/apps/issues/static/issues/js/admin-ticket.js b/orchestra/apps/issues/static/issues/js/admin-ticket.js new file mode 100644 index 00000000..b9d392d0 --- /dev/null +++ b/orchestra/apps/issues/static/issues/js/admin-ticket.js @@ -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); diff --git a/orchestra/apps/issues/static/issues/js/ticket-admin.js b/orchestra/apps/issues/static/issues/js/ticket-admin.js new file mode 100644 index 00000000..21f72e48 --- /dev/null +++ b/orchestra/apps/issues/static/issues/js/ticket-admin.js @@ -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); diff --git a/orchestra/apps/issues/static/issues/markdown_syntax.html b/orchestra/apps/issues/static/issues/markdown_syntax.html new file mode 100644 index 00000000..04aad231 --- /dev/null +++ b/orchestra/apps/issues/static/issues/markdown_syntax.html @@ -0,0 +1,55 @@ + + + + + +Markdown formatting + + + + +

    Markdown Syntax Quick Reference

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Font Styles
    **Strong**Strong
    _Italic_Italic
    > QuoteQuote
         4 or more spacesCode block
    Break Lines
    end a line with 2 or more spaces  first line
    new line
    type an empty line
     
    (or containing only spaces)
    first line
    new line
    Lists
    * Item 1
    * Item 2
    • Item 1
    • Item 2
    1. Item 1
    2. Item 2
    1. Item 1
    2. Item 2
    Headings
    # Title 1 #

    Title 1

    ## Title ##

    Title 2

    Links
    <http://foo.bar>http://foo.bar
    [link](http://foo.bar/)link
    [relative link](/about/)relative link
    + +

    + Full reference of markdown syntax. +

    + + + diff --git a/orchestra/apps/issues/templates/issues/ticket_notification.mail b/orchestra/apps/issues/templates/issues/ticket_notification.mail new file mode 100644 index 00000000..5e329672 --- /dev/null +++ b/orchestra/apps/issues/templates/issues/ticket_notification.mail @@ -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 %} + diff --git a/orchestra/apps/issues/templates/issues/ticket_notification_html.mail b/orchestra/apps/issues/templates/issues/ticket_notification_html.mail new file mode 100644 index 00000000..8c801fc1 --- /dev/null +++ b/orchestra/apps/issues/templates/issues/ticket_notification_html.mail @@ -0,0 +1,60 @@ +{% load markdown %} + +{% 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|markdown }} +{% 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|markdown }} +{% endautoescape %} +
    +

    You have received this notification because you have either subscribed to it, or are involved in it.
    +To change your notification preferences, please click here: {{ site.scheme }}://{{ site.domain }}{% url 'admin:issues_ticket_change' ticket.id %}

    + + +{% endif %} diff --git a/orchestra/apps/issues/tests.py b/orchestra/apps/issues/tests.py new file mode 100644 index 00000000..501deb77 --- /dev/null +++ b/orchestra/apps/issues/tests.py @@ -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) diff --git a/orchestra/apps/lists/__init__.py b/orchestra/apps/lists/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/lists/admin.py b/orchestra/apps/lists/admin.py new file mode 100644 index 00000000..dfcf2e54 --- /dev/null +++ b/orchestra/apps/lists/admin.py @@ -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) diff --git a/orchestra/apps/lists/api.py b/orchestra/apps/lists/api.py new file mode 100644 index 00000000..d8061893 --- /dev/null +++ b/orchestra/apps/lists/api.py @@ -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) diff --git a/orchestra/apps/lists/backends.py b/orchestra/apps/lists/backends.py new file mode 100644 index 00000000..368780f3 --- /dev/null +++ b/orchestra/apps/lists/backends.py @@ -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 diff --git a/orchestra/apps/lists/forms.py b/orchestra/apps/lists/forms.py new file mode 100644 index 00000000..828b15bc --- /dev/null +++ b/orchestra/apps/lists/forms.py @@ -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('Unknown password'), + 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 " + "this form.")) diff --git a/orchestra/apps/lists/models.py b/orchestra/apps/lists/models.py new file mode 100644 index 00000000..410e10fd --- /dev/null +++ b/orchestra/apps/lists/models.py @@ -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) diff --git a/orchestra/apps/lists/serializers.py b/orchestra/apps/lists/serializers.py new file mode 100644 index 00000000..a92629f0 --- /dev/null +++ b/orchestra/apps/lists/serializers.py @@ -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',) diff --git a/orchestra/apps/lists/settings.py b/orchestra/apps/lists/settings.py new file mode 100644 index 00000000..46548933 --- /dev/null +++ b/orchestra/apps/lists/settings.py @@ -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') diff --git a/orchestra/apps/orchestration/README.md b/orchestra/apps/orchestration/README.md new file mode 100644 index 00000000..da635563 --- /dev/null +++ b/orchestra/apps/orchestration/README.md @@ -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 diff --git a/orchestra/apps/orchestration/__init__.py b/orchestra/apps/orchestration/__init__.py new file mode 100644 index 00000000..49bb7f9d --- /dev/null +++ b/orchestra/apps/orchestration/__init__.py @@ -0,0 +1 @@ +from .backends import ServiceBackend diff --git a/orchestra/apps/orchestration/admin.py b/orchestra/apps/orchestration/admin.py new file mode 100644 index 00000000..eb3576db --- /dev/null +++ b/orchestra/apps/orchestration/admin.py @@ -0,0 +1,125 @@ +from django.contrib import admin +from django.core.urlresolvers import reverse +from django.utils.html import escape +from django.utils.translation import ugettext_lazy as _ + +from orchestra.admin.html import monospace_format +from orchestra.admin.utils import link + +from .models import Server, Route, BackendLog, BackendOperation + + +STATE_COLORS = { + BackendLog.RECEIVED: 'darkorange', + BackendLog.TIMEOUT: 'red', + BackendLog.STARTED: 'blue', + BackendLog.SUCCESS: 'green', + BackendLog.FAILURE: 'red', + BackendLog.ERROR: 'red', + BackendLog.REVOKED: 'magenta', +} + + +class RouteAdmin(admin.ModelAdmin): + list_display = [ + 'id', 'backend', 'host', 'match', 'display_model', 'is_active' + ] + list_editable = ['backend', 'host', 'match', 'is_active'] + list_filter = ['backend', 'host', 'is_active'] + + def display_model(self, route): + try: + return route.get_backend().model + except KeyError: + return "NOT AVAILABLE" + display_model.short_description = _("model") + display_model.allow_tags = True + + +class BackendOperationInline(admin.TabularInline): + model = BackendOperation + fields = ('action', 'instance_link') + readonly_fields = ('action', 'instance_link') + extra = 0 + can_delete = False + + class Media: + css = { + 'all': ('orchestra/css/hide-inline-id.css',) + } + + def instance_link(self, operation): + try: + return link('instance')(self, operation) + except: + return _("deleted %s %s") % (operation.content_type, operation.object_id) + instance_link.allow_tags = True + instance_link.short_description = _("Instance") + + def has_add_permission(self, *args, **kwargs): + return False + + +class BackendLogAdmin(admin.ModelAdmin): + list_display = ( + 'id', 'backend', 'server_link', 'display_state', 'exit_code', 'created', + 'execution_time', + ) + list_display_links = ('id', 'backend') + list_filter = ('state', 'backend') + date_hierarchy = 'last_update' + inlines = [BackendOperationInline] + fields = [ + 'backend', 'server', 'state', 'mono_script', 'mono_stdout', 'mono_stderr', + 'mono_traceback', 'exit_code', 'task_id', 'created', 'last_update', + 'execution_time' + ] + readonly_fields = [ + 'backend', 'server', 'state', 'mono_script', 'mono_stdout', 'mono_stderr', + 'mono_traceback', 'exit_code', 'task_id', 'created', 'last_update', + 'execution_time' + ] + + def server_link(self, log): + url = reverse('admin:orchestration_server_change', args=(log.server.pk,)) + return '%s' % (url, log.server.name) + server_link.short_description = _("server") + server_link.allow_tags = True + + def display_state(self, log): + color = STATE_COLORS.get(log.state, 'grey') + return '%s' % (color, log.state) + display_state.short_description = _("state") + display_state.allow_tags = True + display_state.admin_order_field = 'state' + + def mono_script(self, log): + return monospace_format(escape(log.script)) + mono_script.short_description = _("script") + + def mono_stdout(self, log): + return monospace_format(escape(log.stdout)) + mono_stdout.short_description = _("stdout") + + def mono_stderr(self, log): + return monospace_format(escape(log.stderr)) + mono_stderr.short_description = _("stderr") + + def mono_traceback(self, log): + return monospace_format(escape(log.traceback)) + mono_traceback.short_description = _("traceback") + + def queryset(self, request): + """ Order by structured name and imporve performance """ + qs = super(BackendLogAdmin, self).queryset(request) + return qs.select_related('server') + + +class ServerAdmin(admin.ModelAdmin): + list_display = ('name', 'address', 'os') + list_filter = ('os',) + + +admin.site.register(Server, ServerAdmin) +admin.site.register(BackendLog, BackendLogAdmin) +admin.site.register(Route, RouteAdmin) diff --git a/orchestra/apps/orchestration/backends.py b/orchestra/apps/orchestration/backends.py new file mode 100644 index 00000000..2f79cabf --- /dev/null +++ b/orchestra/apps/orchestration/backends.py @@ -0,0 +1,104 @@ +from datetime import datetime +from functools import partial + +from django.utils.translation import ugettext_lazy as _ + +from orchestra.utils import plugins + +from . import methods + + +class ServiceBackend(object): + """ + Service management backend base class + + It uses the _unit of work_ design principle, which allows bulk operations to + be conviniently supported. Each backend generates the configuration for all + the changes of all modified objects, reloading the daemon just once. + """ + verbose_name = None + model = None + related_models = () # ((model, accessor__attribute),) + script_method = methods.BashSSH + function_method = methods.Python + type = 'task' # 'sync' + ignore_fields = [] + + # TODO type: 'script', execution:'task' + + __metaclass__ = plugins.PluginMount + + def __unicode__(self): + return type(self).__name__ + + def __str__(self): + return unicode(self) + + def __init__(self): + self.cmds = [] + + @classmethod + def get_name(cls): + return cls.__name__ + + @classmethod + def is_main(cls, obj): + opts = obj._meta + return cls.model == '%s.%s' % (opts.app_label, opts.object_name) + + @classmethod + def get_related(cls, obj): + opts = obj._meta + model = '%s.%s' % (opts.app_label, opts.object_name) + for rel_model, field in cls.related_models: + if rel_model == model: + related = obj + for attribute in field.split('__'): + related = getattr(related, attribute) + return related + return None + + @classmethod + def get_backends(cls): + return cls.plugins + + @classmethod + def get_choices(cls): + backends = cls.get_backends() + choices = ( (b.get_name(), b.verbose_name or b.get_name()) for b in backends ) + return sorted(choices, key=lambda e: e[1]) + + def get_banner(self): + time = datetime.now().strftime("%h %d, %Y %I:%M:%S") + return "Generated by Orchestra %s" % time + + def execute(self, server): + from .models import BackendLog + state = BackendLog.STARTED if self.cmds else BackendLog.SUCCESS + log = BackendLog.objects.create(backend=self.get_name(), state=state, server=server) + for method, cmds in self.cmds: + method(log, server, cmds) + if log.state != BackendLog.SUCCESS: + break + return log + + def append(self, *cmd): + # aggregate commands acording to its execution method + if isinstance(cmd[0], basestring): + method = self.script_method + cmd = cmd[0] + else: + method = self.function_method + cmd = partial(*cmd) + if not self.cmds or self.cmds[-1][0] != method: + self.cmds.append((method, [cmd])) + else: + self.cmds[-1][1].append(cmd) + + def commit(self): + """ + apply the configuration, usually reloading a service + reloading a service is done in a separated method in order to reload + the service once in bulk operations + """ + pass diff --git a/orchestra/apps/orchestration/helpers.py b/orchestra/apps/orchestration/helpers.py new file mode 100644 index 00000000..6196e4e8 --- /dev/null +++ b/orchestra/apps/orchestration/helpers.py @@ -0,0 +1,46 @@ +from django.contrib import messages +from django.core.mail import mail_admins +from django.utils.html import escape +from django.utils.translation import ugettext_lazy as _ + + +def send_report(method, args, log): + backend = method.im_class().get_name() + server = args[0] + subject = '[Orchestra] %s execution %s on %s' + subject = subject % (backend, log.state, server) + separator = "\n%s\n\n" % ('~ '*40,) + message = separator.join([ + "[EXIT CODE] %s" % log.exit_code, + "[STDERR]\n%s" % log.stderr, + "[STDOUT]\n%s" % log.stdout, + "[SCRIPT]\n%s" % log.script, + "[TRACEBACK]\n%s" % log.traceback, + ]) + html_message = '\n\n'.join([ + '

    Exit code %s

    ' % log.exit_code, + '

    Stderr

    ' + '
    %s
    ' % escape(log.stderr), + '

    Stdout

    ' + '
    %s
    ' % escape(log.stdout), + '

    Script

    ' + '
    %s
    ' % escape(log.script), + '

    Traceback

    ' + '
    %s
    ' % escape(log.traceback), + ]) + mail_admins(subject, message, html_message=html_message) + + +def message_user(request, logs): + total = len(logs) + successes = [ log for log in logs if log.state == log.SUCCESS ] + successes = len(successes) + errors = total-successes + if errors: + msg = 'backends have' if errors > 1 else 'backend has' + msg = _("%d out of %d {0} fail to executed".format(msg)) + messages.warning(request, msg % (errors, total)) + else: + msg = 'backends have' if successes > 1 else 'backend has' + msg = _("%d {0} been successfully executed".format(msg)) + messages.success(request, msg % successes) diff --git a/orchestra/apps/orchestration/manager.py b/orchestra/apps/orchestration/manager.py new file mode 100644 index 00000000..ff8aee30 --- /dev/null +++ b/orchestra/apps/orchestration/manager.py @@ -0,0 +1,73 @@ +import threading + +from django import db + +from . import settings +from .helpers import send_report + + +def get_router(): + module = '.'.join(settings.ORCHESTRATION_ROUTER.split('.')[:-1]) + cls = settings.ORCHESTRATION_ROUTER.split('.')[-1] + module = __import__(module, fromlist=[module]) + return getattr(module, cls) + + +def as_task(execute): + def wrapper(*args, **kwargs): + with db.transaction.commit_manually(): + log = execute(*args, **kwargs) + db.transaction.commit() + if log.state != log.SUCCESS: + send_report(execute, args, log) + return log + return wrapper + + +def close_connection(execute): + """ Threads have their own connection pool, closing it when finishing """ + # TODO rewrite as context manager + def wrapper(*args, **kwargs): + log = execute(*args, **kwargs) + db.connection.close() + # Using the wrapper function as threader messenger for the execute output + wrapper.log = log + return wrapper + + +def execute(operations): + """ generates and executes the operations on the servers """ + router = get_router() + # Generate scripts per server+backend + scripts = {} + for operation in operations: + servers = router.get_servers(operation) + for server in servers: + key = (server, operation.backend) + if key not in scripts: + scripts[key] = (operation.backend(), [operation]) + else: + scripts[key][1].append(operation) + method = getattr(scripts[key][0], operation.action) + method(operation.instance) + # Execute scripts on each server + threads = [] + executions = [] + for key, value in scripts.iteritems(): + server, __ = key + backend, operations = value + backend.commit() + execute = as_task(backend.execute) + execute = close_connection(execute) + thread = threading.Thread(target=execute, args=(server,)) + thread.start() + threads.append(thread) + executions.append((execute, operations)) + [ thread.join() for thread in threads ] + logs = [] + for execution, operations in executions: + for operation in operations: + operation.log = execution.log + operation.save() + logs.append(execution.log) + return logs diff --git a/orchestra/apps/orchestration/methods.py b/orchestra/apps/orchestration/methods.py new file mode 100644 index 00000000..8ac97a80 --- /dev/null +++ b/orchestra/apps/orchestration/methods.py @@ -0,0 +1,102 @@ +import hashlib +import json +import os +import socket +import sys +import select + +import paramiko +from celery.datastructures import ExceptionInfo + +from . import settings + + +def BashSSH(backend, log, server, cmds): + from .models import BackendLog + script = '\n\n'.join(['set -e'] + cmds + ['exit 0']) + script = script.replace('\r', '') + log.script = script + log.save() + + try: + # In order to avoid "Argument list too long" we while generate first a + # script file, then scp the escript and safely execute in remote + digest = hashlib.md5(script).hexdigest() + path = os.path.join(settings.ORCHESTRATION_TEMP_SCRIPT_PATH, digest) + with open(path, 'w') as script_file: + script_file.write(script) + # ssh connection + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + addr = server.get_address() + try: + ssh.connect(addr, username='root', + key_filename=settings.ORCHESTRATION_SSH_KEY_PATH) + except socket.error: + log.state = BackendLog.TIMEOUT + log.save() + return + transport = ssh.get_transport() + channel = transport.open_session() + + sftp = paramiko.SFTPClient.from_transport(transport) + sftp.put(path, path) + sftp.close() + os.remove(path) + + context = { + 'path': path, + 'digest': digest + } + cmd = ( + "[[ $(md5sum %(path)s|awk {'print $1'}) == %(digest)s ]] && bash %(path)s\n" + "RETURN_CODE=$?\n" +# "rm -fr %(path)s\n" + "exit $RETURN_CODE" % context + ) + channel.exec_command(cmd) + if True: # TODO if not async + log.stdout += channel.makefile('rb', -1).read().decode('utf-8') + log.stderr += channel.makefile_stderr('rb', -1).read().decode('utf-8') + else: + while True: + # Non-blocking is the secret ingridient in the async sauce + select.select([channel], [], []) + if channel.recv_ready(): + log.stdout += channel.recv(1024) + if channel.recv_stderr_ready(): + log.stderr += channel.recv_stderr(1024) + log.save() + if channel.exit_status_ready(): + break + log.exit_code = exit_code = channel.recv_exit_status() + log.state = BackendLog.SUCCESS if exit_code == 0 else BackendLog.FAILURE + channel.close() + ssh.close() + log.save() + except: + log.state = BackendLog.ERROR + log.traceback = ExceptionInfo(sys.exc_info()).traceback + log.save() + + +def Python(backend, log, server, cmds): + from .models import BackendLog + script = [ str(cmd.func.func_name) + str(cmd.args) for cmd in cmds ] + script = json.dumps(script, indent=4).replace('"', '') + log.script = '\n'.join([log.script, script]) + log.save() + stdout = '' + try: + for cmd in cmds: + result = cmd(server) + stdout += str(result) + except: + log.exit_code = 1 + log.state = BackendLog.FAILURE + log.traceback = ExceptionInfo(sys.exc_info()).traceback + else: + log.exit_code = 0 + log.state = BackendLog.SUCCESS + log.stdout += stdout + log.save() diff --git a/orchestra/apps/orchestration/middlewares.py b/orchestra/apps/orchestration/middlewares.py new file mode 100644 index 00000000..cd86cb1a --- /dev/null +++ b/orchestra/apps/orchestration/middlewares.py @@ -0,0 +1,96 @@ +import copy +from threading import local + +from django.db.models.signals import pre_delete, post_save +from django.dispatch import receiver +from django.http.response import HttpResponseServerError +from orchestra.utils.python import OrderedSet + +from .backends import ServiceBackend +from .helpers import message_user +from .models import BackendLog +from .models import BackendOperation as Operation + + +@receiver(post_save) +def post_save_collector(sender, *args, **kwargs): + if sender != BackendLog: + OperationsMiddleware.collect(Operation.SAVE, **kwargs) + +@receiver(pre_delete) +def pre_delete_collector(sender, *args, **kwargs): + if sender != BackendLog: + OperationsMiddleware.collect(Operation.DELETE, **kwargs) + + +class OperationsMiddleware(object): + """ + Stores all the operations derived from save and delete signals and executes them + at the end of the request/response cycle + """ + # Thread local is used because request object is not available on model signals + thread_locals = local() + + @classmethod + def get_pending_operations(cls): + # Check if an error poped up before OperationsMiddleware.process_request() + if hasattr(cls.thread_locals, 'request'): + request = cls.thread_locals.request + if not hasattr(request, 'pending_operations'): + request.pending_operations = OrderedSet() + return request.pending_operations + return set() + + @classmethod + def collect(cls, action, **kwargs): + """ Collects all pending operations derived from model signals """ + request = getattr(cls.thread_locals, 'request', None) + if request is None: + return + pending_operations = cls.get_pending_operations() + for backend in ServiceBackend.get_backends(): + instance = None + if backend.is_main(kwargs['instance']): + instance = kwargs['instance'] + else: + candidate = backend.get_related(kwargs['instance']) + if candidate: + delete = Operation.create(backend, candidate, Operation.DELETE) + if delete not in pending_operations: + instance = candidate + # related objects with backend.model trigger save() + action = Operation.SAVE + if instance is not None: + # Prevent creating a deleted instance by deleting existing saves + if action == Operation.DELETE: + save = Operation.create(backend, instance, Operation.SAVE) + try: + pending_operations.remove(save) + except KeyError: + pass + else: + update_fields = kwargs.get('update_fields', None) + if update_fields: + append = False + for field in kwargs.get('update_fields', [None]): + if field not in backend.ignore_fields: + append = True + break + if not append: + continue + instance = copy.copy(instance) + pending_operations.add(Operation.create(backend, instance, action)) + + def process_request(self, request): + """ Store request on a thread local variable """ + type(self).thread_locals.request = request + + def process_response(self, request, response): + """ Processes pending backend operations """ + if not isinstance(response, HttpResponseServerError): + operations = type(self).get_pending_operations() + if operations: + logs = Operation.execute(operations) + if logs: + message_user(request, logs) + return response diff --git a/orchestra/apps/orchestration/models.py b/orchestra/apps/orchestration/models.py new file mode 100644 index 00000000..255aad50 --- /dev/null +++ b/orchestra/apps/orchestration/models.py @@ -0,0 +1,179 @@ +from django.contrib.contenttypes import generic +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from orchestra.utils.apps import autodiscover + +from . import settings, manager +from .backends import ServiceBackend + + +class Server(models.Model): + """ Machine runing daemons (services) """ + name = models.CharField(_("name"), max_length=256, unique=True) + # TODO unique address with blank=True (nullablecharfield) + address = models.CharField(_("address"), max_length=256, blank=True, + help_text=_("IP address or domain name")) + description = models.TextField(_("description"), blank=True) + os = models.CharField(_("operative system"), max_length=32, + choices=settings.ORCHESTRATION_OS_CHOICES, + default=settings.ORCHESTRATION_DEFAULT_OS) + + def __unicode__(self): + return self.name + + def get_address(self): + if self.address: + return self.address + return self.name + + +class BackendLog(models.Model): + RECEIVED = 'RECEIVED' + TIMEOUT = 'TIMEOUT' + STARTED = 'STARTED' + SUCCESS = 'SUCCESS' + FAILURE = 'FAILURE' + ERROR = 'ERROR' + REVOKED = 'REVOKED' + + STATES = ( + (RECEIVED, RECEIVED), + (TIMEOUT, TIMEOUT), + (STARTED, STARTED), + (SUCCESS, SUCCESS), + (FAILURE, FAILURE), + (ERROR, ERROR), + (REVOKED, REVOKED), + ) + + backend = models.CharField(_("backend"), max_length=256) + state = models.CharField(_("state"), max_length=16, choices=STATES, + default=RECEIVED) + server = models.ForeignKey(Server, verbose_name=_("server"), + related_name='execution_logs') + script = models.TextField(_("script")) + stdout = models.TextField() + stderr = models.TextField() + traceback = models.TextField(_("traceback")) + exit_code = models.IntegerField(_("exit code"), null=True) + task_id = models.CharField(_("task ID"), max_length=36, unique=True, null=True, + help_text="Celery task ID") + created = models.DateTimeField(_("created"), auto_now_add=True) + last_update = models.DateTimeField(_("last update"), auto_now=True) + + class Meta: + get_latest_by = 'created' + + @property + def execution_time(self): + return (self.last_update-self.created).total_seconds() + + +class BackendOperation(models.Model): + """ + Encapsulates an operation, storing its related object, the action and the backend. + """ + SAVE = 'save' + DELETE = 'delete' + ACTIONS = ( + (SAVE, _("save")), + (DELETE, _("delete")), + ) + + log = models.ForeignKey('orchestration.BackendLog', related_name='operations') + backend_class = models.CharField(_("backend"), max_length=256) + action = models.CharField(_("action"), max_length=64, choices=ACTIONS) + content_type = models.ForeignKey(ContentType) + object_id = models.PositiveIntegerField() + instance = generic.GenericForeignKey('content_type', 'object_id') + + class Meta: + verbose_name = _("Operation") + verbose_name_plural = _("Operations") + + def __unicode__(self): + return '%s.%s(%s)' % (self.backend_class, self.action, self.instance) + + def __hash__(self): + """ set() """ + backend = getattr(self, 'backend', self.backend_class) + return hash(backend) + hash(self.instance) + hash(self.action) + + def __eq__(self, operation): + """ set() """ + return hash(self) == hash(operation) + + @classmethod + def create(cls, backend, instance, action): + op = cls(backend_class=backend.get_name(), instance=instance, action=action) + op.backend = backend + return op + + @classmethod + def execute(cls, operations): + return manager.execute(operations) + + +autodiscover('backends') + + +class Route(models.Model): + """ + Defines the routing that determine in which server a backend is executed + """ + backend = models.CharField(_("backend"), max_length=256, + choices=ServiceBackend.get_choices()) + host = models.ForeignKey(Server, verbose_name=_("host")) + match = models.CharField(_("match"), max_length=256, blank=True, default='True', + help_text=_("Python expression used for selecting the targe host, " + "instance referes to the current object.")) +# async = models.BooleanField(default=False) +# method = models.CharField(_("method"), max_lenght=32, choices=method_choices, +# default=MethodBackend.get_default()) + is_active = models.BooleanField(_("is active"), default=True) + + class Meta: + unique_together = ('backend', 'host') + + def __unicode__(self): + return "%s@%s" % (self.backend, self.host) + +# def clean(self): +# backend, method = self.get_backend_class(), self.get_method_class() +# if not backend.type in method.types: +# msg = _("%s backend is not compatible with %s method") +# raise ValidationError(msg % (self.backend, self.method) + + @classmethod + def get_servers(cls, operation): + backend_name = operation.backend.get_name() + try: + routes = cls.objects.filter(is_active=True, backend=backend_name) + except cls.DoesNotExist: + return [] + safe_locals = { 'instance': operation.instance } + pks = [ route.pk for route in routes.all() if eval(route.match, safe_locals) ] + return [ route.host for route in routes.filter(pk__in=pks) ] + + def get_backend(self): + for backend in ServiceBackend.get_backends(): + if backend.get_name() == self.backend: + return backend + raise KeyError('This backend is not registered') + +# def get_method_class(self): +# for method in MethodBackend.get_backends(): +# if method.get_name() == self.method: +# return method +# raise ValueError('This method is not registered') + + def enable(self): + self.is_active = True + self.save() + + def disable(self): + self.is_active = False + self.save() diff --git a/orchestra/apps/orchestration/settings.py b/orchestra/apps/orchestration/settings.py new file mode 100644 index 00000000..1a2d0b45 --- /dev/null +++ b/orchestra/apps/orchestration/settings.py @@ -0,0 +1,21 @@ +from os import path + +from django.conf import settings + + +ORCHESTRATION_OS_CHOICES = getattr(settings, 'ORCHESTRATION_OS_CHOICES', ( + ('LINUX', "Linux"), +)) + +ORCHESTRATION_DEFAULT_OS = getattr(settings, 'ORCHESTRATION_DEFAULT_OS', 'LINUX') + +ORCHESTRATION_SSH_KEY_PATH = getattr(settings, 'ORCHESTRATION_SSH_KEY_PATH', + path.join(path.expanduser('~'), '.ssh/id_rsa')) + +ORCHESTRATION_ROUTER = getattr(settings, 'ORCHESTRATION_ROUTER', + 'orchestra.apps.orchestration.models.Route' +) + +ORCHESTRATION_TEMP_SCRIPT_PATH = getattr(settings, 'ORCHESTRATION_TEMP_SCRIPT_PATH', + '/dev/shm' +) diff --git a/orchestra/apps/orchestration/tests/__init__.py b/orchestra/apps/orchestration/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/orchestration/tests/test_route.py b/orchestra/apps/orchestration/tests/test_route.py new file mode 100644 index 00000000..34767203 --- /dev/null +++ b/orchestra/apps/orchestration/tests/test_route.py @@ -0,0 +1,44 @@ +from django.db import IntegrityError, transaction + +from orchestra.utils.tests import BaseTestCase + +from .. import operations, backends +from ..models import Route, Server +from ..utils import get_backend_choices + + +class RouterTests(BaseTestCase): + def setUp(self): + self.host = Server.objects.create(name='web.example.com') + self.host1 = Server.objects.create(name='web1.example.com') + self.host2 = Server.objects.create(name='web2.example.com') + + def test_list_backends(self): + # TODO count actual, register and compare + choices = list(Route._meta.get_field_by_name('backend')[0]._choices) + self.assertLess(1, len(choices)) + + def test_get_instances(self): + + class TestBackend(backends.ServiceBackend): + verbose_name = 'Route' + models = ['routes.Route',] + + choices = get_backend_choices(backends.ServiceBackend.get_backends()) + Route._meta.get_field_by_name('backend')[0]._choices = choices + backend = TestBackend.get_name() + + route = Route.objects.create(backend=backend, host=self.host, + match='True') + operation = operations.Operation(TestBackend, route, 'commit') + self.assertEqual(1, len(Route.get_servers(operation))) + + route = Route.objects.create(backend=backend, host=self.host1, + match='instance.backend == "TestBackend"') + operation = operations.Operation(TestBackend, route, 'commit') + self.assertEqual(2, len(Route.get_servers(operation))) + + route = Route.objects.create(backend=backend, host=self.host2, + match='instance.backend == "something else"') + operation = operations.Operation(TestBackend, route, 'commit') + self.assertEqual(2, len(Route.get_servers(operation))) diff --git a/orchestra/apps/orders/README.md b/orchestra/apps/orders/README.md new file mode 100644 index 00000000..023e0337 --- /dev/null +++ b/orchestra/apps/orders/README.md @@ -0,0 +1,8 @@ +Orders +====== + +Build an asyclic graph with every `model.save()` and `model.delete()` looking for Service.content_type matches. + +`ORDERS_GRAPH_MAX_DEPTH` + +autodiscover contacts by looking for `contact` atribute on related objects with reverse relationship `null=False` diff --git a/orchestra/apps/orders/__init__.py b/orchestra/apps/orders/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/orders/collector.py b/orchestra/apps/orders/collector.py new file mode 100644 index 00000000..900215a6 --- /dev/null +++ b/orchestra/apps/orders/collector.py @@ -0,0 +1,29 @@ +from . import settings + + +class Node(object): + def __init__(self, content): + self.content = content + self.parents = [] + self.path = [] + + def __repr__(self): + return "%s:%s" % (type(self.content).__name__, self.content) + + +class Collector(object): + def __init__(self, obj, cascade_only=False): + self.obj = obj + self.cascade_only = cascade_only + + def collect(self): + depth = settings.ORDERS_COLLECTOR_MAX_DEPTH + return self._rec_collect(self.obj, [self.obj], depth) + + def _rec_collect(self, obj, path, depth): + node = Node(content=obj) + # FK lookups + for field in obj._meta.fields: + if hasattr(field, 'related') and (self.cascade_only or not field.null): + related_object = getattr(obj, field.name) + diff --git a/orchestra/apps/orders/models.py b/orchestra/apps/orders/models.py new file mode 100644 index 00000000..128a89b6 --- /dev/null +++ b/orchestra/apps/orders/models.py @@ -0,0 +1,36 @@ +from django.contrib.contenttypes import generic +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.utils.translation import ugettext as _ + +from . import settings + + +class Service(models.Model): + name = models.CharField(_("name"), max_length=256) + content_type = models.ForeignKey(ContentType, verbose_name=_("content_type")) + match = models.CharField(_("expression"), max_length=256) + + def __unicode__(self): + return self.name + + +class Order(models.Model): + contact = models.ForeignKey(settings.ORDERS_CONTACT_MODEL, + verbose_name=_("contact"), related_name='orders') + content_type = models.ForeignKey(ContentType) + object_id = models.PositiveIntegerField(null=True) + service = models.ForeignKey(Service, verbose_name=_("service"), + related_name='orders')) + registered_on = models.DateTimeField(_("registered on"), auto_now_add=True) + canceled_on = models.DateTimeField(_("canceled on"), null=True, blank=True) + last_billed_on = models.DateTimeField(_("last billed on"), null=True, blank=True) + billed_until = models.DateTimeField(_("billed until"), null=True, blank=True) + ignore = models.BooleanField(_("ignore"), default=False) + description = models.CharField(_("description"), max_length=256, blank=True) + + content_object = generic.GenericForeignKey() + + def __unicode__(self): + return "%s@%s" (self.service, self.contact) + diff --git a/orchestra/apps/orders/settings.py b/orchestra/apps/orders/settings.py new file mode 100644 index 00000000..7a7c8a0d --- /dev/null +++ b/orchestra/apps/orders/settings.py @@ -0,0 +1,7 @@ +from django.conf import settings + + +ORDERS_CONTACT_MODEL = getattr(settings, 'ORDERS_CONTACT_MODEL', 'contacts.Contact') + + +ORDERS_COLLECTOR_MAX_DEPTH = getattr(settings, 'ORDERS_COLLECTOR_MAX_DEPTH', 3) diff --git a/orchestra/apps/orders/tests/__init__.py b/orchestra/apps/orders/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/orders/tests/models.py b/orchestra/apps/orders/tests/models.py new file mode 100644 index 00000000..b9c91273 --- /dev/null +++ b/orchestra/apps/orders/tests/models.py @@ -0,0 +1,21 @@ +from django.db import models + + +class Root(models.Model): + name = models.CharField(max_length=256, default='randomname') + + +class Related(models.Model): + root = models.ForeignKey(Root) + + +class TwoRelated(models.Model): + related = models.ForeignKey(Related) + + +class ThreeRelated(models.Model): + twolated = models.ForeignKey(TwoRelated) + + +class FourRelated(models.Model): + threerelated = models.ForeignKey(ThreeRelated) diff --git a/orchestra/apps/orders/tests/test_collector.py b/orchestra/apps/orders/tests/test_collector.py new file mode 100644 index 00000000..a6382b3d --- /dev/null +++ b/orchestra/apps/orders/tests/test_collector.py @@ -0,0 +1,28 @@ +from django.conf import settings +from django.core.management import call_command +from django.db.models import loading +from django.test import TestCase + +from .models import Root, Related, TwoRelated, ThreeRelated, FourRelated + + +#class CollectorTests(TestCase): +# def setUp(self): +# self.root = Root.objects.create(name='randomname') +# self.related = Related.objects.create(top=self.root) +# +# def _pre_setup(self): +# # Add the models to the db. +# self._original_installed_apps = list(settings.INSTALLED_APPS) +# settings.INSTALLED_APPS += ('orchestra.apps.orders.tests',) +# loading.cache.loaded = False +# call_command('syncdb', interactive=False, verbosity=0) +# super(CollectorTests, self)._pre_setup() +# +# def _post_teardown(self): +# super(CollectorTests, self)._post_teardown() +# settings.INSTALLED_APPS = self._original_installed_apps +# loading.cache.loaded = False + +# def test_models(self): +# self.assertEqual('randomname', self.root.name) diff --git a/orchestra/apps/users/__init__.py b/orchestra/apps/users/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/users/admin.py b/orchestra/apps/users/admin.py new file mode 100644 index 00000000..2ab9ed6e --- /dev/null +++ b/orchestra/apps/users/admin.py @@ -0,0 +1,110 @@ +from django.conf.urls import patterns, url +from django.core.urlresolvers import reverse +from django.contrib import admin +from django.contrib.admin.util import unquote +from django.contrib.auth import admin as auth +from django.utils.translation import ugettext, ugettext_lazy as _ + +from orchestra.admin import ExtendedModelAdmin +from orchestra.admin.utils import wrap_admin_view +from orchestra.apps.accounts.admin import AccountAdminMixin + +from .forms import UserCreationForm, UserChangeForm +from .models import User +from .roles.filters import role_list_filter_factory + + +class UserAdmin(AccountAdminMixin, auth.UserAdmin, ExtendedModelAdmin): + list_display = ('username', 'is_main') + list_filter = ('is_staff', 'is_superuser', 'is_active') + fieldsets = ( + (None, { + 'fields': ('account', 'username', 'password') + }), + (_("Personal info"), { + 'fields': ('first_name', 'last_name', 'email') + }), + (_("Permissions"), { + 'fields': ('is_active', 'is_staff', 'is_superuser', 'is_admin', 'is_main') + }), + (_("Important dates"), { + 'fields': ('last_login', 'date_joined') + }), + ) + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('username', 'password1', 'password2', 'account'), + }), + ) + search_fields = ['username', 'account__user__username'] + readonly_fields = ('is_main', 'account_link') + change_readonly_fields = ('username',) + filter_horizontal = () + add_form = UserCreationForm + form = UserChangeForm + roles = [] + ordering = ('-id',) + + + def is_main(self, user): + return user.account.user == user + is_main.boolean = True + + def get_urls(self): + """ Returns the additional urls for the change view links """ + urls = super(UserAdmin, self).get_urls() + admin_site = self.admin_site + opts = self.model._meta + new_urls = patterns("") + for role in self.roles: + new_urls += patterns("", + url('^(\d+)/%s/$' % role.url_name, + wrap_admin_view(self, role().change_view), + name='%s_%s_%s_change' % (opts.app_label, opts.module_name, role.name)), + url('^(\d+)/%s/delete/$' % role.url_name, + wrap_admin_view(self, role().delete_view), + name='%s_%s_%s_delete' % (opts.app_label, opts.module_name, role.name)) + ) + return new_urls + urls + + def get_fieldsets(self, request, obj=None): + fieldsets = super(UserAdmin, self).get_fieldsets(request, obj=obj) + if obj and obj.account: + fieldsets[0][1]['fields'] = ('account_link',) + fieldsets[0][1]['fields'][1:] + return fieldsets + + def get_list_display(self, request): + roles = [] + for role in self.roles: + def has_role(user, role_class=role): + role = role_class(user=user) + if role.exists: + return 'True' + url = reverse('admin:users_user_%s_change' % role.name, args=(user.pk,)) + false = 'False' + return '%s' % (url, false) + has_role.short_description = _("Has %s") % role.name + has_role.admin_order_field = role.name + has_role.allow_tags = True + roles.append(has_role) + return list(self.list_display) + roles + ['account_link'] + + def get_list_filter(self, request): + roles = [ role_list_filter_factory(role) for role in self.roles ] + return list(self.list_filter) + roles + + def change_view(self, request, object_id, **kwargs): + user = self.get_object(User, unquote(object_id)) + extra_context = kwargs.get('extra_context', {}) + extra_context['roles'] = [ role(user=user) for role in self.roles ] + kwargs['extra_context'] = extra_context + return super(UserAdmin, self).change_view(request, object_id, **kwargs) + + def queryset(self, request): + """ Select related for performance """ + related = ['account__user'] + [ role.name for role in self.roles ] + return super(UserAdmin, self).queryset(request).select_related(*related) + + +admin.site.register(User, UserAdmin) diff --git a/orchestra/apps/users/api.py b/orchestra/apps/users/api.py new file mode 100644 index 00000000..80cc841f --- /dev/null +++ b/orchestra/apps/users/api.py @@ -0,0 +1,23 @@ +from django.contrib.auth import get_user_model +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 .serializers import UserSerializer + + +class UserViewSet(AccountApiMixin, SetPasswordApiMixin, viewsets.ModelViewSet): + model = get_user_model() + serializer_class = UserSerializer + + def get_queryset(self): + """ select related roles """ + qs = super(UserViewSet, self).get_queryset() + return qs.select_related(*self.inserted) + + +router.register(r'users', UserViewSet) diff --git a/orchestra/apps/users/backends.py b/orchestra/apps/users/backends.py new file mode 100644 index 00000000..41895094 --- /dev/null +++ b/orchestra/apps/users/backends.py @@ -0,0 +1,41 @@ +from django.utils.translation import ugettext_lazy as _ + +from orchestra.apps.orchestration import ServiceBackend + +from . import settings + + +class SystemUserBackend(ServiceBackend): + verbose_name = _("System User") + model = 'users.User' + ignore_fields = ['last_login'] + + def save(self, user): + context = self.get_context(user) + if user.is_main: + self.append( + "if [[ $( id %(username)s ) ]]; then \n" + " usermod --password '%(password)s' %(username)s \n" + "else \n" + " useradd %(username)s --password '%(password)s' \\\n" + " --shell /bin/false \n" + "fi" % context + ) + self.append("mkdir -p %(home)s" % context) + self.append("chown %(username)s.%(username)s %(home)s" % context) + else: + self.delete(user) + + def delete(self, user): + context = self.get_context(user) + self.append("{ sleep 2 && killall -u %(username)s -s KILL; } &" % context) + self.append("killall -u %(username)s" % context) + self.append("userdel %(username)s" % context) + + def get_context(self, user): + context = { + 'username': user.username, + 'password': user.password if user.is_active else '*%s' % user.password, + } + context['home'] = settings.USERS_SYSTEMUSER_HOME % context + return context diff --git a/orchestra/apps/users/forms.py b/orchestra/apps/users/forms.py new file mode 100644 index 00000000..a4d3c9c2 --- /dev/null +++ b/orchestra/apps/users/forms.py @@ -0,0 +1,50 @@ +from django import forms +from django.contrib import auth +from django.utils.translation import ugettext, ugettext_lazy as _ + +from orchestra.core.validators import validate_password + +from .models import User + + +class UserCreationForm(auth.forms.UserCreationForm): + class Meta(auth.forms.UserCreationForm.Meta): + model = User + + def __init__(self, *args, **kwargs): + super(UserCreationForm, 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']) + + +class UserChangeForm(forms.ModelForm): + 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 this form.")) + + class Meta: + model = User + fields = '__all__' + + def __init__(self, *args, **kwargs): + super(UserChangeForm, self).__init__(*args, **kwargs) + f = self.fields.get('user_permissions', None) + if f is not None: + f.queryset = f.queryset.select_related('content_type') + + 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.initial["password"] + diff --git a/orchestra/apps/users/models.py b/orchestra/apps/users/models.py new file mode 100644 index 00000000..e1ba1d66 --- /dev/null +++ b/orchestra/apps/users/models.py @@ -0,0 +1,88 @@ +from django.contrib.auth import models as auth +from django.core import validators +from django.db import models +from django.utils import timezone +from django.utils.translation import ugettext_lazy as _ + +from orchestra.core import services + + +class User(auth.AbstractBaseUser): + username = models.CharField(_("username"), max_length=64, unique=True, + help_text=_("Required. 30 characters or fewer. Letters, digits and " + "@/./+/-/_ only."), + validators=[validators.RegexValidator(r'^[\w.@+-]+$', + _("Enter a valid username."), 'invalid')]) + account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), + related_name='users', null=True) + first_name = models.CharField(_("first name"), max_length=30, blank=True) + last_name = models.CharField(_("last name"), max_length=30, blank=True) + email = models.EmailField(_('email address'), blank=True) + is_superuser = models.BooleanField(_("superuser status"), default=False, + help_text=_("Designates that this user has all permissions without " + "explicitly assigning them.")) + is_staff = models.BooleanField(_("staff status"), default=False, + help_text=_("Designates whether the user can log into this admin " + "site.")) + is_admin = models.BooleanField(_("admin status"), default=False, + help_text=_("Designates whether the user can administrate its account.")) + is_active = models.BooleanField(_("active"), default=True, + help_text=_("Designates whether this user should be treated as " + "active. Unselect this instead of deleting accounts.")) + date_joined = models.DateTimeField(_("date joined"), default=timezone.now) + + objects = auth.UserManager() + + USERNAME_FIELD = 'username' + REQUIRED_FIELDS = ['email'] + + def get_full_name(self): + """ Returns the first_name plus the last_name, with a space in between """ + full_name = '%s %s' % (self.first_name, self.last_name) + return full_name.strip() + + def get_short_name(self): + """ Returns the short name for the user """ + return self.first_name + + def email_user(self, subject, message, from_email=None, **kwargs): + """ Sends an email to this User """ + send_mail(subject, message, from_email, [self.email], **kwargs) + + def has_perm(self, perm, obj=None): + """ + Returns True if the user has the specified permission. This method + queries all available auth backends, but returns immediately if any + backend returns True. Thus, a user who has permission from a single + auth backend is assumed to have permission in general. If an object is + provided, permissions for this specific object are checked. + """ + # Active superusers have all permissions. + if self.is_active and self.is_superuser: + return True + # Otherwise we need to check the backends. + return auth._user_has_perm(self, perm, obj) + + def has_perms(self, perm_list, obj=None): + """ + Returns True if the user has each of the specified permissions. If + object is passed, it checks if the user has all required perms for this + object. + """ + for perm in perm_list: + if not self.has_perm(perm, obj): + return False + return True + + def has_module_perms(self, app_label): + """ + Returns True if the user has any permissions in the given app label. + Uses pretty much the same logic as has_perm, above. + """ + # Active superusers have all permissions. + if self.is_active and self.is_superuser: + return True + return auth._user_has_module_perms(self, app_label) + + +services.register(User, menu=False) diff --git a/orchestra/apps/users/roles/__init__.py b/orchestra/apps/users/roles/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/users/roles/admin.py b/orchestra/apps/users/roles/admin.py new file mode 100644 index 00000000..1e442cf3 --- /dev/null +++ b/orchestra/apps/users/roles/admin.py @@ -0,0 +1,147 @@ +from django.contrib import messages +from django.contrib.admin.util import unquote, get_deleted_objects +from django.contrib.admin.templatetags.admin_urls import add_preserved_filters +from django.core.urlresolvers import reverse +from django.db import router +from django.http import Http404, HttpResponseRedirect +from django.template.response import TemplateResponse +from django.shortcuts import redirect +from django.utils.encoding import force_text +from django.utils.html import escape +from django.utils.translation import ugettext, ugettext_lazy as _ + +from orchestra.admin.utils import get_modeladmin + +from .forms import role_form_factory +from ..models import User + + +class RoleAdmin(object): + model = None + name = '' + url_name = '' + form = None + + def __init__(self, user=None): + self.user = user + + @property + def exists(self): + try: + return getattr(self.user, self.name) + except self.model.DoesNotExist: + return False + + def get_user(self, request, object_id): + modeladmin = get_modeladmin(User) + user = modeladmin.get_object(request, unquote(object_id)) + opts = self.model._meta + if user is None: + raise Http404( + _('%(name)s object with primary key %(key)r does not exist.') % + {'name': force_text(opts.verbose_name), 'key': escape(object_id)} + ) + return user + + def change_view(self, request, object_id): + modeladmin = get_modeladmin(User) + user = self.get_user(request, object_id) + self.user = user + obj = None + exists = self.exists + if exists: + obj = getattr(user, self.name) + form_class = self.form if self.form else role_form_factory(self) + form = form_class(instance=obj) + opts = modeladmin.model._meta + app_label = opts.app_label + title = _("Add %s for user %s" % (self.name, user)) + action = _("Create") + # User has submitted the form + if request.method == 'POST': + form = form_class(request.POST, instance=obj) + form.user = user + if form.is_valid(): + obj = form.save() + context = { + 'name': obj._meta.verbose_name, + 'obj': obj, + 'action': _("saved" if exists else "created") + } + modeladmin.log_change(request, request.user, "%s saved" % self.name.capitalize()) + msg = _('The role %(name)s for user "%(obj)s" was %(action)s successfully.') % context + modeladmin.message_user(request, msg, messages.SUCCESS) + url = 'admin:%s_%s_change' % (opts.app_label, opts.module_name) + if not "_continue" in request.POST: + return redirect(url, object_id) + exists = True + + if exists: + title = _("Change %s %s settings" % (user, self.name)) + action = _("Save") + form = form_class(instance=obj) + + context = { + 'title': title, + 'opts': opts, + 'app_label': app_label, + 'form': form, + 'action': action, + 'role': self, + 'roles': [ role(user=user) for role in modeladmin.roles ], + 'media': modeladmin.media + } + + template = 'admin/users/user/role.html' + app = modeladmin.admin_site.name + return TemplateResponse(request, template, context, current_app=app) + + def delete_view(self, request, object_id): + "The 'delete' admin view for this model." + opts = self.model._meta + app_label = opts.app_label + modeladmin = get_modeladmin(User) + user = self.get_user(request, object_id) + obj = getattr(user, self.name) + + using = router.db_for_write(self.model) + + # Populate deleted_objects, a data structure of all related objects that + # will also be deleted. + (deleted_objects, perms_needed, protected) = get_deleted_objects( + [obj], opts, request.user, modeladmin.admin_site, using) + + if request.POST: # The user has already confirmed the deletion. + if perms_needed: + raise PermissionDenied + obj_display = force_text(obj) + modeladmin.log_deletion(request, obj, obj_display) + modeladmin.delete_model(request, obj) + post_url = reverse('admin:users_user_change', args=(user.pk,)) + preserved_filters = modeladmin.get_preserved_filters(request) + post_url = add_preserved_filters( + {'preserved_filters': preserved_filters, 'opts': opts}, post_url + ) + return HttpResponseRedirect(post_url) + + object_name = force_text(opts.verbose_name) + + if perms_needed or protected: + title = _("Cannot delete %(name)s") % {"name": object_name} + else: + title = _("Are you sure?") + + context = { + "title": title, + "object_name": object_name, + "object": obj, + "deleted_objects": deleted_objects, + "perms_lacking": perms_needed, + "protected": protected, + "opts": opts, + "app_label": app_label, + 'preserved_filters': modeladmin.get_preserved_filters(request), + 'role': self, + } + return TemplateResponse(request, 'admin/users/user/delete_role.html', + context, current_app=modeladmin.admin_site.name) diff --git a/orchestra/apps/users/roles/filters.py b/orchestra/apps/users/roles/filters.py new file mode 100644 index 00000000..7bac9a3b --- /dev/null +++ b/orchestra/apps/users/roles/filters.py @@ -0,0 +1,23 @@ +from django.contrib.admin import SimpleListFilter +from django.utils.translation import ugettext_lazy as _ + + +def role_list_filter_factory(role): + class RoleListFilter(SimpleListFilter): + """ Filter Nodes by group according to request.user """ + title = _("has %s" % role.name) + parameter_name = role.url_name + + def lookups(self, request, model_admin): + return ( + ('True', _("Yes")), + ('False', _("No")), + ) + + def queryset(self, request, queryset): + if self.value() == 'True': + return queryset.filter(**{ '%s__isnull' % role.name: False }) + if self.value() == 'False': + return queryset.filter(**{ '%s__isnull' % role.name: True }) + + return RoleListFilter diff --git a/orchestra/apps/users/roles/forms.py b/orchestra/apps/users/roles/forms.py new file mode 100644 index 00000000..decc6610 --- /dev/null +++ b/orchestra/apps/users/roles/forms.py @@ -0,0 +1,17 @@ +from django import forms + + +class RoleAdminBaseForm(forms.ModelForm): + class Meta: + exclude = ('user', ) + + def save(self, *args, **kwargs): + self.instance.user = self.user + return super(RoleAdminBaseForm, self).save(*args, **kwargs) + + +def role_form_factory(role): + class RoleAdminForm(RoleAdminBaseForm): + class Meta(RoleAdminBaseForm.Meta): + model = role.model + return RoleAdminForm diff --git a/orchestra/apps/users/roles/jabber/__init__.py b/orchestra/apps/users/roles/jabber/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/users/roles/jabber/admin.py b/orchestra/apps/users/roles/jabber/admin.py new file mode 100644 index 00000000..8d5590f5 --- /dev/null +++ b/orchestra/apps/users/roles/jabber/admin.py @@ -0,0 +1,17 @@ +from django.contrib import admin +from django.contrib.auth import get_user_model +from django.utils.translation import ugettext_lazy as _ + +from orchestra.admin.utils import insertattr +from orchestra.apps.users.roles.admin import RoleAdmin + +from .models import Jabber + + +class JabberRoleAdmin(RoleAdmin): + model = Jabber + name = 'jabber' + url_name = 'jabber' + + +insertattr(get_user_model(), 'roles', JabberRoleAdmin) diff --git a/orchestra/apps/users/roles/jabber/models.py b/orchestra/apps/users/roles/jabber/models.py new file mode 100644 index 00000000..a82bc429 --- /dev/null +++ b/orchestra/apps/users/roles/jabber/models.py @@ -0,0 +1,10 @@ +from django.db import models +from django.utils.translation import ugettext_lazy as _ + + +class Jabber(models.Model): + user = models.OneToOneField('users.User', verbose_name=_("user"), + related_name='jabber') + + def __unicode__(self): + return str(self.user) diff --git a/orchestra/apps/users/roles/mail/__init__.py b/orchestra/apps/users/roles/mail/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/users/roles/mail/admin.py b/orchestra/apps/users/roles/mail/admin.py new file mode 100644 index 00000000..e36cde21 --- /dev/null +++ b/orchestra/apps/users/roles/mail/admin.py @@ -0,0 +1,129 @@ +from django import forms +from django.contrib import admin +from django.contrib.auth import get_user_model +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 insertattr, link +from orchestra.apps.accounts.admin import SelectAccountAdminMixin +from orchestra.apps.domains.forms import DomainIterator +from orchestra.apps.users.roles.admin import RoleAdmin + +from .forms import MailRoleAdminForm +from .models import Mailbox, Address, Autoresponse + + +class AutoresponseInline(admin.StackedInline): + model = Autoresponse + verbose_name_plural = _("autoresponse") + + def formfield_for_dbfield(self, db_field, **kwargs): + if db_field.name == 'subject': + kwargs['widget'] = forms.TextInput(attrs={'size':'118'}) + return super(AutoresponseInline, self).formfield_for_dbfield(db_field, **kwargs) + + +#class AddressAdmin(SelectAccountAdminMixin, ExtendedModelAdmin): +# list_display = ('email', 'domain_link', 'mailboxes', 'forwards', 'account_link') +# fields = ('account_link', ('name', 'domain'), 'destination') +# inlines = [AutoresponseInline] +# search_fields = ('name', 'domain__name',) +# readonly_fields = ('account_link', 'domain_link', 'email_link') +# filter_by_account_fields = ['domain'] +# +# domain_link = link('domain', order='domain__name') +# +# def email_link(self, address): +# link = self.domain_link(address) +# return "%s@%s" % (address.name, link) +# email_link.short_description = _("Email") +# email_link.allow_tags = True +# +# def mailboxes(self, address): +# boxes = [] +# for mailbox in address.get_mailboxes(): +# user = mailbox.user +# url = reverse('admin:users_user_mailbox_change', args=(user.pk,)) +# boxes.append('%s' % (url, user.username)) +# return '
    '.join(boxes) +# mailboxes.allow_tags = True +# +# def forwards(self, address): +# values = [ dest for dest in address.destination.split() if '@' in dest ] +# return '
    '.join(values) +# forwards.allow_tags = True +# +# def formfield_for_dbfield(self, db_field, **kwargs): +# if db_field.name == 'destination': +# kwargs['widget'] = forms.TextInput(attrs={'size':'118'}) +# return super(AddressAdmin, self).formfield_for_dbfield(db_field, **kwargs) +# +# def queryset(self, request): +# """ Select related for performance """ +# qs = super(AddressAdmin, self).queryset(request) +# # TODO django 1.7 account__user is not needed +# return qs.select_related('domain', 'account__user') + + +class AddressAdmin(SelectAccountAdminMixin, ExtendedModelAdmin): + list_display = ( + 'email', 'domain_link', 'display_mailboxes', 'display_forward', 'account_link' + ) + fields = ('account_link', ('name', 'domain'), 'mailboxes', 'forward') + inlines = [AutoresponseInline] + search_fields = ('name', 'domain__name',) + readonly_fields = ('account_link', 'domain_link', 'email_link') + filter_by_account_fields = ['domain'] + filter_horizontal = ['mailboxes'] + + domain_link = link('domain', order='domain__name') + + def email_link(self, address): + link = self.domain_link(address) + return "%s@%s" % (address.name, link) + email_link.short_description = _("Email") + email_link.allow_tags = True + + def display_mailboxes(self, address): + boxes = [] + for mailbox in address.mailboxes(): + user = mailbox.user + url = reverse('admin:users_user_mailbox_change', args=(user.pk,)) + boxes.append('%s' % (url, user.username)) + return '
    '.join(boxes) + display_mailboxes.short_description = _("Mailboxes") + display_mailboxes.allow_tags = True + + def display_forward(self, address): + values = [ dest for dest in address.forward.split() ] + return '
    '.join(values) + display_forward.short_description = _("Forward") + display_forward.allow_tags = True + + def formfield_for_dbfield(self, db_field, **kwargs): + if db_field.name == 'forward': + kwargs['widget'] = forms.TextInput(attrs={'size':'118'}) + if db_field.name == 'mailboxes': + mailboxes = db_field.rel.to.objects.select_related('user') + kwargs['queryset'] = mailboxes.filter(user__account=self.account) + return super(AddressAdmin, self).formfield_for_dbfield(db_field, **kwargs) + + def queryset(self, request): + """ Select related for performance """ + qs = super(AddressAdmin, self).queryset(request) + # TODO django 1.7 account__user is not needed + return qs.select_related('domain', 'account__user') + + +class MailRoleAdmin(RoleAdmin): + model = Mailbox + name = 'mailbox' + url_name = 'mailbox' + form = MailRoleAdminForm + + +admin.site.register(Address, AddressAdmin) +insertattr(get_user_model(), 'roles', MailRoleAdmin) diff --git a/orchestra/apps/users/roles/mail/api.py b/orchestra/apps/users/roles/mail/api.py new file mode 100644 index 00000000..410d8a1c --- /dev/null +++ b/orchestra/apps/users/roles/mail/api.py @@ -0,0 +1,27 @@ +from rest_framework import viewsets + +from orchestra.api import router +from orchestra.apps.accounts.api import AccountApiMixin + +from .models import Address, Mailbox +from .serializers import AddressSerializer, MailboxSerializer + + +class AddressViewSet(AccountApiMixin, viewsets.ModelViewSet): + model = Address + serializer_class = AddressSerializer + + + +class MailboxViewSet(viewsets.ModelViewSet): + model = Mailbox + serializer_class = MailboxSerializer + + def get_queryset(self): + qs = super(MailboxViewSet, self).get_queryset() + qs = qs.select_related('user') + return qs.filter(user__account=self.request.user.account_id) + + +router.register(r'mailboxes', MailboxViewSet) +router.register(r'addresses', AddressViewSet) diff --git a/orchestra/apps/users/roles/mail/backends.py b/orchestra/apps/users/roles/mail/backends.py new file mode 100644 index 00000000..422eb6ab --- /dev/null +++ b/orchestra/apps/users/roles/mail/backends.py @@ -0,0 +1,143 @@ +import os + +from django.utils import timezone +from django.utils.translation import ugettext_lazy as _ + +from orchestra.apps.orchestration import ServiceBackend + +from . import settings + + +class MailSystemUserBackend(ServiceBackend): + verbose_name = _("Mail system user") + model = 'mail.Mailbox' + + DEFAULT_GROUP = 'postfix' + + def create_user(self, context): + self.append( + "if [[ $( id %(username)s ) ]]; then \n" + " usermod -p '%(password)s' %(username)s \n" + "else \n" + " useradd %(username)s --password '%(password)s' \\\n" + " --shell /dev/null \n" + "fi" % context + ) + self.append("mkdir -p %(home)s" % context) + self.append("chown %(username)s.%(group)s %(home)s" % context) + + def generate_filter(self, mailbox, context): + datetime = timezone.now().strftime("%B %d, %Y, %H:%M") + context['filtering'] = ( + "# Sieve Filter\n" + "# Generated by Orchestra %s\n\n" % datetime + ) + if mailbox.use_custom_filtering: + context['filtering'] += mailbox.custom_filtering + else: + context['filtering'] += settings.EMAILS_DEFAUL_FILTERING + context['filter_path'] = os.path.join(context['home'], '.orchestra.sieve') + self.append("echo '%(filtering)s' > %(filter_path)s" % context) + + def save(self, mailbox): + context = self.get_context(mailbox) + self.create_user(context) + self.generate_filter(mailbox, context) + + def delete(self, mailbox): + context = self.get_context(mailbox) + self.append("{ sleep 2 && killall -u %(username)s -s KILL; } &" % context) + self.append("killall -u %(username)s" % context) + self.append("userdel %(username)s" % context) + self.append("rm -fr %(home)s" % context) + + def get_context(self, mailbox): + user = mailbox.user + context = { + 'username': user.username, + 'password': user.password if user.is_active else '*%s' % user.password, + 'group': self.DEFAULT_GROUP + } + context['home'] = settings.EMAILS_HOME % context + return context + + +class PostfixAddressBackend(ServiceBackend): + verbose_name = _("Postfix address") + model = 'mail.Address' + + def include_virtdomain(self, context): + self.append( + '[[ $(grep "^\s*%(domain)s\s*$" %(virtdomains)s) ]]' + ' || { echo "%(domain)s" >> %(virtdomains)s; UPDATED=1; }' % context + ) + + def exclude_virtdomain(self, context): + domain = context['domain'] + if not Address.objects.filter(domain=domain).exists(): + self.append('sed -i "s/^%(domain)s//" %(virtdomains)s' % context) + + def update_virtusertable(self, context): + self.append( + 'LINE="%(email)s\t%(destination)s"\n' + 'if [[ ! $(grep "^%(email)s\s" %(virtusertable)s) ]]; then\n' + ' echo "$LINE" >> %(virtusertable)s\n' + ' UPDATED=1\n' + 'else\n' + ' if [[ ! $(grep "^${LINE}$" %(virtusertable)s) ]]; then\n' + ' sed -i "s/^%(email)s\s.*$/${LINE}/" %(virtusertable)s\n' + ' UPDATED=1\n' + ' fi\n' + 'fi' % context + ) + + def exclude_virtusertable(self, context): + self.append( + 'if [[ $(grep "^%(email)s\s") ]]; then\n' + ' sed -i "s/^%(email)s\s.*$//" %(virtusertable)s\n' + ' UPDATED=1\n' + 'fi' + ) + + def save(self, address): + context = self.get_context(address) + self.include_virtdomain(context) + self.update_virtusertable(context) + + def delete(self, address): + context = self.get_context(address) + self.exclude_virtdomain(context) + self.exclude_virtusertable(context) + + def commit(self): + context = self.get_context_files() + self.append('[[ $UPDATED == 1 ]] && { ' + 'postmap %(virtdomains)s;' + 'postmap %(virtusertable)s;' + '}' % context) + + def get_context_files(self): + return { + 'virtdomains': settings.EMAILS_VIRTDOMAINS_PATH, + 'virtusertable': settings.EMAILS_VIRTUSERTABLE_PATH, + } + + def get_context(self, address): + context = self.get_context_files() + context.update({ + 'domain': address.domain, + 'email': address.email, + 'destination': address.destination, + }) + return context + + +class AutoresponseBackend(ServiceBackend): + verbose_name = _("Mail autoresponse") + model = 'mail.Autoresponse' + + def save(self, autoresponse): + pass + + def delete(self, autoresponse): + pass diff --git a/orchestra/apps/users/roles/mail/forms.py b/orchestra/apps/users/roles/mail/forms.py new file mode 100644 index 00000000..7f1023ff --- /dev/null +++ b/orchestra/apps/users/roles/mail/forms.py @@ -0,0 +1,53 @@ +from django import forms +from django.core.urlresolvers import reverse +from django.utils.safestring import mark_safe +from django.utils.translation import ugettext_lazy as _ + +from orchestra.forms.widgets import ReadOnlyWidget + +from .models import Mailbox +from ..forms import RoleAdminBaseForm + + +class MailRoleAdminForm(RoleAdminBaseForm): + class Meta(RoleAdminBaseForm.Meta): + model = Mailbox + + def __init__(self, *args, **kwargs): + super(MailRoleAdminForm, self).__init__(*args, **kwargs) + instance = kwargs.get('instance') + if instance: + widget = ReadOnlyWidget(self.addresses(instance)) + self.fields['addresses'] = forms.CharField(widget=widget, + label=_("Addresses")) + +# def addresses(self, mailbox): +# account = mailbox.user.account +# addresses = account.addresses.filter(destination__contains=mailbox.user.username) +# add_url = reverse('admin:mail_address_add') +# add_url += '?account=%d&destination=%s' % (account.pk, mailbox.user.username) +# img = 'Add Another' +# onclick = 'onclick="return showAddAnotherPopup(this);"' +# add_link = '%s Add address' % (add_url, onclick, img) +# value = '%s

    ' % add_link +# for pk, name, domain in addresses.values_list('pk', 'name', 'domain__name'): +# url = reverse('admin:mail_address_change', args=(pk,)) +# name = '%s@%s' % (name, domain) +# value += '
  • %s
  • ' % (url, name) +# value = '
      %s
    ' % value +# return mark_safe('
    %s
    ' % value) + + def addresses(self, mailbox): + account = mailbox.user.account + add_url = reverse('admin:mail_address_add') + add_url += '?account=%d&mailboxes=%s' % (account.pk, mailbox.pk) + img = 'Add Another' + onclick = 'onclick="return showAddAnotherPopup(this);"' + add_link = '%s Add address' % (add_url, onclick, img) + value = '%s

    ' % add_link + for pk, name, domain in mailbox.addresses.values_list('pk', 'name', 'domain__name'): + url = reverse('admin:mail_address_change', args=(pk,)) + name = '%s@%s' % (name, domain) + value += '
  • %s
  • ' % (url, name) + value = '
      %s
    ' % value + return mark_safe('
    %s
    ' % value) diff --git a/orchestra/apps/users/roles/mail/models.py b/orchestra/apps/users/roles/mail/models.py new file mode 100644 index 00000000..858325f9 --- /dev/null +++ b/orchestra/apps/users/roles/mail/models.py @@ -0,0 +1,110 @@ +import re + +from django.contrib.auth.hashers import check_password, make_password +from django.core.validators import RegexValidator +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from orchestra.core import services + +from . import validators, settings + + +class Mailbox(models.Model): + user = models.OneToOneField('users.User', verbose_name=_("User"), + related_name='mailbox') + use_custom_filtering = models.BooleanField(_("Use custom filtering"), + default=False) + custom_filtering = models.TextField(_("filtering"), blank=True, + validators=[validators.validate_sieve], + help_text=_("Arbitrary email filtering in sieve language.")) + + class Meta: + verbose_name_plural = _("mailboxes") + + def __unicode__(self): + return self.user.username + +# def get_addresses(self): +# regex = r'(^|\s)+%s(\s|$)+' % self.user.username +# return Address.objects.filter(destination__regex=regex) +# +# def delete(self, *args, **kwargs): +# """ Update related addresses """ +# regex = re.compile(r'(^|\s)+(\s*%s)(\s|$)+' % self.user.username) +# super(Mailbox, self).delete(*args, **kwargs) +# for address in self.get_addresses(): +# address.destination = regex.sub(r'\3', address.destination).strip() +# if not address.destination: +# address.delete() +# else: +# address.save() + + +#class Address(models.Model): +# name = models.CharField(_("name"), max_length=64, +# validators=[validators.validate_emailname]) +# domain = models.ForeignKey(settings.EMAILS_DOMAIN_MODEL, +# verbose_name=_("domain"), +# related_name='addresses') +# destination = models.CharField(_("destination"), max_length=256, +# validators=[validators.validate_destination], +# help_text=_("Space separated mailbox names or email addresses")) +# account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), +# related_name='addresses') +# +# class Meta: +# verbose_name_plural = _("addresses") +# unique_together = ('name', 'domain') +# +# def __unicode__(self): +# return self.email +# +# @property +# def email(self): +# return "%s@%s" % (self.name, self.domain) +# +# def get_mailboxes(self): +# for dest in self.destination.split(): +# if '@' not in dest: +# yield Mailbox.objects.select_related('user').get(user__username=dest) + + +class Address(models.Model): + name = models.CharField(_("name"), max_length=64, + validators=[validators.validate_emailname]) + domain = models.ForeignKey(settings.EMAILS_DOMAIN_MODEL, + verbose_name=_("domain"), + related_name='addresses') + mailboxes = models.ManyToManyField('mail.Mailbox', verbose_name=_("mailboxes"), + related_name='addresses', blank=True) + forward = models.CharField(_("forward"), max_length=256, blank=True, + validators=[validators.validate_forward]) + account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), + related_name='addresses') + + class Meta: + verbose_name_plural = _("addresses") + unique_together = ('name', 'domain') + + def __unicode__(self): + return self.email + + @property + def email(self): + return "%s@%s" % (self.name, self.domain) + + +class Autoresponse(models.Model): + address = models.OneToOneField(Address, verbose_name=_("address"), + related_name='autoresponse') + # TODO initial_date + subject = models.CharField(_("subject"), max_length=256) + message = models.TextField(_("message")) + enabled = models.BooleanField(_("enabled"), default=False) + + def __unicode__(self): + return self.address + + +services.register(Address) diff --git a/orchestra/apps/users/roles/mail/serializers.py b/orchestra/apps/users/roles/mail/serializers.py new file mode 100644 index 00000000..24bf1a70 --- /dev/null +++ b/orchestra/apps/users/roles/mail/serializers.py @@ -0,0 +1,43 @@ +from rest_framework import serializers + +from orchestra.api import router +from orchestra.apps.accounts.serializers import AccountSerializerMixin + +from .models import Address, Mailbox + + +#class AddressSerializer(serializers.HyperlinkedModelSerializer): +# class Meta: +# model = Address +# fields = ('url', 'name', 'domain', 'destination') + + +class NestedMailboxSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = Mailbox + fields = ('url', 'use_custom_filtering', 'custom_filtering') + + +class MailboxSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = Mailbox + fields = ('url', 'user', 'use_custom_filtering', 'custom_filtering') + + +class AddressSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer): + class Meta: + model = Address + fields = ('url', 'name', 'domain', 'mailboxes', 'forward') + + def get_fields(self, *args, **kwargs): + fields = super(AddressSerializer, self).get_fields(*args, **kwargs) + account = self.context['view'].request.user.account_id + mailboxes = fields['mailboxes'].queryset.select_related('user') + fields['mailboxes'].queryset = mailboxes.filter(user__account=account) + # TODO do it on permissions or in self.filter_by_account_field ? + domain = fields['domain'].queryset + fields['domain'].queryset = domain .filter(account=account) + return fields + + +router.insert('users', 'mailbox', NestedMailboxSerializer, required=False) diff --git a/orchestra/apps/users/roles/mail/settings.py b/orchestra/apps/users/roles/mail/settings.py new file mode 100644 index 00000000..fcca1e32 --- /dev/null +++ b/orchestra/apps/users/roles/mail/settings.py @@ -0,0 +1,29 @@ +from django.conf import settings + + +EMAILS_DOMAIN_MODEL = getattr(settings, 'EMAILS_DOMAIN_MODEL', 'domains.Domain') + +EMAILS_HOME = getattr(settings, 'EMAILS_HOME', '/home/%(username)s/') + +EMAILS_SIEVETEST_PATH = getattr(settings, 'EMAILS_SIEVETEST_PATH', '/dev/shm') + +EMAILS_SIEVETEST_BIN_PATH = getattr(settings, 'EMAILS_SIEVETEST_BIN_PATH', + '%(orchestra_root)s/bin/sieve-test') + + +EMAILS_VIRTUSERTABLE_PATH = getattr(settings, 'EMAILS_VIRTUSERTABLE_PATH', + '/etc/postfix/virtusertable') + + +EMAILS_VIRTDOMAINS_PATH = getattr(settings, 'EMAILS_VIRTDOMAINS_PATH', + '/etc/postfix/virtdomains') + + +EMAILS_DEFAUL_FILTERING = getattr(settings, 'EMAILS_DEFAULT_FILTERING', + 'require ["fileinto","regex","envelope","vacation","reject","relational","comparator-i;ascii-numeric"];\n' + '\n' + 'if header :value "ge" :comparator "i;ascii-numeric" "X-Spam-Score" "5" {\n' + ' fileinto "Junk";\n' + ' discard;\n' + '}' +) diff --git a/orchestra/apps/users/roles/mail/validators.py b/orchestra/apps/users/roles/mail/validators.py new file mode 100644 index 00000000..55d241a4 --- /dev/null +++ b/orchestra/apps/users/roles/mail/validators.py @@ -0,0 +1,63 @@ +import hashlib +import os +import re + +from django.core.validators import ValidationError, EmailValidator +from django.utils.translation import ugettext_lazy as _ + +from orchestra.utils import paths +from orchestra.utils.system import run + +from . import settings + + +def validate_emailname(value): + msg = _("'%s' is not a correct email name" % value) + if '@' in value: + raise ValidationError(msg) + value += '@localhost' + try: + EmailValidator(value) + except ValidationError: + raise ValidationError(msg) + + +#def validate_destination(value): +# """ space separated mailboxes or emails """ +# for destination in value.split(): +# msg = _("'%s' is not an existent mailbox" % destination) +# if '@' in destination: +# if not destination[-1].isalpha(): +# raise ValidationError(msg) +# EmailValidator(destination) +# else: +# from .models import Mailbox +# if not Mailbox.objects.filter(user__username=destination).exists(): +# raise ValidationError(msg) +# validate_emailname(destination) + + +def validate_forward(value): + """ space separated mailboxes or emails """ + for destination in value.split(): + EmailValidator(destination) + + +def validate_sieve(value): + from .models import Mailbox + sieve_name = '%s.sieve' % hashlib.md5(value).hexdigest() + path = os.path.join(settings.EMAILS_SIEVETEST_PATH, sieve_name) + with open(path, 'wb') as f: + f.write(value) + context = { + 'orchestra_root': paths.get_orchestra_root() + } + sievetest = settings.EMAILS_SIEVETEST_BIN_PATH % context + test = run(' '.join([sievetest, path, '/dev/null']), display=False) + if test.return_code: + errors = [] + for line in test.stderr.splitlines(): + error = re.match(r'^.*(line\s+[0-9]+:.*)', line) + if error: + errors += error.groups() + raise ValidationError(' '.join(errors)) diff --git a/orchestra/apps/users/roles/owncloud/__init__.py b/orchestra/apps/users/roles/owncloud/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/users/roles/posix/__init__.py b/orchestra/apps/users/roles/posix/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/users/roles/posix/admin.py b/orchestra/apps/users/roles/posix/admin.py new file mode 100644 index 00000000..45bbae6c --- /dev/null +++ b/orchestra/apps/users/roles/posix/admin.py @@ -0,0 +1,17 @@ +from django.contrib import admin +from django.contrib.auth import get_user_model +from django.utils.translation import ugettext_lazy as _ + +from orchestra.admin.utils import insertattr +from orchestra.apps.users.roles.admin import RoleAdmin + +from .models import POSIX + + +class POSIXRoleAdmin(RoleAdmin): + model = POSIX + name = 'posix' + url_name = 'posix' + + +insertattr(get_user_model(), 'roles', POSIXRoleAdmin) diff --git a/orchestra/apps/users/roles/posix/models.py b/orchestra/apps/users/roles/posix/models.py new file mode 100644 index 00000000..2164d4cd --- /dev/null +++ b/orchestra/apps/users/roles/posix/models.py @@ -0,0 +1,16 @@ +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from . import settings + + +class POSIX(models.Model): + user = models.OneToOneField('users.User', verbose_name=_("user"), + related_name='posix') + home = models.CharField(_("home"), max_length=256, blank=True, + help_text=_("Home directory relative to account's ~primary_user")) + shell = models.CharField(_("shell"), max_length=32, + choices=settings.POSIX_SHELLS, default=settings.POSIX_DEFAULT_SHELL) + + def __unicode__(self): + return str(self.user) diff --git a/orchestra/apps/users/roles/posix/serializers.py b/orchestra/apps/users/roles/posix/serializers.py new file mode 100644 index 00000000..3dc341c5 --- /dev/null +++ b/orchestra/apps/users/roles/posix/serializers.py @@ -0,0 +1,14 @@ +from rest_framework import serializers + +from orchestra.api import router + +from .models import POSIX + + +class POSIXSerializer(serializers.ModelSerializer): + class Meta: + model = POSIX + fields = ('home', 'shell') + + +router.insert('users', 'posix', POSIXSerializer, required=False) diff --git a/orchestra/apps/users/roles/posix/settings.py b/orchestra/apps/users/roles/posix/settings.py new file mode 100644 index 00000000..36860863 --- /dev/null +++ b/orchestra/apps/users/roles/posix/settings.py @@ -0,0 +1,11 @@ +from django.conf import settings +from django.utils.translation import ugettext, ugettext_lazy as _ + + +POSIX_SHELLS = getattr(settings, 'POSIX_SHELLS', ( + ('/bin/false', _("FTP/sFTP only")), + ('/bin/rsync', _("rsync shell")), + ('/bin/bash', "Bash"), +)) + +POSIX_DEFAULT_SHELL = getattr(settings, 'POSIX_DEFAULT_SHELL', '/bin/false') diff --git a/orchestra/apps/users/serializers.py b/orchestra/apps/users/serializers.py new file mode 100644 index 00000000..321e8b02 --- /dev/null +++ b/orchestra/apps/users/serializers.py @@ -0,0 +1,35 @@ +from django.contrib.auth import get_user_model +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 + + +class UserSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer): + password = serializers.CharField(max_length=128, label=_('Password'), + validators=[validate_password], write_only=True, required=False, + widget=widgets.PasswordInput) + + class Meta: + model = get_user_model() + fields = ( + 'url', 'username', 'password', 'first_name', 'last_name', 'email', + 'is_admin', 'is_active', + ) + + def validate_password(self, attrs, source): + """ POST only password """ + if self.object.pk: + if 'password' in attrs: + raise serializers.ValidationError(_("Can not set password")) + elif 'password' not in attrs: + raise serializers.ValidationError(_("Password required")) + return attrs + + def save_object(self, obj, **kwargs): + # FIXME this method will be called when saving nested serializers :( + if not obj.pk: + obj.set_password(obj.password) + super(UserSerializer, self).save_object(obj, **kwargs) diff --git a/orchestra/apps/users/settings.py b/orchestra/apps/users/settings.py new file mode 100644 index 00000000..9521bf19 --- /dev/null +++ b/orchestra/apps/users/settings.py @@ -0,0 +1,4 @@ +from django.conf import settings + + +USERS_SYSTEMUSER_HOME = getattr(settings, 'USERES_SYSTEMUSER_HOME', '/home/%(username)s') diff --git a/orchestra/apps/users/templates/admin/users/user/change_form.html b/orchestra/apps/users/templates/admin/users/user/change_form.html new file mode 100644 index 00000000..82814f98 --- /dev/null +++ b/orchestra/apps/users/templates/admin/users/user/change_form.html @@ -0,0 +1,15 @@ +{% extends "admin/change_form.html" %} +{% load i18n admin_urls %} + +{% block object-tools-items %} +
  • {% trans "User" %}
  • +{% for item in roles %} +
  • {% if item.exists %}{{ item.name.capitalize }}{% else %}Add {{ item.name }}{% endif %}
  • +{% endfor %} +
  • + {% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %} + {% trans "History" %} +
  • +{% if has_absolute_url %}
  • {% trans "View on site" %}
  • {% endif%} +{% endblock %} + diff --git a/orchestra/apps/users/templates/admin/users/user/delete_role.html b/orchestra/apps/users/templates/admin/users/user/delete_role.html new file mode 100644 index 00000000..ae94df22 --- /dev/null +++ b/orchestra/apps/users/templates/admin/users/user/delete_role.html @@ -0,0 +1,15 @@ +{% extends "admin/delete_confirmation.html" %} +{% load i18n admin_urls %} + + +{% block breadcrumbs %} + +{% endblock %} + diff --git a/orchestra/apps/users/templates/admin/users/user/role.html b/orchestra/apps/users/templates/admin/users/user/role.html new file mode 100644 index 00000000..75927310 --- /dev/null +++ b/orchestra/apps/users/templates/admin/users/user/role.html @@ -0,0 +1,70 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls admin_static admin_modify utils %} + + +{% block extrastyle %} +{{ block.super }} + +{{ media }} +{% endblock %} + +{% block coltype %}colM{% endblock %} +{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-form{% endblock %} + + +{% block breadcrumbs %} + +{% endblock %} + + + +{% block content %}
    +{% block object-tools %} + +{% endblock %} + +
    {% csrf_token %} +
    + {% for field in form %} +
    + + {% if not line.fields|length_is:'1' and not field.is_readonly %}{{ field.errors }}{% endif %} + {% if field|is_checkbox %} + {{ field }} + {% else %} + {{ field.label_tag }} {{ field }} + {% endif %} + {% if field.help_text %} +

    {{ field.help_text|safe }}

    + {% endif %} +
    +
    + {% endfor %} + + +
    + + {% if role.exists %}{% endif %} + +
    + + +{% endblock %} diff --git a/orchestra/apps/vps/__init__.py b/orchestra/apps/vps/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/vps/admin.py b/orchestra/apps/vps/admin.py new file mode 100644 index 00000000..236b82c8 --- /dev/null +++ b/orchestra/apps/vps/admin.py @@ -0,0 +1,50 @@ +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.translation import ugettext_lazy as _ + +from orchestra.admin import ExtendedModelAdmin +from orchestra.apps.accounts.admin import AccountAdminMixin + +from .forms import VPSChangeForm, VPSCreationForm +from .models import VPS + + +class VPSAdmin(AccountAdminMixin, ExtendedModelAdmin): + list_display = ('hostname', 'type', 'template', 'account_link') + list_filter = ('type', 'template') + form = VPSChangeForm + add_form = VPSCreationForm + readonly_fields = ('account_link',) + change_readonly_fields = ('account', 'name', 'type', 'template') + fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('account_link', 'hostname', 'type', 'template') + }), + (_("Login"), { + 'classes': ('wide',), + 'fields': ('password',) + }) + ) + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('account', 'hostname', 'type', 'template') + }), + (_("Login"), { + 'classes': ('wide',), + 'fields': ('password1', 'password2',) + }), + ) + + def get_urls(self): + useradmin = UserAdmin(VPS, self.admin_site) + return patterns('', + (r'^(\d+)/password/$', + self.admin_site.admin_view(useradmin.user_change_password)) + ) + super(VPSAdmin, self).get_urls() + + +admin.site.register(VPS, VPSAdmin) diff --git a/orchestra/apps/vps/forms.py b/orchestra/apps/vps/forms.py new file mode 100644 index 00000000..22ed7574 --- /dev/null +++ b/orchestra/apps/vps/forms.py @@ -0,0 +1,39 @@ +from django import forms +from django.contrib.auth.forms import UserCreationForm, ReadOnlyPasswordHashField +from django.utils.translation import ugettext_lazy as _ + + +class VPSCreationForm(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.")) + + class Meta: + fields = ('username', 'account', 'type', 'template') + + 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): + vps = super(VPSCreationForm, self).save(commit=False) + vps.set_password(self.cleaned_data["password1"]) + if commit: + vps.save() + return vps + + +class VPSChangeForm(forms.ModelForm): + password = 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 this form.")) + + def clean_password(self): + return self.initial["password"] diff --git a/orchestra/apps/vps/models.py b/orchestra/apps/vps/models.py new file mode 100644 index 00000000..476acad9 --- /dev/null +++ b/orchestra/apps/vps/models.py @@ -0,0 +1,37 @@ +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from django.contrib.auth.hashers import make_password + +from orchestra.core import services +from orchestra.core.validators import validate_hostname + +from . import settings + + +class VPS(models.Model): + hostname = models.CharField(_("hostname"), max_length=256, unique=True, + validators=[validate_hostname]) + type = models.CharField(_("type"), max_length=64, choices=settings.VPS_TYPES, + default=settings.VPS_DEFAULT_TYPE) + template = models.CharField(_("template"), max_length=64, + choices=settings.VPS_TEMPLATES, default=settings.VPS_DEFAULT_TEMPLATE) + password = models.CharField(_('password'), max_length=128, + help_text=_("root password of this virtual machine")) + account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), + related_name='vpss') + + class Meta: + verbose_name = "VPS" + verbose_name_plural = "VPSs" + + def __unicode__(self): + return self.hostname + + def set_password(self, raw_password): + self.password = make_password(raw_password) + + def get_username(self): + return self.hostname + + +services.register(VPS) diff --git a/orchestra/apps/vps/settings.py b/orchestra/apps/vps/settings.py new file mode 100644 index 00000000..60179aad --- /dev/null +++ b/orchestra/apps/vps/settings.py @@ -0,0 +1,17 @@ +from django.conf import settings + + +VPS_TYPES = getattr(settings, 'VPS_TYPES', ( + ('openvz', 'OpenVZ container'), +)) + + +VPS_DEFAULT_TYPE = getattr(settings, 'VPS_DEFAULT_TYPE', 'openvz') + + +VPS_TEMPLATES = getattr(settings, 'VPS_TEMPLATES', ( + ('debian7', 'Debian 7 - Wheezy'), +)) + + +VPS_DEFAULT_TEMPLATE = getattr(settings, 'VPS_DEFAULT_TEMPLATE', 'debian7') diff --git a/orchestra/apps/webapps/__init__.py b/orchestra/apps/webapps/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/webapps/admin.py b/orchestra/apps/webapps/admin.py new file mode 100644 index 00000000..00aca017 --- /dev/null +++ b/orchestra/apps/webapps/admin.py @@ -0,0 +1,48 @@ +from django import forms +from django.contrib import admin +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext_lazy as _ + +from orchestra.admin import ExtendedModelAdmin +from orchestra.apps.accounts.admin import SelectAccountAdminMixin + +from .models import WebApp, WebAppOption + + +class WebAppOptionInline(admin.TabularInline): + model = WebAppOption + extra = 1 + + 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(WebAppOptionInline, self).formfield_for_dbfield(db_field, **kwargs) + + +class WebAppAdmin(SelectAccountAdminMixin, ExtendedModelAdmin): + fields = ('account_link', 'name', 'type') + list_display = ('name', 'type', 'display_websites', 'account_link') + list_filter = ('type',) + inlines = [WebAppOptionInline] + readonly_fields = ('account_link',) + change_readonly_fields = ('name', 'type') + + def display_websites(self, webapp): + websites = [] + for content in webapp.content_set.all().select_related('website'): + website = content.website + url = reverse('admin:websites_website_change', args=(website.pk,)) + name = "%s on %s" % (website.name, content.path) + websites.append('%s' % (url, name)) + return '
    '.join(websites) + display_websites.short_description = _("web sites") + display_websites.allow_tags = True + + +admin.site.register(WebApp, WebAppAdmin) diff --git a/orchestra/apps/webapps/api.py b/orchestra/apps/webapps/api.py new file mode 100644 index 00000000..3e8eac7d --- /dev/null +++ b/orchestra/apps/webapps/api.py @@ -0,0 +1,28 @@ +from rest_framework import viewsets +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 WebApp +from .serializers import WebAppSerializer + + +class WebAppViewSet(AccountApiMixin, viewsets.ModelViewSet): + model = WebApp + serializer_class = WebAppSerializer + filter_fields = ('name',) + + @collectionlink() + def configuration(self, request): + names = [ + 'WEBAPPS_BASE_ROOT', 'WEBAPPS_TYPES', 'WEBAPPS_WEBAPP_OPTIONS', + 'WEBAPPS_PHP_DISABLED_FUNCTIONS', 'WEBAPPS_DEFAULT_TYPE' + ] + return Response({ + name: getattr(settings, name, None) for name in names + }) + + +router.register(r'webapps', WebAppViewSet) diff --git a/orchestra/apps/webapps/backends/__init__.py b/orchestra/apps/webapps/backends/__init__.py new file mode 100644 index 00000000..07763320 --- /dev/null +++ b/orchestra/apps/webapps/backends/__init__.py @@ -0,0 +1,26 @@ +import pkgutil + + +class WebAppServiceMixin(object): + model = 'webapps.WebApp' + + def create_webapp_dir(self, context): + self.append("mkdir -p '%(app_path)s'" % context) + self.append("chown %(user)s.%(group)s '%(app_path)s'" % context) + + def delete_webapp_dir(self, context): + self.append("rm -fr %(app_path)s" % context) + + def get_context(self, webapp): + return { + 'user': webapp.account.user.username, + 'group': webapp.account.user.username, + 'app_name': webapp.name, + 'type': webapp.type, + 'app_path': webapp.get_path(), + 'banner': self.get_banner(), + } + + +for __, module_name, __ in pkgutil.walk_packages(__path__): + exec('from . import %s' % module_name) diff --git a/orchestra/apps/webapps/backends/awstats.py b/orchestra/apps/webapps/backends/awstats.py new file mode 100644 index 00000000..578c8a6d --- /dev/null +++ b/orchestra/apps/webapps/backends/awstats.py @@ -0,0 +1,12 @@ +from django.utils.translation import ugettext_lazy as _ + +from orchestra.apps.orchestration import ServiceBackend + +from . import WebAppServiceMixin + + +class AwstatsBackend(WebAppServiceMixin, ServiceBackend): + verbose_name = _("Awstats") + + def save(self, webapp): + pass diff --git a/orchestra/apps/webapps/backends/dokuwikimu.py b/orchestra/apps/webapps/backends/dokuwikimu.py new file mode 100644 index 00000000..0356af23 --- /dev/null +++ b/orchestra/apps/webapps/backends/dokuwikimu.py @@ -0,0 +1,28 @@ +from django.utils.translation import ugettext_lazy as _ + +from orchestra.apps.orchestration import ServiceBackend + +from . import WebAppServiceMixin +from .. import settings + +class DokuWikiMuBackend(WebAppServiceMixin, ServiceBackend): + verbose_name = _("DokuWiki multisite") + + def save(self, webapp): + context = self.get_context(webapp) + self.append("mkdir %(app_path)" % context) + self.append("tar xfz %(template)s -C %(app_path)s" % context) + self.append("chown -R www-data %(app_path)s" % context) + # TODO move dokuwiki to user directory + + def delete(self, webapp): + context = self.get_context(webapp) + self.append("rm -fr %(app_path)s" % context) + + def get_context(self, webapp): + context = super(DokuwikiMuBackend, self).get_context(webapp) + context.update({ + 'template': settings.WEBAPPS_DOKUWIKIMU_TEMPLATE_PATH, + 'app_path': os.path.join(settings.WEBAPPS_DOKUWIKIMU_FARM_PATH, webapp.name) + }) + return context diff --git a/orchestra/apps/webapps/backends/drupalmu.py b/orchestra/apps/webapps/backends/drupalmu.py new file mode 100644 index 00000000..4ca40a0d --- /dev/null +++ b/orchestra/apps/webapps/backends/drupalmu.py @@ -0,0 +1,35 @@ +import os + +from django.utils.translation import ugettext_lazy as _ + +from orchestra.apps.orchestration import ServiceBackend + +from . import WebAppServiceMixin +from .. import settings + + +class DrupalMuBackend(WebAppServiceMixin, ServiceBackend): + verbose_name = _("Drupal multisite") + + def save(self, webapp): + context = self.get_context(webapp) + self.append("mkdir %(drupal_path)s" % context) + self.append("chown -R www-data %(drupal_path)s" % context) + self.append( + "# the following assumes settings.php to be previously configured\n" + "REGEX='^\s*$databases\[.default.\]\[.default.\]\[.prefix.\]'\n" + "CONFIG='$databases[\'default\'][\'default\'][\'prefix\'] = \'%(app_name)s_\';'\n" + "if [[ ! $(grep $REGEX %(drupal_settings)s) ]]; then\n" + " echo $CONFIG >> %(drupal_settings)s\n" + "fi" % context + ) + + def selete(self, webapp): + context = self.get_context(webapp) + self.append("rm -fr %(app_path)s" % context) + + def get_context(self, webapp): + context = super(DrupalMuBackend, self).get_context(webapp) + context['drupal_path'] = settings.WEBAPPS_DRUPAL_SITES_PATH % context + context['drupal_settings'] = os.path.join(context['drupal_path'], 'settings.php') + return context diff --git a/orchestra/apps/webapps/backends/phpfcgid.py b/orchestra/apps/webapps/backends/phpfcgid.py new file mode 100644 index 00000000..ec808519 --- /dev/null +++ b/orchestra/apps/webapps/backends/phpfcgid.py @@ -0,0 +1,48 @@ +import os + +from django.utils.translation import ugettext_lazy as _ + +from orchestra.apps.orchestration import ServiceBackend + +from . import WebAppServiceMixin +from .. import settings + + +class PHPFcgidBackend(WebAppServiceMixin, ServiceBackend): + verbose_name = _("PHP-Fcgid") + + def save(self, webapp): + context = self.get_context(webapp) + self.create_webapp_dir(context) + self.append("mkdir -p %(wrapper_dir)s" % context) + self.append( + "{ echo -e '%(wrapper_content)s' | diff -N -I'^\s*#' %(wrapper_path)s - ; } ||" + " { echo -e '%(wrapper_content)s' > %(wrapper_path)s; UPDATED=1; }" % context) + self.append("chmod +x %(wrapper_path)s" % context) + self.append("chown -R %(user)s.%(group)s %(wrapper_dir)s" % context) + + def delete(self, webapp): + context = self.get_context(webapp) + self.delete_webapp_dir(context) + + def get_context(self, webapp): + context = super(PHPFcgidBackend, self).get_context(webapp) + init_vars = webapp.get_php_init_vars() + if init_vars: + init_vars = [ '%s="%s"' % (k,v) for v,k in init_vars.iteritems() ] + init_vars = ', -d '.join(init_vars) + context['init_vars'] = '-d %s' % init_vars + else: + context['init_vars'] = '' + wrapper_path = settings.WEBAPPS_FCGID_PATH % context + context.update({ + 'wrapper_content': ( + "#!/bin/sh\n" + "# %(banner)s\n" + "export PHPRC=/etc/%(type)s/cgi/\n" + "exec /usr/bin/%(type)s-cgi %(init_vars)s\n" + ) % context, + 'wrapper_path': wrapper_path, + 'wrapper_dir': os.path.dirname(wrapper_path), + }) + return context diff --git a/orchestra/apps/webapps/backends/phpfpm.py b/orchestra/apps/webapps/backends/phpfpm.py new file mode 100644 index 00000000..edcc9963 --- /dev/null +++ b/orchestra/apps/webapps/backends/phpfpm.py @@ -0,0 +1,58 @@ +import os + +from django.template import Template, Context +from django.utils.translation import ugettext_lazy as _ + +from orchestra.apps.orchestration import ServiceBackend + +from . import WebAppServiceMixin +from .. import settings + + +class PHPFPMBackend(WebAppServiceMixin, ServiceBackend): + verbose_name = _("PHP-FPM") + + def save(self, webapp): + context = self.get_context(webapp) + self.create_webapp_dir(context) + self.append( + "{ echo -e '%(fpm_config)s' | diff -N -I'^\s*;;' %(fpm_path)s - ; } ||" + " { echo -e '%(fpm_config)s' > %(fpm_path)s; UPDATEDFPM=1; }" % context + ) + + def delete(self, webapp): + context = self.get_context(webapp) + self.delete_webapp_dir(context) + + def commit(self): + super(PHPFPMBackend, self).commit() + self.append('[[ $UPDATEDFPM == 1 ]] && service php5-fpm reload') + + def get_context(self, webapp): + context = super(PHPFPMBackend, self).get_context(webapp) + context.update({ + 'init_vars': webapp.get_php_init_vars(), + 'fpm_port': webapp.get_fpm_port(), + }) + context['fpm_listen'] = settings.WEBAPPS_FPM_LISTEN % context + fpm_config = Template( + "[{{ user }}]\n" + ";; {{ banner }}\n" + "user = {{ user }}\n" + "group = {{ group }}\n\n" + "listen = {{ fpm_listen | safe }}\n" + "listen.owner = {{ user }}\n" + "listen.group = {{ group }}\n" + "pm = ondemand\n" + "pm.max_children = 4\n" + "{% for name,value in init_vars.iteritems %}" + "php_admin_value[{{ name | safe }}] = {{ value | safe }}\n" + "{% endfor %}" + ) + fpm_file = '%(user)s.conf' % context + context.update({ + 'fpm_config': fpm_config.render(Context(context)), + 'fpm_path': os.path.join(settings.WEBAPPS_PHPFPM_POOL_PATH, fpm_file), + }) + return context + diff --git a/orchestra/apps/webapps/backends/static.py b/orchestra/apps/webapps/backends/static.py new file mode 100644 index 00000000..54ca7c5b --- /dev/null +++ b/orchestra/apps/webapps/backends/static.py @@ -0,0 +1,17 @@ +from django.utils.translation import ugettext_lazy as _ + +from orchestra.apps.orchestration import ServiceBackend + +from . import WebAppServiceMixin + + +class StaticBackend(WebAppServiceMixin, ServiceBackend): + verbose_name = _("Static") + + def save(self, webapp): + context = self.get_context(webapp) + self.create_webapp_dir(context) + + def delete(self, webapp): + context = self.get_context(webapp) + self.delete_webapp_dir(context) diff --git a/orchestra/apps/webapps/backends/wordpressmu.py b/orchestra/apps/webapps/backends/wordpressmu.py new file mode 100644 index 00000000..de682b4b --- /dev/null +++ b/orchestra/apps/webapps/backends/wordpressmu.py @@ -0,0 +1,101 @@ +import re +import sys + +import requests +from django.utils.translation import ugettext_lazy as _ + +from orchestra.apps.orchestration import ServiceBackend + +from . import WebAppServiceMixin +from .. import settings + + +class WordpressMuBackend(WebAppServiceMixin, ServiceBackend): + verbose_name = _("Wordpress multisite") + + @property + def script(self): + return self.cmds + + def login(self, session): + base_url = self.get_base_url() + login_url = base_url + '/wp-login.php' + login_data = { + 'log': 'admin', + 'pwd': settings.WEBAPPS_WORDPRESSMU_ADMIN_PASSWORD, + 'redirect_to': '/wp-admin/' + } + response = session.post(login_url, data=login_data) + if response.url != base_url + '/wp-admin/': + raise IOError("Failure login to remote application") + + def get_base_url(self): + base_url = settings.WEBAPPS_WORDPRESSMU_BASE_URL + if base_url.endswith('/'): + base_url = base_url[:-1] + return base_url + + def create_blog(self, webapp, server): + emails = webapp.account.contacts.filter(email_usage__contains='') + email = emails.values_list('email', flat=True).first() + + base_url = self.get_base_url() + session = requests.Session() + self.login(session) + + url = base_url + '/wp-admin/network/site-new.php' + content = session.get(url).content + wpnonce = re.compile('name="_wpnonce_add-blog"\s+value="([^"]*)"') + wpnonce = wpnonce.search(content).groups()[0] + + url += '?action=add-site' + data = { + 'blog[domain]': webapp.name, + 'blog[title]': webapp.name, + 'blog[email]': email, + '_wpnonce_add-blog': wpnonce, + } + response = session.post(url, data=data) + + def delete_blog(self, webapp, server): + # OH, I've enjoied so much coding this methods that I want to thanks + # the wordpress team for the excellent software they are producing + context = self.get_context(webapp) + session = requests.Session() + self.login(session) + + base_url = self.get_base_url() + search = base_url + '/wp-admin/network/sites.php?s=%(name)s&action=blogs' % context + regex = re.compile( + '%(name)s' % context + ) + content = session.get(search).content + ids = regex.search(content).groups() + if len(ids) > 1: + raise ValueError("Multiple matches") + + delete = re.compile('(.*)') + content = delete.search(content).groups()[0] + wpnonce = re.compile('_wpnonce=([^"]*)"') + wpnonce = wpnonce.search(content).groups()[0] + delete = '/wp-admin/network/sites.php?action=confirm&action2=deleteblog' + delete += '&id=%d&_wpnonce=%d' % (ids[0], wpnonce) + + content = session.get(delete).content + wpnonce = re.compile('name="_wpnonce"\s+value="([^"]*)"') + wpnonce = wpnonce.search(content).groups()[0] + data = { + 'action': 'deleteblog', + 'id': ids[0], + '_wpnonce': wpnonce, + '_wp_http_referer': '/wp-admin/network/sites.php', + } + delete = base_url + '/wp-admin/network/sites.php?action=deleteblog' + session.post(delete, data=data) + + def save(self, webapp): + self.append(self.create_blog, webapp) + + def delete(self, webapp): + self.append(self.delete_blog, webapp) diff --git a/orchestra/apps/webapps/models.py b/orchestra/apps/webapps/models.py new file mode 100644 index 00000000..573f5817 --- /dev/null +++ b/orchestra/apps/webapps/models.py @@ -0,0 +1,98 @@ +import re + +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from orchestra.core import validators, services +from orchestra.utils.functional import cached + +from . import settings + + +def settings_to_choices(choices): + return sorted( + [ (name, opt[0]) for name,opt in choices.iteritems() ], + key=lambda e: e[1] + ) + + +class WebApp(models.Model): + """ Represents a web application """ + name = models.CharField(_("name"), max_length=128, + validators=[validators.validate_name]) + type = models.CharField(_("type"), max_length=32, + choices=settings_to_choices(settings.WEBAPPS_TYPES), + default=settings.WEBAPPS_DEFAULT_TYPE) + account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), + related_name='webapps') + + class Meta: + unique_together = ('name', 'account') + verbose_name = _("Web App") + verbose_name_plural = _("Web Apps") + + def __unicode__(self): + return self.name + + @cached + def get_options(self): + return { opt.name: opt.value for opt in self.options.all() } + + def get_php_init_vars(self): + init_vars = [] + options = WebAppOption.objects.filter(webapp__type=self.type) + for opt in options.filter(webapp__account=self.account): + name = opt.name.replace('PHP-', '') + value = "%s" % opt.value + init_vars.append((name, value)) + enabled_functions = self.options.filter(name='enabled_functions') + if enabled_functions: + enabled_functions = enabled_functions.get().value.split(',') + disabled_functions = [] + for function in settings.WEBAPPS_PHP_DISABLED_FUNCTIONS: + if function not in enabled_functions: + disabled_functions.append(function) + init_vars.append(('dissabled_functions', ','.join(disabled_functions))) + return init_vars + + def get_fpm_port(self): + return settings.WEBAPPS_FPM_START_PORT + self.account.user.pk + + def get_method(self): + method = settings.WEBAPPS_TYPES[self.type] + args = method[2] if len(method) == 4 else () + return method[1], args + + def get_path(self): + context = { + 'user': self.account.user, + 'app_name': self.name, + } + return settings.WEBAPPS_BASE_ROOT % context + + +class WebAppOption(models.Model): + webapp = models.ForeignKey(WebApp, verbose_name=_("Web application"), + related_name='options') + name = models.CharField(_("name"), max_length=128, + choices=settings_to_choices(settings.WEBAPPS_OPTIONS)) + value = models.CharField(_("value"), max_length=256) + + class Meta: + unique_together = ('webapp', 'name') + verbose_name = _("option") + verbose_name_plural = _("options") + + def __unicode__(self): + return self.name + + def clean(self): + """ validates name and value according to WEBAPPS_OPTIONS """ + __, regex = settings.WEBAPPS_OPTIONS[self.name] + if not re.match(regex, self.value): + msg = _("'%s' does not match %s") + raise ValidationError(msg % (self.value, regex)) + + +services.register(WebApp) diff --git a/orchestra/apps/webapps/serializers.py b/orchestra/apps/webapps/serializers.py new file mode 100644 index 00000000..111abcae --- /dev/null +++ b/orchestra/apps/webapps/serializers.py @@ -0,0 +1,14 @@ +from rest_framework import serializers + +from orchestra.api.fields import OptionField +from orchestra.apps.accounts.serializers import AccountSerializerMixin + +from .models import WebApp + + +class WebAppSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer): + options = OptionField(required=False) + + class Meta: + model = WebApp + fields = ('url', 'name', 'type', 'options') diff --git a/orchestra/apps/webapps/settings.py b/orchestra/apps/webapps/settings.py new file mode 100644 index 00000000..28254331 --- /dev/null +++ b/orchestra/apps/webapps/settings.py @@ -0,0 +1,200 @@ +from django.conf import settings +from django.utils.translation import ugettext_lazy as _ + + +WEBAPPS_BASE_ROOT = getattr(settings, 'WEBAPPS_BASE_ROOT', '/home/%(user)s/webapps/%(app_name)s/') + + +WEBAPPS_FPM_LISTEN = getattr(settings, 'WEBAPPS_FPM_LISTEN', +# '/var/run/%(user)s-%(app_name)s.sock') + '127.0.0.1:%(fpm_port)s') + +WEBAPPS_FPM_START_PORT = getattr(settings, 'WEBAPPS_FPM_START_PORT', 10000) + +WEBAPPS_FCGID_PATH = getattr(settings, 'WEBAPPS_FCGID_PATH', + '/home/httpd/fcgid/%(user)s/%(type)s-wrapper') + + +WEBAPPS_TYPES = getattr(settings, 'WEBAPPS_TYPES', { + # { name: ( verbose_name, method_name, method_args, description) } + 'php5.5': ( + _("PHP 5.5"), +# 'fpm', ('unix:/var/run/%(user)s-%(app_name)s.sock|fcgi://127.0.0.1%(app_path)s',), + 'fpm', ('fcgi://127.0.0.1:%(fpm_port)s%(app_path)s',), + _("This creates a PHP5.5 application under ~/webapps/\n" + "PHP-FPM will be used to execute PHP files.") + ), + 'php5': ( + _("PHP 5"), + 'fcgid', (WEBAPPS_FCGID_PATH,), + _("This creates a PHP5.2 application under ~/webapps/\n" + "Apache-mod-fcgid will be used to execute PHP files.") + ), + 'php4': ( + _("PHP 4"), + 'fcgid', (WEBAPPS_FCGID_PATH,), + _("This creates a PHP4 application under ~/webapps/\n" + "Apache-mod-fcgid will be used to execute PHP files.") + ), + 'static': ( + _("Static"), + 'alias', (), + _("This creates a Static application under ~/webapps/\n" + "Apache2 will be used to serve static content and execute CGI files.") + ), + 'wordpressmu': ( + _("Wordpress (shared)"), + 'fpm', ('fcgi://127.0.0.1:8990/home/httpd/wordpress-mu/',), + _("This creates a Wordpress site into a shared Wordpress server\n" + "By default this blog will be accessible via http://.blogs.example.com") + + ), + 'dokuwikimu': ( + _("DokuWiki (shared)"), + 'alias', ('/home/httpd/wikifarm/farm/',), + _("This create a Dokuwiki wiki into a shared Dokuwiki server\n") + ), + 'drupalmu': ( + _("Drupdal (shared)"), + 'fpm', ('fcgi://127.0.0.1:8991/home/httpd/drupal-mu/',), + _("This creates a Drupal site into a shared Drupal server\n" + "The installation will be completed after visiting " + "http://.drupal.example.com/install.php?profile=standard&locale=ca\n" + "By default this site will be accessible via http://.drupal.example.com") + ), + 'webalizer': ( + _("Webalizer"), + 'alias', ('%(app_path)s%(site_name)s',), + _("This creates a Webalizer application under ~/webapps/-\n") + ), +}) + + +WEBAPPS_DEFAULT_TYPE = getattr(settings, 'WEBAPPS_DEFAULT_TYPE', 'php5.5') + + +WEBAPPS_DEFAULT_HTTPS_CERT = getattr(settings, 'WEBAPPS_DEFAULT_HTTPS_CERT', + ('/etc/apache2/cert', '/etc/apache2/cert.key') +) + + +WEBAPPS_OPTIONS = getattr(settings, 'WEBAPPS_OPTIONS', { + # { name: ( verbose_name, validation_regex ) } + # PHP + 'enabled_functions': ( + _("PHP - Enabled functions"), + r'^[\w.,-]+$' + ), + 'PHP-register_globals': ( + _("PHP - Register globals"), + r'^(On|Off|on|off)$' + ), + 'PHP-allow_url_include': ( + _("PHP - Allow URL include"), + r'^(On|Off|on|off)$' + ), + 'PHP-auto_append_file': ( + _("PHP - Auto append file"), + r'^none$' + ), + 'PHP-default_socket_timeout': ( + _("PHP - Default socket timeout"), + r'P^[0-9][0-9]?[0-9]?$' + ), + 'PHP-display_errors': ( + _("PHP - Display errors"), + r'^(On|Off|on|off)$' + ), + 'PHP-magic_quotes_gpc': ( + _("PHP - Magic quotes GPC"), + r'^(On|Off|on|off)$' + ), + 'PHP-max_execution_time': ( + _("PHP - Max execution time"), + r'^[0-9][0-9]?[0-9]?$' + ), + 'PHP-max_input_time': ( + _("PHP - Max input time"), + r'^[0-9][0-9]?[0-9]?$' + ), + 'PHP-memory_limit': ( + _("PHP - Memory limit"), + r'^[0-9][0-9]?[0-9]?M$' + ), + 'PHP-mysql.connect_timeout': ( + _("PHP - Mysql connect timeout"), + r'^[0-9][0-9]?[0-9]?$' + ), + 'PHP-post_max_size': ( + _("PHP - Post max size"), + r'^[0-9][0-9]?M$' + ), + 'PHP-safe_mode': ( + _("PHP - Safe mode"), + r'^(On|Off|on|off)$' + ), + 'PHP-suhosin.post.max_vars': ( + _("PHP - Suhosin post max vars"), + r'^[0-9][0-9]?[0-9]?[0-9]?$' + ), + 'PHP-suhosin.request.max_vars': ( + _("PHP - Suhosin request max vars"), + r'^[0-9][0-9]?[0-9]?[0-9]?$' + ), + 'PHP-suhosin.simulation': ( + _("PHP - Suhosin simulation"), + r'^(On|Off|on|off)$' + ), + # FCGID + 'FcgidIdleTimeout': ( + _("FCGI - Idle timeout"), + r'^[0-9][0-9]?[0-9]?$' + ), + 'FcgidBusyTimeout': ( + _("FCGI - Busy timeout"), + r'^[0-9][0-9]?[0-9]?$' + ), + 'FcgidConnectTimeout': ( + _("FCGI - Connection timeout"), + r'^[0-9][0-9]?[0-9]?$' + ), + 'FcgidIOTimeout': ( + _("FCGI - IO timeout"), + r'^[0-9][0-9]?[0-9]?$' + ), +}) + + +WEBAPPS_PHP_DISABLED_FUNCTIONS = getattr(settings, 'WEBAPPS_PHP_DISABLED_FUNCTION', [ + 'exec', 'passthru', 'shell_exec', 'system', 'proc_open', 'popen', 'curl_exec', + 'curl_multi_exec', 'show_source', 'pcntl_exec', 'proc_close', + 'proc_get_status', 'proc_nice', 'proc_terminate', 'ini_alter', 'virtual', + 'openlog', 'escapeshellcmd', 'escapeshellarg', 'dl' +]) + + +WEBAPPS_WORDPRESSMU_BASE_URL = getattr(settings, 'WEBAPPS_WORDPRESSMU_BASE_URL', + 'http://blogs.example.com') + + +WEBAPPS_WORDPRESSMU_ADMIN_PASSWORD = getattr(settings, 'WEBAPPS_WORDPRESSMU_ADMIN_PASSWORD', + 'secret') + + + + + +WEBAPPS_DOKUWIKIMU_TEMPLATE_PATH = setattr(settings, 'WEBAPPS_DOKUWIKIMU_TEMPLATE_PATH', + '/home/httpd/htdocs/wikifarm/template.tar.gz') + + +WEBAPPS_DOKUWIKIMU_FARM_PATH = getattr(settings, 'WEBAPPS_DOKUWIKIMU_FARM_PATH', + '/home/httpd/htdocs/wikifarm/farm') + + +WEBAPPS_DRUPAL_SITES_PATH = getattr(settings, 'WEBAPPS_DRUPAL_SITES_PATH', + '/home/httpd/htdocs/drupal-mu/sites/%(app_name)s') + + +WEBAPPS_PHPFPM_POOL_PATH = getattr(settings, 'WEBAPPS_PHPFPM_POOL_PATH', + '/etc/php5/fpm/pool.d') diff --git a/orchestra/apps/websites/__init__.py b/orchestra/apps/websites/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/websites/admin.py b/orchestra/apps/websites/admin.py new file mode 100644 index 00000000..1e04f7e7 --- /dev/null +++ b/orchestra/apps/websites/admin.py @@ -0,0 +1,93 @@ +from django import forms +from django.contrib import admin +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 orchestra.apps.accounts.widgets import account_related_field_widget_factory + +from .models import Content, Website, WebsiteOption + + +class WebsiteOptionInline(admin.TabularInline): + model = WebsiteOption + extra = 1 + + 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(WebsiteOptionInline, self).formfield_for_dbfield(db_field, **kwargs) + + +class ContentInline(AccountAdminMixin, admin.TabularInline): + model = Content + extra = 1 + fields = ('webapp', 'webapp_link', 'webapp_type', 'path') + readonly_fields = ('webapp_link', 'webapp_type') + filter_by_account_fields = ['webapp'] + + webapp_link = link('webapp', popup=True) + webapp_link.short_description = _("Web App") + + def webapp_type(self, content): + if not content.pk: + return '' + return content.webapp.get_type_display() + webapp_type.short_description = _("Web App type") + + +class WebsiteAdmin(SelectAccountAdminMixin, ExtendedModelAdmin): + list_display = ('name', 'display_domains', 'display_webapps', 'account_link') + list_filter = ('port', 'is_active') + change_readonly_fields = ('name',) + inlines = [ContentInline, WebsiteOptionInline] + filter_horizontal = ['domains'] + fieldsets = ( + (None, { + 'classes': ('extrapretty',), + 'fields': ('account_link', 'name', 'port', 'domains', 'is_active'), + }), + ) + filter_by_account_fields = ['domains'] + + def display_domains(self, website): + domains = [] + for domain in website.domains.all(): + url = '%s://%s' % (website.protocol, domain) + domains.append('%s' % (url, url)) + return '
    '.join(domains) + display_domains.short_description = _("domains") + display_domains.allow_tags = True + + def display_webapps(self, website): + webapps = [] + for content in website.content_set.all().select_related('webapp'): + webapp = content.webapp + url = reverse('admin:webapps_webapp_change', args=(webapp.pk,)) + name = "%s on %s" % (webapp.get_type_display(), content.path) + webapps.append('%s' % (url, name)) + return '
    '.join(webapps) + display_webapps.allow_tags = True + display_webapps.short_description = _("Web apps") + + def formfield_for_dbfield(self, db_field, **kwargs): + if db_field.name == 'root': + kwargs['widget'] = forms.TextInput(attrs={'size':'100'}) + return super(WebsiteAdmin, self).formfield_for_dbfield(db_field, **kwargs) + + def queryset(self, request): + """ Select related for performance """ + qs = super(WebsiteAdmin, self).queryset(request) + return qs.prefetch_related('domains') + + +admin.site.register(Website, WebsiteAdmin) diff --git a/orchestra/apps/websites/api.py b/orchestra/apps/websites/api.py new file mode 100644 index 00000000..7936cf2c --- /dev/null +++ b/orchestra/apps/websites/api.py @@ -0,0 +1,25 @@ +from rest_framework import viewsets +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 Website +from .serializers import WebsiteSerializer + + +class WebsiteViewSet(AccountApiMixin, viewsets.ModelViewSet): + model = Website + serializer_class = WebsiteSerializer + filter_fields = ('name',) + + @collectionlink() + def configuration(self, request): + names = ['WEBSITES_OPTIONS', 'WEBSITES_PORT_CHOICES'] + return Response({ + name: getattr(settings, name, None) for name in names + }) + + +router.register(r'websites', WebsiteViewSet) diff --git a/orchestra/apps/websites/backends/__init__.py b/orchestra/apps/websites/backends/__init__.py new file mode 100644 index 00000000..6e57f3a5 --- /dev/null +++ b/orchestra/apps/websites/backends/__init__.py @@ -0,0 +1,5 @@ +import pkgutil + + +for __, module_name, __ in pkgutil.walk_packages(__path__): + exec('from . import %s' % module_name) diff --git a/orchestra/apps/websites/backends/apache.py b/orchestra/apps/websites/backends/apache.py new file mode 100644 index 00000000..6fcea8e5 --- /dev/null +++ b/orchestra/apps/websites/backends/apache.py @@ -0,0 +1,175 @@ +import os + +from django.template import Template, Context +from django.utils.translation import ugettext_lazy as _ + +from orchestra.apps.orchestration import ServiceBackend + +from .. import settings + + +class Apache2Backend(ServiceBackend): + model = 'websites.Website' + related_models = (('websites.Content', 'website'),) + verbose_name = _("Apache 2") + + def save(self, site): + context = self.get_context(site) + extra_conf = self.get_content_directives(site) + if site.protocol is 'https': + extra_conf += self.get_ssl(site) + extra_conf += self.get_security(site) + context['extra_conf'] = extra_conf + + apache_conf = Template( + "# {{ banner }}\n" + "\n" + " ServerName {{ site.domains.all|first }}\n" + "{% if site.domains.all|slice:\"1:\" %}" + " ServerAlias {{ site.domains.all|slice:\"1:\"|join:' ' }}\n" + "{% endif %}" + " CustomLog {{ logs }} common\n" + " SuexecUserGroup {{ user }} {{ group }}\n" + "{% for line in extra_conf.splitlines %}" + " {{ line | safe }}\n" + "{% endfor %}" + "\n" + ) + apache_conf = apache_conf.render(Context(context)) + apache_conf += self.get_protections(site) + context['apache_conf'] = apache_conf + + self.append( + "{ echo -e '%(apache_conf)s' | diff -N -I'^\s*#' %(sites_available)s - ; } ||" + " { echo -e '%(apache_conf)s' > %(sites_available)s; UPDATED=1; }" % context + ) + self.enable_or_disable(site) + + def delete(self, site): + context = self.get_context(site) + self.append("a2dissite %(site_unique_name)s.conf && UPDATED=1" % context) + self.append("rm -fr %(sites_available)s" % context) + + def commit(self): + """ reload Apache2 if necessary """ + self.append('[[ $UPDATED == 1 ]] && service apache2 reload') + + def get_content_directives(self, site): + directives = '' + for content in site.content_set.all().order_by('-path'): + method, args = content.webapp.get_method() + method = getattr(self, 'get_%s_directives' % method) + directives += method(content, *args) + return directives + + def get_alias_directives(self, content, *args): + context = self.get_content_context(content) + context['path'] = args[0] % context if args else content.webapp.get_path() + return "Alias %(location)s %(path)s\n" % context + + def get_fpm_directives(self, content, *args): + context = self.get_content_context(content) + context['fcgi_path'] = args[0] % context + directive = "ProxyPassMatch ^%(location)s(.*\.php(/.*)?)$ %(fcgi_path)s$1\n" + return directive % context + + def get_fcgid_directives(self, content, fcgid_path): + context = self.get_content_context(content) + context['fcgid_path'] = fcgid_path % context + fcgid = self.get_alias_directives(content) + fcgid += ( + "ProxyPass %(location)s !\n" + "\n" + " Options +ExecCGI\n" + " AddHandler fcgid-script .php\n" + " FcgidWrapper %(fcgid_path)s\n" + ) % context + for option in content.webapp.options.filter(name__startswith='Fcgid'): + fcgid += " %s %s\n" % (option.name, option.value) + fcgid += "\n" + return fcgid + + def get_ssl(self, site): + cert = settings.WEBSITES_DEFAULT_HTTPS_CERT + custom_cert = site.options.filter(name='ssl') + if custom_cert: + cert = tuple(custom_cert[0].value.split()) + directives = ( + "SSLEngine on\n" + "SSLCertificateFile %s\n" + "SSLCertificateKeyFile %s\n" + ) % cert + return directives + + def get_security(self, site): + directives = '' + for rules in site.options.filter(name='sec_rule_remove'): + for rule in rules.split(): + directives += "SecRuleRemoveById %d" % rule + + for modsecurity in site.options.filter(name='sec_rule_off'): + directives += ( + "\n" + " SecRuleEngine Off\n" + "\n" % modsecurity.value + ) + return directives + + def get_protections(self, site): + protections = "" + __, regex = settings.WEBSITES_OPTIONS['directory_protection'] + for protection in site.options.filter(name='directory_protection'): + path, name, passwd = re.match(regex, protection.value).groups() + path = os.path.join(context['root'], path) + passwd = os.path.join(self.USER_HOME % context, passwd) + protections += ("\n" + "\n" + " AllowOverride All\n" +# " AuthPAM_Enabled off\n" + " AuthType Basic\n" + " AuthName %s\n" + " AuthUserFile %s\n" + " \n" + " require valid-user\n" + " \n" + "\n" % (path, name, passwd) + ) + return protections + + def enable_or_disable(self, site): + context = self.get_context(site) + self.append("ls -l %(sites_enabled)s; DISABLED=$?" % context) + if site.is_active: + self.append("if [[ $DISABLED ]]; then a2ensite %(site_unique_name)s.conf;\n" + "else UPDATED=0; fi" % context) + else: + self.append("if [[ ! $DISABLED ]]; then a2dissite %(site_unique_name)s.conf;\n" + "else UPDATED=0; fi" % context) + + def get_context(self, site): + base_apache_conf = settings.WEBSITES_BASE_APACHE_CONF + sites_available = os.path.join(base_apache_conf, 'sites-available') + sites_enabled = os.path.join(base_apache_conf, 'sites-enabled') + context = { + 'site': site, + 'site_name': site.name, + 'site_unique_name': site.unique_name, + 'user': site.account.user.username, + 'group': site.account.user.username, + 'sites_enabled': sites_enabled, + 'sites_available': "%s.conf" % os.path.join(sites_available, site.unique_name), + 'logs': os.path.join(settings.WEBSITES_BASE_APACHE_LOGS, site.unique_name), + 'banner': self.get_banner(), + } + return context + + def get_content_context(self, content): + context = self.get_context(content.website) + context.update({ + 'type': content.webapp.type, + 'location': content.path, + 'app_name': content.webapp.name, + 'app_path': content.webapp.get_path(), + 'fpm_port': content.webapp.get_fpm_port(), + }) + return context diff --git a/orchestra/apps/websites/backends/webalizer.py b/orchestra/apps/websites/backends/webalizer.py new file mode 100644 index 00000000..3b95f531 --- /dev/null +++ b/orchestra/apps/websites/backends/webalizer.py @@ -0,0 +1,84 @@ +import os +from functools import partial + +from django.utils.translation import ugettext_lazy as _ + +from orchestra.apps.orchestration import ServiceBackend + +from .. import settings + + +class WebalizerBackend(ServiceBackend): + verbose_name = _("Webalizer") + model = 'websites.Content' + + def save(self, content): + context = self.get_context(content) + self.append("mkdir -p %(webalizer_path)s" % context) + self.append("[[ ! -e %(webalizer_path)s/index.html ]] && " + "echo 'Webstats are coming soon' > %(webalizer_path)s/index.html" % context) + self.append("echo '%(webalizer_conf)s' > %(webalizer_conf_path)s" % context) + self.append("chown %(user)s.www-data %(webalizer_path)s" % context) + + def delete(self, content): + context = self.get_context(content) + self.append("rm -fr %(webalizer_path)s" % context) + self.append("rm %(webalizer_conf_path)s" % context) + + def get_context(self, content): + conf_file = "%s.conf" % content.website.name + context = { + 'site_logs': os.path.join(settings.WEBSITES_BASE_APACHE_LOGS, content.website.unique_name), + 'site_name': content.website.name, + 'webalizer_path': os.path.join(content.webapp.get_path(), content.website.name), + 'webalizer_conf_path': os.path.join(settings.WEBSITES_WEBALIZER_PATH, conf_file), + 'user': content.webapp.account.user, + 'banner': self.get_banner(), + } + context['webalizer_conf'] = ( + "# %(banner)s\n" + "LogFile %(site_logs)s\n" + "LogType clf\n" + "OutputDir %(webalizer_path)s\n" + "HistoryName webalizer.hist\n" + "Incremental yes\n" + "IncrementalName webalizer.current\n" + "ReportTitle Stats of\n" + "HostName %(site_name)s\n" + "\n" + "PageType htm*\n" + "PageType php*\n" + "PageType shtml\n" + "PageType cgi\n" + "PageType pl\n" + "\n" + "DNSCache /var/lib/dns_cache.db\n" + "DNSChildren 15\n" + "\n" + "HideURL *.gif\n" + "HideURL *.GIF\n" + "HideURL *.jpg\n" + "HideURL *.JPG\n" + "HideURL *.png\n" + "HideURL *.PNG\n" + "HideURL *.ra\n" + "\n" + "IncludeURL *\n" + "\n" + "SearchEngine yahoo.com p=\n" + "SearchEngine altavista.com q=\n" + "SearchEngine google.com q=\n" + "SearchEngine eureka.com q=\n" + "SearchEngine lycos.com query=\n" + "SearchEngine hotbot.com MT=\n" + "SearchEngine msn.com MT=\n" + "SearchEngine infoseek.com qt=\n" + "SearchEngine webcrawler searchText=\n" + "SearchEngine excite search=\n" + "SearchEngine netscape.com search=\n" + "SearchEngine mamma.com query=\n" + "SearchEngine alltheweb.com query=\n" + "\n" + "DumpSites yes\n" + ) % context + return context diff --git a/orchestra/apps/websites/models.py b/orchestra/apps/websites/models.py new file mode 100644 index 00000000..19ecdf2d --- /dev/null +++ b/orchestra/apps/websites/models.py @@ -0,0 +1,92 @@ +import re + +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from orchestra.core import validators, services +from orchestra.utils.functional import cached + +from . import settings + + +def settings_to_choices(choices): + return sorted( + [ (name, opt[0]) for name,opt in choices.iteritems() ], + key=lambda e: e[1] + ) + + +class Website(models.Model): + name = models.CharField(_("name"), max_length=128, unique=True, + validators=[validators.validate_name]) + account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), + related_name='websites') + port = models.PositiveIntegerField(_("port"), + choices=settings.WEBSITES_PORT_CHOICES, + default=settings.WEBSITES_DEFAULT_PORT) + domains = models.ManyToManyField(settings.WEBSITES_DOMAIN_MODEL, + related_name='websites', verbose_name=_("domains")) + contents = models.ManyToManyField('webapps.WebApp', through='websites.Content') + is_active = models.BooleanField(_("is active"), default=True) + + def __unicode__(self): + return self.name + + @property + def unique_name(self): + return "%s-%s" % (self.account, self.name) + + @cached + def get_options(self): + return { opt.name: opt.value for opt in self.options.all() } + + @property + def protocol(self): + if self.port == 80: + return 'http' + if self.port == 443: + return 'https' + raise TypeError('No protocol for port "%s"' % self.port) + + +class WebsiteOption(models.Model): + website = models.ForeignKey(Website, verbose_name=_("web site"), + related_name='options') + name = models.CharField(_("name"), max_length=128, + choices=settings_to_choices(settings.WEBSITES_OPTIONS)) + value = models.CharField(_("value"), max_length=256) + + class Meta: + unique_together = ('website', 'name') + verbose_name = _("option") + verbose_name_plural = _("options") + + def __unicode__(self): + return self.name + + def clean(self): + """ validates name and value according to WEBSITES_WEBSITEOPTIONS """ + __, regex = settings.WEBSITES_OPTIONS[self.name] + if not re.match(regex, self.value): + msg = _("'%s' does not match %s") + raise ValidationError(msg % (self.value, regex)) + + +class Content(models.Model): + webapp = models.ForeignKey('webapps.WebApp', verbose_name=_("web application")) + website = models.ForeignKey('websites.Website', verbose_name=_("web site")) + path = models.CharField(_("path"), max_length=256, blank=True) + + class Meta: + unique_together = ('website', 'path') + + def clean(self): + if not self.path.startswith('/'): + self.path = '/' + self.path + + def __unicode__(self): + return self.website.name + self.path + + +services.register(Website) diff --git a/orchestra/apps/websites/serializers.py b/orchestra/apps/websites/serializers.py new file mode 100644 index 00000000..58f8c4b6 --- /dev/null +++ b/orchestra/apps/websites/serializers.py @@ -0,0 +1,22 @@ +from rest_framework import serializers + +from orchestra.api.fields import OptionField +from orchestra.apps.accounts.serializers import AccountSerializerMixin + +from .models import Website, Content + + +class ContentSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = Content + fields = ('webapp', 'path') + + +class WebsiteSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer): + contents = ContentSerializer(required=False, many=True, allow_add_remove=True, + source='content_set') + options = OptionField(required=False) + + class Meta: + model = Website + fields = ('url', 'name', 'port', 'domains', 'is_active', 'contents', 'options') diff --git a/orchestra/apps/websites/settings.py b/orchestra/apps/websites/settings.py new file mode 100644 index 00000000..f5f33c14 --- /dev/null +++ b/orchestra/apps/websites/settings.py @@ -0,0 +1,50 @@ +from django.conf import settings +from django.utils.translation import ugettext_lazy as _ + + +WEBSITES_PORT_CHOICES = getattr(settings, 'WEBSITES_PORT_CHOICES', ( + (80, 'HTTP'), + (443, 'HTTPS'), +)) + + +WEBSITES_DEFAULT_PORT = getattr(settings, 'WEBSITES_DEFAULT_PORT', 80) + + +WEBSITES_DOMAIN_MODEL = getattr(settings, 'WEBSITES_DOMAIN_MODEL', 'domains.Domain') + + +WEBSITES_OPTIONS = getattr(settings, 'WEBSITES_OPTIONS', { + # { name: ( verbose_name, validation_regex ) } + 'directory_protection': ( + _("HTTPD - Directory protection"), + r'^([\w/_]+)\s+(\".*\")\s+([\w/_\.]+)$' + ), + 'redirection': ( + _("HTTPD - Redirection"), + r'^.*\s+.*$' + ), + 'ssl': ( + _("HTTPD - SSL"), + r'^.*\s+.*$' + ), + 'sec_rule_remove': ( + _("HTTPD - SecRuleRemoveById"), + r'^[0-9,\s]+$' + ), + 'sec_rule_off': ( + _("HTTPD - Disable Modsecurity"), + r'^[\w/_]+$' + ), +}) + + +WEBSITES_BASE_APACHE_CONF = getattr(settings, 'WEBSITES_BASE_APACHE_CONF', + '/etc/apache2/') + +WEBSITES_WEBALIZER_PATH = getattr(settings, 'WEBSITES_WEBALIZER_PATH', + '/home/httpd/webalizer/') + + +WEBSITES_BASE_APACHE_LOGS = getattr(settings, 'WEBSITES_BASE_APACHE_LOGS', + '/var/log/apache2/virtual/') diff --git a/orchestra/bin/celerybeat b/orchestra/bin/celerybeat new file mode 100755 index 00000000..e43c300b --- /dev/null +++ b/orchestra/bin/celerybeat @@ -0,0 +1,212 @@ +#!/bin/bash +# ========================================================= +# celerybeat - Starts the Celery periodic task scheduler. +# ========================================================= +# +# :Usage: /etc/init.d/celerybeat {start|stop|force-reload|restart|try-restart|status} +# :Configuration file: /etc/default/celerybeat or /etc/default/celeryd +# +# See http://docs.celeryproject.org/en/latest/tutorials/daemonizing.html#generic-init-scripts + +### BEGIN INIT INFO +# Provides: celerybeat +# Required-Start: $network $local_fs $remote_fs postgresql celeryd +# Required-Stop: $network $local_fs $remote_fs postgresql celeryd +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: celery periodic task scheduler +### END INIT INFO + +# Cannot use set -e/bash -e since the kill -0 command will abort +# abnormally in the absence of a valid process ID. +#set -e + +DEFAULT_PID_FILE="/var/run/celery/beat.pid" +DEFAULT_LOG_FILE="/var/log/celery/beat.log" +DEFAULT_LOG_LEVEL="INFO" +DEFAULT_CELERYBEAT="celerybeat" + +# /etc/init.d/celerybeat: start and stop the celery periodic task scheduler daemon. + +if test -f /etc/default/celeryd; then + . /etc/default/celeryd +fi + +if test -f /etc/default/celerybeat; then + . /etc/default/celerybeat +fi + +CELERYBEAT=${CELERYBEAT:-$DEFAULT_CELERYBEAT} +CELERYBEAT_LOG_LEVEL=${CELERYBEAT_LOG_LEVEL:-${CELERYBEAT_LOGLEVEL:-$DEFAULT_LOG_LEVEL}} + +# Set CELERY_CREATE_DIRS to always create log/pid dirs. +CELERY_CREATE_DIRS=${CELERY_CREATE_DIRS:-0} +CELERY_CREATE_RUNDIR=$CELERY_CREATE_DIRS +CELERY_CREATE_LOGDIR=$CELERY_CREATE_DIRS +if [ -z "$CELERYBEAT_PID_FILE" ]; then + CELERYBEAT_PID_FILE="$DEFAULT_PID_FILE" + CELERY_CREATE_RUNDIR=1 +fi +if [ -z "$CELERYBEAT_LOG_FILE" ]; then + CELERYBEAT_LOG_FILE="$DEFAULT_LOG_FILE" + CELERY_CREATE_LOGDIR=1 +fi + +export CELERY_LOADER + +CELERYBEAT_OPTS="$CELERYBEAT_OPTS -f $CELERYBEAT_LOG_FILE -l $CELERYBEAT_LOG_LEVEL" + +if [ -n "$2" ]; then + CELERYBEAT_OPTS="$CELERYBEAT_OPTS $2" +fi + +CELERYBEAT_LOG_DIR=`dirname $CELERYBEAT_LOG_FILE` +CELERYBEAT_PID_DIR=`dirname $CELERYBEAT_PID_FILE` + +# Extra start-stop-daemon options, like user/group. +if [ -n "$CELERYBEAT_USER" ]; then + DAEMON_OPTS="$DAEMON_OPTS --uid $CELERYBEAT_USER" +fi +if [ -n "$CELERYBEAT_GROUP" ]; then + DAEMON_OPTS="$DAEMON_OPTS --gid $CELERYBEAT_GROUP" +fi + +CELERYBEAT_CHDIR=${CELERYBEAT_CHDIR:-$CELERYD_CHDIR} +if [ -n "$CELERYBEAT_CHDIR" ]; then + DAEMON_OPTS="$DAEMON_OPTS --workdir $CELERYBEAT_CHDIR" +fi + + +export PATH="${PATH:+$PATH:}/usr/sbin:/sbin" + +check_dev_null() { + if [ ! -c /dev/null ]; then + echo "/dev/null is not a character device!" + exit 75 # EX_TEMPFAIL + fi +} + +maybe_die() { + if [ $? -ne 0 ]; then + echo "Exiting: $*" + exit 77 # EX_NOPERM + fi +} + +create_default_dir() { + if [ ! -d "$1" ]; then + echo "- Creating default directory: '$1'" + mkdir -p "$1" + maybe_die "Couldn't create directory $1" + echo "- Changing permissions of '$1' to 02755" + chmod 02755 "$1" + maybe_die "Couldn't change permissions for $1" + if [ -n "$CELERYBEAT_USER" ]; then + echo "- Changing owner of '$1' to '$CELERYBEAT_USER'" + chown "$CELERYBEAT_USER" "$1" + maybe_die "Couldn't change owner of $1" + fi + if [ -n "$CELERYBEAT_GROUP" ]; then + echo "- Changing group of '$1' to '$CELERYBEAT_GROUP'" + chgrp "$CELERYBEAT_GROUP" "$1" + maybe_die "Couldn't change group of $1" + fi + fi +} + +check_paths() { + if [ $CELERY_CREATE_LOGDIR -eq 1 ]; then + create_default_dir "$CELERYBEAT_LOG_DIR" + fi + if [ $CELERY_CREATE_RUNDIR -eq 1 ]; then + create_default_dir "$CELERYBEAT_PID_DIR" + fi +} + + +create_paths () { + create_default_dir "$CELERYBEAT_LOG_DIR" + create_default_dir "$CELERYBEAT_PID_DIR" +} + + +wait_pid () { + pid=$1 + forever=1 + i=0 + while [ $forever -gt 0 ]; do + kill -0 $pid 1>/dev/null 2>&1 + if [ $? -eq 1 ]; then + echo "OK" + forever=0 + else + kill -TERM "$pid" + i=$((i + 1)) + if [ $i -gt 60 ]; then + echo "ERROR" + echo "Timed out while stopping (30s)" + forever=0 + else + sleep 0.5 + fi + fi + done +} + + +stop_beat () { + echo -n "Stopping celerybeat... " + if [ -f "$CELERYBEAT_PID_FILE" ]; then + wait_pid $(cat "$CELERYBEAT_PID_FILE") + else + echo "NOT RUNNING" + fi +} + +start_beat () { + echo "Starting celerybeat..." + if [ -n "$VIRTUALENV" ]; then + source $VIRTUALENV/bin/activate + fi + $CELERYBEAT $CELERYBEAT_OPTS $DAEMON_OPTS --detach \ + --pidfile="$CELERYBEAT_PID_FILE" +} + + + +case "$1" in + start) + check_dev_null + check_paths + start_beat + ;; + stop) + check_paths + stop_beat + ;; + reload|force-reload) + echo "Use start+stop" + ;; + restart) + echo "Restarting celery periodic task scheduler" + check_paths + stop_beat + check_dev_null + start_beat + ;; + create-paths) + check_dev_null + create_paths + ;; + check-paths) + check_dev_null + check_paths + ;; + *) + echo "Usage: /etc/init.d/celerybeat {start|stop|restart|create-paths}" + exit 64 # EX_USAGE + ;; +esac + +exit 0 + diff --git a/orchestra/bin/celeryd b/orchestra/bin/celeryd new file mode 100755 index 00000000..0d34dce6 --- /dev/null +++ b/orchestra/bin/celeryd @@ -0,0 +1,234 @@ +#!/bin/sh -e +# ============================================ +# celeryd - Starts the Celery worker daemon. +# ============================================ +# +# :Usage: /etc/init.d/celeryd {start|stop|force-reload|restart|try-restart|status} +# :Configuration file: /etc/default/celeryd +# +# See http://docs.celeryproject.org/en/latest/tutorials/daemonizing.html#generic-init-scripts + + +### BEGIN INIT INFO +# Provides: celeryd +# Required-Start: $network $local_fs $remote_fs postgresql celeryev rabbitmq-server +# Required-Stop: $network $local_fs $remote_fs postgresql celeryev rabbitmq-server +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: celery task worker daemon +### END INIT INFO + +# some commands work asyncronously, so we'll wait this many seconds +SLEEP_SECONDS=5 + +DEFAULT_PID_FILE="/var/run/celery/%n.pid" +DEFAULT_LOG_FILE="/var/log/celery/%n.log" +DEFAULT_LOG_LEVEL="INFO" +DEFAULT_NODES="celery" +DEFAULT_CELERYD="-m celery.bin.celeryd_detach" + +CELERY_DEFAULTS=${CELERY_DEFAULTS:-"/etc/default/celeryd"} + +test -f "$CELERY_DEFAULTS" && . "$CELERY_DEFAULTS" + +# Set CELERY_CREATE_DIRS to always create log/pid dirs. +CELERY_CREATE_DIRS=${CELERY_CREATE_DIRS:-0} +CELERY_CREATE_RUNDIR=$CELERY_CREATE_DIRS +CELERY_CREATE_LOGDIR=$CELERY_CREATE_DIRS +if [ -z "$CELERYD_PID_FILE" ]; then + CELERYD_PID_FILE="$DEFAULT_PID_FILE" + CELERY_CREATE_RUNDIR=1 +fi +if [ -z "$CELERYD_LOG_FILE" ]; then + CELERYD_LOG_FILE="$DEFAULT_LOG_FILE" + CELERY_CREATE_LOGDIR=1 +fi + +CELERYD_LOG_LEVEL=${CELERYD_LOG_LEVEL:-${CELERYD_LOGLEVEL:-$DEFAULT_LOG_LEVEL}} +CELERYD_MULTI=${CELERYD_MULTI:-"celeryd-multi"} +CELERYD=${CELERYD:-$DEFAULT_CELERYD} +CELERYD_NODES=${CELERYD_NODES:-$DEFAULT_NODES} + +export CELERY_LOADER + +if [ -n "$2" ]; then + CELERYD_OPTS="$CELERYD_OPTS $2" +fi + +CELERYD_LOG_DIR=`dirname $CELERYD_LOG_FILE` +CELERYD_PID_DIR=`dirname $CELERYD_PID_FILE` + +# Extra start-stop-daemon options, like user/group. +if [ -n "$CELERYD_USER" ]; then + DAEMON_OPTS="$DAEMON_OPTS --uid=$CELERYD_USER" +fi +if [ -n "$CELERYD_GROUP" ]; then + DAEMON_OPTS="$DAEMON_OPTS --gid=$CELERYD_GROUP" +fi + +if [ -n "$CELERYD_CHDIR" ]; then + DAEMON_OPTS="$DAEMON_OPTS --workdir=$CELERYD_CHDIR" +fi + + +check_dev_null() { + if [ ! -c /dev/null ]; then + echo "/dev/null is not a character device!" + exit 75 # EX_TEMPFAIL + fi +} + + +maybe_die() { + if [ $? -ne 0 ]; then + echo "Exiting: $* (errno $?)" + exit 77 # EX_NOPERM + fi +} + +create_default_dir() { + if [ ! -d "$1" ]; then + echo "- Creating default directory: '$1'" + mkdir -p "$1" + maybe_die "Couldn't create directory $1" + echo "- Changing permissions of '$1' to 02755" + chmod 02755 "$1" + maybe_die "Couldn't change permissions for $1" + if [ -n "$CELERYD_USER" ]; then + echo "- Changing owner of '$1' to '$CELERYD_USER'" + chown "$CELERYD_USER" "$1" + maybe_die "Couldn't change owner of $1" + fi + if [ -n "$CELERYD_GROUP" ]; then + echo "- Changing group of '$1' to '$CELERYD_GROUP'" + chgrp "$CELERYD_GROUP" "$1" + maybe_die "Couldn't change group of $1" + fi + fi +} + + +check_paths() { + if [ $CELERY_CREATE_LOGDIR -eq 1 ]; then + create_default_dir "$CELERYD_LOG_DIR" + fi + if [ $CELERY_CREATE_RUNDIR -eq 1 ]; then + create_default_dir "$CELERYD_PID_DIR" + fi +} + +create_paths() { + create_default_dir "$CELERYD_LOG_DIR" + create_default_dir "$CELERYD_PID_DIR" +} + +export PATH="${PATH:+$PATH:}/usr/sbin:/sbin" + + +_get_pid_files() { + [ ! -d "$CELERYD_PID_DIR" ] && return + echo `ls -1 "$CELERYD_PID_DIR"/*.pid 2> /dev/null` +} + +stop_workers () { + $CELERYD_MULTI stopwait $CELERYD_NODES --pidfile="$CELERYD_PID_FILE" + sleep $SLEEP_SECONDS +} + + +start_workers () { + $CELERYD_MULTI start $CELERYD_NODES $DAEMON_OPTS \ + --pidfile="$CELERYD_PID_FILE" \ + --logfile="$CELERYD_LOG_FILE" \ + --loglevel="$CELERYD_LOG_LEVEL" \ + --cmd="$CELERYD" \ + $CELERYD_OPTS + sleep $SLEEP_SECONDS +} + + +restart_workers () { + $CELERYD_MULTI restart $CELERYD_NODES $DAEMON_OPTS \ + --pidfile="$CELERYD_PID_FILE" \ + --logfile="$CELERYD_LOG_FILE" \ + --loglevel="$CELERYD_LOG_LEVEL" \ + --cmd="$CELERYD" \ + $CELERYD_OPTS + sleep $SLEEP_SECONDS +} + +check_status () { + local pid_files= + pid_files=`_get_pid_files` + [ -z "$pid_files" ] && echo "celeryd not running (no pidfile)" && exit 1 + + local one_failed= + for pid_file in $pid_files; do + local node=`basename "$pid_file" .pid` + local pid=`cat "$pid_file"` + local cleaned_pid=`echo "$pid" | sed -e 's/[^0-9]//g'` + if [ -z "$pid" ] || [ "$cleaned_pid" != "$pid" ]; then + echo "bad pid file ($pid_file)" + else + local failed= + kill -0 $pid 2> /dev/null || failed=true + if [ "$failed" ]; then + echo "celeryd (node $node) (pid $pid) is stopped, but pid file exists!" + one_failed=true + else + echo "celeryd (node $node) (pid $pid) is running..." + fi + fi + done + + [ "$one_failed" ] && exit 1 || exit 0 +} + + +case "$1" in + start) + check_dev_null + check_paths + start_workers + ;; + + stop) + check_dev_null + check_paths + stop_workers + ;; + + reload|force-reload) + echo "Use restart" + ;; + + status) + check_status + ;; + + restart) + check_dev_null + check_paths + restart_workers + ;; + try-restart) + check_dev_null + check_paths + restart_workers + ;; + create-paths) + check_dev_null + create_paths + ;; + check-paths) + check_dev_null + check_paths + ;; + *) + echo "Usage: /etc/init.d/celeryd {start|stop|restart|kill|create-paths}" + exit 64 # EX_USAGE + ;; +esac + +exit 0 + diff --git a/orchestra/bin/celeryevcam b/orchestra/bin/celeryevcam new file mode 100755 index 00000000..623e1adb --- /dev/null +++ b/orchestra/bin/celeryevcam @@ -0,0 +1,226 @@ +#!/bin/bash +# ============================================ +# celeryd - Starts the Celery worker daemon. +# ============================================ +# +# :Usage: /etc/init.d/celeryd {start|stop|force-reload|restart|try-restart|status} +# +# :Configuration file: /etc/default/celeryev | /etc/default/celeryd +# +# To configure celeryd you probably need to tell it where to chdir. +# +# EXAMPLE CONFIGURATION +# ===================== +# +# this is an example configuration for a Python project: +# +# /etc/default/celeryd: +# +# # Where to chdir at start. +# CELERYD_CHDIR="/opt/Myproject/" +# +# # Extra arguments to celeryev +# CELERYEV_OPTS="-x" +# +# # Name of the celery config module.# +# CELERY_CONFIG_MODULE="celeryconfig" +# +# # Camera class to use (required) +# CELERYEV_CAM = "myapp.Camera" +# +# EXAMPLE DJANGO CONFIGURATION +# ============================ +# +# # Where the Django project is. +# CELERYD_CHDIR="/opt/Project/" +# +# # Name of the projects settings module. +# export DJANGO_SETTINGS_MODULE="MyProject.settings" +# +# # Path to celeryd +# CELERYEV="/opt/Project/manage.py" +# +# # Extra arguments to manage.py +# CELERYEV_OPTS="celeryev" +# +# # Camera class to use (required) +# CELERYEV_CAM="djcelery.snapshot.Camera" +# +# AVAILABLE OPTIONS +# ================= +# +# * CELERYEV_OPTS +# Additional arguments to celeryd, see `celeryd --help` for a list. +# +# * CELERYD_CHDIR +# Path to chdir at start. Default is to stay in the current directory. +# +# * CELERYEV_PID_FILE +# Full path to the pidfile. Default is /var/run/celeryd.pid. +# +# * CELERYEV_LOG_FILE +# Full path to the celeryd logfile. Default is /var/log/celeryd.log +# +# * CELERYEV_LOG_LEVEL +# Log level to use for celeryd. Default is INFO. +# +# * CELERYEV +# Path to the celeryev program. Default is `celeryev`. +# You can point this to an virtualenv, or even use manage.py for django. +# +# * CELERYEV_USER +# User to run celeryev as. Default is current user. +# +# * CELERYEV_GROUP +# Group to run celeryev as. Default is current user. +# +# * VIRTUALENV +# Full path to the virtualenv environment to activate. Default is none. + +### BEGIN INIT INFO +# Provides: celeryev +# Required-Start: $network $local_fs $remote_fs postgresql rabbitmq-server +# Required-Stop: $network $local_fs $remote_fs postgresql rabbitmq-server +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: celery event snapshots +### END INIT INFO + +# Cannot use set -e/bash -e since the kill -0 command will abort +# abnormally in the absence of a valid process ID. +#set -e + +DEFAULT_PID_FILE="/var/run/celeryev.pid" +DEFAULT_LOG_FILE="/var/log/celeryev.log" +DEFAULT_LOG_LEVEL="INFO" +DEFAULT_CELERYEV="/usr/bin/celeryev" + +if test -f /etc/default/celeryd; then + . /etc/default/celeryd +fi + +if test -f /etc/default/celeryev; then + . /etc/default/celeryev +fi + +CELERYEV=${CELERYEV:-$DEFAULT_CELERYEV} +CELERYEV_PID_FILE=${CELERYEV_PID_FILE:-${CELERYEV_PIDFILE:-$DEFAULT_PID_FILE}} +CELERYEV_LOG_FILE=${CELERYEV_LOG_FILE:-${CELERYEV_LOGFILE:-$DEFAULT_LOG_FILE}} +CELERYEV_LOG_LEVEL=${CELERYEV_LOG_LEVEL:-${CELERYEV_LOG_LEVEL:-$DEFAULT_LOG_LEVEL}} + +export CELERY_LOADER + +if [ -z "$CELERYEV_CAM" ]; then + echo "Missing CELERYEV_CAM variable" 1>&2 + exit +fi + +CELERYEV_OPTS="$CELERYEV_OPTS -f $CELERYEV_LOG_FILE -l $CELERYEV_LOG_LEVEL -c $CELERYEV_CAM" + +if [ -n "$2" ]; then + CELERYEV_OPTS="$CELERYEV_OPTS $2" +fi + +CELERYEV_LOG_DIR=`dirname $CELERYEV_LOG_FILE` +CELERYEV_PID_DIR=`dirname $CELERYEV_PID_FILE` +if [ ! -d "$CELERYEV_LOG_DIR" ]; then + mkdir -p $CELERYEV_LOG_DIR +fi +if [ ! -d "$CELERYEV_PID_DIR" ]; then + mkdir -p $CELERYEV_PID_DIR +fi + +# Extra start-stop-daemon options, like user/group. +if [ -n "$CELERYEV_USER" ]; then + DAEMON_OPTS="$DAEMON_OPTS --uid $CELERYEV_USER" + chown "$CELERYEV_USER" $CELERYEV_LOG_DIR $CELERYEV_PID_DIR +fi +if [ -n "$CELERYEV_GROUP" ]; then + DAEMON_OPTS="$DAEMON_OPTS --gid $CELERYEV_GROUP" + chgrp "$CELERYEV_GROUP" $CELERYEV_LOG_DIR $CELERYEV_PID_DIR +fi + +CELERYEV_CHDIR=${CELERYEV_CHDIR:-$CELERYD_CHDIR} +if [ -n "$CELERYEV_CHDIR" ]; then + DAEMON_OPTS="$DAEMON_OPTS --workdir $CELERYEV_CHDIR" +fi + + +export PATH="${PATH:+$PATH:}/usr/sbin:/sbin" + +check_dev_null() { + if [ ! -c /dev/null ]; then + echo "/dev/null is not a character device!" + exit 1 + fi +} + +wait_pid () { + pid=$1 + forever=1 + i=0 + while [ $forever -gt 0 ]; do + kill -0 $pid 1>/dev/null 2>&1 + if [ $? -eq 1 ]; then + echo "OK" + forever=0 + else + kill -TERM "$pid" + i=$((i + 1)) + if [ $i -gt 60 ]; then + echo "ERROR" + echo "Timed out while stopping (30s)" + forever=0 + else + sleep 0.5 + fi + fi + done +} + + +stop_evcam () { + echo -n "Stopping celeryev..." + if [ -f "$CELERYEV_PID_FILE" ]; then + wait_pid $(cat "$CELERYEV_PID_FILE") + else + echo "NOT RUNNING" + fi +} + +start_evcam () { + echo "Starting celeryev..." + if [ -n "$VIRTUALENV" ]; then + source $VIRTUALENV/bin/activate + fi + $CELERYEV $CELERYEV_OPTS $DAEMON_OPTS --detach \ + --pidfile="$CELERYEV_PID_FILE" +} + + + +case "$1" in + start) + check_dev_null + start_evcam + ;; + stop) + stop_evcam + ;; + reload|force-reload) + echo "Use start+stop" + ;; + restart) + echo "Restarting celery event snapshots" "celeryev" + stop_evcam + check_dev_null + start_evcam + ;; + + *) + echo "Usage: /etc/init.d/celeryev {start|stop|restart}" + exit 1 +esac + +exit 0 + diff --git a/orchestra/bin/django_bash_completion.sh b/orchestra/bin/django_bash_completion.sh new file mode 100755 index 00000000..8f852117 --- /dev/null +++ b/orchestra/bin/django_bash_completion.sh @@ -0,0 +1,72 @@ +# ######################################################################### +# This bash script adds tab-completion feature to django-admin.py and +# manage.py. +# +# Testing it out without installing +# ================================= +# +# To test out the completion without "installing" this, just run this file +# directly, like so: +# +# . ~/path/to/django_bash_completion +# +# Note: There's a dot ('.') at the beginning of that command. +# +# After you do that, tab completion will immediately be made available in your +# current Bash shell. But it won't be available next time you log in. +# +# Installing +# ========== +# +# To install this, point to this file from your .bash_profile, like so: +# +# . ~/path/to/django_bash_completion +# +# Do the same in your .bashrc if .bashrc doesn't invoke .bash_profile. +# +# Settings will take effect the next time you log in. +# +# Uninstalling +# ============ +# +# To uninstall, just remove the line from your .bash_profile and .bashrc. + +_django_completion() +{ + COMPREPLY=( $( COMP_WORDS="${COMP_WORDS[*]}" \ + COMP_CWORD=$COMP_CWORD \ + DJANGO_AUTO_COMPLETE=1 $1 ) ) +} +complete -F _django_completion -o default django-admin.py manage.py django-admin + +_python_django_completion() +{ + if [[ ${COMP_CWORD} -ge 2 ]]; then + PYTHON_EXE=${COMP_WORDS[0]##*/} + echo $PYTHON_EXE | egrep "python([2-9]\.[0-9])?" >/dev/null 2>&1 + if [[ $? == 0 ]]; then + PYTHON_SCRIPT=${COMP_WORDS[1]##*/} + echo $PYTHON_SCRIPT | egrep "manage\.py|django-admin(\.py)?" >/dev/null 2>&1 + if [[ $? == 0 ]]; then + COMPREPLY=( $( COMP_WORDS="${COMP_WORDS[*]:1}" \ + COMP_CWORD=$(( COMP_CWORD-1 )) \ + DJANGO_AUTO_COMPLETE=1 ${COMP_WORDS[*]} ) ) + fi + fi + fi +} + +# Support for multiple interpreters. +unset pythons +if command -v whereis &>/dev/null; then + python_interpreters=$(whereis python | cut -d " " -f 2-) + for python in $python_interpreters; do + pythons="${pythons} ${python##*/}" + done + pythons=$(echo $pythons | tr " " "\n" | sort -u | tr "\n" " ") +else + pythons=python +fi + +complete -F _python_django_completion -o default $pythons + diff --git a/orchestra/bin/orchestra-admin b/orchestra/bin/orchestra-admin new file mode 100755 index 00000000..7149f705 --- /dev/null +++ b/orchestra/bin/orchestra-admin @@ -0,0 +1,422 @@ +#!/bin/bash + +set -u + +bold=$(tput bold) +normal=$(tput sgr0) + + +function help () { + if [[ $# -gt 1 ]]; then + CMD="print_${2}_help" + $CMD + else + print_help + fi +} + + +function print_help () { + cat <<- EOF + + ${bold}NAME${normal} + ${bold}orchestra-admin${normal} - Orchetsra administration script + + ${bold}OPTIONS${normal} + ${bold}install_requirements${normal} + Installs Orchestra requirements using apt-get and pip + + ${bold}install_postfix${normal} + Installs postfix packages including dovecot, amavis, spamassassin and clamav + + ${bold}uninstall_postfix${normal} + Uninstall postfix packages including dovecot, amavis, spamassassin and clamav + + ${bold}install_certificate${normal} + Installs a valid all-purpose self signed certificate that is valid for the next ten years + + ${bold}uninstall_certificate${normal} + Uninstall certificate + + ${bold}startproject${normal} + Creates a new Django-orchestra instance + + ${bold}help${normal} + Displays this help text or related help page as argument + for example: + ${bold}orchestra-admin help startproject${normal} + + EOF +} +# in + + +show () { + echo " ${bold}\$ ${@}${normal}" +} +export -f show + + +run () { + show "${@}" + "${@}" +} +export -f run + + +check_root () { + [ $(whoami) != 'root' ] && { echo -e "\nErr. This should be run as root\n" >&2; exit 1; } +} +export -f check_root + + +get_orchestra_dir () { + if ! $(echo "import orchestra"|python 2> /dev/null); then + echo -e "\nErr. orchestra not installed.\n" >&2 + exit 1 + fi + PATH=$(echo "import orchestra, os; print os.path.dirname(os.path.realpath(orchestra.__file__))" | python) + echo $PATH +} +export -f get_orchestra_dir + + +function print_install_requirements_help () { + cat <<- EOF + + ${bold}NAME${normal} + ${bold}orchetsra-admin install_requirements${normal} - Installs all Orchestra requirements using apt-get and pip + + ${bold}OPTIONS${normal} + ${bold}-t, --testing${normal} + Install Orchestra normal requirements plus those needed for running functional tests + + ${bold}-h, --help${normal} + Displays this help text + + EOF +} + + +function install_requirements () { + opts=$(getopt -o h,t -l help,testing -- "$@") || exit 1 + set -- $opts + testing=false + + while [ $# -gt 0 ]; do + case $1 in + -h|--help) print_deploy_help; exit 0 ;; + -t|--testing) testing=true; shift ;; + (--) shift; break;; + (-*) echo "$0: Err. - unrecognized option $1" 1>&2; exit 1;; + (*) break;; + esac + shift + done + unset OPTIND + unset opt + + check_root + ORCHESTRA_PATH=$(get_orchestra_dir) + + APT="python-pip \ + python-psycopg2 \ + postgresql \ + rabbitmq-server \ + python-dev \ + bind9utils \ + python-cracklib" + + PIP="django==1.6.1 \ + django-celery-email==1.0.3 \ + django-fluent-dashboard==0.3.5 \ + https://bitbucket.org/izi/django-admin-tools/get/a0abfffd76a0.zip \ + South==0.8.1 \ + IPy==0.81 \ + django-extensions==1.1.1 \ + django-transaction-signals==1.0.0 \ + django-celery==3.1.1 \ + celery==3.1.7 \ + kombu==3.0.8 \ + Markdown==2.4 \ + django-debug-toolbar==1.0.1 \ + djangorestframework==2.3.12 \ + paramiko==1.12.1 \ + Pygments==1.6 \ + django-filter==0.7 \ + passlib==1.6.2" + + if $testing; then + APT="${APT} \ + iceweasel \ + xvfb" + PIP="${PIP} \ + selenium \ + xvfbwrapper" + fi + + # Make sure locales are in place before installing postgres + if [[ $({ perl --help > /dev/null; } 2>&1|grep 'locale failed') ]]; then + run sed -i "s/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/" /etc/locale.gen + run locale-gen + update-locale LANG=en_US.UTF-8 + fi + + run apt-get update + run apt-get install -y $APT + run pip install $PIP + + # Some versions of rabbitmq-server will not start automatically by default unless ... + sed -i "s/# Default-Start:.*/# Default-Start: 2 3 4 5/" /etc/init.d/rabbitmq-server + sed -i "s/# Default-Stop:.*/# Default-Stop: 0 1 6/" /etc/init.d/rabbitmq-server + run update-rc.d rabbitmq-server defaults +} +export -f install_requirements + + +print_startproject_help () { + cat <<- EOF + + ${bold}NAME${normal} + ${bold}orchestra-admin startproject${normal} - Create a new Django-Orchestra instance + + ${bold}SYNOPSIS${normal} + Options: [ -h ] + + ${bold}OPTIONS${normal} + ${bold}-h, --help${normal} + This help message + + ${bold}EXAMPLES${normal} + orchestra-admin startproject controlpanel + + EOF +} + + +function startproject () { + local PROJECT_NAME="$2"; shift + + opts=$(getopt -o h -l help -- "$@") || exit 1 + set -- $opts + + set -- $opts + while [ $# -gt 0 ]; do + case $1 in + -h|--help) print_startproject_help; exit 0 ;; + (--) shift; break;; + (-*) echo "$0: Err. - unrecognized option $1" 1>&2; exit 1;; + (*) break;; + esac + shift + done + + unset OPTIND + unset opt + + [ $(whoami) == 'root' ] && { echo -e "\nYou don't want to run this as root\n" >&2; exit 1; } + ORCHESTRA_PATH=$(get_orchestra_dir) || { echo "Error getting orchestra dir"; exit 1; } + if [[ ! -e $PROJECT_NAME/manage.py ]]; then + run django-admin.py startproject $PROJECT_NAME --template="${ORCHESTRA_PATH}/conf/project_template" + # This is a workaround for this issue https://github.com/pypa/pip/issues/317 + run chmod +x $PROJECT_NAME/manage.py + # End of workaround ### + else + echo "Not cloning: $PROJECT_NAME already exists." + fi + # Install bash autocompletition for django commands + if [[ ! $(grep 'source $HOME/.django_bash_completion.sh' ~/.bashrc &> /dev/null) ]]; then + # run wget https://raw.github.com/django/django/master/extras/django_bash_completion \ + # --no-check-certificate -O ~/.django_bash_completion.sh + cp ${ORCHESTRA_PATH}/bin/django_bash_completion.sh ~/.django_bash_completion.sh + echo 'source $HOME/.django_bash_completion.sh' >> ~/.bashrc + fi +} +export -f startproject + + +function print_install_certificate_help () { + cat <<- EOF + + ${bold}NAME${normal} + ${bold}orchetsra-admin install_certificate${normal} - Installs a valid all-purpose self signed certificate that is valid for the next ten years + + ${bold}OPTIONS${normal} + ${bold}-h, --help${normal} + Displays this help text + + EOF +} + +function install_certificate () { + opts=$(getopt -o h -l help -- "$@") || exit 1 + set -- $opts + + while [ $# -gt 0 ]; do + case $1 in + -h|--help) print_deploy_help; exit 0 ;; + (--) shift; break;; + (-*) echo "$0: Err. - unrecognized option $1" 1>&2; exit 1;; + (*) break;; + esac + shift + done + unset OPTIND + unset opt + + check_root + run openssl req -new -x509 -days 3650 -nodes -newkey rsa:4096 -out /etc/ssl/certs/mailserver.pem -keyout /etc/ssl/private/mailserver.pem + run chmod go= /etc/ssl/private/mailserver.pem +} +export -f install_certificate + + +function print_uninstall_certificate_help () { + cat <<- EOF + + ${bold}NAME${normal} + ${bold}orchetsra-admin uninstall_certificate${normal} - Remove self signed certificate + + ${bold}OPTIONS${normal} + ${bold}-h, --help${normal} + Displays this help text + + EOF +} + +function uninstall_certificate () { + opts=$(getopt -o h -l help -- "$@") || exit 1 + set -- $opts + + while [ $# -gt 0 ]; do + case $1 in + -h|--help) print_deploy_help; exit 0 ;; + (--) shift; break;; + (-*) echo "$0: Err. - unrecognized option $1" 1>&2; exit 1;; + (*) break;; + esac + shift + done + unset OPTIND + unset opt + + check_root + run rm -f /etc/ssl/private/mailserver.pem +} +export -f uninstall_certificate + + + +function print_install_postfix_help () { + cat <<- EOF + + ${bold}NAME${normal} + ${bold}orchetsra-admin install_postfix${normal} - Installs postfix server and its dependencies (dovecot, amavis, spamassassin and clamav) using apt-get. Also it generates a valid all-purpose certificate self signed that is valid for the next ten years. + + ${bold}OPTIONS${normal} + ${bold}-h, --help${normal} + Displays this help text + + EOF +} + + +function install_postfix () { + opts=$(getopt -o h -l help -- "$@") || exit 1 + set -- $opts + + while [ $# -gt 0 ]; do + case $1 in + -h|--help) print_deploy_help; exit 0 ;; + (--) shift; break;; + (-*) echo "$0: Err. - unrecognized option $1" 1>&2; exit 1;; + (*) break;; + esac + shift + done + unset OPTIND + unset opt + + check_root + ORCHESTRA_PATH=$(get_orchestra_dir) + + APT="postfix postfix-pgsql \ + swaks \ + dovecot-core dovecot-pop3d dovecot-imapd dovecot-antispam \ + dovecot-pgsql dovecot-sieve dovecot-managesieved dovecot-solr \ + amavisd-new spamassassin \ + clamav-freshclam clamav-base clamav clamav-daemon clamav-testfiles \ + " + run apt-get update + export DEBIAN_FRONTEND=noninteractive + run apt-get install -y $APT + unset $DEBIAN_FRONTEND; + run /usr/bin/freshclam + run apt-get --purge remove 'exim4*' -y + if [ ! -f /etc/ssl/private/mailserver.pem ]; then + install_certificate + fi; +} +export -f install_postfix + +function print_uninstall_postfix_help () { + cat <<- EOF + + ${bold}NAME${normal} + ${bold}orchetsra-admin uninstall_postfix${normal} - Uninstalls postfix server and its dependencies (dovecot, amavis, spamassassin and clamav) using dpkg and remove self signed certificate + + ${bold}OPTIONS${normal} + ${bold}-h, --help${normal} + Displays this help text + + EOF +} + + +function uninstall_postfix () { + opts=$(getopt -o h -l help -- "$@") || exit 1 + set -- $opts + + while [ $# -gt 0 ]; do + case $1 in + -h|--help) print_deploy_help; exit 0 ;; + (--) shift; break;; + (-*) echo "$0: Err. - unrecognized option $1" 1>&2; exit 1;; + (*) break;; + esac + shift + done + unset OPTIND + unset opt + + check_root + ORCHESTRA_PATH=$(get_orchestra_dir) + + APT="postfix postfix-pgsql \ + swaks \ + dovecot-core dovecot-pop3d dovecot-imapd dovecot-antispam \ + dovecot-pgsql dovecot-sieve dovecot-managesieved dovecot-solr \ + amavisd-new spamassassin \ + clamav-freshclam clamav-base clamav clamav-daemon libclamav6 clamav-testfiles \ + " + run dpkg -P --force-depends $APT + run apt-get update + run apt-get -f install -y + + if [ -d /var/run/amavis ]; then + run rm -rf /var/run/amavis + fi; + + if [ -d /var/lib/clamav ]; then + run rm -rf /var/lib/clamav + fi; + + if [ -f /etc/ssl/private/mailserver.pem ]; then + uninstall_certificate + fi; +} +export -f uninstall_postfix + + +[ $# -lt 1 ] && print_help +$1 "${@}" diff --git a/orchestra/bin/sieve-test b/orchestra/bin/sieve-test new file mode 100755 index 00000000..da753038 Binary files /dev/null and b/orchestra/bin/sieve-test differ diff --git a/orchestra/conf/__init__.py b/orchestra/conf/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/conf/base_settings.py b/orchestra/conf/base_settings.py new file mode 100644 index 00000000..73f252ca --- /dev/null +++ b/orchestra/conf/base_settings.py @@ -0,0 +1,222 @@ +# Django settings for orchestra project. + +DEBUG = False +TEMPLATE_DEBUG = DEBUG + +# Enable persistent connections +CONN_MAX_AGE = 60*10 + +# Local time zone for this installation. Choices can be found here: +# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name +# although not all choices may be available on all operating systems. +# In a Windows environment this must be set to your system time zone. +TIME_ZONE = 'UTC' + +# Language code for this installation. All choices can be found here: +# http://www.i18nguy.com/unicode/language-identifiers.html +LANGUAGE_CODE = 'en-us' + +# If you set this to False, Django will make some optimizations so as not +# to load the internationalization machinery. +USE_I18N = True + +# If you set this to False, Django will not format dates, numbers and +# calendars according to the current locale. +USE_L10N = True + +# If you set this to False, Django will not use timezone-aware datetimes. +USE_TZ = True + +# URL prefix for static files. +# Example: "http://media.lawrence.com/static/" +STATIC_URL = '/static/' + + +ALLOWED_HOSTS = '*' + + +MIDDLEWARE_CLASSES = ( + 'django.middleware.gzip.GZipMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.transaction.TransactionMiddleware', +# 'orchestra.apps.contacts.middlewares.ContractMiddleware', + 'orchestra.apps.orchestration.middlewares.OperationsMiddleware', + # Uncomment the next line for simple clickjacking protection: + # 'django.middleware.clickjacking.XFrameOptionsMiddleware', +) + + +TEMPLATE_CONTEXT_PROCESSORS =( + "django.contrib.auth.context_processors.auth", + "django.core.context_processors.debug", + "django.core.context_processors.i18n", + "django.core.context_processors.media", + "django.contrib.messages.context_processors.messages", + "django.core.context_processors.request", + "orchestra.core.context_processors.site", +) + + +INSTALLED_APPS = ( + # django-orchestra apps + 'orchestra', + 'orchestra.apps.orchestration', + 'orchestra.apps.domains', + 'orchestra.apps.users', + 'orchestra.apps.users.roles.mail', + 'orchestra.apps.users.roles.jabber', + 'orchestra.apps.users.roles.posix', + 'orchestra.apps.lists', + 'orchestra.apps.webapps', + 'orchestra.apps.websites', + 'orchestra.apps.databases', + 'orchestra.apps.vps', + 'orchestra.apps.issues', + + # Third-party apps + 'south', + 'django_extensions', + 'djcelery', + 'djcelery_email', + 'fluent_dashboard', + 'admin_tools', + 'admin_tools.theming', + 'admin_tools.menu', + 'admin_tools.dashboard', + 'rest_framework', + 'rest_framework.authtoken', + 'passlib.ext.django', + + # Django.contrib + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.contrib.admin', + + 'orchestra.apps.accounts', + 'orchestra.apps.contacts', +) + + +AUTH_USER_MODEL = 'users.User' + + +AUTHENTICATION_BACKENDS = [ + 'orchestra.permissions.auth.OrchestraPermissionBackend', + 'django.contrib.auth.backends.ModelBackend', +] + + +# Email config +EMAIL_BACKEND = 'djcelery_email.backends.CeleryEmailBackend' + + +################################# +## 3RD PARTY APPS CONIGURATION ## +################################# + +# Admin Tools +ADMIN_TOOLS_MENU = 'orchestra.admin.menu.OrchestraMenu' + +# Fluent dashboard +# TODO subclass like in admin_tools_menu +ADMIN_TOOLS_INDEX_DASHBOARD = 'fluent_dashboard.dashboard.FluentIndexDashboard' +FLUENT_DASHBOARD_ICON_THEME = '../orchestra/icons' + +FLUENT_DASHBOARD_APP_GROUPS = ( + # Services group is generated by orchestra.admin.dashboard + ('Accounts', { + 'models': ( + 'orchestra.apps.accounts.models.Account', + 'orchestra.apps.contacts.models.Contact', + 'orchestra.apps.users.models.User', + ), + 'collapsible': True, + }), + ('Administration', { + 'models': ( + 'djcelery.models.TaskState', + 'orchestra.apps.orchestration.models.Route', + 'orchestra.apps.orchestration.models.BackendLog', + 'orchestra.apps.orchestration.models.Server', + 'orchestra.apps.issues.models.Ticket', + ), + 'collapsible': True, + }), +) + +FLUENT_DASHBOARD_APP_ICONS = { + # Services + 'webs/web': 'web.png', + 'mail/mailbox': 'email.png', + 'mail/address': 'X-office-address-book.png', + 'lists/list': 'email-alter.png', + 'domains/domain': 'domain.png', + 'multitenance/tenant': 'apps.png', + 'webapps/webapp': 'Applications-other.png', + 'websites/website': 'Applications-internet.png', + 'databases/database': 'database.png', + 'databases/databaseuser': 'postgresql.png', + 'vps/vps': 'TuxBox.png', + # Accounts + 'accounts/account': 'Face-monkey.png', + 'contacts/contact': 'contact.png', + # Administration + 'users/user': 'Mr-potato.png', + 'djcelery/taskstate': 'taskstate.png', + 'orchestration/server': 'vps.png', + 'orchestration/route': 'hal.png', + 'orchestration/backendlog': 'scriptlog.png', + 'issues/ticket': "Ticket_star.png", +} + +# Django-celery +import djcelery +djcelery.setup_loader() +# Broker +BROKER_URL = 'amqp://guest:guest@localhost:5672//' +CELERY_SEND_EVENTS = True +CELERYBEAT_SCHEDULER = 'djcelery.schedulers.DatabaseScheduler' +CELERY_DISABLE_RATE_LIMITS = True +# Do not fill the logs with crap +CELERY_REDIRECT_STDOUTS_LEVEL = 'DEBUG' + + +# rest_framework +REST_FRAMEWORK = { + 'DEFAULT_PERMISSION_CLASSES': ( + 'orchestra.permissions.api.OrchestraPermissionBackend', + ), + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.TokenAuthentication', + ), + 'DEFAULT_FILTER_BACKENDS': ( + ('rest_framework.filters.DjangoFilterBackend',) + ), +} + + +# Use a UNIX compatible hash +PASSLIB_CONFIG = ( + "[passlib]\n" + "schemes = sha512_crypt, django_pbkdf2_sha256, django_pbkdf2_sha1," + " django_bcrypt, django_bcrypt_sha256, django_salted_sha1, des_crypt," + " django_salted_md5, django_des_crypt, hex_md5, bcrypt, phpass\n" + "default = sha512_crypt\n" + "deprecated = django_pbkdf2_sha1, django_salted_sha1, django_salted_md5," + " django_des_crypt, des_crypt, hex_md5\n" + "all__vary_rounds = 0.05\n" + "django_pbkdf2_sha256__min_rounds = 10000\n" + "sha512_crypt__min_rounds = 80000\n" + "staff__django_pbkdf2_sha256__default_rounds = 12500\n" + "staff__sha512_crypt__default_rounds = 100000\n" + "superuser__django_pbkdf2_sha256__default_rounds = 15000\n" + "superuser__sha512_crypt__default_rounds = 120000\n" +) diff --git a/orchestra/conf/devel_settings.py b/orchestra/conf/devel_settings.py new file mode 100644 index 00000000..c89e40c4 --- /dev/null +++ b/orchestra/conf/devel_settings.py @@ -0,0 +1,20 @@ +import sys + +from orchestra.conf.base_settings import * + + +DEBUG = True + +TEMPLATE_DEBUG = True + +CELERY_SEND_TASK_ERROR_EMAILS = False + +# When DEBUG is enabled Django appends every executed SQL statement to django.db.connection.queries +# this will grow unbounded in a long running process environment like celeryd +if "celeryd" in sys.argv or 'celeryev' in sys.argv or 'celerybeat' in sys.argv: + DEBUG = False + +# Django debug toolbar +INSTALLED_APPS += ('debug_toolbar', ) +MIDDLEWARE_CLASSES += ('debug_toolbar.middleware.DebugToolbarMiddleware',) +INTERNAL_IPS = ('127.0.0.1', '10.0.3.1',) #10.0.3.1 is the lxcbr0 ip diff --git a/orchestra/conf/production_settings.py b/orchestra/conf/production_settings.py new file mode 100644 index 00000000..717610cb --- /dev/null +++ b/orchestra/conf/production_settings.py @@ -0,0 +1,2 @@ +from orchestra.conf.base_settings import * + diff --git a/orchestra/conf/project_template/manage.py b/orchestra/conf/project_template/manage.py new file mode 100755 index 00000000..c87fc51f --- /dev/null +++ b/orchestra/conf/project_template/manage.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{{ project_name }}.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) + diff --git a/orchestra/conf/project_template/project_name/__init__.py b/orchestra/conf/project_template/project_name/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/conf/project_template/project_name/settings.py b/orchestra/conf/project_template/project_name/settings.py new file mode 100644 index 00000000..6e5a04ac --- /dev/null +++ b/orchestra/conf/project_template/project_name/settings.py @@ -0,0 +1,63 @@ +""" +Django settings for {{ project_name }} project. + +For more information on this file, see +https://docs.djangoproject.com/en/{{ docs_version }}/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/{{ docs_version }}/ref/settings/ +""" + +# Production settings +from orchestra.conf.production_settings import * +# Development settings +# from orchestra.conf.devel_settings import * + + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +import os +BASE_DIR = os.path.dirname(os.path.dirname(__file__)) + + +# SECURITY WARNING: keep the secret key used in production secret! +# Hardcoded values can leak through source control. Consider loading +# the secret key from an environment variable or a file instead. +SECRET_KEY = '{{ secret_key }}' + +ROOT_URLCONF = '{{ project_name }}.urls' + +WSGI_APPLICATION = '{{ project_name }}.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/{{ docs_version }}/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'. + 'NAME': 'orchestra', # Or path to database file if using sqlite3. + 'USER': 'orchestra', # Not used with sqlite3. + 'PASSWORD': 'orchestra', # Not used with sqlite3. + 'HOST': 'localhost', # Set to empty string for localhost. Not used with sqlite3. + 'PORT': '', # Set to empty string for default. Not used with sqlite3. + } +} + + +# Absolute path to the directory static files should be collected to. +# Don't put anything in this directory yourself; store your static files +# in apps' "static/" subdirectories and in STATICFILES_DIRS. +# Example: "/home/media/media.lawrence.com/static/" +STATIC_ROOT = os.path.join(BASE_DIR, 'static') + + +# EMAIL_HOST = 'smtp.yourhost.eu' +# EMAIL_PORT = '' +# EMAIL_HOST_USER = '' +# EMAIL_HOST_PASSWORD = '' +# EMAIL_USE_TLS = False +# DEFAULT_FROM_EMAIL = 'orchestra@yourhost.eu' + + +SITE_NAME = '{{ project_name }}' + diff --git a/orchestra/conf/project_template/project_name/urls.py b/orchestra/conf/project_template/project_name/urls.py new file mode 100644 index 00000000..bda27e97 --- /dev/null +++ b/orchestra/conf/project_template/project_name/urls.py @@ -0,0 +1,6 @@ +from django.conf.urls import patterns, include, url + +urlpatterns = patterns('', + url(r'', include('orchestra.urls')), +) + diff --git a/orchestra/conf/project_template/project_name/wsgi.py b/orchestra/conf/project_template/project_name/wsgi.py new file mode 100644 index 00000000..94d60c8c --- /dev/null +++ b/orchestra/conf/project_template/project_name/wsgi.py @@ -0,0 +1,14 @@ +""" +WSGI config for {{ project_name }} project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/{{ docs_version }}/howto/deployment/wsgi/ +""" + +import os +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{{ project_name }}.settings") + +from django.core.wsgi import get_wsgi_application +application = get_wsgi_application() diff --git a/orchestra/core/__init__.py b/orchestra/core/__init__.py new file mode 100644 index 00000000..1fdb31d0 --- /dev/null +++ b/orchestra/core/__init__.py @@ -0,0 +1,19 @@ +class Service(object): + _registry = {} + + def register(self, model, **kwargs): + if model in self._registry: + raise KeyError("%s already registered" % str(model)) + plural = kwargs.get('verbose_name_plural', model._meta.verbose_name_plural) + plural = plural[0].upper() + plural[1:] + self._registry[model] = { + 'verbose_name': kwargs.get('verbose_name', model._meta.verbose_name), + 'verbose_name_plural': plural, + 'menu': kwargs.get('menu', True) + } + + def get(self): + return self._registry + + +services = Service() diff --git a/orchestra/core/context_processors.py b/orchestra/core/context_processors.py new file mode 100644 index 00000000..56a9c80b --- /dev/null +++ b/orchestra/core/context_processors.py @@ -0,0 +1,10 @@ +from orchestra import settings + + +def site(request): + """ Adds site-related context variables to the context """ + return { + 'SITE_NAME': settings.SITE_NAME, + 'SITE_VERBOSE_NAME': settings.SITE_VERBOSE_NAME + } + diff --git a/orchestra/core/validators.py b/orchestra/core/validators.py new file mode 100644 index 00000000..85cbeea0 --- /dev/null +++ b/orchestra/core/validators.py @@ -0,0 +1,69 @@ +import re + +import crack + +from django.core import validators +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ +from IPy import IP + + +def validate_ipv4_address(value): + msg = _("%s is not a valid IPv4 address") % value + try: + ip = IP(value) + except: + raise ValidationError(msg) + if ip.version() != 4: + raise ValidationError(msg) + + + +def validate_ipv6_address(value): + msg = _("%s is not a valid IPv6 address") % value + try: + ip = IP(value) + except: + raise ValidationError(msg) + if ip.version() != 6: + raise ValidationError(msg) + + +def validate_name(value): + """ + A single non-empty line of free-form text with no whitespace + surrounding it. + """ + validators.RegexValidator('^\S.*\S$', + _("Enter a valid name (text without whitspaces)."), 'invalid')(value) + + +def validate_ascii(value): + try: + value.decode('ascii') + except UnicodeDecodeError: + raise ValidationError('This is not an ASCII string.') + + +def validate_hostname(hostname): + """ + Ensures that each segment + * contains at least one character and a maximum of 63 characters + * consists only of allowed characters + * doesn't begin or end with a hyphen. + http://stackoverflow.com/a/2532344 + """ + if len(hostname) > 255: + return False + if hostname[-1] == ".": + hostname = hostname[:-1] # strip exactly one dot from the right, if present + allowed = re.compile("(?!-)[A-Z\d-]{1,63}(? 10202 + version = int(str(major) + "%02d" % int(major2) + "%02d" % int(minor)) + + # Pre version specific upgrade operations + if version < 001: + pass + + if not options.get('specifics_only'): + # Common stuff + orchestra_admin = os.path.join(os.path.dirname(__file__), '../../bin/') + orchestra_admin = os.path.join(orchestra_admin, 'orchestra-admin') + run('chmod +x %s' % orchestra_admin) + run("%s install_requirements" % orchestra_admin) + + manage_path = os.path.join(get_site_root(), 'manage.py') + run("python %s collectstatic --noinput" % manage_path) + run("python %s syncdb --noinput" % manage_path) + run("python %s migrate --noinput" % manage_path) + if options.get('restart'): + run("python %s restartservices" % manage_path) + + if not version: + self.stderr.write('\n' + 'Next time you migth want to provide a --from argument in order ' + 'to run version specific upgrade operations\n') + return + + # Post version specific operations + if version <= 001: + pass + + if upgrade_notes and options.get('print_upgrade_notes'): + self.stdout.write('\n\033[1m\n' + ' ===================\n' + ' ** UPGRADE NOTES **\n' + ' ===================\n\n' + + '\n'.join(upgrade_notes) + '\033[m\n') diff --git a/orchestra/management/commands/restartservices.py b/orchestra/management/commands/restartservices.py new file mode 100644 index 00000000..50c0c91c --- /dev/null +++ b/orchestra/management/commands/restartservices.py @@ -0,0 +1,11 @@ +from django.core.management.base import BaseCommand + +from orchestra.management.commands.startservices import ManageServiceCommand +from orchestra.settings import RESTART_SERVICES + + +class Command(ManageServiceCommand): + services = RESTART_SERVICES + action = 'restart' + option_list = BaseCommand.option_list + help = 'Restart all related services. Usefull for reload configuration and files.' diff --git a/orchestra/management/commands/setupcelery.py b/orchestra/management/commands/setupcelery.py new file mode 100644 index 00000000..a6bf9c3f --- /dev/null +++ b/orchestra/management/commands/setupcelery.py @@ -0,0 +1,103 @@ +from optparse import make_option +from os import path + +from django.core.management.base import BaseCommand + +from orchestra.utils.paths import get_site_root, get_orchestra_root +from orchestra.utils.system import run, check_root + + +class Command(BaseCommand): + def __init__(self, *args, **kwargs): + super(Command, self).__init__(*args, **kwargs) + self.option_list = BaseCommand.option_list + ( + make_option('--username', dest='username', default='orchestra', + help='Specifies the system user that would run celeryd.'), + make_option('--processes', dest='processes', default=5, + help='Number of celeryd processes.'), + make_option('--noinput', action='store_false', dest='interactive', default=True, + help='Tells Django to NOT prompt the user for input of any kind. ' + 'You must use --username with --noinput, and must contain the ' + 'cleleryd process owner, which is the user how will perform tincd updates'), + ) + + option_list = BaseCommand.option_list + help = 'Configure Celeryd to run with your orchestra instance.' + + @check_root + def handle(self, *args, **options): + context = { + 'site_root': get_site_root(), + 'username': options.get('username'), + 'bin_path': path.join(get_orchestra_root(), 'bin'), + 'processes': options.get('processes'), + } + + celery_config = ( + '# Name of nodes to start, here we have a single node\n' + 'CELERYD_NODES="w1"\n' + '\n' + '# Where to chdir at start.\n' + 'CELERYD_CHDIR="%(site_root)s"\n' + '\n' + '# How to call "manage.py celeryd_multi"\n' + 'CELERYD_MULTI="$CELERYD_CHDIR/manage.py celeryd_multi"\n' + '\n' + '# Extra arguments to celeryd\n' + 'CELERYD_OPTS="-P:w1 processes -c:w1 %(processes)s -Q:w1 celery"\n' + '\n' + '# Name of the celery config module.\n' + 'CELERY_CONFIG_MODULE="celeryconfig"\n' + '\n' + '# %%n will be replaced with the nodename.\n' + 'CELERYD_LOG_FILE="/var/log/celery/%%n.log"\n' + 'CELERYD_PID_FILE="/var/run/celery/%%n.pid"\n' + 'CELERY_CREATE_DIRS=1\n' + '\n' + '# Full path to the celeryd logfile.\n' + 'CELERYEV_LOG_FILE="/var/log/celery/celeryev.log"\n' + 'CELERYEV_PID_FILE="/var/run/celery/celeryev.pid"\n' + '\n' + '# Workers should run as an unprivileged user.\n' + 'CELERYD_USER="%(username)s"\n' + 'CELERYD_GROUP="$CELERYD_USER"\n' + '\n' + '# Persistent revokes\n' + 'CELERYD_STATE_DB="$CELERYD_CHDIR/persistent_revokes"\n' + '\n' + '# Celeryev\n' + 'CELERYEV="$CELERYD_CHDIR/manage.py"\n' + 'CELERYEV_CAM="djcelery.snapshot.Camera"\n' + 'CELERYEV_USER="$CELERYD_USER"\n' + 'CELERYEV_GROUP="$CELERYD_USER"\n' + 'CELERYEV_OPTS="celerycam"\n' + '\n' + '# Celerybeat\n' + 'CELERYBEAT="${CELERYD_CHDIR}/manage.py celerybeat"\n' + 'CELERYBEAT_USER="$CELERYD_USER"\n' + 'CELERYBEAT_GROUP="$CELERYD_USER"\n' + 'CELERYBEAT_CHDIR="$CELERYD_CHDIR"\n' + 'CELERYBEAT_OPTS="--schedule=/var/run/celerybeat-schedule"\n' % context + ) + + run("echo '%s' > /etc/default/celeryd" % celery_config) + + # https://raw.github.com/celery/celery/master/extra/generic-init.d/ + for script in ['celeryevcam', 'celeryd', 'celerybeat']: + context['script'] = script + run('cp %(bin_path)s/%(script)s /etc/init.d/%(script)s' % context) + run('chmod +x /etc/init.d/%(script)s' % context) + run('update-rc.d %(script)s defaults' % context) + + rotate = ( + '/var/log/celery/*.log {\n' + ' weekly\n' + ' missingok\n' + ' rotate 10\n' + ' compress\n' + ' delaycompress\n' + ' notifempty\n' + ' copytruncate\n' + '}' + ) + run("echo '%s' > /etc/logrotate.d/celeryd" % rotate) diff --git a/orchestra/management/commands/setupnginx.py b/orchestra/management/commands/setupnginx.py new file mode 100644 index 00000000..811b0b82 --- /dev/null +++ b/orchestra/management/commands/setupnginx.py @@ -0,0 +1,129 @@ +from optparse import make_option +from os.path import expanduser + +from django.conf import settings +from django.core.management.base import BaseCommand +from django.utils.six.moves import input + +from orchestra.utils.paths import get_project_root, get_site_root, get_project_name +from orchestra.utils.system import run, check_root, get_default_celeryd_username + + +class Command(BaseCommand): + def __init__(self, *args, **kwargs): + super(Command, self).__init__(*args, **kwargs) + self.option_list = BaseCommand.option_list + ( + make_option('--user', dest='user', default=get_default_celeryd_username(), + help='uWSGI daemon user.'), + make_option('--group', dest='group', default='', + help='uWSGI daemon group.'), + make_option('--processes', dest='processes', default=4, + help='uWSGI number of processes.'), + make_option('--noinput', action='store_false', dest='interactive', default=True, + help='Tells Django to NOT prompt the user for input of any kind. ' + 'You must use --username with --noinput, and must contain the ' + 'cleeryd process owner, which is the user how will perform tincd updates'), + ) + + option_list = BaseCommand.option_list + help = 'Configures nginx + uwsgi to run with your Orchestra instance.' + + @check_root + def handle(self, *args, **options): + interactive = options.get('interactive') + + context = { + 'project_name': get_project_name(), + 'project_root': get_project_root(), + 'site_root': get_site_root(), + 'static_root': settings.STATIC_ROOT, + 'user': options.get('user'), + 'group': options.get('group') or options.get('user'), + 'home': expanduser("~%s" % options.get('user')), + 'processes': int(options.get('processes')),} + + nginx_conf = ( + 'server {\n' + ' listen 80;\n' + ' listen [::]:80 ipv6only=on;\n' + ' rewrite ^/$ /admin;\n' + ' client_max_body_size 500m;\n' + ' location / {\n' + ' uwsgi_pass unix:///var/run/uwsgi/app/%(project_name)s/socket;\n' + ' include uwsgi_params;\n' + ' }\n' + ' location /static {\n' + ' alias %(static_root)s;\n' + ' expires 30d;\n' + ' }\n' + '}\n') % context + + uwsgi_conf = ( + '[uwsgi]\n' + 'plugins = python\n' + 'chdir = %(site_root)s\n' + 'module = %(project_name)s.wsgi\n' + 'master = true\n' + 'processes = %(processes)d\n' + 'chmod-socket = 664\n' + 'stats = /run/uwsgi/%%(deb-confnamespace)/%%(deb-confname)/statsocket\n' + 'vacuum = true\n' + 'uid = %(user)s\n' + 'gid = %(group)s\n' + 'env = HOME=%(home)s\n') % context + + nginx = { + 'file': '/etc/nginx/conf.d/%(project_name)s.conf' % context, + 'conf': nginx_conf } + uwsgi = { + 'file': '/etc/uwsgi/apps-available/%(project_name)s.ini' % context, + 'conf': uwsgi_conf } + + for extra_context in (nginx, uwsgi): + context.update(extra_context) + diff = run("echo '%(conf)s'|diff - %(file)s" % context, error_codes=[0,1,2]) + if diff.return_code == 2: + # File does not exist + run("echo '%(conf)s' > %(file)s" % context) + elif diff.return_code == 1: + # File is different, save the old one + if interactive: + msg = ("\n\nFile %(file)s be updated, do you like to overide " + "it? (yes/no): " % context) + confirm = input(msg) + while 1: + if confirm not in ('yes', 'no'): + confirm = input('Please enter either "yes" or "no": ') + continue + if confirm == 'no': + return + break + run("cp %(file)s %(file)s.save" % context) + run("echo '%(conf)s' > %(file)s" % context) + self.stdout.write("\033[1;31mA new version of %(file)s has been installed.\n " + "The old version has been placed at %(file)s.save\033[m" % context) + + run('ln -s /etc/uwsgi/apps-available/%(project_name)s.ini /etc/uwsgi/apps-enabled/' % context, error_codes=[0,1]) + + # nginx should start after tincd + current = "\$local_fs \$remote_fs \$network \$syslog" + run('sed -i "s/ %s$/ %s \$named/g" /etc/init.d/nginx' % (current, current)) + + rotate = ( + '/var/log/nginx/*.log {\n' + ' daily\n' + ' missingok\n' + ' rotate 30\n' + ' compress\n' + ' delaycompress\n' + ' notifempty\n' + ' create 640 root adm\n' + ' sharedscripts\n' + ' postrotate\n' + ' [ ! -f /var/run/nginx.pid ] || kill -USR1 `cat /var/run/nginx.pid`\n' + ' endscript\n' + '}\n') + run("echo '%s' > /etc/logrotate.d/nginx" % rotate) + + # Allow nginx to write to uwsgi socket + run('adduser www-data %(group)s' % context) diff --git a/orchestra/management/commands/setuppostfix.py b/orchestra/management/commands/setuppostfix.py new file mode 100644 index 00000000..78c15812 --- /dev/null +++ b/orchestra/management/commands/setuppostfix.py @@ -0,0 +1,321 @@ +import os +import sys + +from optparse import make_option + +from django.core.management.base import BaseCommand + +from orchestra.utils.system import run, check_root + +class Command(BaseCommand): + def __init__(self, *args, **kwargs): + super(Command, self).__init__(*args, **kwargs) + self.option_list = BaseCommand.option_list + ( + make_option('--db_name', dest='db_name', default='orchestra', + help='Specifies the database to create.'), + make_option('--db_user', dest='db_user', default='orchestra', + help='Specifies the database to create.'), + make_option('--db_password', dest='db_password', default='orchestra', + help='Specifies the database to create.'), + make_option('--db_host', dest='db_host', default='localhost', + help='Specifies the database to create.'), + + make_option('--vmail_username', dest='vmail_username', default='vmail', + help='Specifies username in the operating system (default=vmail).'), + make_option('--vmail_uid', dest='vmail_uid', default='5000', + help='UID of user (default=5000).'), + make_option('--vmail_groupname', dest='vmail_groupname', default='vmail', + help='Specifies the groupname in the operating system (default=vmail).'), + make_option('--vmail_gid', dest='vmail_gid', default='5000', + help='GID of user (default=5000).'), + make_option('--vmail_home', dest='vmail_home', default='/var/vmail', + help='$HOME of user (default=/var/vmail).'), + + make_option('--dovecot_dir', dest='dovecot_dir', default='/etc/dovecot', + help='Dovecot root directory (default=/etc/dovecot).'), + + make_option('--postfix_dir', dest='postfix_dir', default='/etc/postfix', + help='Postfix root directory (default=/etc/postfix).'), + + make_option('--amavis_dir', dest='amavis_dir', default='/etc/amavis', + help='Amavis root directory (default=/etc/amavis).'), + + make_option('--noinput', action='store_false', dest='interactive', default=True, + help='Tells Django to NOT prompt the user for input of any kind. ' + 'You must use --username with --noinput, and must contain the ' + 'cleeryd process owner, which is the user how will perform tincd updates'), + ) + + option_list = BaseCommand.option_list + help = 'Setup Postfix.' + + @check_root + def handle(self, *args, **options): + # Configure firmware generation + context = { + 'db_name': options.get('db_name'), + 'db_user': options.get('db_user'), + 'db_password': options.get('db_password'), + 'db_host': options.get('db_host'), + 'vmail_username': options.get('vmail_username'), + 'vmail_uid': options.get('vmail_uid'), + 'vmail_groupname': options.get('vmail_groupname'), + 'vmail_gid': options.get('vmail_gid'), + 'vmail_home': options.get('vmail_home'), + 'dovecot_dir': options.get('dovecot_dir'), + 'postfix_dir': options.get('postfix_dir'), + 'amavis_dir': options.get('amavis_dir'), + } + + file_name = '%(postfix_dir)s/pgsql-email2email.cf' % context + run("#Processing %s" % file_name) + pgsql_email2email = """user = %(db_user)s +password = %(db_password)s +hosts = %(db_host)s +dbname = %(db_name)s + +query = SELECT mails_mailbox.emailname || '@' || names_domain.name as email FROM mails_mailbox INNER JOIN names_domain ON (mails_mailbox.domain_id = names_domain.id) WHERE mails_mailbox.emailname = '%%u' AND names_domain.name = '%%d' +""" + f = open(file_name, 'w') + f.write(pgsql_email2email % context) + f.close() + run("chown root:postfix %s" % file_name) + run("chmod 640 %s" % file_name) + + file_name = '%(postfix_dir)s/pgsql-virtual-alias-maps.cf' % context + run("#Processing %s" % file_name) + virtual_alias_maps = """user = %(db_user)s +password = %(db_password)s +hosts = %(db_host)s +dbname = %(db_name)s + +query = SELECT mails_mailalias.destination FROM mails_mailalias INNER JOIN names_domain ON (mails_mailalias.domain_id = names_domain.id) WHERE mails_mailalias.emailname = '%%u' AND names_domain.name='%%d' +""" + f = open(file_name, 'w') + f.write(virtual_alias_maps % context) + f.close() + run("chown root:postfix %s" % file_name) + run("chmod 640 %s" % file_name) + + file_name = '%(postfix_dir)s/pgsql-virtual-mailbox-domains.cf' % context + run("#Processing %s" % file_name) + virtual_mailbox_domains = """user = %(db_user)s +password = %(db_password)s +hosts = %(db_host)s +dbname = %(db_name)s + +query = SELECT 1 FROM names_domain WHERE names_domain.name='%%s' +""" + f = open(file_name, 'w') + f.write(virtual_mailbox_domains % context) + f.close() + run("chown root:postfix %s" % file_name) + run("chmod 640 %s" % file_name) + + file_name = '%(postfix_dir)s/pgsql-virtual-mailbox-maps.cf' % context + run("#Processing %s" % file_name) + virtual_mailbox_maps = """user = %(db_user)s +password = %(db_password)s +hosts = %(db_host)s +dbname = %(db_name)s + +query = SELECT 1 FROM mails_mailbox INNER JOIN names_domain ON (mails_mailbox.domain_id = names_domain.id) WHERE mails_mailbox.emailname='%%u' AND names_domain.name='%%d' +""" + f = open(file_name, 'w') + f.write(virtual_mailbox_maps % context) + f.close() + run("chown root:postfix %s" % file_name) + run("chmod 640 %s" % file_name) + + #Dovecot + vmail_usename = run("id -u %(vmail_username)s" % context) + vmail_groupname = run("id -g %(vmail_groupname)s" % context) + if vmail_groupname != context["vmail_gid"]: + run("groupadd -g %(vmail_gid)s %(vmail_groupname)s" % context) + run("chown -R %(vmail_username)s:%(vmail_groupname)s %(vmail_home)s" % context) + if vmail_usename != context["vmail_uid"]: + run("useradd -g %(vmail_groupname)s -u %(vmail_uid)s %(vmail_username)s -d %(vmail_home)s -m" % context) + run("chmod u+w %(vmail_home)s" % context) + + run("chown -R %(vmail_username)s:%(vmail_groupname)s %(vmail_home)s" % context) + run("chmod u+w %(vmail_home)s" % context) + + file_name = "%(dovecot_dir)s/conf.d/10-auth.conf" % context + run("""sed -i "s/auth_mechanisms = plain$/auth_mechanisms = plain login/g" %s """ % file_name) + run("""sed -i "s/\#\!include auth-sql.conf.ext/\!include auth-sql.conf.ext/" %s """ % file_name) + + file_name = "%(dovecot_dir)s/conf.d/auth-sql.conf.ext" % context + run("#Processing %s" % file_name) + auth_sql_conf_ext = """passdb { + driver = sql + args = %(dovecot_dir)s/dovecot-sql.conf.ext +} + +userdb { + driver = static + args = uid=%(vmail_username)s gid=%(vmail_groupname)s home=%(vmail_home)s/%%d/%%n/Maildir allow_all_users=yes +} +""" + f = open(file_name, 'w') + f.write(auth_sql_conf_ext % context) + f.close() + + + file_name = "%(dovecot_dir)s/conf.d/10-mail.conf" % context + run("#Processing %s" % file_name) + mail_conf = """mail_location = maildir:%(vmail_home)s/%%d/%%n/Maildir +namespace inbox { + separator = . + inbox = yes +} + """ + f = open(file_name, 'w') + f.write(mail_conf % context) + f.close() + + + file_name = "%(dovecot_dir)s/conf.d/10-master.conf" % context + run("""sed -i "s/service auth {/service auth {\\n\\tunix_listener \/var\/spool\/postfix\/private\/auth {\\n\\t\\tmode = 0660\\n\\t\\tuser = postfix\\n\\t\\tgroup = postfix\\n\\t}\\n/g" %s """ % file_name) + + + file_name = "%(dovecot_dir)s/conf.d/10-ssl.conf" % context + + run("#Processing %s" % file_name) + ssl_conf = """ssl_cert = > %(settings)s' % context) diff --git a/orchestra/management/commands/startservices.py b/orchestra/management/commands/startservices.py new file mode 100644 index 00000000..f06fb26f --- /dev/null +++ b/orchestra/management/commands/startservices.py @@ -0,0 +1,59 @@ +from optparse import make_option + +from django.core.management.base import BaseCommand + +from orchestra.settings import START_SERVICES +from orchestra.utils.system import run, check_root + + +def run_tuple(services, action, options, optional=False): + if isinstance(services, str): + services = [services] + for service in services: + if options.get(service): + error_codes = [0,1] if optional else [0] + e = run('service %s %s' % (service, action), error_codes=error_codes) + if e.return_code == 1: + return False + return True + + +def flatten(nested, depth=0): + if hasattr(nested, '__iter__'): + for sublist in nested: + for element in flatten(sublist, depth+1): + yield element + else: + yield nested + + + +class ManageServiceCommand(BaseCommand): + def __init__(self, *args, **kwargs): + super(ManageServiceCommand, self).__init__(*args, **kwargs) + self.option_list = BaseCommand.option_list + tuple( + make_option('--no-%s' % service, action='store_false', dest=service, default=True, + help='Do not %s %s' % (self.action, service)) for service in flatten(self.services) + ) + + @check_root + def handle(self, *args, **options): + for service in self.services: + if isinstance(service, str): + run_tuple(service, self.action, options) + else: + failure = True + for opt_service in service: + if run_tuple(opt_service, self.action, options, optional=True): + failure = False + break + if failure: + str_services = [ str(s) for s in service ] + self.stderr.write('Error %sing %s' % (self.action, ' OR '.join(str_services))) + + +class Command(ManageServiceCommand): + services = START_SERVICES + action = 'start' + option_list = BaseCommand.option_list + help = 'Start all related services. Usefull for reload configuration and files.' diff --git a/orchestra/management/commands/stopservices.py b/orchestra/management/commands/stopservices.py new file mode 100644 index 00000000..14bf0edc --- /dev/null +++ b/orchestra/management/commands/stopservices.py @@ -0,0 +1,11 @@ +from django.core.management.base import BaseCommand + +from orchestra.management.commands.startservices import ManageServiceCommand +from orchestra.settings import STOP_SERVICES + + +class Command(ManageServiceCommand): + services = STOP_SERVICES + action = 'stop' + option_list = BaseCommand.option_list + help = 'Stop all related services. Usefull for reload configuration and files.' diff --git a/orchestra/management/commands/upgradeorchestra.py b/orchestra/management/commands/upgradeorchestra.py new file mode 100644 index 00000000..eafcfa49 --- /dev/null +++ b/orchestra/management/commands/upgradeorchestra.py @@ -0,0 +1,98 @@ +import functools +import os +import random +import string +from distutils.sysconfig import get_python_lib +from optparse import make_option + +from django.core.management import call_command +from django.core.management.base import BaseCommand, CommandError + +from orchestra import get_version +from orchestra.utils.system import run, check_root + + +r = functools.partial(run, silent=False) + + +def get_existing_pip_installation(): + """ returns current pip installation path """ + if run("pip freeze|grep django-orchestra", error_codes=[0,1]).return_code == 0: + for lib_path in get_python_lib(), get_python_lib(prefix="/usr/local"): + existing_path = os.path.abspath(os.path.join(lib_path, "orchestra")) + if os.path.exists(existing_path): + return existing_path + return None + + +class Command(BaseCommand): + def __init__(self, *args, **kwargs): + super(Command, self).__init__(*args, **kwargs) + self.option_list = BaseCommand.option_list + ( + make_option('--pip_only', action='store_true', dest='pip_only', default=False, + help='Only run "pip install django-orchestra --upgrade"'), + make_option('--orchestra_version', dest='version', default=False, + help='Specifies what version of the Orchestra you want to install'), + ) + + option_list = BaseCommand.option_list + help = "Upgrading Orchestra's installation. Desired version is accepted as argument" + can_import_settings = False + leave_locale_alone = True + + @check_root + def handle(self, *args, **options): + current_version = get_version() + current_path = get_existing_pip_installation() + + if current_path is not None: + desired_version = options.get('version') + if args: + desired_version = args[0] + if current_version == desired_version: + msg = "Not upgrading, you already have version %s installed" + raise CommandError(msg % desired_version) + # Create a backup of current installation + base_path = os.path.abspath(os.path.join(current_path, '..')) + char_set = string.ascii_uppercase + string.digits + rand_name = ''.join(random.sample(char_set, 6)) + backup = os.path.join(base_path, 'orchestra.' + rand_name) + run("mv %s %s" % (current_path, backup)) + + # collect existing eggs previous to the installation + eggs_regex = os.path.join(base_path, 'django_orchestra-*.egg-info') + eggs = run('ls -d %s' % eggs_regex) + eggs = eggs.stdout.splitlines() + try: + if desired_version: + r('pip install django-orchestra==%s' % desired_version) + else: + # Did I mentioned how I hate PIP? + if run('pip --version|cut -d" " -f2').stdout == '1.0': + r('pip install django-orchestra --upgrade') + else: + # (Fucking pip)^2, it returns exit code 0 even when fails + # because requirement already up-to-date + r('pip install django-orchestra --upgrade --force') + except CommandError: + # Restore backup + run('rm -rf %s' % current_path) + run('mv %s %s' % (backup, current_path)) + raise CommandError("Problem runing pip upgrade, aborting...") + else: + # Some old versions of pip do not performe this cleaning ... + # Remove all backups + run('rm -fr %s' % os.path.join(base_path, 'orchestra\.*')) + # Clean old egg files, yeah, cleaning PIP shit :P + c_version = 'from orchestra import get_version; print get_version()' + version = run('python -c "%s;"' % c_version).stdout + for egg in eggs: + # Do not remove the actual egg file when upgrading twice the same version + if egg.split('/')[-1] != "django_orchestra-%s.egg-info" % version: + run('rm -fr %s' % egg) + else: + raise CommandError("You don't seem to have any previous PIP installation") + + # version specific upgrade operations + if not options.get('pip_only'): + call_command("postupgradeorchestra", version=current_version) diff --git a/orchestra/models/__init__.py b/orchestra/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/models/fields.py b/orchestra/models/fields.py new file mode 100644 index 00000000..50a0e5e1 --- /dev/null +++ b/orchestra/models/fields.py @@ -0,0 +1,57 @@ +from django.db import models +from django.utils.text import capfirst + +from ..forms.fields import MultiSelectFormField +from ..utils.apps import isinstalled + + +class MultiSelectField(models.CharField): + __metaclass__ = models.SubfieldBase + + def formfield(self, **kwargs): + defaults = { + 'required': not self.blank, + 'label': capfirst(self.verbose_name), + 'help_text': self.help_text, + 'choices': self.choices + } + if self.has_default(): + defaults['initial'] = eval(self.get_default()) + defaults.update(kwargs) + return MultiSelectFormField(**defaults) + + def get_db_prep_value(self, value, connection=None, prepared=False): + if isinstance(value, basestring): + return value + elif isinstance(value, list): + return ','.join(value) + + def to_python(self, value): + if value is not None: + return value if isinstance(value, list) else value.split(',') + return '' + + def contribute_to_class(self, cls, name): + super(MultiSelectField, self).contribute_to_class(cls, name) + if self.choices: + def func(self, field=name, choices=dict(self.choices)): + ','.join([ choices.get(value, value) for value in getattr(self, field) ]) + setattr(cls, 'get_%s_display' % self.name, func) + + def validate(self, value, model_instance): + arr_choices = self.get_choices_selected(self.get_choices_default()) + for opt_select in value: + if (opt_select not in arr_choices): + msg = self.error_messages['invalid_choice'] % value + raise exceptions.ValidationError(msg) + return + + def get_choices_selected(self, arr_choices=''): + if not arr_choices: + return False + return [ value for value,__ in arr_choices ] + + +if isinstalled('south'): + from south.modelsinspector import add_introspection_rules + add_introspection_rules([], ["^controller\.models\.fields\.MultiSelectField"]) diff --git a/orchestra/models/utils.py b/orchestra/models/utils.py new file mode 100644 index 00000000..9b3710ab --- /dev/null +++ b/orchestra/models/utils.py @@ -0,0 +1,49 @@ +from django.conf import settings +from django.db.models import loading, Manager +from django.utils import importlib + + +def get_model(label, import_module=True): + """ returns the modeladmin registred for model """ + app_label, model_name = label.split('.') + model = loading.get_model(app_label, model_name) + if model is None: + # Sometimes the models module is not yet imported + for app in settings.INSTALLED_APPS: + if app.endswith(app_label): + app_label = app + importlib.import_module('%s.%s' % (app_label, 'admin')) + return loading.get_model(*label.split('.')) + return model + + +def queryset_as_manager(qs_class): + # Allow chained managers + # Based on http://djangosnippets.org/snippets/562/#c2486 + class ChainerManager(Manager): + def __init__(self): + super(ChainerManager,self).__init__() + self.queryset_class = qs_class + + def get_query_set(self): + return self.queryset_class(self.model) + + def __getattr__(self, attr, *args): + try: + return getattr(type(self), attr, *args) + except AttributeError: + return getattr(self.get_query_set(), attr, *args) + return ChainerManager() + + +def get_field_value(obj, field_name): + names = field_name.split('__') + rel = getattr(obj, names.pop(0)) + for name in names: + try: + rel = getattr(rel, name) + except AttributeError: + # maybe it is a query manager + rel = getattr(rel.get(), name) + return rel + diff --git a/orchestra/permissions/__init__.py b/orchestra/permissions/__init__.py new file mode 100644 index 00000000..921dfe14 --- /dev/null +++ b/orchestra/permissions/__init__.py @@ -0,0 +1 @@ +from .options import * diff --git a/orchestra/permissions/api.py b/orchestra/permissions/api.py new file mode 100644 index 00000000..5bd892cd --- /dev/null +++ b/orchestra/permissions/api.py @@ -0,0 +1,28 @@ +from django.core.urlresolvers import resolve +from rest_framework import exceptions +from rest_framework.permissions import DjangoModelPermissions + + +class OrchestraPermissionBackend(DjangoModelPermissions): + """ Permissions according to each user """ + + def has_permission(self, request, view): + model_cls = getattr(view, 'model', None) + if not model_cls: + name = resolve(request.path).url_name + return name == 'api-root' + + perms = self.get_required_permissions(request.method, model_cls) + if (request.user and + request.user.is_authenticated() and + request.user.has_perms(perms, model_cls)): + return True + return False + + def has_object_permission(self, request, view, obj): + perms = self.get_required_permissions(request.method, type(obj)) + if (request.user and + request.user.is_authenticated() and + request.user.has_perms(perms, obj)): + return True + return False diff --git a/orchestra/permissions/auth.py b/orchestra/permissions/auth.py new file mode 100644 index 00000000..2006ef6e --- /dev/null +++ b/orchestra/permissions/auth.py @@ -0,0 +1,48 @@ +from django.contrib.auth.backends import ModelBackend +from django.db.models.loading import get_model, get_app, get_models + + +class OrchestraPermissionBackend(ModelBackend): + supports_object_permissions = True + supports_anonymous_user = False + supports_inactive_user = False + + def has_perm(self, user, perm, obj=None): + """ perm 'app.action_model' """ + if not user.is_active: + return False + + perm_type = perm.split('.')[1].split('_')[0] + if obj is None: + app_label = perm.split('.')[0] + model_label = perm.split('_')[1] + model = get_model(app_label, model_label) + perm_manager = model + else: + perm_manager = obj + + try: + is_authorized = perm_manager.has_permission(user, perm_type) + except AttributeError: + is_authorized = False + + return is_authorized + + def has_module_perms(self, user, app_label): + """ + Returns True if user_obj has any permissions in the given app_label. + """ + if not user.is_active: + return False + app = get_app(app_label) + for model in get_models(app): + try: + has_perm = model.has_permission.view(user) + except AttributeError: + pass + else: + if has_perm: + return True + return False + + diff --git a/orchestra/permissions/options.py b/orchestra/permissions/options.py new file mode 100644 index 00000000..1838ecd5 --- /dev/null +++ b/orchestra/permissions/options.py @@ -0,0 +1,107 @@ +import functools +import inspect + + +# WARNING: *MAGIC MODULE* +# This is not a safe place, lot of magic is happening here + + +class Permission(object): + """ + Base class used for defining class and instance permissions. + Enabling an ''intuitive'' interface for checking permissions: + + # Define permissions + class NodePermission(Permission): + def change(self, obj, cls, user): + return obj.user == user + + # Provide permissions + Node.has_permission = NodePermission() + + # Check class permission by passing it as string + Node.has_permission(user, 'change') + + # Check class permission by calling it + Node.has_permission.change(user) + + # Check instance permissions + node = Node() + node.has_permission(user, 'change') + node.has_permission.change(user) + """ + def __get__(self, obj, cls): + """ Hacking object internals to provide means for the mentioned interface """ + # call interface: has_permission(user, 'perm') + def call(user, perm): + return getattr(self, perm)(obj, cls, user) + + # has_permission.perm(user) + for func in inspect.getmembers(type(self), predicate=inspect.ismethod): + if func[1].im_class is not type(self): + # aggregated methods + setattr(call, func[0], functools.partial(func[1], obj, cls)) + else: + # self methods + setattr(call, func[0], functools.partial(func[1], self, obj, cls)) + return call + + def _aggregate(self, obj, cls, perm): + """ Aggregates cls methods to self class""" + for method in inspect.getmembers(perm, predicate=inspect.ismethod): + if not method[0].startswith('_'): + setattr(type(self), method[0], method[1]) + + +class ReadOnlyPermission(Permission): + """ Read only permissions """ + def view(self, obj, cls, user): + return True + + +class AllowAllPermission(object): + """ All methods return True """ + def __get__(self, obj, cls): + return self.AllowAllWrapper() + + class AllowAllWrapper(object): + """ Fake object that always returns True """ + def __call__(self, *args): + return True + + def __getattr__(self, name): + return lambda n: True + + +class RelatedPermission(Permission): + """ + Inherit permissions of a related object + + The following example will inherit permissions from sliver_iface.sliver.slice + SliverIfaces.has_permission = RelatedPermission('sliver.slices') + """ + def __init__(self, relation): + self.relation = relation + + def __get__(self, obj, cls): + """ Hacking object internals to provide means for the mentioned interface """ + # Walk through FK relations + relations = self.relation.split('.') + if obj is None: + parent = cls + for relation in relations: + parent = getattr(parent, relation).field.rel.to + else: + parent = reduce(getattr, relations, obj) + + # call interface: has_permission(user, 'perm') + def call(user, perm): + return parent.has_permission(user, perm) + + # method interface: has_permission.perm(user) + for name, func in parent.has_permission.__dict__.iteritems(): + if not name.startswith('_'): + setattr(call, name, func) + + return call + diff --git a/orchestra/settings.py b/orchestra/settings.py new file mode 100644 index 00000000..75b8585b --- /dev/null +++ b/orchestra/settings.py @@ -0,0 +1,28 @@ +from django.conf import settings +from django.utils.translation import ugettext_lazy as _ + + +# Domain name used when it will not be possible to infere the domain from a request +# For example in periodic tasks +SITE_URL = getattr(settings, 'SITE_URL', 'http://localhost') + +SITE_NAME = getattr(settings, 'SITE_NAME', 'confine') + +SITE_VERBOSE_NAME = getattr(settings, 'SITE_VERBOSE_NAME', + _("%s Hosting Management" % SITE_NAME.capitalize())) + + +# Service management commands + +START_SERVICES = getattr(settings, 'START_SERVICES', + ['postgresql', 'celeryevcam', 'celeryd', 'celerybeat', ('uwsgi', 'nginx'),] +) + +RESTART_SERVICES = getattr(settings, 'RESTART_SERVICES', + ['celeryd', 'celerybeat', 'uwsgi'] +) + +STOP_SERVICES = getattr(settings, 'STOP_SERVICES', + [('uwsgi', 'nginx'), 'celerybeat', 'celeryd', 'celeryevcam', 'postgresql'] +) + diff --git a/orchestra/static/admin/css/login.css b/orchestra/static/admin/css/login.css new file mode 100644 index 00000000..99cc756e --- /dev/null +++ b/orchestra/static/admin/css/login.css @@ -0,0 +1,99 @@ +/* LOGIN FORM */ + + + +#header #branding h1 { + margin: 0; + padding: 5px 10px; + background: transparent url(/static/orchestra/images/orchestra-logo.png) 10px 5px no-repeat; + text-indent: 0; + height: 31px; + width: 420px; + font-size: 18px; + color: whitesmoke; + font-weight: bold; + padding-left: 50px; + line-height: 30px; +} + +.version { + font-size: 0%; +} + +#header #branding:hover a { + text-decoration: none; +} + +.login #container #content h1 { + text-align: center; +} + +.login #container #content p { + text-align: center; +} + +.login .register-links { + text-align: right +} + + +body.login { + background: #eee; +} + +.login #container { + background: white; + border: 1px solid #ccc; + width: 28em; + min-width: 460px; + margin-left: auto; + margin-right: auto; + margin-top: 100px; +} + +.login #content-main { +/*changed*/ + width: 90%; + margin-left: 20px; +} + +.login form { + margin-top: 1em; +} + +.login .form-row { + padding: 4px 0; + float: left; + width: 100%; +} + +.login .form-row label { + padding-right: 0.5em; + line-height: 2em; + font-size: 1em; + clear: both; + color: #333; +} + +.login .form-row #id_username, .login .form-row #id_password { + clear: both; + padding: 6px; + width: 100%; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +.login span.help { + font-size: 10px; + display: block; +} + +.login .submit-row { + clear: both; + padding: 1em 0 0 9.4em; +} + +.login .password-reset-link { + text-align: center; +} diff --git a/orchestra/static/admin_tools/css/theming.css b/orchestra/static/admin_tools/css/theming.css new file mode 100644 index 00000000..2ab98cbe --- /dev/null +++ b/orchestra/static/admin_tools/css/theming.css @@ -0,0 +1,21 @@ +/** + * theming styles + * + */ + +#header { + background: url(../images/admin-tools.png) 0 0 repeat-x; +} + +div.breadcrumbs { + display: block; + padding: 10px 15px; + border: 0; + background-position: 0 -8px; + border-bottom: 1px solid #ededed; +} + +div.breadcrumbs a { + display: inline; +} + diff --git a/orchestra/static/orchestra/css/adminextraprettystyle.css b/orchestra/static/orchestra/css/adminextraprettystyle.css new file mode 100644 index 00000000..df03ecb4 --- /dev/null +++ b/orchestra/static/orchestra/css/adminextraprettystyle.css @@ -0,0 +1,95 @@ +body { + background:#FBFAF9 url(/static/orchestra/images/page-gradient.png)top left repeat-x; +} + +#header #branding h1 { + margin: 0; + padding: 5px 10px; + background: transparent url(/static/orchestra/images/orchestra-logo.png) 10px 5px no-repeat; + text-indent: 0; + height: 31px; + font-size: 18px; + color: whitesmoke; + font-weight: bold; + padding-left: 50px; + line-height: 30px; +} + +.version:before { + content: "v"; + opacity: 0.6; + padding-right: 0.25em; +} + +.version { + font-size: 60%; +} + +#header #branding:hover a { + text-decoration: none; + color: azure; +} + +#header-branding { + background-image: url(/static/admin_tools/images/admin-tools.png); + width: 100%; + z-index: -1; + height: 41px; + position: absolute; +} + +#header-menu { + background: url(/static/admin_tools/images/admin-tools.png) 0 295px; + width: 100%; + z-index: -1; + margin-top: 41px; + height: 41px; + position: absolute; +} + +#header-breadcrumb { + width: 100%; + z-index: -1; + margin-top: 76px; + height: 69px; + position: absolute; + background-attachment: scroll; background-clip: border-box; + background-color: rgb(255, 255, 255); + background-image: url(/static/admin/img/nav-bg-reverse.gif); + background-origin: padding-box; + background-position: 0px -8px; + background-size: auto; + border-bottom-color: rgb(237, 237, 237); + border-bottom-style: solid; + border-bottom-width: 1px; + border-left-color: rgb(153, 153, 153); + border-left-style: none; + border-left-width: 0px; + border-right-color: rgb(153, 153, 153); + border-right-style: none; + border-right-width: 0px; + border-top-color: rgb(153, 153, 153); + border-top-style: none; + border-top-width: 0px; + color: white; + height: 13px; + padding-bottom: 10px; + padding-top: 10px; + background-repeat: repeat-x; + padding-left: 0; + padding-right: 0; +} + +#container { + max-width:1150px; + margin:0 auto; +} + +#header { + background:none; +} + +.dashboard-module { + background-color: white; +} + diff --git a/orchestra/static/orchestra/css/hide-inline-id.css b/orchestra/static/orchestra/css/hide-inline-id.css new file mode 100644 index 00000000..1e38d6f1 --- /dev/null +++ b/orchestra/static/orchestra/css/hide-inline-id.css @@ -0,0 +1,7 @@ +.inline-group .tabular td.original p { + visibility: hidden; +} + +.inline-group .tabular tr.has_original td { + padding-top: 0.8em; +} diff --git a/orchestra/static/orchestra/icons/Applications-internet.png b/orchestra/static/orchestra/icons/Applications-internet.png new file mode 100644 index 00000000..e7237941 Binary files /dev/null and b/orchestra/static/orchestra/icons/Applications-internet.png differ diff --git a/orchestra/static/orchestra/icons/Applications-internet.svg b/orchestra/static/orchestra/icons/Applications-internet.svg new file mode 100644 index 00000000..e7849607 --- /dev/null +++ b/orchestra/static/orchestra/icons/Applications-internet.svg @@ -0,0 +1,520 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + Internet Category + + + Jakub Steiner + + + + + Tuomas Kuosmanen + + + + http://jimmac.musichall.cz + + + internet + tools + applications + category + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/Applications-other.png b/orchestra/static/orchestra/icons/Applications-other.png new file mode 100644 index 00000000..4c38a91c Binary files /dev/null and b/orchestra/static/orchestra/icons/Applications-other.png differ diff --git a/orchestra/static/orchestra/icons/Applications-other.svg b/orchestra/static/orchestra/icons/Applications-other.svg new file mode 100644 index 00000000..293ba54d --- /dev/null +++ b/orchestra/static/orchestra/icons/Applications-other.svg @@ -0,0 +1,380 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + Other applications + + + Jakub Steiner + + + http://jimmac.musichall.cz/ + + + category + applications + other + unspecified + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/Emblem-important.png b/orchestra/static/orchestra/icons/Emblem-important.png new file mode 100644 index 00000000..d041dc94 Binary files /dev/null and b/orchestra/static/orchestra/icons/Emblem-important.png differ diff --git a/orchestra/static/orchestra/icons/Emblem-important.svg b/orchestra/static/orchestra/icons/Emblem-important.svg new file mode 100644 index 00000000..6333216f --- /dev/null +++ b/orchestra/static/orchestra/icons/Emblem-important.svg @@ -0,0 +1,108 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/Face-monkey.png b/orchestra/static/orchestra/icons/Face-monkey.png new file mode 100644 index 00000000..d0aec90c Binary files /dev/null and b/orchestra/static/orchestra/icons/Face-monkey.png differ diff --git a/orchestra/static/orchestra/icons/Face-monkey.svg b/orchestra/static/orchestra/icons/Face-monkey.svg new file mode 100644 index 00000000..00ceb900 --- /dev/null +++ b/orchestra/static/orchestra/icons/Face-monkey.svg @@ -0,0 +1,414 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + Ulisse Perusin + + + emote-monkey + emoticons monkey + + 18/05/2006 + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/Koala.png b/orchestra/static/orchestra/icons/Koala.png new file mode 100644 index 00000000..165593c0 Binary files /dev/null and b/orchestra/static/orchestra/icons/Koala.png differ diff --git a/orchestra/static/orchestra/icons/Koala.svg b/orchestra/static/orchestra/icons/Koala.svg new file mode 100644 index 00000000..5e4cd20b --- /dev/null +++ b/orchestra/static/orchestra/icons/Koala.svg @@ -0,0 +1,5595 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + Koala / OpenDylan + Feb. 2008 + + + F.Bellaiche <frederic.bellaiche@gmail.com> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/Mr-potato.png b/orchestra/static/orchestra/icons/Mr-potato.png new file mode 100644 index 00000000..8b4ce405 Binary files /dev/null and b/orchestra/static/orchestra/icons/Mr-potato.png differ diff --git a/orchestra/static/orchestra/icons/Mr-potato.svg b/orchestra/static/orchestra/icons/Mr-potato.svg new file mode 100644 index 00000000..85c5b744 --- /dev/null +++ b/orchestra/static/orchestra/icons/Mr-potato.svg @@ -0,0 +1,4273 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + Mr. Potato + May 2007 + + + F.Bellaiche <frederic.bellaiche@gmail.com> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/Text-x-boo.svg b/orchestra/static/orchestra/icons/Text-x-boo.svg new file mode 100644 index 00000000..887265ac --- /dev/null +++ b/orchestra/static/orchestra/icons/Text-x-boo.svg @@ -0,0 +1,997 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + Boo Source Code + + + boo + source code + file + + + + + + Vinicius Depizzol + + + + + + Paper sheet by Jakub Steiner <http://jimmac.musichall.cz> + + + 2007-10-30 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/Text-x-script.svg b/orchestra/static/orchestra/icons/Text-x-script.svg new file mode 100644 index 00000000..9784f053 --- /dev/null +++ b/orchestra/static/orchestra/icons/Text-x-script.svg @@ -0,0 +1,419 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + Generic Script + + + text + plaintext + regular + script + shell + bash + python + perl + php + ruby + + + + + + Jakub Steiner + + + http://jimmac.musichall.cz + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/Ticket_star.png b/orchestra/static/orchestra/icons/Ticket_star.png new file mode 100644 index 00000000..b975d597 Binary files /dev/null and b/orchestra/static/orchestra/icons/Ticket_star.png differ diff --git a/orchestra/static/orchestra/icons/Tux.png b/orchestra/static/orchestra/icons/Tux.png new file mode 100644 index 00000000..30f31573 Binary files /dev/null and b/orchestra/static/orchestra/icons/Tux.png differ diff --git a/orchestra/static/orchestra/icons/Tux.svg b/orchestra/static/orchestra/icons/Tux.svg new file mode 100644 index 00000000..1c5406d7 --- /dev/null +++ b/orchestra/static/orchestra/icons/Tux.svg @@ -0,0 +1,648 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/TuxBox.png b/orchestra/static/orchestra/icons/TuxBox.png new file mode 100644 index 00000000..72eb7a0f Binary files /dev/null and b/orchestra/static/orchestra/icons/TuxBox.png differ diff --git a/orchestra/static/orchestra/icons/TuxBox.svg b/orchestra/static/orchestra/icons/TuxBox.svg new file mode 100644 index 00000000..6aa10910 --- /dev/null +++ b/orchestra/static/orchestra/icons/TuxBox.svg @@ -0,0 +1,4661 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/X-office-address-book.png b/orchestra/static/orchestra/icons/X-office-address-book.png new file mode 100644 index 00000000..22250592 Binary files /dev/null and b/orchestra/static/orchestra/icons/X-office-address-book.png differ diff --git a/orchestra/static/orchestra/icons/X-office-address-book.svg b/orchestra/static/orchestra/icons/X-office-address-book.svg new file mode 100644 index 00000000..0b4a7f79 --- /dev/null +++ b/orchestra/static/orchestra/icons/X-office-address-book.svg @@ -0,0 +1,301 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + Addess Book + + + + Jakub Steiner + + + http://jimmac.musichall.cz + + + address + contact + book + office + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/apps.png b/orchestra/static/orchestra/icons/apps.png new file mode 100644 index 00000000..85a372f6 Binary files /dev/null and b/orchestra/static/orchestra/icons/apps.png differ diff --git a/orchestra/static/orchestra/icons/apps.svg b/orchestra/static/orchestra/icons/apps.svg new file mode 100644 index 00000000..741e1198 --- /dev/null +++ b/orchestra/static/orchestra/icons/apps.svg @@ -0,0 +1,518 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/auth.svg b/orchestra/static/orchestra/icons/auth.svg new file mode 100644 index 00000000..ebd09c56 --- /dev/null +++ b/orchestra/static/orchestra/icons/auth.svg @@ -0,0 +1,2449 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/bill.svg b/orchestra/static/orchestra/icons/bill.svg new file mode 100644 index 00000000..2eafda24 --- /dev/null +++ b/orchestra/static/orchestra/icons/bill.svg @@ -0,0 +1,1348 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/contact.png b/orchestra/static/orchestra/icons/contact.png new file mode 100644 index 00000000..619edf79 Binary files /dev/null and b/orchestra/static/orchestra/icons/contact.png differ diff --git a/orchestra/static/orchestra/icons/contact.svg b/orchestra/static/orchestra/icons/contact.svg new file mode 100644 index 00000000..39162b9e --- /dev/null +++ b/orchestra/static/orchestra/icons/contact.svg @@ -0,0 +1,2810 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/daemon.png b/orchestra/static/orchestra/icons/daemon.png new file mode 100644 index 00000000..c65a62f5 Binary files /dev/null and b/orchestra/static/orchestra/icons/daemon.png differ diff --git a/orchestra/static/orchestra/icons/daemon.svg b/orchestra/static/orchestra/icons/daemon.svg new file mode 100644 index 00000000..8196e5f4 --- /dev/null +++ b/orchestra/static/orchestra/icons/daemon.svg @@ -0,0 +1,562 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/database.png b/orchestra/static/orchestra/icons/database.png new file mode 100644 index 00000000..bba97076 Binary files /dev/null and b/orchestra/static/orchestra/icons/database.png differ diff --git a/orchestra/static/orchestra/icons/database.svg b/orchestra/static/orchestra/icons/database.svg new file mode 100644 index 00000000..d0d20658 --- /dev/null +++ b/orchestra/static/orchestra/icons/database.svg @@ -0,0 +1,401 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/dns.png b/orchestra/static/orchestra/icons/dns.png new file mode 100644 index 00000000..9be09aab Binary files /dev/null and b/orchestra/static/orchestra/icons/dns.png differ diff --git a/orchestra/static/orchestra/icons/dns.svg b/orchestra/static/orchestra/icons/dns.svg new file mode 100644 index 00000000..9234b23e --- /dev/null +++ b/orchestra/static/orchestra/icons/dns.svg @@ -0,0 +1,3045 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/domain.png b/orchestra/static/orchestra/icons/domain.png new file mode 100644 index 00000000..c4aa11dc Binary files /dev/null and b/orchestra/static/orchestra/icons/domain.png differ diff --git a/orchestra/static/orchestra/icons/domain.svg b/orchestra/static/orchestra/icons/domain.svg new file mode 100644 index 00000000..eaa48b97 --- /dev/null +++ b/orchestra/static/orchestra/icons/domain.svg @@ -0,0 +1,3045 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/email-alter.png b/orchestra/static/orchestra/icons/email-alter.png new file mode 100644 index 00000000..9d68dd46 Binary files /dev/null and b/orchestra/static/orchestra/icons/email-alter.png differ diff --git a/orchestra/static/orchestra/icons/email-alter.svg b/orchestra/static/orchestra/icons/email-alter.svg new file mode 100644 index 00000000..26e685d0 --- /dev/null +++ b/orchestra/static/orchestra/icons/email-alter.svg @@ -0,0 +1,3072 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/email.png b/orchestra/static/orchestra/icons/email.png new file mode 100644 index 00000000..013e7c5d Binary files /dev/null and b/orchestra/static/orchestra/icons/email.png differ diff --git a/orchestra/static/orchestra/icons/email.svg b/orchestra/static/orchestra/icons/email.svg new file mode 100644 index 00000000..507ed3ef --- /dev/null +++ b/orchestra/static/orchestra/icons/email.svg @@ -0,0 +1,1643 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/extrafield.png b/orchestra/static/orchestra/icons/extrafield.png new file mode 100644 index 00000000..2f11d1ee Binary files /dev/null and b/orchestra/static/orchestra/icons/extrafield.png differ diff --git a/orchestra/static/orchestra/icons/extrafield.svg b/orchestra/static/orchestra/icons/extrafield.svg new file mode 100644 index 00000000..29894f3a --- /dev/null +++ b/orchestra/static/orchestra/icons/extrafield.svg @@ -0,0 +1,1170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/gnome-terminal.png b/orchestra/static/orchestra/icons/gnome-terminal.png new file mode 100644 index 00000000..33ddd945 Binary files /dev/null and b/orchestra/static/orchestra/icons/gnome-terminal.png differ diff --git a/orchestra/static/orchestra/icons/gnome-terminal.svg b/orchestra/static/orchestra/icons/gnome-terminal.svg new file mode 100644 index 00000000..db9baffa --- /dev/null +++ b/orchestra/static/orchestra/icons/gnome-terminal.svg @@ -0,0 +1,354 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/hal.png b/orchestra/static/orchestra/icons/hal.png new file mode 100644 index 00000000..94915222 Binary files /dev/null and b/orchestra/static/orchestra/icons/hal.png differ diff --git a/orchestra/static/orchestra/icons/hal.svg b/orchestra/static/orchestra/icons/hal.svg new file mode 100644 index 00000000..185e65f0 --- /dev/null +++ b/orchestra/static/orchestra/icons/hal.svg @@ -0,0 +1,1002 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/job.svg b/orchestra/static/orchestra/icons/job.svg new file mode 100644 index 00000000..9d09e63d --- /dev/null +++ b/orchestra/static/orchestra/icons/job.svg @@ -0,0 +1,3386 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/monitor.png b/orchestra/static/orchestra/icons/monitor.png new file mode 100644 index 00000000..060bc328 Binary files /dev/null and b/orchestra/static/orchestra/icons/monitor.png differ diff --git a/orchestra/static/orchestra/icons/monitor.svg b/orchestra/static/orchestra/icons/monitor.svg new file mode 100644 index 00000000..a3974a6e --- /dev/null +++ b/orchestra/static/orchestra/icons/monitor.svg @@ -0,0 +1,3134 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/mysql.png b/orchestra/static/orchestra/icons/mysql.png new file mode 100644 index 00000000..37b93dcb Binary files /dev/null and b/orchestra/static/orchestra/icons/mysql.png differ diff --git a/orchestra/static/orchestra/icons/mysql.svg b/orchestra/static/orchestra/icons/mysql.svg new file mode 100644 index 00000000..61fd6971 --- /dev/null +++ b/orchestra/static/orchestra/icons/mysql.svg @@ -0,0 +1,1686 @@ + + + + + + + + image/svg+xml + + MySQL Server + Aug 2007 + + + F.Bellaiche <frederic.bellaiche@gmail.com> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/order.png b/orchestra/static/orchestra/icons/order.png new file mode 100644 index 00000000..ead63361 Binary files /dev/null and b/orchestra/static/orchestra/icons/order.png differ diff --git a/orchestra/static/orchestra/icons/order.svg b/orchestra/static/orchestra/icons/order.svg new file mode 100644 index 00000000..5daa110b --- /dev/null +++ b/orchestra/static/orchestra/icons/order.svg @@ -0,0 +1,1790 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/periodictask.svg b/orchestra/static/orchestra/icons/periodictask.svg new file mode 100644 index 00000000..29a1f844 --- /dev/null +++ b/orchestra/static/orchestra/icons/periodictask.svg @@ -0,0 +1,3801 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/postgresql.png b/orchestra/static/orchestra/icons/postgresql.png new file mode 100644 index 00000000..e7edb890 Binary files /dev/null and b/orchestra/static/orchestra/icons/postgresql.png differ diff --git a/orchestra/static/orchestra/icons/postgresql.svg b/orchestra/static/orchestra/icons/postgresql.svg new file mode 100644 index 00000000..eae64940 --- /dev/null +++ b/orchestra/static/orchestra/icons/postgresql.svg @@ -0,0 +1,894 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + PostgreSQL Server + Aug 2007 + + + F.Bellaiche <frederic.bellaiche@gmail.com> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/scriptlog.png b/orchestra/static/orchestra/icons/scriptlog.png new file mode 100644 index 00000000..9f9e13bd Binary files /dev/null and b/orchestra/static/orchestra/icons/scriptlog.png differ diff --git a/orchestra/static/orchestra/icons/scriptlog.svg b/orchestra/static/orchestra/icons/scriptlog.svg new file mode 100644 index 00000000..df4fd642 --- /dev/null +++ b/orchestra/static/orchestra/icons/scriptlog.svg @@ -0,0 +1,756 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + text + plaintext + regular + script + shell + bash + python + perl + php + ruby + + + + + + Jakub Steiner + + + http://jimmac.musichall.cz + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/ssh.svg b/orchestra/static/orchestra/icons/ssh.svg new file mode 100644 index 00000000..5e358e7b --- /dev/null +++ b/orchestra/static/orchestra/icons/ssh.svg @@ -0,0 +1,890 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + SSH Server + Aug 2007 + + + F.Bellaiche <frederic.bellaiche@gmail.com> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/taskstate.png b/orchestra/static/orchestra/icons/taskstate.png new file mode 100755 index 00000000..777c5898 Binary files /dev/null and b/orchestra/static/orchestra/icons/taskstate.png differ diff --git a/orchestra/static/orchestra/icons/transaction.png b/orchestra/static/orchestra/icons/transaction.png new file mode 100644 index 00000000..24df7ee9 Binary files /dev/null and b/orchestra/static/orchestra/icons/transaction.png differ diff --git a/orchestra/static/orchestra/icons/transaction.svg b/orchestra/static/orchestra/icons/transaction.svg new file mode 100644 index 00000000..15156998 --- /dev/null +++ b/orchestra/static/orchestra/icons/transaction.svg @@ -0,0 +1,1418 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/users.png b/orchestra/static/orchestra/icons/users.png new file mode 100644 index 00000000..854e90e6 Binary files /dev/null and b/orchestra/static/orchestra/icons/users.png differ diff --git a/orchestra/static/orchestra/icons/users.svg b/orchestra/static/orchestra/icons/users.svg new file mode 100644 index 00000000..d28c7075 --- /dev/null +++ b/orchestra/static/orchestra/icons/users.svg @@ -0,0 +1,3802 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/vps.png b/orchestra/static/orchestra/icons/vps.png new file mode 100644 index 00000000..71880e1c Binary files /dev/null and b/orchestra/static/orchestra/icons/vps.png differ diff --git a/orchestra/static/orchestra/icons/vps.svg b/orchestra/static/orchestra/icons/vps.svg new file mode 100644 index 00000000..f98a6bfa --- /dev/null +++ b/orchestra/static/orchestra/icons/vps.svg @@ -0,0 +1,3625 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/web.png b/orchestra/static/orchestra/icons/web.png new file mode 100644 index 00000000..e5c771fa Binary files /dev/null and b/orchestra/static/orchestra/icons/web.png differ diff --git a/orchestra/static/orchestra/icons/web.svg b/orchestra/static/orchestra/icons/web.svg new file mode 100644 index 00000000..72692a6a --- /dev/null +++ b/orchestra/static/orchestra/icons/web.svg @@ -0,0 +1,295 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/icons/zone.png b/orchestra/static/orchestra/icons/zone.png new file mode 100644 index 00000000..00de2afb Binary files /dev/null and b/orchestra/static/orchestra/icons/zone.png differ diff --git a/orchestra/static/orchestra/icons/zone.svg b/orchestra/static/orchestra/icons/zone.svg new file mode 100644 index 00000000..66cb148b --- /dev/null +++ b/orchestra/static/orchestra/icons/zone.svg @@ -0,0 +1,2875 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/images/favicon.png b/orchestra/static/orchestra/images/favicon.png new file mode 100644 index 00000000..0ff8c430 Binary files /dev/null and b/orchestra/static/orchestra/images/favicon.png differ diff --git a/orchestra/static/orchestra/images/orchestra-logo.png b/orchestra/static/orchestra/images/orchestra-logo.png new file mode 100644 index 00000000..1adc0739 Binary files /dev/null and b/orchestra/static/orchestra/images/orchestra-logo.png differ diff --git a/orchestra/static/orchestra/images/orchestra-logo.svg b/orchestra/static/orchestra/images/orchestra-logo.svg new file mode 100644 index 00000000..3471eca4 --- /dev/null +++ b/orchestra/static/orchestra/images/orchestra-logo.svg @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/orchestra/static/orchestra/images/page-gradient.png b/orchestra/static/orchestra/images/page-gradient.png new file mode 100644 index 00000000..8e16a280 Binary files /dev/null and b/orchestra/static/orchestra/images/page-gradient.png differ diff --git a/orchestra/templates/admin/base.html b/orchestra/templates/admin/base.html new file mode 100644 index 00000000..bf45da0f --- /dev/null +++ b/orchestra/templates/admin/base.html @@ -0,0 +1,104 @@ +{% load theming_tags staticfiles %} + + + +{% block title %}{% endblock %} + + +{% block extrastyle %}{% endblock %} + +{% if LANGUAGE_BIDI %}{% endif %} +{% render_theming_css %} +{% block adminextraprettystyle %}{% endblock %} + +{% block extrahead %}{% endblock %} +{% block blockbots %}{% endblock %} + +{% load i18n %} + + + + +{% block branding-stetic %} + {% if not is_popup %} +
    +
    + {% endif %} +{% endblock %} + +{% block breadcrumb-stetic %} + {% if not is_popup %} +
    + {% endif %} +{% endblock %} + + +{% block container-stetic %}
    {% endblock %} + + {% if not is_popup %} + + {% block header-stetic %} + + {% block breadcrumbs %}{% endblock %} + {% endif %} + + {% block messages %} + {% if messages %} +
      {% for message in messages %} + {{ message }} + {% endfor %}
    + {% endif %} + {% endblock messages %} + + +
    + {% block pretitle %}{% endblock %} + {% block content_title %}{% if title %}

    {{ title }}

    {% endif %}{% endblock %} + {% block content %} + {% block object-tools %}{% endblock %} + {{ content }} + {% endblock %} + {% block sidebar %}{% endblock %} +
    +
    + + + {% block footer %}{% endblock %} +
    + + + + + diff --git a/orchestra/templates/admin/base_site.html b/orchestra/templates/admin/base_site.html new file mode 100644 index 00000000..ead258a6 --- /dev/null +++ b/orchestra/templates/admin/base_site.html @@ -0,0 +1,26 @@ +{% extends "admin/base.html" %} +{% load admin_tools_menu_tags utils %} + +{% block title %}{% if header_title %}{{ header_title }}{% else %}{{ title }}{% endif %} | {{ SITE_VERBOSE_NAME }} {% endblock %} + +{% block extrastyle %} +{{ block.super }} +{% if user.is_active and user.is_staff %} +{% if not is_popup %} +{% admin_tools_render_menu_css %} +{% endif %} +{% endif %} +{% endblock %} + +{% block branding %} +

    {{ SITE_VERBOSE_NAME }} {% version %} +{% endblock %} + +{% block nav-global %} +{% if user.is_active and user.is_staff %} +{% if not is_popup %} +{% admin_tools_render_menu %} +{% endif %} +{% endif %} +{% endblock %} + diff --git a/orchestra/templates/admin/index.html b/orchestra/templates/admin/index.html new file mode 100644 index 00000000..9c841f69 --- /dev/null +++ b/orchestra/templates/admin/index.html @@ -0,0 +1,18 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_tools_dashboard_tags %} + +{% block extrastyle %} +{{ block.super }} +{% block dashboard_css %}{% admin_tools_render_dashboard_css %}{% endblock %} +{% endblock %} + +{% block title %}{{ SITE_VERBOSE_NAME }}{% endblock %} + +{% block breadcrumb-stetic %}{% endblock %} +{% block bodyclass %}dashboard{% endblock %} + +{% block breadcrumbs %}{% endblock %} +{% block content_title %}{% endblock %} +{% block content %} +{% admin_tools_render_dashboard %} +{% endblock %} diff --git a/orchestra/templates/admin/login.html b/orchestra/templates/admin/login.html new file mode 100644 index 00000000..41505d1b --- /dev/null +++ b/orchestra/templates/admin/login.html @@ -0,0 +1,62 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_static utils %} + +{% block extrastyle %} + {{ block.super }} + +{% endblock %} + +{% block adminextraprettystyle %} +{% endblock %} +{% block bodyclass %}login{% endblock %} + +{% block nav-global %}{% endblock %} + +{% block content_title %}{% endblock %} + +{% block breadcrumbs %}{% endblock %} + +{% block content %} +{% if form.errors and not form.non_field_errors and not form.this_is_the_login_form.errors %} +

    +{% if form.errors.items|length == 1 %}{% trans "Please correct the error below." %}{% else %}{% trans "Please correct the errors below." %}{% endif %} +

    +{% endif %} + +{% if form.non_field_errors or form.this_is_the_login_form.errors %} +{% for error in form.non_field_errors|add:form.this_is_the_login_form.errors %} +

    + {{ error }} +

    +{% endfor %} +{% endif %} + +
    +
    {% csrf_token %} +
    + {% if not form.this_is_the_login_form.errors %}{{ form.username.errors }}{% endif %} + {{ form.username }} +
    +
    + {% if not form.this_is_the_login_form.errors %}{{ form.password.errors }}{% endif %} + {{ form.password }} + + +
    + {% url 'admin_password_reset' as password_reset_url %} + {% if password_reset_url %} + + {% endif %} +
    + +
    +
    + + +
    +{% endblock %} + diff --git a/orchestra/templates/rest_framework/api.html b/orchestra/templates/rest_framework/api.html new file mode 100644 index 00000000..05f93fd5 --- /dev/null +++ b/orchestra/templates/rest_framework/api.html @@ -0,0 +1,27 @@ +{% extends "rest_framework/base.html" %} +{% load rest_framework utils %} + +{% block head %} + {{ block.super }} + +{% endblock %} + +{% block title %}{{ SITE_VERBOSE_NAME }} REST API{% endblock %} +{% block branding %}{{ SITE_VERBOSE_NAME }} REST API {% version %}{% endblock %} +{% block userlinks %} +
  • Admin
  • + {% if user.is_authenticated %} + + {% else %} +
  • {% optional_login request %}
  • + {% endif %} +{% endblock %} + diff --git a/orchestra/templatetags/__init__.py b/orchestra/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/templatetags/markdown.py b/orchestra/templatetags/markdown.py new file mode 100644 index 00000000..ad7d6faf --- /dev/null +++ b/orchestra/templatetags/markdown.py @@ -0,0 +1,13 @@ +from __future__ import absolute_import + +from django import template +from markdown import markdown + + +register = template.Library() + + +@register.filter(name='markdown') +def do_markdown(text): + return markdown(text) + diff --git a/orchestra/templatetags/utils.py b/orchestra/templatetags/utils.py new file mode 100644 index 00000000..ce5d5c70 --- /dev/null +++ b/orchestra/templatetags/utils.py @@ -0,0 +1,47 @@ +from django import template +from django.core.urlresolvers import reverse, NoReverseMatch +from django.forms import CheckboxInput + +from orchestra import get_version + + +register = template.Library() + + +@register.simple_tag(name="version") +def controller_version(): + return get_version() + + +@register.simple_tag(name="admin_url", takes_context=True) +def rest_to_admin_url(context): + """ returns the admin equivelent url of the current REST API view """ + view = context['view'] + model = getattr(view, 'model', None) + url = 'admin:index' + args = [] + if model: + url = 'admin:%s_%s' % (model._meta.app_label, model._meta.module_name) + pk = view.kwargs.get(view.pk_url_kwarg) + if pk: + url += '_change' + args = [pk] + else: + url += '_changelist' + try: + return reverse(url, args=args) + except NoReverseMatch: + return reverse('admin:index') + + +@register.filter +def size(value, length): + value = str(value)[:int(length)] + num_spaces = int(length) - len(str(value)) + return str(value) + (' '*num_spaces) + + +@register.filter(name='is_checkbox') +def is_checkbox(field): + return isinstance(field.field.widget, CheckboxInput) + diff --git a/orchestra/urls.py b/orchestra/urls.py new file mode 100644 index 00000000..0b5892d9 --- /dev/null +++ b/orchestra/urls.py @@ -0,0 +1,31 @@ +from __future__ import absolute_import + +from django.contrib import admin +from django.conf import settings +from django.conf.urls import patterns, include, url + +from . import api + + +admin.autodiscover() +api.autodiscover() + +urlpatterns = patterns('', + # Admin + url(r'^admin/', include(admin.site.urls)), + url(r'^admin_tools/', include('admin_tools.urls')), + # REST API + url(r'^api/', include(api.router.urls)), + url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')), + url(r'^api-token-auth/', + 'rest_framework.authtoken.views.obtain_auth_token', + name='api-token-auth' + ), + +) + +if settings.DEBUG: + import debug_toolbar + urlpatterns += patterns('', + url(r'^__debug__/', include(debug_toolbar.urls)), + ) diff --git a/orchestra/utils/__init__.py b/orchestra/utils/__init__.py new file mode 100644 index 00000000..921dfe14 --- /dev/null +++ b/orchestra/utils/__init__.py @@ -0,0 +1 @@ +from .options import * diff --git a/orchestra/utils/apps.py b/orchestra/utils/apps.py new file mode 100644 index 00000000..69ebfa29 --- /dev/null +++ b/orchestra/utils/apps.py @@ -0,0 +1,42 @@ +from django.utils.importlib import import_module +from django.utils.module_loading import module_has_submodule + +def autodiscover(module): + """ Auto-discover INSTALLED_APPS module.py """ + from django.conf import settings + for app in settings.INSTALLED_APPS: + mod = import_module(app) + try: + import_module('%s.%s' % (app, module)) + except ImportError: + # Decide whether to bubble up this error. If the app just + # doesn't have the module, we can ignore the error + # attempting to import it, otherwise we want it to bubble up. + if module_has_submodule(mod, module): + print '%s module caused this error:' % module + raise + +def isinstalled(app): + """ returns True if app is installed """ + from django.conf import settings + return app in settings.INSTALLED_APPS + + +def add_app(INSTALLED_APPS, app, prepend=False, append=True): + """ add app to installed_apps """ + if app not in INSTALLED_APPS: + if prepend: + return (app,) + INSTALLED_APPS + else: + return INSTALLED_APPS + (app,) + return INSTALLED_APPS + + +def remove_app(INSTALLED_APPS, app): + """ remove app from installed_apps """ + if app in INSTALLED_APPS: + apps = list(INSTALLED_APPS) + apps.remove(app) + return tuple(apps) + return INSTALLED_APPS + diff --git a/orchestra/utils/functional.py b/orchestra/utils/functional.py new file mode 100644 index 00000000..2dcb5fee --- /dev/null +++ b/orchestra/utils/functional.py @@ -0,0 +1,9 @@ +def cached(func): + """ caches func return value """ + def cached_func(self, *args, **kwargs): + attr = '_cached_' + func.__name__ + if not hasattr(self, attr): + setattr(self, attr, func(self, *args, **kwargs)) + return getattr(self, attr) + return cached_func + diff --git a/orchestra/utils/options.py b/orchestra/utils/options.py new file mode 100644 index 00000000..d3e7c42c --- /dev/null +++ b/orchestra/utils/options.py @@ -0,0 +1,39 @@ +import urlparse + +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.template import Context + + +def send_email_template(template, context, to, email_from=None, html=None): + """ + Renders an email template with this format: + {% if subject %}Subject{% endif %} + {% if message %}Email body{% endif %} + + context can be a dictionary or a template.Context instance + """ + + if isinstance(context, dict): + context = Context(context) + if type(to) in [str, unicode]: + to = [to] + + if not 'site' in context: + from orchestra import settings + url = urlparse.urlparse(settings.SITE_URL) + context['site'] = { + 'name': settings.SITE_NAME, + 'scheme': url.scheme, + 'domain': url.netloc, + } + + #subject cannot have new lines + subject = render_to_string(template, {'subject': True}, context).strip() + message = render_to_string(template, {'message': True}, context) + msg = EmailMultiAlternatives(subject, message, email_from, to) + if html: + html_message = render_to_string(html, {'message': True}, context) + msg.attach_alternative(html_message, "text/html") + msg.send() + diff --git a/orchestra/utils/paths.py b/orchestra/utils/paths.py new file mode 100644 index 00000000..73d3e03f --- /dev/null +++ b/orchestra/utils/paths.py @@ -0,0 +1,24 @@ +import os + + +def get_project_root(): + """ Return the current project path site/project """ + from django.conf import settings + settings_file = os.sys.modules[settings.SETTINGS_MODULE].__file__ + return os.path.dirname(os.path.normpath(settings_file)) + + +def get_project_name(): + """ Returns current project name """ + return os.path.basename(get_project_root()) + + +def get_site_root(): + """ Returns project site path """ + return os.path.abspath(os.path.join(get_project_root(), '..')) + + +def get_orchestra_root(): + """ Returns orchestra base path """ + import orchestra + return os.path.dirname(os.path.realpath(orchestra.__file__)) diff --git a/orchestra/utils/plugins.py b/orchestra/utils/plugins.py new file mode 100644 index 00000000..6b53a4ea --- /dev/null +++ b/orchestra/utils/plugins.py @@ -0,0 +1,13 @@ +class PluginMount(type): + def __init__(cls, name, bases, attrs): + if not hasattr(cls, 'plugins'): + # This branch only executes when processing the mount point itself. + # So, since this is a new plugin type, not an implementation, this + # class shouldn't be registered as a plugin. Instead, it sets up a + # list where plugins can be registered later. + cls.plugins = [] + else: + # This must be a plugin implementation, which should be registered. + # Simply appending it to the list is all that's needed to keep + # track of it later. + cls.plugins.append(cls) diff --git a/orchestra/utils/python.py b/orchestra/utils/python.py new file mode 100644 index 00000000..adba5e05 --- /dev/null +++ b/orchestra/utils/python.py @@ -0,0 +1,60 @@ +import collections + + +class OrderedSet(collections.MutableSet): + def __init__(self, iterable=None): + self.end = end = [] + end += [None, end, end] # sentinel node for doubly linked list + self.map = {} # key --> [key, prev, next] + if iterable is not None: + self |= iterable + + def __len__(self): + return len(self.map) + + def __contains__(self, key): + return key in self.map + + def add(self, key): + if key not in self.map: + end = self.end + curr = end[1] + curr[2] = end[1] = self.map[key] = [key, curr, end] + + def discard(self, key): + if key in self.map: + key, prev, next = self.map.pop(key) + prev[2] = next + next[1] = prev + + def __iter__(self): + end = self.end + curr = end[2] + while curr is not end: + yield curr[0] + curr = curr[2] + + def __reversed__(self): + end = self.end + curr = end[1] + while curr is not end: + yield curr[0] + curr = curr[1] + + def pop(self, last=True): + if not self: + raise KeyError('set is empty') + key = self.end[1][0] if last else self.end[2][0] + self.discard(key) + return key + + def __repr__(self): + if not self: + return '%s()' % (self.__class__.__name__,) + return '%s(%r)' % (self.__class__.__name__, list(self)) + + def __eq__(self, other): + if isinstance(other, OrderedSet): + return len(self) == len(other) and list(self) == list(other) + return set(self) == set(other) + diff --git a/orchestra/utils/system.py b/orchestra/utils/system.py new file mode 100644 index 00000000..9ff0e4f9 --- /dev/null +++ b/orchestra/utils/system.py @@ -0,0 +1,116 @@ +import errno +import fcntl +import getpass +import os +import re +import select +import subprocess +import sys + +from django.core.management.base import CommandError + + +def check_root(func): + """ Function decorator that checks if user has root permissions """ + def wrapped(*args, **kwargs): + if getpass.getuser() != 'root': + cmd_name = func.__module__.split('.')[-1] + msg = "Sorry, '%s' must be executed as a superuser (root)" + raise CommandError(msg % cmd_name) + return func(*args, **kwargs) + return wrapped + + +class _AttributeString(str): + """ Simple string subclass to allow arbitrary attribute access. """ + @property + def stdout(self): + return str(self) + + +def make_async(fd): + """ Helper function to add the O_NONBLOCK flag to a file descriptor """ + fcntl.fcntl(fd, fcntl.F_SETFL, fcntl.fcntl(fd, fcntl.F_GETFL) | os.O_NONBLOCK) + + +def read_async(fd): + """ + Helper function to read some data from a file descriptor, ignoring EAGAIN errors + """ + try: + return fd.read() + except IOError, e: + if e.errno != errno.EAGAIN: + raise e + else: + return '' + + +def run(command, display=True, error_codes=[0], silent=True): + """ Subprocess wrapper for running commands """ + if display: + sys.stderr.write("\n\033[1m $ %s\033[0m\n" % command) + out_stream = subprocess.PIPE + err_stream = subprocess.PIPE + + p = subprocess.Popen(command, shell=True, executable='/bin/bash', + stdout=out_stream, stderr=err_stream) + make_async(p.stdout) + make_async(p.stderr) + + stdout = str() + stderr = str() + + # Async reading of stdout and sterr + while True: + # Wait for data to become available + select.select([p.stdout, p.stderr], [], []) + + # Try reading some data from each + stdoutPiece = read_async(p.stdout) + stderrPiece = read_async(p.stderr) + + if display and stdoutPiece: + sys.stdout.write(stdoutPiece) + if display and stderrPiece: + sys.stderr.write(stderrPiece) + + stdout += stdoutPiece + stderr += stderrPiece + returnCode = p.poll() + + if returnCode != None: + break + + out = _AttributeString(stdout.strip()) + err = _AttributeString(stderr.strip()) + p.stdout.close() + p.stderr.close() + + out.failed = False + out.return_code = returnCode + out.stderr = err + if p.returncode not in error_codes: + out.failed = True + msg = "\nrun() encountered an error (return code %s) while executing '%s'\n" + msg = msg % (p.returncode, command) + sys.stderr.write("\n\033[1;31mCommandError: %s %s\033[m\n" % (msg, err)) + if not silent: + raise CommandError("\n%s\n %s\n" % (msg, err)) + + out.succeeded = not out.failed + return out + + +def get_default_celeryd_username(): + """ Introspect celeryd defaults file in order to get its username """ + user = None + try: + with open('/etc/default/celeryd') as celeryd_defaults: + for line in celeryd_defaults.readlines(): + if 'CELERYD_USER=' in line: + user = re.findall('"([^"]*)"', line)[0] + finally: + if user is None: + raise CommandError("Can not find the default celeryd username") + return user diff --git a/orchestra/utils/tests.py b/orchestra/utils/tests.py new file mode 100644 index 00000000..87112a27 --- /dev/null +++ b/orchestra/utils/tests.py @@ -0,0 +1,100 @@ +import string +import random + +from django.conf import settings +from django.contrib.auth import BACKEND_SESSION_KEY, SESSION_KEY, get_user_model +from django.contrib.sessions.backends.db import SessionStore +from django.test import LiveServerTestCase, TestCase +from orm.api import Api +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.firefox.webdriver import WebDriver +from xvfbwrapper import Xvfb + +from orchestra.apps.accounts.models import Account + + +User = get_user_model() + + +class AppDependencyMixin(object): + DEPENDENCIES = () + + @classmethod + def setUpClass(cls): + current_app = cls.__module__.split('.tests.')[0] + INSTALLED_APPS = ( + 'orchestra', + 'orchestra.apps.accounts', + current_app + ) + INSTALLED_APPS += cls.DEPENDENCIES + INSTALLED_APPS += ( + # Third-party apps + 'south', + 'django_extensions', + 'djcelery', + 'djcelery_email', + 'fluent_dashboard', + 'admin_tools', + 'admin_tools.theming', + 'admin_tools.menu', + 'admin_tools.dashboard', + 'rest_framework', + # Django.contrib + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.contrib.admin', + ) + settings.INSTALLED_APPS = INSTALLED_APPS + super(AppDependencyMixin, cls).setUpClass() + + +class BaseTestCase(TestCase, AppDependencyMixin): + pass + + +class BaseLiveServerTestCase(AppDependencyMixin, LiveServerTestCase): + @classmethod + def setUpClass(cls): + cls.vdisplay = Xvfb() + cls.vdisplay.start() + cls.selenium = WebDriver() + super(BaseLiveServerTestCase, cls).setUpClass() + + @classmethod + def tearDownClass(cls): + cls.selenium.quit() + cls.vdisplay.stop() + super(BaseLiveServerTestCase, cls).tearDownClass() + + def setUp(self): + super(BaseLiveServerTestCase, self).setUp() + self.rest = Api(self.live_server_url + '/api/') + self.account = Account.objects.create(name='orchestra') + self.username = 'orchestra' + self.password = 'orchestra' + self.user = User.objects.create_superuser(username='orchestra', password='orchestra', + email='orchestra@orchestra.org', account=self.account) + + def admin_login(self): + session = SessionStore() + session[SESSION_KEY] = self.user.pk + session[BACKEND_SESSION_KEY] = settings.AUTHENTICATION_BACKENDS[0] + session.save() + ## to set a cookie we need to first visit the domain. + self.selenium.get(self.live_server_url + '/admin/') + self.selenium.add_cookie(dict( + name=settings.SESSION_COOKIE_NAME, + value=session.session_key, # + path='/', + )) + + def rest_login(self): + self.rest.login(username=self.username, password=self.password) + + +def random_ascii(length): + return ''.join([random.choice(string.hexdigits) for i in range(0, length)]).lower() diff --git a/orchestra/utils/time.py b/orchestra/utils/time.py new file mode 100644 index 00000000..ca1f838d --- /dev/null +++ b/orchestra/utils/time.py @@ -0,0 +1,34 @@ +from __future__ import absolute_import + +from datetime import datetime + +from django.utils.timesince import timesince as django_timesince +from django.utils.timezone import is_aware, utc + + +def timesince(d, now=None, reversed=False): + """ Hack to provide second precision under 2 minutes """ + if not now: + now = datetime.now(utc if is_aware(d) else None) + + delta = (d - now) if reversed else (now - d) + s = django_timesince(d, now=now, reversed=reversed) + + if len(s.split(' ')) is 2: + count, name = s.split(' ') + if name in ['minutes', 'minute']: + seconds = delta.seconds % 60 + extension = '%(number)d %(type)s' % {'number': seconds, 'type': 'seconds'} + if int(count) is 0: + return extension + elif int(count) < 2: + s += ', %s' % extension + return s + + +def timeuntil(d, now=None): + """ + Like timesince, but returns a string measuring the time until + the given time. + """ + return timesince(d, now, reversed=True) diff --git a/scripts/container/create.sh b/scripts/container/create.sh new file mode 100755 index 00000000..8d3718de --- /dev/null +++ b/scripts/container/create.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +# This is a helper script for creating a basic LXC container with some convenient packages +# ./create.sh [container_name] + +set -u + +NAME=${1:-orchestra} +CONTAINER="/var/lib/lxc/$NAME/rootfs" +PASSWORD=$NAME +export SUITE="wheezy" + + +[ $(whoami) != 'root' ] && { + echo -e "\nErr. This script should run as root\n" >&2 + exit 1 +} + +lxc-create -h &> /dev/null || { + echo -e "\nErr. It seems like LXC is not installed, run apt-get install lxc\n" >&2 + exit 1 +} + + +lxc-create -n $NAME -t debian + +mount --bind /dev $CONTAINER/dev +mount -t sysfs none $CONTAINER/sys +trap "umount $CONTAINER/{dev,sys}; exit 1;"INT TERM EXIT + + +sed -i "s/\tlocalhost$/\tlocalhost $NAME/" $CONTAINER/etc/hosts +sed -i "s/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/" $CONTAINER/etc/locale.gen +chroot $CONTAINER locale-gen + + +chroot $CONTAINER apt-get install -y --force-yes \ + nano git screen sudo iputils-ping python2.7 python-pip wget curl dnsutils rsyslog + +chroot $CONTAINER apt-get clean + + +sleep 0.1 +umount $CONTAINER/{dev,sys} +trap - INT TERM EXIT diff --git a/scripts/container/deploy.sh b/scripts/container/deploy.sh new file mode 100755 index 00000000..98a26597 --- /dev/null +++ b/scripts/container/deploy.sh @@ -0,0 +1,118 @@ +#!/bin/bash + +# Automated development deployment of django-orchestra + +# This script is safe to run several times, for example in order to upgrade your deployment + + +set -u +bold=$(tput bold) +normal=$(tput sgr0) + + +[ $(whoami) != 'root' ] && { + echo -e "\nErr. This script should run as root\n" >&2 + exit 1 +} + +USER='orchestra' +PASSWORD="orchestra" +HOME="/home/$USER" +PROJECT_NAME='panel' +BASE_DIR="$HOME/$PROJECT_NAME" + + +run () { + echo " ${bold}\$ su $USER -c \"${@}\"${normal}" + su $USER -c "${@}" +} + + +# Create a system user for running Orchestra +useradd orchestra -s "/bin/bash" +echo "$USER:$PASSWORD" | chpasswd +mkdir $HOME +chown $USER.$USER $HOME +sudo adduser $USER sudo + + +CURRENT_VERSION=$(python -c "from orchestra import get_version; print get_version();" 2> /dev/null || false) + +if [[ ! $CURRENT_VERSION ]]; then + # First Orchestra installation + run "git clone https://github.com/glic3rinu/django-orchestra.git ~/django-orchestra" + echo $HOME/django-orchestra/ | sudo tee /usr/local/lib/python2.7/dist-packages/orchestra.pth + sudo cp $HOME/django-orchestra/orchestra/bin/orchestra-admin /usr/local/bin/ + sudo orchestra-admin install_requirements +fi + +if [[ ! -e $BASE_DIR ]]; then + cd $HOME + run "orchestra-admin startproject $PROJECT_NAME" + cd - +fi + +MANAGE="$BASE_DIR/manage.py" + +if [[ ! $(sudo su postgres -c "psql -lqt" | awk {'print $1'} | grep '^orchestra$') ]]; then + # orchestra database does not esists + # Speeding up tests, don't do this in production! + POSTGRES_VERSION=$(psql --version | head -n1 | awk {'print $3'} | cut -d'.' -f1,2) + sudo sed -i "s/^#fsync =\s*.*/fsync = off/" \ + /etc/postgresql/${POSTGRES_VERSION}/main/postgresql.conf + sudo sed -i "s/^#full_page_writes =\s*.*/full_page_writes = off/" \ + /etc/postgresql/${POSTGRES_VERSION}/main/postgresql.conf + + sudo service postgresql restart + sudo python $MANAGE setuppostgres --db_name orchestra --db_user orchestra --db_password orchestra + # Create database permissions are needed for running tests + sudo su postgres -c 'psql -c "ALTER USER orchestra CREATEDB;"' +fi + +if [[ $CURRENT_VERSION ]]; then + # Per version upgrade specific operations + sudo python $MANAGE postupgradeorchestra --no-restart --from $CURRENT_VERSION +else + sudo python $MANAGE syncdb --noinput + sudo python $MANAGE migrate --noinput +fi + +sudo python $MANAGE setupcelery --username $USER --processes 2 + +# Install and configure Nginx web server +run "mkdir $BASE_DIR/static" +run "python $MANAGE collectstatic --noinput" +sudo apt-get install -y nginx uwsgi uwsgi-plugin-python +sudo python $MANAGE setupnginx +sudo service nginx start + +# Apply changes +sudo python $MANAGE restartservices + +# Create a orchestra user +cat <<- EOF | python $MANAGE shell +from django.contrib.auth.models import User +from orchestra.apps.accounts.models import Account +if not User.objects.filter(username=$USER).exists(): + print 'Creating orchestra superuser' + user = User.objects.create_superuser($USER, "'$USER@localhost'", $PASSWORD) + user.account = Account.objects.create(user=user) + user.save() + +EOF + +# Change to development settings +PRODUCTION="from orchestra.conf.production_settings import \*" +DEVEL="from orchestra.conf.devel_settings import \*" +sed -i "s/^$PRODUCTION/# $PRODUCTION/" $BASE_DIR/$PROJECT_NAME/settings.py +sed -i "s/^#\s*$DEVEL/$DEVEL/" $BASE_DIR/$PROJECT_NAME/settings.py + + +cat << EOF + +${bold} + * Admin interface login * + - username: $USER + - password: $PASSWORD +${normal} +EOF diff --git a/scripts/migration/README.md b/scripts/migration/README.md new file mode 100644 index 00000000..ff9bfebb --- /dev/null +++ b/scripts/migration/README.md @@ -0,0 +1,6 @@ +Migration Scripts +================= + +**Warnign, this scripts will not work for you !** + +They are just examples of how I migrated my existing system data to Orchestra. diff --git a/scripts/migration/accounts.sh b/scripts/migration/accounts.sh new file mode 100644 index 00000000..473849b1 --- /dev/null +++ b/scripts/migration/accounts.sh @@ -0,0 +1,17 @@ + +echo "from orchestra.apps.accounts.models import Account" +echo "from orchestra.apps.users.models import User" + +cd /etc/apache2/sites-enabled/ +ls | while read line; do + USERNAME=$(echo $line|sed "s/\.conf//") + SHADOW=$(grep "^$USERNAME:" /var/yp/ypfiles/shadow) + [[ $SHADOW ]] && { + echo "user,__ = User.objects.get_or_create(username='$USERNAME')" + echo "account, __ = Account.objects.get_or_create(user=user)" + echo "user.password = '$(echo $SHADOW|cut -d: -f2)'" + echo "user.account = account" + echo "user.save()" + } +done +cd - &> /dev/null diff --git a/scripts/migration/apache2.py b/scripts/migration/apache2.py new file mode 100644 index 00000000..b6d7d375 --- /dev/null +++ b/scripts/migration/apache2.py @@ -0,0 +1,62 @@ +import re +import glob + + +print "from orchestra.apps.accounts.models import Account" +print "from orchestra.apps.domains.models import Domain" +print "from orchestra.apps.webapps.models import WebApp" +print "from orchestra.apps.websites.models import Website, Content" + + +for conf in glob.glob('/etc/apache2/sites-enabled/*'): + username = conf.split('/')[-1].split('.')[0] + with open(conf, 'rb') as conf: + print "account = Account.objects.get(user__username='%s')" % username + for line in conf.readlines(): + line = line.strip() + if line.startswith(''): + port = 443 + elif line.startswith("ServerName"): + domain = line.split()[1] + name = domain + domains.append("'%s'" % domain) + elif line.startswith("ServerAlias"): + for domain in line.split()[1:]: + domains.append("'%s'" % domain) + elif line.startswith("Alias /fcgi-bin/"): + fcgid = line.split('/')[-1] or line.split('/')[-2] + fcgid = fcgid.split('-')[0] + apps.append((name, fcgid, '/')) + elif line.startswith("Alias /webalizer"): + apps.append(('webalizer', 'webalizer', '/webalizer')) + elif line == '': + if port == 443: + name += '-ssl' + print "# SITE" + print "website, __ = Website.objects.get_or_create(name='%s', account=account, port=%d)" % (name, port) + domains = ', '.join(domains) + print "for domain in [%s]:" % str(domains) + print " try:" + print " domain = Domain.objects.get(name=domain)" + print " except:" + print " domain = Domain.objects.create(name=domain, account=account)" + print " else:" + print " domain.account = account" + print " domain.save()" + print " website.domains.add(domain)" + print "" + for name, type, path in apps: + print "try:" + print " webapp = WebApp.objects.get(account=account, name='%s')" % name + print "except:" + print " webapp = WebApp.objects.create(account=account, name='%s', type='%s')" % (name, type) + print "else:" + print " webapp.type = '%s'" % type + print " webapp.save()" + print "" + print "Content.objects.get_or_create(website=website, webapp=webapp, path='%s')" % path + print '\n' diff --git a/scripts/migration/domains.sh b/scripts/migration/domains.sh new file mode 100644 index 00000000..9729613e --- /dev/null +++ b/scripts/migration/domains.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# GENERATES Python code that will fill your Orchestra database with the existing zone files +# +# DEPENDS on bind9utils +# sudo apt-get install bind9utils +# +# EXAMPLE +# 1) bash bind9-domains.sh /etc/bind/master > bind9-domains.migrate.py +# 2) python manage.py shell < bind9-domains9.migrate.py + + +ZONE_PATH=${1:-/etc/bind/master/} + +echo "from orchestra.apps.domains.models import Domain" +echo "from orchestra.apps.accounts.models import Account" + +echo "account = Account.objects.get(pk=1)" +ERRORS="" +while read name; do + ZONE=$(named-checkzone -D $name ${ZONE_PATH}/$name) + if [[ $? != 0 ]]; then + ERRORS="${ERRORS} $name" + else + for DOMAIN in $(echo "$ZONE" | awk {'print $1'} | uniq); do + echo "try:" + echo " domain = Domain.objects.get(name='${DOMAIN%?}')" + echo "except:" + echo " domain = Domain.objects.create(name='${DOMAIN%?}', account=account)" + echo "" + RECORDS=$(echo "$ZONE" | grep '\sIN\s' | grep "^${DOMAIN}\s") + echo "$RECORDS" | while read record; do + TYPE=$(echo "$record" | awk {'print $4'}) + VALUE=$(echo "$record" | sed "s/.*IN\s[A-Z]*\s*//") + # WARNING This is example code for exclude default records !! + if [[ + ! ( $TYPE == 'SOA' ) && + ! ( $TYPE == 'MX' && $(echo $VALUE | grep 'pangea.org') ) && + ! ( $TYPE == 'A' && $VALUE == '77.246.179.81' ) && + ! ( $TYPE == 'CNAME' && $VALUE = 'web.pangea.org.' ) && + ! ( $TYPE == 'NS' && $(echo $VALUE | grep 'pangea.org') ) + ]]; then + echo "domain.records.get_or_create(type='$TYPE', value='$VALUE')" + fi + done + done + fi +done < <(ls $ZONE_PATH) + +[[ $ERRORS != "" ]] && echo "Not included due to errors:$ERRORS" >& 2 diff --git a/scripts/migration/mailbox.sh b/scripts/migration/mailbox.sh new file mode 100644 index 00000000..94388f4b --- /dev/null +++ b/scripts/migration/mailbox.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +# This script assumes accounts.sh has already been executed + +echo "from orchestra.apps.users.models import User" +echo "from orchestra.apps.users.models.roles.mailbox import Mailbox" + +SHADOW="/var/yp/ypfiles/shadow" +BASE_ACCOUNT=1 + +cat $SHADOW | while read line; do + USERNAME=$(echo "$line" | cut -d':' -f1) + PASSWORD=$(echo "$line" | cut -d':' -f2) + echo "try:" + echo " user = User.objects.get(username='$USERNAME')" + echo "except:" + echo " user = User.objects.create(username='$USERNAME', password='$PASSWORD', account_id=$BASE_ACCOUNT)" + echo " Mailbox.objects.create(user=user)" + echo "" + + UNDERSCORED_ACCOUNT_NAME=${USERNAME//*_/} + DOTTED_ACCOUNT_NAME=${USERNAME//*./} + echo "if user.account_id == $BASE_ACCOUNT:" + echo " try:" + echo " account = User.objects.get(username='$UNDERSCORED_ACCOUNT_NAME').account" + echo " user.account = account" + echo " user.save()" + echo " except:" + echo " try:" + echo " account = User.objects.get(username='$DOTTED_ACCOUNT_NAME').account" + echo " user.account = account" + echo " user.save()" + echo " except:" + echo " pass" + echo "" +done diff --git a/scripts/migration/mysql.sh b/scripts/migration/mysql.sh new file mode 100644 index 00000000..8dadda58 --- /dev/null +++ b/scripts/migration/mysql.sh @@ -0,0 +1,11 @@ +#!/bin/bash + + +QUERY="select db,db.user,user.user,password from user left join db on user.user=db.user;" + +mysql mysql -sN -e "$QUERY" | while read line; do + DBNAME=$(echo "$line" | awk {'print $1'}) + OWNER=$(echo "$line" | awk {'print $2'}) + USER=$(echo "$line" | awk {'print $3'}) + PASSWORD=$(echo "$line" | awk {'print $4'}) + if OWNER diff --git a/scripts/migration/virtusertable.sh b/scripts/migration/virtusertable.sh new file mode 100644 index 00000000..212f73a8 --- /dev/null +++ b/scripts/migration/virtusertable.sh @@ -0,0 +1,34 @@ +#!/bin/bash + + +VIRTUALTABLE="/etc/postfix/virtusertable" + + +echo "from orchestra.apps.users import User" +echo "from orchestra.apps.users.roles.mailbox import Address, Mailbox" +echo "from orchestra.apps.domains import Domain" + +cat "$VIRTUALTABLE"|grep -v "^\s*$"|while read line; do + NAME=$(echo "$line" | awk {'print $1'} | cut -d'@' -f1) + DOMAIN=$(echo "$line" | awk {'print $1'} | cut -d'@' -f2) + DESTINATION=$(echo "$line" | awk '{$1=""; print $0}' | sed -e 's/^ *//' -e 's/ *$//') + echo "domain = Domain.objects.get(name='$DOMAIN')" + for PLACE in $DESTINATION; do + if [[ ! $(echo $PLACE | grep '@') ]]; then + echo "try:" + echo " user = User.objects.get(username='$PLACE')" + echo "except:" + echo " print 'User $PLACE does not exists'" + echo "else:" + echo " mailbox, __ = Mailbox.objects.get_or_create(user=user)" + echo " if user.account_id != 1:" + echo " user.account=domain.account" + echo " user.save()" + echo "" + fi + done + echo "address, __ = Address.objects.get_or_create(name='$NAME', domain=domain)" + echo "address.account=domain.account" + echo "address.destination='$DESTINATION'" + echo "address.save()" +done diff --git a/scripts/services/README.md b/scripts/services/README.md new file mode 100644 index 00000000..2a6f1ba1 --- /dev/null +++ b/scripts/services/README.md @@ -0,0 +1,6 @@ +Service Scripts +=============== + +Here you'll find some recipes that I used for installing my servers. + +They are compatible with the backends that ship with Orchestra. diff --git a/scripts/services/apache_full_stack.md b/scripts/services/apache_full_stack.md new file mode 100644 index 00000000..229bd26a --- /dev/null +++ b/scripts/services/apache_full_stack.md @@ -0,0 +1,96 @@ +Apache 2 MPM Event with PHP-FPM, FCGID and SUEXEC on Debian Jessie +================================================================== + +The goal of this setup is having a high-performance state-of-the-art deployment of Apache and PHP while being compatible with legacy applications. + +* Apache Event MPM engine handles requests asynchronously, instead of using a dedicated thread or process per request. + +* PHP-FPM is a FastCGI process manager included in modern versions of PHP. + Compared to FCGID it provides better process management features and enables the OPCache to be shared between workers. + +* FCGID and SuEXEC are used for legacy apps that need older versions of PHP (i.e. PHP 5.2 or PHP 4) + + +*Sources:* + * Source http://wiki.apache.org/httpd/PHP-FPM + + +*Related:* + * [PHP4 on debian](php4_on_debian.md) + * [VsFTPd](vsftpd.md) + * [Webalizer](webalizer.md) + + + +1. Install the machinery + ```bash + apt-get update + apt-get install apache2-mpm-event php5-fpm libapache2-mod-fcgid apache2-suexec-custom php5-cgi + ``` + + +2. Enable some convinient Apache modules + ```bash + a2enmod suexec + a2enmod ssl + a2enmod auth_pam + a2enmod proxy_fcgi + a2emmod userdir + ``` + * TODO compat module + https://httpd.apache.org/docs/trunk/mod/mod_access_compat.html + + +3. Configure `suexec-custom` + ```bash + sed -i "s#/var/www#/home#" /etc/apache2/suexec/www-data + sed -i "s#public_html#webapps#" /etc/apache2/suexec/www-data + ``` + + +4. Create logs directory for virtualhosts + ```bash + mkdir -p /var/log/apache2/virtual/ + chown -R www-data:www-data /var/log/apache2 + ``` + + +5. Restart Apache + ```bash + service apache2 restart + ``` + + +* TODO + libapache2-mod-auth-pam + https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=710770 + + +* ExecCGI + ```bash + + Options +ExecCGI + + ``` + + +TODO CHRoot + https://andrewbevitt.com/tutorials/apache-varnish-chrooted-php-fpm-wordpress-virtual-host/ + + ```bash + echo ' + [vhost] + istemplate = 1 + listen.mode = 0660 + pm.max_children = 5 + pm.start_servers = 1 + pm.min_spare_servers = 1 + pm.max_spare_servers = 2 + ' > /etc/php5/fpm/conf.d/vhost-template.conf + ``` + + ```bash + mkdir -p /var/run/fpm/socks/ + chmod 771 /var/run/fpm/socks + chown orchestra.orchestra /var/run/fpm/socks + ``` diff --git a/scripts/services/bind9.sh b/scripts/services/bind9.sh new file mode 100644 index 00000000..9aeb303a --- /dev/null +++ b/scripts/services/bind9.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +# Installs and confingures bind9 to work with Orchestra + + +apt-get update +apt-get install bind9 + +echo "nameserver 127.0.0.1" > /etc/resolv.conf diff --git a/scripts/services/mailman.sh b/scripts/services/mailman.sh new file mode 100644 index 00000000..e3edf7a4 --- /dev/null +++ b/scripts/services/mailman.sh @@ -0,0 +1,3 @@ +apt-get install mailman + + diff --git a/scripts/services/mysql.sh b/scripts/services/mysql.sh new file mode 100644 index 00000000..71bfee8c --- /dev/null +++ b/scripts/services/mysql.sh @@ -0,0 +1 @@ +apt-get install mysql-server diff --git a/scripts/services/php4_on_debian.md b/scripts/services/php4_on_debian.md new file mode 100644 index 00000000..9538403d --- /dev/null +++ b/scripts/services/php4_on_debian.md @@ -0,0 +1,94 @@ +PHP 4.4.9 for Debian Wheezy / Jessie +==================================== + +**This recipe is for compiling a Debian Wheezy/Jessie compatible version of PHP 4.4.9** + + +1. Debootstrap a Debian Wheezy + ```bash + debootstrap --include=build-essential wheezy php4strap + chroot php4strap + ``` + + +2. Download and install PHP 4.4.9 + ```bash + mkdir /tmp/php4-build + cd /tmp/php4-build + wget http://de.php.net/get/php-4.4.9.tar.bz2/from/this/mirror -O php-4.4.9.tar.bz2 + tar jxf php-4.4.9.tar.bz2 + ``` + + +3. Install PHP building dependencies + ```bash + cat /etc/apt/sources.list | sed "s/^deb /deb-src /" >> /etc/apt/sources.list + apt-get update + apt-get build-dep php5 + ``` + +4. Create some links + ```bash + ln -s /usr/lib/x86_64-linux-gnu/libjpeg.so /usr/lib/ + ln -s /usr/lib/x86_64-linux-gnu/libpng.so /usr/lib/ + ln -s /usr/lib/x86_64-linux-gnu/libexpat.so /usr/lib/ + ln -s /usr/lib/x86_64-linux-gnu/libmysqlclient.so /usr/lib/libmysqlclient.so + ``` + +4. Configure PHP4 + + *Notice that some common features are not enabled, this is because are not supported by related libraries that ship with modern Debian releases* + + ```bash + ./configure --prefix=/usr/local/php4 \ + --enable-force-cgi-redirect \ + --enable-fastcgi \ + --with-config-file-path=/usr/local/etc/php4/cgi \ + --with-gettext \ + --with-jpeg-dir=/usr/local/lib \ + --with-mysql=/usr \ + --with-pear \ + --with-png-dir=/usr/local/lib \ + --with-xml \ + --with-zlib \ + --with-zlib-dir=/usr/include \ + --enable-bcmath \ + --enable-calendar \ + --enable-magic-quotes \ + --enable-sockets \ + --enable-track-vars \ + --enable-mbstring \ + --enable-memory-limit \ + --with-bz2 \ + --enable-dba \ + --enable-dbx \ + --with-iconv \ + --with-mime-magic \ + --disable-shmop \ + --enable-sysvmsg \ + --enable-wddx \ + --with-xmlrpc \ + --enable-yp \ + --with-gd + ``` + +5. Compile and install PHP4 + ```bash + make + make install + strip /usr/local/php4/bin/* + ``` + + +6. Grab the binaries + ```bash + exit + scp -r php4strap/usr/local/php4 root@destination-server:/usr/local/ + ``` + + +7. I needed to install some extra dependecies on my server + ```bash + apt-get install libmysqlclient18 libpng12-0 libjpeg8 + ``` + diff --git a/scripts/services/postfix.md b/scripts/services/postfix.md new file mode 100644 index 00000000..47226145 --- /dev/null +++ b/scripts/services/postfix.md @@ -0,0 +1,12 @@ +apt-get install postfix + + +# http://www.postfix.org/VIRTUAL_README.html#virtual_mailbox +# https://help.ubuntu.com/community/PostfixVirtualMailBoxClamSmtpHowto + + +# http://wiki2.dovecot.org/HowTo/VirtualUserFlatFilesPostfix + + +root@web:~# apt-get install dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-sieve + diff --git a/scripts/services/vsftpd.md b/scripts/services/vsftpd.md new file mode 100644 index 00000000..6bde4a11 --- /dev/null +++ b/scripts/services/vsftpd.md @@ -0,0 +1,25 @@ +VsFTPd with System Users +======================== + + +1. Install `vsftpd` + ```bash + apt-get install vsftpd + ``` + + +2. Make some configurations + ```bash + sed -i "s/anonymous_enable=YES/anonymous_enable=NO/" /etc/vsftpd.conf + sed -i "s/#local_enable=YES/local_enable=YES/" /etc/vsftpd.conf + sed -i "s/#write_enable=YES/write_enable=YES" /etc/vsftpd.conf + sed -i "s/#chroot_local_user=YES/chroot_local_user=YES/" /etc/vsftpd.conf + + echo '/bin/false' >> /etc/shells + ``` + + +3. Apply changes + ```bash + /etc/init.d/vsftpd restart + ``` diff --git a/scripts/services/webalizer.md b/scripts/services/webalizer.md new file mode 100644 index 00000000..509bf337 --- /dev/null +++ b/scripts/services/webalizer.md @@ -0,0 +1,21 @@ +Webalizer +========= + + +1. Install `vsftpd` + ```bash + apt-get install webalizer + ``` + + +2. Modify Apache/Nginx log postrotate + ```bash + for i in /home/httpd/webalizer/*.conf; do + file=$(grep ^LogFile "$i" | tr -s ' ' | cut -f2 -d ' ').1 + if [ -f "$file" ]; then + /usr/bin/webalizer -q -c "$i" "$file" 2>&1 \ + # Supress truncating warnings + | grep -v '^Warning: Truncating oversized ' >&2 + fi + done + ``` diff --git a/scripts/tests/setup.sh b/scripts/tests/setup.sh new file mode 100644 index 00000000..05d7a6bb --- /dev/null +++ b/scripts/tests/setup.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +# Setup the test environment + + +apt-get update +apt-get install python-pip iceweasel xvfb +pip install selenium xvfbwrapper + diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..10202805 --- /dev/null +++ b/setup.py @@ -0,0 +1,49 @@ +import os, sys +from distutils.sysconfig import get_python_lib +from setuptools import setup, find_packages + + +# allow setup.py to be run from any path +os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) + +packages = find_packages('.') + +# Dynamically calculate the version based on orchestra.VERSION. +version = __import__('orchestra').get_version() + + +setup( + name = "django-orchestra", + version = version, + author = "Marc Aymerich", + author_email = "marcay@pangea.org", + url = "http://orchestra.pangea.org", + license = "GPLv3", + description = "A framework for building web hosting control panels", + long_description = ( + "There are a lot of widely used open source hosting control panels, " + "however none of them seems apropiate when you already have a production " + "service infrastructure or simply you want a particular architecture.\n" + "The goal of this project is to provide the tools for easily build a fully " + "featured control panel that fits any service architecture."), + include_package_data = True, + scripts=['orchestra/bin/orchestra-admin'], + packages = packages, + classifiers = [ + 'Development Status :: 1 - Alpha', + 'Environment :: Web Environment', + 'Framework :: Django', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: BSD License', + 'Operating System :: POSIX :: Linux', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', + 'Topic :: Internet :: WWW/HTTP :: Site Management', + 'Topic :: Software Development :: Libraries :: Application Frameworks', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Topic :: Server/Management', + ] +)