Initial commit
This commit is contained in:
commit
dddb11bf40
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
*.log
|
||||||
|
*.pot
|
||||||
|
*.pyc
|
||||||
|
*~
|
||||||
|
.svn
|
||||||
|
local_settings.py
|
83
INSTALL.md
Normal file
83
INSTALL.md
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
Installation
|
||||||
|
============
|
||||||
|
|
||||||
|
Django-orchestra ships with a set of management commands for automating some of the installation steps.
|
||||||
|
|
||||||
|
These commands are meant to be run within a **clean** Debian-like distribution, you should be specially careful while following this guide on a customized system.
|
||||||
|
|
||||||
|
Django-orchestra can be installed on any Linux system, however it is **strongly recommended** to chose the reference platform for your deployment (Debian 7.0 wheezy and Python 2.7).
|
||||||
|
|
||||||
|
|
||||||
|
1. Create a system user for running Orchestra
|
||||||
|
```bash
|
||||||
|
adduser orchestra
|
||||||
|
# not required but it will be very handy
|
||||||
|
sudo adduser orchestra sudo
|
||||||
|
su - orchestra
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install django-orchestra's source code
|
||||||
|
```bash
|
||||||
|
sudo apt-get install python-pip
|
||||||
|
sudo pip install django-orchestra # ==dev if you want the in-devel version
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Install requirements
|
||||||
|
```bash
|
||||||
|
sudo orchestra-admin install_requirements
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Create a new project
|
||||||
|
```bash
|
||||||
|
cd ~orchestra
|
||||||
|
orchestra-admin startproject <project_name> # e.g. panel
|
||||||
|
cd <project_name>
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Create and configure a Postgres database
|
||||||
|
```bash
|
||||||
|
sudo python manage.py setuppostgres
|
||||||
|
python manage.py syncdb
|
||||||
|
python manage.py migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Create a panel administrator
|
||||||
|
```bash
|
||||||
|
python manage.py createsuperuser
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Configure celeryd
|
||||||
|
```bash
|
||||||
|
sudo python manage.py setupcelery --username orchestra
|
||||||
|
```
|
||||||
|
|
||||||
|
8. Configure the web server:
|
||||||
|
```bash
|
||||||
|
python manage.py collectstatic --noinput
|
||||||
|
sudo apt-get install nginx-full uwsgi uwsgi-plugin-python
|
||||||
|
sudo python manage.py setupnginx
|
||||||
|
```
|
||||||
|
|
||||||
|
9. Start all services:
|
||||||
|
```bash
|
||||||
|
sudo python manage.py startservices
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Upgrade
|
||||||
|
=======
|
||||||
|
To upgrade your Orchestra installation to the last release you can use `upgradeorchestra` management command. Before rolling the upgrade it is strongly recommended to check the [release notes](http://django-orchestra.readthedocs.org/en/latest/).
|
||||||
|
```bash
|
||||||
|
sudo python manage.py upgradeorchestra
|
||||||
|
```
|
||||||
|
|
||||||
|
Current in *development* version (master branch) can be installed by
|
||||||
|
```bash
|
||||||
|
sudo python manage.py upgradeorchestra dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Additionally the following command can be used in order to determine the currently installed version:
|
||||||
|
```bash
|
||||||
|
python manage.py orchestraversion
|
||||||
|
```
|
||||||
|
|
14
LICENSE
Normal file
14
LICENSE
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
Copyright (C) 2013 Marc Aymerich
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
8
MANIFEST.in
Normal file
8
MANIFEST.in
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
recursive-include orchestra *
|
||||||
|
|
||||||
|
recursive-exclude * __pycache__
|
||||||
|
recursive-exclude * *.py[co]
|
||||||
|
recursive-exclude * *~
|
||||||
|
recursive-exclude * *.save
|
||||||
|
recursive-exclude * *.svg
|
||||||
|
|
94
README.md
Normal file
94
README.md
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
![](orchestra/static/orchestra/icons/Emblem-important.png) **This project is in early development stage**
|
||||||
|
|
||||||
|
Django Orchestra
|
||||||
|
================
|
||||||
|
|
||||||
|
Orchestra is a Django-based framework for building web hosting control panels.
|
||||||
|
|
||||||
|
* [Documentation](http://django-orchestra.readthedocs.org/)
|
||||||
|
* [Install and upgrade](INSTALL.md)
|
||||||
|
* [Roadmap](ROADMAP.md)
|
||||||
|
|
||||||
|
|
||||||
|
Motivation
|
||||||
|
----------
|
||||||
|
There are a lot of widely used open source hosting control panels, however, none of them seems apropiate when you already have an existing service infrastructure or simply you want your services to run on a particular architecture.
|
||||||
|
|
||||||
|
The goal of this project is to provide the tools for easily build a fully featured control panel that is not tied to any particular service architecture.
|
||||||
|
|
||||||
|
|
||||||
|
Overview
|
||||||
|
--------
|
||||||
|
|
||||||
|
Django-orchestra is mostly a bunch of [plugable applications](orchestra/apps) providing common functionalities, like service management, resource monitoring or billing.
|
||||||
|
|
||||||
|
The admin interface relies on [Django Admin](https://docs.djangoproject.com/en/dev/ref/contrib/admin/), but enhaced with [Django Admin Tools](https://bitbucket.org/izi/django-admin-tools) and [Django Fluent Dashboard](https://github.com/edoburu/django-fluent-dashboard). [Django REST Framework](http://www.django-rest-framework.org/) is used for the REST API, with it you can build your client-side custom user interface.
|
||||||
|
|
||||||
|
Every app is [reusable](https://docs.djangoproject.com/en/dev/intro/reusable-apps/), this means that you can add any Orchestra application into your Django project `INSTALLED_APPS` strigh away.
|
||||||
|
However, Orchestra also provides glue, tools and patterns that you may find very convinient to use. Checkout the [documentation](http://django-orchestra.readthedocs.org/) if you want to know more.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Development and Testing Setup
|
||||||
|
-----------------------------
|
||||||
|
If you are planing to do some development or perhaps just checking out this project, you may want to consider doing it under the following setup
|
||||||
|
|
||||||
|
1. Create a basic [LXC](http://linuxcontainers.org/) container, start it and get inside.
|
||||||
|
```bash
|
||||||
|
wget -O /tmp/create.sh \
|
||||||
|
https://raw2.github.com/glic3rinu/django-orchestra/master/scripts/container/create.sh
|
||||||
|
chmod +x /tmp/create.sh
|
||||||
|
sudo /tmp/create.sh
|
||||||
|
sudo lxc-start -n orchestra
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Deploy Django-orchestra development environment inside the container
|
||||||
|
```bash
|
||||||
|
wget -O /tmp/deploy.sh \
|
||||||
|
https://raw2.github.com/glic3rinu/django-orchestra/master/scripts/container/deploy.sh
|
||||||
|
chmod +x /tmp/deploy.sh
|
||||||
|
cd /tmp/ # Moving away from /root before running deploy.sh
|
||||||
|
/tmp/deploy.sh
|
||||||
|
```
|
||||||
|
Django-orchestra source code should be now under `~orchestra/django-orchestra` and an Orchestra instance called _panel_ under `~orchestra/panel`
|
||||||
|
|
||||||
|
|
||||||
|
3. Nginx should be serving on port 80, but Django's development server can be used as well:
|
||||||
|
```bash
|
||||||
|
su - orchestra
|
||||||
|
cd panel
|
||||||
|
python manage.py runserver 0.0.0.0:8888
|
||||||
|
```
|
||||||
|
|
||||||
|
4. A convenient practice can be mounting `~orchestra` on your host machine so you can code with your favourite IDE, sshfs can be used for that
|
||||||
|
```bash
|
||||||
|
# On your host
|
||||||
|
mkdir ~<user>/orchestra
|
||||||
|
sshfs orchestra@<container-ip>: ~<user>/orchestra
|
||||||
|
```
|
||||||
|
|
||||||
|
5. To upgrade to current master just
|
||||||
|
```bash
|
||||||
|
cd ~orchestra/django-orchestra/
|
||||||
|
git pull origin master
|
||||||
|
sudo ~orchestra/django-orchestra/scripts/container/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
License
|
||||||
|
-------
|
||||||
|
Copyright (C) 2013 Marc Aymerich
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
Status API Training Shop Blog About
|
43
ROADMAP.md
Normal file
43
ROADMAP.md
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
# Roadmap
|
||||||
|
|
||||||
|
|
||||||
|
### 1.0a1 Milestone (first alpha release on May '14)
|
||||||
|
|
||||||
|
1. [x] Automated deployment of the development environment
|
||||||
|
2. [x] Automated installation and upgrading
|
||||||
|
2. [ ] Testing framework for running unittests and functional tests
|
||||||
|
2. [ ] Continuous integration environment
|
||||||
|
2. [x] Admin interface based on django.contrib.admin foundations
|
||||||
|
3. [x] REST API based on django-rest-framework foundations
|
||||||
|
2. [x] [Orchestra-orm](https://github.com/glic3rinu/orchestra-orm) a Python library for easily interacting with the REST API
|
||||||
|
3. [x] Service orchestration framework
|
||||||
|
4. [ ] Data model, input validation, admin and REST interfaces, permissions, unit and functional tests, service management, migration scripts and some documentation of:
|
||||||
|
1. [ ] Web applications and FTP accounts
|
||||||
|
2. [ ] Databases
|
||||||
|
1. [ ] Mail accounts, aliases, forwards
|
||||||
|
1. [ ] DNS
|
||||||
|
1. [ ] Mailing lists
|
||||||
|
1. [ ] Contact management and service contraction
|
||||||
|
1. [ ] Object level permissions system
|
||||||
|
1. [ ] Unittests of all the logic
|
||||||
|
2. [ ] Functional tests of all Admin and REST interations
|
||||||
|
1. [ ] Initial documentation
|
||||||
|
|
||||||
|
|
||||||
|
### 1.0b1 Milestone (first beta release on Jul '14)
|
||||||
|
|
||||||
|
1. [ ] Resource monitoring
|
||||||
|
1. [ ] Orders
|
||||||
|
2. [ ] Pricing
|
||||||
|
3. [ ] Billing
|
||||||
|
1. [ ] Payment gateways
|
||||||
|
2. [ ] Scheduling of service cancellations
|
||||||
|
1. [ ] Full documentation
|
||||||
|
|
||||||
|
|
||||||
|
### 1.0 Milestone (first stable release on Dec '14)
|
||||||
|
|
||||||
|
1. [ ] Stabilize data model, internal APIs and REST API
|
||||||
|
1. [ ] Integration with third-party service providers, e.g. Gandi
|
||||||
|
1. [ ] Support for additional services like VPS
|
||||||
|
2. [ ] Issue tracking system
|
37
TODO.md
Normal file
37
TODO.md
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
TODO
|
||||||
|
====
|
||||||
|
|
||||||
|
* scape strings before executing scripts in order to prevent exploits: django templates automatically scapes things. Most important is to ensuer that all escape ' to "
|
||||||
|
* 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?
|
5
build/pip-delete-this-directory.txt
Normal file
5
build/pip-delete-this-directory.txt
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
This file is placed here by pip to indicate the source was put
|
||||||
|
here by pip.
|
||||||
|
|
||||||
|
Once this package is successfully installed this source code will be
|
||||||
|
deleted (unless you remove this file).
|
222
docs/API.rst
Normal file
222
docs/API.rst
Normal file
|
@ -0,0 +1,222 @@
|
||||||
|
=================================
|
||||||
|
Orchestra REST API Specification
|
||||||
|
=================================
|
||||||
|
|
||||||
|
:Version: 0.1
|
||||||
|
|
||||||
|
Resources
|
||||||
|
---------
|
||||||
|
|
||||||
|
.. contents::
|
||||||
|
:local:
|
||||||
|
|
||||||
|
Panel [application/vnd.orchestra.Panel+json]
|
||||||
|
============================================
|
||||||
|
|
||||||
|
A Panel represents a user's view of all accessible resources.
|
||||||
|
A "Panel" resource model contains the following fields:
|
||||||
|
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
**Field name** **Type** **Occurs** **Description**
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
uri URI 1 A GET against this URI refreshes the client representation of the resources accessible to this user.
|
||||||
|
services Object[] 0..1 {'DNS': {'names': "names_URI", 'zones': "zones_URI}, {'Mail': {'Virtual_user': "virtual_user_URI" ....
|
||||||
|
accountancy Object[] 0..1
|
||||||
|
administration Object[] 0..1
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
|
||||||
|
|
||||||
|
Contact [application/vnd.orchestra.Contact+json]
|
||||||
|
================================================
|
||||||
|
|
||||||
|
A Contact represents
|
||||||
|
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
**Field name** **Type** **Occurs** **Description**
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
uri URI 1
|
||||||
|
name String 1
|
||||||
|
surname String 0..1
|
||||||
|
second_surname String 0..1
|
||||||
|
national_id String 1
|
||||||
|
type String 1
|
||||||
|
language String 1
|
||||||
|
address String 1
|
||||||
|
city String 1
|
||||||
|
zipcode Number 1
|
||||||
|
province String 1
|
||||||
|
country String 1
|
||||||
|
fax String 0..1
|
||||||
|
emails String[] 1
|
||||||
|
phones String[] 1
|
||||||
|
billing_contact Contact 0..1
|
||||||
|
technical_contact Contact 0..1
|
||||||
|
administrative_contact Contact 0..1
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
|
||||||
|
TODO: phone and emails for this contacts this contacts should be equal to Contact on Django models
|
||||||
|
|
||||||
|
|
||||||
|
User [application/vnd.orchestra.User+json]
|
||||||
|
==========================================
|
||||||
|
|
||||||
|
A User represents
|
||||||
|
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
**Field name** **Type** **Occurs** **Description**
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
username String
|
||||||
|
uri URI 1
|
||||||
|
contact Contact
|
||||||
|
password String
|
||||||
|
first_name String
|
||||||
|
last_name String
|
||||||
|
email_address String
|
||||||
|
active Boolean
|
||||||
|
staff_status Boolean
|
||||||
|
superuser_status Boolean
|
||||||
|
groups Group
|
||||||
|
user_permissions Permission[]
|
||||||
|
last_login String
|
||||||
|
date_joined String
|
||||||
|
system_user SystemUser
|
||||||
|
virtual_user VirtualUser
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
|
||||||
|
|
||||||
|
SystemUser [application/vnd.orchestra.SystemUser+json]
|
||||||
|
======================================================
|
||||||
|
|
||||||
|
========================== =========== ========== ===========================
|
||||||
|
**Field name** **Type** **Occurs** **Description**
|
||||||
|
========================== =========== ========== ===========================
|
||||||
|
user User
|
||||||
|
uri URI 1
|
||||||
|
user_shell String
|
||||||
|
user_uid Number
|
||||||
|
primary_group Group
|
||||||
|
homedir String
|
||||||
|
only_ftp Boolean
|
||||||
|
========================== =========== ========== ===========================
|
||||||
|
|
||||||
|
|
||||||
|
VirtualUser [application/vnd.orchestra.VirtualUser+json]
|
||||||
|
========================================================
|
||||||
|
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
**Field name** **Type** **Occurs** **Description**
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
user User
|
||||||
|
uri URI 1
|
||||||
|
emailname String
|
||||||
|
domain Name <VirtualDomain?>
|
||||||
|
home String
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
|
||||||
|
Zone [application/vnd.orchestra.Zone+json]
|
||||||
|
==========================================
|
||||||
|
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
**Field name** **Type** **Occurs** **Description**
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
origin String
|
||||||
|
uri URI 1
|
||||||
|
contact Contact
|
||||||
|
primary_ns String
|
||||||
|
hostmaster_email String
|
||||||
|
serial Number
|
||||||
|
slave_refresh Number
|
||||||
|
slave_retry Number
|
||||||
|
slave_expiration Number
|
||||||
|
min_caching_time Number
|
||||||
|
records Object[] Domain record i.e. {'name': ('type', 'value') }
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
|
||||||
|
Name [application/vnd.orchestra.Name+json]
|
||||||
|
==========================================
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
**Field name** **Type** **Occurs** **Description**
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
name String
|
||||||
|
extension String
|
||||||
|
uri URI 1
|
||||||
|
contact Contact
|
||||||
|
register_provider String
|
||||||
|
name_server Object[] Name server key/value i.e. {'ns1.pangea.org': '1.1.1.1'}
|
||||||
|
virtual_domain Boolean <TODO: is redundant with virtual domain type?>
|
||||||
|
virtual_domain_type String
|
||||||
|
zone Zone
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
|
||||||
|
VirtualHost [application/vnd.orchestra.VirtualHost+json]
|
||||||
|
========================================================
|
||||||
|
<TODO: REST and dynamic attributes (resources, contacts)>
|
||||||
|
A VirtualHost represents an Apache-like virtualhost configuration, which is useful for generating all the configuration files on the web server.
|
||||||
|
A VirtualHost resource model contains the following fields:
|
||||||
|
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
**Field name** **Type** **Occurs** **Description**
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
server_name String
|
||||||
|
uri URI
|
||||||
|
contact Contact
|
||||||
|
ip String
|
||||||
|
port Number
|
||||||
|
domains Name[]
|
||||||
|
document_root String
|
||||||
|
custom_directives String[]
|
||||||
|
fcgid_user String
|
||||||
|
fcgid_group string String
|
||||||
|
fcgid_directives Object Fcgid custom directives represented on a key/value pairs i.e. {'FcgidildeTimeout': 1202}
|
||||||
|
php_version String
|
||||||
|
php_directives Object PHP custom directives represented on key/value pairs i.e. {'display errors': 'True'}
|
||||||
|
resource_swap_current Number PHP custom directives represented on key/value pairs i.e. {'display errors': 'True'}
|
||||||
|
resource_swap_limit Number PHP custom directives represented on key/value pairs i.e. {'display errors': 'True'}
|
||||||
|
resource_cpu_current Number
|
||||||
|
resource_cpu_limit Number
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
|
||||||
|
Daemon [application/vnd.orchestra.Daemon+json]
|
||||||
|
==============================================
|
||||||
|
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
**Field name** **Type** **Occurs** **Description**
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
name String
|
||||||
|
uri URI 1
|
||||||
|
content_type String
|
||||||
|
active Boolean
|
||||||
|
save_template String
|
||||||
|
save_method String
|
||||||
|
delete_template String
|
||||||
|
delete_method String
|
||||||
|
daemon_instances Object[] {'host': 'expression'}
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
|
||||||
|
Monitor [application/vnd.orchestra.Monitor+json]
|
||||||
|
================================================
|
||||||
|
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
**Field name** **Type** **Occurs** **Description**
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
uri URI 1
|
||||||
|
daemon Daemon
|
||||||
|
resource String
|
||||||
|
monitoring_template String
|
||||||
|
monitoring method String
|
||||||
|
exceed_template String <TODO: rename on monitor django model>
|
||||||
|
exceed_method String
|
||||||
|
recover_template String
|
||||||
|
recover_method String
|
||||||
|
allow_limit Boolean
|
||||||
|
allow_unlimit Boolean
|
||||||
|
default_initial Number
|
||||||
|
block_size Number
|
||||||
|
algorithm String
|
||||||
|
period String
|
||||||
|
interval String 0..1
|
||||||
|
crontab String 0..1
|
||||||
|
========================== ============ ========== ===========================
|
||||||
|
|
||||||
|
|
||||||
|
#Layout inspired from http://kenai.com/projects/suncloudapis/pages/CloudAPISpecificationResourceModels
|
153
docs/Makefile
Normal file
153
docs/Makefile
Normal file
|
@ -0,0 +1,153 @@
|
||||||
|
# Makefile for Sphinx documentation
|
||||||
|
#
|
||||||
|
|
||||||
|
# You can set these variables from the command line.
|
||||||
|
SPHINXOPTS =
|
||||||
|
SPHINXBUILD = sphinx-build
|
||||||
|
PAPER =
|
||||||
|
BUILDDIR = _build
|
||||||
|
|
||||||
|
# Internal variables.
|
||||||
|
PAPEROPT_a4 = -D latex_paper_size=a4
|
||||||
|
PAPEROPT_letter = -D latex_paper_size=letter
|
||||||
|
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
|
||||||
|
# the i18n builder cannot share the environment and doctrees with the others
|
||||||
|
I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
|
||||||
|
|
||||||
|
.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Please use \`make <target>' where <target> is one of"
|
||||||
|
@echo " html to make standalone HTML files"
|
||||||
|
@echo " dirhtml to make HTML files named index.html in directories"
|
||||||
|
@echo " singlehtml to make a single large HTML file"
|
||||||
|
@echo " pickle to make pickle files"
|
||||||
|
@echo " json to make JSON files"
|
||||||
|
@echo " htmlhelp to make HTML files and a HTML help project"
|
||||||
|
@echo " qthelp to make HTML files and a qthelp project"
|
||||||
|
@echo " devhelp to make HTML files and a Devhelp project"
|
||||||
|
@echo " epub to make an epub"
|
||||||
|
@echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
|
||||||
|
@echo " latexpdf to make LaTeX files and run them through pdflatex"
|
||||||
|
@echo " text to make text files"
|
||||||
|
@echo " man to make manual pages"
|
||||||
|
@echo " texinfo to make Texinfo files"
|
||||||
|
@echo " info to make Texinfo files and run them through makeinfo"
|
||||||
|
@echo " gettext to make PO message catalogs"
|
||||||
|
@echo " changes to make an overview of all changed/added/deprecated items"
|
||||||
|
@echo " linkcheck to check all external links for integrity"
|
||||||
|
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
|
||||||
|
|
||||||
|
clean:
|
||||||
|
-rm -rf $(BUILDDIR)/*
|
||||||
|
|
||||||
|
html:
|
||||||
|
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
|
||||||
|
@echo
|
||||||
|
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
||||||
|
|
||||||
|
dirhtml:
|
||||||
|
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
|
||||||
|
@echo
|
||||||
|
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
|
||||||
|
|
||||||
|
singlehtml:
|
||||||
|
$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
|
||||||
|
@echo
|
||||||
|
@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
|
||||||
|
|
||||||
|
pickle:
|
||||||
|
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
|
||||||
|
@echo
|
||||||
|
@echo "Build finished; now you can process the pickle files."
|
||||||
|
|
||||||
|
json:
|
||||||
|
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
|
||||||
|
@echo
|
||||||
|
@echo "Build finished; now you can process the JSON files."
|
||||||
|
|
||||||
|
htmlhelp:
|
||||||
|
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
|
||||||
|
@echo
|
||||||
|
@echo "Build finished; now you can run HTML Help Workshop with the" \
|
||||||
|
".hhp project file in $(BUILDDIR)/htmlhelp."
|
||||||
|
|
||||||
|
qthelp:
|
||||||
|
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
|
||||||
|
@echo
|
||||||
|
@echo "Build finished; now you can run "qcollectiongenerator" with the" \
|
||||||
|
".qhcp project file in $(BUILDDIR)/qthelp, like this:"
|
||||||
|
@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-orchestra.qhcp"
|
||||||
|
@echo "To view the help file:"
|
||||||
|
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-orchestra.qhc"
|
||||||
|
|
||||||
|
devhelp:
|
||||||
|
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
|
||||||
|
@echo
|
||||||
|
@echo "Build finished."
|
||||||
|
@echo "To view the help file:"
|
||||||
|
@echo "# mkdir -p $$HOME/.local/share/devhelp/django-orchestra"
|
||||||
|
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-orchestra"
|
||||||
|
@echo "# devhelp"
|
||||||
|
|
||||||
|
epub:
|
||||||
|
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
|
||||||
|
@echo
|
||||||
|
@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
|
||||||
|
|
||||||
|
latex:
|
||||||
|
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||||
|
@echo
|
||||||
|
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
|
||||||
|
@echo "Run \`make' in that directory to run these through (pdf)latex" \
|
||||||
|
"(use \`make latexpdf' here to do that automatically)."
|
||||||
|
|
||||||
|
latexpdf:
|
||||||
|
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||||
|
@echo "Running LaTeX files through pdflatex..."
|
||||||
|
$(MAKE) -C $(BUILDDIR)/latex all-pdf
|
||||||
|
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
|
||||||
|
|
||||||
|
text:
|
||||||
|
$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
|
||||||
|
@echo
|
||||||
|
@echo "Build finished. The text files are in $(BUILDDIR)/text."
|
||||||
|
|
||||||
|
man:
|
||||||
|
$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
|
||||||
|
@echo
|
||||||
|
@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
|
||||||
|
|
||||||
|
texinfo:
|
||||||
|
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
|
||||||
|
@echo
|
||||||
|
@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
|
||||||
|
@echo "Run \`make' in that directory to run these through makeinfo" \
|
||||||
|
"(use \`make info' here to do that automatically)."
|
||||||
|
|
||||||
|
info:
|
||||||
|
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
|
||||||
|
@echo "Running Texinfo files through makeinfo..."
|
||||||
|
make -C $(BUILDDIR)/texinfo info
|
||||||
|
@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
|
||||||
|
|
||||||
|
gettext:
|
||||||
|
$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
|
||||||
|
@echo
|
||||||
|
@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
|
||||||
|
|
||||||
|
changes:
|
||||||
|
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
|
||||||
|
@echo
|
||||||
|
@echo "The overview file is in $(BUILDDIR)/changes."
|
||||||
|
|
||||||
|
linkcheck:
|
||||||
|
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
|
||||||
|
@echo
|
||||||
|
@echo "Link check complete; look for any errors in the above output " \
|
||||||
|
"or in $(BUILDDIR)/linkcheck/output.txt."
|
||||||
|
|
||||||
|
doctest:
|
||||||
|
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
|
||||||
|
@echo "Testing of doctests in the sources finished, look at the " \
|
||||||
|
"results in $(BUILDDIR)/doctest/output.txt."
|
242
docs/conf.py
Normal file
242
docs/conf.py
Normal file
|
@ -0,0 +1,242 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# django-orchestra documentation build configuration file, created by
|
||||||
|
# sphinx-quickstart on Wed Aug 8 11:07:40 2012.
|
||||||
|
#
|
||||||
|
# This file is execfile()d with the current directory set to its containing dir.
|
||||||
|
#
|
||||||
|
# Note that not all possible configuration values are present in this
|
||||||
|
# autogenerated file.
|
||||||
|
#
|
||||||
|
# All configuration values have a default; values that are commented out
|
||||||
|
# serve to show the default.
|
||||||
|
|
||||||
|
import sys, os
|
||||||
|
|
||||||
|
# If extensions (or modules to document with autodoc) are in another directory,
|
||||||
|
# add these directories to sys.path here. If the directory is relative to the
|
||||||
|
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||||
|
#sys.path.insert(0, os.path.abspath('.'))
|
||||||
|
|
||||||
|
# -- General configuration -----------------------------------------------------
|
||||||
|
|
||||||
|
# If your documentation needs a minimal Sphinx version, state it here.
|
||||||
|
#needs_sphinx = '1.0'
|
||||||
|
|
||||||
|
# Add any Sphinx extension module names here, as strings. They can be extensions
|
||||||
|
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
|
||||||
|
extensions = ['sphinx.ext.autodoc']
|
||||||
|
|
||||||
|
# Add any paths that contain templates here, relative to this directory.
|
||||||
|
templates_path = ['_templates']
|
||||||
|
|
||||||
|
# The suffix of source filenames.
|
||||||
|
source_suffix = '.rst'
|
||||||
|
|
||||||
|
# The encoding of source files.
|
||||||
|
#source_encoding = 'utf-8-sig'
|
||||||
|
|
||||||
|
# The master toctree document.
|
||||||
|
master_doc = 'index'
|
||||||
|
|
||||||
|
# General information about the project.
|
||||||
|
project = u'django-orchestra'
|
||||||
|
copyright = u'2012, Marc Aymerich'
|
||||||
|
|
||||||
|
# The version info for the project you're documenting, acts as replacement for
|
||||||
|
# |version| and |release|, also used in various other places throughout the
|
||||||
|
# built documents.
|
||||||
|
#
|
||||||
|
# The short X.Y version.
|
||||||
|
version = '0.1'
|
||||||
|
# The full version, including alpha/beta/rc tags.
|
||||||
|
release = '0.1'
|
||||||
|
|
||||||
|
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||||
|
# for a list of supported languages.
|
||||||
|
#language = None
|
||||||
|
|
||||||
|
# There are two options for replacing |today|: either, you set today to some
|
||||||
|
# non-false value, then it is used:
|
||||||
|
#today = ''
|
||||||
|
# Else, today_fmt is used as the format for a strftime call.
|
||||||
|
#today_fmt = '%B %d, %Y'
|
||||||
|
|
||||||
|
# List of patterns, relative to source directory, that match files and
|
||||||
|
# directories to ignore when looking for source files.
|
||||||
|
exclude_patterns = ['_build']
|
||||||
|
|
||||||
|
# The reST default role (used for this markup: `text`) to use for all documents.
|
||||||
|
#default_role = None
|
||||||
|
|
||||||
|
# If true, '()' will be appended to :func: etc. cross-reference text.
|
||||||
|
#add_function_parentheses = True
|
||||||
|
|
||||||
|
# If true, the current module name will be prepended to all description
|
||||||
|
# unit titles (such as .. function::).
|
||||||
|
#add_module_names = True
|
||||||
|
|
||||||
|
# If true, sectionauthor and moduleauthor directives will be shown in the
|
||||||
|
# output. They are ignored by default.
|
||||||
|
#show_authors = False
|
||||||
|
|
||||||
|
# The name of the Pygments (syntax highlighting) style to use.
|
||||||
|
pygments_style = 'sphinx'
|
||||||
|
|
||||||
|
# A list of ignored prefixes for module index sorting.
|
||||||
|
#modindex_common_prefix = []
|
||||||
|
|
||||||
|
|
||||||
|
# -- Options for HTML output ---------------------------------------------------
|
||||||
|
|
||||||
|
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||||
|
# a list of builtin themes.
|
||||||
|
html_theme = 'default'
|
||||||
|
|
||||||
|
# Theme options are theme-specific and customize the look and feel of a theme
|
||||||
|
# further. For a list of options available for each theme, see the
|
||||||
|
# documentation.
|
||||||
|
#html_theme_options = {}
|
||||||
|
|
||||||
|
# Add any paths that contain custom themes here, relative to this directory.
|
||||||
|
#html_theme_path = []
|
||||||
|
|
||||||
|
# The name for this set of Sphinx documents. If None, it defaults to
|
||||||
|
# "<project> v<release> documentation".
|
||||||
|
#html_title = None
|
||||||
|
|
||||||
|
# A shorter title for the navigation bar. Default is the same as html_title.
|
||||||
|
#html_short_title = None
|
||||||
|
|
||||||
|
# The name of an image file (relative to this directory) to place at the top
|
||||||
|
# of the sidebar.
|
||||||
|
#html_logo = None
|
||||||
|
|
||||||
|
# The name of an image file (within the static path) to use as favicon of the
|
||||||
|
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
|
||||||
|
# pixels large.
|
||||||
|
#html_favicon = None
|
||||||
|
|
||||||
|
# Add any paths that contain custom static files (such as style sheets) here,
|
||||||
|
# relative to this directory. They are copied after the builtin static files,
|
||||||
|
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||||
|
html_static_path = ['_static']
|
||||||
|
|
||||||
|
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
|
||||||
|
# using the given strftime format.
|
||||||
|
#html_last_updated_fmt = '%b %d, %Y'
|
||||||
|
|
||||||
|
# If true, SmartyPants will be used to convert quotes and dashes to
|
||||||
|
# typographically correct entities.
|
||||||
|
#html_use_smartypants = True
|
||||||
|
|
||||||
|
# Custom sidebar templates, maps document names to template names.
|
||||||
|
#html_sidebars = {}
|
||||||
|
|
||||||
|
# Additional templates that should be rendered to pages, maps page names to
|
||||||
|
# template names.
|
||||||
|
#html_additional_pages = {}
|
||||||
|
|
||||||
|
# If false, no module index is generated.
|
||||||
|
#html_domain_indices = True
|
||||||
|
|
||||||
|
# If false, no index is generated.
|
||||||
|
#html_use_index = True
|
||||||
|
|
||||||
|
# If true, the index is split into individual pages for each letter.
|
||||||
|
#html_split_index = False
|
||||||
|
|
||||||
|
# If true, links to the reST sources are added to the pages.
|
||||||
|
#html_show_sourcelink = True
|
||||||
|
|
||||||
|
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
|
||||||
|
#html_show_sphinx = True
|
||||||
|
|
||||||
|
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
|
||||||
|
#html_show_copyright = True
|
||||||
|
|
||||||
|
# If true, an OpenSearch description file will be output, and all pages will
|
||||||
|
# contain a <link> tag referring to it. The value of this option must be the
|
||||||
|
# base URL from which the finished HTML is served.
|
||||||
|
#html_use_opensearch = ''
|
||||||
|
|
||||||
|
# This is the file name suffix for HTML files (e.g. ".xhtml").
|
||||||
|
#html_file_suffix = None
|
||||||
|
|
||||||
|
# Output file base name for HTML help builder.
|
||||||
|
htmlhelp_basename = 'django-orchestradoc'
|
||||||
|
|
||||||
|
|
||||||
|
# -- Options for LaTeX output --------------------------------------------------
|
||||||
|
|
||||||
|
latex_elements = {
|
||||||
|
# The paper size ('letterpaper' or 'a4paper').
|
||||||
|
#'papersize': 'letterpaper',
|
||||||
|
|
||||||
|
# The font size ('10pt', '11pt' or '12pt').
|
||||||
|
#'pointsize': '10pt',
|
||||||
|
|
||||||
|
# Additional stuff for the LaTeX preamble.
|
||||||
|
#'preamble': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Grouping the document tree into LaTeX files. List of tuples
|
||||||
|
# (source start file, target name, title, author, documentclass [howto/manual]).
|
||||||
|
latex_documents = [
|
||||||
|
('index', 'django-orchestra.tex', u'django-orchestra Documentation',
|
||||||
|
u'Marc Aymerich', 'manual'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# The name of an image file (relative to this directory) to place at the top of
|
||||||
|
# the title page.
|
||||||
|
#latex_logo = None
|
||||||
|
|
||||||
|
# For "manual" documents, if this is true, then toplevel headings are parts,
|
||||||
|
# not chapters.
|
||||||
|
#latex_use_parts = False
|
||||||
|
|
||||||
|
# If true, show page references after internal links.
|
||||||
|
#latex_show_pagerefs = False
|
||||||
|
|
||||||
|
# If true, show URL addresses after external links.
|
||||||
|
#latex_show_urls = False
|
||||||
|
|
||||||
|
# Documents to append as an appendix to all manuals.
|
||||||
|
#latex_appendices = []
|
||||||
|
|
||||||
|
# If false, no module index is generated.
|
||||||
|
#latex_domain_indices = True
|
||||||
|
|
||||||
|
|
||||||
|
# -- Options for manual page output --------------------------------------------
|
||||||
|
|
||||||
|
# One entry per manual page. List of tuples
|
||||||
|
# (source start file, name, description, authors, manual section).
|
||||||
|
man_pages = [
|
||||||
|
('index', 'django-orchestra', u'django-orchestra Documentation',
|
||||||
|
[u'Marc Aymerich'], 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
# If true, show URL addresses after external links.
|
||||||
|
#man_show_urls = False
|
||||||
|
|
||||||
|
|
||||||
|
# -- Options for Texinfo output ------------------------------------------------
|
||||||
|
|
||||||
|
# Grouping the document tree into Texinfo files. List of tuples
|
||||||
|
# (source start file, target name, title, author,
|
||||||
|
# dir menu entry, description, category)
|
||||||
|
texinfo_documents = [
|
||||||
|
('index', 'django-orchestra', u'django-orchestra Documentation',
|
||||||
|
u'Marc Aymerich', 'django-orchestra', 'One line description of project.',
|
||||||
|
'Miscellaneous'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Documents to append as an appendix to all manuals.
|
||||||
|
#texinfo_appendices = []
|
||||||
|
|
||||||
|
# If false, no module index is generated.
|
||||||
|
#texinfo_domain_indices = True
|
||||||
|
|
||||||
|
# How to display URL addresses: 'footnote', 'no', or 'inline'.
|
||||||
|
#texinfo_show_urls = 'footnote'
|
21
docs/index.rst
Normal file
21
docs/index.rst
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
.. django-orchestra documentation master file, created by
|
||||||
|
sphinx-quickstart on Wed Aug 8 11:07:40 2012.
|
||||||
|
You can adapt this file completely to your liking, but it should at least
|
||||||
|
contain the root `toctree` directive.
|
||||||
|
|
||||||
|
Welcome to django-orchestra's documentation!
|
||||||
|
============================================
|
||||||
|
|
||||||
|
Contents:
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
|
||||||
|
|
||||||
|
Indices and tables
|
||||||
|
==================
|
||||||
|
|
||||||
|
* :ref:`genindex`
|
||||||
|
* :ref:`modindex`
|
||||||
|
* :ref:`search`
|
||||||
|
|
190
docs/make.bat
Normal file
190
docs/make.bat
Normal file
|
@ -0,0 +1,190 @@
|
||||||
|
@ECHO OFF
|
||||||
|
|
||||||
|
REM Command file for Sphinx documentation
|
||||||
|
|
||||||
|
if "%SPHINXBUILD%" == "" (
|
||||||
|
set SPHINXBUILD=sphinx-build
|
||||||
|
)
|
||||||
|
set BUILDDIR=_build
|
||||||
|
set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% .
|
||||||
|
set I18NSPHINXOPTS=%SPHINXOPTS% .
|
||||||
|
if NOT "%PAPER%" == "" (
|
||||||
|
set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
|
||||||
|
set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "" goto help
|
||||||
|
|
||||||
|
if "%1" == "help" (
|
||||||
|
:help
|
||||||
|
echo.Please use `make ^<target^>` where ^<target^> is one of
|
||||||
|
echo. html to make standalone HTML files
|
||||||
|
echo. dirhtml to make HTML files named index.html in directories
|
||||||
|
echo. singlehtml to make a single large HTML file
|
||||||
|
echo. pickle to make pickle files
|
||||||
|
echo. json to make JSON files
|
||||||
|
echo. htmlhelp to make HTML files and a HTML help project
|
||||||
|
echo. qthelp to make HTML files and a qthelp project
|
||||||
|
echo. devhelp to make HTML files and a Devhelp project
|
||||||
|
echo. epub to make an epub
|
||||||
|
echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
|
||||||
|
echo. text to make text files
|
||||||
|
echo. man to make manual pages
|
||||||
|
echo. texinfo to make Texinfo files
|
||||||
|
echo. gettext to make PO message catalogs
|
||||||
|
echo. changes to make an overview over all changed/added/deprecated items
|
||||||
|
echo. linkcheck to check all external links for integrity
|
||||||
|
echo. doctest to run all doctests embedded in the documentation if enabled
|
||||||
|
goto end
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "clean" (
|
||||||
|
for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
|
||||||
|
del /q /s %BUILDDIR%\*
|
||||||
|
goto end
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "html" (
|
||||||
|
%SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
echo.
|
||||||
|
echo.Build finished. The HTML pages are in %BUILDDIR%/html.
|
||||||
|
goto end
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "dirhtml" (
|
||||||
|
%SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
echo.
|
||||||
|
echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
|
||||||
|
goto end
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "singlehtml" (
|
||||||
|
%SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
echo.
|
||||||
|
echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
|
||||||
|
goto end
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "pickle" (
|
||||||
|
%SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
echo.
|
||||||
|
echo.Build finished; now you can process the pickle files.
|
||||||
|
goto end
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "json" (
|
||||||
|
%SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
echo.
|
||||||
|
echo.Build finished; now you can process the JSON files.
|
||||||
|
goto end
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "htmlhelp" (
|
||||||
|
%SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
echo.
|
||||||
|
echo.Build finished; now you can run HTML Help Workshop with the ^
|
||||||
|
.hhp project file in %BUILDDIR%/htmlhelp.
|
||||||
|
goto end
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "qthelp" (
|
||||||
|
%SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
echo.
|
||||||
|
echo.Build finished; now you can run "qcollectiongenerator" with the ^
|
||||||
|
.qhcp project file in %BUILDDIR%/qthelp, like this:
|
||||||
|
echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-orchestra.qhcp
|
||||||
|
echo.To view the help file:
|
||||||
|
echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-orchestra.ghc
|
||||||
|
goto end
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "devhelp" (
|
||||||
|
%SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
echo.
|
||||||
|
echo.Build finished.
|
||||||
|
goto end
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "epub" (
|
||||||
|
%SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
echo.
|
||||||
|
echo.Build finished. The epub file is in %BUILDDIR%/epub.
|
||||||
|
goto end
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "latex" (
|
||||||
|
%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
echo.
|
||||||
|
echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
|
||||||
|
goto end
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "text" (
|
||||||
|
%SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
echo.
|
||||||
|
echo.Build finished. The text files are in %BUILDDIR%/text.
|
||||||
|
goto end
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "man" (
|
||||||
|
%SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
echo.
|
||||||
|
echo.Build finished. The manual pages are in %BUILDDIR%/man.
|
||||||
|
goto end
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "texinfo" (
|
||||||
|
%SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
echo.
|
||||||
|
echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
|
||||||
|
goto end
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "gettext" (
|
||||||
|
%SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
echo.
|
||||||
|
echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
|
||||||
|
goto end
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "changes" (
|
||||||
|
%SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
echo.
|
||||||
|
echo.The overview file is in %BUILDDIR%/changes.
|
||||||
|
goto end
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "linkcheck" (
|
||||||
|
%SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
echo.
|
||||||
|
echo.Link check complete; look for any errors in the above output ^
|
||||||
|
or in %BUILDDIR%/linkcheck/output.txt.
|
||||||
|
goto end
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "doctest" (
|
||||||
|
%SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
echo.
|
||||||
|
echo.Testing of doctests in the sources finished, look at the ^
|
||||||
|
results in %BUILDDIR%/doctest/output.txt.
|
||||||
|
goto end
|
||||||
|
)
|
||||||
|
|
||||||
|
:end
|
23
orchestra/__init__.py
Normal file
23
orchestra/__init__.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
VERSION = (0, 0, 1, 'alpha', 1)
|
||||||
|
|
||||||
|
|
||||||
|
def get_version():
|
||||||
|
"Returns a PEP 386-compliant version number from VERSION."
|
||||||
|
assert len(VERSION) == 5
|
||||||
|
assert VERSION[3] in ('alpha', 'beta', 'rc', 'final')
|
||||||
|
|
||||||
|
# Now build the two parts of the version number:
|
||||||
|
# main = X.Y[.Z]
|
||||||
|
# sub = .devN - for pre-alpha releases
|
||||||
|
# | {a|b|c}N - for alpha, beta and rc releases
|
||||||
|
|
||||||
|
parts = 2 if VERSION[2] == 0 else 3
|
||||||
|
main = '.'.join(str(x) for x in VERSION[:parts])
|
||||||
|
|
||||||
|
sub = ''
|
||||||
|
|
||||||
|
if VERSION[3] != 'final':
|
||||||
|
mapping = {'alpha': 'a', 'beta': 'b', 'rc': 'c'}
|
||||||
|
sub = mapping[VERSION[3]] + str(VERSION[4])
|
||||||
|
|
||||||
|
return str(main + sub)
|
2
orchestra/admin/__init__.py
Normal file
2
orchestra/admin/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
from options import *
|
||||||
|
from dashboard import *
|
20
orchestra/admin/dashboard.py
Normal file
20
orchestra/admin/dashboard.py
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from orchestra.core import services
|
||||||
|
|
||||||
|
|
||||||
|
def generate_services_group():
|
||||||
|
models = []
|
||||||
|
for model, options in services.get().iteritems():
|
||||||
|
if options.get('menu', True):
|
||||||
|
models.append("%s.%s" % (model.__module__, model._meta.object_name))
|
||||||
|
|
||||||
|
settings.FLUENT_DASHBOARD_APP_GROUPS += (
|
||||||
|
('Services', {
|
||||||
|
'models': models,
|
||||||
|
'collapsible': True
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
generate_services_group()
|
55
orchestra/admin/decorators.py
Normal file
55
orchestra/admin/decorators.py
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.admin import helpers
|
||||||
|
from django.template.response import TemplateResponse
|
||||||
|
from django.utils.decorators import available_attrs
|
||||||
|
from django.utils.encoding import force_text
|
||||||
|
|
||||||
|
|
||||||
|
def action_with_confirmation(action_name, extra_context={},
|
||||||
|
template='admin/controller/generic_confirmation.html'):
|
||||||
|
"""
|
||||||
|
Generic pattern for actions that needs confirmation step
|
||||||
|
If custom template is provided the form must contain:
|
||||||
|
<input type="hidden" name="post" value="generic_confirmation" />
|
||||||
|
"""
|
||||||
|
def decorator(func, extra_context=extra_context, template=template):
|
||||||
|
@wraps(func, assigned=available_attrs(func))
|
||||||
|
def inner(modeladmin, request, queryset):
|
||||||
|
# The user has already confirmed the action.
|
||||||
|
if request.POST.get('post') == "generic_confirmation":
|
||||||
|
stay = func(modeladmin, request, queryset)
|
||||||
|
if not stay:
|
||||||
|
return
|
||||||
|
|
||||||
|
opts = modeladmin.model._meta
|
||||||
|
app_label = opts.app_label
|
||||||
|
action_value = func.__name__
|
||||||
|
|
||||||
|
if len(queryset) == 1:
|
||||||
|
objects_name = force_text(opts.verbose_name)
|
||||||
|
else:
|
||||||
|
objects_name = force_text(opts.verbose_name_plural)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"title": "Are you sure?",
|
||||||
|
"content_message": "Are you sure you want to %s the selected %s?" %
|
||||||
|
(action_name, objects_name),
|
||||||
|
"action_name": action_name.capitalize(),
|
||||||
|
"action_value": action_value,
|
||||||
|
"deletable_objects": queryset,
|
||||||
|
'queryset': queryset,
|
||||||
|
"opts": opts,
|
||||||
|
"app_label": app_label,
|
||||||
|
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
|
||||||
|
}
|
||||||
|
|
||||||
|
context.update(extra_context)
|
||||||
|
|
||||||
|
# Display the confirmation page
|
||||||
|
return TemplateResponse(request, template,
|
||||||
|
context, current_app=modeladmin.admin_site.name)
|
||||||
|
return inner
|
||||||
|
return decorator
|
||||||
|
|
10
orchestra/admin/html.py
Normal file
10
orchestra/admin/html.py
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
|
|
||||||
|
MONOSPACE_FONTS = ('Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,'
|
||||||
|
'Bitstream Vera Sans Mono,Courier New,monospace')
|
||||||
|
|
||||||
|
|
||||||
|
def monospace_format(text):
|
||||||
|
style="font-family:%s;padding-left:110px;" % MONOSPACE_FONTS
|
||||||
|
return mark_safe('<pre style="%s">%s</pre>' % (style, text))
|
96
orchestra/admin/menu.py
Normal file
96
orchestra/admin/menu.py
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
from admin_tools.menu import items, Menu
|
||||||
|
from django.core.urlresolvers import reverse
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from orchestra.core import services
|
||||||
|
from orchestra.utils.apps import isinstalled
|
||||||
|
|
||||||
|
|
||||||
|
def api_link(context):
|
||||||
|
""" Dynamically generates API related URL """
|
||||||
|
if 'opts' in context:
|
||||||
|
opts = context['opts']
|
||||||
|
elif 'cl' in context:
|
||||||
|
opts = context['cl'].opts
|
||||||
|
else:
|
||||||
|
return reverse('api-root')
|
||||||
|
if 'object_id' in context:
|
||||||
|
object_id = context['object_id']
|
||||||
|
try:
|
||||||
|
return reverse('%s-detail' % opts.module_name, args=[object_id])
|
||||||
|
except:
|
||||||
|
return reverse('api-root')
|
||||||
|
try:
|
||||||
|
return reverse('%s-list' % opts.module_name)
|
||||||
|
except:
|
||||||
|
return reverse('api-root')
|
||||||
|
|
||||||
|
|
||||||
|
def get_services():
|
||||||
|
result = []
|
||||||
|
for model, options in services.get().iteritems():
|
||||||
|
if options.get('menu', True):
|
||||||
|
opts = model._meta
|
||||||
|
url = reverse('admin:%s_%s_changelist' % (opts.app_label, opts.model_name))
|
||||||
|
result.append(items.MenuItem(options.get('verbose_name_plural'), url))
|
||||||
|
return sorted(result, key=lambda i: i.title)
|
||||||
|
|
||||||
|
|
||||||
|
def get_accounts():
|
||||||
|
accounts = [
|
||||||
|
items.MenuItem(_("Accounts"), reverse('admin:accounts_account_changelist'))
|
||||||
|
]
|
||||||
|
if isinstalled('orchestra.apps.contacts'):
|
||||||
|
url = reverse('admin:contacts_contact_changelist')
|
||||||
|
accounts.append(items.MenuItem(_("Contacts"), url))
|
||||||
|
if isinstalled('orchestra.apps.users'):
|
||||||
|
url = reverse('admin:users_user_changelist')
|
||||||
|
users = [items.MenuItem(_("Users"), url)]
|
||||||
|
if isinstalled('rest_framework.authtoken'):
|
||||||
|
tokens = reverse('admin:authtoken_token_changelist')
|
||||||
|
users.append(items.MenuItem(_("Tokens"), tokens))
|
||||||
|
accounts.append(items.MenuItem(_("Users"), url, children=users))
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
|
||||||
|
def get_administration():
|
||||||
|
administration = []
|
||||||
|
return administration
|
||||||
|
|
||||||
|
|
||||||
|
def get_administration_models():
|
||||||
|
administration_models = []
|
||||||
|
if isinstalled('orchestra.apps.orchestration'):
|
||||||
|
administration_models.append('orchestra.apps.orchestration.*')
|
||||||
|
if isinstalled('djcelery'):
|
||||||
|
administration_models.append('djcelery.*')
|
||||||
|
if isinstalled('orchestra.apps.issues'):
|
||||||
|
administration_models.append('orchestra.apps.issues.*')
|
||||||
|
return administration_models
|
||||||
|
|
||||||
|
|
||||||
|
class OrchestraMenu(Menu):
|
||||||
|
def init_with_context(self, context):
|
||||||
|
self.children += [
|
||||||
|
items.MenuItem(
|
||||||
|
_('Dashboard'),
|
||||||
|
reverse('admin:index')
|
||||||
|
),
|
||||||
|
items.Bookmarks(),
|
||||||
|
items.MenuItem(
|
||||||
|
_("Services"),
|
||||||
|
reverse('admin:index'),
|
||||||
|
children=get_services()
|
||||||
|
),
|
||||||
|
items.MenuItem(
|
||||||
|
_("Accounts"),
|
||||||
|
reverse('admin:accounts_account_changelist'),
|
||||||
|
children=get_accounts()
|
||||||
|
),
|
||||||
|
items.AppList(
|
||||||
|
_("Administration"),
|
||||||
|
models=get_administration_models(),
|
||||||
|
children=get_administration()
|
||||||
|
),
|
||||||
|
items.MenuItem("API", api_link(context))
|
||||||
|
]
|
76
orchestra/admin/options.py
Normal file
76
orchestra/admin/options.py
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
from django import forms
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.forms.models import BaseInlineFormSet
|
||||||
|
|
||||||
|
from .utils import set_default_filter
|
||||||
|
|
||||||
|
|
||||||
|
class ExtendedModelAdmin(admin.ModelAdmin):
|
||||||
|
add_fields = ()
|
||||||
|
add_fieldsets = ()
|
||||||
|
add_form = None
|
||||||
|
change_readonly_fields = ()
|
||||||
|
|
||||||
|
def get_readonly_fields(self, request, obj=None):
|
||||||
|
fields = super(ExtendedModelAdmin, self).get_readonly_fields(request, obj=obj)
|
||||||
|
if obj:
|
||||||
|
return fields + self.change_readonly_fields
|
||||||
|
return fields
|
||||||
|
|
||||||
|
def get_fieldsets(self, request, obj=None):
|
||||||
|
if not obj:
|
||||||
|
if self.add_fieldsets:
|
||||||
|
return self.add_fieldsets
|
||||||
|
elif self.add_fields:
|
||||||
|
return [(None, {'fields': self.add_fields})]
|
||||||
|
return super(ExtendedModelAdmin, self).get_fieldsets(request, obj=obj)
|
||||||
|
|
||||||
|
def get_inline_instances(self, request, obj=None):
|
||||||
|
""" add_inlines and inline.parent_object """
|
||||||
|
self.inlines = getattr(self, 'add_inlines', self.inlines)
|
||||||
|
if obj:
|
||||||
|
self.inlines = type(self).inlines
|
||||||
|
inlines = super(ExtendedModelAdmin, self).get_inline_instances(request, obj=obj)
|
||||||
|
for inline in inlines:
|
||||||
|
inline.parent_object = obj
|
||||||
|
return inlines
|
||||||
|
|
||||||
|
def get_form(self, request, obj=None, **kwargs):
|
||||||
|
""" Use special form during user creation """
|
||||||
|
defaults = {}
|
||||||
|
if obj is None and self.add_form:
|
||||||
|
defaults['form'] = self.add_form
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return super(ExtendedModelAdmin, self).get_form(request, obj, **defaults)
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeListDefaultFilter(object):
|
||||||
|
"""
|
||||||
|
Enables support for default filtering on admin change list pages
|
||||||
|
Your model admin class should define an default_changelist_filters attribute
|
||||||
|
default_changelist_filters = (('my_nodes', 'True'),)
|
||||||
|
"""
|
||||||
|
default_changelist_filters = ()
|
||||||
|
|
||||||
|
def changelist_view(self, request, extra_context=None):
|
||||||
|
""" Default filter as 'my_nodes=True' """
|
||||||
|
defaults = []
|
||||||
|
for queryarg, value in self.default_changelist_filters:
|
||||||
|
set_default_filter(queryarg, request, value)
|
||||||
|
defaults.append(queryarg)
|
||||||
|
# hack response cl context in order to hook default filter awaearness into search_form.html template
|
||||||
|
response = super(ChangeListDefaultFilter, self).changelist_view(request, extra_context=extra_context)
|
||||||
|
if hasattr(response, 'context_data') and 'cl' in response.context_data:
|
||||||
|
response.context_data['cl'].default_changelist_filters = defaults
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class AtLeastOneRequiredInlineFormSet(BaseInlineFormSet):
|
||||||
|
def clean(self):
|
||||||
|
"""Check that at least one service has been entered."""
|
||||||
|
super(AtLeastOneRequiredInlineFormSet, self).clean()
|
||||||
|
if any(self.errors):
|
||||||
|
return
|
||||||
|
if not any(cleaned_data and not cleaned_data.get('DELETE', False)
|
||||||
|
for cleaned_data in self.cleaned_data):
|
||||||
|
raise forms.ValidationError('At least one item required.')
|
133
orchestra/admin/utils.py
Normal file
133
orchestra/admin/utils.py
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
from functools import update_wrapper
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.core.urlresolvers import reverse
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import importlib
|
||||||
|
from django.utils.html import escape
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from orchestra.models.utils import get_field_value
|
||||||
|
from orchestra.utils.time import timesince, timeuntil
|
||||||
|
|
||||||
|
|
||||||
|
def get_modeladmin(model, import_module=True):
|
||||||
|
""" returns the modeladmin registred for model """
|
||||||
|
for k,v in admin.site._registry.iteritems():
|
||||||
|
if k is model:
|
||||||
|
return v
|
||||||
|
if import_module:
|
||||||
|
# Sometimes the admin module is not yet imported
|
||||||
|
app_label = model._meta.app_label
|
||||||
|
for app in settings.INSTALLED_APPS:
|
||||||
|
if app.endswith(app_label):
|
||||||
|
app_label = app
|
||||||
|
importlib.import_module('%s.%s' % (app_label, 'admin'))
|
||||||
|
return get_modeladmin(model, import_module=False)
|
||||||
|
|
||||||
|
|
||||||
|
def insertattr(model, name, value, weight=0):
|
||||||
|
""" Inserts attribute to a modeladmin """
|
||||||
|
is_model = models.Model in model.__mro__
|
||||||
|
modeladmin = get_modeladmin(model) if is_model else model
|
||||||
|
# Avoid inlines defined on parent class be shared between subclasses
|
||||||
|
# Seems that if we use tuples they are lost in some conditions like changing
|
||||||
|
# the tuple in modeladmin.__init__
|
||||||
|
if not getattr(modeladmin, name):
|
||||||
|
setattr(type(modeladmin), name, [])
|
||||||
|
|
||||||
|
inserted_attrs = getattr(modeladmin, '__inserted_attrs__', {})
|
||||||
|
if not name in inserted_attrs:
|
||||||
|
weights = {}
|
||||||
|
if hasattr(modeladmin, 'weights') and name in modeladmin.weights:
|
||||||
|
weights = modeladmin.weights.get(name)
|
||||||
|
inserted_attrs[name] = [ (attr, weights.get(attr, 0)) for attr in getattr(modeladmin, name) ]
|
||||||
|
|
||||||
|
inserted_attrs[name].append((value, weight))
|
||||||
|
inserted_attrs[name].sort(key=lambda a: a[1])
|
||||||
|
setattr(modeladmin, name, [ attr[0] for attr in inserted_attrs[name] ])
|
||||||
|
setattr(modeladmin, '__inserted_attrs__', inserted_attrs)
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_admin_view(modeladmin, view):
|
||||||
|
""" Add admin authentication to view """
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
return modeladmin.admin_site.admin_view(view)(*args, **kwargs)
|
||||||
|
return update_wrapper(wrapper, view)
|
||||||
|
|
||||||
|
|
||||||
|
def set_default_filter(queryarg, request, value):
|
||||||
|
""" set default filters for changelist_view """
|
||||||
|
if queryarg not in request.GET:
|
||||||
|
q = request.GET.copy()
|
||||||
|
if callable(value):
|
||||||
|
value = value(request)
|
||||||
|
q[queryarg] = value
|
||||||
|
request.GET = q
|
||||||
|
request.META['QUERY_STRING'] = request.GET.urlencode()
|
||||||
|
|
||||||
|
|
||||||
|
def link(*args, **kwargs):
|
||||||
|
""" utility function for creating admin links """
|
||||||
|
field = args[0] if args else ''
|
||||||
|
order = kwargs.pop('order', field)
|
||||||
|
popup = kwargs.pop('popup', False)
|
||||||
|
|
||||||
|
def display_link(self, instance):
|
||||||
|
obj = getattr(instance, field, instance)
|
||||||
|
if not getattr(obj, 'pk', False):
|
||||||
|
return '---'
|
||||||
|
opts = obj._meta
|
||||||
|
view_name = 'admin:%s_%s_change' % (opts.app_label, opts.model_name)
|
||||||
|
url = reverse(view_name, args=(obj.pk,))
|
||||||
|
extra = ''
|
||||||
|
if popup:
|
||||||
|
extra = 'onclick="return showAddAnotherPopup(this);"'
|
||||||
|
return '<a href="%s" %s>%s</a>' % (url, extra, obj)
|
||||||
|
display_link.allow_tags = True
|
||||||
|
display_link.short_description = _(field)
|
||||||
|
display_link.admin_order_field = order
|
||||||
|
return display_link
|
||||||
|
|
||||||
|
|
||||||
|
def colored(field_name, colours, description='', verbose=False, bold=True):
|
||||||
|
""" returns a method that will render obj with colored html """
|
||||||
|
def colored_field(obj, field=field_name, colors=colours, verbose=verbose):
|
||||||
|
value = escape(get_field_value(obj, field))
|
||||||
|
color = colors.get(value, "black")
|
||||||
|
if verbose:
|
||||||
|
# Get the human-readable value of a choice field
|
||||||
|
value = getattr(obj, 'get_%s_display' % field)()
|
||||||
|
colored_value = '<span style="color: %s;">%s</span>' % (color, value)
|
||||||
|
if bold:
|
||||||
|
colored_value = '<b>%s</b>' % colored_value
|
||||||
|
return mark_safe(colored_value)
|
||||||
|
if not description:
|
||||||
|
description = field_name.split('__').pop().replace('_', ' ').capitalize()
|
||||||
|
colored_field.short_description = description
|
||||||
|
colored_field.allow_tags = True
|
||||||
|
colored_field.admin_order_field = field_name
|
||||||
|
return colored_field
|
||||||
|
|
||||||
|
|
||||||
|
def display_timesince(date, double=False):
|
||||||
|
"""
|
||||||
|
Format date for messages create_on: show a relative time
|
||||||
|
with contextual helper to show fulltime format.
|
||||||
|
"""
|
||||||
|
if not date:
|
||||||
|
return 'Never'
|
||||||
|
date_rel = timesince(date)
|
||||||
|
if not double:
|
||||||
|
date_rel = date_rel.split(',')[0]
|
||||||
|
date_rel += ' ago'
|
||||||
|
date_abs = date.strftime("%Y-%m-%d %H:%M:%S %Z")
|
||||||
|
return mark_safe("<span title='%s'>%s</span>" % (date_abs, date_rel))
|
||||||
|
|
||||||
|
|
||||||
|
def display_timeuntil(date):
|
||||||
|
date_rel = timeuntil(date) + ' left'
|
||||||
|
date_abs = date.strftime("%Y-%m-%d %H:%M:%S %Z")
|
||||||
|
return mark_safe("<span title='%s'>%s</span>" % (date_abs, date_rel))
|
2
orchestra/api/__init__.py
Normal file
2
orchestra/api/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
from options import *
|
||||||
|
from actions import *
|
18
orchestra/api/actions.py
Normal file
18
orchestra/api/actions.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from .serializers import SetPasswordSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class SetPasswordApiMixin(object):
|
||||||
|
@action(serializer_class=SetPasswordSerializer)
|
||||||
|
def set_password(self, request, pk):
|
||||||
|
obj = self.get_object()
|
||||||
|
serializer = SetPasswordSerializer(data=request.DATA)
|
||||||
|
if serializer.is_valid():
|
||||||
|
obj.set_password(serializer.data['password'])
|
||||||
|
obj.save()
|
||||||
|
return Response({'status': 'password changed'})
|
||||||
|
else:
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
51
orchestra/api/fields.py
Normal file
51
orchestra/api/fields.py
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from rest_framework import serializers, exceptions
|
||||||
|
|
||||||
|
|
||||||
|
class OptionField(serializers.WritableField):
|
||||||
|
"""
|
||||||
|
Dict-like representation of a OptionField
|
||||||
|
A bit hacky, objects get deleted on from_native method and Serializer will
|
||||||
|
need a custom override of restore_object method.
|
||||||
|
"""
|
||||||
|
def to_native(self, value):
|
||||||
|
""" dict-like representation of a Property Model"""
|
||||||
|
return dict((prop.name, prop.value) for prop in value.all())
|
||||||
|
|
||||||
|
def from_native(self, value):
|
||||||
|
""" Convert a dict-like representation back to a WebOptionField """
|
||||||
|
parent = self.parent
|
||||||
|
related_manager = getattr(parent.object, self.source or 'options', False)
|
||||||
|
properties = serializers.RelationsList()
|
||||||
|
if value:
|
||||||
|
model = getattr(parent.opts.model, self.source or 'options').related.model
|
||||||
|
if isinstance(value, basestring):
|
||||||
|
try:
|
||||||
|
value = json.loads(value)
|
||||||
|
except:
|
||||||
|
raise exceptions.ParseError("Malformed property: %s" % str(value))
|
||||||
|
if not related_manager:
|
||||||
|
# POST (new parent object)
|
||||||
|
return [ model(name=n, value=v) for n,v in value.iteritems() ]
|
||||||
|
# PUT
|
||||||
|
to_save = []
|
||||||
|
for (name, value) in value.iteritems():
|
||||||
|
try:
|
||||||
|
# Update existing property
|
||||||
|
prop = related_manager.get(name=name)
|
||||||
|
except model.DoesNotExist:
|
||||||
|
# Create a new one
|
||||||
|
prop = model(name=name, value=value)
|
||||||
|
else:
|
||||||
|
prop.value = value
|
||||||
|
to_save.append(prop.pk)
|
||||||
|
properties.append(prop)
|
||||||
|
|
||||||
|
# Discart old values
|
||||||
|
if related_manager:
|
||||||
|
properties._deleted = [] # Redefine class attribute
|
||||||
|
for obj in related_manager.all():
|
||||||
|
if not value or obj.pk not in to_save:
|
||||||
|
properties._deleted.append(obj)
|
||||||
|
return properties
|
52
orchestra/api/helpers.py
Normal file
52
orchestra/api/helpers.py
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
from django.core.urlresolvers import NoReverseMatch
|
||||||
|
from rest_framework.reverse import reverse
|
||||||
|
from rest_framework.routers import replace_methodname
|
||||||
|
|
||||||
|
|
||||||
|
def replace_collectionmethodname(format_string, methodname):
|
||||||
|
ret = replace_methodname(format_string, methodname)
|
||||||
|
ret = ret.replace('{collectionmethodname}', methodname)
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def link_wrap(view, view_names):
|
||||||
|
def wrapper(self, request, view=view, *args, **kwargs):
|
||||||
|
""" wrapper function that inserts HTTP links on view """
|
||||||
|
links = []
|
||||||
|
for name in view_names:
|
||||||
|
try:
|
||||||
|
url = reverse(name, request=self.request)
|
||||||
|
except NoReverseMatch:
|
||||||
|
url = reverse(name, args, kwargs, request=request)
|
||||||
|
links.append('<%s>; rel="%s"' % (url, name))
|
||||||
|
response = view(self, request, *args, **kwargs)
|
||||||
|
response['Link'] = ', '.join(links)
|
||||||
|
return response
|
||||||
|
for attr in dir(view):
|
||||||
|
try:
|
||||||
|
setattr(wrapper, attr, getattr(view, attr))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def insert_links(viewset, base_name):
|
||||||
|
collection_links = ['api-root', '%s-list' % base_name]
|
||||||
|
object_links = ['api-root', '%s-list' % base_name, '%s-detail' % base_name]
|
||||||
|
exception_links = ['api-root']
|
||||||
|
list_links = ['api-root']
|
||||||
|
retrieve_links = ['api-root', '%s-list' % base_name]
|
||||||
|
# Determine any `@action` or `@link` decorated methods on the viewset
|
||||||
|
for methodname in dir(viewset):
|
||||||
|
method = getattr(viewset, methodname)
|
||||||
|
view_name = '%s-%s' % (base_name, methodname.replace('_', '-'))
|
||||||
|
if hasattr(method, 'collection_bind_to_methods'):
|
||||||
|
list_links.append(view_name)
|
||||||
|
retrieve_links.append(view_name)
|
||||||
|
setattr(viewset, methodname, link_wrap(method, collection_links))
|
||||||
|
elif hasattr(method, 'bind_to_methods'):
|
||||||
|
retrieve_links.append(view_name)
|
||||||
|
setattr(viewset, methodname, link_wrap(method, object_links))
|
||||||
|
viewset.handle_exception = link_wrap(viewset.handle_exception, exception_links)
|
||||||
|
viewset.list = link_wrap(viewset.list, list_links)
|
||||||
|
viewset.retrieve = link_wrap(viewset.retrieve, retrieve_links)
|
139
orchestra/api/options.py
Normal file
139
orchestra/api/options.py
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
from django import conf
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
from rest_framework import views
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.reverse import reverse
|
||||||
|
from rest_framework.routers import DefaultRouter, Route, flatten, replace_methodname
|
||||||
|
|
||||||
|
from .. import settings
|
||||||
|
from ..utils.apps import autodiscover as module_autodiscover
|
||||||
|
from .helpers import insert_links, replace_collectionmethodname
|
||||||
|
|
||||||
|
|
||||||
|
def collectionlink(**kwargs):
|
||||||
|
"""
|
||||||
|
Used to mark a method on a ViewSet collection that should be routed for GET requests.
|
||||||
|
"""
|
||||||
|
def decorator(func):
|
||||||
|
func.collection_bind_to_methods = ['get']
|
||||||
|
func.kwargs = kwargs
|
||||||
|
return func
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
class LinkHeaderRouter(DefaultRouter):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
""" collection view method route """
|
||||||
|
super(LinkHeaderRouter, self).__init__(*args, **kwargs)
|
||||||
|
self.routes.insert(0, Route(
|
||||||
|
url=r'^{prefix}/{collectionmethodname}{trailing_slash}$',
|
||||||
|
mapping={
|
||||||
|
'{httpmethod}': '{collectionmethodname}',
|
||||||
|
},
|
||||||
|
name='{basename}-{methodnamehyphen}',
|
||||||
|
initkwargs={}
|
||||||
|
))
|
||||||
|
|
||||||
|
def get_routes(self, viewset):
|
||||||
|
""" allow links and actions to be bound to a collection view """
|
||||||
|
known_actions = flatten([route.mapping.values() for route in self.routes])
|
||||||
|
dynamic_routes = []
|
||||||
|
collection_dynamic_routes = []
|
||||||
|
for methodname in dir(viewset):
|
||||||
|
attr = getattr(viewset, methodname)
|
||||||
|
bind = getattr(attr, 'bind_to_methods', None)
|
||||||
|
httpmethods = getattr(attr, 'collection_bind_to_methods', bind)
|
||||||
|
if httpmethods:
|
||||||
|
if methodname in known_actions:
|
||||||
|
msg = ('Cannot use @action or @link decorator on method "%s" '
|
||||||
|
'as it is an existing route' % methodname)
|
||||||
|
raise ImproperlyConfigured(msg)
|
||||||
|
httpmethods = [method.lower() for method in httpmethods]
|
||||||
|
if bind:
|
||||||
|
dynamic_routes.append((httpmethods, methodname))
|
||||||
|
else:
|
||||||
|
collection_dynamic_routes.append((httpmethods, methodname))
|
||||||
|
|
||||||
|
ret = []
|
||||||
|
for route in self.routes:
|
||||||
|
# Dynamic routes (@link or @action decorator)
|
||||||
|
if route.mapping == {'{httpmethod}': '{methodname}'}:
|
||||||
|
replace = replace_methodname
|
||||||
|
routes = dynamic_routes
|
||||||
|
elif route.mapping == {'{httpmethod}': '{collectionmethodname}'}:
|
||||||
|
replace = replace_collectionmethodname
|
||||||
|
routes = collection_dynamic_routes
|
||||||
|
else:
|
||||||
|
ret.append(route)
|
||||||
|
continue
|
||||||
|
for httpmethods, methodname in routes:
|
||||||
|
initkwargs = route.initkwargs.copy()
|
||||||
|
initkwargs.update(getattr(viewset, methodname).kwargs)
|
||||||
|
ret.append(Route(
|
||||||
|
url=replace(route.url, methodname),
|
||||||
|
mapping={ httpmethod: methodname for httpmethod in httpmethods },
|
||||||
|
name=replace(route.name, methodname),
|
||||||
|
initkwargs=initkwargs,
|
||||||
|
))
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def get_api_root_view(self):
|
||||||
|
""" returns the root view, with all the linked collections """
|
||||||
|
class APIRoot(views.APIView):
|
||||||
|
def get(instance, request, format=None):
|
||||||
|
root_url = reverse('api-root', request=request, format=format)
|
||||||
|
token_url = reverse('api-token-auth', request=request, format=format)
|
||||||
|
links = [
|
||||||
|
'<%s>; rel="%s"' % (root_url, 'api-root'),
|
||||||
|
'<%s>; rel="%s"' % (token_url, 'api-get-auth-token'),
|
||||||
|
]
|
||||||
|
if not request.user.is_anonymous():
|
||||||
|
list_name = '{basename}-list'
|
||||||
|
detail_name = '{basename}-detail'
|
||||||
|
for prefix, viewset, basename in self.registry:
|
||||||
|
singleton_pk = getattr(viewset, 'singleton_pk', False)
|
||||||
|
if singleton_pk:
|
||||||
|
url_name = detail_name.format(basename=basename)
|
||||||
|
kwargs = { 'pk': singleton_pk(viewset(), request) }
|
||||||
|
else:
|
||||||
|
url_name = list_name.format(basename=basename)
|
||||||
|
kwargs = {}
|
||||||
|
url = reverse(url_name, request=request, format=format, kwargs=kwargs)
|
||||||
|
links.append('<%s>; rel="%s"' % (url, url_name))
|
||||||
|
# Add user link
|
||||||
|
url_name = detail_name.format(basename='user')
|
||||||
|
kwargs = { 'pk': request.user.pk }
|
||||||
|
url = reverse(url_name, request=request, format=format, kwargs=kwargs)
|
||||||
|
links.append('<%s>; rel="%s"' % (url, url_name))
|
||||||
|
headers = { 'Link': ', '.join(links) }
|
||||||
|
content = {
|
||||||
|
name: getattr(settings, name, None)
|
||||||
|
for name in ['SITE_NAME', 'SITE_VERBOSE_NAME']
|
||||||
|
}
|
||||||
|
content['INSTALLED_APPS'] = conf.settings.INSTALLED_APPS
|
||||||
|
return Response(content, headers=headers)
|
||||||
|
return APIRoot.as_view()
|
||||||
|
|
||||||
|
def register(self, prefix, viewset, base_name=None):
|
||||||
|
""" inserts link headers on every viewset """
|
||||||
|
if base_name is None:
|
||||||
|
base_name = self.get_default_base_name(viewset)
|
||||||
|
insert_links(viewset, base_name)
|
||||||
|
self.registry.append((prefix, viewset, base_name))
|
||||||
|
|
||||||
|
def insert(self, prefix, name, field, **kwargs):
|
||||||
|
""" Dynamically add new fields to an existing serializer """
|
||||||
|
for _prefix, viewset, basename in self.registry:
|
||||||
|
if _prefix == prefix:
|
||||||
|
if viewset.serializer_class is None:
|
||||||
|
viewset.serializer_class = viewset().get_serializer_class()
|
||||||
|
viewset.serializer_class.Meta.fields += (name,)
|
||||||
|
viewset.serializer_class.base_fields.update({name: field(**kwargs)})
|
||||||
|
setattr(viewset, 'inserted', getattr(viewset, 'inserted', []))
|
||||||
|
viewset.inserted.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
# Create a router and register our viewsets with it.
|
||||||
|
router = LinkHeaderRouter()
|
||||||
|
|
||||||
|
autodiscover = lambda: (module_autodiscover('api'), module_autodiscover('serializers'))
|
33
orchestra/api/serializers.py
Normal file
33
orchestra/api/serializers.py
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
from django.forms import widgets
|
||||||
|
from django.utils.translation import ugettext, ugettext_lazy as _
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from ..core.validators import validate_password
|
||||||
|
|
||||||
|
|
||||||
|
class SetPasswordSerializer(serializers.Serializer):
|
||||||
|
password = serializers.CharField(max_length=128, label=_('Password'),
|
||||||
|
widget=widgets.PasswordInput, validators=[validate_password])
|
||||||
|
|
||||||
|
|
||||||
|
class MultiSelectField(serializers.ChoiceField):
|
||||||
|
widget = widgets.CheckboxSelectMultiple
|
||||||
|
|
||||||
|
def field_from_native(self, data, files, field_name, into):
|
||||||
|
""" convert multiselect data into comma separated string """
|
||||||
|
if field_name in data:
|
||||||
|
data = data.copy()
|
||||||
|
try:
|
||||||
|
# data is a querydict when using forms
|
||||||
|
data[field_name] = ','.join(data.getlist(field_name))
|
||||||
|
except AttributeError:
|
||||||
|
data[field_name] = ','.join(data[field_name])
|
||||||
|
return super(MultiSelectField, self).field_from_native(data, files, field_name, into)
|
||||||
|
|
||||||
|
def valid_value(self, value):
|
||||||
|
""" checks for each item if is a valid value """
|
||||||
|
for val in value.split(','):
|
||||||
|
valid = super(MultiSelectField, self).valid_value(val)
|
||||||
|
if not valid:
|
||||||
|
return False
|
||||||
|
return True
|
0
orchestra/apps/__init__.py
Normal file
0
orchestra/apps/__init__.py
Normal file
0
orchestra/apps/accounts/__init__.py
Normal file
0
orchestra/apps/accounts/__init__.py
Normal file
212
orchestra/apps/accounts/admin.py
Normal file
212
orchestra/apps/accounts/admin.py
Normal file
|
@ -0,0 +1,212 @@
|
||||||
|
from django import forms
|
||||||
|
from django.conf.urls import patterns, url
|
||||||
|
from django.contrib import admin, messages
|
||||||
|
from django.contrib.admin.util import unquote
|
||||||
|
from django.core.urlresolvers import reverse
|
||||||
|
from django.http import HttpResponseRedirect
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from orchestra.admin import ExtendedModelAdmin
|
||||||
|
from orchestra.admin.utils import wrap_admin_view, link
|
||||||
|
from orchestra.core import services
|
||||||
|
|
||||||
|
from .filters import HasMainUserListFilter
|
||||||
|
from .forms import AccountCreationForm, AccountChangeForm
|
||||||
|
from .models import Account
|
||||||
|
|
||||||
|
|
||||||
|
class AccountAdmin(ExtendedModelAdmin):
|
||||||
|
list_display = ('name', 'user_link', 'type', 'is_active')
|
||||||
|
list_filter = (
|
||||||
|
'type', 'is_active', HasMainUserListFilter
|
||||||
|
)
|
||||||
|
add_fieldsets = (
|
||||||
|
(_("User"), {
|
||||||
|
'fields': ('username', 'password1', 'password2',),
|
||||||
|
}),
|
||||||
|
(_("Account info"), {
|
||||||
|
'fields': (('type', 'language'), 'comments'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
fieldsets = (
|
||||||
|
(_("User"), {
|
||||||
|
'fields': ('user_link', 'password',),
|
||||||
|
}),
|
||||||
|
(_("Account info"), {
|
||||||
|
'fields': (('type', 'language'), 'comments'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
readonly_fields = ('user_link',)
|
||||||
|
search_fields = ('users__username',)
|
||||||
|
add_form = AccountCreationForm
|
||||||
|
form = AccountChangeForm
|
||||||
|
|
||||||
|
user_link = link('user', order='user__username')
|
||||||
|
|
||||||
|
def name(self, account):
|
||||||
|
return account.name
|
||||||
|
name.admin_order_field = 'user__username'
|
||||||
|
|
||||||
|
def formfield_for_dbfield(self, db_field, **kwargs):
|
||||||
|
""" Make value input widget bigger """
|
||||||
|
if db_field.name == 'comments':
|
||||||
|
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 4})
|
||||||
|
return super(AccountAdmin, self).formfield_for_dbfield(db_field, **kwargs)
|
||||||
|
|
||||||
|
def change_view(self, request, object_id, form_url='', extra_context=None):
|
||||||
|
if request.method == 'GET':
|
||||||
|
account = self.get_object(request, unquote(object_id))
|
||||||
|
if not account.is_active:
|
||||||
|
messages.warning(request, 'This account is disabled.')
|
||||||
|
context = {
|
||||||
|
'services': sorted(
|
||||||
|
[ model._meta for model in services.get().keys() ],
|
||||||
|
key=lambda i: i.verbose_name_plural.lower()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
context.update(extra_context or {})
|
||||||
|
return super(AccountAdmin, self).change_view(request, object_id,
|
||||||
|
form_url=form_url, extra_context=context)
|
||||||
|
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
""" Save user and account, they are interdependent """
|
||||||
|
obj.user.save()
|
||||||
|
obj.user_id = obj.user.pk
|
||||||
|
obj.save()
|
||||||
|
obj.user.account = obj
|
||||||
|
obj.user.save()
|
||||||
|
|
||||||
|
def queryset(self, request):
|
||||||
|
""" Select related for performance """
|
||||||
|
# TODO move invoicecontact to contacts
|
||||||
|
related = ('user', 'invoicecontact')
|
||||||
|
return super(AccountAdmin, self).queryset(request).select_related(*related)
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(Account, AccountAdmin)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountListAdmin(AccountAdmin):
|
||||||
|
""" Account list to allow account selection when creating new services """
|
||||||
|
list_display = ('select_account', 'type', 'user')
|
||||||
|
actions = None
|
||||||
|
search_fields = ['user__username',]
|
||||||
|
ordering = ('user__username',)
|
||||||
|
|
||||||
|
def select_account(self, instance):
|
||||||
|
context = {
|
||||||
|
'url': '../?account=' + str(instance.pk),
|
||||||
|
'name': instance.name
|
||||||
|
}
|
||||||
|
return '<a href="%(url)s">%(name)s</a>' % context
|
||||||
|
select_account.short_description = _("account")
|
||||||
|
select_account.allow_tags = True
|
||||||
|
select_account.order_admin_field = 'user__username'
|
||||||
|
|
||||||
|
def changelist_view(self, request, extra_context=None):
|
||||||
|
opts = self.model._meta
|
||||||
|
original_app_label = request.META['PATH_INFO'].split('/')[-5]
|
||||||
|
original_model = request.META['PATH_INFO'].split('/')[-4]
|
||||||
|
context = {
|
||||||
|
'title': _("Select account for adding a new %s") % (original_model),
|
||||||
|
'original_app_label': original_app_label,
|
||||||
|
'original_model': original_model,
|
||||||
|
}
|
||||||
|
context.update(extra_context or {})
|
||||||
|
return super(AccountListAdmin, self).changelist_view(request,
|
||||||
|
extra_context=context)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountAdminMixin(object):
|
||||||
|
""" Provide basic account support to ModelAdmin and AdminInline classes """
|
||||||
|
readonly_fields = ('account_link',)
|
||||||
|
filter_by_account_fields = []
|
||||||
|
|
||||||
|
def account_link(self, instance):
|
||||||
|
account = instance.account if instance.pk else self.account
|
||||||
|
url = reverse('admin:accounts_account_change', args=(account.pk,))
|
||||||
|
return '<a href="%s">%s</a>' % (url, account.name)
|
||||||
|
account_link.short_description = _("account")
|
||||||
|
account_link.allow_tags = True
|
||||||
|
account_link.admin_order_field = 'account__user__username'
|
||||||
|
|
||||||
|
def queryset(self, request):
|
||||||
|
""" Select related for performance """
|
||||||
|
qs = super(AccountAdminMixin, self).queryset(request)
|
||||||
|
return qs.select_related('account__user')
|
||||||
|
|
||||||
|
def formfield_for_dbfield(self, db_field, **kwargs):
|
||||||
|
""" Improve performance of account field and filter by account """
|
||||||
|
if db_field.name == 'account':
|
||||||
|
qs = kwargs.get('queryset', db_field.rel.to.objects)
|
||||||
|
kwargs['queryset'] = qs.select_related('user')
|
||||||
|
formfield = super(AccountAdminMixin, self).formfield_for_dbfield(db_field, **kwargs)
|
||||||
|
if db_field.name in self.filter_by_account_fields:
|
||||||
|
if hasattr(self, 'account'):
|
||||||
|
# Hack widget render in order to append ?account=id to the add url
|
||||||
|
old_render = formfield.widget.render
|
||||||
|
def render(*args, **kwargs):
|
||||||
|
output = old_render(*args, **kwargs)
|
||||||
|
output = output.replace('/add/"', '/add/?account=%s"' % self.account.pk)
|
||||||
|
return mark_safe(output)
|
||||||
|
formfield.widget.render = render
|
||||||
|
# Filter related object by account
|
||||||
|
formfield.queryset = formfield.queryset.filter(account=self.account)
|
||||||
|
return formfield
|
||||||
|
|
||||||
|
|
||||||
|
class SelectAccountAdminMixin(AccountAdminMixin):
|
||||||
|
""" Provides support for accounts on ModelAdmin """
|
||||||
|
def get_readonly_fields(self, request, obj=None):
|
||||||
|
if obj:
|
||||||
|
self.account = obj.account
|
||||||
|
return super(AccountAdminMixin, self).get_readonly_fields(request, obj=obj)
|
||||||
|
|
||||||
|
def get_inline_instances(self, request, obj=None):
|
||||||
|
inlines = super(AccountAdminMixin, self).get_inline_instances(request, obj=obj)
|
||||||
|
if hasattr(self, 'account'):
|
||||||
|
account = self.account
|
||||||
|
else:
|
||||||
|
account = Account.objects.get(pk=request.GET['account'])
|
||||||
|
[ setattr(inline, 'account', account) for inline in inlines ]
|
||||||
|
return inlines
|
||||||
|
|
||||||
|
def get_urls(self):
|
||||||
|
""" Hooks select account url """
|
||||||
|
urls = super(AccountAdminMixin, self).get_urls()
|
||||||
|
admin_site = self.admin_site
|
||||||
|
opts = self.model._meta
|
||||||
|
info = opts.app_label, opts.module_name
|
||||||
|
account_list = AccountListAdmin(Account, admin_site).changelist_view
|
||||||
|
select_urls = patterns("",
|
||||||
|
url("/select-account/$",
|
||||||
|
wrap_admin_view(self, account_list),
|
||||||
|
name='%s_%s_select_account' % info),
|
||||||
|
)
|
||||||
|
return select_urls + urls
|
||||||
|
|
||||||
|
def add_view(self, request, form_url='', extra_context=None):
|
||||||
|
""" Redirects to select account view if required """
|
||||||
|
if request.user.is_superuser:
|
||||||
|
if 'account' in request.GET or Account.objects.count() == 1:
|
||||||
|
kwargs = {}
|
||||||
|
if 'account' in request.GET:
|
||||||
|
kwargs = dict(pk=request.GET['account'])
|
||||||
|
self.account = Account.objects.get(**kwargs)
|
||||||
|
opts = self.model._meta
|
||||||
|
context = {
|
||||||
|
'title': _("Add %s for %s") % (opts.verbose_name, self.account.name)
|
||||||
|
}
|
||||||
|
context.update(extra_context or {})
|
||||||
|
return super(AccountAdminMixin, self).add_view(request,
|
||||||
|
form_url=form_url, extra_context=context)
|
||||||
|
return HttpResponseRedirect('./select-account/')
|
||||||
|
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
"""
|
||||||
|
Given a model instance save it to the database.
|
||||||
|
"""
|
||||||
|
if not change:
|
||||||
|
obj.account_id = self.account.pk
|
||||||
|
obj.save()
|
25
orchestra/apps/accounts/api.py
Normal file
25
orchestra/apps/accounts/api.py
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
from rest_framework import viewsets
|
||||||
|
|
||||||
|
from orchestra.api import router
|
||||||
|
|
||||||
|
from .models import Account
|
||||||
|
from .serializers import AccountSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class AccountApiMixin(object):
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = super(AccountApiMixin, self).get_queryset()
|
||||||
|
return qs.filter(account=self.request.user.account_id)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountViewSet(viewsets.ModelViewSet):
|
||||||
|
model = Account
|
||||||
|
serializer_class = AccountSerializer
|
||||||
|
singleton_pk = lambda _,request: request.user.account.pk
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = super(AccountViewSet, self).get_queryset()
|
||||||
|
return qs.filter(id=self.request.user.account_id)
|
||||||
|
|
||||||
|
|
||||||
|
router.register(r'accounts', AccountViewSet)
|
20
orchestra/apps/accounts/filters.py
Normal file
20
orchestra/apps/accounts/filters.py
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
from django.contrib.admin import SimpleListFilter
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class HasMainUserListFilter(SimpleListFilter):
|
||||||
|
""" Filter Nodes by group according to request.user """
|
||||||
|
title = _("has main user")
|
||||||
|
parameter_name = 'mainuser'
|
||||||
|
|
||||||
|
def lookups(self, request, model_admin):
|
||||||
|
return (
|
||||||
|
('True', _("Yes")),
|
||||||
|
('False', _("No")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def queryset(self, request, queryset):
|
||||||
|
if self.value() == 'True':
|
||||||
|
return queryset.filter(users__isnull=False).distinct()
|
||||||
|
if self.value() == 'False':
|
||||||
|
return queryset.filter(users__isnull=True).distinct()
|
55
orchestra/apps/accounts/forms.py
Normal file
55
orchestra/apps/accounts/forms.py
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
from django import forms
|
||||||
|
from django.contrib import auth
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from orchestra.core.validators import validate_password
|
||||||
|
from orchestra.forms.widgets import ReadOnlyWidget
|
||||||
|
|
||||||
|
User = auth.get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class AccountCreationForm(auth.forms.UserCreationForm):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(AccountCreationForm, self).__init__(*args, **kwargs)
|
||||||
|
self.fields['password1'].validators.append(validate_password)
|
||||||
|
|
||||||
|
def clean_username(self):
|
||||||
|
# Since User.username is unique, this check is redundant,
|
||||||
|
# but it sets a nicer error message than the ORM. See #13147.
|
||||||
|
username = self.cleaned_data["username"]
|
||||||
|
try:
|
||||||
|
User._default_manager.get(username=username)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return username
|
||||||
|
raise forms.ValidationError(self.error_messages['duplicate_username'])
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
account = super(auth.forms.UserCreationForm, self).save(commit=False)
|
||||||
|
user = User(username=self.cleaned_data['username'], is_admin=True)
|
||||||
|
user.set_password(self.cleaned_data['password1'])
|
||||||
|
user.account = account
|
||||||
|
account.user = user
|
||||||
|
if commit:
|
||||||
|
user.save()
|
||||||
|
account.save()
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
class AccountChangeForm(forms.ModelForm):
|
||||||
|
username = forms.CharField()
|
||||||
|
password = auth.forms.ReadOnlyPasswordHashField(label=_("Password"),
|
||||||
|
help_text=_("Raw passwords are not stored, so there is no way to see "
|
||||||
|
"this user's password, but you can change the password "
|
||||||
|
"using <a href=\"password/\">this form</a>."))
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(AccountChangeForm, self).__init__(*args, **kwargs)
|
||||||
|
account = kwargs.get('instance')
|
||||||
|
self.fields['username'].widget = ReadOnlyWidget(account.user.username)
|
||||||
|
self.fields['password'].initial = account.user.password
|
||||||
|
|
||||||
|
def clean_password(self):
|
||||||
|
# Regardless of what the user provides, return the initial value.
|
||||||
|
# This is done here, rather than on the field, because the
|
||||||
|
# field does not have access to the initial value
|
||||||
|
return self.fields['password'].initial
|
0
orchestra/apps/accounts/management/__init__.py
Normal file
0
orchestra/apps/accounts/management/__init__.py
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
from optparse import make_option
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from orchestra.apps.accounts.models import Account
|
||||||
|
from orchestra.apps.users.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(Command, self).__init__(*args, **kwargs)
|
||||||
|
self.option_list = BaseCommand.option_list + (
|
||||||
|
make_option('--noinput', action='store_false', dest='interactive',
|
||||||
|
default=True),
|
||||||
|
make_option('--username', action='store', dest='username'),
|
||||||
|
make_option('--password', action='store', dest='password'),
|
||||||
|
make_option('--email', action='store', dest='email'),
|
||||||
|
)
|
||||||
|
|
||||||
|
option_list = BaseCommand.option_list
|
||||||
|
help = 'Used to create an initial account and its user.'
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
interactive = options.get('interactive')
|
||||||
|
if not interactive:
|
||||||
|
email = options.get('email')
|
||||||
|
username = options.get('username')
|
||||||
|
password = options.get('password')
|
||||||
|
user = User.objects.create_superuser(username, email, password, account=account,
|
||||||
|
is_main=True)
|
||||||
|
account = Account.objects.create(user=user)
|
||||||
|
user.account = account
|
||||||
|
user.save()
|
||||||
|
|
|
@ -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()
|
25
orchestra/apps/accounts/models.py
Normal file
25
orchestra/apps/accounts/models.py
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from . import settings
|
||||||
|
|
||||||
|
|
||||||
|
class Account(models.Model):
|
||||||
|
user = models.OneToOneField(get_user_model(), related_name='accounts')
|
||||||
|
type = models.CharField(_("type"), max_length=32, choices=settings.ACCOUNTS_TYPES,
|
||||||
|
default=settings.ACCOUNTS_DEFAULT_TYPE)
|
||||||
|
language = models.CharField(_("language"), max_length=2,
|
||||||
|
choices=settings.ACCOUNTS_LANGUAGES,
|
||||||
|
default=settings.ACCOUNTS_DEFAULT_LANGUAGE)
|
||||||
|
register_date = models.DateTimeField(_("register date"), auto_now_add=True)
|
||||||
|
comments = models.TextField(_("comments"), max_length=256, blank=True)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
self._cached_name = getattr(self, '_cached_name', self.user.username)
|
||||||
|
return self._cached_name
|
17
orchestra/apps/accounts/serializers.py
Normal file
17
orchestra/apps/accounts/serializers.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from .models import Account
|
||||||
|
|
||||||
|
|
||||||
|
class AccountSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Account
|
||||||
|
fields = (
|
||||||
|
'url', 'user', 'type', 'language', 'register_date', 'is_active'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountSerializerMixin(object):
|
||||||
|
def save_object(self, obj, **kwargs):
|
||||||
|
obj.account = self.context['request'].user.account
|
||||||
|
super(AccountSerializerMixin, self).save_object(obj, **kwargs)
|
20
orchestra/apps/accounts/settings.py
Normal file
20
orchestra/apps/accounts/settings.py
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
ACCOUNTS_TYPES = getattr(settings, 'ACCOUNTS_TYPES', (
|
||||||
|
('INDIVIDUAL', _("Individual")),
|
||||||
|
('ASSOCIATION', _("Association")),
|
||||||
|
('COMPANY', _("Company")),
|
||||||
|
('PUBLICBODY', _("Public body")),
|
||||||
|
))
|
||||||
|
|
||||||
|
ACCOUNTS_DEFAULT_TYPE = getattr(settings, 'ACCOUNTS_DEFAULT_TYPE', 'INDIVIDUAL')
|
||||||
|
|
||||||
|
|
||||||
|
ACCOUNTS_LANGUAGES = getattr(settings, 'ACCOUNTS_LANGUAGES', (
|
||||||
|
('en', _('English')),
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
ACCOUNTS_DEFAULT_LANGUAGE = getattr(settings, 'ACCOUNTS_DEFAULT_LANGUAGE', 'en')
|
|
@ -0,0 +1,20 @@
|
||||||
|
{% extends "admin/change_form.html" %}
|
||||||
|
{% load i18n admin_urls admin_static admin_modify %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block object-tools-items %}
|
||||||
|
{% for service in services %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url service|admin_urlname:'changelist' %}?account={{ original.pk }}" class="historylink">{{ service.verbose_name_plural|capfirst }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
<li>
|
||||||
|
<a href="disable/" class="historylink">{% trans "Disable" %}</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
{% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %}
|
||||||
|
<a href="{% add_preserved_filters history_url %}" class="historylink">{% trans "History" %}</a>
|
||||||
|
</li>
|
||||||
|
{% if has_absolute_url %}<li><a href="{{ absolute_url }}" class="viewsitelink">{% trans "View on site" %}</a></li>{% endif%}
|
||||||
|
{% endblock %}
|
||||||
|
|
0
orchestra/apps/contacts/__init__.py
Normal file
0
orchestra/apps/contacts/__init__.py
Normal file
67
orchestra/apps/contacts/admin.py
Normal file
67
orchestra/apps/contacts/admin.py
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
from django import forms
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from orchestra.admin import AtLeastOneRequiredInlineFormSet
|
||||||
|
from orchestra.admin.utils import insertattr
|
||||||
|
from orchestra.apps.accounts.admin import AccountAdmin, AccountAdminMixin
|
||||||
|
|
||||||
|
from .filters import HasInvoiceContactListFilter
|
||||||
|
from .models import Contact, InvoiceContact
|
||||||
|
|
||||||
|
|
||||||
|
class ContactAdmin(AccountAdminMixin, admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
'short_name', 'full_name', 'email', 'phone', 'phone2', 'country',
|
||||||
|
'account_link'
|
||||||
|
)
|
||||||
|
list_filter = ('email_usage',)
|
||||||
|
search_fields = (
|
||||||
|
'contact__user__username', 'short_name', 'full_name', 'phone', 'phone2',
|
||||||
|
'email'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(Contact, ContactAdmin)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceContactInline(admin.StackedInline):
|
||||||
|
model = InvoiceContact
|
||||||
|
fields = ('name', 'address', ('city', 'zipcode'), 'country', 'vat')
|
||||||
|
|
||||||
|
def formfield_for_dbfield(self, db_field, **kwargs):
|
||||||
|
""" Make value input widget bigger """
|
||||||
|
if db_field.name == 'address':
|
||||||
|
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 2})
|
||||||
|
return super(InvoiceContactInline, self).formfield_for_dbfield(db_field, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactInline(InvoiceContactInline):
|
||||||
|
model = Contact
|
||||||
|
formset = AtLeastOneRequiredInlineFormSet
|
||||||
|
extra = 0
|
||||||
|
fields = (
|
||||||
|
'short_name', 'full_name', 'email', 'email_usage', ('phone', 'phone2'),
|
||||||
|
'address', ('city', 'zipcode'), 'country',
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_extra(self, request, obj=None, **kwargs):
|
||||||
|
return 0 if obj and obj.contacts.exists() else 1
|
||||||
|
|
||||||
|
|
||||||
|
def has_invoice(account):
|
||||||
|
try:
|
||||||
|
account.invoicecontact.get()
|
||||||
|
except InvoiceContact.DoesNotExist:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
has_invoice.boolean = True
|
||||||
|
has_invoice.admin_order_field = 'invoicecontact'
|
||||||
|
|
||||||
|
|
||||||
|
insertattr(AccountAdmin, 'inlines', ContactInline)
|
||||||
|
insertattr(AccountAdmin, 'inlines', InvoiceContactInline)
|
||||||
|
insertattr(AccountAdmin, 'list_display', has_invoice)
|
||||||
|
insertattr(AccountAdmin, 'list_filter', HasInvoiceContactListFilter)
|
||||||
|
for field in ('contacts__short_name', 'contacts__full_name', 'contacts__phone',
|
||||||
|
'contacts__phone2', 'contacts__email'):
|
||||||
|
insertattr(AccountAdmin, 'search_fields', field)
|
21
orchestra/apps/contacts/api.py
Normal file
21
orchestra/apps/contacts/api.py
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
from rest_framework import viewsets
|
||||||
|
|
||||||
|
from orchestra.api import router
|
||||||
|
from orchestra.apps.accounts.api import AccountApiMixin
|
||||||
|
|
||||||
|
from .models import Contact, InvoiceContact
|
||||||
|
from .serializers import ContactSerializer, InvoiceContactSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class ContactViewSet(AccountApiMixin, viewsets.ModelViewSet):
|
||||||
|
model = Contact
|
||||||
|
serializer_class = ContactSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceContactViewSet(AccountApiMixin, viewsets.ModelViewSet):
|
||||||
|
model = InvoiceContact
|
||||||
|
serializer_class = InvoiceContactSerializer
|
||||||
|
|
||||||
|
|
||||||
|
router.register(r'contacts', ContactViewSet)
|
||||||
|
router.register(r'invoicecontacts', InvoiceContactViewSet)
|
20
orchestra/apps/contacts/filters.py
Normal file
20
orchestra/apps/contacts/filters.py
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
from django.contrib.admin import SimpleListFilter
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class HasInvoiceContactListFilter(SimpleListFilter):
|
||||||
|
""" Filter Nodes by group according to request.user """
|
||||||
|
title = _("has invoice contact")
|
||||||
|
parameter_name = 'invoice'
|
||||||
|
|
||||||
|
def lookups(self, request, model_admin):
|
||||||
|
return (
|
||||||
|
('True', _("Yes")),
|
||||||
|
('False', _("No")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def queryset(self, request, queryset):
|
||||||
|
if self.value() == 'True':
|
||||||
|
return queryset.filter(invoicecontact__isnull=False)
|
||||||
|
if self.value() == 'False':
|
||||||
|
return queryset.filter(invoicecontact__isnull=True)
|
41
orchestra/apps/contacts/models.py
Normal file
41
orchestra/apps/contacts/models.py
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from orchestra.models.fields import MultiSelectField
|
||||||
|
|
||||||
|
from . import settings
|
||||||
|
|
||||||
|
|
||||||
|
class Contact(models.Model):
|
||||||
|
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
|
||||||
|
related_name='contacts', null=True)
|
||||||
|
short_name = models.CharField(_("short name"), max_length=128)
|
||||||
|
full_name = models.CharField(_("full name"), max_length=256, blank=True)
|
||||||
|
email = models.EmailField()
|
||||||
|
email_usage = MultiSelectField(_("email usage"), max_length=256, blank=True,
|
||||||
|
choices=settings.CONTACTS_EMAIL_USAGES,
|
||||||
|
default=settings.CONTACTS_DEFAULT_EMAIL_USAGES)
|
||||||
|
phone = models.CharField(_("Phone"), max_length=32, blank=True)
|
||||||
|
phone2 = models.CharField(_("Alternative Phone"), max_length=32, blank=True)
|
||||||
|
address = models.TextField(_("address"), blank=True)
|
||||||
|
city = models.CharField(_("city"), max_length=128, blank=True,
|
||||||
|
default=settings.CONTACTS_DEFAULT_CITY)
|
||||||
|
zipcode = models.PositiveIntegerField(_("zip code"), null=True, blank=True)
|
||||||
|
country = models.CharField(_("country"), max_length=20, blank=True,
|
||||||
|
default=settings.CONTACTS_DEFAULT_COUNTRY)
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return self.short_name
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceContact(models.Model):
|
||||||
|
account = models.OneToOneField('accounts.Account', verbose_name=_("account"),
|
||||||
|
related_name='invoicecontact')
|
||||||
|
name = models.CharField(_("name"), max_length=256)
|
||||||
|
address = models.TextField(_("address"))
|
||||||
|
city = models.CharField(_("city"), max_length=128,
|
||||||
|
default=settings.CONTACTS_DEFAULT_CITY)
|
||||||
|
zipcode = models.PositiveIntegerField(_("zip code"))
|
||||||
|
country = models.CharField(_("country"), max_length=20,
|
||||||
|
default=settings.CONTACTS_DEFAULT_COUNTRY)
|
||||||
|
vat = models.CharField(_("VAT number"), max_length=64)
|
23
orchestra/apps/contacts/serializers.py
Normal file
23
orchestra/apps/contacts/serializers.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from orchestra.api.serializers import MultiSelectField
|
||||||
|
from orchestra.apps.accounts.serializers import AccountSerializerMixin
|
||||||
|
|
||||||
|
from . import settings
|
||||||
|
from .models import Contact, InvoiceContact
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer):
|
||||||
|
email_usage = MultiSelectField(choices=settings.CONTACTS_EMAIL_USAGES)
|
||||||
|
class Meta:
|
||||||
|
model = Contact
|
||||||
|
fields = (
|
||||||
|
'url', 'short_name', 'full_name', 'email', 'email_usage', 'phone',
|
||||||
|
'phone2', 'address', 'city', 'zipcode', 'country'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceContactSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = InvoiceContact
|
||||||
|
fields = ('url', 'name', 'address', 'city', 'zipcode', 'country', 'vat')
|
24
orchestra/apps/contacts/settings.py
Normal file
24
orchestra/apps/contacts/settings.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
CONTACTS_EMAIL_USAGES = getattr(settings, 'CONTACTS_EMAIL_USAGES', (
|
||||||
|
('SUPPORT', _("Support tickets")),
|
||||||
|
('ADMIN', _("Administrative")),
|
||||||
|
('BILL', _("Billing")),
|
||||||
|
('TECH', _("Technical")),
|
||||||
|
('ADDS', _("Announcements")),
|
||||||
|
('EMERGENCY', _("Emergency contact")),
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
CONTACTS_DEFAULT_EMAIL_USAGES = getattr(settings, 'CONTACTS_DEFAULT_EMAIL_USAGES',
|
||||||
|
('SUPPORT', 'ADMIN', 'BILL', 'TECH', 'ADDS', 'EMERGENCY')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CONTACTS_DEFAULT_CITY = getattr(settings, 'CONTACTS_DEFAULT_CITY', 'Barcelona')
|
||||||
|
|
||||||
|
CONTACTS_DEFAULT_PROVINCE = getattr(settings, 'CONTACTS_DEFAULT_PROVINCE', 'Barcelona')
|
||||||
|
|
||||||
|
CONTACTS_DEFAULT_COUNTRY = getattr(settings, 'CONTACTS_DEFAULT_COUNTRY', 'Spain')
|
0
orchestra/apps/databases/__init__.py
Normal file
0
orchestra/apps/databases/__init__.py
Normal file
135
orchestra/apps/databases/admin.py
Normal file
135
orchestra/apps/databases/admin.py
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.conf.urls import patterns
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
from django.core.urlresolvers import reverse
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from orchestra.admin import ExtendedModelAdmin
|
||||||
|
from orchestra.admin.utils import link
|
||||||
|
from orchestra.apps.accounts.admin import AccountAdminMixin, SelectAccountAdminMixin
|
||||||
|
|
||||||
|
from .forms import (DatabaseUserChangeForm, DatabaseUserCreationForm,
|
||||||
|
DatabaseCreationForm)
|
||||||
|
from .models import Database, Role, DatabaseUser
|
||||||
|
|
||||||
|
|
||||||
|
class UserInline(admin.TabularInline):
|
||||||
|
model = Role
|
||||||
|
verbose_name_plural = _("Users")
|
||||||
|
readonly_fields = ('user_link',)
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
user_link = link('user')
|
||||||
|
|
||||||
|
def formfield_for_dbfield(self, db_field, **kwargs):
|
||||||
|
""" Make value input widget bigger """
|
||||||
|
if db_field.name == 'user':
|
||||||
|
users = db_field.rel.to.objects.filter(type=self.parent_object.type)
|
||||||
|
kwargs['queryset'] = users.filter(account=self.account)
|
||||||
|
return super(UserInline, self).formfield_for_dbfield(db_field, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionInline(AccountAdminMixin, admin.TabularInline):
|
||||||
|
model = Role
|
||||||
|
verbose_name_plural = _("Permissions")
|
||||||
|
readonly_fields = ('database_link',)
|
||||||
|
extra = 0
|
||||||
|
filter_by_account_fields = ['database']
|
||||||
|
|
||||||
|
database_link = link('database', popup=True)
|
||||||
|
|
||||||
|
def formfield_for_dbfield(self, db_field, **kwargs):
|
||||||
|
""" Make value input widget bigger """
|
||||||
|
formfield = super(PermissionInline, self).formfield_for_dbfield(db_field, **kwargs)
|
||||||
|
if db_field.name == 'database':
|
||||||
|
# Hack widget render in order to append ?account=id to the add url
|
||||||
|
db_type = self.parent_object.type
|
||||||
|
old_render = formfield.widget.render
|
||||||
|
def render(*args, **kwargs):
|
||||||
|
output = old_render(*args, **kwargs)
|
||||||
|
output = output.replace('/add/?', '/add/?type=%s&' % db_type)
|
||||||
|
return mark_safe(output)
|
||||||
|
formfield.widget.render = render
|
||||||
|
formfield.queryset = formfield.queryset.filter(type=db_type)
|
||||||
|
return formfield
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
|
||||||
|
list_display = ('name', 'type', 'account_link')
|
||||||
|
list_filter = ('type',)
|
||||||
|
search_fields = ['name', 'account__user__username']
|
||||||
|
inlines = [UserInline]
|
||||||
|
add_inlines = []
|
||||||
|
change_readonly_fields = ('name', 'type')
|
||||||
|
extra = 1
|
||||||
|
fieldsets = (
|
||||||
|
(None, {
|
||||||
|
'classes': ('extrapretty',),
|
||||||
|
'fields': ('account_link', 'name', 'type'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
add_fieldsets = (
|
||||||
|
(None, {
|
||||||
|
'classes': ('wide',),
|
||||||
|
'fields': ('account_link', 'name', 'type')
|
||||||
|
}),
|
||||||
|
(_("Create new user"), {
|
||||||
|
'classes': ('wide',),
|
||||||
|
'fields': ('username', 'password1', 'password2'),
|
||||||
|
}),
|
||||||
|
(_("Use existing user"), {
|
||||||
|
'classes': ('wide',),
|
||||||
|
'fields': ('user',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
add_form = DatabaseCreationForm
|
||||||
|
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
super(DatabaseAdmin, self).save_model(request, obj, form, change)
|
||||||
|
if not change:
|
||||||
|
user = form.cleaned_data['user']
|
||||||
|
if not user:
|
||||||
|
user = DatabaseUser.objects.create(
|
||||||
|
username=form.cleaned_data['username'],
|
||||||
|
type=obj.type,
|
||||||
|
account_id = obj.account.pk,
|
||||||
|
)
|
||||||
|
user.set_password(form.cleaned_data["password1"])
|
||||||
|
user.save()
|
||||||
|
Role.objects.create(database=obj, user=user, is_owner=True)
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseUserAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
|
||||||
|
list_display = ('username', 'type', 'account_link')
|
||||||
|
list_filter = ('type',)
|
||||||
|
search_fields = ['username', 'account__user__username']
|
||||||
|
form = DatabaseUserChangeForm
|
||||||
|
add_form = DatabaseUserCreationForm
|
||||||
|
change_readonly_fields = ('username', 'type')
|
||||||
|
inlines = [PermissionInline]
|
||||||
|
add_inlines = []
|
||||||
|
fieldsets = (
|
||||||
|
(None, {
|
||||||
|
'classes': ('extrapretty',),
|
||||||
|
'fields': ('account_link', 'username', 'password', 'type')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
add_fieldsets = (
|
||||||
|
(None, {
|
||||||
|
'classes': ('extrapretty',),
|
||||||
|
'fields': ('account_link', 'username', 'password1', 'password2', 'type')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_urls(self):
|
||||||
|
useradmin = UserAdmin(DatabaseUser, self.admin_site)
|
||||||
|
return patterns('',
|
||||||
|
(r'^(\d+)/password/$',
|
||||||
|
self.admin_site.admin_view(useradmin.user_change_password))
|
||||||
|
) + super(DatabaseUserAdmin, self).get_urls()
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(Database, DatabaseAdmin)
|
||||||
|
admin.site.register(DatabaseUser, DatabaseUserAdmin)
|
26
orchestra/apps/databases/api.py
Normal file
26
orchestra/apps/databases/api.py
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
from rest_framework import viewsets
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from orchestra.api import router, SetPasswordApiMixin
|
||||||
|
from orchestra.apps.accounts.api import AccountApiMixin
|
||||||
|
|
||||||
|
from .models import Database, DatabaseUser
|
||||||
|
from .serializers import DatabaseSerializer, DatabaseUserSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseViewSet(AccountApiMixin, viewsets.ModelViewSet):
|
||||||
|
model = Database
|
||||||
|
serializer_class = DatabaseSerializer
|
||||||
|
filter_fields = ('name',)
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseUserViewSet(AccountApiMixin, SetPasswordApiMixin, viewsets.ModelViewSet):
|
||||||
|
model = DatabaseUser
|
||||||
|
serializer_class = DatabaseUserSerializer
|
||||||
|
filter_fields = ('username',)
|
||||||
|
|
||||||
|
|
||||||
|
router.register(r'databases', DatabaseViewSet)
|
||||||
|
router.register(r'databaseusers', DatabaseUserViewSet)
|
60
orchestra/apps/databases/backends.py
Normal file
60
orchestra/apps/databases/backends.py
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
from orchestra.apps.orchestration import ServiceBackend
|
||||||
|
|
||||||
|
from . import settings
|
||||||
|
|
||||||
|
|
||||||
|
class MySQLDBBackend(ServiceBackend):
|
||||||
|
verbose_name = "MySQL database"
|
||||||
|
model = 'databases.Database'
|
||||||
|
|
||||||
|
def save(self, database):
|
||||||
|
if database.type == database.MYSQL:
|
||||||
|
context = self.get_context(database)
|
||||||
|
self.append("mysql -e 'CREATE DATABASE `%(database)s`;'" % context)
|
||||||
|
self.append("mysql -e 'GRANT ALL PRIVILEGES ON `%(database)s`.* "
|
||||||
|
" TO \"%(owner)s\"@\"%(host)s\" WITH GRANT OPTION;'" % context)
|
||||||
|
|
||||||
|
def delete(self, database):
|
||||||
|
if database.type == database.MYSQL:
|
||||||
|
context = self.get_context(database)
|
||||||
|
self.append("mysql -e 'DROP DATABASE `%(database)s`;'" % context)
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
self.append("mysql -e 'FLUSH PRIVILEGES;'")
|
||||||
|
|
||||||
|
def get_context(self, database):
|
||||||
|
return {
|
||||||
|
'owner': database.owner.username,
|
||||||
|
'database': database.name,
|
||||||
|
'host': settings.DATABASES_DEFAULT_HOST,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MySQLUserBackend(ServiceBackend):
|
||||||
|
verbose_name = "MySQL user"
|
||||||
|
model = 'databases.DatabaseUser'
|
||||||
|
|
||||||
|
def save(self, database):
|
||||||
|
if database.type == database.MYSQL:
|
||||||
|
context = self.get_context(database)
|
||||||
|
self.append("mysql -e 'CREATE USER \"%(username)s\"@\"%(host)s\";'" % context)
|
||||||
|
self.append("mysql -e 'UPDATE mysql.user SET Password=\"%(password)s\" "
|
||||||
|
" WHERE User=\"%(username)s\";'" % context)
|
||||||
|
|
||||||
|
def delete(self, database):
|
||||||
|
if database.type == database.MYSQL:
|
||||||
|
context = self.get_context(database)
|
||||||
|
self.append("mysql -e 'DROP USER \"%(username)s\"@\"%(host)s\";'" % context)
|
||||||
|
|
||||||
|
def get_context(self, database):
|
||||||
|
return {
|
||||||
|
'username': database.username,
|
||||||
|
'password': database.password,
|
||||||
|
'host': settings.DATABASES_DEFAULT_HOST,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MySQLPermissionBackend(ServiceBackend):
|
||||||
|
model = 'databases.UserDatabaseRelation'
|
||||||
|
verbose_name = "MySQL permission"
|
||||||
|
|
135
orchestra/apps/databases/forms.py
Normal file
135
orchestra/apps/databases/forms.py
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
from django import forms
|
||||||
|
from django.contrib.auth.forms import UserCreationForm, ReadOnlyPasswordHashField
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from orchestra.core.validators import validate_password
|
||||||
|
|
||||||
|
from .models import DatabaseUser, Database, Role
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseUserCreationForm(forms.ModelForm):
|
||||||
|
password1 = forms.CharField(label=_("Password"), required=False,
|
||||||
|
widget=forms.PasswordInput, validators=[validate_password])
|
||||||
|
password2 = forms.CharField(label=_("Password confirmation"), required=False,
|
||||||
|
widget=forms.PasswordInput,
|
||||||
|
help_text=_("Enter the same password as above, for verification."))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DatabaseUser
|
||||||
|
fields = ('username', 'account', 'type')
|
||||||
|
|
||||||
|
def clean_password2(self):
|
||||||
|
password1 = self.cleaned_data.get("password1")
|
||||||
|
password2 = self.cleaned_data.get("password2")
|
||||||
|
if password1 and password2 and password1 != password2:
|
||||||
|
msg = _("The two password fields didn't match.")
|
||||||
|
raise forms.ValidationError(msg)
|
||||||
|
return password2
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
user = super(DatabaseUserCreationForm, self).save(commit=False)
|
||||||
|
user.set_password(self.cleaned_data["password1"])
|
||||||
|
if commit:
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseCreationForm(DatabaseUserCreationForm):
|
||||||
|
username = forms.RegexField(label=_("Username"), max_length=30,
|
||||||
|
required=False, regex=r'^[\w.@+-]+$',
|
||||||
|
help_text=_("Required. 30 characters or fewer. Letters, digits and "
|
||||||
|
"@/./+/-/_ only."),
|
||||||
|
error_messages={
|
||||||
|
'invalid': _("This value may contain only letters, numbers and "
|
||||||
|
"@/./+/-/_ characters.")})
|
||||||
|
user = forms.ModelChoiceField(required=False, queryset=DatabaseUser.objects)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Database
|
||||||
|
fields = ('username', 'account', 'type')
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(DatabaseCreationForm, self).__init__(*args, **kwargs)
|
||||||
|
account_id = self.initial.get('account', None)
|
||||||
|
if account_id:
|
||||||
|
qs = self.fields['user'].queryset.filter(account=account_id)
|
||||||
|
choices = [ (u.pk, "%s (%s)" % (u, u.get_type_display())) for u in qs ]
|
||||||
|
self.fields['user'].queryset = qs
|
||||||
|
self.fields['user'].choices = [(None, '--------'),] + choices
|
||||||
|
|
||||||
|
def clean_password2(self):
|
||||||
|
username = self.cleaned_data.get('username')
|
||||||
|
password1 = self.cleaned_data.get('password1')
|
||||||
|
password2 = self.cleaned_data.get('password2')
|
||||||
|
if username and not (password1 and password2):
|
||||||
|
raise forms.ValidationError(_("Missing password"))
|
||||||
|
if password1 and password2 and password1 != password2:
|
||||||
|
msg = _("The two password fields didn't match.")
|
||||||
|
raise forms.ValidationError(msg)
|
||||||
|
return password2
|
||||||
|
|
||||||
|
def clean_user(self):
|
||||||
|
user = self.cleaned_data.get('user')
|
||||||
|
if user and user.type != self.cleaned_data.get('type'):
|
||||||
|
msg = _("Database type and user type doesn't match")
|
||||||
|
raise forms.ValidationError(msg)
|
||||||
|
return user
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super(DatabaseCreationForm, self).clean()
|
||||||
|
if 'user' in cleaned_data and 'username' in cleaned_data:
|
||||||
|
msg = _("Use existing user or create a new one?")
|
||||||
|
if cleaned_data['user'] and self.cleaned_data['username']:
|
||||||
|
raise forms.ValidationError(msg)
|
||||||
|
elif not (cleaned_data['username'] or cleaned_data['user']):
|
||||||
|
raise forms.ValidationError(msg)
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
db = super(DatabaseUserCreationForm, self).save(commit=False)
|
||||||
|
user = self.cleaned_data['user']
|
||||||
|
if commit:
|
||||||
|
if not user:
|
||||||
|
user = DatabaseUser(
|
||||||
|
username=self.cleaned_data['username'],
|
||||||
|
type=self.cleaned_data['type'],
|
||||||
|
)
|
||||||
|
user.set_password(self.cleaned_data["password1"])
|
||||||
|
user.save()
|
||||||
|
role, __ = Role.objects.get_or_create(database=db, user=user)
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
class ReadOnlySQLPasswordHashField(ReadOnlyPasswordHashField):
|
||||||
|
class ReadOnlyPasswordHashWidget(forms.Widget):
|
||||||
|
def render(self, name, value, attrs):
|
||||||
|
original = ReadOnlyPasswordHashField.widget().render(name, value, attrs)
|
||||||
|
if 'Invalid' not in original:
|
||||||
|
return original
|
||||||
|
encoded = value
|
||||||
|
final_attrs = self.build_attrs(attrs)
|
||||||
|
if not encoded:
|
||||||
|
summary = mark_safe("<strong>%s</strong>" % _("No password set."))
|
||||||
|
else:
|
||||||
|
size = len(value)
|
||||||
|
summary = value[:size/2] + '*'*(size-size/2)
|
||||||
|
summary = "<strong>hash</strong>: %s" % summary
|
||||||
|
if value.startswith('*'):
|
||||||
|
summary = "<strong>algorithm</strong>: sha1_bin_hex %s" % summary
|
||||||
|
return format_html("<div>%s</div>" % summary)
|
||||||
|
widget = ReadOnlyPasswordHashWidget
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseUserChangeForm(forms.ModelForm):
|
||||||
|
password = ReadOnlySQLPasswordHashField(label=_("Password"),
|
||||||
|
help_text=_("Raw passwords are not stored, so there is no way to see "
|
||||||
|
"this user's password, but you can change the password "
|
||||||
|
"using <a href=\"password/\">this form</a>."))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DatabaseUser
|
||||||
|
|
||||||
|
def clean_password(self):
|
||||||
|
return self.initial["password"]
|
91
orchestra/apps/databases/models.py
Normal file
91
orchestra/apps/databases/models.py
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from orchestra.core import validators, services
|
||||||
|
|
||||||
|
from . import settings
|
||||||
|
|
||||||
|
|
||||||
|
class Database(models.Model):
|
||||||
|
""" Represents a basic database for a web application """
|
||||||
|
MYSQL = 'mysql'
|
||||||
|
POSTGRESQL = 'postgresql'
|
||||||
|
|
||||||
|
name = models.CharField(_("name"), max_length=128,
|
||||||
|
validators=[validators.validate_name])
|
||||||
|
users = models.ManyToManyField('databases.DatabaseUser', verbose_name=_("users"),
|
||||||
|
through='databases.Role', related_name='users')
|
||||||
|
type = models.CharField(_("type"), max_length=32,
|
||||||
|
choices=settings.DATABASES_TYPE_CHOICES,
|
||||||
|
default=settings.DATABASES_DEFAULT_TYPE)
|
||||||
|
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
|
||||||
|
related_name='databases')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('name', 'type')
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return "%s" % self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def owner(self):
|
||||||
|
self.users.get(is_owner=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Role(models.Model):
|
||||||
|
database = models.ForeignKey(Database, verbose_name=_("database"),
|
||||||
|
related_name='roles')
|
||||||
|
user = models.ForeignKey('databases.DatabaseUser', verbose_name=_("user"),
|
||||||
|
related_name='roles')
|
||||||
|
is_owner = models.BooleanField(_("is owener"), default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('database', 'user')
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return "%s@%s" % (self.user, self.database)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
if self.user.type != self.database.type:
|
||||||
|
msg = _("Database and user type doesn't match")
|
||||||
|
raise validators.ValidationError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseUser(models.Model):
|
||||||
|
MYSQL = 'mysql'
|
||||||
|
POSTGRESQL = 'postgresql'
|
||||||
|
|
||||||
|
username = models.CharField(_("username"), max_length=128,
|
||||||
|
validators=[validators.validate_name])
|
||||||
|
password = models.CharField(_("password"), max_length=128)
|
||||||
|
type = models.CharField(_("type"), max_length=32,
|
||||||
|
choices=settings.DATABASES_TYPE_CHOICES,
|
||||||
|
default=settings.DATABASES_DEFAULT_TYPE)
|
||||||
|
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
|
||||||
|
related_name='databaseusers')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name_plural = _("DB users")
|
||||||
|
unique_together = ('username', 'type')
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return self.username
|
||||||
|
|
||||||
|
def get_username(self):
|
||||||
|
return self.username
|
||||||
|
|
||||||
|
def set_password(self, password):
|
||||||
|
if self.type == self.MYSQL:
|
||||||
|
# MySQL stores sha1(sha1(password).binary).hex
|
||||||
|
binary = hashlib.sha1(password).digest()
|
||||||
|
hexdigest = hashlib.sha1(binary).hexdigest()
|
||||||
|
password = '*%s' % hexdigest.upper()
|
||||||
|
self.password = password
|
||||||
|
else:
|
||||||
|
raise TypeError("Database type '%s' not supported" % self.type)
|
||||||
|
|
||||||
|
|
||||||
|
services.register(Database)
|
||||||
|
services.register(DatabaseUser, verbose_name_plural=_("Database users"))
|
40
orchestra/apps/databases/serializers.py
Normal file
40
orchestra/apps/databases/serializers.py
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
from django.forms import widgets
|
||||||
|
from django.utils.translation import ugettext, ugettext_lazy as _
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from orchestra.apps.accounts.serializers import AccountSerializerMixin
|
||||||
|
from orchestra.core.validators import validate_password
|
||||||
|
|
||||||
|
from .models import Database, DatabaseUser, Role
|
||||||
|
|
||||||
|
|
||||||
|
class UserSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Role
|
||||||
|
fields = ('user', 'is_owner',)
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Role
|
||||||
|
fields = ('database', 'is_owner',)
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer):
|
||||||
|
users = UserSerializer(source='roles', many=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Database
|
||||||
|
fields = ('url', 'name', 'type', 'users')
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseUserSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer):
|
||||||
|
password = serializers.CharField(max_length=128, label=_('Password'),
|
||||||
|
validators=[validate_password], write_only=True,
|
||||||
|
widget=widgets.PasswordInput)
|
||||||
|
permission = PermissionSerializer(source='roles', many=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DatabaseUser
|
||||||
|
fields = ('url', 'username', 'password', 'type', 'permission')
|
||||||
|
write_only_fields = ('username',)
|
14
orchestra/apps/databases/settings.py
Normal file
14
orchestra/apps/databases/settings.py
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
DATABASES_TYPE_CHOICES = getattr(settings, 'DATABASES_TYPE_CHOICES', (
|
||||||
|
('mysql', 'MySQL'),
|
||||||
|
('postgres', 'PostgreSQL'),
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
DATABASES_DEFAULT_TYPE = getattr(settings, 'DATABASES_DEFAULT_TYPE', 'mysql')
|
||||||
|
|
||||||
|
|
||||||
|
DATABASES_DEFAULT_HOST = getattr(settings, 'DATABASES_DEFAULT_HOST', 'localhost')
|
0
orchestra/apps/domains/__init__.py
Normal file
0
orchestra/apps/domains/__init__.py
Normal file
125
orchestra/apps/domains/admin.py
Normal file
125
orchestra/apps/domains/admin.py
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
from django import forms
|
||||||
|
from django.conf.urls import patterns, url
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.admin.util import unquote
|
||||||
|
from django.core.urlresolvers import reverse
|
||||||
|
from django.db.models import F
|
||||||
|
from django.template.response import TemplateResponse
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from orchestra.admin import ChangeListDefaultFilter, ExtendedModelAdmin
|
||||||
|
from orchestra.admin.utils import wrap_admin_view, link
|
||||||
|
from orchestra.apps.accounts.admin import AccountAdminMixin
|
||||||
|
from orchestra.utils import apps
|
||||||
|
|
||||||
|
from .forms import RecordInlineFormSet, DomainAdminForm
|
||||||
|
from .filters import TopDomainListFilter
|
||||||
|
from .models import Domain, Record
|
||||||
|
|
||||||
|
|
||||||
|
class RecordInline(admin.TabularInline):
|
||||||
|
model = Record
|
||||||
|
formset = RecordInlineFormSet
|
||||||
|
verbose_name_plural = _("Extra records")
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
css = {
|
||||||
|
'all': ('orchestra/css/hide-inline-id.css',)
|
||||||
|
}
|
||||||
|
|
||||||
|
def formfield_for_dbfield(self, db_field, **kwargs):
|
||||||
|
""" Make value input widget bigger """
|
||||||
|
if db_field.name == 'value':
|
||||||
|
kwargs['widget'] = forms.TextInput(attrs={'size':'100'})
|
||||||
|
return super(RecordInline, self).formfield_for_dbfield(db_field, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class DomainInline(admin.TabularInline):
|
||||||
|
model = Domain
|
||||||
|
fields = ('domain_link',)
|
||||||
|
readonly_fields = ('domain_link',)
|
||||||
|
extra = 0
|
||||||
|
verbose_name_plural = _("Subdomains")
|
||||||
|
|
||||||
|
domain_link = link()
|
||||||
|
domain_link.short_description = _("Name")
|
||||||
|
|
||||||
|
def has_add_permission(self, *args, **kwargs):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class DomainAdmin(ChangeListDefaultFilter, AccountAdminMixin, ExtendedModelAdmin):
|
||||||
|
fields = ('name', 'account')
|
||||||
|
list_display = ('structured_name', 'is_top', 'websites', 'account_link')
|
||||||
|
inlines = [RecordInline, DomainInline]
|
||||||
|
list_filter = [TopDomainListFilter]
|
||||||
|
change_readonly_fields = ('name',)
|
||||||
|
search_fields = ['name', 'account__user__username']
|
||||||
|
default_changelist_filters = (('top_domain', 'True'),)
|
||||||
|
form = DomainAdminForm
|
||||||
|
|
||||||
|
def structured_name(self, domain):
|
||||||
|
if not self.is_top(domain):
|
||||||
|
return ' '*4 + domain.name
|
||||||
|
return domain.name
|
||||||
|
structured_name.short_description = _("name")
|
||||||
|
structured_name.allow_tags = True
|
||||||
|
structured_name.admin_order_field = 'structured_name'
|
||||||
|
|
||||||
|
def is_top(self, domain):
|
||||||
|
return not bool(domain.top)
|
||||||
|
is_top.boolean = True
|
||||||
|
is_top.admin_order_field = 'top'
|
||||||
|
|
||||||
|
def websites(self, domain):
|
||||||
|
if apps.isinstalled('orchestra.apps.websites'):
|
||||||
|
webs = domain.websites.all()
|
||||||
|
if webs:
|
||||||
|
links = []
|
||||||
|
for web in webs:
|
||||||
|
url = reverse('admin:websites_website_change', args=(web.pk,))
|
||||||
|
links.append('<a href="%s">%s</a>' % (url, web.name))
|
||||||
|
return '<br>'.join(links)
|
||||||
|
return _("No website")
|
||||||
|
websites.admin_order_field = 'websites__name'
|
||||||
|
websites.short_description = _("Websites")
|
||||||
|
websites.allow_tags = True
|
||||||
|
|
||||||
|
def get_urls(self):
|
||||||
|
""" Returns the additional urls for the change view links """
|
||||||
|
urls = super(DomainAdmin, self).get_urls()
|
||||||
|
admin_site = self.admin_site
|
||||||
|
opts = self.model._meta
|
||||||
|
urls = patterns("",
|
||||||
|
url('^(\d+)/view-zone/$',
|
||||||
|
wrap_admin_view(self, self.view_zone_view),
|
||||||
|
name='domains_domain_view_zone')
|
||||||
|
) + urls
|
||||||
|
return urls
|
||||||
|
|
||||||
|
def view_zone_view(self, request, object_id):
|
||||||
|
zone = self.get_object(request, unquote(object_id))
|
||||||
|
context = {
|
||||||
|
'opts': self.model._meta,
|
||||||
|
'object': zone,
|
||||||
|
'title': _("%s zone content") % zone.origin.name
|
||||||
|
}
|
||||||
|
return TemplateResponse(request, 'admin/domains/domain/view_zone.html',
|
||||||
|
context)
|
||||||
|
|
||||||
|
def queryset(self, request):
|
||||||
|
""" Order by structured name and imporve performance """
|
||||||
|
qs = super(DomainAdmin, self).queryset(request)
|
||||||
|
qs = qs.select_related('top', 'account__user')
|
||||||
|
# qs = qs.select_related('top')
|
||||||
|
# For some reason if we do this we know for sure that join table will be called T4
|
||||||
|
__ = str(qs.query)
|
||||||
|
qs = qs.extra(
|
||||||
|
select={'structured_name': 'CONCAT(T4.name, domains_domain.name)'},
|
||||||
|
).order_by('structured_name')
|
||||||
|
if apps.isinstalled('orchestra.apps.websites'):
|
||||||
|
qs = qs.prefetch_related('websites')
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(Domain, DomainAdmin)
|
35
orchestra/apps/domains/api.py
Normal file
35
orchestra/apps/domains/api.py
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
from rest_framework import viewsets
|
||||||
|
from rest_framework.decorators import link
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from orchestra.api import router, collectionlink
|
||||||
|
from orchestra.apps.accounts.api import AccountApiMixin
|
||||||
|
|
||||||
|
from . import settings
|
||||||
|
from .models import Domain
|
||||||
|
from .serializers import DomainSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class DomainViewSet(AccountApiMixin, viewsets.ModelViewSet):
|
||||||
|
model = Domain
|
||||||
|
serializer_class = DomainSerializer
|
||||||
|
filter_fields = ('name',)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = super(DomainViewSet, self).get_queryset()
|
||||||
|
return qs.prefetch_related('records')
|
||||||
|
|
||||||
|
@collectionlink()
|
||||||
|
def configuration(self, request):
|
||||||
|
names = ['DOMAINS_DEFAULT_A', 'DOMAINS_DEFAULT_MX', 'DOMAINS_DEFAULT_NS']
|
||||||
|
return Response({
|
||||||
|
name: getattr(settings, name, None) for name in names
|
||||||
|
})
|
||||||
|
|
||||||
|
@link()
|
||||||
|
def view_zone(self, request, pk=None):
|
||||||
|
domain = self.get_object()
|
||||||
|
return Response({'zone': domain.render_zone()})
|
||||||
|
|
||||||
|
|
||||||
|
router.register(r'domains', DomainViewSet)
|
104
orchestra/apps/domains/backends.py
Normal file
104
orchestra/apps/domains/backends.py
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from . import settings
|
||||||
|
|
||||||
|
from orchestra.apps.orchestration import ServiceBackend
|
||||||
|
|
||||||
|
|
||||||
|
class Bind9MasterDomainBackend(ServiceBackend):
|
||||||
|
verbose_name = _("Bind9 master domain")
|
||||||
|
model = 'domains.Domain'
|
||||||
|
related_models = (
|
||||||
|
('domains.Record', 'domain__origin'),
|
||||||
|
('domains.Domain', 'origin'),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_main(cls, obj):
|
||||||
|
""" work around Domain.top self relationship """
|
||||||
|
if super(Bind9MasterDomainBackend, cls).is_main(obj):
|
||||||
|
return not obj.top
|
||||||
|
|
||||||
|
def save(self, domain):
|
||||||
|
context = self.get_context(domain)
|
||||||
|
domain.refresh_serial()
|
||||||
|
context['zone'] = ';; %(banner)s\n' % context
|
||||||
|
context['zone'] += domain.render_zone()
|
||||||
|
self.append("{ echo -e '%(zone)s' | diff -N -I'^;;' %(zone_path)s - ; } ||"
|
||||||
|
" { echo -e '%(zone)s' > %(zone_path)s; UPDATED=1; }" % context)
|
||||||
|
self.update_conf(context)
|
||||||
|
|
||||||
|
def update_conf(self, context):
|
||||||
|
self.append("grep '\s*zone\s*\"%(name)s\"\s*{' %(conf_path)s > /dev/null ||"
|
||||||
|
" { echo -e '%(conf)s' >> %(conf_path)s; UPDATED=1; }" % context)
|
||||||
|
for subdomain in context['subdomains']:
|
||||||
|
context['name'] = subdomain.name
|
||||||
|
self.delete_conf(context)
|
||||||
|
|
||||||
|
def delete(self, domain):
|
||||||
|
context = self.get_context(domain)
|
||||||
|
self.append('rm -f %(zone_path)s;' % context)
|
||||||
|
self.delete_conf(context)
|
||||||
|
|
||||||
|
def delete_conf(self, context):
|
||||||
|
self.append('awk -v s=%(name)s \'BEGIN {'
|
||||||
|
' RS=""; s="zone \\""s"\\""'
|
||||||
|
'} $0!~s{ print $0"\\n" }\' %(conf_path)s > %(conf_path)s.tmp'
|
||||||
|
% context)
|
||||||
|
self.append('diff -I"^\s*//" %(conf_path)s.tmp %(conf_path)s || UPDATED=1' % context)
|
||||||
|
self.append('mv %(conf_path)s.tmp %(conf_path)s' % context)
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
""" reload bind if needed """
|
||||||
|
self.append('[[ $UPDATED == 1 ]] && service bind9 reload')
|
||||||
|
|
||||||
|
def get_context(self, domain):
|
||||||
|
context = {
|
||||||
|
'name': domain.name,
|
||||||
|
'zone_path': settings.DOMAINS_ZONE_PATH % {'name': domain.name},
|
||||||
|
'subdomains': domain.get_subdomains(),
|
||||||
|
'banner': self.get_banner(),
|
||||||
|
}
|
||||||
|
context.update({
|
||||||
|
'conf_path': settings.DOMAINS_MASTERS_PATH,
|
||||||
|
'conf': 'zone "%(name)s" {\n'
|
||||||
|
' // %(banner)s\n'
|
||||||
|
' type master;\n'
|
||||||
|
' file "%(zone_path)s";\n'
|
||||||
|
'};\n' % context
|
||||||
|
})
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class Bind9SlaveDomainBackend(Bind9MasterDomainBackend):
|
||||||
|
verbose_name = _("Bind9 slave domain")
|
||||||
|
related_models = (('domains.Domain', 'origin'),)
|
||||||
|
def save(self, domain):
|
||||||
|
context = self.get_context(domain)
|
||||||
|
self.update_conf(context)
|
||||||
|
|
||||||
|
def delete(self, domain):
|
||||||
|
context = self.get_context(domain)
|
||||||
|
self.delete_conf(context)
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
""" ideally slave should be restarted after master """
|
||||||
|
self.append('[[ $UPDATED == 1 ]] && { sleep 1 && service bind9 reload; } &')
|
||||||
|
|
||||||
|
def get_context(self, domain):
|
||||||
|
context = {
|
||||||
|
'name': domain.name,
|
||||||
|
'masters': '; '.join(settings.DOMAINS_MASTERS),
|
||||||
|
'subdomains': domain.get_subdomains()
|
||||||
|
}
|
||||||
|
context.update({
|
||||||
|
'conf_path': settings.DOMAINS_SLAVES_PATH,
|
||||||
|
'conf': 'zone "%(name)s" {\n'
|
||||||
|
' type slave;\n'
|
||||||
|
' file "%(name)s";\n'
|
||||||
|
' masters { %(masters)s; };\n'
|
||||||
|
'};\n' % context
|
||||||
|
})
|
||||||
|
return context
|
35
orchestra/apps/domains/filters.py
Normal file
35
orchestra/apps/domains/filters.py
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
from django.contrib.admin import SimpleListFilter
|
||||||
|
from django.utils.encoding import force_text
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class TopDomainListFilter(SimpleListFilter):
|
||||||
|
""" Filter Nodes by group according to request.user """
|
||||||
|
title = _("Top domains")
|
||||||
|
parameter_name = 'top_domain'
|
||||||
|
|
||||||
|
def lookups(self, request, model_admin):
|
||||||
|
return (
|
||||||
|
('True', _("Top domains")),
|
||||||
|
('False', _("All")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def queryset(self, request, queryset):
|
||||||
|
if self.value() == 'True':
|
||||||
|
return queryset.filter(top__isnull=True)
|
||||||
|
|
||||||
|
def choices(self, cl):
|
||||||
|
""" Enable default selection different than All """
|
||||||
|
for lookup, title in self.lookup_choices:
|
||||||
|
title = title._proxy____args[0]
|
||||||
|
selected = self.value() == force_text(lookup)
|
||||||
|
if not selected and title == "Top domains" and self.value() is None:
|
||||||
|
selected = True
|
||||||
|
# end of workaround
|
||||||
|
yield {
|
||||||
|
'selected': selected,
|
||||||
|
'query_string': cl.get_query_string({
|
||||||
|
self.parameter_name: lookup,
|
||||||
|
}, []),
|
||||||
|
'display': title,
|
||||||
|
}
|
56
orchestra/apps/domains/forms.py
Normal file
56
orchestra/apps/domains/forms.py
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
from django import forms
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from . import validators
|
||||||
|
from .helpers import domain_for_validation
|
||||||
|
from .models import Domain
|
||||||
|
|
||||||
|
|
||||||
|
class DomainAdminForm(forms.ModelForm):
|
||||||
|
def clean(self):
|
||||||
|
""" inherit related top domain account, when exists """
|
||||||
|
cleaned_data = super(DomainAdminForm, self).clean()
|
||||||
|
if not cleaned_data['account']:
|
||||||
|
domain = Domain(name=cleaned_data['name'])
|
||||||
|
top = domain.get_top()
|
||||||
|
if not top:
|
||||||
|
# Fake an account to make django validation happy
|
||||||
|
Account = self.fields['account']._queryset.model
|
||||||
|
cleaned_data['account'] = Account()
|
||||||
|
msg = _("An account should be provided for top domain names")
|
||||||
|
raise ValidationError(msg)
|
||||||
|
cleaned_data['account'] = top.account
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class RecordInlineFormSet(forms.models.BaseInlineFormSet):
|
||||||
|
def clean(self):
|
||||||
|
""" Checks if everything is consistent """
|
||||||
|
if any(self.errors):
|
||||||
|
return
|
||||||
|
if self.instance.name:
|
||||||
|
records = []
|
||||||
|
for form in self.forms:
|
||||||
|
data = form.cleaned_data
|
||||||
|
if data and not data['DELETE']:
|
||||||
|
records.append(data)
|
||||||
|
domain = domain_for_validation(self.instance, records)
|
||||||
|
validators.validate_zone(domain.render_zone())
|
||||||
|
|
||||||
|
|
||||||
|
class DomainIterator(forms.models.ModelChoiceIterator):
|
||||||
|
""" Group ticket owner by superusers, ticket.group and regular users """
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.account = kwargs.pop('account')
|
||||||
|
self.domains = kwargs.pop('domains')
|
||||||
|
super(forms.models.ModelChoiceIterator, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
yield ('', '---------')
|
||||||
|
account_domains = self.domains.filter(account=self.account)
|
||||||
|
account_domains = account_domains.values_list('pk', 'name')
|
||||||
|
yield (_("Account"), list(account_domains))
|
||||||
|
domains = self.domains.exclude(account=self.account)
|
||||||
|
domains = domains.values_list('pk', 'name')
|
||||||
|
yield (_("Other"), list(domains))
|
24
orchestra/apps/domains/helpers.py
Normal file
24
orchestra/apps/domains/helpers.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import copy
|
||||||
|
|
||||||
|
from .models import Domain, Record
|
||||||
|
|
||||||
|
|
||||||
|
def domain_for_validation(instance, records):
|
||||||
|
""" Create a fake zone in order to generate the whole zone file and check it """
|
||||||
|
domain = copy.copy(instance)
|
||||||
|
if not domain.pk:
|
||||||
|
domain.top = domain.get_top()
|
||||||
|
def get_records():
|
||||||
|
for data in records:
|
||||||
|
yield Record(type=data['type'], value=data['value'])
|
||||||
|
domain.get_records = get_records
|
||||||
|
if domain.top:
|
||||||
|
subdomains = domain.get_topsubdomains().exclude(pk=instance.pk)
|
||||||
|
domain.top.get_subdomains = lambda: list(subdomains) + [domain]
|
||||||
|
elif not domain.pk:
|
||||||
|
subdomains = []
|
||||||
|
for subdomain in Domain.objects.filter(name__endswith=domain.name):
|
||||||
|
subdomain.top = domain
|
||||||
|
subdomains.append(subdomain)
|
||||||
|
domain.get_subdomains = lambda: subdomains
|
||||||
|
return domain
|
174
orchestra/apps/domains/models.py
Normal file
174
orchestra/apps/domains/models.py
Normal file
|
@ -0,0 +1,174 @@
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from orchestra.core import services
|
||||||
|
from orchestra.core.validators import (validate_ipv4_address, validate_ipv6_address,
|
||||||
|
validate_hostname, validate_ascii)
|
||||||
|
from orchestra.utils.functional import cached
|
||||||
|
|
||||||
|
from . import settings, validators, utils
|
||||||
|
|
||||||
|
|
||||||
|
class Domain(models.Model):
|
||||||
|
name = models.CharField(_("name"), max_length=256, unique=True,
|
||||||
|
validators=[validate_hostname, validators.validate_allowed_domain])
|
||||||
|
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
|
||||||
|
related_name='domains', blank=True)
|
||||||
|
top = models.ForeignKey('domains.Domain', null=True, related_name='subdomains')
|
||||||
|
serial = models.IntegerField(_("serial"), default=utils.generate_zone_serial,
|
||||||
|
help_text=_("Serial number"))
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
@cached
|
||||||
|
def origin(self):
|
||||||
|
return self.top or self
|
||||||
|
|
||||||
|
def get_records(self):
|
||||||
|
""" proxy method, needed for input validation """
|
||||||
|
return self.records.all()
|
||||||
|
|
||||||
|
def get_topsubdomains(self):
|
||||||
|
""" proxy method, needed for input validation """
|
||||||
|
return self.origin.subdomains.all()
|
||||||
|
|
||||||
|
def get_subdomains(self):
|
||||||
|
return self.get_topsubdomains().filter(name__regex=r'.%s$' % self.name)
|
||||||
|
|
||||||
|
def render_zone(self):
|
||||||
|
origin = self.origin
|
||||||
|
zone = origin.render_records()
|
||||||
|
for subdomain in origin.get_topsubdomains():
|
||||||
|
zone += subdomain.render_records()
|
||||||
|
return zone
|
||||||
|
|
||||||
|
def refresh_serial(self):
|
||||||
|
""" Increases the domain serial number by one """
|
||||||
|
serial = utils.generate_zone_serial()
|
||||||
|
if serial <= self.serial:
|
||||||
|
num = int(str(self.serial)[8:]) + 1
|
||||||
|
if num >= 99:
|
||||||
|
raise ValueError('No more serial numbers for today')
|
||||||
|
serial = str(self.serial)[:8] + '%.2d' % num
|
||||||
|
serial = int(serial)
|
||||||
|
self.serial = serial
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def render_records(self):
|
||||||
|
types = {}
|
||||||
|
records = []
|
||||||
|
for record in self.get_records():
|
||||||
|
types[record.type] = True
|
||||||
|
if record.type == record.SOA:
|
||||||
|
# Update serial and insert at 0
|
||||||
|
value = record.value.split()
|
||||||
|
value[2] = str(self.serial)
|
||||||
|
records.insert(0, (record.SOA, ' '.join(value)))
|
||||||
|
else:
|
||||||
|
records.append((record.type, record.value))
|
||||||
|
if not self.top:
|
||||||
|
if Record.NS not in types:
|
||||||
|
for ns in settings.DOMAINS_DEFAULT_NS:
|
||||||
|
records.append((Record.NS, ns))
|
||||||
|
if Record.SOA not in types:
|
||||||
|
soa = [
|
||||||
|
"%s." % settings.DOMAINS_DEFAULT_NAME_SERVER,
|
||||||
|
utils.format_hostmaster(settings.DOMAINS_DEFAULT_HOSTMASTER),
|
||||||
|
str(self.serial),
|
||||||
|
settings.DOMAINS_DEFAULT_REFRESH,
|
||||||
|
settings.DOMAINS_DEFAULT_RETRY,
|
||||||
|
settings.DOMAINS_DEFAULT_EXPIRATION,
|
||||||
|
settings.DOMAINS_DEFAULT_MIN_CACHING_TIME
|
||||||
|
]
|
||||||
|
records.insert(0, (Record.SOA, ' '.join(soa)))
|
||||||
|
no_cname = Record.CNAME not in types
|
||||||
|
if Record.MX not in types and no_cname:
|
||||||
|
for mx in settings.DOMAINS_DEFAULT_MX:
|
||||||
|
records.append((Record.MX, mx))
|
||||||
|
if (Record.A not in types and Record.AAAA not in types) and no_cname:
|
||||||
|
records.append((Record.A, settings.DOMAINS_DEFAULT_A))
|
||||||
|
result = ''
|
||||||
|
for type, value in records:
|
||||||
|
name = '%s.%s' % (self.name, ' '*(37-len(self.name)))
|
||||||
|
type = '%s %s' % (type, ' '*(7-len(type)))
|
||||||
|
result += '%s IN %s %s\n' % (name, type, value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
""" create top relation """
|
||||||
|
update = False
|
||||||
|
if not self.pk:
|
||||||
|
top = self.get_top()
|
||||||
|
if top:
|
||||||
|
self.top = top
|
||||||
|
else:
|
||||||
|
update = True
|
||||||
|
super(Domain, self).save(*args, **kwargs)
|
||||||
|
if update:
|
||||||
|
domains = Domain.objects.exclude(pk=self.pk)
|
||||||
|
for domain in domains.filter(name__endswith=self.name):
|
||||||
|
domain.top = self
|
||||||
|
domain.save()
|
||||||
|
self.get_subdomains().update(account=self.account)
|
||||||
|
|
||||||
|
def get_top(self):
|
||||||
|
split = self.name.split('.')
|
||||||
|
top = None
|
||||||
|
for i in range(1, len(split)-1):
|
||||||
|
name = '.'.join(split[i:])
|
||||||
|
domain = Domain.objects.filter(name=name)
|
||||||
|
if domain:
|
||||||
|
top = domain.get()
|
||||||
|
return top
|
||||||
|
|
||||||
|
|
||||||
|
class Record(models.Model):
|
||||||
|
""" Represents a domain resource record """
|
||||||
|
MX = 'MX'
|
||||||
|
NS = 'NS'
|
||||||
|
CNAME = 'CNAME'
|
||||||
|
A = 'A'
|
||||||
|
AAAA = 'AAAA'
|
||||||
|
SRV = 'SRV'
|
||||||
|
TXT = 'TXT'
|
||||||
|
SOA = 'SOA'
|
||||||
|
|
||||||
|
TYPE_CHOICES = (
|
||||||
|
(MX, "MX"),
|
||||||
|
(NS, "NS"),
|
||||||
|
(CNAME, "CNAME"),
|
||||||
|
(A, _("A (IPv4 address)")),
|
||||||
|
(AAAA, _("AAAA (IPv6 address)")),
|
||||||
|
(SRV, "SRV"),
|
||||||
|
(TXT, "TXT"),
|
||||||
|
(SOA, "SOA"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO TTL
|
||||||
|
domain = models.ForeignKey(Domain, verbose_name=_("domain"), related_name='records')
|
||||||
|
type = models.CharField(max_length=32, choices=TYPE_CHOICES)
|
||||||
|
value = models.CharField(max_length=256)
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return "%s IN %s %s" % (self.domain, self.type, self.value)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
""" validates record value based on its type """
|
||||||
|
# validate value
|
||||||
|
mapp = {
|
||||||
|
self.MX: validators.validate_mx_record,
|
||||||
|
self.NS: validators.validate_zone_label,
|
||||||
|
self.A: validate_ipv4_address,
|
||||||
|
self.AAAA: validate_ipv6_address,
|
||||||
|
self.CNAME: validators.validate_zone_label,
|
||||||
|
self.TXT: validate_ascii,
|
||||||
|
self.SRV: validators.validate_srv_record,
|
||||||
|
self.SOA: validators.validate_soa_record,
|
||||||
|
}
|
||||||
|
mapp[self.type](self.value)
|
||||||
|
|
||||||
|
|
||||||
|
services.register(Domain)
|
40
orchestra/apps/domains/serializers.py
Normal file
40
orchestra/apps/domains/serializers.py
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from orchestra.apps.accounts.serializers import AccountSerializerMixin
|
||||||
|
|
||||||
|
from .helpers import domain_for_validation
|
||||||
|
from .models import Domain, Record
|
||||||
|
from . import validators
|
||||||
|
|
||||||
|
|
||||||
|
class RecordSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Record
|
||||||
|
fields = ('type', 'value')
|
||||||
|
|
||||||
|
def get_identity(self, data):
|
||||||
|
return data.get('value')
|
||||||
|
|
||||||
|
|
||||||
|
class DomainSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer):
|
||||||
|
""" Validates if this zone generates a correct zone file """
|
||||||
|
records = RecordSerializer(required=False, many=True, allow_add_remove=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Domain
|
||||||
|
fields = ('url', 'id', 'name', 'records')
|
||||||
|
|
||||||
|
def full_clean(self, instance):
|
||||||
|
""" Checks if everything is consistent """
|
||||||
|
instance = super(DomainSerializer, self).full_clean(instance)
|
||||||
|
if instance and instance.name:
|
||||||
|
records = self.init_data['records']
|
||||||
|
domain = domain_for_validation(instance, records)
|
||||||
|
try:
|
||||||
|
validators.validate_zone(domain.render_zone())
|
||||||
|
except ValidationError as err:
|
||||||
|
self._errors = { 'all': err.message }
|
||||||
|
return None
|
||||||
|
return instance
|
||||||
|
|
51
orchestra/apps/domains/settings.py
Normal file
51
orchestra/apps/domains/settings.py
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
DOMAINS_DEFAULT_NAME_SERVER = getattr(settings, 'DOMAINS_DEFAULT_NAME_SERVER',
|
||||||
|
'ns.example.com')
|
||||||
|
|
||||||
|
DOMAINS_DEFAULT_HOSTMASTER = getattr(settings, 'DOMAINS_DEFAULT_HOSTMASTER',
|
||||||
|
'hostmaster@example.com')
|
||||||
|
|
||||||
|
DOMAINS_DEFAULT_TTL = getattr(settings, 'DOMAINS_DEFAULT_TTL', '1h')
|
||||||
|
|
||||||
|
DOMAINS_DEFAULT_REFRESH = getattr(settings, 'DOMAINS_DEFAULT_REFRESH', '1d')
|
||||||
|
|
||||||
|
DOMAINS_DEFAULT_RETRY = getattr(settings, 'DOMAINS_DEFAULT_RETRY', '2h')
|
||||||
|
|
||||||
|
DOMAINS_DEFAULT_EXPIRATION = getattr(settings, 'DOMAINS_DEFAULT_EXPIRATION', '4w')
|
||||||
|
|
||||||
|
DOMAINS_DEFAULT_MIN_CACHING_TIME = getattr(settings, 'DOMAINS_DEFAULT_MIN_CACHING_TIME', '1h')
|
||||||
|
|
||||||
|
DOMAINS_ZONE_PATH = getattr(settings, 'DOMAINS_ZONE_PATH', '/etc/bind/master/%(name)s')
|
||||||
|
|
||||||
|
DOMAINS_MASTERS_PATH = getattr(settings, 'DOMAINS_MASTERS_PATH', '/etc/bind/named.conf.local')
|
||||||
|
|
||||||
|
DOMAINS_SLAVES_PATH = getattr(settings, 'DOMAINS_SLAVES_PATH', '/etc/bind/named.conf.local')
|
||||||
|
|
||||||
|
DOMAINS_MASTERS = getattr(settings, 'DOMAINS_MASTERS', ['10.0.3.13'])
|
||||||
|
|
||||||
|
DOMAINS_CHECKZONE_BIN_PATH = getattr(settings, 'DOMAINS_CHECKZONE_BIN_PATH',
|
||||||
|
'/usr/sbin/named-checkzone -i local')
|
||||||
|
|
||||||
|
DOMAINS_CHECKZONE_PATH = getattr(settings, 'DOMAINS_CHECKZONE_PATH', '/dev/shm')
|
||||||
|
|
||||||
|
DOMAINS_DEFAULT_A = getattr(settings, 'DOMAINS_DEFAULT_A', '10.0.3.13')
|
||||||
|
|
||||||
|
DOMAINS_DEFAULT_MX = getattr(settings, 'DOMAINS_DEFAULT_MX', (
|
||||||
|
'10 mail.orchestra.lan.',
|
||||||
|
'10 mail2.orchestra.lan.',
|
||||||
|
))
|
||||||
|
|
||||||
|
DOMAINS_DEFAULT_NS = getattr(settings, 'DOMAINS_DEFAULT_NS', (
|
||||||
|
'ns1.orchestra.lan.',
|
||||||
|
'ns2.orchestra.lan.',
|
||||||
|
))
|
||||||
|
|
||||||
|
DOMAINS_FORBIDDEN = getattr(settings, 'DOMAINS_FORBIDDEN',
|
||||||
|
# This setting prevents users from providing random domain names, i.e. google.com
|
||||||
|
# You can generate a 5K forbidden domains list from Alexa's top 1M
|
||||||
|
# wget http://s3.amazonaws.com/alexa-static/top-1m.csv.zip -O /tmp/top-1m.csv.zip
|
||||||
|
# unzip -p /tmp/top-1m.csv.zip | head -n 5000 | sed "s/^.*,//" > forbidden_domains.list
|
||||||
|
# '%(site_root)s/forbidden_domains.list')
|
||||||
|
'')
|
|
@ -0,0 +1,15 @@
|
||||||
|
{% extends "admin/change_form.html" %}
|
||||||
|
{% load i18n admin_urls admin_static admin_modify %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block object-tools-items %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'admin:domains_domain_view_zone' original.pk %}" class="historylink">{% trans "View zone" %}</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
{% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %}
|
||||||
|
<a href="{% add_preserved_filters history_url %}" class="historylink">{% trans "History" %}</a>
|
||||||
|
</li>
|
||||||
|
{% if has_absolute_url %}<li><a href="{{ absolute_url }}" class="viewsitelink">{% trans "View on site" %}</a></li>{% endif%}
|
||||||
|
{% endblock %}
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
{% extends "admin/base_site.html" %}
|
||||||
|
{% load i18n admin_urls %}
|
||||||
|
|
||||||
|
{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} delete-confirmation{% endblock %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
<div class="breadcrumbs">
|
||||||
|
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
|
||||||
|
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_label|capfirst }}</a>
|
||||||
|
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst|escape }}</a>
|
||||||
|
› <a href="{% url opts|admin_urlname:'change' object.pk|admin_urlquote %}">{{ object|truncatewords:"18" }}</a>
|
||||||
|
› {% trans 'View zone' %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<style> code,pre { font-size:1.1em; } </style>
|
||||||
|
<pre style="margin-left:20px;">
|
||||||
|
{{ object.render_zone }}
|
||||||
|
</pre>
|
||||||
|
{% endblock %}
|
||||||
|
|
0
orchestra/apps/domains/tests/__init__.py
Normal file
0
orchestra/apps/domains/tests/__init__.py
Normal file
299
orchestra/apps/domains/tests/functional_tests/tests.py
Normal file
299
orchestra/apps/domains/tests/functional_tests/tests.py
Normal file
|
@ -0,0 +1,299 @@
|
||||||
|
import functools
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from selenium.webdriver.support.select import Select
|
||||||
|
|
||||||
|
from orchestra.apps.orchestration.models import Server, Route
|
||||||
|
from orchestra.utils.tests import BaseLiveServerTestCase, random_ascii
|
||||||
|
from orchestra.utils.system import run
|
||||||
|
|
||||||
|
from orchestra.apps.domains import settings, utils, backends
|
||||||
|
from orchestra.apps.domains.models import Domain, Record
|
||||||
|
|
||||||
|
|
||||||
|
run = functools.partial(run, display=False)
|
||||||
|
|
||||||
|
|
||||||
|
class DomainTestMixin(object):
|
||||||
|
def setUp(self):
|
||||||
|
super(DomainTestMixin, self).setUp()
|
||||||
|
self.MASTER_ADDR = os.environ['ORCHESTRA_DNS_MASTER_ADDR']
|
||||||
|
self.SLAVE_ADDR = os.environ['ORCHESTRA_DNS_SLAVE_ADDR']
|
||||||
|
self.domain_name = 'orchestra%s.lan' % random_ascii(10)
|
||||||
|
self.domain_records = (
|
||||||
|
(Record.MX, '10 mail.orchestra.lan.'),
|
||||||
|
(Record.MX, '20 mail2.orchestra.lan.'),
|
||||||
|
(Record.NS, 'ns1.%s.' % self.domain_name),
|
||||||
|
(Record.NS, 'ns2.%s.' % self.domain_name),
|
||||||
|
)
|
||||||
|
self.domain_update_records = (
|
||||||
|
(Record.MX, '30 mail3.orchestra.lan.'),
|
||||||
|
(Record.MX, '40 mail4.orchestra.lan.'),
|
||||||
|
(Record.NS, 'ns1.%s.' % self.domain_name),
|
||||||
|
(Record.NS, 'ns2.%s.' % self.domain_name),
|
||||||
|
)
|
||||||
|
self.subdomain1_name = 'ns1.%s' % self.domain_name
|
||||||
|
self.subdomain1_records = (
|
||||||
|
(Record.A, '%s' % self.SLAVE_ADDR),
|
||||||
|
)
|
||||||
|
self.subdomain2_name = 'ns2.%s' % self.domain_name
|
||||||
|
self.subdomain2_records = (
|
||||||
|
(Record.A, '%s' % self.MASTER_ADDR),
|
||||||
|
)
|
||||||
|
self.subdomain3_name = 'www.%s' % self.domain_name
|
||||||
|
self.subdomain3_records = (
|
||||||
|
(Record.CNAME, 'external.server.org.'),
|
||||||
|
)
|
||||||
|
self.second_domain_name = 'django%s.lan' % random_ascii(10)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
try:
|
||||||
|
self.delete(self.domain_name)
|
||||||
|
except Domain.DoesNotExist:
|
||||||
|
pass
|
||||||
|
super(DomainTestMixin, self).tearDown()
|
||||||
|
|
||||||
|
def add_route(self):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def add(self, domain_name, records):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def delete(self, domain_name, records):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def update(self, domain_name, records):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def validate_add(self, server_addr, domain_name):
|
||||||
|
context = {
|
||||||
|
'domain_name': domain_name,
|
||||||
|
'server_addr': server_addr
|
||||||
|
}
|
||||||
|
dig_soa = 'dig @%(server_addr)s %(domain_name)s SOA|grep "\sSOA\s"'
|
||||||
|
soa = run(dig_soa % context).stdout.split()
|
||||||
|
# testdomain.org. 3600 IN SOA ns.example.com. hostmaster.example.com. 2014021100 86400 7200 2419200 3600
|
||||||
|
self.assertEqual('%(domain_name)s.' % context, soa[0])
|
||||||
|
self.assertEqual('3600', soa[1])
|
||||||
|
self.assertEqual('IN', soa[2])
|
||||||
|
self.assertEqual('SOA', soa[3])
|
||||||
|
self.assertEqual('%s.' % settings.DOMAINS_DEFAULT_NAME_SERVER, soa[4])
|
||||||
|
hostmaster = utils.format_hostmaster(settings.DOMAINS_DEFAULT_HOSTMASTER)
|
||||||
|
self.assertEqual(hostmaster, soa[5])
|
||||||
|
|
||||||
|
dig_ns = 'dig @%(server_addr)s %(domain_name)s NS|grep "\sNS\s"'
|
||||||
|
name_servers = run(dig_ns % context).stdout
|
||||||
|
# testdomain.org. 3600 IN NS ns1.orchestra.lan.
|
||||||
|
ns_records = ['ns1.%s.' % self.domain_name, 'ns2.%s.' % self.domain_name]
|
||||||
|
self.assertEqual(2, len(name_servers.splitlines()))
|
||||||
|
for ns in name_servers.splitlines():
|
||||||
|
ns = ns.split()
|
||||||
|
# testdomain.org. 3600 IN NS ns1.orchestra.lan.
|
||||||
|
self.assertEqual('%(domain_name)s.' % context, ns[0])
|
||||||
|
self.assertEqual('3600', ns[1])
|
||||||
|
self.assertEqual('IN', ns[2])
|
||||||
|
self.assertEqual('NS', ns[3])
|
||||||
|
self.assertIn(ns[4], ns_records)
|
||||||
|
|
||||||
|
dig_mx = 'dig @%(server_addr)s %(domain_name)s MX|grep "\sMX\s"'
|
||||||
|
mail_servers = run(dig_mx % context).stdout
|
||||||
|
for mx in mail_servers.splitlines():
|
||||||
|
mx = mx.split()
|
||||||
|
# testdomain.org. 3600 IN NS ns1.orchestra.lan.
|
||||||
|
self.assertEqual('%(domain_name)s.' % context, mx[0])
|
||||||
|
self.assertEqual('3600', mx[1])
|
||||||
|
self.assertEqual('IN', mx[2])
|
||||||
|
self.assertEqual('MX', mx[3])
|
||||||
|
self.assertIn(mx[4], ['10', '20'])
|
||||||
|
self.assertIn(mx[5], ['mail2.orchestra.lan.', 'mail.orchestra.lan.'])
|
||||||
|
|
||||||
|
def validate_delete(self, server_addr, domain_name):
|
||||||
|
context = {
|
||||||
|
'domain_name': domain_name,
|
||||||
|
'server_addr': server_addr
|
||||||
|
}
|
||||||
|
dig_soa = 'dig @%(server_addr)s %(domain_name)s|grep "\sSOA\s"'
|
||||||
|
soa = run(dig_soa % context, error_codes=[0,1]).stdout
|
||||||
|
if soa:
|
||||||
|
soa = soa.split()
|
||||||
|
self.assertEqual('IN', soa[2])
|
||||||
|
self.assertEqual('SOA', soa[3])
|
||||||
|
self.assertNotEqual('%s.' % settings.DOMAINS_DEFAULT_NAME_SERVER, soa[4])
|
||||||
|
hostmaster = utils.format_hostmaster(settings.DOMAINS_DEFAULT_HOSTMASTER)
|
||||||
|
self.assertNotEqual(hostmaster, soa[5])
|
||||||
|
|
||||||
|
def validate_update(self, server_addr, domain_name):
|
||||||
|
domain = Domain.objects.get(name=domain_name)
|
||||||
|
context = {
|
||||||
|
'domain_name': domain_name,
|
||||||
|
'server_addr': server_addr
|
||||||
|
}
|
||||||
|
dig_soa = 'dig @%(server_addr)s %(domain_name)s SOA|grep "\sSOA\s"'
|
||||||
|
soa = run(dig_soa % context).stdout.split()
|
||||||
|
# testdomain.org. 3600 IN SOA ns.example.com. hostmaster.example.com. 2014021100 86400 7200 2419200 3600
|
||||||
|
self.assertEqual('%(domain_name)s.' % context, soa[0])
|
||||||
|
self.assertEqual('3600', soa[1])
|
||||||
|
self.assertEqual('IN', soa[2])
|
||||||
|
self.assertEqual('SOA', soa[3])
|
||||||
|
self.assertEqual('%s.' % settings.DOMAINS_DEFAULT_NAME_SERVER, soa[4])
|
||||||
|
hostmaster = utils.format_hostmaster(settings.DOMAINS_DEFAULT_HOSTMASTER)
|
||||||
|
self.assertEqual(hostmaster, soa[5])
|
||||||
|
|
||||||
|
dig_ns = 'dig @%(server_addr)s %(domain_name)s NS|grep "\sNS\s"'
|
||||||
|
name_servers = run(dig_ns % context).stdout
|
||||||
|
ns_records = ['ns1.%s.' % self.domain_name, 'ns2.%s.' % self.domain_name]
|
||||||
|
self.assertEqual(2, len(name_servers.splitlines()))
|
||||||
|
for ns in name_servers.splitlines():
|
||||||
|
ns = ns.split()
|
||||||
|
# testdomain.org. 3600 IN NS ns1.orchestra.lan.
|
||||||
|
self.assertEqual('%(domain_name)s.' % context, ns[0])
|
||||||
|
self.assertEqual('3600', ns[1])
|
||||||
|
self.assertEqual('IN', ns[2])
|
||||||
|
self.assertEqual('NS', ns[3])
|
||||||
|
self.assertIn(ns[4], ns_records)
|
||||||
|
|
||||||
|
dig_mx = 'dig @%(server_addr)s %(domain_name)s MX|grep "\sMX\s"'
|
||||||
|
mx = run(dig_mx % context).stdout.split()
|
||||||
|
# testdomain.org. 3600 IN MX 10 orchestra.lan.
|
||||||
|
self.assertEqual('%(domain_name)s.' % context, mx[0])
|
||||||
|
self.assertEqual('3600', mx[1])
|
||||||
|
self.assertEqual('IN', mx[2])
|
||||||
|
self.assertEqual('MX', mx[3])
|
||||||
|
self.assertIn(mx[4], ['30', '40'])
|
||||||
|
self.assertIn(mx[5], ['mail3.orchestra.lan.', 'mail4.orchestra.lan.'])
|
||||||
|
|
||||||
|
dig_cname = 'dig @%(server_addr)s www.%(domain_name)s CNAME|grep "\sCNAME\s"'
|
||||||
|
cname = run(dig_cname % context).stdout.split()
|
||||||
|
# testdomain.org. 3600 IN MX 10 orchestra.lan.
|
||||||
|
self.assertEqual('www.%(domain_name)s.' % context, cname[0])
|
||||||
|
self.assertEqual('3600', cname[1])
|
||||||
|
self.assertEqual('IN', cname[2])
|
||||||
|
self.assertEqual('CNAME', cname[3])
|
||||||
|
self.assertEqual('external.server.org.', cname[4])
|
||||||
|
|
||||||
|
def test_add(self):
|
||||||
|
self.add(self.subdomain1_name, self.subdomain1_records)
|
||||||
|
self.add(self.subdomain2_name, self.subdomain2_records)
|
||||||
|
self.add(self.domain_name, self.domain_records)
|
||||||
|
self.validate_add(self.MASTER_ADDR, self.domain_name)
|
||||||
|
self.validate_add(self.SLAVE_ADDR, self.domain_name)
|
||||||
|
|
||||||
|
def test_delete(self):
|
||||||
|
self.add(self.subdomain1_name, self.subdomain1_records)
|
||||||
|
self.add(self.subdomain2_name, self.subdomain2_records)
|
||||||
|
self.add(self.domain_name, self.domain_records)
|
||||||
|
self.delete(self.domain_name)
|
||||||
|
for name in [self.domain_name, self.subdomain1_name, self.subdomain2_name]:
|
||||||
|
self.validate_delete(self.MASTER_ADDR, name)
|
||||||
|
self.validate_delete(self.SLAVE_ADDR, name)
|
||||||
|
|
||||||
|
def test_update(self):
|
||||||
|
self.add(self.subdomain1_name, self.subdomain1_records)
|
||||||
|
self.add(self.subdomain2_name, self.subdomain2_records)
|
||||||
|
self.add(self.domain_name, self.domain_records)
|
||||||
|
self.update(self.domain_name, self.domain_update_records)
|
||||||
|
self.add(self.subdomain3_name, self.subdomain3_records)
|
||||||
|
self.validate_update(self.MASTER_ADDR, self.domain_name)
|
||||||
|
time.sleep(5)
|
||||||
|
self.validate_update(self.SLAVE_ADDR, self.domain_name)
|
||||||
|
|
||||||
|
def test_add_add_delete_delete(self):
|
||||||
|
self.add(self.subdomain1_name, self.subdomain1_records)
|
||||||
|
self.add(self.subdomain2_name, self.subdomain2_records)
|
||||||
|
self.add(self.domain_name, self.domain_records)
|
||||||
|
self.add(self.second_domain_name, self.domain_records)
|
||||||
|
self.delete(self.domain_name)
|
||||||
|
self.validate_add(self.MASTER_ADDR, self.second_domain_name)
|
||||||
|
self.validate_add(self.SLAVE_ADDR, self.second_domain_name)
|
||||||
|
self.delete(self.second_domain_name)
|
||||||
|
self.validate_delete(self.MASTER_ADDR, self.second_domain_name)
|
||||||
|
self.validate_delete(self.SLAVE_ADDR, self.second_domain_name)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminDomainMixin(DomainTestMixin):
|
||||||
|
def setUp(self):
|
||||||
|
super(AdminDomainMixin, self).setUp()
|
||||||
|
self.add_route()
|
||||||
|
self.admin_login()
|
||||||
|
|
||||||
|
def _add_records(self, records):
|
||||||
|
self.selenium.find_element_by_link_text('Add another Record').click()
|
||||||
|
for i, record in zip(range(0, len(records)), records):
|
||||||
|
type, value = record
|
||||||
|
type_input = self.selenium.find_element_by_id('id_records-%d-type' % i)
|
||||||
|
type_select = Select(type_input)
|
||||||
|
type_select.select_by_value(type)
|
||||||
|
value_input = self.selenium.find_element_by_id('id_records-%d-value' % i)
|
||||||
|
value_input.clear()
|
||||||
|
value_input.send_keys(value)
|
||||||
|
return value_input
|
||||||
|
|
||||||
|
def add(self, domain_name, records):
|
||||||
|
url = self.live_server_url + '/admin/domains/domain/add/'
|
||||||
|
self.selenium.get(url)
|
||||||
|
name = self.selenium.find_element_by_id('id_name')
|
||||||
|
name.send_keys(domain_name)
|
||||||
|
value_input = self._add_records(records)
|
||||||
|
value_input.submit()
|
||||||
|
self.assertNotEqual(url, self.selenium.current_url)
|
||||||
|
|
||||||
|
def delete(self, domain_name):
|
||||||
|
domain = Domain.objects.get(name=domain_name)
|
||||||
|
url = self.live_server_url + '/admin/domains/domain/%d/delete/' % domain.pk
|
||||||
|
self.selenium.get(url)
|
||||||
|
form = self.selenium.find_element_by_name('post')
|
||||||
|
form.submit()
|
||||||
|
self.assertNotEqual(url, self.selenium.current_url)
|
||||||
|
|
||||||
|
def update(self, domain_name, records):
|
||||||
|
domain = Domain.objects.get(name=domain_name)
|
||||||
|
url = self.live_server_url + '/admin/domains/domain/%d/' % domain.pk
|
||||||
|
self.selenium.get(url)
|
||||||
|
value_input = self._add_records(records)
|
||||||
|
value_input.submit()
|
||||||
|
self.assertNotEqual(url, self.selenium.current_url)
|
||||||
|
|
||||||
|
|
||||||
|
class RESTDomainMixin(DomainTestMixin):
|
||||||
|
def setUp(self):
|
||||||
|
super(RESTDomainMixin, self).setUp()
|
||||||
|
self.rest_login()
|
||||||
|
self.add_route()
|
||||||
|
|
||||||
|
def add(self, domain_name, records):
|
||||||
|
records = [ dict(type=type, value=value) for type,value in records ]
|
||||||
|
self.rest.domains.create(name=domain_name, records=records)
|
||||||
|
|
||||||
|
def delete(self, domain_name):
|
||||||
|
domain = Domain.objects.get(name=domain_name)
|
||||||
|
domain = self.rest.domains.retrieve(id=domain.pk)
|
||||||
|
domain.delete()
|
||||||
|
|
||||||
|
def update(self, domain_name, records):
|
||||||
|
records = [ dict(type=type, value=value) for type,value in records ]
|
||||||
|
domains = self.rest.domains.retrieve(name=domain_name)
|
||||||
|
domain = domains.get()
|
||||||
|
domain.update(records=records)
|
||||||
|
|
||||||
|
|
||||||
|
class Bind9BackendMixin(object):
|
||||||
|
DEPENDENCIES = (
|
||||||
|
'orchestra.apps.orchestration',
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_route(self):
|
||||||
|
master = Server.objects.create(name=self.MASTER_ADDR)
|
||||||
|
backend = backends.Bind9MasterDomainBackend.get_name()
|
||||||
|
Route.objects.create(backend=backend, match=True, host=master)
|
||||||
|
slave = Server.objects.create(name=self.SLAVE_ADDR)
|
||||||
|
backend = backends.Bind9SlaveDomainBackend.get_name()
|
||||||
|
Route.objects.create(backend=backend, match=True, host=slave)
|
||||||
|
|
||||||
|
|
||||||
|
class RESTBind9BackendDomainTest(Bind9BackendMixin, RESTDomainMixin, BaseLiveServerTestCase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AdminBind9BackendDomainest(Bind9BackendMixin, AdminDomainMixin, BaseLiveServerTestCase):
|
||||||
|
pass
|
18
orchestra/apps/domains/tests/test_domains.py
Normal file
18
orchestra/apps/domains/tests/test_domains.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
from django.db import IntegrityError, transaction
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from ..models import Domain
|
||||||
|
|
||||||
|
|
||||||
|
class DomainTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.domain = Domain.objects.create(name='rostrepalid.org')
|
||||||
|
Domain.objects.create(name='www.rostrepalid.org')
|
||||||
|
Domain.objects.create(name='mail.rostrepalid.org')
|
||||||
|
|
||||||
|
def test_top_relation(self):
|
||||||
|
self.assertEqual(2, len(self.domain.subdomains.all()))
|
||||||
|
|
||||||
|
def test_render_zone(self):
|
||||||
|
print self.domain.render_zone()
|
||||||
|
|
26
orchestra/apps/domains/utils.py
Normal file
26
orchestra/apps/domains/utils.py
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
|
||||||
|
def generate_zone_serial():
|
||||||
|
today = datetime.date.today()
|
||||||
|
return int("%.4d%.2d%.2d%.2d" % (today.year, today.month, today.day, 0))
|
||||||
|
|
||||||
|
|
||||||
|
def format_hostmaster(hostmaster):
|
||||||
|
"""
|
||||||
|
The DNS encodes the <local-part> as a single label, and encodes the
|
||||||
|
<mail-domain> as a domain name. The single label from the <local-part>
|
||||||
|
is prefaced to the domain name from <mail-domain> to form the domain
|
||||||
|
name corresponding to the mailbox. Thus the mailbox HOSTMASTER@SRI-
|
||||||
|
NIC.ARPA is mapped into the domain name HOSTMASTER.SRI-NIC.ARPA. If the
|
||||||
|
<local-part> contains dots or other special characters, its
|
||||||
|
representation in a master file will require the use of backslash
|
||||||
|
quoting to ensure that the domain name is properly encoded. For
|
||||||
|
example, the mailbox Action.domains@ISI.EDU would be represented as
|
||||||
|
Action\.domains.ISI.EDU.
|
||||||
|
http://www.ietf.org/rfc/rfc1035.txt
|
||||||
|
"""
|
||||||
|
name, domain = hostmaster.split('@')
|
||||||
|
if '.' in name:
|
||||||
|
name = name.replace('.', '\.')
|
||||||
|
return "%s.%s." % (name, domain)
|
108
orchestra/apps/domains/validators.py
Normal file
108
orchestra/apps/domains/validators.py
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from orchestra.utils import paths
|
||||||
|
from orchestra.utils.system import run
|
||||||
|
|
||||||
|
from . import settings
|
||||||
|
|
||||||
|
|
||||||
|
def validate_allowed_domain(value):
|
||||||
|
context = {
|
||||||
|
'site_root': paths.get_site_root()
|
||||||
|
}
|
||||||
|
fname = settings.DOMAINS_FORBIDDEN
|
||||||
|
if fname:
|
||||||
|
fname = fname % context
|
||||||
|
with open(fname, 'r') as forbidden:
|
||||||
|
for domain in forbidden.readlines():
|
||||||
|
if re.match(r'^(.*\.)*%s$' % domain.strip(), value):
|
||||||
|
raise ValidationError(_("This domain name is not allowed"))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_zone_interval(value):
|
||||||
|
try:
|
||||||
|
int(value)
|
||||||
|
except ValueError:
|
||||||
|
value, magnitude = value[:-1], value[-1]
|
||||||
|
if magnitude not in ('s', 'm', 'h', 'd', 'w') or not value.isdigit():
|
||||||
|
msg = _("%s is not an appropiate zone interval value") % value
|
||||||
|
raise ValidationError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_zone_label(value):
|
||||||
|
"""
|
||||||
|
http://www.ietf.org/rfc/rfc1035.txt
|
||||||
|
The labels must follow the rules for ARPANET host names. They must
|
||||||
|
start with a letter, end with a letter or digit, and have as interior
|
||||||
|
characters only letters, digits, and hyphen. There are also some
|
||||||
|
restrictions on the length. Labels must be 63 characters or less.
|
||||||
|
"""
|
||||||
|
if not re.match(r'^[a-z][\.\-0-9a-z]*[\.0-9a-z]$', value):
|
||||||
|
msg = _("Labels must start with a letter, end with a letter or digit, "
|
||||||
|
"and have as interior characters only letters, digits, and hyphen")
|
||||||
|
raise ValidationError(msg)
|
||||||
|
if not value.endswith('.'):
|
||||||
|
msg = _("Use a fully expanded domain name ending with a dot")
|
||||||
|
raise ValidationError(msg)
|
||||||
|
if len(value) > 63:
|
||||||
|
raise ValidationError(_("Labels must be 63 characters or less"))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_mx_record(value):
|
||||||
|
msg = _("%s is not an appropiate MX record value") % value
|
||||||
|
value = value.split()
|
||||||
|
if len(value) == 1:
|
||||||
|
value = value[0]
|
||||||
|
elif len(value) == 2:
|
||||||
|
try:
|
||||||
|
int(value[0])
|
||||||
|
except ValueError:
|
||||||
|
raise ValidationError(msg)
|
||||||
|
value = value[1]
|
||||||
|
elif len(value) > 2:
|
||||||
|
raise ValidationError(msg)
|
||||||
|
validate_zone_label(value)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_srv_record(value):
|
||||||
|
# 1 0 9 server.example.com.
|
||||||
|
msg = _("%s is not an appropiate SRV record value") % value
|
||||||
|
value = value.split()
|
||||||
|
for i in [0,1,2]:
|
||||||
|
try:
|
||||||
|
int(value[i])
|
||||||
|
except ValueError:
|
||||||
|
raise ValidationError(msg)
|
||||||
|
validate_zone_label(value[-1])
|
||||||
|
|
||||||
|
|
||||||
|
def validate_soa_record(value):
|
||||||
|
# ns1.pangea.ORG. hostmaster.pangea.ORG. 2012010401 28800 7200 604800 86400
|
||||||
|
msg = _("%s is not an appropiate SRV record value") % value
|
||||||
|
values = value.split()
|
||||||
|
if len(values) != 7:
|
||||||
|
raise ValidationError(msg)
|
||||||
|
validate_zone_label(values[0])
|
||||||
|
validate_zone_label(values[1])
|
||||||
|
for value in values[2:]:
|
||||||
|
try:
|
||||||
|
int(value)
|
||||||
|
except ValueError:
|
||||||
|
raise ValidationError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_zone(zone):
|
||||||
|
""" Ultimate zone file validation using named-checkzone """
|
||||||
|
zone_name = zone.split()[0][:-1]
|
||||||
|
path = os.path.join(settings.DOMAINS_CHECKZONE_PATH, zone_name)
|
||||||
|
with open(path, 'wb') as f:
|
||||||
|
f.write(zone)
|
||||||
|
checkzone = settings.DOMAINS_CHECKZONE_BIN_PATH
|
||||||
|
check = run(' '.join([checkzone, zone_name, path]), error_codes=[0,1], display=False)
|
||||||
|
if check.return_code == 1:
|
||||||
|
errors = re.compile(r'zone.*: (.*)').findall(check.stdout)[:-1]
|
||||||
|
raise ValidationError(', '.join(errors))
|
1
orchestra/apps/issues/__init__.py
Normal file
1
orchestra/apps/issues/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
REQUIRED_APPS = ['slices']
|
109
orchestra/apps/issues/actions.py
Normal file
109
orchestra/apps/issues/actions.py
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from orchestra.admin.decorators import action_with_confirmation
|
||||||
|
|
||||||
|
from .forms import ChangeReasonForm
|
||||||
|
from .helpers import markdown_formated_changes
|
||||||
|
from .models import Queue, Ticket
|
||||||
|
|
||||||
|
|
||||||
|
def change_ticket_state_factory(action, final_state):
|
||||||
|
context = {
|
||||||
|
'action': action,
|
||||||
|
'form': ChangeReasonForm()
|
||||||
|
}
|
||||||
|
@transaction.atomic
|
||||||
|
@action_with_confirmation(action, extra_context=context)
|
||||||
|
def change_ticket_state(modeladmin, request, queryset, action=action, final_state=final_state):
|
||||||
|
form = ChangeReasonForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
reason = form.cleaned_data['reason']
|
||||||
|
for ticket in queryset:
|
||||||
|
if ticket.state != final_state:
|
||||||
|
changes = {'state': (ticket.state, final_state)}
|
||||||
|
is_read = ticket.is_read_by(request.user)
|
||||||
|
getattr(ticket, action)()
|
||||||
|
modeladmin.log_change(request, ticket, "Marked as %s" % final_state.lower())
|
||||||
|
content = markdown_formated_changes(changes)
|
||||||
|
content += reason
|
||||||
|
ticket.messages.create(content=content, author=request.user)
|
||||||
|
if is_read and not ticket.is_read_by(request.user):
|
||||||
|
ticket.mark_as_read_by(request.user)
|
||||||
|
msg = "%s selected tickets are now %s." % (queryset.count(), final_state.lower())
|
||||||
|
modeladmin.message_user(request, msg)
|
||||||
|
else:
|
||||||
|
context['form'] = form
|
||||||
|
# action_with_confirmation must display form validation errors
|
||||||
|
return True
|
||||||
|
change_ticket_state.url_name = action
|
||||||
|
change_ticket_state.verbose_name = u'%s\u2026' % action
|
||||||
|
change_ticket_state.short_description = '%s selected tickets' % action.capitalize()
|
||||||
|
change_ticket_state.description = 'Mark ticket as %s.' % final_state.lower()
|
||||||
|
change_ticket_state.__name__ = action
|
||||||
|
return change_ticket_state
|
||||||
|
|
||||||
|
|
||||||
|
action_map = {
|
||||||
|
Ticket.RESOLVED: 'resolve',
|
||||||
|
Ticket.REJECTED: 'reject',
|
||||||
|
Ticket.CLOSED: 'close' }
|
||||||
|
|
||||||
|
|
||||||
|
thismodule = sys.modules[__name__]
|
||||||
|
for state, name in action_map.items():
|
||||||
|
action = change_ticket_state_factory(name, state)
|
||||||
|
setattr(thismodule, '%s_tickets' % name, action)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def take_tickets(modeladmin, request, queryset):
|
||||||
|
for ticket in queryset:
|
||||||
|
if ticket.owner != request.user:
|
||||||
|
changes = {'owner': (ticket.owner, request.user)}
|
||||||
|
is_read = ticket.is_read_by(request.user)
|
||||||
|
ticket.take(request.user)
|
||||||
|
modeladmin.log_change(request, ticket, "Taken")
|
||||||
|
content = markdown_formated_changes(changes)
|
||||||
|
ticket.messages.create(content=content, author=request.user)
|
||||||
|
if is_read and not ticket.is_read_by(request.user):
|
||||||
|
ticket.mark_as_read_by(request.user)
|
||||||
|
msg = "%s selected tickets are now owned by %s." % (queryset.count(), request.user)
|
||||||
|
modeladmin.message_user(request, msg)
|
||||||
|
take_tickets.url_name = 'take'
|
||||||
|
take_tickets.short_description = 'Take selected tickets'
|
||||||
|
take_tickets.description = 'Make yourself owner of the ticket.'
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def mark_as_unread(modeladmin, request, queryset):
|
||||||
|
""" Mark a tickets as unread """
|
||||||
|
for ticket in queryset:
|
||||||
|
ticket.mark_as_unread_by(request.user)
|
||||||
|
msg = "%s selected tickets have been marked as unread." % queryset.count()
|
||||||
|
modeladmin.message_user(request, msg)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def mark_as_read(modeladmin, request, queryset):
|
||||||
|
""" Mark a tickets as unread """
|
||||||
|
for ticket in queryset:
|
||||||
|
ticket.mark_as_read_by(request.user)
|
||||||
|
msg = "%s selected tickets have been marked as read." % queryset.count()
|
||||||
|
modeladmin.message_user(request, msg)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def set_default_queue(modeladmin, request, queryset):
|
||||||
|
""" Set a queue as default issues queue """
|
||||||
|
if queryset.count() != 1:
|
||||||
|
messages.warning(request, "Please, select only one queue.")
|
||||||
|
return
|
||||||
|
Queue.objects.filter(default=True).update(default=False)
|
||||||
|
queue = queryset.get()
|
||||||
|
queue.default = True
|
||||||
|
queue.save()
|
||||||
|
modeladmin.log_change(request, queue, "Chosen as default.")
|
||||||
|
messages.info(request, "Chosen '%s' as default queue." % queue)
|
337
orchestra/apps/issues/admin.py
Normal file
337
orchestra/apps/issues/admin.py
Normal file
|
@ -0,0 +1,337 @@
|
||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.conf.urls import patterns
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.core.urlresolvers import reverse
|
||||||
|
from django.db import models
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.utils.html import strip_tags
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
from markdown import markdown
|
||||||
|
|
||||||
|
from orchestra.admin import ChangeListDefaultFilter, ExtendedModelAdmin#, ChangeViewActions
|
||||||
|
from orchestra.admin.utils import (link, colored, wrap_admin_view, display_timesince)
|
||||||
|
|
||||||
|
from .actions import (reject_tickets, resolve_tickets, take_tickets, close_tickets,
|
||||||
|
mark_as_unread, mark_as_read, set_default_queue)
|
||||||
|
from .filters import MyTicketsListFilter, TicketStateListFilter
|
||||||
|
from .forms import MessageInlineForm, TicketForm
|
||||||
|
from .helpers import get_ticket_changes, markdown_formated_changes, filter_actions
|
||||||
|
from .models import Ticket, Queue, Message
|
||||||
|
|
||||||
|
|
||||||
|
PRIORITY_COLORS = {
|
||||||
|
Ticket.HIGH: 'red',
|
||||||
|
Ticket.MEDIUM: 'darkorange',
|
||||||
|
Ticket.LOW: 'green',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
STATE_COLORS = {
|
||||||
|
Ticket.NEW: 'grey',
|
||||||
|
Ticket.IN_PROGRESS: 'darkorange',
|
||||||
|
Ticket.FEEDBACK: 'purple',
|
||||||
|
Ticket.RESOLVED: 'green',
|
||||||
|
Ticket.REJECTED: 'firebrick',
|
||||||
|
Ticket.CLOSED: 'grey',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MessageReadOnlyInline(admin.TabularInline):
|
||||||
|
model = Message
|
||||||
|
extra = 0
|
||||||
|
can_delete = False
|
||||||
|
fields = ['content_html']
|
||||||
|
readonly_fields = ['content_html']
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
css = {
|
||||||
|
'all': ('orchestra/css/hide-inline-id.css',)
|
||||||
|
}
|
||||||
|
|
||||||
|
def content_html(self, obj):
|
||||||
|
context = {
|
||||||
|
'num': obj.num,
|
||||||
|
'time': display_timesince(obj.created_on),
|
||||||
|
'author': link('author')(self, obj),
|
||||||
|
}
|
||||||
|
summary = _("#%(num)i Updated by %(author)s about %(time)s") % context
|
||||||
|
header = '<strong style="color:#666;">%s</strong><hr />' % summary
|
||||||
|
content = markdown(obj.content)
|
||||||
|
content = content.replace('>\n', '>')
|
||||||
|
return header + content
|
||||||
|
content_html.short_description = _("Content")
|
||||||
|
content_html.allow_tags = True
|
||||||
|
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class MessageInline(admin.TabularInline):
|
||||||
|
model = Message
|
||||||
|
extra = 1
|
||||||
|
max_num = 1
|
||||||
|
form = MessageInlineForm
|
||||||
|
can_delete = False
|
||||||
|
fields = ['content']
|
||||||
|
|
||||||
|
def get_formset(self, request, obj=None, **kwargs):
|
||||||
|
""" hook request.user on the inline form """
|
||||||
|
self.form.user = request.user
|
||||||
|
return super(MessageInline, self).get_formset(request, obj, **kwargs)
|
||||||
|
|
||||||
|
def queryset(self, request):
|
||||||
|
""" Don't show any message """
|
||||||
|
qs = super(MessageInline, self).queryset(request)
|
||||||
|
return qs.none()
|
||||||
|
|
||||||
|
|
||||||
|
class TicketInline(admin.TabularInline):
|
||||||
|
fields = [
|
||||||
|
'ticket_id', 'subject', 'creator_link', 'owner_link', 'colored_state',
|
||||||
|
'colored_priority', 'created', 'last_modified'
|
||||||
|
]
|
||||||
|
readonly_fields = [
|
||||||
|
'ticket_id', 'subject', 'creator_link', 'owner_link', 'colored_state',
|
||||||
|
'colored_priority', 'created', 'last_modified'
|
||||||
|
]
|
||||||
|
model = Ticket
|
||||||
|
extra = 0
|
||||||
|
max_num = 0
|
||||||
|
|
||||||
|
creator_link = link('creator')
|
||||||
|
owner_link = link('owner')
|
||||||
|
|
||||||
|
def ticket_id(self, instance):
|
||||||
|
return mark_safe('<b>%s</b>' % link()(self, instance))
|
||||||
|
ticket_id.short_description = '#'
|
||||||
|
|
||||||
|
def colored_state(self, instance):
|
||||||
|
return colored('state', STATE_COLORS, bold=False)(instance)
|
||||||
|
colored_state.short_description = _("State")
|
||||||
|
|
||||||
|
def colored_priority(self, instance):
|
||||||
|
return colored('priority', PRIORITY_COLORS, bold=False)(instance)
|
||||||
|
colored_priority.short_description = _("Priority")
|
||||||
|
|
||||||
|
def created(self, instance):
|
||||||
|
return display_timesince(instance.created_on)
|
||||||
|
|
||||||
|
def last_modified(self, instance):
|
||||||
|
return display_timesince(instance.last_modified_on)
|
||||||
|
|
||||||
|
|
||||||
|
class TicketAdmin(ChangeListDefaultFilter, ExtendedModelAdmin): #TODO ChangeViewActions,
|
||||||
|
list_display = [
|
||||||
|
'unbold_id', 'bold_subject', 'display_creator', 'display_owner',
|
||||||
|
'display_queue', 'display_priority', 'display_state', 'last_modified'
|
||||||
|
]
|
||||||
|
list_display_links = ('unbold_id', 'bold_subject')
|
||||||
|
list_filter = [
|
||||||
|
MyTicketsListFilter, 'queue__name', 'priority', TicketStateListFilter,
|
||||||
|
]
|
||||||
|
default_changelist_filters = (
|
||||||
|
('my_tickets', lambda r: 'True' if not r.user.is_superuser else 'False'),
|
||||||
|
('state', 'OPEN')
|
||||||
|
)
|
||||||
|
date_hierarchy = 'created_on'
|
||||||
|
search_fields = [
|
||||||
|
'id', 'subject', 'creator__username', 'creator__email', 'queue__name',
|
||||||
|
'owner__username'
|
||||||
|
]
|
||||||
|
actions = [
|
||||||
|
mark_as_unread, mark_as_read, 'delete_selected', reject_tickets,
|
||||||
|
resolve_tickets, close_tickets, take_tickets
|
||||||
|
]
|
||||||
|
sudo_actions = ['delete_selected']
|
||||||
|
change_view_actions = [
|
||||||
|
resolve_tickets, close_tickets, reject_tickets, take_tickets
|
||||||
|
]
|
||||||
|
# change_form_template = "admin/orchestra/change_form.html"
|
||||||
|
form = TicketForm
|
||||||
|
add_inlines = []
|
||||||
|
inlines = [ MessageReadOnlyInline, MessageInline ]
|
||||||
|
readonly_fields = (
|
||||||
|
'display_summary', 'display_queue', 'display_owner', 'display_state',
|
||||||
|
'display_priority'
|
||||||
|
)
|
||||||
|
readonly_fieldsets = (
|
||||||
|
(None, {
|
||||||
|
'classes': ('wide',),
|
||||||
|
'fields': ('display_summary',
|
||||||
|
('display_queue', 'display_owner'),
|
||||||
|
('display_state', 'display_priority'),
|
||||||
|
'display_description')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
fieldsets = readonly_fieldsets + (
|
||||||
|
('Update', {
|
||||||
|
'classes': ('collapse', 'wide'),
|
||||||
|
'fields': ('subject',
|
||||||
|
('queue', 'owner',),
|
||||||
|
('state', 'priority'),
|
||||||
|
'description')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
add_fieldsets = (
|
||||||
|
(None, {
|
||||||
|
'classes': ('wide',),
|
||||||
|
'fields': ('subject',
|
||||||
|
('queue', 'owner',),
|
||||||
|
('state', 'priority'),
|
||||||
|
'description')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
css = {
|
||||||
|
'all': ('issues/css/ticket-admin.css',)
|
||||||
|
}
|
||||||
|
js = (
|
||||||
|
'issues/js/ticket-admin.js',
|
||||||
|
)
|
||||||
|
|
||||||
|
display_creator = link('creator')
|
||||||
|
display_queue = link('queue')
|
||||||
|
display_owner = link('owner')
|
||||||
|
|
||||||
|
def display_summary(self, ticket):
|
||||||
|
author_url = link('creator')(self, ticket)
|
||||||
|
created = display_timesince(ticket.created_on)
|
||||||
|
messages = ticket.messages.order_by('-created_on')
|
||||||
|
updated = ''
|
||||||
|
if messages:
|
||||||
|
updated_on = display_timesince(messages[0].created_on)
|
||||||
|
updated_by = link('author')(self, messages[0])
|
||||||
|
updated = '. Updated by %s about %s' % (updated_by, updated_on)
|
||||||
|
msg = '<h4>Added by %s about %s%s</h4>' % (author_url, created, updated)
|
||||||
|
return mark_safe(msg)
|
||||||
|
display_summary.short_description = 'Summary'
|
||||||
|
|
||||||
|
def display_priority(self, ticket):
|
||||||
|
""" State colored for change_form """
|
||||||
|
return colored('priority', PRIORITY_COLORS, bold=False, verbose=True)(ticket)
|
||||||
|
display_priority.short_description = _("Priority")
|
||||||
|
display_priority.admin_order_field = 'priority'
|
||||||
|
|
||||||
|
def display_state(self, ticket):
|
||||||
|
""" State colored for change_form """
|
||||||
|
return colored('state', STATE_COLORS, bold=False, verbose=True)(ticket)
|
||||||
|
display_state.short_description = _("State")
|
||||||
|
display_state.admin_order_field = 'state'
|
||||||
|
|
||||||
|
def unbold_id(self, ticket):
|
||||||
|
""" Unbold id if ticket is read """
|
||||||
|
if ticket.is_read_by(self.user):
|
||||||
|
return '<span style="font-weight:normal;font-size:11px;">%s</span>' % ticket.pk
|
||||||
|
return ticket.pk
|
||||||
|
unbold_id.allow_tags = True
|
||||||
|
unbold_id.short_description = "#"
|
||||||
|
unbold_id.admin_order_field = 'id'
|
||||||
|
|
||||||
|
def bold_subject(self, ticket):
|
||||||
|
""" Bold subject when tickets are unread for request.user """
|
||||||
|
if ticket.is_read_by(self.user):
|
||||||
|
return ticket.subject
|
||||||
|
return "<strong class='unread'>%s</strong>" % ticket.subject
|
||||||
|
bold_subject.allow_tags = True
|
||||||
|
bold_subject.short_description = _("Subject")
|
||||||
|
bold_subject.admin_order_field = 'subject'
|
||||||
|
|
||||||
|
def last_modified(self, instance):
|
||||||
|
return display_timesince(instance.last_modified_on)
|
||||||
|
last_modified.admin_order_field = 'last_modified_on'
|
||||||
|
|
||||||
|
def formfield_for_dbfield(self, db_field, **kwargs):
|
||||||
|
""" Make value input widget bigger """
|
||||||
|
if db_field.name == 'subject':
|
||||||
|
kwargs['widget'] = forms.TextInput(attrs={'size':'120'})
|
||||||
|
return super(TicketAdmin, self).formfield_for_dbfield(db_field, **kwargs)
|
||||||
|
|
||||||
|
def save_model(self, request, obj, *args, **kwargs):
|
||||||
|
""" Define creator for new tickets """
|
||||||
|
if not obj.pk:
|
||||||
|
obj.creator = request.user
|
||||||
|
super(TicketAdmin, self).save_model(request, obj, *args, **kwargs)
|
||||||
|
obj.mark_as_read_by(request.user)
|
||||||
|
|
||||||
|
def get_urls(self):
|
||||||
|
""" add markdown preview url """
|
||||||
|
urls = super(TicketAdmin, self).get_urls()
|
||||||
|
my_urls = patterns('',
|
||||||
|
(r'^preview/$', wrap_admin_view(self, self.message_preview_view))
|
||||||
|
)
|
||||||
|
return my_urls + urls
|
||||||
|
|
||||||
|
def add_view(self, request, form_url='', extra_context=None):
|
||||||
|
""" Do not sow message inlines """
|
||||||
|
return super(TicketAdmin, self).add_view(request, form_url, extra_context)
|
||||||
|
|
||||||
|
def change_view(self, request, object_id, form_url='', extra_context=None):
|
||||||
|
""" Change view actions based on ticket state """
|
||||||
|
ticket = get_object_or_404(Ticket, pk=object_id)
|
||||||
|
# Change view actions based on ticket state
|
||||||
|
self.change_view_actions = filter_actions(self, ticket, request)
|
||||||
|
if request.method == 'POST':
|
||||||
|
# Hack: Include the ticket changes on the request.POST
|
||||||
|
# other approaches get really messy
|
||||||
|
changes = get_ticket_changes(self, request, ticket)
|
||||||
|
if changes:
|
||||||
|
content = markdown_formated_changes(changes)
|
||||||
|
content += request.POST[u'messages-2-0-content']
|
||||||
|
request.POST[u'messages-2-0-content'] = content
|
||||||
|
ticket.mark_as_read_by(request.user)
|
||||||
|
context = {'title': "Issue #%i - %s" % (ticket.id, ticket.subject)}
|
||||||
|
context.update(extra_context or {})
|
||||||
|
return super(TicketAdmin, self).change_view(
|
||||||
|
request, object_id, form_url, extra_context=context)
|
||||||
|
|
||||||
|
def changelist_view(self, request, extra_context=None):
|
||||||
|
# Hook user for bold_subject
|
||||||
|
self.user = request.user
|
||||||
|
return super(TicketAdmin,self).changelist_view(request, extra_context=extra_context)
|
||||||
|
|
||||||
|
def message_preview_view(self, request):
|
||||||
|
""" markdown preview render via ajax """
|
||||||
|
data = request.POST.get("data")
|
||||||
|
data_formated = markdown(strip_tags(data))
|
||||||
|
return HttpResponse(data_formated)
|
||||||
|
|
||||||
|
|
||||||
|
class QueueAdmin(admin.ModelAdmin):
|
||||||
|
# TODO notify
|
||||||
|
list_display = [
|
||||||
|
'name', 'default', 'num_tickets'
|
||||||
|
]
|
||||||
|
actions = [set_default_queue]
|
||||||
|
inlines = [TicketInline]
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
css = {
|
||||||
|
'all': ('orchestra/css/hide-inline-id.css',)
|
||||||
|
}
|
||||||
|
|
||||||
|
def num_tickets(self, queue):
|
||||||
|
num = queue.tickets.count()
|
||||||
|
url = reverse('admin:issues_ticket_changelist')
|
||||||
|
url += '?my_tickets=False&queue=%i' % queue.pk
|
||||||
|
return mark_safe('<a href="%s">%d</a>' % (url, num))
|
||||||
|
num_tickets.short_description = _("Tickets")
|
||||||
|
num_tickets.admin_order_field = 'tickets__count'
|
||||||
|
|
||||||
|
def queryset(self, request):
|
||||||
|
qs = super(QueueAdmin, self).queryset(request)
|
||||||
|
qs = qs.annotate(models.Count('tickets'))
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(Ticket, TicketAdmin)
|
||||||
|
admin.site.register(Queue, QueueAdmin)
|
43
orchestra/apps/issues/filters.py
Normal file
43
orchestra/apps/issues/filters.py
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
from django.contrib.admin import SimpleListFilter
|
||||||
|
|
||||||
|
from .models import Ticket
|
||||||
|
|
||||||
|
|
||||||
|
class MyTicketsListFilter(SimpleListFilter):
|
||||||
|
""" Filter tickets by created_by according to request.user """
|
||||||
|
title = 'Tickets'
|
||||||
|
parameter_name = 'my_tickets'
|
||||||
|
|
||||||
|
def lookups(self, request, model_admin):
|
||||||
|
return (
|
||||||
|
('True', 'My Tickets'),
|
||||||
|
('False', 'All'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def queryset(self, request, queryset):
|
||||||
|
if self.value() == 'True':
|
||||||
|
return queryset.involved_by(request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class TicketStateListFilter(SimpleListFilter):
|
||||||
|
title = 'State'
|
||||||
|
parameter_name = 'state'
|
||||||
|
|
||||||
|
def lookups(self, request, model_admin):
|
||||||
|
return (
|
||||||
|
('OPEN', "Open"),
|
||||||
|
(Ticket.NEW, "New"),
|
||||||
|
(Ticket.IN_PROGRESS, "In Progress"),
|
||||||
|
(Ticket.RESOLVED, "Resolved"),
|
||||||
|
(Ticket.FEEDBACK, "Feedback"),
|
||||||
|
(Ticket.REJECTED, "Rejected"),
|
||||||
|
(Ticket.CLOSED, "Closed"),
|
||||||
|
('False', 'All'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def queryset(self, request, queryset):
|
||||||
|
if self.value() == 'OPEN':
|
||||||
|
return queryset.exclude(state__in=[Ticket.CLOSED, Ticket.REJECTED])
|
||||||
|
elif self.value() == 'False':
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(state=self.value())
|
106
orchestra/apps/issues/forms.py
Normal file
106
orchestra/apps/issues/forms.py
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
from django import forms
|
||||||
|
from django.core.urlresolvers import reverse
|
||||||
|
from django.utils.html import strip_tags
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
from markdown import markdown
|
||||||
|
|
||||||
|
from orchestra.apps.users.models import User
|
||||||
|
from orchestra.forms.widgets import ReadOnlyWidget
|
||||||
|
|
||||||
|
from .models import Queue, Ticket
|
||||||
|
|
||||||
|
|
||||||
|
class MarkDownWidget(forms.Textarea):
|
||||||
|
""" MarkDown textarea widget with syntax preview """
|
||||||
|
|
||||||
|
markdown_url = '/static/issues/markdown_syntax.html'
|
||||||
|
markdown_help_text = (
|
||||||
|
'<a href="%s" onclick=\'window.open("%s", "", "resizable=yes, '
|
||||||
|
'location=no, width=300, height=640, menubar=no, status=no, scrollbars=yes"); '
|
||||||
|
'return false;\'>markdown format</a>' % (markdown_url, markdown_url)
|
||||||
|
)
|
||||||
|
markdown_help_text = 'HTML not allowed, you can use %s' % markdown_help_text
|
||||||
|
|
||||||
|
def render(self, name, value, attrs):
|
||||||
|
widget_id = attrs['id'] if attrs and 'id' in attrs else 'id_%s' % name
|
||||||
|
textarea = super(MarkDownWidget, self).render(name, value, attrs)
|
||||||
|
preview = ('<a class="load-preview" href="#" data-field="{0}">preview</a>'\
|
||||||
|
'<div id="{0}-preview" class="content-preview"></div>'.format(widget_id))
|
||||||
|
return mark_safe('<p class="help">%s<br/>%s<br/>%s</p>' % (
|
||||||
|
self.markdown_help_text, textarea, preview))
|
||||||
|
|
||||||
|
|
||||||
|
class MessageInlineForm(forms.ModelForm):
|
||||||
|
""" Add message form """
|
||||||
|
created_on = forms.CharField(label="Created On", required=False)
|
||||||
|
content = forms.CharField(widget=MarkDownWidget(), required=False)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(MessageInlineForm, self).__init__(*args, **kwargs)
|
||||||
|
admin_link = reverse('admin:users_user_change', args=(self.user.pk,))
|
||||||
|
self.fields['created_on'].widget = ReadOnlyWidget('')
|
||||||
|
|
||||||
|
def clean_content(self):
|
||||||
|
""" clean HTML tags """
|
||||||
|
return strip_tags(self.cleaned_data['content'])
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self.instance.pk is None:
|
||||||
|
self.instance.author = self.user
|
||||||
|
return super(MessageInlineForm, self).save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class UsersIterator(forms.models.ModelChoiceIterator):
|
||||||
|
""" Group ticket owner by superusers, ticket.group and regular users """
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.ticket = kwargs.pop('ticket', False)
|
||||||
|
super(forms.models.ModelChoiceIterator, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
yield ('', '---------')
|
||||||
|
users = User.objects.exclude(is_active=False).order_by('name')
|
||||||
|
superusers = users.filter(is_superuser=True)
|
||||||
|
if superusers:
|
||||||
|
yield ('Operators', list(superusers.values_list('pk', 'name')))
|
||||||
|
users = users.exclude(is_superuser=True)
|
||||||
|
if users:
|
||||||
|
yield ('Other', list(users.values_list('pk', 'name')))
|
||||||
|
|
||||||
|
|
||||||
|
class TicketForm(forms.ModelForm):
|
||||||
|
display_description = forms.CharField(label=_("Description"), required=False)
|
||||||
|
description = forms.CharField(widget=MarkDownWidget(attrs={'class':'vLargeTextField'}))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Ticket
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(TicketForm, self).__init__(*args, **kwargs)
|
||||||
|
ticket = kwargs.get('instance', False)
|
||||||
|
users = self.fields['owner'].queryset
|
||||||
|
self.fields['owner'].queryset = users.filter(is_superuser=True)
|
||||||
|
if not ticket:
|
||||||
|
# Provide default ticket queue for new ticket
|
||||||
|
try:
|
||||||
|
self.initial['queue'] = Queue.objects.get(default=True).id
|
||||||
|
except Queue.DoesNotExist:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
description = markdown(ticket.description)
|
||||||
|
# some hacks for better line breaking
|
||||||
|
description = description.replace('>\n', '#Ha9G9-?8')
|
||||||
|
description = description.replace('\n', '<br>')
|
||||||
|
description = description.replace('#Ha9G9-?8', '>\n')
|
||||||
|
description = '<div style="padding-left: 180px;">%s</div>' % description
|
||||||
|
widget = ReadOnlyWidget(description, description)
|
||||||
|
self.fields['display_description'].widget = widget
|
||||||
|
|
||||||
|
def clean_description(self):
|
||||||
|
""" clean HTML tags """
|
||||||
|
return strip_tags(self.cleaned_data['description'])
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeReasonForm(forms.Form):
|
||||||
|
reason = forms.CharField(widget=forms.Textarea(attrs={'cols': '100', 'rows': '10'}),
|
||||||
|
required=False)
|
36
orchestra/apps/issues/helpers.py
Normal file
36
orchestra/apps/issues/helpers.py
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
def filter_actions(modeladmin, ticket, request):
|
||||||
|
if not hasattr(modeladmin, 'change_view_actions_backup'):
|
||||||
|
modeladmin.change_view_actions_backup = list(modeladmin.change_view_actions)
|
||||||
|
actions = modeladmin.change_view_actions_backup
|
||||||
|
if ticket.state == modeladmin.model.CLOSED:
|
||||||
|
del_actions = actions
|
||||||
|
else:
|
||||||
|
from .actions import action_map
|
||||||
|
del_actions = [action_map.get(ticket.state, None)]
|
||||||
|
if ticket.owner == request.user:
|
||||||
|
del_actions.append('take')
|
||||||
|
exclude = lambda a: not (a == action or a.url_name == action)
|
||||||
|
for action in del_actions:
|
||||||
|
actions = filter(exclude, actions)
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
|
def markdown_formated_changes(changes):
|
||||||
|
markdown = ''
|
||||||
|
for name, values in changes.items():
|
||||||
|
context = (name.capitalize(), values[0], values[1])
|
||||||
|
markdown += '* **%s** changed from _%s_ to _%s_\n' % context
|
||||||
|
return markdown + '\n'
|
||||||
|
|
||||||
|
|
||||||
|
def get_ticket_changes(modeladmin, request, ticket):
|
||||||
|
ModelForm = modeladmin.get_form(request, ticket)
|
||||||
|
form = ModelForm(request.POST, request.FILES)
|
||||||
|
changes = {}
|
||||||
|
if form.is_valid():
|
||||||
|
for attr in ['state', 'priority', 'owner', 'queue']:
|
||||||
|
old_value = getattr(ticket, attr)
|
||||||
|
new_value = form.cleaned_data[attr]
|
||||||
|
if old_value != new_value:
|
||||||
|
changes[attr] = (old_value, new_value)
|
||||||
|
return changes
|
185
orchestra/apps/issues/models.py
Normal file
185
orchestra/apps/issues/models.py
Normal file
|
@ -0,0 +1,185 @@
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from orchestra.apps.contacts import settings as contacts_settings
|
||||||
|
from orchestra.models.fields import MultiSelectField
|
||||||
|
from orchestra.utils import send_email_template
|
||||||
|
|
||||||
|
from . import settings
|
||||||
|
|
||||||
|
|
||||||
|
class Queue(models.Model):
|
||||||
|
name = models.CharField(_("name"), max_length=128, unique=True)
|
||||||
|
default = models.BooleanField(_("default"), default=False)
|
||||||
|
notify = MultiSelectField(_("notify"), max_length=256, blank=True,
|
||||||
|
choices=contacts_settings.CONTACTS_EMAIL_USAGES,
|
||||||
|
default=contacts_settings.CONTACTS_DEFAULT_EMAIL_USAGES,
|
||||||
|
help_text=_("Contacts to notify by email"))
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
""" mark as default queue if needed """
|
||||||
|
existing_default = Queue.objects.filter(default=True)
|
||||||
|
if self.default:
|
||||||
|
existing_default.update(default=False)
|
||||||
|
elif not existing_default:
|
||||||
|
self.default = True
|
||||||
|
super(Queue, self).save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class Ticket(models.Model):
|
||||||
|
HIGH = 'HIGH'
|
||||||
|
MEDIUM = 'MEDIUM'
|
||||||
|
LOW = 'LOW'
|
||||||
|
PRIORITIES = (
|
||||||
|
(HIGH, 'High'),
|
||||||
|
(MEDIUM, 'Medium'),
|
||||||
|
(LOW, 'Low'),
|
||||||
|
)
|
||||||
|
|
||||||
|
NEW = 'NEW'
|
||||||
|
IN_PROGRESS = 'IN_PROGRESS'
|
||||||
|
RESOLVED = 'RESOLVED'
|
||||||
|
FEEDBACK = 'FEEDBACK'
|
||||||
|
REJECTED = 'REJECTED'
|
||||||
|
CLOSED = 'CLOSED'
|
||||||
|
STATES = (
|
||||||
|
(NEW, 'New'),
|
||||||
|
(IN_PROGRESS, 'In Progress'),
|
||||||
|
(RESOLVED, 'Resolved'),
|
||||||
|
(FEEDBACK, 'Feedback'),
|
||||||
|
(REJECTED, 'Rejected'),
|
||||||
|
(CLOSED, 'Closed'),
|
||||||
|
)
|
||||||
|
|
||||||
|
creator = models.ForeignKey(get_user_model(), verbose_name=_("created by"),
|
||||||
|
related_name='tickets_created')
|
||||||
|
owner = models.ForeignKey(get_user_model(), null=True, blank=True,
|
||||||
|
related_name='tickets_owned', verbose_name=_("assigned to"))
|
||||||
|
queue = models.ForeignKey(Queue, related_name='tickets', null=True, blank=True)
|
||||||
|
subject = models.CharField(_("subject"), max_length=256)
|
||||||
|
description = models.TextField(_("description"))
|
||||||
|
priority = models.CharField(_("priority"), max_length=32, choices=PRIORITIES,
|
||||||
|
default=MEDIUM)
|
||||||
|
state = models.CharField(_("state"), max_length=32, choices=STATES, default=NEW)
|
||||||
|
created_on = models.DateTimeField(_("created on"), auto_now_add=True)
|
||||||
|
last_modified_on = models.DateTimeField(_("last modified on"), auto_now=True)
|
||||||
|
cc = models.TextField("CC", help_text=_("emails to send a carbon copy to"),
|
||||||
|
blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-last_modified_on"]
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return unicode(self.pk)
|
||||||
|
|
||||||
|
def get_notification_emails(self):
|
||||||
|
""" Get emails of the users related to the ticket """
|
||||||
|
emails = list(settings.ISSUES_SUPPORT_EMAILS)
|
||||||
|
emails.append(self.creator.email)
|
||||||
|
if self.owner:
|
||||||
|
emails.append(self.owner.email)
|
||||||
|
for contact in self.creator.account.contacts.all():
|
||||||
|
if self.queue and set(contact.email_usage).union(set(self.queue.nofify)):
|
||||||
|
emails.append(contact.email)
|
||||||
|
for message in self.messages.distinct('author'):
|
||||||
|
emails.append(message.author.email)
|
||||||
|
return set(emails + self.get_cc_emails())
|
||||||
|
|
||||||
|
def notify(self, message=None, content=None):
|
||||||
|
""" Send an email to ticket stakeholders notifying an state update """
|
||||||
|
emails = self.get_notification_emails()
|
||||||
|
template = 'issues/ticket_notification.mail'
|
||||||
|
html_template = 'issues/ticket_notification_html.mail'
|
||||||
|
context = {
|
||||||
|
'ticket': self,
|
||||||
|
'ticket_message': message
|
||||||
|
}
|
||||||
|
send_email_template(template, context, emails, html=html_template)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
""" notify stakeholders of new ticket """
|
||||||
|
new_issue = not self.pk
|
||||||
|
super(Ticket, self).save(*args, **kwargs)
|
||||||
|
if new_issue:
|
||||||
|
# PK should be available for rendering the template
|
||||||
|
self.notify()
|
||||||
|
|
||||||
|
def is_involved_by(self, user):
|
||||||
|
""" returns whether user has participated or is referenced on the ticket
|
||||||
|
as owner or member of the group
|
||||||
|
"""
|
||||||
|
return Ticket.objects.filter(pk=self.pk).involved_by(user).exists()
|
||||||
|
|
||||||
|
def is_visible_by(self, user):
|
||||||
|
""" returns whether ticket is visible by user """
|
||||||
|
return Ticket.objects.filter(pk=self.pk).visible_by(user).exists()
|
||||||
|
|
||||||
|
def get_cc_emails(self):
|
||||||
|
return self.cc.split(',') if self.cc else []
|
||||||
|
|
||||||
|
def mark_as_read_by(self, user):
|
||||||
|
TicketTracker.objects.get_or_create(ticket=self, user=user)
|
||||||
|
|
||||||
|
def mark_as_unread_by(self, user):
|
||||||
|
TicketTracker.objects.filter(ticket=self, user=user).delete()
|
||||||
|
|
||||||
|
def mark_as_unread(self):
|
||||||
|
TicketTracker.objects.filter(ticket=self).delete()
|
||||||
|
|
||||||
|
def is_read_by(self, user):
|
||||||
|
return TicketTracker.objects.filter(ticket=self, user=user).exists()
|
||||||
|
|
||||||
|
def reject(self):
|
||||||
|
self.state = Ticket.REJECTED
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def resolve(self):
|
||||||
|
self.state = Ticket.RESOLVED
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.state = Ticket.CLOSED
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def take(self, user):
|
||||||
|
self.owner = user
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Message(models.Model):
|
||||||
|
ticket = models.ForeignKey('issues.Ticket', verbose_name=_("ticket"),
|
||||||
|
related_name='messages')
|
||||||
|
author = models.ForeignKey(get_user_model(), verbose_name=_("author"),
|
||||||
|
related_name='ticket_messages')
|
||||||
|
content = models.TextField(_("content"))
|
||||||
|
created_on = models.DateTimeField(_("created on"), auto_now_add=True)
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return u"#%i" % self.id
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
""" notify stakeholders of ticket update """
|
||||||
|
if not self.pk:
|
||||||
|
self.ticket.mark_as_unread()
|
||||||
|
self.ticket.notify(message=self)
|
||||||
|
super(Message, self).save(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def num(self):
|
||||||
|
return self.ticket.messages.filter(id__lte=self.id).count()
|
||||||
|
|
||||||
|
|
||||||
|
class TicketTracker(models.Model):
|
||||||
|
""" Keeps track of user read tickets """
|
||||||
|
ticket = models.ForeignKey(Ticket, verbose_name=_("ticket"),
|
||||||
|
related_name='trackers')
|
||||||
|
user = models.ForeignKey(get_user_model(), verbose_name=_("user"),
|
||||||
|
related_name='ticket_trackers')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = (('ticket', 'user'),)
|
7
orchestra/apps/issues/settings.py
Normal file
7
orchestra/apps/issues/settings.py
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
ISSUES_SUPPORT_EMAILS = getattr(settings, 'ISSUES_SUPPORT_EMAILS', [])
|
||||||
|
|
||||||
|
|
||||||
|
ISSUES_NOTIFY_SUPERUSERS = getattr(settings, 'ISSUES_NOTIFY_SUPERUSERS', True)
|
67
orchestra/apps/issues/static/issues/css/ticket-admin.css
Normal file
67
orchestra/apps/issues/static/issues/css/ticket-admin.css
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
fieldset .field-box {
|
||||||
|
float: left;
|
||||||
|
margin-right: 20px;
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
background-color: #B6B6B6;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
form .field-display_description p, form .field-display_description ul {
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
form .field-display_description ul {
|
||||||
|
margin-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul li {
|
||||||
|
list-style-type: disc;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** messages format ***/
|
||||||
|
#messages-group {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#messages-2-group {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#messages-2-group h2, #messages-2-group thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#id_messages-2-0-content {
|
||||||
|
width: 99%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ticket.description preview CSS overrides **/
|
||||||
|
.content-preview {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
padding: 2px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aligned .content-preview p {
|
||||||
|
margin-left: 5px;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
.module .content-preview ol,
|
||||||
|
.module .content-preview ul {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** unread messages admin changelist **/
|
||||||
|
strong.unread {
|
||||||
|
display: inline-block;
|
||||||
|
padding-left: 21px;
|
||||||
|
background: url(../images/unread_ticket.gif) no-repeat left;
|
||||||
|
}
|
||||||
|
|
BIN
orchestra/apps/issues/static/issues/images/btn_edit.gif
Normal file
BIN
orchestra/apps/issues/static/issues/images/btn_edit.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 204 B |
BIN
orchestra/apps/issues/static/issues/images/unread_ticket.gif
Normal file
BIN
orchestra/apps/issues/static/issues/images/unread_ticket.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 260 B |
16
orchestra/apps/issues/static/issues/js/admin-ticket.js
Normal file
16
orchestra/apps/issues/static/issues/js/admin-ticket.js
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
(function($) {
|
||||||
|
$(document).ready(function($) {
|
||||||
|
// load markdown preview
|
||||||
|
$('.load-preview').on("click", function() {
|
||||||
|
var field = '#' + $(this).attr('data-field'),
|
||||||
|
data = {
|
||||||
|
'data': $(field).val(),
|
||||||
|
'csrfmiddlewaretoken': $('[name=csrfmiddlewaretoken]',
|
||||||
|
'#ticket_form').val(),
|
||||||
|
},
|
||||||
|
preview = field + '-preview';
|
||||||
|
$(preview).load("/admin/issues/ticket/preview/", data);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})(django.jQuery);
|
30
orchestra/apps/issues/static/issues/js/ticket-admin.js
Normal file
30
orchestra/apps/issues/static/issues/js/ticket-admin.js
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
|
||||||
|
(function($) {
|
||||||
|
$(document).ready(function($) {
|
||||||
|
// visibility helper show on hover
|
||||||
|
$v = $('#id_visibility');
|
||||||
|
$v_help = $('#ticket_form .field-box.field-visibility .help')
|
||||||
|
$v.hover(
|
||||||
|
function() { $v_help.show(); },
|
||||||
|
function() { $v_help.hide(); }
|
||||||
|
);
|
||||||
|
|
||||||
|
// show subject edit field on click
|
||||||
|
$('#subject-edit').click(function() {
|
||||||
|
$('.field-box.field-subject').show();
|
||||||
|
});
|
||||||
|
|
||||||
|
// load markdown preview
|
||||||
|
$('.load-preview').on("click", function() {
|
||||||
|
var field = '#' + $(this).attr('data-field'),
|
||||||
|
data = {
|
||||||
|
'data': $(field).val(),
|
||||||
|
'csrfmiddlewaretoken': $('[name=csrfmiddlewaretoken]',
|
||||||
|
'#ticket_form').val(),
|
||||||
|
},
|
||||||
|
preview = field + '-preview';
|
||||||
|
$(preview).load("/admin/issues/ticket/preview/", data);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})(django.jQuery);
|
55
orchestra/apps/issues/static/issues/markdown_syntax.html
Normal file
55
orchestra/apps/issues/static/issues/markdown_syntax.html
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||||
|
<meta http-equiv="Content-Style-Type" content="text/css" />
|
||||||
|
<title>Markdown formatting</title>
|
||||||
|
<style type="text/css">
|
||||||
|
h1 { font-family: Verdana, sans-serif; font-size: 14px; text-align: center; color: #444; }
|
||||||
|
body { font-family: Verdana, sans-serif; font-size: 12px; color: #444; }
|
||||||
|
table th { padding-top: 1em; }
|
||||||
|
table td { vertical-align: top; background-color: #f5f5f5; height: 2em; vertical-align: middle;}
|
||||||
|
table td code { font-size: 1.2em; }
|
||||||
|
table td h1 { font-size: 1.8em; text-align: left; }
|
||||||
|
table td h2 { font-size: 1.4em; text-align: left; }
|
||||||
|
table td h3 { font-size: 1.2em; text-align: left; }
|
||||||
|
table td span { background-color: red; letter-spacing: 2px; }
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>Markdown Syntax Quick Reference</h1>
|
||||||
|
|
||||||
|
<table width="100%">
|
||||||
|
<tr><th colspan="2">Font Styles</th></tr>
|
||||||
|
<tr><th></th><td width="50%">**Strong**</td><td width="50%"><strong>Strong</strong></td></tr>
|
||||||
|
<tr><th></th><td>_Italic_</td><td><em>Italic</em></td></tr>
|
||||||
|
<tr><th></th><td>> Quote</td><td><cite>Quote</cite></td></tr>
|
||||||
|
<tr><th></th><td> 4 or more spaces</td><td><code>Code block</code></td></tr>
|
||||||
|
|
||||||
|
<tr><th colspan="2">Break Lines</th></tr>
|
||||||
|
<tr><th></th><td>end a line with 2 or more spaces<span> </span></td><td><code>first line <br/>new line</code></td></tr>
|
||||||
|
<tr><th></th><td>type an empty line<br/><span> </span><br/> (or containing only spaces)</td><td><code>first line <br/>new line</code></td></tr>
|
||||||
|
|
||||||
|
|
||||||
|
<tr><th colspan="2">Lists</th></tr>
|
||||||
|
<tr><th></th><td>* Item 1<br />* Item 2</td><td><ul><li>Item 1</li><li>Item 2</li></ul></td></tr>
|
||||||
|
<tr><th></th><td>1. Item 1<br />2. Item 2</td><td><ol><li>Item 1</li><li>Item 2</li></ol></td></tr>
|
||||||
|
|
||||||
|
<tr><th colspan="2">Headings</th></tr>
|
||||||
|
<tr><th></th><td># Title 1 #</td><td><h1>Title 1</h1></td></tr>
|
||||||
|
<tr><th></th><td>## Title ##</td><td><h2>Title 2</h2></td></tr>
|
||||||
|
|
||||||
|
<tr><th colspan="2">Links</th></tr>
|
||||||
|
<tr><th></th><td><http://foo.bar></td><td><a href="#">http://foo.bar</a></td></tr>
|
||||||
|
<tr><th></th><td>[link](http://foo.bar/)</td><td><a href="#">link</a></td></tr>
|
||||||
|
<tr><th></th><td>[relative link](/about/)</td><td><a href="#">relative link</a></td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p><a href="http://daringfireball.net/projects/markdown/syntax">
|
||||||
|
Full reference of markdown syntax</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -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 %}
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
{% load markdown %}
|
||||||
|
|
||||||
|
{% if message %}
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Verdana, sans-serif;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color:#484848;
|
||||||
|
}
|
||||||
|
h1, h2, h3 { font-family: "Trebuchet MS", Verdana, sans-serif; margin: 0p=
|
||||||
|
x; }
|
||||||
|
h1 { font-size: 1.2em; }
|
||||||
|
h2, h3 { font-size: 1.1em; }
|
||||||
|
a, a:link, a:visited { color: #2A5685;}
|
||||||
|
a:hover, a:active { color: #c61a1a; }
|
||||||
|
a.wiki-anchor { display: none; }
|
||||||
|
hr {
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
background: #ccc;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% if not ticket_message %}
|
||||||
|
Issue #{{ ticket.id }} has been reported by {{ ticket.created_by }}.
|
||||||
|
{% else %}
|
||||||
|
Issue #{{ ticket.id }} has been updated by {{ ticket_message.author }}.
|
||||||
|
{% autoescape off %}
|
||||||
|
{{ ticket_message.content|markdown }}
|
||||||
|
{% endautoescape %}
|
||||||
|
{% endif %}
|
||||||
|
<hr />
|
||||||
|
<h1><a href="http://{{ site.domain }}{% url 'admin:issues_ticket_change' ticket.id %}">Issue #{{ ticket.pk }}: {{ ticket.subject }}</a></h1>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Author: {{ ticket.created_by }}</li>
|
||||||
|
<li>Status: {{ ticket.get_state_display }}</li>
|
||||||
|
<li>Priority: {{ ticket.get_priority_display }}</li>
|
||||||
|
<li>Visibility: {{ ticket.get_visibility_display }}</li>
|
||||||
|
<li>Group: {% if ticket.group %}{{ ticket.group }}{% endif %}</li>
|
||||||
|
<li>Assigned to: {% if ticket.owner %}{{ ticket.owner }}{% endif %}</li>
|
||||||
|
<li>Queue: {{ ticket.queue }}</li>
|
||||||
|
</ul>
|
||||||
|
{% autoescape off %}
|
||||||
|
{{ ticket.description|markdown }}
|
||||||
|
{% endautoescape %}
|
||||||
|
<hr />
|
||||||
|
<span class="footer"><p>You have received this notification because you have either subscribed to it, or are involved in it.<br />
|
||||||
|
To change your notification preferences, please click here: <a class="external" href="{{ site.scheme }}://{{ site.domain }}{% url 'admin:issues_ticket_change' ticket.id %}">{{ site.scheme }}://{{ site.domain }}{% url 'admin:issues_ticket_change' ticket.id %}</a></p></span>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{% endif %}
|
16
orchestra/apps/issues/tests.py
Normal file
16
orchestra/apps/issues/tests.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
"""
|
||||||
|
This file demonstrates writing tests using the unittest module. These will pass
|
||||||
|
when you run "manage.py test".
|
||||||
|
|
||||||
|
Replace this with more appropriate tests for your application.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleTest(TestCase):
|
||||||
|
def test_basic_addition(self):
|
||||||
|
"""
|
||||||
|
Tests that 1 + 1 always equals 2.
|
||||||
|
"""
|
||||||
|
self.assertEqual(1 + 1, 2)
|
0
orchestra/apps/lists/__init__.py
Normal file
0
orchestra/apps/lists/__init__.py
Normal file
60
orchestra/apps/lists/admin.py
Normal file
60
orchestra/apps/lists/admin.py
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.conf.urls import patterns
|
||||||
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
from django.utils.translation import ugettext, ugettext_lazy as _
|
||||||
|
|
||||||
|
from orchestra.admin import ExtendedModelAdmin
|
||||||
|
from orchestra.admin.utils import link
|
||||||
|
from orchestra.apps.accounts.admin import SelectAccountAdminMixin
|
||||||
|
|
||||||
|
from .forms import ListCreationForm, ListChangeForm
|
||||||
|
from .models import List
|
||||||
|
|
||||||
|
|
||||||
|
class ListAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
|
||||||
|
list_display = ('name', 'address_name', 'address_domain_link', 'account_link')
|
||||||
|
add_fieldsets = (
|
||||||
|
(None, {
|
||||||
|
'classes': ('wide',),
|
||||||
|
'fields': ('account_link', 'name',)
|
||||||
|
}),
|
||||||
|
(_("Address"), {
|
||||||
|
'classes': ('wide',),
|
||||||
|
'fields': (('address_name', 'address_domain'),)
|
||||||
|
}),
|
||||||
|
(_("Admin"), {
|
||||||
|
'classes': ('wide',),
|
||||||
|
'fields': ('admin_email', 'password1', 'password2'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
fieldsets = (
|
||||||
|
(None, {
|
||||||
|
'classes': ('wide',),
|
||||||
|
'fields': ('account_link', 'name',)
|
||||||
|
}),
|
||||||
|
(_("Address"), {
|
||||||
|
'classes': ('wide',),
|
||||||
|
'fields': (('address_name', 'address_domain'),)
|
||||||
|
}),
|
||||||
|
(_("Admin"), {
|
||||||
|
'classes': ('wide',),
|
||||||
|
'fields': ('admin_email', 'password',),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
readonly_fields = ('account_link',)
|
||||||
|
change_readonly_fields = ('name',)
|
||||||
|
form = ListChangeForm
|
||||||
|
add_form = ListCreationForm
|
||||||
|
filter_by_account_fields = ['address_domain']
|
||||||
|
|
||||||
|
address_domain_link = link('address_domain', order='address_domain__name')
|
||||||
|
|
||||||
|
def get_urls(self):
|
||||||
|
useradmin = UserAdmin(List, self.admin_site)
|
||||||
|
return patterns('',
|
||||||
|
(r'^(\d+)/password/$',
|
||||||
|
self.admin_site.admin_view(useradmin.user_change_password))
|
||||||
|
) + super(ListAdmin, self).get_urls()
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(List, ListAdmin)
|
16
orchestra/apps/lists/api.py
Normal file
16
orchestra/apps/lists/api.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
from rest_framework import viewsets
|
||||||
|
|
||||||
|
from orchestra.api import router, SetPasswordApiMixin
|
||||||
|
from orchestra.apps.accounts.api import AccountApiMixin
|
||||||
|
|
||||||
|
from .models import List
|
||||||
|
from .serializers import ListSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class ListViewSet(AccountApiMixin, SetPasswordApiMixin, viewsets.ModelViewSet):
|
||||||
|
model = List
|
||||||
|
serializer_class = ListSerializer
|
||||||
|
filter_fields = ('name',)
|
||||||
|
|
||||||
|
|
||||||
|
router.register(r'lists', ListViewSet)
|
11
orchestra/apps/lists/backends.py
Normal file
11
orchestra/apps/lists/backends.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
from django.template import Template, Context
|
||||||
|
|
||||||
|
from orchestra.apps.orchestration import ServiceBackend
|
||||||
|
|
||||||
|
|
||||||
|
class MailmanBackend(ServiceBackend):
|
||||||
|
verbose_name = "Mailman"
|
||||||
|
model = 'lists.List'
|
||||||
|
|
||||||
|
def save(self, mailinglist):
|
||||||
|
pass
|
51
orchestra/apps/lists/forms.py
Normal file
51
orchestra/apps/lists/forms.py
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
from django import forms
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from orchestra.core.validators import validate_password
|
||||||
|
from orchestra.forms.widgets import ReadOnlyWidget
|
||||||
|
|
||||||
|
|
||||||
|
class CleanAddressMixin(object):
|
||||||
|
def clean_address_domain(self):
|
||||||
|
name = self.cleaned_data.get('address_name')
|
||||||
|
domain = self.cleaned_data.get('address_domain')
|
||||||
|
if name and not domain:
|
||||||
|
msg = _("Domain should be selected for provided address name")
|
||||||
|
raise forms.ValidationError(msg)
|
||||||
|
elif not name and domain:
|
||||||
|
msg = _("Address name should be provided for this selected domain")
|
||||||
|
raise forms.ValidationError(msg)
|
||||||
|
return domain
|
||||||
|
|
||||||
|
|
||||||
|
class ListCreationForm(CleanAddressMixin, forms.ModelForm):
|
||||||
|
password1 = forms.CharField(label=_("Password"),
|
||||||
|
widget=forms.PasswordInput)
|
||||||
|
password2 = forms.CharField(label=_("Password confirmation"),
|
||||||
|
widget=forms.PasswordInput,
|
||||||
|
help_text=_("Enter the same password as above, for verification."))
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(ListAdminForm, self).__init__(*args, **kwargs)
|
||||||
|
self.fields['password1'].validators.append(validate_password)
|
||||||
|
|
||||||
|
def clean_password2(self):
|
||||||
|
password1 = self.cleaned_data.get("password1")
|
||||||
|
password2 = self.cleaned_data.get("password2")
|
||||||
|
if password1 and password2 and password1 != password2:
|
||||||
|
msg = _("The two password fields didn't match.")
|
||||||
|
raise forms.ValidationError(msg)
|
||||||
|
return password2
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
obj = super(ListAdminForm, self).save(commit=commit)
|
||||||
|
obj.set_password(self.cleaned_data["password1"])
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
class ListChangeForm(CleanAddressMixin, forms.ModelForm):
|
||||||
|
password = forms.CharField(label=_("Password"),
|
||||||
|
widget=ReadOnlyWidget('<strong>Unknown password</strong>'),
|
||||||
|
help_text=_("List passwords are not stored, so there is no way to see this "
|
||||||
|
"list's password, but you can change the password using "
|
||||||
|
"<a href=\"password/\">this form</a>."))
|
35
orchestra/apps/lists/models.py
Normal file
35
orchestra/apps/lists/models.py
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from orchestra.core import services
|
||||||
|
from orchestra.core.validators import validate_name
|
||||||
|
|
||||||
|
from . import settings
|
||||||
|
|
||||||
|
|
||||||
|
class List(models.Model):
|
||||||
|
name = models.CharField(_("name"), max_length=128, unique=True,
|
||||||
|
validators=[validate_name])
|
||||||
|
address_name = models.CharField(_("address name"), max_length=128,
|
||||||
|
validators=[validate_name], blank=True)
|
||||||
|
address_domain = models.ForeignKey(settings.LISTS_DOMAIN_MODEL,
|
||||||
|
verbose_name=_("address domain"), blank=True, null=True)
|
||||||
|
admin_email = models.EmailField(_("admin email"),
|
||||||
|
help_text=_("Administration email address"))
|
||||||
|
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
|
||||||
|
related_name='lists')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('address_name', 'address_domain')
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return "%s@%s" % (self.address_name, self.address_domain)
|
||||||
|
|
||||||
|
def get_username(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def set_password(self, password):
|
||||||
|
self.password = password
|
||||||
|
|
||||||
|
|
||||||
|
services.register(List)
|
11
orchestra/apps/lists/serializers.py
Normal file
11
orchestra/apps/lists/serializers.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from orchestra.apps.accounts.serializers import AccountSerializerMixin
|
||||||
|
|
||||||
|
from .models import List
|
||||||
|
|
||||||
|
|
||||||
|
class ListSerializer(AccountSerializerMixin, serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = List
|
||||||
|
fields = ('name', 'address_name', 'address_domain',)
|
8
orchestra/apps/lists/settings.py
Normal file
8
orchestra/apps/lists/settings.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
# Data access
|
||||||
|
|
||||||
|
LISTS_DOMAIN_MODEL = getattr(settings, 'LISTS_DOMAIN_MODEL', 'domains.Domain')
|
||||||
|
|
||||||
|
LISTS_DEFAULT_DOMAIN = getattr(settings, 'LIST_DEFAULT_DOMAIN', 'grups.orchestra.lan')
|
88
orchestra/apps/orchestration/README.md
Normal file
88
orchestra/apps/orchestration/README.md
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
# Orchestration
|
||||||
|
|
||||||
|
This module handles the management of the services controlled by Orchestra.
|
||||||
|
|
||||||
|
Orchestration module has the following pieces:
|
||||||
|
|
||||||
|
* `Operation` encapsulates an operation, storing the related object, the action and the backend
|
||||||
|
* `OperationsMiddleware` collects and executes all save and delete operations, more on [next section](#operationsmiddleware)
|
||||||
|
* `manager` it manage the execution of the operations
|
||||||
|
* `backends` defines the logic that will be executed on the servers in order to control a particular service
|
||||||
|
* `router` determines in which server an operation should be executed
|
||||||
|
* `Server` defines a server hosting services
|
||||||
|
* `methods` script execution methods, e.g. SSH
|
||||||
|
* `ScriptLog` it logs the script execution
|
||||||
|
|
||||||
|
Routes
|
||||||
|
======
|
||||||
|
|
||||||
|
This application provides support for mapping services to server machines accross the network.
|
||||||
|
|
||||||
|
It supports _routing_ based on Python expression, which means that you can efectively
|
||||||
|
control services that are distributed accross several machines. For example, different
|
||||||
|
websites that are distributed accross _n_ web servers on a _shared hosting_
|
||||||
|
environment.
|
||||||
|
|
||||||
|
### OperationsMiddleware
|
||||||
|
|
||||||
|
When enabled, `middlewares.OperationsMiddleware` automatically executes the service backends when a change on the data model occurs. The main steps that performs are:
|
||||||
|
|
||||||
|
1. Collect all `save` and `delete` model signals triggered on each HTTP request
|
||||||
|
2. Find related backends using the routing backend
|
||||||
|
3. Generate a single script per server (_unit of work_)
|
||||||
|
4. Execute the scripts on the servers
|
||||||
|
|
||||||
|
|
||||||
|
### Service Management Properties
|
||||||
|
|
||||||
|
We can identify three different characteristics regarding service management:
|
||||||
|
|
||||||
|
* **Authority**: Whether or not Orchestra is the only source of the service configuration. When Orchestra is the authority then service configuration is _completely generated_ from the Orchestra database (or services are configured to read their configuration directly from Orchestra database). Otherwise Orchestra will execute small tasks translating model changes into configuration changes, allowing manual configurations to be preserved.
|
||||||
|
* **Flow**: _push_, when Orchestra drives the execution or _pull_, when external services connects to Orchestra.
|
||||||
|
* **Execution**: _synchronous_, when the execution blocks the HTTP request, or _asynchronous_ when it doesn't. Asynchronous execution means concurrency, and concurrency scalability.
|
||||||
|
|
||||||
|
|
||||||
|
_Sorry for the bad terminology, I was not able to find more appropriate terms on the literature._
|
||||||
|
|
||||||
|
|
||||||
|
### Registry vs Synchronization vs Task
|
||||||
|
From the above management properties we can extract three main service management strategies: (a) _registry based management_, (b) _synchronization based management_ and (c) _task based management_. Orchestra provides support for all of them, it is left to you to decide which one suits your requirements better.
|
||||||
|
|
||||||
|
Following a brief description and evaluation of the tradeoffs to help on your decision making.
|
||||||
|
|
||||||
|
|
||||||
|
#### a. Registry Based Management
|
||||||
|
When Orchestra acts as a pure **configuration registry (authority)**, doing nothing more than store service's configuration on the database. The configuration is **pulled** from Orchestra by the servers themselves, so it is **asynchronous** by nature.
|
||||||
|
|
||||||
|
This strategy considers two different implementations:
|
||||||
|
|
||||||
|
- The service is configured to read the configuration directly from Orchestra database (or REST API). This approach simplifies configuration management but also can make Orchestra a single point of failure on your architecture.
|
||||||
|
- A client-side application periodically fetches the service configuration from the Orchestra database and regenerates the service configuration files. This approach is very tolerant to failures, since the services will keep on working, and the new configuration will be applied after recovering. A delay may occur until the changes are applied to the services (_eventual consistency_), but it can be mitigated by notifying the application when a relevant change occur.
|
||||||
|
|
||||||
|
|
||||||
|
#### b. Synchronization Based Management
|
||||||
|
When Orchestra is the configuration **authority** and also _the responsible of applying the changes_ on the servers (**push** flow). The configuration files are **regenerated** every time by Orchestra, deleting any existing manual configuration. This model is very consistent since it only depends on the current state of the system (_stateless_). Therefore, it makes sense to execute the synchronization operation in **asynchronous** fashion.
|
||||||
|
|
||||||
|
In contrast to registry based management, synchronization management is _fully centralized_, all the management operations are driven by Orchestra so you don't need to install nor configure anything on your servers.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#### c. Task Based Management
|
||||||
|
This model refers when Orchestra is _not the only source of configuration_. Therefore, Orchestra translates isolated data model changes directly into localized changes on the service configuration, and executing them using a **push** strategy. For example `save()` or `delete()` object-level operations may have sibling configuration management operations. In contrast to synchronization, tasks are able to preserve configuration not performed by Orchestra.
|
||||||
|
|
||||||
|
This model is intuitive, efficient and also very consistent when tasks are execute **synchronously** with the request/response cycle. However, **asynchronous** task execution can have _consistency issues_; tasks have state, and this state can be lost when:
|
||||||
|
- A failure occur while applying some changes, e.g. network error or worker crash while deleting a service
|
||||||
|
- Scripts are executed out of order, e.g. create and delete a service is applied in inverse order
|
||||||
|
|
||||||
|
In general, _synchornous execution of tasks is preferred_ over asynchornous, unless response delays are not tolerable.
|
||||||
|
|
||||||
|
##### What state does actually mean?
|
||||||
|
Lets assume you have deleted a mailbox, and Orchestra has created an script that deletes that mailbox on the mail server. However a failure has occurred and the mailbox deletion task has been lost. Since the state has also been lost it is not easy to tell what to do now in order to maintain consistency.
|
||||||
|
|
||||||
|
|
||||||
|
### Additional Notes
|
||||||
|
* The script that manage the service needs to be idempotent, i.e. the outcome of running the script is always the same, no matter how many times it is executed.
|
||||||
|
|
||||||
|
* Renaming of attributes may lead to undesirable effects, e.g. changing a database name will create a new database rather than just changing its name.
|
||||||
|
|
||||||
|
* The system does not magically perform data migrations between servers when its _route_ has changed
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue