Compare commits

..

15 Commits

204 changed files with 2252 additions and 4710 deletions

73
.github/workflows/django.yml vendored Normal file
View File

@ -0,0 +1,73 @@
name: Django CI
on:
push:
branches: [ master ]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
# Service containers to run with `container-job`
services:
# Label used to access the service container
postgres:
# Docker Hub image
image: postgres
ports:
- 5432:5432
# Provide the password for postgres
env:
POSTGRES_DB: test_myapp
POSTGRES_USER: testuser
POSTGRES_PASSWORD: s3cretPass
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
strategy:
max-parallel: 4
matrix:
python-version: [3.6]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
sudo apt-get update -qy
sudo apt-get -y install python3-dev libxml2 libxml2-dev libxslt-dev bind9utils ca-certificates gettext libcrack2-dev libxml2-dev libxslt1-dev ssh-client wget xvfb zlib1g-dev git iceweasel dnsutils postgresql-contrib libgirepository1.0-dev
python -m pip install --upgrade pip
pip install wheel
pip install -e .
pip install -r requirements.txt
pip install -r requirements-testing.txt
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
# flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings.
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics
- name: Run Tests
run: |
# orchestra-admin startproject panel
django-admin.py startproject panel --template=orchestra/conf/project_template -v3
coverage run --source='orchestra' panel/manage.py test orchestra --noinput -v3
coverage report
coverage xml
env:
DATABASE_URL: postgres://testuser:s3cretPass@localhost:5432/test_myapp
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}

View File

@ -11,7 +11,7 @@ If you are planing to do some development you may want to consider doing it unde
2. Build a new image, create and start a container 2. Build a new image, create and start a container
```bash ```bash
curl -L https://raw.githubusercontent.com/ribaguifi/django-orchestra/master/scripts/containers/Dockerfile > /tmp/Dockerfile curl -L http://git.io/orchestra-Dockerfile > /tmp/Dockerfile
docker build -t orchestra /tmp/ docker build -t orchestra /tmp/
docker create --name orchestra -i -t -u orchestra -w /home/orchestra orchestra bash docker create --name orchestra -i -t -u orchestra -w /home/orchestra orchestra bash
docker start orchestra docker start orchestra
@ -21,13 +21,12 @@ If you are planing to do some development you may want to consider doing it unde
3. Deploy django-orchestra development environment, inside the container 3. Deploy django-orchestra development environment, inside the container
```bash ```bash
bash <( curl -L https://raw.githubusercontent.com/ribaguifi/django-orchestra/master/scripts/containers/deploy.sh ) --dev bash <( curl -L http://git.io/orchestra-deploy ) --dev
``` ```
3. Nginx should be serving on port 80, but Django's development server can be used as well: 3. Nginx should be serving on port 80, but Django's development server can be used as well:
```bash ```bash
cd panel cd panel
python3 manage.py migrate
python3 manage.py runserver 0.0.0.0:8888 python3 manage.py runserver 0.0.0.0:8888
``` ```
@ -35,5 +34,5 @@ If you are planing to do some development you may want to consider doing it unde
5. To upgrade to current master just re-run the deploy script 5. To upgrade to current master just re-run the deploy script
```bash ```bash
git pull origin master git pull origin master
bash <( curl -L https://raw.githubusercontent.com/ribaguifi/django-orchestra/master/scripts/containers/deploy.sh ) --dev bash <( curl -L http://git.io/orchestra-deploy ) --dev
``` ```

View File

@ -1,132 +0,0 @@
# System requirements:
The most important requirement is use python3.6
we need install this packages:
```
bind9utils
ca-certificates
gettext
libcrack2-dev
libxml2-dev
libxslt1-dev
python3
python3-pip
python3-dev
ssh-client
wget
xvfb
zlib1g-dev
git
iceweasel
dnsutils
```
We need install too a *wkhtmltopdf* package
You can use one of your OS or get it from original.
This it is in https://wkhtmltopdf.org/downloads.html
# pip installations
We need install this packages:
```
Django==1.10.5
django-fluent-dashboard==0.6.1
django-admin-tools==0.8.0
django-extensions==1.7.4
django-celery==3.1.17
celery==3.1.23
kombu==3.0.35
billiard==3.3.0.23
Markdown==2.4
djangorestframework==3.4.7
ecdsa==0.11
Pygments==1.6
django-filter==0.15.2
jsonfield==0.9.22
python-dateutil==2.2
https://github.com/glic3rinu/passlib/archive/master.zip
django-iban==0.3.0
requests
phonenumbers
django-countries
django-localflavor
amqp
anyjson
pytz
cracklib
lxml==3.3.5
selenium
xvfbwrapper
freezegun
coverage
flake8
django-debug-toolbar==1.3.0
django-nose==1.4.4
sqlparse
pyinotify
PyMySQL
```
If you want to use Orchestra you need to install from pip like this:
```
pip3 install http://git.io/django-orchestra-dev
```
But if you want develop orquestra you need to do this:
```
git clone https://github.com/ribaguifi/django-orchestra
pip install -e django-orchestra
```
# Database
For default use sqlite3 if you want to use postgresql you need install this packages:
```
psycopg2 postgresql
```
You can use it for debian or ubuntu:
```
sudo apt-get install python3-psycopg2 postgresql-contrib
```
Remember create a database for your project and give permitions for the correct user like this:
```
psql -U postgres
psql (12.4)
Digite «help» para obtener ayuda.
postgres=# CREATE database orchesta;
postgres=# CREATE USER orchesta WITH PASSWORD 'orquesta';
postgres=# GRANT ALL PRIVILEGES ON DATABASE orchesta TO orchesta;
```
# Create new project
You can use orchestra-admin for create your new project
```
orchestra-admin startproject <project_name> # e.g. panel
cd <project_name>
```
Next we need change the settings.py for configure the correct database
In settings.py we need change the DATABASE section like this:
```
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': 'orchestra'
'USER': 'orchestra',
'PASSWORD': 'orchestra',
'HOST': 'localhost',
'PORT': '5432',
'CONN_MAX_AGE': 60*10
}
}
```
For end you need to do the migrations:
```
python3 manage.py migrate
```

View File

@ -3,7 +3,7 @@ from collections import OrderedDict
from functools import update_wrapper from functools import update_wrapper
from django.contrib import admin from django.contrib import admin
from django.urls import reverse from django.core.urlresolvers import reverse
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _

View File

@ -1,4 +1,4 @@
from django.urls import reverse from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from fluent_dashboard import dashboard, appsettings from fluent_dashboard import dashboard, appsettings
from fluent_dashboard.modules import CmsAppIconList from fluent_dashboard.modules import CmsAppIconList

View File

@ -5,7 +5,7 @@ from django import forms
from django.contrib.admin import helpers from django.contrib.admin import helpers
from django.core import validators from django.core import validators
from django.forms.models import modelformset_factory, BaseModelFormSet from django.forms.models import modelformset_factory, BaseModelFormSet
from django.template import Template from django.template import Template, Context
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.forms.widgets import SpanWidget from orchestra.forms.widgets import SpanWidget
@ -28,9 +28,9 @@ class AdminFormMixin(object):
' {% include "admin/includes/fieldset.html" %}' ' {% include "admin/includes/fieldset.html" %}'
'{% endfor %}' '{% endfor %}'
) )
context = { context = Context({
'adminform': adminform 'adminform': adminform
} })
return template.render(context) return template.render(context)
@ -71,9 +71,9 @@ class AdminFormSet(BaseModelFormSet):
</div> </div>
</div>""") </div>""")
) )
context = { context = Context({
'formset': self 'formset': self
} })
return template.render(context) return template.render(context)

View File

@ -1,7 +1,7 @@
from copy import deepcopy from copy import deepcopy
from admin_tools.menu import items, Menu from admin_tools.menu import items, Menu
from django.urls import reverse from django.core.urlresolvers import reverse
from django.utils.text import capfirst from django.utils.text import capfirst
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _

View File

@ -6,11 +6,11 @@ from functools import wraps
from django.conf import settings from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.urls import reverse, NoReverseMatch from django.core.urlresolvers import reverse, NoReverseMatch
from django.db import models from django.db import models
from django.shortcuts import redirect from django.shortcuts import redirect
from django.utils import timezone from django.utils import timezone
from django.utils.html import escape, format_html from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from orchestra.models.utils import get_field_value from orchestra.models.utils import get_field_value
@ -113,21 +113,21 @@ def admin_link(*args, **kwargs):
return '---' return '---'
if not getattr(obj, 'pk', None): if not getattr(obj, 'pk', None):
return '---' return '---'
display_ = kwargs.get('display') display = kwargs.get('display')
if display_: if display:
display_ = getattr(obj, display_, display_) display = getattr(obj, display, display)
else: else:
display_ = obj display = obj
try: try:
url = change_url(obj) url = change_url(obj)
except NoReverseMatch: except NoReverseMatch:
# Does not has admin # Does not has admin
return str(display_) return str(display)
extra = '' extra = ''
if kwargs['popup']: if kwargs['popup']:
extra = mark_safe('onclick="return showAddAnotherPopup(this);"') extra = 'onclick="return showAddAnotherPopup(this);"'
title = "Change %s" % obj._meta.verbose_name title = "Change %s" % obj._meta.verbose_name
return format_html('<a href="{}" title="{}" {}>{}</a>', url, title, extra, display_) return mark_safe('<a href="%s" title="%s" %s>%s</a>' % (url, title, extra, display))
@admin_field @admin_field
@ -158,7 +158,7 @@ def admin_date(*args, **kwargs):
date = date.strftime("%Y-%m-%d %H:%M:%S %Z") date = date.strftime("%Y-%m-%d %H:%M:%S %Z")
else: else:
date = date.strftime("%Y-%m-%d") date = date.strftime("%Y-%m-%d")
return format_html('<span title="{0}">{1}</span>', date, natural) return '<span title="{0}">{1}</span>'.format(date, escape(natural))
def get_object_from_url(modeladmin, request): def get_object_from_url(modeladmin, request):

View File

@ -1,12 +1,12 @@
from rest_framework import status from rest_framework import status
from rest_framework.decorators import action from rest_framework.decorators import detail_route
from rest_framework.response import Response from rest_framework.response import Response
from .serializers import SetPasswordSerializer from .serializers import SetPasswordSerializer
class SetPasswordApiMixin(object): class SetPasswordApiMixin(object):
@action(detail=True, methods=['post'], serializer_class=SetPasswordSerializer) @detail_route(methods=['post'], serializer_class=SetPasswordSerializer)
def set_password(self, request, pk): def set_password(self, request, pk):
obj = self.get_object() obj = self.get_object()
data = request.data data = request.data

View File

@ -1,4 +1,4 @@
from django.urls import NoReverseMatch from django.core.urlresolvers import NoReverseMatch
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
@ -23,16 +23,16 @@ def link_wrap(view, view_names):
return wrapper return wrapper
def insert_links(viewset, basename): def insert_links(viewset, base_name):
collection_links = ['api-root', '%s-list' % basename] collection_links = ['api-root', '%s-list' % base_name]
object_links = ['api-root', '%s-list' % basename, '%s-detail' % basename] object_links = ['api-root', '%s-list' % base_name, '%s-detail' % base_name]
exception_links = ['api-root'] exception_links = ['api-root']
list_links = ['api-root'] list_links = ['api-root']
retrieve_links = ['api-root', '%s-list' % basename] retrieve_links = ['api-root', '%s-list' % base_name]
# Determine any `@action` or `@link` decorated methods on the viewset # Determine any `@action` or `@link` decorated methods on the viewset
for methodname in dir(viewset): for methodname in dir(viewset):
method = getattr(viewset, methodname) method = getattr(viewset, methodname)
view_name = '%s-%s' % (basename, methodname.replace('_', '-')) view_name = '%s-%s' % (base_name, methodname.replace('_', '-'))
if hasattr(method, 'collection_bind_to_methods'): if hasattr(method, 'collection_bind_to_methods'):
list_links.append(view_name) list_links.append(view_name)
retrieve_links.append(view_name) retrieve_links.append(view_name)

View File

@ -65,12 +65,12 @@ class LinkHeaderRouter(DefaultRouter):
APIRoot.router = self APIRoot.router = self
return APIRoot.as_view() return APIRoot.as_view()
def register(self, prefix, viewset, basename=None): def register(self, prefix, viewset, base_name=None):
""" inserts link headers on every viewset """ """ inserts link headers on every viewset """
if basename is None: if base_name is None:
basename = self.get_default_basename(viewset) base_name = self.get_default_base_name(viewset)
insert_links(viewset, basename) insert_links(viewset, base_name)
self.registry.append((prefix, viewset, basename)) self.registry.append((prefix, viewset, base_name))
def get_viewset(self, prefix_or_model): def get_viewset(self, prefix_or_model):
for _prefix, viewset, __ in self.registry: for _prefix, viewset, __ in self.registry:

View File

@ -23,7 +23,7 @@ class APIRoot(views.APIView):
'accountancy': {}, 'accountancy': {},
'services': {}, 'services': {},
} }
if not request.user.is_anonymous: if not request.user.is_anonymous():
list_name = '{basename}-list' list_name = '{basename}-list'
detail_name = '{basename}-detail' detail_name = '{basename}-detail'
for prefix, viewset, basename in self.router.registry: for prefix, viewset, basename in self.router.registry:

View File

@ -64,10 +64,7 @@ class HyperlinkedModelSerializer(serializers.HyperlinkedModelSerializer):
class RelatedHyperlinkedModelSerializer(HyperlinkedModelSerializer): class RelatedHyperlinkedModelSerializer(HyperlinkedModelSerializer):
""" returns object on to_internal_value based on URL """ """ returns object on to_internal_value based on URL """
def to_internal_value(self, data): def to_internal_value(self, data):
try: url = data.get('url')
url = data.get('url')
except AttributeError:
url = None
if not url: if not url:
raise ValidationError({ raise ValidationError({
'url': "URL is required." 'url': "URL is required."
@ -84,14 +81,14 @@ class SetPasswordHyperlinkedSerializer(HyperlinkedModelSerializer):
validators=[validate_password], write_only=True, required=False, validators=[validate_password], write_only=True, required=False,
style={'widget': widgets.PasswordInput}) style={'widget': widgets.PasswordInput})
def validate_password(self, value): def validate_password(self, attrs, source):
""" POST only password """ """ POST only password """
if self.instance: if self.instance:
if value: if 'password' in attrs:
raise serializers.ValidationError(_("Can not set password")) raise serializers.ValidationError(_("Can not set password"))
elif not value: elif 'password' not in attrs:
raise serializers.ValidationError(_("Password required")) raise serializers.ValidationError(_("Password required"))
return value return attrs
def validate(self, attrs): def validate(self, attrs):
""" remove password in case is not a real model field """ """ remove password in case is not a real model field """

View File

@ -157,7 +157,7 @@ function install_requirements () {
PIP="${PIP} \ PIP="${PIP} \
selenium \ selenium \
xvfbwrapper \ xvfbwrapper \
freezegun==0.3.14 \ freezegun \
coverage \ coverage \
flake8 \ flake8 \
django-debug-toolbar==1.3.0 \ django-debug-toolbar==1.3.0 \
@ -174,7 +174,7 @@ function install_requirements () {
minor=$(echo -e "$wkhtmltox_version\n0.12.2.1" | sort -V | head -n 1) minor=$(echo -e "$wkhtmltox_version\n0.12.2.1" | sort -V | head -n 1)
if [[ ! $wkhtmltox_version ]] || [[ $wkhtmltox_version != 0.12.2.1 && $minor == ${wkhtmltox_version} ]]; then if [[ ! $wkhtmltox_version ]] || [[ $wkhtmltox_version != 0.12.2.1 && $minor == ${wkhtmltox_version} ]]; then
wkhtmltox=$(mktemp) wkhtmltox=$(mktemp)
wget https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.buster_amd64.deb -O ${wkhtmltox} wget http://download.gna.org/wkhtmltopdf/0.12/0.12.2.1/wkhtmltox-0.12.2.1_linux-jessie-amd64.deb -O ${wkhtmltox}
dpkg -i ${wkhtmltox} || { echo "Installing missing dependencies for wkhtmltox..." && apt-get -f -y install; } dpkg -i ${wkhtmltox} || { echo "Installing missing dependencies for wkhtmltox..." && apt-get -f -y install; }
fi fi
} }

View File

@ -178,7 +178,7 @@ def fire_pending_tasks(manage, db):
if is_due(now, minute, hour, day_of_week, day_of_month, month_of_year): if is_due(now, minute, hour, day_of_week, day_of_month, month_of_year):
command = 'python3 -W ignore::DeprecationWarning {manage} runtask {task_id}'.format( command = 'python3 -W ignore::DeprecationWarning {manage} runtask {task_id}'.format(
manage=manage, task_id=task_id) manage=manage, task_id=task_id)
proc = run(command, run_async=True) proc = run(command, async=True)
yield proc yield proc
@ -201,7 +201,7 @@ def fire_pending_messages(settings, db):
if has_pending_messages(settings, db): if has_pending_messages(settings, db):
command = 'python3 -W ignore::DeprecationWarning {manage} sendpendingmessages'.format(manage=manage) command = 'python3 -W ignore::DeprecationWarning {manage} sendpendingmessages'.format(manage=manage)
proc = run(command, run_async=True) proc = run(command, async=True)
yield proc yield proc

View File

@ -25,7 +25,6 @@ SECRET_KEY = '{{ secret_key }}'
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
ALLOWED_HOSTS = []
# Application definition # Application definition
@ -66,7 +65,6 @@ INSTALLED_APPS = [
'admin_tools.dashboard', 'admin_tools.dashboard',
'rest_framework', 'rest_framework',
'rest_framework.authtoken', 'rest_framework.authtoken',
'django_filters',
'passlib.ext.django', 'passlib.ext.django',
'django_countries', 'django_countries',
# 'debug_toolbar', # 'debug_toolbar',
@ -86,21 +84,6 @@ INSTALLED_APPS = [
] ]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'orchestra.core.caches.RequestCacheMiddleware',
# also handles transations, ATOMIC_REQUESTS does not wrap middlewares
'orchestra.contrib.orchestration.middlewares.OperationsMiddleware',
]
ROOT_URLCONF = '{{ project_name }}.urls' ROOT_URLCONF = '{{ project_name }}.urls'
TEMPLATES = [ TEMPLATES = [
@ -133,35 +116,17 @@ WSGI_APPLICATION = '{{ project_name }}.wsgi.application'
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', 'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 'NAME': 'test_myapp',
'USER': '', # Not used with sqlite3. 'USER': 'testuser',
'PASSWORD': '', # Not used with sqlite3. 'PASSWORD': 's3cretPass',
'HOST': '', # Set to empty string for localhost. Not used with sqlite3. 'HOST': 'localhost',
'PORT': '', # Set to empty string for default. Not used with sqlite3. 'PORT': '5432',
'CONN_MAX_AGE': 60*10 # Enable persistent connections 'CONN_MAX_AGE': 60*10 # Enable persistent connections
} }
} }
# Password validation
# https://docs.djangoproject.com/en/{{ docs_version }}/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/{{ docs_version }}/topics/i18n/ # https://docs.djangoproject.com/en/{{ docs_version }}/topics/i18n/
@ -203,6 +168,22 @@ LOCALE_PATHS = (
ORCHESTRA_SITE_NAME = '{{ project_name }}' ORCHESTRA_SITE_NAME = '{{ project_name }}'
MIDDLEWARE_CLASSES = (
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
# 'django.middleware.locale.LocaleMiddleware'
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.security.SecurityMiddleware',
'orchestra.core.caches.RequestCacheMiddleware',
# also handles transations, ATOMIC_REQUESTS does not wrap middlewares
'orchestra.contrib.orchestration.middlewares.OperationsMiddleware',
)
AUTH_USER_MODEL = 'accounts.Account' AUTH_USER_MODEL = 'accounts.Account'
@ -247,7 +228,7 @@ REST_FRAMEWORK = {
'rest_framework.authentication.TokenAuthentication', 'rest_framework.authentication.TokenAuthentication',
), ),
'DEFAULT_FILTER_BACKENDS': ( 'DEFAULT_FILTER_BACKENDS': (
('django_filters.rest_framework.DjangoFilterBackend',) ('rest_framework.filters.DjangoFilterBackend',)
), ),
} }
@ -261,6 +242,7 @@ PASSLIB_CONFIG = (
"default = sha512_crypt\n" "default = sha512_crypt\n"
"deprecated = django_pbkdf2_sha1, django_salted_sha1, django_salted_md5, " "deprecated = django_pbkdf2_sha1, django_salted_sha1, django_salted_md5, "
" django_des_crypt, des_crypt, hex_md5\n" " django_des_crypt, des_crypt, hex_md5\n"
"all__vary_rounds = 0.05\n"
"django_pbkdf2_sha256__min_rounds = 10000\n" "django_pbkdf2_sha256__min_rounds = 10000\n"
"sha512_crypt__min_rounds = 80000\n" "sha512_crypt__min_rounds = 80000\n"
"staff__django_pbkdf2_sha256__default_rounds = 12500\n" "staff__django_pbkdf2_sha256__default_rounds = 12500\n"

View File

@ -4,7 +4,7 @@ from django.contrib import messages
from django.contrib.admin import helpers from django.contrib.admin import helpers
from django.contrib.admin.utils import NestedObjects, quote from django.contrib.admin.utils import NestedObjects, quote
from django.contrib.auth import get_permission_codename from django.contrib.auth import get_permission_codename
from django.urls import reverse, NoReverseMatch from django.core.urlresolvers import reverse, NoReverseMatch
from django.db import router from django.db import router
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
@ -175,7 +175,7 @@ def delete_related_services(modeladmin, request, queryset):
for model, objs in collector.model_objs.items(): for model, objs in collector.model_objs.items():
count = 0 count = 0
# discount main systemuser # discount main systemuser
if model is modeladmin.model.main_systemuser.field.related_model: if model is modeladmin.model.main_systemuser.field.rel.to:
count = len(objs) - 1 count = len(objs) - 1
# Discount account # Discount account
elif model is not modeladmin.model and model in registered_services: elif model is not modeladmin.model and model in registered_services:

View File

@ -8,7 +8,7 @@ from django.conf.urls import url
from django.contrib import admin, messages from django.contrib import admin, messages
from django.contrib.admin.utils import unquote from django.contrib.admin.utils import unquote
from django.contrib.auth import admin as auth from django.contrib.auth import admin as auth
from django.urls import reverse from django.core.urlresolvers import reverse
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.templatetags.static import static from django.templatetags.static import static
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@ -158,7 +158,6 @@ class AccountListAdmin(AccountAdmin):
actions = None actions = None
change_list_template = 'admin/accounts/account/select_account_list.html' change_list_template = 'admin/accounts/account/select_account_list.html'
@mark_safe
def select_account(self, instance): def select_account(self, instance):
# TODO get query string from request.META['QUERY_STRING'] to preserve filters # TODO get query string from request.META['QUERY_STRING'] to preserve filters
context = { context = {
@ -168,6 +167,7 @@ class AccountListAdmin(AccountAdmin):
} }
return _('<a href="%(url)s">%(plus)s Add to %(name)s</a>') % context return _('<a href="%(url)s">%(plus)s Add to %(name)s</a>') % context
select_account.short_description = _("account") select_account.short_description = _("account")
select_account.allow_tags = True
select_account.admin_order_field = 'username' select_account.admin_order_field = 'username'
def changelist_view(self, request, extra_context=None): def changelist_view(self, request, extra_context=None):
@ -207,7 +207,6 @@ class AccountAdminMixin(object):
account = None account = None
list_select_related = ('account',) list_select_related = ('account',)
@mark_safe
def display_active(self, instance): def display_active(self, instance):
if not instance.is_active: if not instance.is_active:
return '<img src="%s" alt="False">' % static('admin/img/icon-no.svg') return '<img src="%s" alt="False">' % static('admin/img/icon-no.svg')
@ -216,12 +215,14 @@ class AccountAdminMixin(object):
return '<img style="width:13px" src="%s" alt="False" title="%s">' % (static('admin/img/inline-delete.svg'), msg) return '<img style="width:13px" src="%s" alt="False" title="%s">' % (static('admin/img/inline-delete.svg'), msg)
return '<img src="%s" alt="False">' % static('admin/img/icon-yes.svg') return '<img src="%s" alt="False">' % static('admin/img/icon-yes.svg')
display_active.short_description = _("active") display_active.short_description = _("active")
display_active.allow_tags = True
display_active.admin_order_field = 'is_active' display_active.admin_order_field = 'is_active'
def account_link(self, instance): def account_link(self, instance):
account = instance.account if instance.pk else self.account account = instance.account if instance.pk else self.account
return admin_link()(account) return admin_link()(account)
account_link.short_description = _("account") account_link.short_description = _("account")
account_link.allow_tags = True
account_link.admin_order_field = 'account__username' account_link.admin_order_field = 'account__username'
def get_form(self, request, obj=None, **kwargs): def get_form(self, request, obj=None, **kwargs):

View File

@ -47,7 +47,7 @@ def create_account_creation_form():
# Previous validation error # Previous validation error
return return
errors = {} errors = {}
systemuser_model = Account.main_systemuser.field.related_model systemuser_model = Account.main_systemuser.field.rel.to
if systemuser_model.objects.filter(username=account.username).exists(): if systemuser_model.objects.filter(username=account.username).exists():
errors['username'] = _("A system user with this name already exists.") errors['username'] = _("A system user with this name already exists.")
for model, key, related_kwargs, __ in create_related: for model, key, related_kwargs, __ in create_related:

View File

@ -3,7 +3,6 @@ from __future__ import unicode_literals
from django.db import models, migrations from django.db import models, migrations
import django.core.validators import django.core.validators
import django.db.models.deletion
import django.utils.timezone import django.utils.timezone
import django.contrib.auth.models import django.contrib.auth.models
@ -33,7 +32,7 @@ class Migration(migrations.Migration):
('is_superuser', models.BooleanField(help_text='Designates that this user has all permissions without explicitly assigning them.', default=False, verbose_name='superuser status')), ('is_superuser', models.BooleanField(help_text='Designates that this user has all permissions without explicitly assigning them.', default=False, verbose_name='superuser status')),
('is_active', models.BooleanField(help_text='Designates whether this account should be treated as active. Unselect this instead of deleting accounts.', default=True, verbose_name='active')), ('is_active', models.BooleanField(help_text='Designates whether this account should be treated as active. Unselect this instead of deleting accounts.', default=True, verbose_name='active')),
('date_joined', models.DateTimeField(verbose_name='date joined', default=django.utils.timezone.now)), ('date_joined', models.DateTimeField(verbose_name='date joined', default=django.utils.timezone.now)),
('main_systemuser', models.ForeignKey(to='systemusers.SystemUser', editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='accounts_main')), ('main_systemuser', models.ForeignKey(to='systemusers.SystemUser', editable=False, null=True, related_name='accounts_main')),
], ],
options={ options={
'abstract': False, 'abstract': False,

View File

@ -1,86 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2021-04-22 11:08
from __future__ import unicode_literals
import django.contrib.auth.models
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import orchestra.contrib.accounts.models
class Migration(migrations.Migration):
replaces = [('accounts', '0001_initial'), ('accounts', '0002_auto_20170528_2005'), ('accounts', '0003_auto_20210330_1049'), ('accounts', '0004_auto_20210422_1108')]
initial = True
dependencies = [
('systemusers', '0001_initial'),
('auth', '0006_require_contenttypes_0002'),
]
operations = [
migrations.CreateModel(
name='Account',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('username', models.CharField(help_text='Required. 64 characters or fewer. Letters, digits and ./-/_ only.', max_length=32, unique=True, validators=[django.core.validators.RegexValidator('^[\\w.-]+$', 'Enter a valid username.', 'invalid')], verbose_name='username')),
('short_name', models.CharField(blank=True, max_length=64, verbose_name='short name')),
('full_name', models.CharField(max_length=256, verbose_name='full name')),
('email', models.EmailField(help_text='Used for password recovery', max_length=254, verbose_name='email address')),
('type', models.CharField(choices=[('INDIVIDUAL', 'Individual'), ('ASSOCIATION', 'Association'), ('CUSTOMER', 'Customer'), ('COMPANY', 'Company'), ('PUBLICBODY', 'Public body'), ('STAFF', 'Staff'), ('FRIEND', 'Friend')], default='INDIVIDUAL', max_length=32, verbose_name='type')),
('language', models.CharField(choices=[('EN', 'English')], default='EN', max_length=2, verbose_name='language')),
('comments', models.TextField(blank=True, max_length=256, verbose_name='comments')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this account should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('main_systemuser', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='accounts_main', to='systemusers.SystemUser')),
],
options={
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.AlterModelManagers(
name='account',
managers=[
('objects', orchestra.contrib.accounts.models.AccountManager()),
],
),
migrations.AlterField(
model_name='account',
name='language',
field=models.CharField(choices=[('CA', 'Catalan'), ('ES', 'Spanish'), ('EN', 'English')], default='CA', max_length=2, verbose_name='language'),
),
migrations.AlterField(
model_name='account',
name='type',
field=models.CharField(choices=[('INDIVIDUAL', 'Individual'), ('ASSOCIATION', 'Association'), ('CUSTOMER', 'Customer'), ('STAFF', 'Staff'), ('FRIEND', 'Friend')], default='INDIVIDUAL', max_length=32, verbose_name='type'),
),
migrations.AlterField(
model_name='account',
name='username',
field=models.CharField(help_text='Required. 32 characters or fewer. Letters, digits and ./-/_ only.', max_length=32, unique=True, validators=[django.core.validators.RegexValidator('^[\\w.-]+$', 'Enter a valid username.', 'invalid')], verbose_name='username'),
),
migrations.AlterField(
model_name='account',
name='language',
field=models.CharField(choices=[('EN', 'English')], default='EN', max_length=2, verbose_name='language'),
),
migrations.AlterField(
model_name='account',
name='type',
field=models.CharField(choices=[('INDIVIDUAL', 'Individual'), ('ASSOCIATION', 'Association'), ('CUSTOMER', 'Customer'), ('COMPANY', 'Company'), ('PUBLICBODY', 'Public body'), ('STAFF', 'Staff'), ('FRIEND', 'Friend')], default='INDIVIDUAL', max_length=32, verbose_name='type'),
),
migrations.AlterField(
model_name='account',
name='main_systemuser',
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='accounts_main', to='systemusers.SystemUser'),
),
]

View File

@ -1,25 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2021-03-30 10:49
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_auto_20170528_2005'),
]
operations = [
migrations.AlterField(
model_name='account',
name='language',
field=models.CharField(choices=[('EN', 'English')], default='EN', max_length=2, verbose_name='language'),
),
migrations.AlterField(
model_name='account',
name='type',
field=models.CharField(choices=[('INDIVIDUAL', 'Individual'), ('ASSOCIATION', 'Association'), ('CUSTOMER', 'Customer'), ('COMPANY', 'Company'), ('PUBLICBODY', 'Public body'), ('STAFF', 'Staff'), ('FRIEND', 'Friend')], default='INDIVIDUAL', max_length=32, verbose_name='type'),
),
]

View File

@ -29,7 +29,7 @@ class Account(auth.AbstractBaseUser):
validators.RegexValidator(r'^[\w.-]+$', _("Enter a valid username."), 'invalid') validators.RegexValidator(r'^[\w.-]+$', _("Enter a valid username."), 'invalid')
]) ])
main_systemuser = models.ForeignKey(settings.ACCOUNTS_SYSTEMUSER_MODEL, null=True, main_systemuser = models.ForeignKey(settings.ACCOUNTS_SYSTEMUSER_MODEL, null=True,
related_name='accounts_main', editable=False, on_delete=models.SET_NULL) related_name='accounts_main', editable=False)
short_name = models.CharField(_("short name"), max_length=64, blank=True) short_name = models.CharField(_("short name"), max_length=64, blank=True)
full_name = models.CharField(_("full name"), max_length=256) full_name = models.CharField(_("full name"), max_length=256)
email = models.EmailField(_('email address'), help_text=_("Used for password recovery")) email = models.EmailField(_('email address'), help_text=_("Used for password recovery"))
@ -52,11 +52,6 @@ class Account(auth.AbstractBaseUser):
USERNAME_FIELD = 'username' USERNAME_FIELD = 'username'
REQUIRED_FIELDS = ['email'] REQUIRED_FIELDS = ['email']
def __init__(self, *args, **kwargs):
# ignore `is_staff` kwarg because is handled with `is_superuser`
kwargs.pop('is_staff', None)
super().__init__(*args, **kwargs)
def __str__(self): def __str__(self):
return self.name return self.name

View File

@ -5,7 +5,7 @@ from datetime import date
from django.contrib import messages from django.contrib import messages
from django.contrib.admin import helpers from django.contrib.admin import helpers
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.urls import reverse from django.core.urlresolvers import reverse
from django.db import transaction from django.db import transaction
from django.forms.models import modelformset_factory from django.forms.models import modelformset_factory
from django.http import HttpResponse, HttpResponseRedirect from django.http import HttpResponse, HttpResponseRedirect

View File

@ -2,12 +2,11 @@ from django import forms
from django.conf.urls import url from django.conf.urls import url
from django.contrib import admin, messages from django.contrib import admin, messages
from django.contrib.admin.utils import unquote from django.contrib.admin.utils import unquote
from django.urls import reverse from django.core.urlresolvers import reverse
from django.db import models from django.db import models
from django.db.models import F, Sum, Prefetch from django.db.models import F, Sum, Prefetch
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.templatetags.static import static from django.templatetags.static import static
from django.utils.html import format_html
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.shortcuts import redirect from django.shortcuts import redirect
@ -16,7 +15,7 @@ from orchestra.admin import ExtendedModelAdmin
from orchestra.admin.utils import admin_date, insertattr, admin_link, change_url from orchestra.admin.utils import admin_date, insertattr, admin_link, change_url
from orchestra.contrib.accounts.actions import list_accounts from orchestra.contrib.accounts.actions import list_accounts
from orchestra.contrib.accounts.admin import AccountAdminMixin, AccountAdmin from orchestra.contrib.accounts.admin import AccountAdminMixin, AccountAdmin
from orchestra.forms.widgets import PaddingCheckboxSelectMultiple from orchestra.forms.widgets import paddingCheckboxSelectMultiple
from . import settings, actions from . import settings, actions
from .filters import (BillTypeListFilter, HasBillContactListFilter, TotalListFilter, from .filters import (BillTypeListFilter, HasBillContactListFilter, TotalListFilter,
@ -68,7 +67,6 @@ class BillLineInline(admin.TabularInline):
order_link = admin_link('order', display='pk') order_link = admin_link('order', display='pk')
@mark_safe
def display_total(self, line): def display_total(self, line):
if line.pk: if line.pk:
total = line.compute_total() total = line.compute_total()
@ -80,6 +78,7 @@ class BillLineInline(admin.TabularInline):
return '<a href="%s" title="%s">%s <img src="%s"></img></a>' % (url, content, total, img) return '<a href="%s" title="%s">%s <img src="%s"></img></a>' % (url, content, total, img)
return '<a href="%s">%s</a>' % (url, total) return '<a href="%s">%s</a>' % (url, total)
display_total.short_description = _("Total") display_total.short_description = _("Total")
display_total.allow_tags = True
def formfield_for_dbfield(self, db_field, **kwargs): def formfield_for_dbfield(self, db_field, **kwargs):
""" Make value input widget bigger """ """ Make value input widget bigger """
@ -105,26 +104,27 @@ class ClosedBillLineInline(BillLineInline):
readonly_fields = fields readonly_fields = fields
can_delete = False can_delete = False
@mark_safe
def display_description(self, line): def display_description(self, line):
descriptions = [line.description] descriptions = [line.description]
for subline in line.sublines.all(): for subline in line.sublines.all():
descriptions.append('&nbsp;' * 4 + subline.description) descriptions.append('&nbsp;'*4+subline.description)
return '<br>'.join(descriptions) return '<br>'.join(descriptions)
display_description.short_description = _("Description") display_description.short_description = _("Description")
display_description.allow_tags = True
@mark_safe
def display_subtotal(self, line): def display_subtotal(self, line):
subtotals = ['&nbsp;' + str(line.subtotal)] subtotals = ['&nbsp;' + str(line.subtotal)]
for subline in line.sublines.all(): for subline in line.sublines.all():
subtotals.append(str(subline.total)) subtotals.append(str(subline.total))
return '<br>'.join(subtotals) return '<br>'.join(subtotals)
display_subtotal.short_description = _("Subtotal") display_subtotal.short_description = _("Subtotal")
display_subtotal.allow_tags = True
def display_total(self, line): def display_total(self, line):
if line.pk: if line.pk:
return line.compute_total() return line.compute_total()
display_total.short_description = _("Total") display_total.short_description = _("Total")
display_total.allow_tags = True
def has_add_permission(self, request): def has_add_permission(self, request):
return False return False
@ -242,7 +242,6 @@ class BillLineManagerAdmin(BillLineAdmin):
class BillAdminMixin(AccountAdminMixin): class BillAdminMixin(AccountAdminMixin):
@mark_safe
def display_total_with_subtotals(self, bill): def display_total_with_subtotals(self, bill):
if bill.pk: if bill.pk:
currency = settings.BILLS_CURRENCY.lower() currency = settings.BILLS_CURRENCY.lower()
@ -252,10 +251,10 @@ class BillAdminMixin(AccountAdminMixin):
subtotals.append(_("Taxes %s%% VAT %s &%s;") % (tax, subtotal[1], currency)) subtotals.append(_("Taxes %s%% VAT %s &%s;") % (tax, subtotal[1], currency))
subtotals = '\n'.join(subtotals) subtotals = '\n'.join(subtotals)
return '<span title="%s">%s &%s;</span>' % (subtotals, bill.compute_total(), currency) return '<span title="%s">%s &%s;</span>' % (subtotals, bill.compute_total(), currency)
display_total_with_subtotals.allow_tags = True
display_total_with_subtotals.short_description = _("total") display_total_with_subtotals.short_description = _("total")
display_total_with_subtotals.admin_order_field = 'approx_total' display_total_with_subtotals.admin_order_field = 'approx_total'
@mark_safe
def display_payment_state(self, bill): def display_payment_state(self, bill):
if bill.pk: if bill.pk:
t_opts = bill.transactions.model._meta t_opts = bill.transactions.model._meta
@ -277,6 +276,7 @@ class BillAdminMixin(AccountAdminMixin):
color = PAYMENT_STATE_COLORS.get(bill.payment_state, 'grey') color = PAYMENT_STATE_COLORS.get(bill.payment_state, 'grey')
return '<a href="{url}" style="color:{color}" title="{title}">{name}</a>'.format( return '<a href="{url}" style="color:{color}" title="{title}">{name}</a>'.format(
url=url, color=color, name=state, title=title) url=url, color=color, name=state, title=title)
display_payment_state.allow_tags = True
display_payment_state.short_description = _("Payment") display_payment_state.short_description = _("Payment")
def get_queryset(self, request): def get_queryset(self, request):
@ -376,14 +376,16 @@ class BillAdmin(BillAdminMixin, ExtendedModelAdmin):
def display_total(self, bill): def display_total(self, bill):
currency = settings.BILLS_CURRENCY.lower() currency = settings.BILLS_CURRENCY.lower()
return format_html('{} &{};', bill.compute_total(), currency) return '%s &%s;' % (bill.compute_total(), currency)
display_total.allow_tags = True
display_total.short_description = _("total") display_total.short_description = _("total")
display_total.admin_order_field = 'approx_total' display_total.admin_order_field = 'approx_total'
def type_link(self, bill): def type_link(self, bill):
bill_type = bill.type.lower() bill_type = bill.type.lower()
url = reverse('admin:bills_%s_changelist' % bill_type) url = reverse('admin:bills_%s_changelist' % bill_type)
return format_html('<a href="{}">{}</a>', url, bill.get_type_display()) return '<a href="%s">%s</a>' % (url, bill.get_type_display())
type_link.allow_tags = True
type_link.short_description = _("type") type_link.short_description = _("type")
type_link.admin_order_field = 'type' type_link.admin_order_field = 'type'
@ -477,7 +479,7 @@ class BillContactInline(admin.StackedInline):
if db_field.name == 'address': if db_field.name == 'address':
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 2}) kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 2})
if db_field.name == 'email_usage': if db_field.name == 'email_usage':
kwargs['widget'] = PaddingCheckboxSelectMultiple(45) kwargs['widget'] = paddingCheckboxSelectMultiple(45)
return super().formfield_for_dbfield(db_field, **kwargs) return super().formfield_for_dbfield(db_field, **kwargs)

View File

@ -1,6 +1,6 @@
from django.http import HttpResponse from django.http import HttpResponse
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.decorators import action from rest_framework.decorators import detail_route
from orchestra.api import router, LogApiMixin from orchestra.api import router, LogApiMixin
from orchestra.contrib.accounts.api import AccountApiMixin from orchestra.contrib.accounts.api import AccountApiMixin
@ -15,7 +15,7 @@ class BillViewSet(LogApiMixin, AccountApiMixin, viewsets.ModelViewSet):
queryset = Bill.objects.all() queryset = Bill.objects.all()
serializer_class = BillSerializer serializer_class = BillSerializer
@action(detail=True, methods=['get']) @detail_route(methods=['get'])
def document(self, request, pk): def document(self, request, pk):
bill = self.get_object() bill = self.get_object()
content_type = request.META.get('HTTP_ACCEPT') content_type = request.META.get('HTTP_ACCEPT')

View File

@ -1,5 +1,5 @@
from django.contrib.admin import SimpleListFilter from django.contrib.admin import SimpleListFilter
from django.urls import reverse from django.core.urlresolvers import reverse
from django.db.models import Q from django.db.models import Q
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _

View File

@ -1,5 +1,5 @@
from django.contrib import messages from django.contrib import messages
from django.urls import reverse from django.core.urlresolvers import reverse
from django.utils.encoding import force_text from django.utils.encoding import force_text
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@ -21,7 +21,7 @@ def validate_contact(request, bill, error=True):
message = msg.format(relation=_("Related"), account=account, url=url) message = msg.format(relation=_("Related"), account=account, url=url)
send(request, mark_safe(message)) send(request, mark_safe(message))
valid = False valid = False
main = type(bill).account.field.related_model.objects.get_main() main = type(bill).account.field.rel.to.objects.get_main()
if not hasattr(main, 'billcontact'): if not hasattr(main, 'billcontact'):
account = force_text(main) account = force_text(main)
url = reverse('admin:accounts_account_change', args=(main.id,)) url = reverse('admin:accounts_account_change', args=(main.id,))

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import django.db.models.deletion
from django.db import models, migrations from django.db import models, migrations
@ -15,7 +14,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='bill', model_name='bill',
name='amend_of', name='amend_of',
field=models.ForeignKey(to='bills.Bill', blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='amends', verbose_name='amend of', null=True), field=models.ForeignKey(to='bills.Bill', blank=True, related_name='amends', verbose_name='amend of', null=True),
), ),
migrations.AlterField( migrations.AlterField(
model_name='billcontact', model_name='billcontact',

File diff suppressed because one or more lines are too long

View File

@ -1,12 +1,12 @@
import datetime import datetime
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.urls import reverse from django.core.urlresolvers import reverse
from django.core.validators import ValidationError, RegexValidator from django.core.validators import ValidationError, RegexValidator
from django.db import models from django.db import models
from django.db.models import F, Sum from django.db.models import F, Sum
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.template import loader from django.template import loader, Context
from django.utils import timezone, translation from django.utils import timezone, translation
from django.utils.encoding import force_text from django.utils.encoding import force_text
from django.utils.functional import cached_property from django.utils.functional import cached_property
@ -24,7 +24,7 @@ from . import settings
class BillContact(models.Model): class BillContact(models.Model):
account = models.OneToOneField('accounts.Account', verbose_name=_("account"), account = models.OneToOneField('accounts.Account', verbose_name=_("account"),
related_name='billcontact', on_delete=models.CASCADE) related_name='billcontact')
name = models.CharField(_("name"), max_length=256, blank=True, name = models.CharField(_("name"), max_length=256, blank=True,
help_text=_("Account full name will be used when left blank.")) help_text=_("Account full name will be used when left blank."))
address = models.TextField(_("address")) address = models.TextField(_("address"))
@ -102,9 +102,9 @@ class Bill(models.Model):
number = models.CharField(_("number"), max_length=16, unique=True, blank=True) number = models.CharField(_("number"), max_length=16, unique=True, blank=True)
account = models.ForeignKey('accounts.Account', verbose_name=_("account"), account = models.ForeignKey('accounts.Account', verbose_name=_("account"),
related_name='%(class)s', on_delete=models.CASCADE) related_name='%(class)s')
amend_of = models.ForeignKey('self', null=True, blank=True, verbose_name=_("amend of"), amend_of = models.ForeignKey('self', null=True, blank=True, verbose_name=_("amend of"),
related_name='amends', on_delete=models.SET_NULL) related_name='amends')
type = models.CharField(_("type"), max_length=16, choices=TYPES) type = models.CharField(_("type"), max_length=16, choices=TYPES)
created_on = models.DateField(_("created on"), auto_now_add=True) created_on = models.DateField(_("created on"), auto_now_add=True)
closed_on = models.DateField(_("closed on"), blank=True, null=True, db_index=True) closed_on = models.DateField(_("closed on"), blank=True, null=True, db_index=True)
@ -303,7 +303,7 @@ class Bill(models.Model):
with translation.override(language or self.account.language): with translation.override(language or self.account.language):
if payment is False: if payment is False:
payment = self.account.paymentsources.get_default() payment = self.account.paymentsources.get_default()
context = { context = Context({
'bill': self, 'bill': self,
'lines': self.lines.all().prefetch_related('sublines'), 'lines': self.lines.all().prefetch_related('sublines'),
'seller': self.seller, 'seller': self.seller,
@ -318,7 +318,7 @@ class Bill(models.Model):
'payment': payment and payment.get_bill_context(), 'payment': payment and payment.get_bill_context(),
'default_due_date': self.get_due_date(payment=payment), 'default_due_date': self.get_due_date(payment=payment),
'now': timezone.now(), 'now': timezone.now(),
} })
template_name = 'BILLS_%s_TEMPLATE' % self.get_type() template_name = 'BILLS_%s_TEMPLATE' % self.get_type()
template = getattr(settings, template_name, settings.BILLS_DEFAULT_TEMPLATE) template = getattr(settings, template_name, settings.BILLS_DEFAULT_TEMPLATE)
bill_template = loader.get_template(template) bill_template = loader.get_template(template)
@ -416,7 +416,7 @@ class ProForma(Bill):
class BillLine(models.Model): class BillLine(models.Model):
""" Base model for bill item representation """ """ Base model for bill item representation """
bill = models.ForeignKey(Bill, verbose_name=_("bill"), related_name='lines', on_delete=models.CASCADE) bill = models.ForeignKey(Bill, verbose_name=_("bill"), related_name='lines')
description = models.CharField(_("description"), max_length=256) description = models.CharField(_("description"), max_length=256)
rate = models.DecimalField(_("rate"), blank=True, null=True, max_digits=12, decimal_places=2) rate = models.DecimalField(_("rate"), blank=True, null=True, max_digits=12, decimal_places=2)
quantity = models.DecimalField(_("quantity"), blank=True, null=True, max_digits=12, quantity = models.DecimalField(_("quantity"), blank=True, null=True, max_digits=12,
@ -434,7 +434,7 @@ class BillLine(models.Model):
created_on = models.DateField(_("created"), auto_now_add=True) created_on = models.DateField(_("created"), auto_now_add=True)
# Amendment # Amendment
amended_line = models.ForeignKey('self', verbose_name=_("amended line"), amended_line = models.ForeignKey('self', verbose_name=_("amended line"),
related_name='amendment_lines', null=True, blank=True, on_delete=models.CASCADE) related_name='amendment_lines', null=True, blank=True)
class Meta: class Meta:
get_latest_by = 'id' get_latest_by = 'id'
@ -495,7 +495,7 @@ class BillSubline(models.Model):
) )
# TODO: order info for undoing # TODO: order info for undoing
line = models.ForeignKey(BillLine, verbose_name=_("bill line"), related_name='sublines', on_delete=models.CASCADE) line = models.ForeignKey(BillLine, verbose_name=_("bill line"), related_name='sublines')
description = models.CharField(_("description"), max_length=256) description = models.CharField(_("description"), max_length=256)
total = models.DecimalField(max_digits=12, decimal_places=2) total = models.DecimalField(max_digits=12, decimal_places=2)
type = models.CharField(_("type"), max_length=16, choices=TYPES, default=OTHER) type = models.CharField(_("type"), max_length=16, choices=TYPES, default=OTHER)

View File

@ -7,7 +7,7 @@ from orchestra.admin.actions import SendEmail
from orchestra.admin.utils import insertattr, change_url from orchestra.admin.utils import insertattr, change_url
from orchestra.contrib.accounts.actions import list_accounts from orchestra.contrib.accounts.actions import list_accounts
from orchestra.contrib.accounts.admin import AccountAdmin, AccountAdminMixin from orchestra.contrib.accounts.admin import AccountAdmin, AccountAdminMixin
from orchestra.forms.widgets import PaddingCheckboxSelectMultiple from orchestra.forms.widgets import paddingCheckboxSelectMultiple
from .filters import EmailUsageListFilter from .filters import EmailUsageListFilter
from .models import Contact from .models import Contact
@ -72,7 +72,7 @@ class ContactAdmin(AccountAdminMixin, ExtendedModelAdmin):
if db_field.name == 'address': if db_field.name == 'address':
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 2}) kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 2})
if db_field.name == 'email_usage': if db_field.name == 'email_usage':
kwargs['widget'] = PaddingCheckboxSelectMultiple(130) kwargs['widget'] = paddingCheckboxSelectMultiple(130)
return super(ContactAdmin, self).formfield_for_dbfield(db_field, **kwargs) return super(ContactAdmin, self).formfield_for_dbfield(db_field, **kwargs)
@ -101,7 +101,7 @@ class ContactInline(admin.StackedInline):
if db_field.name == 'address': if db_field.name == 'address':
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 2}) kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 2})
if db_field.name == 'email_usage': if db_field.name == 'email_usage':
kwargs['widget'] = PaddingCheckboxSelectMultiple(45) kwargs['widget'] = paddingCheckboxSelectMultiple(45)
return super(ContactInline, self).formfield_for_dbfield(db_field, **kwargs) return super(ContactInline, self).formfield_for_dbfield(db_field, **kwargs)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -33,7 +33,7 @@ class Contact(models.Model):
objects = ContactQuerySet.as_manager() objects = ContactQuerySet.as_manager()
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
related_name='contacts', null=True, on_delete=models.SET_NULL) related_name='contacts', null=True)
short_name = models.CharField(_("short name"), max_length=128) short_name = models.CharField(_("short name"), max_length=128)
full_name = models.CharField(_("full name"), max_length=256, blank=True) full_name = models.CharField(_("full name"), max_length=256, blank=True)
email = models.EmailField() email = models.EmailField()

View File

@ -1,8 +1,6 @@
from django.conf.urls import url from django.conf.urls import url
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ExtendedModelAdmin, ChangePasswordAdminMixin from orchestra.admin import ExtendedModelAdmin, ChangePasswordAdminMixin
@ -52,14 +50,14 @@ class DatabaseAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
list_prefetch_related = ('users',) list_prefetch_related = ('users',)
actions = (list_accounts, save_selected) actions = (list_accounts, save_selected)
@mark_safe
def display_users(self, db): def display_users(self, db):
links = [] links = []
for user in db.users.all(): for user in db.users.all():
link = format_html('<a href="{}">{}</a>', change_url(user), user.username) link = '<a href="%s">%s</a>' % (change_url(user), user.username)
links.append(link) links.append(link)
return '<br>'.join(links) return '<br>'.join(links)
display_users.short_description = _("Users") display_users.short_description = _("Users")
display_users.allow_tags = True
display_users.admin_order_field = 'users__username' display_users.admin_order_field = 'users__username'
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
@ -101,14 +99,14 @@ class DatabaseUserAdmin(SelectAccountAdminMixin, ChangePasswordAdminMixin, Exten
list_prefetch_related = ('databases',) list_prefetch_related = ('databases',)
actions = (list_accounts, save_selected) actions = (list_accounts, save_selected)
@mark_safe
def display_databases(self, user): def display_databases(self, user):
links = [] links = []
for db in user.databases.all(): for db in user.databases.all():
link = format_html('<a href="{}">{}</a>', change_url(db), db.name) link = '<a href="%s">%s</a>' % (change_url(db), db.name)
links.append(link) links.append(link)
return '<br>'.join(links) return '<br>'.join(links)
display_databases.short_description = _("Databases") display_databases.short_description = _("Databases")
display_databases.allow_tags = True
display_databases.admin_order_field = 'databases__name' display_databases.admin_order_field = 'databases__name'
def get_urls(self): def get_urls(self):

View File

@ -91,7 +91,7 @@ class DatabaseCreationForm(DatabaseUserCreationForm):
class ReadOnlySQLPasswordHashField(ReadOnlyPasswordHashField): class ReadOnlySQLPasswordHashField(ReadOnlyPasswordHashField):
class ReadOnlyPasswordHashWidget(forms.Widget): class ReadOnlyPasswordHashWidget(forms.Widget):
def render(self, name, value, attrs, renderer=None): def render(self, name, value, attrs):
original = ReadOnlyPasswordHashField.widget().render(name, value, attrs) original = ReadOnlyPasswordHashField.widget().render(name, value, attrs)
if 'Invalid' not in original: if 'Invalid' not in original:
return original return original

View File

@ -3,7 +3,6 @@ from __future__ import unicode_literals
from django.db import models, migrations from django.db import models, migrations
from django.conf import settings from django.conf import settings
import django.db.models.deletion
import orchestra.core.validators import orchestra.core.validators
@ -20,7 +19,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(serialize=False, primary_key=True, verbose_name='ID', auto_created=True)), ('id', models.AutoField(serialize=False, primary_key=True, verbose_name='ID', auto_created=True)),
('name', models.CharField(verbose_name='name', max_length=64, validators=[orchestra.core.validators.validate_name])), ('name', models.CharField(verbose_name='name', max_length=64, validators=[orchestra.core.validators.validate_name])),
('type', models.CharField(default='mysql', choices=[('mysql', 'MySQL'), ('postgres', 'PostgreSQL')], verbose_name='type', max_length=32)), ('type', models.CharField(default='mysql', choices=[('mysql', 'MySQL'), ('postgres', 'PostgreSQL')], verbose_name='type', max_length=32)),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='databases', verbose_name='Account', to=settings.AUTH_USER_MODEL)), ('account', models.ForeignKey(related_name='databases', verbose_name='Account', to=settings.AUTH_USER_MODEL)),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
@ -30,7 +29,7 @@ class Migration(migrations.Migration):
('username', models.CharField(verbose_name='username', max_length=16, validators=[orchestra.core.validators.validate_name])), ('username', models.CharField(verbose_name='username', max_length=16, validators=[orchestra.core.validators.validate_name])),
('password', models.CharField(verbose_name='password', max_length=256)), ('password', models.CharField(verbose_name='password', max_length=256)),
('type', models.CharField(default='mysql', choices=[('mysql', 'MySQL'), ('postgres', 'PostgreSQL')], verbose_name='type', max_length=32)), ('type', models.CharField(default='mysql', choices=[('mysql', 'MySQL'), ('postgres', 'PostgreSQL')], verbose_name='type', max_length=32)),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='databaseusers', verbose_name='Account', to=settings.AUTH_USER_MODEL)), ('account', models.ForeignKey(related_name='databaseusers', verbose_name='Account', to=settings.AUTH_USER_MODEL)),
], ],
options={ options={
'verbose_name_plural': 'DB users', 'verbose_name_plural': 'DB users',

View File

@ -1,82 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2021-04-22 11:25
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import orchestra.core.validators
class Migration(migrations.Migration):
replaces = [('databases', '0001_initial'), ('databases', '0002_auto_20170528_2005'), ('databases', '0003_database_comments'), ('databases', '0004_auto_20210330_1049')]
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Database',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=64, validators=[orchestra.core.validators.validate_name], verbose_name='name')),
('type', models.CharField(choices=[('mysql', 'MySQL'), ('postgres', 'PostgreSQL')], default='mysql', max_length=32, verbose_name='type')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='databases', to=settings.AUTH_USER_MODEL, verbose_name='Account')),
],
),
migrations.CreateModel(
name='DatabaseUser',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('username', models.CharField(max_length=16, validators=[orchestra.core.validators.validate_name], verbose_name='username')),
('password', models.CharField(max_length=256, verbose_name='password')),
('type', models.CharField(choices=[('mysql', 'MySQL'), ('postgres', 'PostgreSQL')], default='mysql', max_length=32, verbose_name='type')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='databaseusers', to=settings.AUTH_USER_MODEL, verbose_name='Account')),
],
options={
'verbose_name_plural': 'DB users',
},
),
migrations.AddField(
model_name='database',
name='users',
field=models.ManyToManyField(blank=True, related_name='databases', to='databases.DatabaseUser', verbose_name='users'),
),
migrations.AlterUniqueTogether(
name='databaseuser',
unique_together=set([('username', 'type')]),
),
migrations.AlterUniqueTogether(
name='database',
unique_together=set([('name', 'type')]),
),
migrations.AlterField(
model_name='database',
name='type',
field=models.CharField(choices=[('mysql', 'MySQL')], default='mysql', max_length=32, verbose_name='type'),
),
migrations.AlterField(
model_name='databaseuser',
name='type',
field=models.CharField(choices=[('mysql', 'MySQL')], default='mysql', max_length=32, verbose_name='type'),
),
migrations.AddField(
model_name='database',
name='comments',
field=models.TextField(blank=True, default=''),
),
migrations.AlterField(
model_name='database',
name='type',
field=models.CharField(choices=[('mysql', 'MySQL'), ('postgres', 'PostgreSQL')], default='mysql', max_length=32, verbose_name='type'),
),
migrations.AlterField(
model_name='databaseuser',
name='type',
field=models.CharField(choices=[('mysql', 'MySQL'), ('postgres', 'PostgreSQL')], default='mysql', max_length=32, verbose_name='type'),
),
]

View File

@ -1,30 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2021-03-30 10:49
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('databases', '0003_database_comments'),
]
operations = [
migrations.AlterField(
model_name='database',
name='comments',
field=models.TextField(blank=True, default=''),
),
migrations.AlterField(
model_name='database',
name='type',
field=models.CharField(choices=[('mysql', 'MySQL'), ('postgres', 'PostgreSQL')], default='mysql', max_length=32, verbose_name='type'),
),
migrations.AlterField(
model_name='databaseuser',
name='type',
field=models.CharField(choices=[('mysql', 'MySQL'), ('postgres', 'PostgreSQL')], default='mysql', max_length=32, verbose_name='type'),
),
]

View File

@ -20,8 +20,8 @@ class Database(models.Model):
type = models.CharField(_("type"), max_length=32, type = models.CharField(_("type"), max_length=32,
choices=settings.DATABASES_TYPE_CHOICES, choices=settings.DATABASES_TYPE_CHOICES,
default=settings.DATABASES_DEFAULT_TYPE) default=settings.DATABASES_DEFAULT_TYPE)
account = models.ForeignKey('accounts.Account', on_delete=models.CASCADE, account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
verbose_name=_("Account"), related_name='databases') related_name='databases')
comments = models.TextField(default="", blank=True) comments = models.TextField(default="", blank=True)
class Meta: class Meta:
@ -60,8 +60,8 @@ class DatabaseUser(models.Model):
type = models.CharField(_("type"), max_length=32, type = models.CharField(_("type"), max_length=32,
choices=settings.DATABASES_TYPE_CHOICES, choices=settings.DATABASES_TYPE_CHOICES,
default=settings.DATABASES_DEFAULT_TYPE) default=settings.DATABASES_DEFAULT_TYPE)
account = models.ForeignKey('accounts.Account', on_delete=models.CASCADE, account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
verbose_name=_("Account"), related_name='databaseusers') related_name='databaseusers')
class Meta: class Meta:
verbose_name_plural = _("DB users") verbose_name_plural = _("DB users")

View File

@ -1,24 +1,22 @@
import MySQLdb
import os import os
import socket import socket
import time import time
import unittest
import MySQLdb
from django.conf import settings as djsettings from django.conf import settings as djsettings
from django.core.management.base import CommandError from django.core.management.base import CommandError
from django.urls import reverse from django.core.urlresolvers import reverse
from orchestra.admin.utils import change_url
from orchestra.contrib.orchestration.models import Route, Server
from orchestra.utils.sys import sshrun
from orchestra.utils.tests import (BaseLiveServerTestCase, random_ascii,
save_response_on_error, snapshot_on_error)
from selenium.webdriver.support.select import Select from selenium.webdriver.support.select import Select
from orchestra.admin.utils import change_url
from orchestra.contrib.orchestration.models import Server, Route
from orchestra.utils.sys import sshrun
from orchestra.utils.tests import (BaseLiveServerTestCase, random_ascii, save_response_on_error,
snapshot_on_error)
from ... import backends, settings from ... import backends, settings
from ...models import Database, DatabaseUser from ...models import Database, DatabaseUser
TEST_REST_API = int(os.getenv('TEST_REST_API', '0'))
class DatabaseTestMixin(object): class DatabaseTestMixin(object):
MASTER_SERVER = os.environ.get('ORCHESTRA_SECOND_SERVER', 'localhost') MASTER_SERVER = os.environ.get('ORCHESTRA_SECOND_SERVER', 'localhost')
@ -183,7 +181,6 @@ class MySQLControllerMixin(object):
"""mysql mysql -e 'SELECT * FROM user WHERE user="%(username)s";'""" % context, display=False).stdout) """mysql mysql -e 'SELECT * FROM user WHERE user="%(username)s";'""" % context, display=False).stdout)
@unittest.skipUnless(TEST_REST_API, "REST API tests")
class RESTDatabaseMixin(DatabaseTestMixin): class RESTDatabaseMixin(DatabaseTestMixin):
def setUp(self): def setUp(self):
super(RESTDatabaseMixin, self).setUp() super(RESTDatabaseMixin, self).setUp()

View File

@ -1,10 +1,8 @@
from django.contrib import admin from django.contrib import admin
from django.urls import reverse from django.core.urlresolvers import reverse
from django.db import models from django.db import models
from django.db.models.functions import Concat, Coalesce from django.db.models.functions import Concat, Coalesce
from django.templatetags.static import static from django.templatetags.static import static
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext, ugettext_lazy as _ from django.utils.translation import ugettext, ugettext_lazy as _
from orchestra.admin import ExtendedModelAdmin from orchestra.admin import ExtendedModelAdmin
@ -74,8 +72,9 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
def structured_name(self, domain): def structured_name(self, domain):
if domain.is_top: if domain.is_top:
return domain.name return domain.name
return mark_safe('&nbsp;'*4 + domain.name) return '&nbsp;'*4 + domain.name
structured_name.short_description = _("name") structured_name.short_description = _("name")
structured_name.allow_tags = True
structured_name.admin_order_field = 'structured_name' structured_name.admin_order_field = 'structured_name'
def display_is_top(self, domain): def display_is_top(self, domain):
@ -84,7 +83,6 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
display_is_top.boolean = True display_is_top.boolean = True
display_is_top.admin_order_field = 'top' display_is_top.admin_order_field = 'top'
@mark_safe
def display_websites(self, domain): def display_websites(self, domain):
if apps.isinstalled('orchestra.contrib.websites'): if apps.isinstalled('orchestra.contrib.websites'):
websites = domain.websites.all() websites = domain.websites.all()
@ -94,22 +92,22 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
site_link = get_on_site_link(website.get_absolute_url()) site_link = get_on_site_link(website.get_absolute_url())
admin_url = change_url(website) admin_url = change_url(website)
title = _("Edit website") title = _("Edit website")
link = format_html('<a href="{}" title="{}">{} {}</a>', link = '<a href="%s" title="%s">%s %s</a>' % (
admin_url, title, website.name, site_link) admin_url, title, website.name, site_link)
links.append(link) links.append(link)
return '<br>'.join(links) return '<br>'.join(links)
add_url = reverse('admin:websites_website_add') add_url = reverse('admin:websites_website_add')
add_url += '?account=%i&domains=%i' % (domain.account_id, domain.pk) add_url += '?account=%i&domains=%i' % (domain.account_id, domain.pk)
add_link = format_html( image = '<img src="%s"></img>' % static('orchestra/images/add.png')
'<a href="{}" title="{}"><img src="{}" /></a>', add_url, add_link = '<a href="%s" title="%s">%s</a>' % (
_("Add website"), static('orchestra/images/add.png'), add_url, _("Add website"), image
) )
return _("No website %s") % (add_link) return _("No website %s") % (add_link)
return '---' return '---'
display_websites.admin_order_field = 'websites__name' display_websites.admin_order_field = 'websites__name'
display_websites.short_description = _("Websites") display_websites.short_description = _("Websites")
display_websites.allow_tags = True
@mark_safe
def display_addresses(self, domain): def display_addresses(self, domain):
if apps.isinstalled('orchestra.contrib.mailboxes'): if apps.isinstalled('orchestra.contrib.mailboxes'):
add_url = reverse('admin:mailboxes_address_add') add_url = reverse('admin:mailboxes_address_add')
@ -128,9 +126,10 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
return '---' return '---'
display_addresses.short_description = _("Addresses") display_addresses.short_description = _("Addresses")
display_addresses.admin_order_field = 'addresses__count' display_addresses.admin_order_field = 'addresses__count'
display_addresses.allow_tags = True
@mark_safe
def implicit_records(self, domain): def implicit_records(self, domain):
defaults = []
types = set(domain.records.values_list('type', flat=True)) types = set(domain.records.values_list('type', flat=True))
ttl = settings.DOMAINS_DEFAULT_TTL ttl = settings.DOMAINS_DEFAULT_TTL
lines = [] lines = []
@ -142,13 +141,14 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
value=record.value value=record.value
) )
if not domain.record_is_implicit(record, types): if not domain.record_is_implicit(record, types):
line = format_html('<strike>{}</strike>', line) line = '<strike>%s</strike>' % line
if record.type is Record.SOA: if record.type is Record.SOA:
lines.insert(0, line) lines.insert(0, line)
else: else:
lines.append(line) lines.append(line)
return '<br>'.join(lines) return '<br>'.join(lines)
implicit_records.short_description = _("Implicit records") implicit_records.short_description = _("Implicit records")
implicit_records.allow_tags = True
def get_fieldsets(self, request, obj=None): def get_fieldsets(self, request, obj=None):
""" Add SOA fields when domain is top """ """ Add SOA fields when domain is top """

View File

@ -1,5 +1,5 @@
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.decorators import action from rest_framework.decorators import detail_route
from rest_framework.response import Response from rest_framework.response import Response
from orchestra.api import router from orchestra.api import router
@ -19,7 +19,7 @@ class DomainViewSet(AccountApiMixin, viewsets.ModelViewSet):
qs = super(DomainViewSet, self).get_queryset() qs = super(DomainViewSet, self).get_queryset()
return qs.prefetch_related('records') return qs.prefetch_related('records')
@action(detail=True) @detail_route()
def view_zone(self, request, pk=None): def view_zone(self, request, pk=None):
domain = self.get_object() domain = self.get_object()
return Response({ return Response({

View File

@ -2,7 +2,6 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.db import models, migrations from django.db import models, migrations
import django.db.models.deletion
import orchestra.contrib.domains.utils import orchestra.contrib.domains.utils
import orchestra.contrib.domains.validators import orchestra.contrib.domains.validators
from django.conf import settings from django.conf import settings
@ -21,8 +20,8 @@ class Migration(migrations.Migration):
('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')), ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')),
('name', models.CharField(unique=True, max_length=256, validators=[orchestra.contrib.domains.validators.validate_domain_name, orchestra.contrib.domains.validators.validate_allowed_domain], verbose_name='name', help_text='Domain or subdomain name.')), ('name', models.CharField(unique=True, max_length=256, validators=[orchestra.contrib.domains.validators.validate_domain_name, orchestra.contrib.domains.validators.validate_allowed_domain], verbose_name='name', help_text='Domain or subdomain name.')),
('serial', models.IntegerField(default=orchestra.contrib.domains.utils.generate_zone_serial, verbose_name='serial', help_text='Serial number')), ('serial', models.IntegerField(default=orchestra.contrib.domains.utils.generate_zone_serial, verbose_name='serial', help_text='Serial number')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='domains', help_text='Automatically selected for subdomains.', to=settings.AUTH_USER_MODEL, verbose_name='Account', blank=True)), ('account', models.ForeignKey(related_name='domains', help_text='Automatically selected for subdomains.', to=settings.AUTH_USER_MODEL, verbose_name='Account', blank=True)),
('top', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, null=True, to='domains.Domain', editable=False, related_name='subdomain_set')), ('top', models.ForeignKey(null=True, to='domains.Domain', editable=False, related_name='subdomain_set')),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
@ -32,7 +31,7 @@ class Migration(migrations.Migration):
('ttl', models.CharField(help_text='Record TTL, defaults to 1h', max_length=8, validators=[orchestra.contrib.domains.validators.validate_zone_interval], verbose_name='TTL', blank=True)), ('ttl', models.CharField(help_text='Record TTL, defaults to 1h', max_length=8, validators=[orchestra.contrib.domains.validators.validate_zone_interval], verbose_name='TTL', blank=True)),
('type', models.CharField(max_length=32, verbose_name='type', choices=[('MX', 'MX'), ('NS', 'NS'), ('CNAME', 'CNAME'), ('A', 'A (IPv4 address)'), ('AAAA', 'AAAA (IPv6 address)'), ('SRV', 'SRV'), ('TXT', 'TXT'), ('SOA', 'SOA')])), ('type', models.CharField(max_length=32, verbose_name='type', choices=[('MX', 'MX'), ('NS', 'NS'), ('CNAME', 'CNAME'), ('A', 'A (IPv4 address)'), ('AAAA', 'AAAA (IPv6 address)'), ('SRV', 'SRV'), ('TXT', 'TXT'), ('SOA', 'SOA')])),
('value', models.CharField(max_length=256, verbose_name='value')), ('value', models.CharField(max_length=256, verbose_name='value')),
('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='records', to='domains.Domain', verbose_name='domain')), ('domain', models.ForeignKey(related_name='records', to='domains.Domain', verbose_name='domain')),
], ],
), ),
] ]

View File

@ -1,83 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2021-04-22 11:27
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import orchestra.contrib.domains.utils
import orchestra.contrib.domains.validators
class Migration(migrations.Migration):
replaces = [('domains', '0001_initial'), ('domains', '0002_auto_20150715_1017'), ('domains', '0003_auto_20150720_1121'), ('domains', '0004_auto_20150720_1121'), ('domains', '0005_auto_20160219_1034'), ('domains', '0006_auto_20170528_2011'), ('domains', '0007_auto_20190805_1134'), ('domains', '0008_domain_dns2136_address_match_list'), ('domains', '0009_auto_20200204_1217'), ('domains', '0010_auto_20210330_1049')]
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Domain',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Domain or subdomain name.', max_length=256, unique=True, validators=[orchestra.contrib.domains.validators.validate_domain_name, orchestra.contrib.domains.validators.validate_allowed_domain], verbose_name='name')),
('serial', models.IntegerField(default=orchestra.contrib.domains.utils.generate_zone_serial, help_text='Serial number', verbose_name='serial')),
('account', models.ForeignKey(blank=True, help_text='Automatically selected for subdomains.', on_delete=django.db.models.deletion.CASCADE, related_name='domains', to=settings.AUTH_USER_MODEL, verbose_name='Account')),
('top', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subdomain_set', to='domains.Domain')),
],
),
migrations.CreateModel(
name='Record',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ttl', models.CharField(blank=True, help_text='Record TTL, defaults to 1h', max_length=8, validators=[orchestra.contrib.domains.validators.validate_zone_interval], verbose_name='TTL')),
('type', models.CharField(choices=[('MX', 'MX'), ('NS', 'NS'), ('CNAME', 'CNAME'), ('A', 'A (IPv4 address)'), ('AAAA', 'AAAA (IPv6 address)'), ('SRV', 'SRV'), ('TXT', 'TXT'), ('SPF', 'SPF')], max_length=32, verbose_name='type')),
('value', models.CharField(help_text='MX, NS and CNAME records sould end with a dot.', max_length=1024, verbose_name='value')),
('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='records', to='domains.Domain', verbose_name='domain')),
],
),
migrations.AlterField(
model_name='domain',
name='serial',
field=models.IntegerField(default=orchestra.contrib.domains.utils.generate_zone_serial, editable=False, help_text='A revision number that changes whenever this domain is updated.', verbose_name='serial'),
),
migrations.AddField(
model_name='domain',
name='expire',
field=models.CharField(blank=True, help_text='The time that a secondary server will keep trying to complete a zone transfer. If this time expires prior to a successful zone transfer, the secondary server will expire its zone file. This means the secondary will stop answering queries. The default value is <tt>4w</tt>.', max_length=16, validators=[orchestra.contrib.domains.validators.validate_zone_interval], verbose_name='expire'),
),
migrations.AddField(
model_name='domain',
name='min_ttl',
field=models.CharField(blank=True, help_text='The minimum time-to-live value applies to all resource records in the zone file. This value is supplied in query responses to inform other servers how long they should keep the data in cache. The default value is <tt>1h</tt>.', max_length=16, validators=[orchestra.contrib.domains.validators.validate_zone_interval], verbose_name='min TTL'),
),
migrations.AddField(
model_name='domain',
name='refresh',
field=models.CharField(blank=True, help_text="The time a secondary DNS server waits before querying the primary DNS server's SOA record to check for changes. When the refresh time expires, the secondary DNS server requests a copy of the current SOA record from the primary. The primary DNS server complies with this request. The secondary DNS server compares the serial number of the primary DNS server's current SOA record and the serial number in it's own SOA record. If they are different, the secondary DNS server will request a zone transfer from the primary DNS server. The default value is <tt>1d</tt>.", max_length=16, validators=[orchestra.contrib.domains.validators.validate_zone_interval], verbose_name='refresh'),
),
migrations.AddField(
model_name='domain',
name='retry',
field=models.CharField(blank=True, help_text='The time a secondary server waits before retrying a failed zone transfer. Normally, the retry time is less than the refresh time. The default value is <tt>2h</tt>.', max_length=16, validators=[orchestra.contrib.domains.validators.validate_zone_interval], verbose_name='retry'),
),
migrations.AlterField(
model_name='domain',
name='name',
field=models.CharField(db_index=True, help_text='Domain or subdomain name.', max_length=256, unique=True, validators=[orchestra.contrib.domains.validators.validate_domain_name, orchestra.contrib.domains.validators.validate_allowed_domain], verbose_name='name'),
),
migrations.AlterField(
model_name='domain',
name='top',
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subdomain_set', to='domains.Domain', verbose_name='top domain'),
),
migrations.AddField(
model_name='domain',
name='dns2136_address_match_list',
field=models.CharField(blank=True, default='key pangea.key;', help_text="A bind-9 'address_match_list' that will be granted permission to perform dns2136 updates. Chiefly used to enable Let's Encrypt self-service validation.", max_length=80),
),
]

View File

@ -2,7 +2,6 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
import orchestra.contrib.domains.validators import orchestra.contrib.domains.validators
@ -21,7 +20,7 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='domain', model_name='domain',
name='top', name='top',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, editable=False, verbose_name='top domain', related_name='subdomain_set', to='domains.Domain', null=True), field=models.ForeignKey(editable=False, verbose_name='top domain', related_name='subdomain_set', to='domains.Domain', null=True),
), ),
migrations.AlterField( migrations.AlterField(
model_name='record', model_name='record',

View File

@ -1,26 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2021-03-30 10:49
from __future__ import unicode_literals
from django.db import migrations, models
import orchestra.contrib.domains.validators
class Migration(migrations.Migration):
dependencies = [
('domains', '0009_auto_20200204_1217'),
]
operations = [
migrations.AlterField(
model_name='domain',
name='min_ttl',
field=models.CharField(blank=True, help_text='The minimum time-to-live value applies to all resource records in the zone file. This value is supplied in query responses to inform other servers how long they should keep the data in cache. The default value is <tt>1h</tt>.', max_length=16, validators=[orchestra.contrib.domains.validators.validate_zone_interval], verbose_name='min TTL'),
),
migrations.AlterField(
model_name='record',
name='ttl',
field=models.CharField(blank=True, help_text='Record TTL, defaults to 1h', max_length=8, validators=[orchestra.contrib.domains.validators.validate_zone_interval], verbose_name='TTL'),
),
]

View File

@ -31,9 +31,9 @@ class Domain(models.Model):
validators.validate_allowed_domain validators.validate_allowed_domain
]) ])
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), blank=True, account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), blank=True,
related_name='domains', on_delete=models.CASCADE, help_text=_("Automatically selected for subdomains.")) related_name='domains', help_text=_("Automatically selected for subdomains."))
top = models.ForeignKey('domains.Domain', null=True, related_name='subdomain_set', top = models.ForeignKey('domains.Domain', null=True, related_name='subdomain_set',
editable=False, verbose_name=_("top domain"), on_delete=models.CASCADE) editable=False, verbose_name=_("top domain"))
serial = models.IntegerField(_("serial"), default=utils.generate_zone_serial, editable=False, serial = models.IntegerField(_("serial"), default=utils.generate_zone_serial, editable=False,
help_text=_("A revision number that changes whenever this domain is updated.")) help_text=_("A revision number that changes whenever this domain is updated."))
refresh = models.CharField(_("refresh"), max_length=16, blank=True, refresh = models.CharField(_("refresh"), max_length=16, blank=True,
@ -318,7 +318,7 @@ class Record(models.Model):
SOA: (validators.validate_soa_record,), SOA: (validators.validate_soa_record,),
} }
domain = models.ForeignKey(Domain, verbose_name=_("domain"), related_name='records', on_delete=models.CASCADE) domain = models.ForeignKey(Domain, verbose_name=_("domain"), related_name='records')
ttl = models.CharField(_("TTL"), max_length=8, blank=True, ttl = models.CharField(_("TTL"), max_length=8, blank=True,
help_text=_("Record TTL, defaults to %s") % settings.DOMAINS_DEFAULT_TTL, help_text=_("Record TTL, defaults to %s") % settings.DOMAINS_DEFAULT_TTL,
validators=[validators.validate_zone_interval]) validators=[validators.validate_zone_interval])

View File

@ -4,7 +4,7 @@ import socket
from functools import partial from functools import partial
from django.conf import settings as djsettings from django.conf import settings as djsettings
from django.urls import reverse from django.core.urlresolvers import reverse
from selenium.webdriver.support.select import Select from selenium.webdriver.support.select import Select
from orchestra.contrib.orchestration.models import Server, Route from orchestra.contrib.orchestration.models import Server, Route

View File

@ -1,14 +1,12 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.admin.templatetags.admin_static import static
from django.contrib.admin.templatetags.admin_urls import add_preserved_filters
from django.contrib.admin.utils import unquote
from django.http import HttpResponseRedirect
from django.urls import NoReverseMatch, reverse
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.core.urlresolvers import reverse, NoReverseMatch
from django.contrib.admin.templatetags.admin_urls import add_preserved_filters
from django.http import HttpResponseRedirect
from django.contrib.admin.utils import unquote
from django.contrib.admin.templatetags.admin_static import static
from orchestra.admin.utils import admin_date, admin_link from orchestra.admin.utils import admin_link, admin_date
class LogEntryAdmin(admin.ModelAdmin): class LogEntryAdmin(admin.ModelAdmin):
@ -36,12 +34,11 @@ class LogEntryAdmin(admin.ModelAdmin):
user_link = admin_link('user') user_link = admin_link('user')
display_action_time = admin_date('action_time', short_description=_("Time")) display_action_time = admin_date('action_time', short_description=_("Time"))
@mark_safe
def display_message(self, log): def display_message(self, log):
edit = format_html('<a href="{url}"><img src="{img}"></img></a>', **{ edit = '<a href="%(url)s"><img src="%(img)s"></img></a>' % {
'url': reverse('admin:admin_logentry_change', args=(log.pk,)), 'url': reverse('admin:admin_logentry_change', args=(log.pk,)),
'img': static('admin/img/icon-changelink.svg'), 'img': static('admin/img/icon-changelink.svg'),
}) }
if log.is_addition(): if log.is_addition():
return _('Added "%(link)s". %(edit)s') % { return _('Added "%(link)s". %(edit)s') % {
'link': self.content_object_link(log), 'link': self.content_object_link(log),
@ -60,6 +57,7 @@ class LogEntryAdmin(admin.ModelAdmin):
} }
display_message.short_description = _("Message") display_message.short_description = _("Message")
display_message.admin_order_field = 'action_flag' display_message.admin_order_field = 'action_flag'
display_message.allow_tags = True
def display_action(self, log): def display_action(self, log):
if log.is_addition(): if log.is_addition():
@ -77,9 +75,10 @@ class LogEntryAdmin(admin.ModelAdmin):
url = reverse(view, args=(log.object_id,)) url = reverse(view, args=(log.object_id,))
except NoReverseMatch: except NoReverseMatch:
return log.object_repr return log.object_repr
return format_html('<a href="{}">{}</a>', url, log.object_repr) return '<a href="%s">%s</a>' % (url, log.object_repr)
content_object_link.short_description = _("Content object") content_object_link.short_description = _("Content object")
content_object_link.admin_order_field = 'object_repr' content_object_link.admin_order_field = 'object_repr'
content_object_link.allow_tags = True
def render_change_form(self, request, context, add=False, change=False, form_url='', obj=None): def render_change_form(self, request, context, add=False, change=False, form_url='', obj=None):
""" Add rel_opts and object to context """ """ Add rel_opts and object to context """

View File

@ -1,12 +1,11 @@
from django import forms from django import forms
from django.conf.urls import url from django.conf.urls import url
from django.contrib import admin from django.contrib import admin
from django.urls import reverse from django.core.urlresolvers import reverse
from django.db import models from django.db import models
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.html import format_html, strip_tags from django.utils.html import strip_tags
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from markdown import markdown from markdown import markdown
@ -51,7 +50,6 @@ class MessageReadOnlyInline(admin.TabularInline):
'all': ('orchestra/css/hide-inline-id.css',) 'all': ('orchestra/css/hide-inline-id.css',)
} }
@mark_safe
def content_html(self, msg): def content_html(self, msg):
context = { context = {
'number': msg.number, 'number': msg.number,
@ -60,13 +58,12 @@ class MessageReadOnlyInline(admin.TabularInline):
} }
summary = _("#%(number)i Updated by %(author)s about %(time)s") % context summary = _("#%(number)i Updated by %(author)s about %(time)s") % context
header = '<strong style="color:#666;">%s</strong><hr />' % summary header = '<strong style="color:#666;">%s</strong><hr />' % summary
content = markdown(msg.content) content = markdown(msg.content)
content = content.replace('>\n', '>') content = content.replace('>\n', '>')
content = '<div style="padding-left:20px;">%s</div>' % content content = '<div style="padding-left:20px;">%s</div>' % content
return header + content return header + content
content_html.short_description = _("Content") content_html.short_description = _("Content")
content_html.allow_tags = True
def has_add_permission(self, request): def has_add_permission(self, request):
return False return False
@ -114,10 +111,10 @@ class TicketInline(admin.TabularInline):
colored_state = admin_colored('state', colors=STATE_COLORS, bold=False) colored_state = admin_colored('state', colors=STATE_COLORS, bold=False)
colored_priority = admin_colored('priority', colors=PRIORITY_COLORS, bold=False) colored_priority = admin_colored('priority', colors=PRIORITY_COLORS, bold=False)
@mark_safe
def ticket_id(self, instance): def ticket_id(self, instance):
return '<b>%s</b>' % admin_link()(instance) return '<b>%s</b>' % admin_link()(instance)
ticket_id.short_description = '#' ticket_id.short_description = '#'
ticket_id.allow_tags = True
class TicketAdmin(ExtendedModelAdmin): class TicketAdmin(ExtendedModelAdmin):
@ -138,7 +135,7 @@ class TicketAdmin(ExtendedModelAdmin):
'owner__username' 'owner__username'
) )
actions = ( actions = (
mark_as_unread, mark_as_read, reject_tickets, mark_as_unread, mark_as_read, 'delete_selected', reject_tickets,
resolve_tickets, close_tickets, take_tickets resolve_tickets, close_tickets, take_tickets
) )
sudo_actions = ('delete_selected',) sudo_actions = ('delete_selected',)
@ -195,7 +192,6 @@ class TicketAdmin(ExtendedModelAdmin):
display_state = admin_colored('state', colors=STATE_COLORS, bold=False) display_state = admin_colored('state', colors=STATE_COLORS, bold=False)
display_priority = admin_colored('priority', colors=PRIORITY_COLORS, bold=False) display_priority = admin_colored('priority', colors=PRIORITY_COLORS, bold=False)
@mark_safe
def display_summary(self, ticket): def display_summary(self, ticket):
context = { context = {
'creator': admin_link('creator')(self, ticket) if ticket.creator else ticket.creator_name, 'creator': admin_link('creator')(self, ticket) if ticket.creator else ticket.creator_name,
@ -211,12 +207,14 @@ class TicketAdmin(ExtendedModelAdmin):
context['updated'] = '. Updated by %(updater)s about %(updated)s' % context context['updated'] = '. Updated by %(updater)s about %(updated)s' % context
return '<h4>Added by %(creator)s about %(created)s%(updated)s</h4>' % context return '<h4>Added by %(creator)s about %(created)s%(updated)s</h4>' % context
display_summary.short_description = 'Summary' display_summary.short_description = 'Summary'
display_summary.allow_tags = True
def unbold_id(self, ticket): def unbold_id(self, ticket):
""" Unbold id if ticket is read """ """ Unbold id if ticket is read """
if ticket.is_read_by(self.user): if ticket.is_read_by(self.user):
return format_html('<span style="font-weight:normal;font-size:11px;">{}</span>', ticket.pk) return '<span style="font-weight:normal;font-size:11px;">%s</span>' % ticket.pk
return ticket.pk return ticket.pk
unbold_id.allow_tags = True
unbold_id.short_description = "#" unbold_id.short_description = "#"
unbold_id.admin_order_field = 'id' unbold_id.admin_order_field = 'id'
@ -224,7 +222,8 @@ class TicketAdmin(ExtendedModelAdmin):
""" Bold subject when tickets are unread for request.user """ """ Bold subject when tickets are unread for request.user """
if ticket.is_read_by(self.user): if ticket.is_read_by(self.user):
return ticket.subject return ticket.subject
return format_html("<strong class='unread'>{}</strong>", ticket.subject) return "<strong class='unread'>%s</strong>" % ticket.subject
bold_subject.allow_tags = True
bold_subject.short_description = _("Subject") bold_subject.short_description = _("Subject")
bold_subject.admin_order_field = 'subject' bold_subject.admin_order_field = 'subject'
@ -298,9 +297,10 @@ class QueueAdmin(admin.ModelAdmin):
num = queue.tickets__count num = queue.tickets__count
url = reverse('admin:issues_ticket_changelist') url = reverse('admin:issues_ticket_changelist')
url += '?queue=%i' % queue.pk url += '?queue=%i' % queue.pk
return format_html('<a href="{}">{}</a>', url, num) return '<a href="%s">%d</a>' % (url, num)
num_tickets.short_description = _("Tickets") num_tickets.short_description = _("Tickets")
num_tickets.admin_order_field = 'tickets__count' num_tickets.admin_order_field = 'tickets__count'
num_tickets.allow_tags = True
def get_list_display(self, request): def get_list_display(self, request):
""" show notifications """ """ show notifications """

View File

@ -1,5 +1,5 @@
from rest_framework import viewsets, mixins from rest_framework import viewsets, mixins
from rest_framework.decorators import action from rest_framework.decorators import detail_route
from rest_framework.response import Response from rest_framework.response import Response
from orchestra.api import router, LogApiMixin from orchestra.api import router, LogApiMixin
@ -13,13 +13,13 @@ class TicketViewSet(LogApiMixin, viewsets.ModelViewSet):
queryset = Ticket.objects.all() queryset = Ticket.objects.all()
serializer_class = TicketSerializer serializer_class = TicketSerializer
@action(detail=True) @detail_route()
def mark_as_read(self, request, pk=None): def mark_as_read(self, request, pk=None):
ticket = self.get_object() ticket = self.get_object()
ticket.mark_as_read_by(request.user) ticket.mark_as_read_by(request.user)
return Response({'status': 'Ticket marked as read'}) return Response({'status': 'Ticket marked as read'})
@action(detail=True) @detail_route()
def mark_as_unread(self, request, pk=None): def mark_as_unread(self, request, pk=None):
ticket = self.get_object() ticket = self.get_object()
ticket.mark_as_unread_by(request.user) ticket.mark_as_unread_by(request.user)

View File

@ -22,7 +22,7 @@ class MarkDownWidget(forms.Textarea):
) )
markdown_help_text = 'HTML not allowed, you can use %s' % markdown_help_text markdown_help_text = 'HTML not allowed, you can use %s' % markdown_help_text
def render(self, name, value, attrs, renderer=None): def render(self, name, value, attrs):
widget_id = attrs['id'] if attrs and 'id' in attrs else 'id_%s' % name widget_id = attrs['id'] if attrs and 'id' in attrs else 'id_%s' % name
textarea = super(MarkDownWidget, self).render(name, value, attrs) textarea = super(MarkDownWidget, self).render(name, value, attrs)
preview = ('<a class="load-preview" href="#" data-field="{0}">preview</a>'\ preview = ('<a class="load-preview" href="#" data-field="{0}">preview</a>'\

View File

@ -1,7 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import django.db.models.deletion
from django.db import models, migrations from django.db import models, migrations
import orchestra.models.fields import orchestra.models.fields
from django.conf import settings from django.conf import settings
@ -21,7 +20,7 @@ class Migration(migrations.Migration):
('author_name', models.CharField(blank=True, max_length=256, verbose_name='author name')), ('author_name', models.CharField(blank=True, max_length=256, verbose_name='author name')),
('content', models.TextField(verbose_name='content')), ('content', models.TextField(verbose_name='content')),
('created_on', models.DateTimeField(auto_now_add=True, verbose_name='created on')), ('created_on', models.DateTimeField(auto_now_add=True, verbose_name='created on')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_messages', to=settings.AUTH_USER_MODEL, verbose_name='author')), ('author', models.ForeignKey(related_name='ticket_messages', to=settings.AUTH_USER_MODEL, verbose_name='author')),
], ],
options={ options={
'get_latest_by': 'id', 'get_latest_by': 'id',
@ -49,9 +48,9 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='modified')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='modified')),
('cc', models.TextField(blank=True, help_text='emails to send a carbon copy to', verbose_name='CC')), ('cc', models.TextField(blank=True, help_text='emails to send a carbon copy to', verbose_name='CC')),
('creator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tickets_created', null=True, to=settings.AUTH_USER_MODEL, verbose_name='created by')), ('creator', models.ForeignKey(related_name='tickets_created', null=True, to=settings.AUTH_USER_MODEL, verbose_name='created by')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, blank=True, related_name='tickets_owned', null=True, to=settings.AUTH_USER_MODEL, verbose_name='assigned to')), ('owner', models.ForeignKey(blank=True, related_name='tickets_owned', null=True, to=settings.AUTH_USER_MODEL, verbose_name='assigned to')),
('queue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, blank=True, related_name='tickets', null=True, to='issues.Queue')), ('queue', models.ForeignKey(blank=True, related_name='tickets', null=True, to='issues.Queue')),
], ],
options={ options={
'ordering': ['-updated_at'], 'ordering': ['-updated_at'],
@ -61,14 +60,14 @@ class Migration(migrations.Migration):
name='TicketTracker', name='TicketTracker',
fields=[ fields=[
('id', models.AutoField(primary_key=True, serialize=False, auto_created=True, verbose_name='ID')), ('id', models.AutoField(primary_key=True, serialize=False, auto_created=True, verbose_name='ID')),
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trackers', to='issues.Ticket', verbose_name='ticket')), ('ticket', models.ForeignKey(related_name='trackers', to='issues.Ticket', verbose_name='ticket')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_trackers', to=settings.AUTH_USER_MODEL, verbose_name='user')), ('user', models.ForeignKey(related_name='ticket_trackers', to=settings.AUTH_USER_MODEL, verbose_name='user')),
], ],
), ),
migrations.AddField( migrations.AddField(
model_name='message', model_name='message',
name='ticket', name='ticket',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='issues.Ticket', verbose_name='ticket'), field=models.ForeignKey(related_name='messages', to='issues.Ticket', verbose_name='ticket'),
), ),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
name='tickettracker', name='tickettracker',

View File

@ -1,114 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2021-04-22 11:27
from __future__ import unicode_literals
import datetime
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
from django.utils.timezone import utc
import orchestra.models.fields
class Migration(migrations.Migration):
replaces = [('issues', '0001_initial'), ('issues', '0002_auto_20150709_1018'), ('issues', '0003_auto_20160320_1127'), ('issues', '0004_auto_20170528_2011')]
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Message',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('author_name', models.CharField(blank=True, max_length=256, verbose_name='author name')),
('content', models.TextField(verbose_name='content')),
('created_on', models.DateTimeField(auto_now_add=True, verbose_name='created on')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_messages', to=settings.AUTH_USER_MODEL, verbose_name='author')),
],
options={
'get_latest_by': 'id',
},
),
migrations.CreateModel(
name='Queue',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=128, unique=True, verbose_name='name')),
('verbose_name', models.CharField(blank=True, max_length=128, verbose_name='verbose_name')),
('default', models.BooleanField(default=False, verbose_name='default')),
('notify', orchestra.models.fields.MultiSelectField(blank=True, choices=[('SUPPORT', 'Support tickets'), ('ADMIN', 'Administrative'), ('BILLING', 'Billing'), ('TECH', 'Technical'), ('ADDS', 'Announcements'), ('EMERGENCY', 'Emergency contact')], default=('SUPPORT', 'ADMIN', 'BILLING', 'TECH', 'ADDS', 'EMERGENCY'), help_text='Contacts to notify by email', max_length=256, verbose_name='notify')),
],
),
migrations.CreateModel(
name='Ticket',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('creator_name', models.CharField(blank=True, max_length=256, verbose_name='creator name')),
('subject', models.CharField(max_length=256, verbose_name='subject')),
('description', models.TextField(verbose_name='description')),
('priority', models.CharField(choices=[('HIGH', 'High'), ('MEDIUM', 'Medium'), ('LOW', 'Low')], default='MEDIUM', max_length=32, verbose_name='priority')),
('state', models.CharField(choices=[('NEW', 'New'), ('IN_PROGRESS', 'In Progress'), ('RESOLVED', 'Resolved'), ('FEEDBACK', 'Feedback'), ('REJECTED', 'Rejected'), ('CLOSED', 'Closed')], default='NEW', max_length=32, verbose_name='state')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='modified')),
('cc', models.TextField(blank=True, help_text='emails to send a carbon copy to', verbose_name='CC')),
('creator', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tickets_created', to=settings.AUTH_USER_MODEL, verbose_name='created by')),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tickets_owned', to=settings.AUTH_USER_MODEL, verbose_name='assigned to')),
('queue', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tickets', to='issues.Queue')),
],
options={
'ordering': ['-updated_at'],
},
),
migrations.CreateModel(
name='TicketTracker',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trackers', to='issues.Ticket', verbose_name='ticket')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_trackers', to=settings.AUTH_USER_MODEL, verbose_name='user')),
],
),
migrations.AddField(
model_name='message',
name='ticket',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='issues.Ticket', verbose_name='ticket'),
),
migrations.AlterUniqueTogether(
name='tickettracker',
unique_together=set([('ticket', 'user')]),
),
migrations.AlterField(
model_name='ticket',
name='created_at',
field=models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created'),
),
migrations.RemoveField(
model_name='message',
name='created_on',
),
migrations.AddField(
model_name='message',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2016, 3, 20, 10, 27, 45, 766388, tzinfo=utc), verbose_name='created at'),
preserve_default=False,
),
migrations.AlterField(
model_name='ticket',
name='creator',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tickets_created', to=settings.AUTH_USER_MODEL, verbose_name='created by'),
),
migrations.AlterField(
model_name='ticket',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tickets_owned', to=settings.AUTH_USER_MODEL, verbose_name='assigned to'),
),
migrations.AlterField(
model_name='ticket',
name='queue',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tickets', to='issues.Queue'),
),
]

View File

@ -161,10 +161,10 @@ class Ticket(models.Model):
class Message(models.Model): class Message(models.Model):
ticket = models.ForeignKey('issues.Ticket', on_delete=models.CASCADE, ticket = models.ForeignKey('issues.Ticket', verbose_name=_("ticket"),
verbose_name=_("ticket"), related_name='messages') related_name='messages')
author = models.ForeignKey(djsettings.AUTH_USER_MODEL, on_delete=models.CASCADE, author = models.ForeignKey(djsettings.AUTH_USER_MODEL, verbose_name=_("author"),
verbose_name=_("author"), related_name='ticket_messages') related_name='ticket_messages')
author_name = models.CharField(_("author name"), max_length=256, blank=True) author_name = models.CharField(_("author name"), max_length=256, blank=True)
content = models.TextField(_("content")) content = models.TextField(_("content"))
created_at = models.DateTimeField(_("created at"), auto_now_add=True) created_at = models.DateTimeField(_("created at"), auto_now_add=True)
@ -191,10 +191,9 @@ class Message(models.Model):
class TicketTracker(models.Model): class TicketTracker(models.Model):
""" Keeps track of user read tickets """ """ Keeps track of user read tickets """
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, ticket = models.ForeignKey(Ticket, verbose_name=_("ticket"), related_name='trackers')
verbose_name=_("ticket"), related_name='trackers') user = models.ForeignKey(djsettings.AUTH_USER_MODEL, verbose_name=_("user"),
user = models.ForeignKey(djsettings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='ticket_trackers')
verbose_name=_("user"), related_name='ticket_trackers')
class Meta: class Meta:
unique_together = ( unique_together = (

View File

@ -3,7 +3,6 @@ from __future__ import unicode_literals
from django.db import models, migrations from django.db import models, migrations
from django.conf import settings from django.conf import settings
import django.db.models.deletion
import orchestra.core.validators import orchestra.core.validators
@ -23,8 +22,8 @@ class Migration(migrations.Migration):
('address_name', models.CharField(max_length=128, validators=[orchestra.core.validators.validate_name], verbose_name='address name', blank=True)), ('address_name', models.CharField(max_length=128, validators=[orchestra.core.validators.validate_name], verbose_name='address name', blank=True)),
('admin_email', models.EmailField(max_length=254, verbose_name='admin email', help_text='Administration email address')), ('admin_email', models.EmailField(max_length=254, verbose_name='admin email', help_text='Administration email address')),
('is_active', models.BooleanField(default=True, verbose_name='active', help_text='Designates whether this account should be treated as active. Unselect this instead of deleting accounts.')), ('is_active', models.BooleanField(default=True, verbose_name='active', help_text='Designates whether this account should be treated as active. Unselect this instead of deleting accounts.')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lists', to=settings.AUTH_USER_MODEL, verbose_name='Account')), ('account', models.ForeignKey(related_name='lists', to=settings.AUTH_USER_MODEL, verbose_name='Account')),
('address_domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, null=True, blank=True, to='domains.Domain', verbose_name='address domain')), ('address_domain', models.ForeignKey(null=True, blank=True, to='domains.Domain', verbose_name='address domain')),
], ],
), ),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(

View File

@ -1,69 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2021-04-22 11:27
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import orchestra.core.validators
class Migration(migrations.Migration):
replaces = [('lists', '0001_initial'), ('lists', '0002_auto_20160912_1221'), ('lists', '0003_auto_20160912_1241'), ('lists', '0004_auto_20210330_1049')]
initial = True
dependencies = [
('domains', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='List',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Default list address &lt;name&gt;@lists.orchestra.lan', max_length=128, unique=True, validators=[orchestra.core.validators.validate_name], verbose_name='name')),
('address_name', models.CharField(blank=True, max_length=128, validators=[orchestra.core.validators.validate_name], verbose_name='address name')),
('admin_email', models.EmailField(help_text='Administration email address', max_length=254, verbose_name='admin email')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this account should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lists', to=settings.AUTH_USER_MODEL, verbose_name='Account')),
('address_domain', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='domains.Domain', verbose_name='address domain')),
],
),
migrations.AlterUniqueTogether(
name='list',
unique_together=set([('address_name', 'address_domain')]),
),
migrations.AlterField(
model_name='list',
name='address_domain',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='domains.Domain', verbose_name='address domain'),
),
migrations.AlterField(
model_name='list',
name='address_name',
field=models.CharField(blank=True, max_length=52, validators=[orchestra.core.validators.validate_name], verbose_name='address name'),
),
migrations.AlterField(
model_name='list',
name='name',
field=models.CharField(help_text='Default list address &lt;name&gt;@grups.pangea.org', max_length=52, unique=True, validators=[orchestra.core.validators.validate_name], verbose_name='name'),
),
migrations.AlterField(
model_name='list',
name='address_name',
field=models.CharField(blank=True, max_length=64, validators=[orchestra.core.validators.validate_name], verbose_name='address name'),
),
migrations.AlterField(
model_name='list',
name='name',
field=models.CharField(help_text='Default list address &lt;name&gt;@grups.pangea.org', max_length=64, unique=True, validators=[orchestra.core.validators.validate_name], verbose_name='name'),
),
migrations.AlterField(
model_name='list',
name='name',
field=models.CharField(help_text='Default list address &lt;name&gt;@lists.orchestra.lan', max_length=64, unique=True, validators=[orchestra.core.validators.validate_name], verbose_name='name'),
),
]

View File

@ -1,21 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2021-03-30 10:49
from __future__ import unicode_literals
from django.db import migrations, models
import orchestra.core.validators
class Migration(migrations.Migration):
dependencies = [
('lists', '0003_auto_20160912_1241'),
]
operations = [
migrations.AlterField(
model_name='list',
name='name',
field=models.CharField(help_text='Default list address &lt;name&gt;@lists.orchestra.lan', max_length=64, unique=True, validators=[orchestra.core.validators.validate_name], verbose_name='name'),
),
]

View File

@ -30,7 +30,7 @@ class List(models.Model):
admin_email = models.EmailField(_("admin email"), admin_email = models.EmailField(_("admin email"),
help_text=_("Administration email address")) help_text=_("Administration email address"))
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
related_name='lists', on_delete=models.CASCADE) related_name='lists')
# TODO also admin # TODO also admin
is_active = models.BooleanField(_("active"), default=True, is_active = models.BooleanField(_("active"), default=True,
help_text=_("Designates whether this account should be treated as active. " help_text=_("Designates whether this account should be treated as active. "

View File

@ -12,7 +12,7 @@ from .models import List
class RelatedDomainSerializer(AccountSerializerMixin, RelatedHyperlinkedModelSerializer): class RelatedDomainSerializer(AccountSerializerMixin, RelatedHyperlinkedModelSerializer):
class Meta: class Meta:
model = List.address_domain.field.related_model model = List.address_domain.field.rel.to
fields = ('url', 'id', 'name') fields = ('url', 'id', 'name')

View File

@ -1,26 +1,24 @@
import os import os
import smtplib import smtplib
import time import time
import unittest import requests
from email.mime.text import MIMEText from email.mime.text import MIMEText
import requests
from django.conf import settings as djsettings from django.conf import settings as djsettings
from django.core.management.base import CommandError from django.core.management.base import CommandError
from django.urls import reverse from django.core.urlresolvers import reverse
from selenium.webdriver.support.select import Select
from orchestra.admin.utils import change_url from orchestra.admin.utils import change_url
from orchestra.contrib.domains.models import Domain from orchestra.contrib.domains.models import Domain
from orchestra.contrib.orchestration.models import Route, Server from orchestra.contrib.orchestration.models import Server, Route
from orchestra.utils.sys import sshrun from orchestra.utils.sys import sshrun
from orchestra.utils.tests import (BaseLiveServerTestCase, random_ascii, from orchestra.utils.tests import (BaseLiveServerTestCase, random_ascii, snapshot_on_error,
save_response_on_error, snapshot_on_error) save_response_on_error)
from selenium.webdriver.support.select import Select
from ... import backends, settings from ... import backends, settings
from ...models import List from ...models import List
TEST_REST_API = int(os.getenv('TEST_REST_API', '0'))
class ListMixin(object): class ListMixin(object):
MASTER_SERVER = os.environ.get('ORCHESTRA_SLAVE_SERVER', 'localhost') MASTER_SERVER = os.environ.get('ORCHESTRA_SLAVE_SERVER', 'localhost')
@ -160,7 +158,6 @@ class ListMixin(object):
self.validate_delete(name) self.validate_delete(name)
@unittest.skipUnless(TEST_REST_API, "REST API tests")
class RESTListMixin(ListMixin): class RESTListMixin(ListMixin):
def setUp(self): def setUp(self):
super(RESTListMixin, self).setUp() super(RESTListMixin, self).setUp()

View File

@ -3,10 +3,9 @@ from urllib.parse import parse_qs
from django import forms from django import forms
from django.contrib import admin, messages from django.contrib import admin, messages
from django.urls import reverse from django.core.urlresolvers import reverse
from django.db.models import F, Count, Value as V from django.db.models import F, Count, Value as V
from django.db.models.functions import Concat from django.db.models.functions import Concat
from django.utils.html import format_html, format_html_join
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -83,7 +82,6 @@ class MailboxAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedMo
if settings.MAILBOXES_LOCAL_DOMAIN: if settings.MAILBOXES_LOCAL_DOMAIN:
type(self).actions = self.actions + (SendMailboxEmail(),) type(self).actions = self.actions + (SendMailboxEmail(),)
@mark_safe
def display_addresses(self, mailbox): def display_addresses(self, mailbox):
# Get from forwards # Get from forwards
cache = caches.get_request_cache() cache = caches.get_request_cache()
@ -95,7 +93,7 @@ class MailboxAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedMo
qs = qs.values_list('id', 'email', 'forward') qs = qs.values_list('id', 'email', 'forward')
for addr_id, email, mbox in qs: for addr_id, email, mbox in qs:
url = reverse('admin:mailboxes_address_change', args=(addr_id,)) url = reverse('admin:mailboxes_address_change', args=(addr_id,))
link = format_html('<a href="{}">{}</a>', url, email) link = '<a href="%s">%s</a>' % (url, email)
try: try:
cached_forwards[mbox].append(link) cached_forwards[mbox].append(link)
except KeyError: except KeyError:
@ -109,23 +107,26 @@ class MailboxAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedMo
addresses = [] addresses = []
for addr in mailbox.addresses.all(): for addr in mailbox.addresses.all():
url = change_url(addr) url = change_url(addr)
addresses.append(format_html('<a href="{}">{}</a>', url, addr.email)) addresses.append('<a href="%s">%s</a>' % (url, addr.email))
return '<br>'.join(addresses+forwards) return '<br>'.join(addresses+forwards)
display_addresses.short_description = _("Addresses") display_addresses.short_description = _("Addresses")
display_addresses.allow_tags = True
def display_forwards(self, mailbox): def display_forwards(self, mailbox):
forwards = mailbox.get_forwards() forwards = []
return format_html_join( for addr in mailbox.get_forwards():
'<br>', '<a href="{}">{}</a>', url = change_url(addr)
[(change_url(addr), addr.email) for addr in forwards] forwards.append('<a href="%s">%s</a>' % (url, addr.email))
) return '<br>'.join(forwards)
display_forwards.short_description = _("Forward from") display_forwards.short_description = _("Forward from")
display_forwards.allow_tags = True
@mark_safe
def display_filtering(self, mailbox): def display_filtering(self, mailbox):
""" becacuse of allow_tags = True """
return mailbox.get_filtering_display() return mailbox.get_filtering_display()
display_filtering.short_description = _("Filtering") display_filtering.short_description = _("Filtering")
display_filtering.admin_order_field = 'filtering' display_filtering.admin_order_field = 'filtering'
display_filtering.allow_tags = True
def formfield_for_dbfield(self, db_field, **kwargs): def formfield_for_dbfield(self, db_field, **kwargs):
if db_field.name == 'filtering': if db_field.name == 'filtering':
@ -216,7 +217,7 @@ class MailboxAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedMo
elif obj.custom_filtering: elif obj.custom_filtering:
messages.warning(request, msg) messages.warning(request, msg)
super(MailboxAdmin, self).save_model(request, obj, form, change) super(MailboxAdmin, self).save_model(request, obj, form, change)
obj.addresses.set(form.cleaned_data['addresses']) obj.addresses = form.cleaned_data['addresses']
class AddressAdmin(SelectAccountAdminMixin, ExtendedModelAdmin): class AddressAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
@ -246,27 +247,29 @@ class AddressAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
def email_link(self, address): def email_link(self, address):
link = self.domain_link(address) link = self.domain_link(address)
return format_html("{}@{}", address.name, link) return "%s@%s" % (address.name, link)
email_link.short_description = _("Email") email_link.short_description = _("Email")
email_link.allow_tags = True
def display_mailboxes(self, address): def display_mailboxes(self, address):
boxes = address.mailboxes.all() boxes = []
return format_html_join( for mailbox in address.mailboxes.all():
mark_safe('<br>'), '<a href="{}">{}</a>', url = change_url(mailbox)
[(change_url(mailbox), mailbox.name) for mailbox in boxes] boxes.append('<a href="%s">%s</a>' % (url, mailbox.name))
) return '<br>'.join(boxes)
display_mailboxes.short_description = _("Mailboxes") display_mailboxes.short_description = _("Mailboxes")
display_mailboxes.allow_tags = True
display_mailboxes.admin_order_field = 'mailboxes__count' display_mailboxes.admin_order_field = 'mailboxes__count'
def display_all_mailboxes(self, address): def display_all_mailboxes(self, address):
boxes = address.get_mailboxes() boxes = []
return format_html_join( for mailbox in address.get_mailboxes():
mark_safe('<br>'), '<a href="{}">{}</a>', url = change_url(mailbox)
[(change_url(mailbox), mailbox.name) for mailbox in boxes] boxes.append('<a href="%s">%s</a>' % (url, mailbox.name))
) return '<br>'.join(boxes)
display_all_mailboxes.short_description = _("Mailboxes links") display_all_mailboxes.short_description = _("Mailboxes links")
display_all_mailboxes.allow_tags = True
@mark_safe
def display_forward(self, address): def display_forward(self, address):
forward_mailboxes = {m.name: m for m in address.get_forward_mailboxes()} forward_mailboxes = {m.name: m for m in address.get_forward_mailboxes()}
values = [] values = []
@ -278,6 +281,7 @@ class AddressAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
values.append(forward) values.append(forward)
return '<br>'.join(values) return '<br>'.join(values)
display_forward.short_description = _("Forward") display_forward.short_description = _("Forward")
display_forward.allow_tags = True
display_forward.admin_order_field = 'forward' display_forward.admin_order_field = 'forward'
def formfield_for_dbfield(self, db_field, **kwargs): def formfield_for_dbfield(self, db_field, **kwargs):

View File

@ -4,7 +4,7 @@ from orchestra.api import router, SetPasswordApiMixin, LogApiMixin
from orchestra.contrib.accounts.api import AccountApiMixin from orchestra.contrib.accounts.api import AccountApiMixin
from .models import Address, Mailbox from .models import Address, Mailbox
from .serializers import AddressSerializer, MailboxSerializer, MailboxWritableSerializer from .serializers import AddressSerializer, MailboxSerializer
class AddressViewSet(LogApiMixin, AccountApiMixin, viewsets.ModelViewSet): class AddressViewSet(LogApiMixin, AccountApiMixin, viewsets.ModelViewSet):
@ -17,12 +17,6 @@ class MailboxViewSet(LogApiMixin, SetPasswordApiMixin, AccountApiMixin, viewsets
queryset = Mailbox.objects.prefetch_related('addresses__domain').all() queryset = Mailbox.objects.prefetch_related('addresses__domain').all()
serializer_class = MailboxSerializer serializer_class = MailboxSerializer
def get_serializer_class(self):
if self.request.method == 'GET':
return self.serializer_class
return MailboxWritableSerializer
router.register(r'mailboxes', MailboxViewSet) router.register(r'mailboxes', MailboxViewSet)
router.register(r'addresses', AddressViewSet) router.register(r'addresses', AddressViewSet)

View File

@ -3,7 +3,6 @@ from __future__ import unicode_literals
from django.db import models, migrations from django.db import models, migrations
from django.conf import settings from django.conf import settings
import django.db.models.deletion
import orchestra.contrib.mailboxes.validators import orchestra.contrib.mailboxes.validators
import django.core.validators import django.core.validators
@ -22,8 +21,8 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(verbose_name='name', validators=[orchestra.contrib.mailboxes.validators.validate_emailname], blank=True, help_text='Address name, left blank for a <i>catch-all</i> address', max_length=64)), ('name', models.CharField(verbose_name='name', validators=[orchestra.contrib.mailboxes.validators.validate_emailname], blank=True, help_text='Address name, left blank for a <i>catch-all</i> address', max_length=64)),
('forward', models.CharField(verbose_name='forward', validators=[orchestra.contrib.mailboxes.validators.validate_forward], blank=True, help_text='Space separated email addresses or mailboxes', max_length=256)), ('forward', models.CharField(verbose_name='forward', validators=[orchestra.contrib.mailboxes.validators.validate_forward], blank=True, help_text='Space separated email addresses or mailboxes', max_length=256)),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, related_name='addresses', verbose_name='Account')), ('account', models.ForeignKey(to=settings.AUTH_USER_MODEL, related_name='addresses', verbose_name='Account')),
('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='domains.Domain', related_name='addresses', verbose_name='domain')), ('domain', models.ForeignKey(to='domains.Domain', related_name='addresses', verbose_name='domain')),
], ],
options={ options={
'verbose_name_plural': 'addresses', 'verbose_name_plural': 'addresses',
@ -36,7 +35,7 @@ class Migration(migrations.Migration):
('subject', models.CharField(verbose_name='subject', max_length=256)), ('subject', models.CharField(verbose_name='subject', max_length=256)),
('message', models.TextField(verbose_name='message')), ('message', models.TextField(verbose_name='message')),
('enabled', models.BooleanField(verbose_name='enabled', default=False)), ('enabled', models.BooleanField(verbose_name='enabled', default=False)),
('address', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='mailboxes.Address', related_name='autoresponse', verbose_name='address')), ('address', models.OneToOneField(to='mailboxes.Address', related_name='autoresponse', verbose_name='address')),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
@ -48,7 +47,7 @@ class Migration(migrations.Migration):
('filtering', models.CharField(choices=[('CUSTOM', 'Custom filtering'), ('REDIRECT', 'Archive spam (X-Spam-Score&ge;9)'), ('DISABLE', 'Disable'), ('REJECT', 'Reject spam (X-Spam-Score&ge;9)')], max_length=16, default='REDIRECT')), ('filtering', models.CharField(choices=[('CUSTOM', 'Custom filtering'), ('REDIRECT', 'Archive spam (X-Spam-Score&ge;9)'), ('DISABLE', 'Disable'), ('REJECT', 'Reject spam (X-Spam-Score&ge;9)')], max_length=16, default='REDIRECT')),
('custom_filtering', models.TextField(verbose_name='filtering', validators=[orchestra.contrib.mailboxes.validators.validate_sieve], blank=True, help_text='Arbitrary email filtering in sieve language. This overrides any automatic junk email filtering')), ('custom_filtering', models.TextField(verbose_name='filtering', validators=[orchestra.contrib.mailboxes.validators.validate_sieve], blank=True, help_text='Arbitrary email filtering in sieve language. This overrides any automatic junk email filtering')),
('is_active', models.BooleanField(verbose_name='active', default=True)), ('is_active', models.BooleanField(verbose_name='active', default=True)),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, related_name='mailboxes', verbose_name='account')), ('account', models.ForeignKey(to=settings.AUTH_USER_MODEL, related_name='mailboxes', verbose_name='account')),
], ],
options={ options={
'verbose_name_plural': 'mailboxes', 'verbose_name_plural': 'mailboxes',

View File

@ -1,71 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2021-04-22 11:27
from __future__ import unicode_literals
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import orchestra.contrib.mailboxes.validators
class Migration(migrations.Migration):
replaces = [('mailboxes', '0001_initial'), ('mailboxes', '0002_auto_20160219_1032'), ('mailboxes', '0003_auto_20170528_2011')]
initial = True
dependencies = [
('domains', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Address',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(blank=True, help_text='Address name, left blank for a <i>catch-all</i> address', max_length=64, validators=[orchestra.contrib.mailboxes.validators.validate_emailname], verbose_name='name')),
('forward', models.CharField(blank=True, help_text='Space separated email addresses or mailboxes', max_length=256, validators=[orchestra.contrib.mailboxes.validators.validate_forward], verbose_name='forward')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addresses', to=settings.AUTH_USER_MODEL, verbose_name='Account')),
('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addresses', to='domains.Domain', verbose_name='domain')),
],
options={
'verbose_name_plural': 'addresses',
},
),
migrations.CreateModel(
name='Autoresponse',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('subject', models.CharField(max_length=256, verbose_name='subject')),
('message', models.TextField(verbose_name='message')),
('enabled', models.BooleanField(default=False, verbose_name='enabled')),
('address', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='autoresponse', to='mailboxes.Address', verbose_name='address')),
],
),
migrations.CreateModel(
name='Mailbox',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(db_index=True, help_text='Required. 32 characters or fewer. Letters, digits and ./-/_ only.', max_length=32, unique=True, validators=[django.core.validators.RegexValidator('^[\\w.-]+$', 'Enter a valid mailbox name.')], verbose_name='name')),
('password', models.CharField(max_length=128, verbose_name='password')),
('filtering', models.CharField(choices=[('CUSTOM', 'Custom filtering'), ('DISABLE', 'Disable'), ('REDIRECT', 'Archive spam (Score&ge;8)'), ('REDIRECT5', 'Archive spam (Score&ge;5)'), ('REJECT', 'Reject spam (Score&ge;8)'), ('REJECT5', 'Reject spam (Score&ge;5)')], default='REDIRECT', max_length=16)),
('custom_filtering', models.TextField(blank=True, help_text="Arbitrary email filtering in <a href='https://tty1.net/blog/2011/sieve-tutorial_en.html'>sieve language</a>. This overrides any automatic junk email filtering", validators=[orchestra.contrib.mailboxes.validators.validate_sieve], verbose_name='filtering')),
('is_active', models.BooleanField(default=True, verbose_name='active')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mailboxes', to=settings.AUTH_USER_MODEL, verbose_name='account')),
],
options={
'verbose_name_plural': 'mailboxes',
},
),
migrations.AddField(
model_name='address',
name='mailboxes',
field=models.ManyToManyField(blank=True, related_name='addresses', to='mailboxes.Mailbox', verbose_name='mailboxes'),
),
migrations.AlterUniqueTogether(
name='address',
unique_together=set([('name', 'domain')]),
),
]

View File

@ -23,7 +23,7 @@ class Mailbox(models.Model):
]) ])
password = models.CharField(_("password"), max_length=128) password = models.CharField(_("password"), max_length=128)
account = models.ForeignKey('accounts.Account', verbose_name=_("account"), account = models.ForeignKey('accounts.Account', verbose_name=_("account"),
related_name='mailboxes', on_delete=models.CASCADE) related_name='mailboxes')
filtering = models.CharField(max_length=16, filtering = models.CharField(max_length=16,
default=settings.MAILBOXES_MAILBOX_DEFAULT_FILTERING, default=settings.MAILBOXES_MAILBOX_DEFAULT_FILTERING,
choices=[(k, v[0]) for k,v in sorted(settings.MAILBOXES_MAILBOX_FILTERINGS.items())]) choices=[(k, v[0]) for k,v in sorted(settings.MAILBOXES_MAILBOX_FILTERINGS.items())])
@ -44,7 +44,7 @@ class Mailbox(models.Model):
def active(self): def active(self):
try: try:
return self.is_active and self.account.is_active return self.is_active and self.account.is_active
except type(self).account.field.related_model.DoesNotExist: except type(self).account.field.rel.to.DoesNotExist:
return self.is_active return self.is_active
def disable(self): def disable(self):
@ -97,14 +97,14 @@ class Address(models.Model):
validators=[validators.validate_emailname], validators=[validators.validate_emailname],
help_text=_("Address name, left blank for a <i>catch-all</i> address")) help_text=_("Address name, left blank for a <i>catch-all</i> address"))
domain = models.ForeignKey(settings.MAILBOXES_DOMAIN_MODEL, domain = models.ForeignKey(settings.MAILBOXES_DOMAIN_MODEL,
verbose_name=_("domain"), related_name='addresses', on_delete=models.CASCADE) verbose_name=_("domain"), related_name='addresses')
mailboxes = models.ManyToManyField(Mailbox, verbose_name=_("mailboxes"), mailboxes = models.ManyToManyField(Mailbox, verbose_name=_("mailboxes"),
related_name='addresses', blank=True) related_name='addresses', blank=True)
forward = models.CharField(_("forward"), max_length=256, blank=True, forward = models.CharField(_("forward"), max_length=256, blank=True,
validators=[validators.validate_forward], validators=[validators.validate_forward],
help_text=_("Space separated email addresses or mailboxes")) help_text=_("Space separated email addresses or mailboxes"))
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
related_name='addresses', on_delete=models.CASCADE) related_name='addresses')
class Meta: class Meta:
verbose_name_plural = _("addresses") verbose_name_plural = _("addresses")
@ -168,7 +168,7 @@ class Address(models.Model):
class Autoresponse(models.Model): class Autoresponse(models.Model):
address = models.OneToOneField(Address, verbose_name=_("address"), address = models.OneToOneField(Address, verbose_name=_("address"),
related_name='autoresponse', on_delete=models.CASCADE) related_name='autoresponse')
# TODO initial_date # TODO initial_date
subject = models.CharField(_("subject"), max_length=256) subject = models.CharField(_("subject"), max_length=256)
message = models.TextField(_("message")) message = models.TextField(_("message"))

View File

@ -1,4 +1,3 @@
from django.db import transaction
from rest_framework import serializers from rest_framework import serializers
from orchestra.api.serializers import SetPasswordHyperlinkedSerializer, RelatedHyperlinkedModelSerializer from orchestra.api.serializers import SetPasswordHyperlinkedSerializer, RelatedHyperlinkedModelSerializer
@ -9,7 +8,7 @@ from .models import Mailbox, Address
class RelatedDomainSerializer(AccountSerializerMixin, RelatedHyperlinkedModelSerializer): class RelatedDomainSerializer(AccountSerializerMixin, RelatedHyperlinkedModelSerializer):
class Meta: class Meta:
model = Address.domain.field.related_model model = Address.domain.field.rel.to
fields = ('url', 'id', 'name') fields = ('url', 'id', 'name')
@ -36,41 +35,6 @@ class MailboxSerializer(AccountSerializerMixin, SetPasswordHyperlinkedSerializer
postonly_fields = ('name', 'password') postonly_fields = ('name', 'password')
class AddressRelatedField(serializers.HyperlinkedRelatedField):
# Filter addresses by account (user)
def get_queryset(self):
qs = super().get_queryset()
return qs.filter(account=self.context['account'])
class MailboxWritableSerializer(AccountSerializerMixin, SetPasswordHyperlinkedSerializer):
addresses = AddressRelatedField(many=True, view_name='address-detail', queryset=Address.objects.all())
class Meta:
model = Mailbox
fields = (
'url', 'id', 'name', 'password', 'filtering', 'custom_filtering', 'addresses', 'is_active'
)
postonly_fields = ('name', 'password')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['addresses'].context['account'] = self.account
@transaction.atomic
def create(self, validated_data):
addresses = validated_data.pop('addresses', [])
instance = super().create(validated_data)
instance.addresses.set(addresses)
return instance
@transaction.atomic
def update(self, instance, validated_data):
addresses = validated_data.pop('addresses', [])
instance.addresses.set(addresses)
return super().update(instance, validated_data)
class RelatedMailboxSerializer(AccountSerializerMixin, RelatedHyperlinkedModelSerializer): class RelatedMailboxSerializer(AccountSerializerMixin, RelatedHyperlinkedModelSerializer):
class Meta: class Meta:
model = Mailbox model = Mailbox
@ -79,7 +43,7 @@ class RelatedMailboxSerializer(AccountSerializerMixin, RelatedHyperlinkedModelSe
class AddressSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer): class AddressSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer):
domain = RelatedDomainSerializer() domain = RelatedDomainSerializer()
mailboxes = RelatedMailboxSerializer(many=True, required=False) mailboxes = RelatedMailboxSerializer(many=True, required=False) #allow_add_remove=True
class Meta: class Meta:
model = Address model = Address
@ -87,21 +51,6 @@ class AddressSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSeri
def validate(self, attrs): def validate(self, attrs):
attrs = super(AddressSerializer, self).validate(attrs) attrs = super(AddressSerializer, self).validate(attrs)
mailboxes = attrs.get('mailboxes', []) if not attrs['mailboxes'] and not attrs['forward']:
forward = attrs.get('forward', '')
if not mailboxes and not forward:
raise serializers.ValidationError("A mailbox or forward address should be provided.") raise serializers.ValidationError("A mailbox or forward address should be provided.")
return attrs return attrs
@transaction.atomic
def create(self, validated_data):
mailboxes = validated_data.pop('mailboxes', [])
obj = super().create(validated_data)
obj.mailboxes.set(mailboxes)
return obj
@transaction.atomic
def update(self, instance, validated_data):
mailboxes = validated_data.pop('mailboxes', [])
instance.mailboxes.set(mailboxes)
return super().update(instance, validated_data)

View File

@ -27,7 +27,7 @@ def create_local_address(sender, *args, **kwargs):
mbox = kwargs['instance'] mbox = kwargs['instance']
local_domain = settings.MAILBOXES_LOCAL_DOMAIN local_domain = settings.MAILBOXES_LOCAL_DOMAIN
if not mbox.pk and local_domain: if not mbox.pk and local_domain:
Domain = Address._meta.get_field('domain').remote_field.model Domain = Address._meta.get_field('domain').rel.to
try: try:
domain = Domain.objects.get(name=local_domain) domain = Domain.objects.get(name=local_domain)
except Domain.DoesNotExist: except Domain.DoesNotExist:

View File

@ -4,14 +4,13 @@ import poplib
import smtplib import smtplib
import time import time
import textwrap import textwrap
import unittest
from email.mime.text import MIMEText from email.mime.text import MIMEText
from django.apps import apps from django.apps import apps
from django.conf import settings as djsettings from django.conf import settings as djsettings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.management.base import CommandError from django.core.management.base import CommandError
from django.urls import reverse from django.core.urlresolvers import reverse
from selenium.webdriver.support.select import Select from selenium.webdriver.support.select import Select
from orchestra.contrib.orchestration.models import Server, Route from orchestra.contrib.orchestration.models import Server, Route
@ -22,8 +21,6 @@ from orchestra.utils.tests import BaseLiveServerTestCase, random_ascii, snapshot
from ... import backends, settings from ... import backends, settings
from ...models import Mailbox from ...models import Mailbox
TEST_REST_API = int(os.getenv('TEST_REST_API', '0'))
class MailboxMixin(object): class MailboxMixin(object):
MASTER_SERVER = os.environ.get('ORCHESTRA_SLAVE_SERVER', 'localhost') MASTER_SERVER = os.environ.get('ORCHESTRA_SLAVE_SERVER', 'localhost')
@ -42,7 +39,7 @@ class MailboxMixin(object):
def add_route(self): def add_route(self):
server = Server.objects.create(name=self.MASTER_SERVER) server = Server.objects.create(name=self.MASTER_SERVER)
backend = backends.RoundcubeIdentityController.get_name() backend = backends.PasswdVirtualUserBackend.get_name()
Route.objects.create(backend=backend, match=True, host=server) Route.objects.create(backend=backend, match=True, host=server)
backend = backends.PostfixAddressController.get_name() backend = backends.PostfixAddressController.get_name()
Route.objects.create(backend=backend, match=True, host=server) Route.objects.create(backend=backend, match=True, host=server)
@ -238,7 +235,6 @@ class MailboxMixin(object):
# TODO test autoreply # TODO test autoreply
@unittest.skipUnless(TEST_REST_API, "REST API tests")
class RESTMailboxMixin(MailboxMixin): class RESTMailboxMixin(MailboxMixin):
def setUp(self): def setUp(self):
super(RESTMailboxMixin, self).setUp() super(RESTMailboxMixin, self).setUp()

View File

@ -1,4 +1,4 @@
from django.urls import reverse from django.core.urlresolvers import reverse
from django.shortcuts import redirect from django.shortcuts import redirect

View File

@ -3,11 +3,9 @@ import email
from django import forms from django import forms
from django.contrib import admin from django.contrib import admin
from django.urls import reverse from django.core.urlresolvers import reverse
from django.db.models import Count from django.db.models import Count
from django.shortcuts import redirect from django.shortcuts import redirect
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ExtendedModelAdmin from orchestra.admin import ExtendedModelAdmin
@ -62,10 +60,11 @@ class MessageAdmin(ExtendedModelAdmin):
def display_subject(self, instance): def display_subject(self, instance):
subject = instance.subject subject = instance.subject
if len(subject) > 64: if len(subject) > 64:
return mark_safe(subject[:64] + '&hellip;') return subject[:64] + '&hellip;'
return subject return subject
display_subject.short_description = _("Subject") display_subject.short_description = _("Subject")
display_subject.admin_order_field = 'subject' display_subject.admin_order_field = 'subject'
display_subject.allow_tags = True
def display_retries(self, instance): def display_retries(self, instance):
num_logs = instance.logs__count num_logs = instance.logs__count
@ -75,9 +74,10 @@ class MessageAdmin(ExtendedModelAdmin):
else: else:
url = reverse('admin:mailer_smtplog_changelist') url = reverse('admin:mailer_smtplog_changelist')
url += '?&message=%i' % instance.pk url += '?&message=%i' % instance.pk
return format_html('<a href="{}" onclick="return showAddAnotherPopup(this);">{}</a>', url, instance.retries) return '<a href="%s" onclick="return showAddAnotherPopup(this);">%d</a>' % (url, instance.retries)
display_retries.short_description = _("Retries") display_retries.short_description = _("Retries")
display_retries.admin_order_field = 'retries' display_retries.admin_order_field = 'retries'
display_retries.allow_tags = True
def display_content(self, instance): def display_content(self, instance):
part = email.message_from_string(instance.content) part = email.message_from_string(instance.content)
@ -99,8 +99,9 @@ class MessageAdmin(ExtendedModelAdmin):
payload = payload.decode(charset) payload = payload.decode(charset)
if part.get_content_type() == 'text/plain': if part.get_content_type() == 'text/plain':
payload = payload.replace('\n', '<br>').replace(' ', '&nbsp;') payload = payload.replace('\n', '<br>').replace(' ', '&nbsp;')
return mark_safe(payload) return payload
display_content.short_description = _("Content") display_content.short_description = _("Content")
display_content.allow_tags = True
def display_full_subject(self, instance): def display_full_subject(self, instance):
return instance.subject return instance.subject

View File

@ -1,7 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import django.db.models.deletion
from django.db import models, migrations from django.db import models, migrations
@ -33,7 +32,7 @@ class Migration(migrations.Migration):
('result', models.CharField(choices=[('SUCCESS', 'Success'), ('FAILURE', 'Failure')], default='SUCCESS', max_length=16)), ('result', models.CharField(choices=[('SUCCESS', 'Success'), ('FAILURE', 'Failure')], default='SUCCESS', max_length=16)),
('date', models.DateTimeField(auto_now_add=True)), ('date', models.DateTimeField(auto_now_add=True)),
('log_message', models.TextField()), ('log_message', models.TextField()),
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='mailer.Message', editable=False, related_name='logs')), ('message', models.ForeignKey(to='mailer.Message', editable=False, related_name='logs')),
], ],
), ),
] ]

View File

@ -1,89 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2021-04-22 11:28
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
replaces = [('mailer', '0001_initial'), ('mailer', '0002_auto_20150617_1021'), ('mailer', '0003_auto_20150617_1024'), ('mailer', '0004_auto_20150805_1328'), ('mailer', '0005_auto_20160219_1056')]
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Message',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('state', models.CharField(choices=[('QUEUED', 'Queued'), ('SENT', 'Sent'), ('DEFERRED', 'Deferred'), ('FAILED', 'Failes')], default='QUEUED', max_length=16, verbose_name='State')),
('priority', models.PositiveIntegerField(choices=[(0, 'Critical (not queued)'), (1, 'High'), (2, 'Normal'), (3, 'Low')], default=2, verbose_name='Priority')),
('to_address', models.CharField(max_length=256)),
('from_address', models.CharField(max_length=256)),
('subject', models.CharField(max_length=256, verbose_name='subject')),
('content', models.TextField(verbose_name='content')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('retries', models.PositiveIntegerField(default=0, verbose_name='retries')),
('last_retry', models.DateTimeField(auto_now=True, verbose_name='last try')),
],
),
migrations.CreateModel(
name='SMTPLog',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('result', models.CharField(choices=[('SUCCESS', 'Success'), ('FAILURE', 'Failure')], default='SUCCESS', max_length=16)),
('date', models.DateTimeField(auto_now_add=True)),
('log_message', models.TextField()),
('message', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='mailer.Message')),
],
),
migrations.RenameField(
model_name='message',
old_name='last_retry',
new_name='last_try',
),
migrations.AlterField(
model_name='message',
name='last_try',
field=models.DateTimeField(verbose_name='last try'),
),
migrations.AlterField(
model_name='message',
name='subject',
field=models.TextField(verbose_name='subject'),
),
migrations.AlterField(
model_name='message',
name='last_try',
field=models.DateTimeField(null=True, verbose_name='last try'),
),
migrations.AlterField(
model_name='message',
name='state',
field=models.CharField(choices=[('QUEUED', 'Queued'), ('SENT', 'Sent'), ('DEFERRED', 'Deferred'), ('FAILED', 'Failed')], default='QUEUED', max_length=16, verbose_name='State'),
),
migrations.AlterField(
model_name='message',
name='last_try',
field=models.DateTimeField(db_index=True, null=True, verbose_name='last try'),
),
migrations.AlterField(
model_name='message',
name='priority',
field=models.PositiveIntegerField(choices=[(0, 'Critical (not queued)'), (1, 'High'), (2, 'Normal'), (3, 'Low')], db_index=True, default=2, verbose_name='Priority'),
),
migrations.AlterField(
model_name='message',
name='retries',
field=models.PositiveIntegerField(db_index=True, default=0, verbose_name='retries'),
),
migrations.AlterField(
model_name='message',
name='state',
field=models.CharField(choices=[('QUEUED', 'Queued'), ('SENT', 'Sent'), ('DEFERRED', 'Deferred'), ('FAILED', 'Failed')], db_index=True, default='QUEUED', max_length=16, verbose_name='State'),
),
]

View File

@ -67,7 +67,7 @@ class SMTPLog(models.Model):
(SUCCESS, _("Success")), (SUCCESS, _("Success")),
(FAILURE, _("Failure")), (FAILURE, _("Failure")),
) )
message = models.ForeignKey(Message, editable=False, related_name='logs', on_delete=models.CASCADE) message = models.ForeignKey(Message, editable=False, related_name='logs')
result = models.CharField(max_length=16, choices=RESULTS, default=SUCCESS) result = models.CharField(max_length=16, choices=RESULTS, default=SUCCESS)
date = models.DateTimeField(auto_now_add=True) date = models.DateTimeField(auto_now_add=True)
log_message = models.TextField() log_message = models.TextField()

View File

@ -1,8 +1,7 @@
from django import forms from django import forms
from django.contrib import admin from django.contrib import admin
from django.urls import reverse from django.core.urlresolvers import reverse
from django.db import models from django.db import models
from django.utils.html import format_html
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -39,13 +38,15 @@ class MiscServiceAdmin(ExtendedModelAdmin):
actions = (disable, enable) actions = (disable, enable)
def display_name(self, misc): def display_name(self, misc):
return format_html('<span title="{}">{}</span>', misc.description, misc.name) return '<span title="%s">%s</span>' % (misc.description, misc.name)
display_name.short_description = _("name") display_name.short_description = _("name")
display_name.allow_tags = True
display_name.admin_order_field = 'name' display_name.admin_order_field = 'name'
def display_verbose_name(self, misc): def display_verbose_name(self, misc):
return format_html('<span title="{}">{}</span>', misc.description, misc.verbose_name) return '<span title="%s">%s</span>' % (misc.description, misc.verbose_name)
display_verbose_name.short_description = _("verbose name") display_verbose_name.short_description = _("verbose name")
display_verbose_name.allow_tags = True
display_verbose_name.admin_order_field = 'verbose_name' display_verbose_name.admin_order_field = 'verbose_name'
def num_instances(self, misc): def num_instances(self, misc):

View File

@ -2,7 +2,6 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.db import models, migrations from django.db import models, migrations
import django.db.models.deletion
import orchestra.core.validators import orchestra.core.validators
from django.conf import settings from django.conf import settings
import orchestra.models.fields import orchestra.models.fields
@ -23,7 +22,7 @@ class Migration(migrations.Migration):
('description', models.TextField(blank=True, verbose_name='description')), ('description', models.TextField(blank=True, verbose_name='description')),
('amount', models.PositiveIntegerField(default=1, verbose_name='amount')), ('amount', models.PositiveIntegerField(default=1, verbose_name='amount')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this service should be treated as active. Unselect this instead of deleting services.', verbose_name='active')), ('is_active', models.BooleanField(default=True, help_text='Designates whether this service should be treated as active. Unselect this instead of deleting services.', verbose_name='active')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='miscellaneous', verbose_name='account', to=settings.AUTH_USER_MODEL)), ('account', models.ForeignKey(related_name='miscellaneous', verbose_name='account', to=settings.AUTH_USER_MODEL)),
], ],
options={ options={
'verbose_name_plural': 'miscellaneous', 'verbose_name_plural': 'miscellaneous',
@ -44,6 +43,6 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='miscellaneous', model_name='miscellaneous',
name='service', name='service',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='instances', verbose_name='service', to='miscellaneous.MiscService'), field=models.ForeignKey(related_name='instances', verbose_name='service', to='miscellaneous.MiscService'),
), ),
] ]

View File

@ -1,59 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2021-04-22 11:28
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import orchestra.core.validators
import orchestra.models.fields
class Migration(migrations.Migration):
replaces = [('miscellaneous', '0001_initial'), ('miscellaneous', '0002_auto_20150723_1252')]
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Miscellaneous',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('identifier', orchestra.models.fields.NullableCharField(help_text='A unique identifier for this service.', max_length=256, null=True, unique=True, verbose_name='identifier')),
('description', models.TextField(blank=True, verbose_name='description')),
('amount', models.PositiveIntegerField(default=1, verbose_name='amount')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this service should be treated as active. Unselect this instead of deleting services.', verbose_name='active')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='miscellaneous', to=settings.AUTH_USER_MODEL, verbose_name='account')),
],
options={
'verbose_name_plural': 'miscellaneous',
},
),
migrations.CreateModel(
name='MiscService',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Raw name used for internal referenciation, i.e. service match definition', max_length=32, unique=True, validators=[orchestra.core.validators.validate_name], verbose_name='name')),
('verbose_name', models.CharField(blank=True, help_text='Human readable name', max_length=256, verbose_name='verbose name')),
('description', models.TextField(blank=True, help_text='Optional description', verbose_name='description')),
('has_identifier', models.BooleanField(default=True, help_text='Designates if this service has a <b>unique text</b> field that identifies it or not.', verbose_name='has identifier')),
('has_amount', models.BooleanField(default=False, help_text='Designates whether this service has <tt>amount</tt> property or not.', verbose_name='has amount')),
('is_active', models.BooleanField(default=True, help_text='Whether new instances of this service can be created or not. Unselect this instead of deleting services.', verbose_name='active')),
],
),
migrations.AddField(
model_name='miscellaneous',
name='service',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='instances', to='miscellaneous.MiscService', verbose_name='service'),
),
migrations.AlterField(
model_name='miscellaneous',
name='identifier',
field=orchestra.models.fields.NullableCharField(db_index=True, help_text='A unique identifier for this service.', max_length=256, null=True, unique=True, verbose_name='identifier'),
),
]

View File

@ -42,10 +42,10 @@ class MiscService(models.Model):
class Miscellaneous(models.Model): class Miscellaneous(models.Model):
service = models.ForeignKey(MiscService, on_delete=models.CASCADE, service = models.ForeignKey(MiscService, verbose_name=_("service"),
verbose_name=_("service"), related_name='instances') related_name='instances')
account = models.ForeignKey('accounts.Account', on_delete=models.CASCADE, account = models.ForeignKey('accounts.Account', verbose_name=_("account"),
verbose_name=_("account"), related_name='miscellaneous') related_name='miscellaneous')
identifier = NullableCharField(_("identifier"), max_length=256, null=True, unique=True, identifier = NullableCharField(_("identifier"), max_length=256, null=True, unique=True,
db_index=True, help_text=_("A unique identifier for this service.")) db_index=True, help_text=_("A unique identifier for this service."))
description = models.TextField(_("description"), blank=True) description = models.TextField(_("description"), blank=True)

View File

@ -39,10 +39,10 @@ class Operation():
self.routes = routes self.routes = routes
@classmethod @classmethod
def execute(cls, operations, serialize=False, run_async=None): def execute(cls, operations, serialize=False, async=None):
from . import manager from . import manager
scripts, backend_serialize = manager.generate(operations) scripts, backend_serialize = manager.generate(operations)
return manager.execute(scripts, serialize=(serialize or backend_serialize), run_async=run_async) return manager.execute(scripts, serialize=(serialize or backend_serialize), async=async)
@classmethod @classmethod
def create_for_action(cls, instances, action): def create_for_action(cls, instances, action):

View File

@ -30,14 +30,14 @@ STATE_COLORS = {
class RouteAdmin(ExtendedModelAdmin): class RouteAdmin(ExtendedModelAdmin):
list_display = ( list_display = (
'display_backend', 'host', 'match', 'display_model', 'display_actions', 'run_async', 'display_backend', 'host', 'match', 'display_model', 'display_actions', 'async',
'is_active' 'is_active'
) )
list_editable = ('host', 'match', 'run_async', 'is_active') list_editable = ('host', 'match', 'async', 'is_active')
list_filter = ('host', 'is_active', 'run_async', 'backend') list_filter = ('host', 'is_active', 'async', 'backend')
list_prefetch_related = ('host',) list_prefetch_related = ('host',)
ordering = ('backend',) ordering = ('backend',)
add_fields = ('backend', 'host', 'match', 'run_async', 'is_active') add_fields = ('backend', 'host', 'match', 'async', 'is_active')
change_form = RouteForm change_form = RouteForm
actions = (orchestrate,) actions = (orchestrate,)
change_view_actions = actions change_view_actions = actions
@ -51,18 +51,19 @@ class RouteAdmin(ExtendedModelAdmin):
def display_model(self, route): def display_model(self, route):
try: try:
return route.backend_class.model return escape(route.backend_class.model)
except KeyError: except KeyError:
return mark_safe("<span style='color: red;'>NOT AVAILABLE</span>") return "<span style='color: red;'>NOT AVAILABLE</span>"
display_model.short_description = _("model") display_model.short_description = _("model")
display_model.allow_tags = True
@mark_safe
def display_actions(self, route): def display_actions(self, route):
try: try:
return '<br>'.join(route.backend_class.get_actions()) return '<br>'.join(route.backend_class.get_actions())
except KeyError: except KeyError:
return "<span style='color: red;'>NOT AVAILABLE</span>" return "<span style='color: red;'>NOT AVAILABLE</span>"
display_actions.short_description = _("actions") display_actions.short_description = _("actions")
display_actions.allow_tags = True
def formfield_for_dbfield(self, db_field, **kwargs): def formfield_for_dbfield(self, db_field, **kwargs):
""" Provides dynamic help text on backend form field """ """ Provides dynamic help text on backend form field """
@ -119,6 +120,7 @@ class BackendOperationInline(admin.TabularInline):
return _("Deleted {0}").format(operation.instance_repr or '-'.join( return _("Deleted {0}").format(operation.instance_repr or '-'.join(
(escape(operation.content_type), escape(operation.object_id)))) (escape(operation.content_type), escape(operation.object_id))))
return link return link
instance_link.allow_tags = True
instance_link.short_description = _("Instance") instance_link.short_description = _("Instance")
def has_add_permission(self, *args, **kwargs): def has_add_permission(self, *args, **kwargs):
@ -177,12 +179,14 @@ class ServerAdmin(ExtendedModelAdmin):
change_view_actions = actions change_view_actions = actions
def display_ping(self, instance): def display_ping(self, instance):
return mark_safe(self._remote_state[instance.pk][0]) return self._remote_state[instance.pk][0]
display_ping.short_description = _("Ping") display_ping.short_description = _("Ping")
display_ping.allow_tags = True
def display_uptime(self, instance): def display_uptime(self, instance):
return mark_safe(self._remote_state[instance.pk][1]) return self._remote_state[instance.pk][1]
display_uptime.short_description = _("Uptime") display_uptime.short_description = _("Uptime")
display_uptime.allow_tags = True
def get_queryset(self, request): def get_queryset(self, request):
""" Order by structured name and imporve performance """ """ Order by structured name and imporve performance """

View File

@ -182,7 +182,7 @@ class ServiceBackend(plugins.Plugin, metaclass=ServiceMount):
log = manager.create(backend=self.get_name(), state=state, server=server) log = manager.create(backend=self.get_name(), state=state, server=server)
return log return log
def execute(self, server, run_async=False, log=None): def execute(self, server, async=False, log=None):
from .models import BackendLog from .models import BackendLog
if log is None: if log is None:
log = self.create_log(server) log = self.create_log(server)
@ -190,7 +190,7 @@ class ServiceBackend(plugins.Plugin, metaclass=ServiceMount):
if run: if run:
scripts = self.scripts scripts = self.scripts
for method, commands in scripts: for method, commands in scripts:
method(log, server, commands, run_async) method(log, server, commands, async)
if log.state != BackendLog.SUCCESS: if log.state != BackendLog.SUCCESS:
break break
return log return log

View File

@ -1,6 +1,6 @@
from django import forms from django import forms
from orchestra.forms.widgets import SpanWidget, PaddingCheckboxSelectMultiple from orchestra.forms.widgets import SpanWidget, paddingCheckboxSelectMultiple
class RouteForm(forms.ModelForm): class RouteForm(forms.ModelForm):
@ -16,5 +16,5 @@ class RouteForm(forms.ModelForm):
else: else:
self.fields['backend'].widget = SpanWidget() self.fields['backend'].widget = SpanWidget()
actions = backend_class.actions actions = backend_class.actions
self.fields['async_actions'].widget = PaddingCheckboxSelectMultiple(45) self.fields['async_actions'].widget = paddingCheckboxSelectMultiple(45)
self.fields['async_actions'].choices = ((action, action) for action in actions) self.fields['async_actions'].choices = ((action, action) for action in actions)

View File

@ -2,7 +2,7 @@ import textwrap
from django.contrib import messages from django.contrib import messages
from django.core.mail import mail_admins from django.core.mail import mail_admins
from django.urls import reverse, NoReverseMatch from django.core.urlresolvers import reverse, NoReverseMatch
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import ungettext, ugettext_lazy as _ from django.utils.translation import ungettext, ugettext_lazy as _
@ -105,7 +105,7 @@ def get_backend_url(ids):
def get_messages(logs): def get_messages(logs):
messages = [] messages = []
total, successes, run_async = 0, 0, 0 total, successes, async = 0, 0, 0
ids = [] ids = []
async_ids = [] async_ids = []
for log in logs: for log in logs:
@ -118,17 +118,17 @@ def get_messages(logs):
if log.is_success: if log.is_success:
successes += 1 successes += 1
elif not log.has_finished: elif not log.has_finished:
run_async += 1 async += 1
async_ids.append(log.id) async_ids.append(log.id)
errors = total-successes-run_async errors = total-successes-async
url = get_backend_url(ids) url = get_backend_url(ids)
async_url = get_backend_url(async_ids) async_url = get_backend_url(async_ids)
async_msg = '' async_msg = ''
if run_async: if async:
async_msg = ungettext( async_msg = ungettext(
_('<a href="{async_url}">{name}</a> is running on the background'), _('<a href="{async_url}">{name}</a> is running on the background'),
_('<a href="{async_url}">{run_async} backends</a> are running on the background'), _('<a href="{async_url}">{async} backends</a> are running on the background'),
run_async) async)
if errors: if errors:
if total == 1: if total == 1:
msg = _('<a href="{url}">{name}</a> has fail to execute') msg = _('<a href="{url}">{name}</a> has fail to execute')
@ -139,7 +139,7 @@ def get_messages(logs):
errors) errors)
if async_msg: if async_msg:
msg += ', ' + str(async_msg) msg += ', ' + str(async_msg)
msg = msg.format(errors=errors, run_async=run_async, async_url=async_url, total=total, url=url, msg = msg.format(errors=errors, async=async, async_url=async_url, total=total, url=url,
name=log.backend) name=log.backend)
messages.append(('error', msg + '.')) messages.append(('error', msg + '.'))
elif successes: elif successes:
@ -158,12 +158,12 @@ def get_messages(logs):
_('<a href="{url}">{total} backends</a> have been executed'), _('<a href="{url}">{total} backends</a> have been executed'),
total) total)
msg = msg.format( msg = msg.format(
total=total, url=url, async_url=async_url, run_async=run_async, successes=successes, total=total, url=url, async_url=async_url, async=async, successes=successes,
name=log.backend name=log.backend
) )
messages.append(('success', msg + '.')) messages.append(('success', msg + '.'))
else: else:
msg = async_msg.format(url=url, async_url=async_url, run_async=run_async, name=log.backend) msg = async_msg.format(url=url, async_url=async_url, async=async, name=log.backend)
messages.append(('success', msg + '.')) messages.append(('success', msg + '.'))
return messages return messages

View File

@ -116,7 +116,7 @@ class Command(BaseCommand):
if not confirm("\n\nAre your sure to execute the previous scripts on %(servers)s (yes/no)? " % context): if not confirm("\n\nAre your sure to execute the previous scripts on %(servers)s (yes/no)? " % context):
return return
if not dry: if not dry:
logs = manager.execute(scripts, serialize=serialize, run_async=True) logs = manager.execute(scripts, serialize=serialize, async=True)
running = list(logs) running = list(logs)
stdout = 0 stdout = 0
stderr = 0 stderr = 0

View File

@ -97,12 +97,12 @@ def generate(operations):
return scripts, serialize return scripts, serialize
def execute(scripts, serialize=False, run_async=None): def execute(scripts, serialize=False, async=None):
""" """
executes the operations on the servers executes the operations on the servers
serialize: execute one backend at a time serialize: execute one backend at a time
run_async: do not join threads (overrides route.run_async) async: do not join threads (overrides route.async)
""" """
if settings.ORCHESTRATION_DISABLE_EXECUTION: if settings.ORCHESTRATION_DISABLE_EXECUTION:
logger.info('Orchestration execution is dissabled by ORCHESTRATION_DISABLE_EXECUTION.') logger.info('Orchestration execution is dissabled by ORCHESTRATION_DISABLE_EXECUTION.')
@ -115,12 +115,12 @@ def execute(scripts, serialize=False, run_async=None):
route, __, async_action = key route, __, async_action = key
backend, operations = value backend, operations = value
args = (route.host,) args = (route.host,)
if run_async is None: if async is None:
is_async = not serialize and (route.run_async or async_action) is_async = not serialize and (route.async or async_action)
else: else:
is_async = not serialize and (run_async or async_action) is_async = not serialize and (async or async_action)
kwargs = { kwargs = {
'run_async': is_async, 'async': is_async,
} }
# we clone the connection just in case we are isolated inside a transaction # we clone the connection just in case we are isolated inside a transaction
with db.clone(model=BackendLog) as handle: with db.clone(model=BackendLog) as handle:

View File

@ -17,7 +17,7 @@ from . import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def Paramiko(backend, log, server, cmds, run_async=False, paramiko_connections={}): def Paramiko(backend, log, server, cmds, async=False, paramiko_connections={}):
""" """
Executes cmds to remote server using Pramaiko Executes cmds to remote server using Pramaiko
""" """
@ -55,7 +55,7 @@ def Paramiko(backend, log, server, cmds, run_async=False, paramiko_connections={
channel.shutdown_write() channel.shutdown_write()
# Log results # Log results
logger.debug('%s running on %s' % (backend, server)) logger.debug('%s running on %s' % (backend, server))
if run_async: if async:
second = False second = False
while True: while True:
# Non-blocking is the secret ingridient in the async sauce # Non-blocking is the secret ingridient in the async sauce
@ -97,7 +97,7 @@ def Paramiko(backend, log, server, cmds, run_async=False, paramiko_connections={
channel.close() channel.close()
def OpenSSH(backend, log, server, cmds, run_async=False): def OpenSSH(backend, log, server, cmds, async=False):
""" """
Executes cmds to remote server using SSH with connection resuse for maximum performance Executes cmds to remote server using SSH with connection resuse for maximum performance
""" """
@ -110,9 +110,9 @@ def OpenSSH(backend, log, server, cmds, run_async=False):
return return
try: try:
ssh = sshrun(server.get_address(), script, executable=backend.script_executable, ssh = sshrun(server.get_address(), script, executable=backend.script_executable,
persist=True, run_async=run_async, silent=True) persist=True, async=async, silent=True)
logger.debug('%s running on %s' % (backend, server)) logger.debug('%s running on %s' % (backend, server))
if run_async: if async:
for state in ssh: for state in ssh:
log.stdout += state.stdout.decode('utf8') log.stdout += state.stdout.decode('utf8')
log.stderr += state.stderr.decode('utf8') log.stderr += state.stderr.decode('utf8')
@ -148,7 +148,7 @@ def SSH(*args, **kwargs):
return method(*args, **kwargs) return method(*args, **kwargs)
def Python(backend, log, server, cmds, run_async=False): def Python(backend, log, server, cmds, async=False):
script = '' script = ''
functions = set() functions = set()
for cmd in cmds: for cmd in cmds:
@ -170,7 +170,7 @@ def Python(backend, log, server, cmds, run_async=False):
log.stdout += line + '\n' log.stdout += line + '\n'
if result: if result:
log.stdout += '# Result: %s\n' % result log.stdout += '# Result: %s\n' % result
if run_async: if async:
log.save(update_fields=('stdout', 'updated_at')) log.save(update_fields=('stdout', 'updated_at'))
except: except:
log.exit_code = 1 log.exit_code = 1

View File

@ -1,15 +1,15 @@
from threading import local from threading import local
from django.contrib.admin.models import LogEntry from django.contrib.admin.models import LogEntry
from django.core.urlresolvers import resolve
from django.db import transaction from django.db import transaction
from django.db.models.signals import m2m_changed, post_save, pre_delete from django.db.models.signals import pre_delete, post_save, m2m_changed
from django.dispatch import receiver from django.dispatch import receiver
from django.http.response import HttpResponseServerError from django.http.response import HttpResponseServerError
from django.urls import resolve
from django.utils.deprecation import MiddlewareMixin
from orchestra.utils.python import OrderedSet from orchestra.utils.python import OrderedSet
from . import Operation, manager from . import manager, Operation
from .helpers import message_user from .helpers import message_user
from .models import BackendLog, BackendOperation from .models import BackendLog, BackendOperation
@ -35,7 +35,7 @@ def m2m_collector(sender, *args, **kwargs):
OperationsMiddleware.collect(Operation.SAVE, **kwargs) OperationsMiddleware.collect(Operation.SAVE, **kwargs)
class OperationsMiddleware(MiddlewareMixin): class OperationsMiddleware(object):
""" """
Stores all the operations derived from save and delete signals and executes them Stores all the operations derived from save and delete signals and executes them
at the end of the request/response cycle at the end of the request/response cycle

View File

@ -1,7 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import django.db.models.deletion
from django.db import models, migrations from django.db import models, migrations
import orchestra.models.fields import orchestra.models.fields
@ -39,8 +38,8 @@ class Migration(migrations.Migration):
('backend', models.CharField(max_length=256, verbose_name='backend')), ('backend', models.CharField(max_length=256, verbose_name='backend')),
('action', models.CharField(max_length=64, verbose_name='action')), ('action', models.CharField(max_length=64, verbose_name='action')),
('object_id', models.PositiveIntegerField()), ('object_id', models.PositiveIntegerField()),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), ('content_type', models.ForeignKey(to='contenttypes.ContentType')),
('log', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='operations', to='orchestration.BackendLog')), ('log', models.ForeignKey(related_name='operations', to='orchestration.BackendLog')),
], ],
options={ options={
'verbose_name_plural': 'Operations', 'verbose_name_plural': 'Operations',
@ -69,12 +68,12 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='route', model_name='route',
name='host', name='host',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='orchestration.Server', verbose_name='host'), field=models.ForeignKey(to='orchestration.Server', verbose_name='host'),
), ),
migrations.AddField( migrations.AddField(
model_name='backendlog', model_name='backendlog',
name='server', name='server',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='execution_logs', to='orchestration.Server', verbose_name='server'), field=models.ForeignKey(related_name='execution_logs', to='orchestration.Server', verbose_name='server'),
), ),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
name='route', name='route',

View File

@ -1,149 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2021-04-22 11:27
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
import orchestra.core.validators
import orchestra.models.fields
class Migration(migrations.Migration):
replaces = [('orchestration', '0001_initial'), ('orchestration', '0002_auto_20150506_1420'), ('orchestration', '0003_auto_20150512_1512'), ('orchestration', '0004_route_async_actions'), ('orchestration', '0005_auto_20150709_1016'), ('orchestration', '0006_auto_20160219_1110'), ('orchestration', '0007_auto_20170528_2011'), ('orchestration', '0008_auto_20190805_1134'), ('orchestration', '0009_rename_route_async_run_async')]
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='BackendLog',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('backend', models.CharField(max_length=256, verbose_name='backend')),
('state', models.CharField(choices=[('RECEIVED', 'RECEIVED'), ('TIMEOUT', 'TIMEOUT'), ('STARTED', 'STARTED'), ('SUCCESS', 'SUCCESS'), ('FAILURE', 'FAILURE'), ('ERROR', 'ERROR'), ('ABORTED', 'ABORTED'), ('REVOKED', 'REVOKED')], default='RECEIVED', max_length=16, verbose_name='state')),
('script', models.TextField(verbose_name='script')),
('stdout', models.TextField(verbose_name='stdout')),
('stderr', models.TextField(verbose_name='stdin')),
('traceback', models.TextField(verbose_name='traceback')),
('exit_code', models.IntegerField(null=True, verbose_name='exit code')),
('task_id', models.CharField(help_text='Celery task ID when used as execution backend', max_length=36, null=True, unique=True, verbose_name='task ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated')),
],
options={
'get_latest_by': 'id',
},
),
migrations.CreateModel(
name='BackendOperation',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('backend', models.CharField(max_length=256, verbose_name='backend')),
('action', models.CharField(max_length=64, verbose_name='action')),
('object_id', models.PositiveIntegerField(null=True)),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
('log', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='operations', to='orchestration.BackendLog')),
('instance_repr', models.CharField(default='', max_length=256, verbose_name='instance representation')),
],
options={
'verbose_name_plural': 'Operations',
'verbose_name': 'Operation',
},
),
migrations.CreateModel(
name='Route',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('backend', models.CharField(choices=[('Apache2Traffic', '[M] Apache 2 Traffic'), ('DovecotMaildirDisk', '[M] Dovecot Maildir size'), ('Exim4Traffic', '[M] Exim4 traffic'), ('MailmanSubscribers', '[M] Mailman subscribers'), ('MailmanTraffic', '[M] Mailman traffic'), ('MysqlDisk', '[M] MySQL disk'), ('OpenVZTraffic', '[M] OpenVZTraffic'), ('PostfixMailscannerTraffic', '[M] Postfix-Mailscanner traffic'), ('UNIXUserDisk', '[M] UNIX user disk'), ('VsFTPdTraffic', '[M] VsFTPd traffic'), ('Apache2Controller', '[S] Apache 2'), ('BSCWController', '[S] BSCW SaaS'), ('Bind9MasterDomainController', '[S] Bind9 master domain'), ('Bind9SlaveDomainController', '[S] Bind9 slave domain'), ('DokuWikiMuController', '[S] DokuWiki multisite'), ('DovecotPostfixPasswdVirtualUserController', '[S] Dovecot-Postfix virtualuser'), ('DrupalMuController', '[S] Drupal multisite'), ('GitLabSaaSController', '[S] GitLab SaaS'), ('AutoresponseController', '[S] Mail autoresponse'), ('MailmanController', '[S] Mailman'), ('MySQLController', '[S] MySQL database'), ('MySQLUserController', '[S] MySQL user'), ('PHPController', '[S] PHP FPM/FCGID'), ('PostfixAddressController', '[S] Postfix address'), ('uWSGIPythonController', '[S] Python uWSGI'), ('StaticController', '[S] Static'), ('SymbolicLinkController', '[S] Symbolic link webapp'), ('UNIXUserMaildirController', '[S] UNIX maildir user'), ('UNIXUserController', '[S] UNIX user'), ('WebalizerAppController', '[S] Webalizer App'), ('WebalizerController', '[S] Webalizer Content'), ('WordPressController', '[S] Wordpress'), ('WordpressMuController', '[S] Wordpress multisite'), ('PhpListSaaSController', '[S] phpList SaaS')], max_length=256, verbose_name='backend')),
('match', models.CharField(blank=True, default='True', help_text='Python expression used for selecting the targe host, <em>instance</em> referes to the current object.', max_length=256, verbose_name='match')),
('is_active', models.BooleanField(default=True, verbose_name='active')),
],
),
migrations.CreateModel(
name='Server',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Verbose name or hostname of this server.', max_length=256, unique=True, verbose_name='name')),
('address', orchestra.models.fields.NullableCharField(blank=True, help_text='Optional IP address or domain name. If blank, name field will be used for address resolution.<br>If the IP address never changes you can set this field and save DNS requests.', max_length=256, null=True, unique=True, validators=[orchestra.core.validators.OrValidator(orchestra.core.validators.validate_ip_address, orchestra.core.validators.validate_hostname)], verbose_name='address')),
('description', models.TextField(blank=True, verbose_name='description')),
('os', models.CharField(choices=[('LINUX', 'Linux')], default='LINUX', max_length=32, verbose_name='operative system')),
],
),
migrations.AddField(
model_name='route',
name='host',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='orchestration.Server', verbose_name='host'),
),
migrations.AddField(
model_name='backendlog',
name='server',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='execution_logs', to='orchestration.Server', verbose_name='server'),
),
migrations.AddField(
model_name='route',
name='run_async',
field=models.BooleanField(default=False, help_text='Whether or not block the request/response cycle waitting this backend to finish its execution. Usually you want slave servers to run asynchronously.'),
),
migrations.AlterUniqueTogether(
name='route',
unique_together=set([('backend', 'host')]),
),
migrations.AlterField(
model_name='backendlog',
name='state',
field=models.CharField(choices=[('RECEIVED', 'RECEIVED'), ('TIMEOUT', 'TIMEOUT'), ('STARTED', 'STARTED'), ('SUCCESS', 'SUCCESS'), ('FAILURE', 'FAILURE'), ('ERROR', 'ERROR'), ('ABORTED', 'ABORTED'), ('REVOKED', 'REVOKED'), ('NOTHING', 'NOTHING')], default='RECEIVED', max_length=16, verbose_name='state'),
),
migrations.AlterField(
model_name='backendlog',
name='stderr',
field=models.TextField(verbose_name='stderr'),
),
migrations.AlterField(
model_name='route',
name='backend',
field=models.CharField(choices=[('Apache2Traffic', '[M] Apache 2 Traffic'), ('DovecotMaildirDisk', '[M] Dovecot Maildir size'), ('Exim4Traffic', '[M] Exim4 traffic'), ('MailmanSubscribers', '[M] Mailman subscribers'), ('MailmanTraffic', '[M] Mailman traffic'), ('MysqlDisk', '[M] MySQL disk'), ('OpenVZTraffic', '[M] OpenVZTraffic'), ('PostfixMailscannerTraffic', '[M] Postfix-Mailscanner traffic'), ('UNIXUserDisk', '[M] UNIX user disk'), ('VsFTPdTraffic', '[M] VsFTPd traffic'), ('Apache2Controller', '[S] Apache 2'), ('BSCWController', '[S] BSCW SaaS'), ('Bind9MasterDomainController', '[S] Bind9 master domain'), ('Bind9SlaveDomainController', '[S] Bind9 slave domain'), ('DokuWikiMuController', '[S] DokuWiki multisite'), ('DovecotPostfixPasswdVirtualUserController', '[S] Dovecot-Postfix virtualuser'), ('DrupalMuController', '[S] Drupal multisite'), ('GitLabSaaSController', '[S] GitLab SaaS'), ('AutoresponseController', '[S] Mail autoresponse'), ('MailmanController', '[S] Mailman'), ('MailmanVirtualDomainController', '[S] Mailman virtdomain-only'), ('MySQLController', '[S] MySQL database'), ('MySQLUserController', '[S] MySQL user'), ('PHPController', '[S] PHP FPM/FCGID'), ('PostfixAddressController', '[S] Postfix address'), ('PostfixAddressVirtualDomainController', '[S] Postfix address virtdomain-only'), ('uWSGIPythonController', '[S] Python uWSGI'), ('StaticController', '[S] Static'), ('SymbolicLinkController', '[S] Symbolic link webapp'), ('SyncBind9MasterDomainController', '[S] Sync Bind9 master domain'), ('SyncBind9SlaveDomainController', '[S] Sync Bind9 slave domain'), ('UNIXUserMaildirController', '[S] UNIX maildir user'), ('UNIXUserController', '[S] UNIX user'), ('WebalizerAppController', '[S] Webalizer App'), ('WebalizerController', '[S] Webalizer Content'), ('WordPressController', '[S] Wordpress'), ('WordpressMuController', '[S] Wordpress multisite'), ('PhpListSaaSController', '[S] phpList SaaS')], max_length=256, verbose_name='backend'),
),
migrations.AddField(
model_name='route',
name='async_actions',
field=orchestra.models.fields.MultiSelectField(blank=True, help_text='Specify individual actions to be executed asynchronoulsy.', max_length=256),
),
migrations.AlterField(
model_name='backendlog',
name='created_at',
field=models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created'),
),
migrations.AlterField(
model_name='route',
name='backend',
field=models.CharField(choices=[('Apache2Traffic', '[M] Apache 2 Traffic'), ('ApacheTrafficByName', '[M] ApacheTrafficByName'), ('DokuWikiMuTraffic', '[M] DokuWiki MU Traffic'), ('DovecotMaildirDisk', '[M] Dovecot Maildir size'), ('Exim4Traffic', '[M] Exim4 traffic'), ('MailmanSubscribers', '[M] Mailman subscribers'), ('MailmanTraffic', '[M] Mailman traffic'), ('MysqlDisk', '[M] MySQL disk'), ('OpenVZTraffic', '[M] OpenVZTraffic'), ('PostfixMailscannerTraffic', '[M] Postfix-Mailscanner traffic'), ('UNIXUserDisk', '[M] UNIX user disk'), ('VsFTPdTraffic', '[M] VsFTPd traffic'), ('WordpressMuTraffic', '[M] Wordpress MU Traffic'), ('OwnCloudDiskQuota', '[M] ownCloud SaaS Disk Quota'), ('OwncloudTraffic', '[M] ownCloud SaaS Traffic'), ('PhpListTraffic', '[M] phpList SaaS Traffic'), ('Apache2Controller', '[S] Apache 2'), ('BSCWController', '[S] BSCW SaaS'), ('Bind9MasterDomainController', '[S] Bind9 master domain'), ('Bind9SlaveDomainController', '[S] Bind9 slave domain'), ('DokuWikiMuController', '[S] DokuWiki multisite'), ('DrupalMuController', '[S] Drupal multisite'), ('GitLabSaaSController', '[S] GitLab SaaS'), ('AutoresponseController', '[S] Mail autoresponse'), ('MailScannerSpamRuleController', '[S] MailScanner ruleset'), ('MailmanController', '[S] Mailman'), ('MailmanVirtualDomainController', '[S] Mailman virtdomain-only'), ('MoodleController', '[S] Moodle'), ('MoodleWWWRootController', '[S] Moodle WWWRoot (required)'), ('MoodleMuController', '[S] Moodle multisite'), ('MySQLController', '[S] MySQL database'), ('MySQLUserController', '[S] MySQL user'), ('PHPController', '[S] PHP FPM/FCGID'), ('PangeaProxmoxOVZ', '[S] PangeaProxmoxOVZ'), ('PostfixAddressController', '[S] Postfix address'), ('PostfixAddressVirtualDomainController', '[S] Postfix address virtdomain-only'), ('PostfixRecipientAccessController', '[S] Postfix recipient access'), ('ProxmoxOVZ', '[S] ProxmoxOVZ'), ('uWSGIPythonController', '[S] Python uWSGI'), ('StaticController', '[S] Static'), ('SymbolicLinkController', '[S] Symbolic link webapp'), ('SyncBind9MasterDomainController', '[S] Sync Bind9 master domain'), ('SyncBind9SlaveDomainController', '[S] Sync Bind9 slave domain'), ('UNIXUserMaildirController', '[S] UNIX maildir user'), ('UNIXUserController', '[S] UNIX user'), ('WebalizerAppController', '[S] Webalizer App'), ('WebalizerController', '[S] Webalizer Content'), ('WordPressURLController', '[S] WordPress URL'), ('WordPressController', '[S] Wordpress'), ('WordpressMuController', '[S] Wordpress multisite'), ('OwnCloudController', '[S] ownCloud SaaS'), ('PhpListSaaSController', '[S] phpList SaaS')], max_length=256, verbose_name='backend'),
),
migrations.AlterIndexTogether(
name='backendoperation',
index_together=set([('content_type', 'object_id')]),
),
migrations.AlterField(
model_name='route',
name='backend',
field=models.CharField(choices=[('Apache2Traffic', '[M] Apache 2 Traffic'), ('ApacheTrafficByName', '[M] ApacheTrafficByName'), ('DokuWikiMuTraffic', '[M] DokuWiki MU Traffic'), ('DovecotMaildirDisk', '[M] Dovecot Maildir size'), ('Exim4Traffic', '[M] Exim4 traffic'), ('MailmanSubscribers', '[M] Mailman subscribers'), ('MailmanTraffic', '[M] Mailman traffic'), ('MysqlDisk', '[M] MySQL disk'), ('PostfixMailscannerTraffic', '[M] Postfix-Mailscanner traffic'), ('ProxmoxOpenVZTraffic', '[M] ProxmoxOpenVZTraffic'), ('UNIXUserDisk', '[M] UNIX user disk'), ('VsFTPdTraffic', '[M] VsFTPd traffic'), ('WordpressMuTraffic', '[M] Wordpress MU Traffic'), ('NextCloudDiskQuota', '[M] nextCloud SaaS Disk Quota'), ('NextcloudTraffic', '[M] nextCloud SaaS Traffic'), ('OwnCloudDiskQuota', '[M] ownCloud SaaS Disk Quota'), ('OwncloudTraffic', '[M] ownCloud SaaS Traffic'), ('PhpListTraffic', '[M] phpList SaaS Traffic'), ('Apache2Controller', '[S] Apache 2'), ('BSCWController', '[S] BSCW SaaS'), ('Bind9MasterDomainController', '[S] Bind9 master domain'), ('Bind9SlaveDomainController', '[S] Bind9 slave domain'), ('DokuWikiMuController', '[S] DokuWiki multisite'), ('DrupalMuController', '[S] Drupal multisite'), ('GitLabSaaSController', '[S] GitLab SaaS'), ('LetsEncryptController', "[S] Let's encrypt!"), ('LxcController', '[S] LxcController'), ('AutoresponseController', '[S] Mail autoresponse'), ('MailScannerSpamRuleController', '[S] MailScanner ruleset'), ('MailmanController', '[S] Mailman'), ('MailmanVirtualDomainController', '[S] Mailman virtdomain-only'), ('MoodleController', '[S] Moodle'), ('MoodleWWWRootController', '[S] Moodle WWWRoot (required)'), ('MoodleMuController', '[S] Moodle multisite'), ('MySQLController', '[S] MySQL database'), ('MySQLUserController', '[S] MySQL user'), ('PHPController', '[S] PHP FPM/FCGID'), ('PangeaProxmoxOVZ', '[S] PangeaProxmoxOVZ'), ('PostfixAddressController', '[S] Postfix address'), ('PostfixAddressVirtualDomainController', '[S] Postfix address virtdomain-only'), ('PostfixRecipientAccessController', '[S] Postfix recipient access'), ('ProxmoxOVZ', '[S] ProxmoxOVZ'), ('uWSGIPythonController', '[S] Python uWSGI'), ('StaticController', '[S] Static'), ('SymbolicLinkController', '[S] Symbolic link webapp'), ('SyncBind9MasterDomainController', '[S] Sync Bind9 master domain'), ('SyncBind9SlaveDomainController', '[S] Sync Bind9 slave domain'), ('UNIXUserMaildirController', '[S] UNIX maildir user'), ('UNIXUserController', '[S] UNIX user'), ('WebalizerAppController', '[S] Webalizer App'), ('WebalizerController', '[S] Webalizer Content'), ('WordPressForceSSLController', '[S] WordPress Force SSL'), ('WordPressURLController', '[S] WordPress URL'), ('WordPressController', '[S] Wordpress'), ('WordpressMuController', '[S] Wordpress multisite'), ('NextCloudController', '[S] nextCloud SaaS'), ('OwnCloudController', '[S] ownCloud SaaS'), ('PhpListSaaSController', '[S] phpList SaaS')], max_length=256, verbose_name='backend'),
),
migrations.AlterField(
model_name='route',
name='host',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='routes', to='orchestration.Server', verbose_name='host'),
),
migrations.AlterField(
model_name='route',
name='backend',
field=models.CharField(choices=[('Apache2Traffic', '[M] Apache 2 Traffic'), ('ApacheTrafficByName', '[M] ApacheTrafficByName'), ('DokuWikiMuTraffic', '[M] DokuWiki MU Traffic'), ('DovecotMaildirDisk', '[M] Dovecot Maildir size'), ('Exim4Traffic', '[M] Exim4 traffic'), ('MailmanSubscribers', '[M] Mailman subscribers'), ('MailmanTraffic', '[M] Mailman traffic'), ('MysqlDisk', '[M] MySQL disk'), ('PostfixMailscannerTraffic', '[M] Postfix-Mailscanner traffic'), ('ProxmoxOpenVZTraffic', '[M] ProxmoxOpenVZTraffic'), ('UNIXUserDisk', '[M] UNIX user disk'), ('VsFTPdTraffic', '[M] VsFTPd traffic'), ('WordpressMuTraffic', '[M] Wordpress MU Traffic'), ('NextCloudDiskQuota', '[M] nextCloud SaaS Disk Quota'), ('NextcloudTraffic', '[M] nextCloud SaaS Traffic'), ('OwnCloudDiskQuota', '[M] ownCloud SaaS Disk Quota'), ('OwncloudTraffic', '[M] ownCloud SaaS Traffic'), ('PhpListTraffic', '[M] phpList SaaS Traffic'), ('Apache2Controller', '[S] Apache 2'), ('BSCWController', '[S] BSCW SaaS'), ('Bind9MasterDomainController', '[S] Bind9 master domain'), ('Bind9SlaveDomainController', '[S] Bind9 slave domain'), ('DokuWikiMuController', '[S] DokuWiki multisite'), ('DrupalMuController', '[S] Drupal multisite'), ('GitLabSaaSController', '[S] GitLab SaaS'), ('LetsEncryptController', "[S] Let's encrypt!"), ('LxcController', '[S] LxcController'), ('AutoresponseController', '[S] Mail autoresponse'), ('MailScannerSpamRuleController', '[S] MailScanner ruleset'), ('MailmanController', '[S] Mailman'), ('MailmanVirtualDomainController', '[S] Mailman virtdomain-only'), ('MoodleController', '[S] Moodle'), ('MoodleWWWRootController', '[S] Moodle WWWRoot (required)'), ('MoodleMuController', '[S] Moodle multisite'), ('MySQLController', '[S] MySQL database'), ('MySQLUserController', '[S] MySQL user'), ('PHPController', '[S] PHP FPM/FCGID'), ('PangeaProxmoxOVZ', '[S] PangeaProxmoxOVZ'), ('PostfixAddressController', '[S] Postfix address'), ('PostfixAddressVirtualDomainController', '[S] Postfix address virtdomain-only'), ('PostfixRecipientAccessController', '[S] Postfix recipient access'), ('ProxmoxOVZ', '[S] ProxmoxOVZ'), ('uWSGIPythonController', '[S] Python uWSGI'), ('RoundcubeIdentityController', '[S] Roundcube Identity Controller'), ('StaticController', '[S] Static'), ('SymbolicLinkController', '[S] Symbolic link webapp'), ('SyncBind9MasterDomainController', '[S] Sync Bind9 master domain'), ('SyncBind9SlaveDomainController', '[S] Sync Bind9 slave domain'), ('UNIXUserMaildirController', '[S] UNIX maildir user'), ('UNIXUserController', '[S] UNIX user'), ('WebalizerAppController', '[S] Webalizer App'), ('WebalizerController', '[S] Webalizer Content'), ('WordPressForceSSLController', '[S] WordPress Force SSL'), ('WordPressURLController', '[S] WordPress URL'), ('WordPressController', '[S] Wordpress'), ('WordpressMuController', '[S] Wordpress multisite'), ('NextCloudController', '[S] nextCloud SaaS'), ('OwnCloudController', '[S] ownCloud SaaS'), ('PhpListSaaSController', '[S] phpList SaaS')], max_length=256, verbose_name='backend'),
),
migrations.AlterField(
model_name='route',
name='backend',
field=models.CharField(choices=[('Apache2Traffic', '[M] Apache 2 Traffic'), ('ApacheTrafficByName', '[M] ApacheTrafficByName'), ('DokuWikiMuTraffic', '[M] DokuWiki MU Traffic'), ('DovecotMaildirDisk', '[M] Dovecot Maildir size'), ('Exim4Traffic', '[M] Exim4 traffic'), ('MailmanSubscribers', '[M] Mailman subscribers'), ('MailmanTraffic', '[M] Mailman traffic'), ('MysqlDisk', '[M] MySQL disk'), ('PostfixMailscannerTraffic', '[M] Postfix-Mailscanner traffic'), ('ProxmoxOpenVZTraffic', '[M] ProxmoxOpenVZTraffic'), ('UNIXUserDisk', '[M] UNIX user disk'), ('VsFTPdTraffic', '[M] VsFTPd traffic'), ('WordpressMuTraffic', '[M] Wordpress MU Traffic'), ('NextCloudDiskQuota', '[M] nextCloud SaaS Disk Quota'), ('NextcloudTraffic', '[M] nextCloud SaaS Traffic'), ('OwnCloudDiskQuota', '[M] ownCloud SaaS Disk Quota'), ('OwncloudTraffic', '[M] ownCloud SaaS Traffic'), ('PhpListTraffic', '[M] phpList SaaS Traffic'), ('Apache2Controller', '[S] Apache 2'), ('BSCWController', '[S] BSCW SaaS'), ('Bind9MasterDomainController', '[S] Bind9 master domain'), ('Bind9SlaveDomainController', '[S] Bind9 slave domain'), ('DokuWikiMuController', '[S] DokuWiki multisite'), ('DrupalMuController', '[S] Drupal multisite'), ('GitLabSaaSController', '[S] GitLab SaaS'), ('LetsEncryptController', "[S] Let's encrypt!"), ('LxcController', '[S] LxcController'), ('AutoresponseController', '[S] Mail autoresponse'), ('MailmanController', '[S] Mailman'), ('MailmanVirtualDomainController', '[S] Mailman virtdomain-only'), ('MoodleController', '[S] Moodle'), ('MoodleWWWRootController', '[S] Moodle WWWRoot (required)'), ('MoodleMuController', '[S] Moodle multisite'), ('MySQLController', '[S] MySQL database'), ('MySQLUserController', '[S] MySQL user'), ('PHPController', '[S] PHP FPM/FCGID'), ('PostfixAddressController', '[S] Postfix address'), ('PostfixAddressVirtualDomainController', '[S] Postfix address virtdomain-only'), ('ProxmoxOVZ', '[S] ProxmoxOVZ'), ('uWSGIPythonController', '[S] Python uWSGI'), ('RoundcubeIdentityController', '[S] Roundcube Identity Controller'), ('StaticController', '[S] Static'), ('SymbolicLinkController', '[S] Symbolic link webapp'), ('UNIXUserMaildirController', '[S] UNIX maildir user'), ('UNIXUserController', '[S] UNIX user'), ('WebalizerAppController', '[S] Webalizer App'), ('WebalizerController', '[S] Webalizer Content'), ('WordPressForceSSLController', '[S] WordPress Force SSL'), ('WordPressURLController', '[S] WordPress URL'), ('WordPressController', '[S] Wordpress'), ('WordpressMuController', '[S] Wordpress multisite'), ('NextCloudController', '[S] nextCloud SaaS'), ('OwnCloudController', '[S] ownCloud SaaS'), ('PhpListSaaSController', '[S] phpList SaaS')], max_length=256, verbose_name='backend'),
),
]

View File

@ -1,25 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2021-03-30 10:49
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orchestration', '0008_auto_20190805_1134'),
]
operations = [
migrations.RenameField(
model_name='route',
old_name='async',
new_name='run_async',
),
migrations.AlterField(
model_name='route',
name='backend',
field=models.CharField(choices=[('Apache2Traffic', '[M] Apache 2 Traffic'), ('ApacheTrafficByName', '[M] ApacheTrafficByName'), ('DokuWikiMuTraffic', '[M] DokuWiki MU Traffic'), ('DovecotMaildirDisk', '[M] Dovecot Maildir size'), ('Exim4Traffic', '[M] Exim4 traffic'), ('MailmanSubscribers', '[M] Mailman subscribers'), ('MailmanTraffic', '[M] Mailman traffic'), ('MysqlDisk', '[M] MySQL disk'), ('PostfixMailscannerTraffic', '[M] Postfix-Mailscanner traffic'), ('ProxmoxOpenVZTraffic', '[M] ProxmoxOpenVZTraffic'), ('UNIXUserDisk', '[M] UNIX user disk'), ('VsFTPdTraffic', '[M] VsFTPd traffic'), ('WordpressMuTraffic', '[M] Wordpress MU Traffic'), ('NextCloudDiskQuota', '[M] nextCloud SaaS Disk Quota'), ('NextcloudTraffic', '[M] nextCloud SaaS Traffic'), ('OwnCloudDiskQuota', '[M] ownCloud SaaS Disk Quota'), ('OwncloudTraffic', '[M] ownCloud SaaS Traffic'), ('PhpListTraffic', '[M] phpList SaaS Traffic'), ('Apache2Controller', '[S] Apache 2'), ('BSCWController', '[S] BSCW SaaS'), ('Bind9MasterDomainController', '[S] Bind9 master domain'), ('Bind9SlaveDomainController', '[S] Bind9 slave domain'), ('DokuWikiMuController', '[S] DokuWiki multisite'), ('DrupalMuController', '[S] Drupal multisite'), ('GitLabSaaSController', '[S] GitLab SaaS'), ('LetsEncryptController', "[S] Let's encrypt!"), ('LxcController', '[S] LxcController'), ('AutoresponseController', '[S] Mail autoresponse'), ('MailmanController', '[S] Mailman'), ('MailmanVirtualDomainController', '[S] Mailman virtdomain-only'), ('MoodleController', '[S] Moodle'), ('MoodleWWWRootController', '[S] Moodle WWWRoot (required)'), ('MoodleMuController', '[S] Moodle multisite'), ('MySQLController', '[S] MySQL database'), ('MySQLUserController', '[S] MySQL user'), ('PHPController', '[S] PHP FPM/FCGID'), ('PostfixAddressController', '[S] Postfix address'), ('PostfixAddressVirtualDomainController', '[S] Postfix address virtdomain-only'), ('ProxmoxOVZ', '[S] ProxmoxOVZ'), ('uWSGIPythonController', '[S] Python uWSGI'), ('RoundcubeIdentityController', '[S] Roundcube Identity Controller'), ('StaticController', '[S] Static'), ('SymbolicLinkController', '[S] Symbolic link webapp'), ('UNIXUserMaildirController', '[S] UNIX maildir user'), ('UNIXUserController', '[S] UNIX user'), ('WebalizerAppController', '[S] Webalizer App'), ('WebalizerController', '[S] Webalizer Content'), ('WordPressForceSSLController', '[S] WordPress Force SSL'), ('WordPressURLController', '[S] WordPress URL'), ('WordPressController', '[S] Wordpress'), ('WordpressMuController', '[S] Wordpress multisite'), ('NextCloudController', '[S] nextCloud SaaS'), ('OwnCloudController', '[S] ownCloud SaaS'), ('PhpListSaaSController', '[S] phpList SaaS')], max_length=256, verbose_name='backend'),
),
]

View File

@ -51,9 +51,8 @@ class Server(models.Model):
def clean(self): def clean(self):
self.name = self.name.strip() self.name = self.name.strip()
if self.address: self.address = self.address.strip()
self.address = self.address.strip() if self.name and not self.address:
elif self.name:
validate = OrValidator(validate_ip_address, validate_hostname) validate = OrValidator(validate_ip_address, validate_hostname)
validate_hostname(self.name) validate_hostname(self.name)
try: try:
@ -91,7 +90,7 @@ class BackendLog(models.Model):
backend = models.CharField(_("backend"), max_length=256) backend = models.CharField(_("backend"), max_length=256)
state = models.CharField(_("state"), max_length=16, choices=STATES, default=RECEIVED) state = models.CharField(_("state"), max_length=16, choices=STATES, default=RECEIVED)
server = models.ForeignKey(Server, verbose_name=_("server"), related_name='execution_logs', on_delete=models.CASCADE) server = models.ForeignKey(Server, verbose_name=_("server"), related_name='execution_logs')
script = models.TextField(_("script")) script = models.TextField(_("script"))
stdout = models.TextField(_("stdout")) stdout = models.TextField(_("stdout"))
stderr = models.TextField(_("stderr")) stderr = models.TextField(_("stderr"))
@ -136,10 +135,10 @@ class BackendOperation(models.Model):
""" """
Encapsulates an operation, storing its related object, the action and the backend. Encapsulates an operation, storing its related object, the action and the backend.
""" """
log = models.ForeignKey('orchestration.BackendLog', related_name='operations', on_delete=models.CASCADE) log = models.ForeignKey('orchestration.BackendLog', related_name='operations')
backend = models.CharField(_("backend"), max_length=256) backend = models.CharField(_("backend"), max_length=256)
action = models.CharField(_("action"), max_length=64) action = models.CharField(_("action"), max_length=64)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) content_type = models.ForeignKey(ContentType)
object_id = models.PositiveIntegerField(null=True) object_id = models.PositiveIntegerField(null=True)
instance_repr = models.CharField(_("instance representation"), max_length=256) instance_repr = models.CharField(_("instance representation"), max_length=256)
@ -200,11 +199,11 @@ class Route(models.Model):
""" """
backend = models.CharField(_("backend"), max_length=256, backend = models.CharField(_("backend"), max_length=256,
choices=ServiceBackend.get_choices()) choices=ServiceBackend.get_choices())
host = models.ForeignKey(Server, verbose_name=_("host"), related_name='routes', on_delete=models.CASCADE) host = models.ForeignKey(Server, verbose_name=_("host"), related_name='routes')
match = models.CharField(_("match"), max_length=256, blank=True, default='True', match = models.CharField(_("match"), max_length=256, blank=True, default='True',
help_text=_("Python expression used for selecting the targe host, " help_text=_("Python expression used for selecting the targe host, "
"<em>instance</em> referes to the current object.")) "<em>instance</em> referes to the current object."))
run_async = models.BooleanField(default=False, async = models.BooleanField(default=False,
help_text=_("Whether or not block the request/response cycle waitting this backend to " help_text=_("Whether or not block the request/response cycle waitting this backend to "
"finish its execution. Usually you want slave servers to run asynchronously.")) "finish its execution. Usually you want slave servers to run asynchronously."))
async_actions = MultiSelectField(max_length=256, blank=True, async_actions = MultiSelectField(max_length=256, blank=True,

View File

@ -12,7 +12,7 @@ class RouterTests(BaseTestCase):
def test_list_backends(self): def test_list_backends(self):
# TODO count actual, register and compare # TODO count actual, register and compare
choices = list(Route._meta.get_field('backend').choices) choices = list(Route._meta.get_field('backend')._choices)
self.assertLess(1, len(choices)) self.assertLess(1, len(choices))
def test_get_instances(self): def test_get_instances(self):
@ -25,7 +25,7 @@ class RouterTests(BaseTestCase):
pass pass
choices = backends.ServiceBackend.get_choices() choices = backends.ServiceBackend.get_choices()
Route._meta.get_field('backend').choices = choices Route._meta.get_field('backend')._choices = choices
backend = TestBackend.get_name() backend = TestBackend.get_name()
route = Route.objects.create(backend=backend, host=self.host, match='True') route = Route.objects.create(backend=backend, host=self.host, match='True')

View File

@ -6,20 +6,15 @@ def retrieve_state(servers):
pings = [] pings = []
for server in servers: for server in servers:
address = server.get_address() address = server.get_address()
ping = run('ping -c 1 -w 1 %s' % address, run_async=True) ping = run('ping -c 1 -w 1 %s' % address, async=True)
pings.append(ping) pings.append(ping)
uptime = sshrun(address, 'uptime', persist=True, run_async=True, options={'ConnectTimeout': 1}) uptime = sshrun(address, 'uptime', persist=True, async=True, options={'ConnectTimeout': 1})
uptimes.append(uptime) uptimes.append(uptime)
state = {} state = {}
for server, ping, uptime in zip(servers, pings, uptimes): for server, ping, uptime in zip(servers, pings, uptimes):
ping = join(ping, silent=True) ping = join(ping, silent=True)
ping = ping.stdout.splitlines()[-1].decode()
try:
ping = ping.stdout.splitlines()[-1].decode()
except IndexError:
ping = ''
if ping.startswith('rtt'): if ping.startswith('rtt'):
ping = '%s ms' % ping.split('/')[4] ping = '%s ms' % ping.split('/')[4]
else: else:

View File

@ -1,5 +1,5 @@
from django.contrib import admin, messages from django.contrib import admin, messages
from django.urls import reverse from django.core.urlresolvers import reverse
from django.db import transaction from django.db import transaction
from django.utils import timezone from django.utils import timezone
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe

View File

@ -1,10 +1,9 @@
from datetime import datetime
from django import forms from django import forms
from django.contrib import admin from django.contrib import admin
from django.urls import reverse, NoReverseMatch from django.core.urlresolvers import reverse, NoReverseMatch
from django.db.models import Prefetch from django.db.models import Prefetch
from django.utils import timezone from django.utils import timezone
from django.utils.html import escape, format_html from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -113,8 +112,9 @@ class OrderAdmin(AccountAdminMixin, ExtendedModelAdmin):
display_cancelled_on = admin_date('cancelled_on') display_cancelled_on = admin_date('cancelled_on')
def display_description(self, order): def display_description(self, order):
return format_html(order.description[:64]) return order.description[:64]
display_description.short_description = _("Description") display_description.short_description = _("Description")
display_description.allow_tags = True
display_description.admin_order_field = 'description' display_description.admin_order_field = 'description'
def content_object_link(self, order): def content_object_link(self, order):
@ -125,13 +125,13 @@ class OrderAdmin(AccountAdminMixin, ExtendedModelAdmin):
# Does not has admin # Does not has admin
return order.content_object_repr return order.content_object_repr
description = str(order.content_object) description = str(order.content_object)
return format_html('<a href="{url}">{description}</a>', return '<a href="{url}">{description}</a>'.format(
url=url, description=description) url=url, description=description)
return order.content_object_repr return order.content_object_repr
content_object_link.short_description = _("Content object") content_object_link.short_description = _("Content object")
content_object_link.allow_tags = True
content_object_link.admin_order_field = 'content_object_repr' content_object_link.admin_order_field = 'content_object_repr'
@mark_safe
def bills_links(self, order): def bills_links(self, order):
bills = [] bills = []
make_link = admin_link() make_link = admin_link()
@ -139,6 +139,7 @@ class OrderAdmin(AccountAdminMixin, ExtendedModelAdmin):
bills.append(make_link(line.bill)) bills.append(make_link(line.bill))
return '<br>'.join(bills) return '<br>'.join(bills)
bills_links.short_description = _("Bills") bills_links.short_description = _("Bills")
bills_links.allow_tags = True
def display_billed_until(self, order): def display_billed_until(self, order):
billed_until = order.billed_until billed_until = order.billed_until
@ -155,12 +156,12 @@ class OrderAdmin(AccountAdminMixin, ExtendedModelAdmin):
red = True red = True
elif billed_until < timezone.now().date(): elif billed_until < timezone.now().date():
red = True red = True
color = mark_safe('style="color:red;"') if red else '' color = 'style="color:red;"' if red else ''
return format_html( return '<span title="{raw}" {color}>{human}</span>'.format(
'<span title="{raw}" {color}>{human}</span>',
raw=escape(str(billed_until)), color=color, human=human, raw=escape(str(billed_until)), color=color, human=human,
) )
display_billed_until.short_description = _("billed until") display_billed_until.short_description = _("billed until")
display_billed_until.allow_tags = True
display_billed_until.admin_order_field = 'billed_until' display_billed_until.admin_order_field = 'billed_until'
def display_metric(self, order): def display_metric(self, order):

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