Compare commits

..

98 Commits

Author SHA1 Message Date
Santiago L 5ab4779e1a Fix kombu dependencies on requirements.txt 2022-01-18 15:05:45 +01:00
Santiago L 5e6cd2f147
Merge pull request #12 from ribaguifi/dev/api-writable
Update some API endpoints to make it writable
2021-11-24 11:03:17 +01:00
Santiago L 03666d8ed0 Filter related addresses by account 2021-10-14 13:03:08 +02:00
Santiago L e88e27a56e Make MailboxViewSet writable: create & update 2021-10-07 14:14:21 +02:00
Santiago L 9a4f4ee17c Fix SetPasswordHyperlinkedSerializer (update to new DRF) 2021-10-07 14:11:50 +02:00
Santiago L 008f49100f Fix display_mailboxes format (mark HTML as safe) 2021-09-24 13:54:34 +02:00
Santiago L b0f77ad591 Merge branch 'master' into dev/api-writable 2021-07-13 13:25:00 +02:00
Santiago L 639ecdde58
Merge pull request #9 from ribaguifi/dev/django2.2
Upgrade to Django 2.2.X series
2021-07-13 13:20:30 +02:00
Santiago L 361b4b41a8 Upgrade Pygments and phonenumbers libraries 2021-07-13 12:55:35 +02:00
Santiago L 6720df314b Bump jsonfield version to 3.1.0 2021-07-13 12:46:20 +02:00
Santiago L 9f80c75da7 Drop unused python-ecdsa dependency 2021-07-13 12:09:29 +02:00
Santiago L 1258a27688 Bump django-localflavor version to 3.1
See related issue #10
2021-07-13 12:01:27 +02:00
Santiago L a400c25de9 Upgrade django-filter & django-extensions 2021-07-13 11:48:41 +02:00
Santiago L e3ec82a182 Bump django-rest-framework version to 3.12.4 2021-07-13 11:24:07 +02:00
Santiago L cda47e2fb6 Remove deprecated passlib setting __vary_rounds
Deprecated since version 1.7 and will be removed in 2.0
The (very minimal) security benefit it provides was judged to not
be worth code complexity it requires.
2021-07-09 13:03:53 +02:00
Santiago L d3e5ea59a9 Bump passlib version to 1.7.4 2021-07-09 12:34:43 +02:00
Santiago L b37d9cc515 Remove unneeded custom 'delete_selected'
`has_delete_permission` already avoids deleting main system users
2021-07-08 14:48:51 +02:00
Santiago L 1faab905d6 Remove duplicated 'delete_selected' action of TicketAdmin
Fixes admin.E130 error
2021-07-08 13:58:37 +02:00
Santiago L de26baf75a Refactor TransactionProcessAdmin.delete_selected override
Override `delete_queryset` instead of overriding `delete_selected`
action. Fixes admin.E130 error.
Related ticket https://github.com/django/django/pull/10603
2021-07-08 13:49:24 +02:00
Santiago L 50f916fa4d Replace `base_name` => `basename` for consisntency's sake
Changed on DRF 3.12.x and related to PR https://github.com/encode/django-rest-framework/pull/5990
2021-07-08 12:46:55 +02:00
Santiago L c21a52a756 Bump django-rest-framework version to 3.12.2 2021-07-08 12:46:17 +02:00
Santiago L a90e500186 Bump Django version to 2.2.24 2021-07-08 12:32:53 +02:00
Santiago L 7d6a2474ab Handle missing url attribute on write requests 2021-07-08 12:25:29 +02:00
Santiago L b365580165 Merge branch 'master' into dev/api-writable 2021-06-22 14:13:28 +02:00
Santiago L bcfed9cb79 Use BaseCommand on orchestraversion
class NoArgsCommand has been removed on Django 1.10
2021-06-22 14:11:06 +02:00
Santiago L 867d9afe65 Make /aoi/addresses/ endpoint writable 2021-06-18 11:11:50 +02:00
Santiago L e1d71fa620 Add support to create Address via API 2021-06-08 13:37:00 +02:00
Santiago L 70f7551e7d Replace Router.get_default_base_name by Router.get_default_basename
Deprecated in DRF 3.9.0
2021-06-08 13:34:36 +02:00
Santiago L 81c67778e5 Fix RelatedDomainSerializer model
Regression introduced by 7d975637d5
partially fixed on 48ef1f21e3
2021-06-08 12:58:36 +02:00
Santiago L 9a3b6dcbc3 Add 'exclude' attribute to TransactionSerializer
Creating a ModelSerializer without either the 'fields' attribute or the
'exclude' attribute has been deprecated since 3.3.0
2021-06-08 10:23:04 +02:00
Santiago L 5e7a823205 Revert "documentation ribaguifi style instalation"
This reverts commit 5b4b7310e6.
Remove duplicated project settings template.
2021-06-08 10:05:40 +02:00
Santiago L e1224ddd57 Add django_filters to INSTALLED_APPS
Fix TemplateDoesNotExist django_filters/rest_framework/form.html
2021-06-08 10:02:59 +02:00
Santiago L 7b59931bcf
Merge pull request #8 from ribaguifi/dev/django2.1-admin
Refactor admin code to support Django 2.1
2021-05-24 12:55:35 +02:00
Santiago L 0e10d2b142 Bump python-dateutil to 2.7.0+ 2021-05-24 12:53:50 +02:00
Santiago L 47eb0f1efe Rename local var display because shadows built-in 2021-05-24 12:37:36 +02:00
Santiago L 28c03ac6c8 Handle HTML safe rendering on accounts, bills & payments
Drop `allow_tags` attribute which has been removed on Django 2.0
2021-05-24 12:36:49 +02:00
Santiago L 9953124a95 Replace Context by dict
Since Django 1.10 template objects returned by get_template() and
select_template() no longer accept a Context in their render() method.
2021-05-24 11:19:30 +02:00
Santiago L 06c226d302 Handle HTML safe rendering on webapps & miscellaneous
Drop `allow_tags` attribute which has been removed on Django 2.0
2021-05-21 11:17:06 +02:00
Santiago L 4f695c2e6e Handle HTML safe rendering on orchestration, resources & history
Drop `allow_tags` attribute which has been removed on Django 2.0
2021-05-21 10:47:27 +02:00
Santiago L e6495a967b Handle HTML safe rendering on issues, plans & saas
Drop `allow_tags` attribute which has been removed on Django 2.0
2021-05-21 10:07:59 +02:00
Santiago L 6d8a2ced53 Context shoud be dict on render_email_template()
template.Context intance is no longer accepted
2021-05-20 14:08:09 +02:00
Santiago L a2927f7616 Add required param `renderer` to MarkDownWidget 2021-05-20 14:02:10 +02:00
Santiago L f13fea5030 Fix display format on accounts, databases...
domains, mailboxes & mailer

Drop `allow_tags` attribute which has been removed on Django 2.0
2021-05-20 13:58:16 +02:00
Santiago L f0683660ae Fix display format on bills, orders & services
Drop `allow_tags` attribute which has been removed on Django 2.0
2021-05-17 14:15:12 +02:00
Santiago L b24ddf7546 Handle empty ping response 2021-05-17 13:22:08 +02:00
Santiago L 3b4bb51925 Fix display format on SaaS & Sever admin list
mark_safe generated HTML
2021-05-17 13:20:18 +02:00
Santiago L a6c5aa32df Fix Mailbox creation.
Direct assignment to the reverse side of a many-to-many set is
prohibited. Use addresses.set() instead.
2021-05-17 12:54:16 +02:00
Santiago L 13b4ac5eee Add required param `renderer` to ReadOnlyPasswordHashWidget 2021-05-13 14:42:05 +02:00
Santiago L 8dc792b851 Fix render() of PaddingCheckboxSelectMultiple widget 2021-05-13 12:37:17 +02:00
Santiago L 5a21f766b4 Add required param `renderer` to Widget.render
Added on Django 1.11 and required since 2.1
The renderer argument is added to the Widget.render() method.
https://docs.djangoproject.com/en/2.1/releases/1.11/#id2
2021-05-13 11:52:34 +02:00
Santiago L 7183174f4c Handle empty address on Server.clean() 2021-05-13 10:57:48 +02:00
Santiago L 48ef1f21e3 Navigate through FK field to related model
Fix regression introduced by 7d975637d5
when there is a misunderstanding while replacing deprecated rel.to
2021-05-12 14:38:17 +02:00
Santiago L aebbd424fc Fix admin list_display with HTML content 2021-05-12 14:16:28 +02:00
Santiago L 5389f425ce mark_safe display_websites & display_addresses 2021-05-12 13:55:47 +02:00
Santiago L ed9bfc0eb7 Merge branch 'dev/django2.1-middleware' 2021-05-11 14:20:51 +02:00
Santiago L 0095da61ea Bump to Markdown==3.3.4
Required by Django Rest Framework
2021-05-11 14:02:53 +02:00
Santiago L 58be94bde2 Upgrade orchestra middlewares
Refactor to changes introduced on Django 1.10
https://docs.djangoproject.com/en/2.1/topics/http/middleware/#upgrading-pre-django-1-10-style-middleware
2021-05-11 14:00:41 +02:00
Santiago L be5e06129a Add password validation settings
New on Django 1.9
2021-05-11 13:48:26 +02:00
Santiago L 69df9780bf Update setting MIDDLEWARE_CLASSES to MIDDLEWARE 2021-05-11 13:47:33 +02:00
Santiago L 18a41d507b
Merge pull request #7 from ribaguifi/dev/django2.1
Upgrade to Django 2.1
2021-05-11 13:06:52 +02:00
Santiago L f7627926cb Replace detail_route with action decorator
DRF 3.10.0 deprecates the detail_route decorator in favor of action
2021-05-06 13:08:21 +02:00
Santiago L ffd08459c4 Bump to django-rest-framework 3.10.3 2021-05-06 13:07:56 +02:00
Santiago L 085b8f85bd Bump to django-filter 2.2.0
Compatible with Django 2.1
2021-05-06 12:58:54 +02:00
Santiago L d5fce3b6e2 Replace string_concat() with format_lazy()
On Django 1.11 django.utils.translation.string_concat() is
deprecated in favor of django.utils.text.format_lazy()
and it has been removed on Django 2.1
2021-05-06 12:07:17 +02:00
Santiago L 777a7f6de5 Bump to Django 2.1 2021-05-06 12:06:31 +02:00
Santiago L 422305a636
Merge pull request #6 from ribaguifi/dev/python36support
Refactor to support Python 3.6 and Django 2.0
2021-05-06 11:08:46 +02:00
Santiago L d6cebf66a2 DjangoFilterBackend moved to django_filters 2021-05-06 10:57:11 +02:00
Santiago L 0338b927cf query.order_by is a tuple, update value to compare 2021-04-22 14:51:03 +02:00
Santiago L 97f1c7ef2b Replace field.rel.to with field.remote_field.model
Field.rel and Field.remote_field.to are removed in Django 2.x
2021-04-22 14:44:47 +02:00
Santiago L b6cf0c34f5 Call User.is_authenticated and User.is_anonymous as properties 2021-04-22 14:31:05 +02:00
Santiago L 7fa7106d72 Update migrations to include mandatory on_delete
Django 2.0
2021-04-22 14:18:01 +02:00
Santiago L 6ef7f921e9 Upgrade requirements to Django 2.0
Update too related dependencies.
2021-04-22 13:40:54 +02:00
Santiago L a8b17da992 Squash migrations 2021-04-22 13:29:09 +02:00
Santiago L c689a6e44c Fix Message.author on_cascade because cannot be null. 2021-04-22 10:52:33 +02:00
Santiago L de979011f9 Deprecated Passing a 3-tuple or an app_name to include()
Django 1.9
2021-04-22 10:51:23 +02:00
Santiago L 7d975637d5 Replace ForeignKey.field.rel.to --> field.model
rel.to dropped on Django 2.0
2021-04-22 10:44:09 +02:00
Santiago L d863598d81 Define on_delete argument for ForeignKey and OneToOneField
Required since Django 2.0
2021-04-22 10:28:00 +02:00
Santiago L eadc06d4c5 django.core.urlresolvers moved to django.urls
Django 2.0
2021-04-21 14:27:18 +02:00
Santiago L 2b06652a5b Handle edge cases of last day of the month of billing period. 2021-03-31 12:11:53 +02:00
Santiago L dc722ec17a Set env variable to skip REST_API tests. 2021-03-31 10:39:47 +02:00
Santiago L e7aabf4799 Python3 requires to open explicitly on binary mode to write bytes 2021-03-30 17:46:42 +02:00
Cayo Puigdefabregas fa8a895299 fixing test traffic monitors is a list 2021-03-30 15:21:50 +02:00
Cayo Puigdefabregas 091120d3c2 fixing test_traffic get_total 2021-03-30 15:21:23 +02:00
Cayo Puigdefabregas c952d782cd fixing mailbox test 2021-03-30 15:21:00 +02:00
Cayo Puigdefabregas 226327cacf fixing job test 2021-03-30 15:20:37 +02:00
Cayo Puigdefabregas 6f043cd272 fixing ftp test 2021-03-30 15:20:25 +02:00
Cayo Puigdefabregas 0633df114e fixing DomainBillingTest 2021-03-30 15:19:39 +02:00
Cayo Puigdefabregas a53b71bab1 fixed choices 2021-03-30 15:19:19 +02:00
Cayo Puigdefabregas c010c10157 fixed admin_login in test 2021-03-30 14:28:15 +02:00
Santiago L acac7727c2 Fix services tests 2021-03-30 14:27:21 +02:00
Cayo Puigdefabregas 48ef6d63bc Fixed bug in python backend 2021-03-30 14:25:10 +02:00
Santiago L 45bf31c9da Fix freezegun version 2021-03-30 14:19:17 +02:00
Santiago L 08a76a8de4 Ignore Account.is_staff kwarg (auth.AbstractBaseUser) 2021-03-30 13:56:04 +02:00
Santiago L 14fbd98e33 Refactor PHP tests dropping legacy controller (backend) 2021-03-30 13:35:00 +02:00
Santiago L 58395147c9 Replace PasswdVirtualUserBackend with RoundcubeIdentityController 2021-03-30 13:13:26 +02:00
Santiago L c505f9a3c6 Replace SystemUserBackend with UNIXUserController 2021-03-30 13:11:41 +02:00
Santiago L f4c0a7413c Generate missing migrations. 2021-03-30 12:52:12 +02:00
Santiago L 9d2d0befc4 Rename `async`--> `run_async`
On Python3.5 async becames a reserved keyword.
2021-03-30 12:51:12 +02:00
204 changed files with 4175 additions and 2680 deletions

View File

@ -1,5 +0,0 @@
SECRET_KEY=k_=*vfue(^campsl63)7w5m&cu9u4o4-!vaw94qzyrymyv0hgg
DEBUG=True
ALLOWED_HOSTS=.localhost,127.0.0.1
DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/NAME
STATIC_ROOT=PATH_TO_STATIC_ROOT

View File

@ -1,76 +0,0 @@
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 -r total_requirements.txt
pip install -e .
- 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/ribaguifi_template -v3
#python panel/manage.py test orchestra --noinput -v3
coverage run --source='orchestra' panel/manage.py test orchestra --noinput -v3
coverage report
coverage xml
env:
SECRET_KEY: zrhnooq6)sb+0+xb)(o0rvbf5)a(vc8ncv&1&kng@3i_pmx3oy
DEBUG: True
ALLOWED_HOSTS: .localhost,127.0.0.1
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

@ -1,70 +0,0 @@
We need have python3.6
#Install Packages
```bash
apt=(
bind9utils
ca-certificates
gettext
libcrack2-dev
libxml2-dev
libxslt1-dev
ssh-client
wget
xvfb
zlib1g-dev
git
iceweasel
dnsutils
postgresql-contrib
)
sudo apt-get install --no-install-recommends -y ${apt[@]}
```
It is necessary install *wkhtmltopdf*
You can install it from https://wkhtmltopdf.org/downloads.html
Clone this repository
```bash
git clone https://github.com/ribaguifi/django-orchestra
```
Prepare env and install requirements
```bash
cd django-orchestra
python3.6 -m venv env
source env/bin/activate
pip3 install --upgrade pip
pip3 install -r total_requirements.txt
pip3 install -e .
```
Configure project using environment file (you can use provided example as quickstart):
```bash
cp .env.example .env
```
Prepare your Postgres database (create database, user and grant permissions):
```sql
CREATE DATABASE myproject;
CREATE USER myuser WITH PASSWORD 'password';
GRANT ALL PRIVILEGES ON DATABASE myproject TO myuser;
```
Prepare a new project:
```bash
django-admin.py startproject PROJECT_NAME --template="orchestra/conf/ribaguifi_template"
```
Run migrations:
```bash
python3 manage.py migrate
```
(Optional) You can start a Django development server to check that everything is ok.
```bash
python3 manage.py runserver
```
Open [http://127.0.0.1:8000/](http://127.0.0.1:8000/) in your browser.

View File

@ -1,41 +0,0 @@
FROM python:3.6
RUN apt-get -y update
RUN pip3 install wheel
RUN apt-get -y install python3-dev
RUN apt-get install -y bind9utils ca-certificates gettext libcrack2-dev libxml2-dev\
libxslt1-dev ssh-client wget xvfb zlib1g-dev git iceweasel dnsutils postgresql-contrib\
curl sudo vim libgirepository1.0-dev
RUN apt-get clean
RUN useradd orchestra --shell /bin/bash && \
{ echo "orchestra:orchestra" | chpasswd; } && \
mkhomedir_helper orchestra && \
adduser orchestra sudo
# RUN echo 'EXPORT $PATH="$PATH:/home/orchestra/.local/bin/"' > /home/orchestra/.bashrc
# RUN git clone https://github.com/ribaguifi/django-orchestra.git
# RUN orchestra-admin startproject panel
# RUN python3 panel/manage.py migrate
# RUN python3 panel/manage.py runserver
# install wkhtmltox
RUN apt-get install -y xfonts-75dpi
RUN wget https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.buster_amd64.deb -O /tmp/wkhtmltox.deb
RUN dpkg -i /tmp/wkhtmltox.deb
RUN wget https://github.com/mozilla/geckodriver/releases/download/v0.29.0/geckodriver-v0.29.0-linux64.tar.gz -O /tmp/geckodriver.tar.gz
RUN tar -xf /tmp/geckodriver.tar.gz -C /usr/local/bin/
# install orchestra requirements
RUN pip3 install --upgrade pip
# TODO(@slamora): requirements.txt duplicates ../totaL_requirements.txt
# Docker compose security policy forbiddes access to parent folders
COPY requirements.txt requirements.txt
RUN pip3 install -r requirements.txt
EXPOSE 8000

View File

@ -1,33 +0,0 @@
# orchestra environment based on docker-compose
Docker compose environment to develop django-orchestra.
**NOTE**: On web container, volume `/code` contains the source code of the host.
1. Build (or rebuild if any change done) the containers:
```
cd examples/
docker-compose build
```
2. Start the containers:
```
docker-compose up
```
3. Run a bash on `web` container:
```
docker-compose run web bash
```
4. Run on the web docker container the first time:
```
su - orchestra
bash /code/examples/init_project.sh
```
5. Run tests or do whatever you need:
```
cd panel
python manage.py test --noinput orchestra.contrib.lists.tests.functional_tests.tests.AdminListTest.test_add
```

View File

@ -1,4 +0,0 @@
create database orchestra;
CREATE USER orchestra WITH PASSWORD 'orchestra';
GRANT ALL PRIVILEGES ON DATABASE orchestra TO orchestra;
ALTER ROLE orchestra CREATEDB;

View File

@ -1,17 +0,0 @@
version: '3'
services:
web:
build: .
ports:
- 8000:8000
volumes:
- ..:/code
postgres:
image: postgres
ports:
- 5432:5432
environment:
POSTGRES_DB: orchestra
POSTGRES_USER: orchestra
POSTGRES_PASSWORD: orchestra

View File

@ -1,5 +0,0 @@
SECRET_KEY=zrhnooq6)sb+0+xb)(o0rvbf5)a(vc8ncv&1&kng@3i_pmx3oy
DEBUG=True
ALLOWED_HOSTS=.localhost,127.0.0.1
DATABASE_URL=postgres://orchestra:orchestra@postgres:5432/orchestra
STATIC_ROOT=PATH_TO_STATIC_ROOT

View File

@ -1,27 +0,0 @@
sudo pip3 install -e /code
psql -U orchestra -h postgres < /code/examples/createdb.sql
cd ~
django-admin.py startproject panel --template="/code/orchestra/conf/ribaguifi_template"
cp /code/examples/env.example panel/.env
cd panel
python3 manage.py setupcronbeat
python3 manage.py syncperiodictasks
sudo apt-get install -y rabbitmq-server
sudo python3 manage.py setupcelery --username orchestra
sudo python3 manage.py setuplog
python3 manage.py collectstatic --noinput
sudo apt-get install -y nginx-full uwsgi uwsgi-plugin-python3
sudo python3 manage.py setupnginx --user orchestra
sudo /etc/init.d/rabbitmq-server start
sudo pip uninstall django-celery
sudo pip install -r /code/requirements.txt
sudo python3 manage.py startservices
# python3 panel/manage.py migrate

View File

@ -1,53 +0,0 @@
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
djangorestframework==3.4.7
django-celery-email
django-debug-toolbar
django-cors-headers
django-countries
django-filter==0.15.2
django-flat-theme
django-fluent-dashboard
django-iban
django-localflavor
django-multiselectfield
django-nose==1.4.4
django-reversion
django-transaction-signals
celery==3.1.23
kombu==3.0.35
billiard==3.3.0.23
Markdown==2.4
ecdsa==0.11
Pygments==1.6
jsonfield==0.9.22
python_dateutil
requests
phonenumbers
amqp==1.4.9
anyjson
pytz
cracklib
lxml==3.3.5
selenium
xvfbwrapper
freezegun==1.1.0
coverage
flake8
sqlparse
pyinotify
PyMySQL
dj_database_url==0.5.0
psycopg2
python-decouple
https://github.com/glic3rinu/passlib/archive/master.zip
paramiko
mysqlclient
pycrypto==2.6.1
pygobject
six
nose
-e git+https://github.com/ribaguifi/orchestra-orm.git#egg=orchestra-orm

View File

@ -93,7 +93,7 @@ Remember create a database for your project and give permitions for the correct
``` ```
psql -U postgres psql -U postgres
psql (12.4) psql (12.4)
Digite «help». Digite «help» para obtener ayuda.
postgres=# CREATE database orchesta; postgres=# CREATE database orchesta;
postgres=# CREATE USER orchesta WITH PASSWORD 'orquesta'; postgres=# CREATE USER orchesta WITH PASSWORD 'orquesta';

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.core.urlresolvers import reverse from django.urls 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 _
@ -56,7 +56,7 @@ def search(request):
if service.search: if service.search:
models.add(service.model) models.add(service.model)
model_name_map[service.model._meta.model_name] = service.model model_name_map[service.model._meta.model_name] = service.model
# Account direct access # Account direct access
if search_term.endswith('!'): if search_term.endswith('!'):
from ..contrib.accounts.models import Account from ..contrib.accounts.models import Account

View File

@ -1,4 +1,4 @@
from django.core.urlresolvers import reverse from django.urls 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
@ -11,7 +11,7 @@ class AppDefaultIconList(CmsAppIconList):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.icons = kwargs.pop('icons') self.icons = kwargs.pop('icons')
super(AppDefaultIconList, self).__init__(*args, **kwargs) super(AppDefaultIconList, self).__init__(*args, **kwargs)
def get_icon_for_model(self, app_name, model_name, default=None): def get_icon_for_model(self, app_name, model_name, default=None):
icon = self.icons.get('.'.join((app_name, model_name))) icon = self.icons.get('.'.join((app_name, model_name)))
return super(AppDefaultIconList, self).get_icon_for_model(app_name, model_name, default=icon) return super(AppDefaultIconList, self).get_icon_for_model(app_name, model_name, default=icon)
@ -19,7 +19,7 @@ class AppDefaultIconList(CmsAppIconList):
class OrchestraIndexDashboard(dashboard.FluentIndexDashboard): class OrchestraIndexDashboard(dashboard.FluentIndexDashboard):
""" Gets application modules from services, accounts and administration registries """ """ Gets application modules from services, accounts and administration registries """
def __init__(self, **kwargs): def __init__(self, **kwargs):
super(dashboard.FluentIndexDashboard, self).__init__(**kwargs) super(dashboard.FluentIndexDashboard, self).__init__(**kwargs)
self.children.append(self.get_personal_module()) self.children.append(self.get_personal_module())
@ -27,7 +27,7 @@ class OrchestraIndexDashboard(dashboard.FluentIndexDashboard):
recent_actions = self.get_recent_actions_module() recent_actions = self.get_recent_actions_module()
recent_actions.enabled = True recent_actions.enabled = True
self.children.append(recent_actions) self.children.append(recent_actions)
def process_registered_view(self, module, view_name, options): def process_registered_view(self, module, view_name, options):
app_name, name = view_name.split('_')[:-1] app_name, name = view_name.split('_')[:-1]
module.icons['.'.join((app_name, name))] = options.get('icon') module.icons['.'.join((app_name, name))] = options.get('icon')
@ -47,7 +47,7 @@ class OrchestraIndexDashboard(dashboard.FluentIndexDashboard):
'title': options.get('verbose_name_plural'), 'title': options.get('verbose_name_plural'),
'url': add_url, 'url': add_url,
}) })
def get_application_modules(self): def get_application_modules(self):
modules = [] modules = []
# Honor settings override, hacky. I Know # Honor settings override, hacky. I Know

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, Context from django.template import Template
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)
@ -93,7 +93,7 @@ class AdminPasswordChangeForm(forms.Form):
required=False, validators=[validate_password]) required=False, validators=[validate_password])
password2 = forms.CharField(label=_("Password (again)"), widget=forms.PasswordInput, password2 = forms.CharField(label=_("Password (again)"), widget=forms.PasswordInput,
required=False) required=False)
def __init__(self, user, *args, **kwargs): def __init__(self, user, *args, **kwargs):
self.related = kwargs.pop('related', []) self.related = kwargs.pop('related', [])
self.raw = kwargs.pop('raw', False) self.raw = kwargs.pop('raw', False)
@ -109,7 +109,7 @@ class AdminPasswordChangeForm(forms.Form):
self.fields['password2_%i' % ix] = forms.CharField(label=_("Password (again)"), self.fields['password2_%i' % ix] = forms.CharField(label=_("Password (again)"),
widget=forms.PasswordInput, required=False) widget=forms.PasswordInput, required=False)
setattr(self, 'clean_password2_%i' % ix, partial(self.clean_password2, ix=ix)) setattr(self, 'clean_password2_%i' % ix, partial(self.clean_password2, ix=ix))
def clean_password2(self, ix=''): def clean_password2(self, ix=''):
if ix != '': if ix != '':
ix = '_%i' % ix ix = '_%i' % ix
@ -129,7 +129,7 @@ class AdminPasswordChangeForm(forms.Form):
code='password_mismatch', code='password_mismatch',
) )
return password2 return password2
def clean_password(self, ix=''): def clean_password(self, ix=''):
if ix != '': if ix != '':
ix = '_%i' % ix ix = '_%i' % ix
@ -146,14 +146,14 @@ class AdminPasswordChangeForm(forms.Form):
code='bad_hash', code='bad_hash',
) )
return password return password
def clean(self): def clean(self):
if not self.password_provided: if not self.password_provided:
raise forms.ValidationError( raise forms.ValidationError(
self.error_messages['password_missing'], self.error_messages['password_missing'],
code='password_missing', code='password_missing',
) )
def save(self, commit=True): def save(self, commit=True):
""" """
Saves the new password. Saves the new password.
@ -182,7 +182,7 @@ class AdminPasswordChangeForm(forms.Form):
if commit: if commit:
rel.save(update_fields=['password']) rel.save(update_fields=['password'])
return self.user return self.user
def _get_changed_data(self): def _get_changed_data(self):
data = super().changed_data data = super().changed_data
for name in self.fields.keys(): for name in self.fields.keys():
@ -202,7 +202,7 @@ class SendEmailForm(forms.Form):
widget=forms.TextInput(attrs={'size': '118'})) widget=forms.TextInput(attrs={'size': '118'}))
message = forms.CharField(label=_("Message"), message = forms.CharField(label=_("Message"),
widget=forms.Textarea(attrs={'cols': 118, 'rows': 15})) widget=forms.Textarea(attrs={'cols': 118, 'rows': 15}))
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
initial = kwargs.get('initial') initial = kwargs.get('initial')
@ -210,7 +210,7 @@ class SendEmailForm(forms.Form):
self.fields['to'].widget = SpanWidget(original=initial['to']) self.fields['to'].widget = SpanWidget(original=initial['to'])
else: else:
self.fields.pop('to') self.fields.pop('to')
def clean_comma_separated_emails(self, value): def clean_comma_separated_emails(self, value):
clean_value = [] clean_value = []
for email in value.split(','): for email in value.split(','):
@ -222,7 +222,7 @@ class SendEmailForm(forms.Form):
raise validators.ValidationError("Comma separated email addresses.") raise validators.ValidationError("Comma separated email addresses.")
clean_value.append(email) clean_value.append(email)
return clean_value return clean_value
def clean_extra_to(self): def clean_extra_to(self):
extra_to = self.cleaned_data['extra_to'] extra_to = self.cleaned_data['extra_to']
return self.clean_comma_separated_emails(extra_to) return self.clean_comma_separated_emails(extra_to)

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.core.urlresolvers import reverse from django.urls 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 _
@ -16,7 +16,7 @@ def api_link(context):
opts = context['cl'].opts opts = context['cl'].opts
else: else:
return reverse('api-root') return reverse('api-root')
if 'object_id' in context: if 'object_id' in context:
object_id = context['object_id'] object_id = context['object_id']
try: try:
return reverse('%s-detail' % opts.model_name, args=[object_id]) return reverse('%s-detail' % opts.model_name, args=[object_id])
@ -42,7 +42,7 @@ def process_registry(register):
item = items.MenuItem(name, url) item = items.MenuItem(name, url)
item.options = options item.options = options
return item return item
childrens = {} childrens = {}
for model, options in register.get().items(): for model, options in register.get().items():
if options.get('menu', True): if options.get('menu', True):
@ -68,7 +68,7 @@ def process_registry(register):
class OrchestraMenu(Menu): class OrchestraMenu(Menu):
template = 'admin/orchestra/menu.html' template = 'admin/orchestra/menu.html'
def init_with_context(self, context): def init_with_context(self, context):
self.children = [ self.children = [
# items.MenuItem( # items.MenuItem(

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.core.urlresolvers import reverse, NoReverseMatch from django.urls 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 from django.utils.html import escape, format_html
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 = 'onclick="return showAddAnotherPopup(this);"' extra = mark_safe('onclick="return showAddAnotherPopup(this);"')
title = "Change %s" % obj._meta.verbose_name title = "Change %s" % obj._meta.verbose_name
return mark_safe('<a href="%s" title="%s" %s>%s</a>' % (url, title, extra, display)) return format_html('<a href="{}" title="{}" {}>{}</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 '<span title="{0}">{1}</span>'.format(date, escape(natural)) return format_html('<span title="{0}">{1}</span>', date, 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 detail_route from rest_framework.decorators import action
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):
@detail_route(methods=['post'], serializer_class=SetPasswordSerializer) @action(detail=True, 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.core.urlresolvers import NoReverseMatch from django.urls 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, base_name): def insert_links(viewset, basename):
collection_links = ['api-root', '%s-list' % base_name] collection_links = ['api-root', '%s-list' % basename]
object_links = ['api-root', '%s-list' % base_name, '%s-detail' % base_name] object_links = ['api-root', '%s-list' % basename, '%s-detail' % basename]
exception_links = ['api-root'] exception_links = ['api-root']
list_links = ['api-root'] list_links = ['api-root']
retrieve_links = ['api-root', '%s-list' % base_name] retrieve_links = ['api-root', '%s-list' % basename]
# 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' % (base_name, methodname.replace('_', '-')) view_name = '%s-%s' % (basename, 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

@ -18,33 +18,33 @@ class LogApiMixin(object):
message = _('Added.') message = _('Added.')
self.log(request, message, ADDITION, instance=self.serializer.instance) self.log(request, message, ADDITION, instance=self.serializer.instance)
return response return response
def perform_create(self, serializer): def perform_create(self, serializer):
""" stores serializer for accessing instance on create() """ """ stores serializer for accessing instance on create() """
super(LogApiMixin, self).perform_create(serializer) super(LogApiMixin, self).perform_create(serializer)
self.serializer = serializer self.serializer = serializer
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
from django.contrib.admin.models import CHANGE from django.contrib.admin.models import CHANGE
response = super(LogApiMixin, self).update(request, *args, **kwargs) response = super(LogApiMixin, self).update(request, *args, **kwargs)
message = _('Changed data') message = _('Changed data')
self.log(request, message, CHANGE) self.log(request, message, CHANGE)
return response return response
def partial_update(self, request, *args, **kwargs): def partial_update(self, request, *args, **kwargs):
from django.contrib.admin.models import CHANGE from django.contrib.admin.models import CHANGE
response = super(LogApiMixin, self).partial_update(request, *args, **kwargs) response = super(LogApiMixin, self).partial_update(request, *args, **kwargs)
message = _('Changed %s') % response.data message = _('Changed %s') % response.data
self.log(request, message, CHANGE) self.log(request, message, CHANGE)
return response return response
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
from django.contrib.admin.models import DELETION from django.contrib.admin.models import DELETION
message = _('Deleted') message = _('Deleted')
self.log(request, message, DELETION) self.log(request, message, DELETION)
response = super(LogApiMixin, self).destroy(request, *args, **kwargs) response = super(LogApiMixin, self).destroy(request, *args, **kwargs)
return response return response
def log(self, request, message, action, instance=None): def log(self, request, message, action, instance=None):
from django.contrib.admin.models import LogEntry from django.contrib.admin.models import LogEntry
instance = instance or self.get_object() instance = instance or self.get_object()
@ -64,21 +64,21 @@ class LinkHeaderRouter(DefaultRouter):
APIRoot = import_class(settings.ORCHESTRA_API_ROOT_VIEW) APIRoot = import_class(settings.ORCHESTRA_API_ROOT_VIEW)
APIRoot.router = self APIRoot.router = self
return APIRoot.as_view() return APIRoot.as_view()
def register(self, prefix, viewset, base_name=None): def register(self, prefix, viewset, basename=None):
""" inserts link headers on every viewset """ """ inserts link headers on every viewset """
if base_name is None: if basename is None:
base_name = self.get_default_base_name(viewset) basename = self.get_default_basename(viewset)
insert_links(viewset, base_name) insert_links(viewset, basename)
self.registry.append((prefix, viewset, base_name)) self.registry.append((prefix, viewset, basename))
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:
if _prefix == prefix_or_model or viewset.queryset.model == prefix_or_model: if _prefix == prefix_or_model or viewset.queryset.model == prefix_or_model:
return viewset return viewset
msg = "%s does not have a regiestered viewset" % prefix_or_model msg = "%s does not have a regiestered viewset" % prefix_or_model
raise KeyError(msg) raise KeyError(msg)
def insert(self, prefix_or_model, name, field, **kwargs): def insert(self, prefix_or_model, name, field, **kwargs):
""" Dynamically add new fields to an existing serializer """ """ Dynamically add new fields to an existing serializer """
viewset = self.get_viewset(prefix_or_model) viewset = self.get_viewset(prefix_or_model)

View File

@ -11,7 +11,7 @@ class APIRoot(views.APIView):
'ORCHESTRA_SITE_NAME', 'ORCHESTRA_SITE_NAME',
'ORCHESTRA_SITE_VERBOSE_NAME' 'ORCHESTRA_SITE_VERBOSE_NAME'
) )
def get(self, request, format=None): def get(self, request, format=None):
root_url = reverse('api-root', request=request, format=format) root_url = reverse('api-root', request=request, format=format)
token_url = reverse('api-token-auth', request=request, format=format) token_url = reverse('api-token-auth', request=request, format=format)
@ -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:
@ -60,7 +60,7 @@ class APIRoot(views.APIView):
for name in self.names for name in self.names
}) })
return Response(body, headers=headers) return Response(body, headers=headers)
def options(self, request): def options(self, request):
metadata = super(APIRoot, self).options(request) metadata = super(APIRoot, self).options(request)
metadata.data['settings'] = { metadata.data['settings'] = {

View File

@ -17,7 +17,7 @@ class SetPasswordSerializer(serializers.Serializer):
class HyperlinkedModelSerializer(serializers.HyperlinkedModelSerializer): class HyperlinkedModelSerializer(serializers.HyperlinkedModelSerializer):
""" support for postonly_fields, fields whose value can only be set on post """ """ support for postonly_fields, fields whose value can only be set on post """
def validate(self, attrs): def validate(self, attrs):
""" calls model.clean() """ """ calls model.clean() """
attrs = super(HyperlinkedModelSerializer, self).validate(attrs) attrs = super(HyperlinkedModelSerializer, self).validate(attrs)
@ -39,7 +39,7 @@ class HyperlinkedModelSerializer(serializers.HyperlinkedModelSerializer):
instance = ModelClass(**validated_data) instance = ModelClass(**validated_data)
instance.clean() instance.clean()
return attrs return attrs
def post_only_cleanning(self, instance, validated_data): def post_only_cleanning(self, instance, validated_data):
""" removes postonly_fields from attrs """ """ removes postonly_fields from attrs """
model_attrs = dict(**validated_data) model_attrs = dict(**validated_data)
@ -49,12 +49,12 @@ class HyperlinkedModelSerializer(serializers.HyperlinkedModelSerializer):
if attr in post_only_fields: if attr in post_only_fields:
model_attrs.pop(attr) model_attrs.pop(attr)
return model_attrs return model_attrs
def update(self, instance, validated_data): def update(self, instance, validated_data):
""" removes postonly_fields from attrs when not posting """ """ removes postonly_fields from attrs when not posting """
model_attrs = self.post_only_cleanning(instance, validated_data) model_attrs = self.post_only_cleanning(instance, validated_data)
return super(HyperlinkedModelSerializer, self).update(instance, model_attrs) return super(HyperlinkedModelSerializer, self).update(instance, model_attrs)
def partial_update(self, instance, validated_data): def partial_update(self, instance, validated_data):
""" removes postonly_fields from attrs when not posting """ """ removes postonly_fields from attrs when not posting """
model_attrs = self.post_only_cleanning(instance, validated_data) model_attrs = self.post_only_cleanning(instance, validated_data)
@ -64,7 +64,10 @@ 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):
url = data.get('url') try:
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."
@ -80,16 +83,16 @@ class SetPasswordHyperlinkedSerializer(HyperlinkedModelSerializer):
password = serializers.CharField(max_length=128, label=_('Password'), password = serializers.CharField(max_length=128, label=_('Password'),
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, attrs, source): def validate_password(self, value):
""" POST only password """ """ POST only password """
if self.instance: if self.instance:
if 'password' in attrs: if value:
raise serializers.ValidationError(_("Can not set password")) raise serializers.ValidationError(_("Can not set password"))
elif 'password' not in attrs: elif not value:
raise serializers.ValidationError(_("Password required")) raise serializers.ValidationError(_("Password required"))
return attrs return value
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 """
try: try:
@ -102,7 +105,7 @@ class SetPasswordHyperlinkedSerializer(HyperlinkedModelSerializer):
if password is not None: if password is not None:
attrs['password'] = password attrs['password'] = password
return attrs return attrs
def create(self, validated_data): def create(self, validated_data):
password = validated_data.pop('password') password = validated_data.pop('password')
instance = self.Meta.model(**validated_data) instance = self.Meta.model(**validated_data)

View File

@ -116,17 +116,20 @@ function install_requirements () {
update-locale LANG=en_US.UTF-8 update-locale LANG=en_US.UTF-8
fi fi
# lxml: libxml2-dev, libxslt1-dev, zlib1g-dev
APT="bind9utils \ APT="bind9utils \
ca-certificates \ ca-certificates \
gettext \ gettext \
libcrack2-dev \ libcrack2-dev \
libxml2-dev \
libxslt1-dev \
python3 \ python3 \
python3-pip \ python3-pip \
python3-dev \ python3-dev \
python3-lxml \
ssh-client \ ssh-client \
wget \ wget \
xvfb" xvfb \
zlib1g-dev"
if $testing; then if $testing; then
APT="${APT} \ APT="${APT} \
git \ git \
@ -147,13 +150,14 @@ function install_requirements () {
fi fi
# cracklib and lxml are excluded on the requirements.txt because they need unconvinient system dependencies # cracklib and lxml are excluded on the requirements.txt because they need unconvinient system dependencies
PIP="$(wget https://raw.githubusercontent.com/ribaguifi/django-orchestra/dev/github-actions/requirements.txt -O - | tr '\n' ' ') \ PIP="$(wget http://git.io/orchestra-requirements.txt -O - | tr '\n' ' ') \
cracklib" cracklib \
lxml==3.3.5"
if $testing; then if $testing; then
PIP="${PIP} \ PIP="${PIP} \
selenium \ selenium \
xvfbwrapper \ xvfbwrapper \
freezegun \ freezegun==0.3.14 \
coverage \ coverage \
flake8 \ flake8 \
django-debug-toolbar==1.3.0 \ django-debug-toolbar==1.3.0 \

View File

@ -27,7 +27,7 @@ class crontab_parser(object):
_range = r'(\w+?)-(\w+)' _range = r'(\w+?)-(\w+)'
_steps = r'/(\w+)?' _steps = r'/(\w+)?'
_star = r'\*' _star = r'\*'
def __init__(self, max_=60, min_=0): def __init__(self, max_=60, min_=0):
self.max_ = max_ self.max_ = max_
self.min_ = min_ self.min_ = min_
@ -45,14 +45,14 @@ class crontab_parser(object):
raise self.ParseException('empty part') raise self.ParseException('empty part')
acc |= set(self._parse_part(part)) acc |= set(self._parse_part(part))
return acc return acc
def _parse_part(self, part): def _parse_part(self, part):
for regex, handler in self.pats: for regex, handler in self.pats:
m = regex.match(part) m = regex.match(part)
if m: if m:
return handler(m.groups()) return handler(m.groups())
return self._expand_range((part, )) return self._expand_range((part, ))
def _expand_range(self, toks): def _expand_range(self, toks):
fr = self._expand_number(toks[0]) fr = self._expand_number(toks[0])
if len(toks) > 1: if len(toks) > 1:
@ -62,19 +62,19 @@ class crontab_parser(object):
list(range(self.min_, to + 1))) list(range(self.min_, to + 1)))
return list(range(fr, to + 1)) return list(range(fr, to + 1))
return [fr] return [fr]
def _range_steps(self, toks): def _range_steps(self, toks):
if len(toks) != 3 or not toks[2]: if len(toks) != 3 or not toks[2]:
raise self.ParseException('empty filter') raise self.ParseException('empty filter')
return self._expand_range(toks[:2])[::int(toks[2])] return self._expand_range(toks[:2])[::int(toks[2])]
def _star_steps(self, toks): def _star_steps(self, toks):
if not toks or not toks[0]: if not toks or not toks[0]:
raise self.ParseException('empty filter') raise self.ParseException('empty filter')
return self._expand_star()[::int(toks[0])] return self._expand_star()[::int(toks[0])]
def _expand_star(self, *args): def _expand_star(self, *args):
return list(range(self.min_, self.max_ + self.min_)) return list(range(self.min_, self.max_ + self.min_))
def _expand_number(self, s): def _expand_number(self, s):
if isinstance(s, str) and s[0] == '-': if isinstance(s, str) and s[0] == '-':
raise self.ParseException('negative numbers not supported') raise self.ParseException('negative numbers not supported')
@ -99,7 +99,7 @@ class Setting(object):
def __init__(self, manage): def __init__(self, manage):
self.manage = manage self.manage = manage
self.settings_file = self.get_settings_file(manage) self.settings_file = self.get_settings_file(manage)
def get_settings(self): def get_settings(self):
""" get db settings from settings.py file without importing """ """ get db settings from settings.py file without importing """
settings = {'__file__': self.settings_file} settings = {'__file__': self.settings_file}
@ -111,7 +111,7 @@ class Setting(object):
content += line content += line
exec(content, settings) exec(content, settings)
return settings return settings
def get_settings_file(self, manage): def get_settings_file(self, manage):
with open(manage, 'r') as handler: with open(manage, 'r') as handler:
regex = re.compile(r'"DJANGO_SETTINGS_MODULE"\s*,\s*"([^"]+)"') regex = re.compile(r'"DJANGO_SETTINGS_MODULE"\s*,\s*"([^"]+)"')
@ -128,7 +128,7 @@ class Setting(object):
class DB(object): class DB(object):
def __init__(self, settings): def __init__(self, settings):
self.settings = settings['DATABASES']['default'] self.settings = settings['DATABASES']['default']
def connect(self): def connect(self):
if self.settings['ENGINE'] == 'django.db.backends.sqlite3': if self.settings['ENGINE'] == 'django.db.backends.sqlite3':
import sqlite3 import sqlite3
@ -138,7 +138,7 @@ class DB(object):
self.conn = psycopg2.connect("dbname='{NAME}' user='{USER}' host='{HOST}' password='{PASSWORD}'".format(**self.settings)) self.conn = psycopg2.connect("dbname='{NAME}' user='{USER}' host='{HOST}' password='{PASSWORD}'".format(**self.settings))
else: else:
raise ValueError("%s engine not supported." % self.settings['ENGINE']) raise ValueError("%s engine not supported." % self.settings['ENGINE'])
def query(self, query): def query(self, query):
cur = self.conn.cursor() cur = self.conn.cursor()
try: try:
@ -147,7 +147,7 @@ class DB(object):
finally: finally:
cur.close() cur.close()
return result return result
def close(self): def close(self):
self.conn.close() self.conn.close()
@ -161,7 +161,7 @@ def fire_pending_tasks(manage, db):
"WHERE p.crontab_id = c.id AND p.enabled = {}" "WHERE p.crontab_id = c.id AND p.enabled = {}"
).format(enabled) ).format(enabled)
return db.query(query) return db.query(query)
def is_due(now, minute, hour, day_of_week, day_of_month, month_of_year): def is_due(now, minute, hour, day_of_week, day_of_month, month_of_year):
n_minute, n_hour, n_day_of_week, n_day_of_month, n_month_of_year = now n_minute, n_hour, n_day_of_week, n_day_of_month, n_month_of_year = now
return ( return (
@ -171,14 +171,14 @@ def fire_pending_tasks(manage, db):
n_day_of_month in crontab_parser(31, 1).parse(day_of_month) and n_day_of_month in crontab_parser(31, 1).parse(day_of_month) and
n_month_of_year in crontab_parser(12, 1).parse(month_of_year) n_month_of_year in crontab_parser(12, 1).parse(month_of_year)
) )
now = datetime.utcnow() now = datetime.utcnow()
now = tuple(map(int, now.strftime("%M %H %w %d %m").split())) now = tuple(map(int, now.strftime("%M %H %w %d %m").split()))
for minute, hour, day_of_week, day_of_month, month_of_year, task_id in get_tasks(db): for minute, hour, day_of_week, day_of_month, month_of_year, task_id in get_tasks(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, async=True) proc = run(command, run_async=True)
yield proc yield proc
@ -187,7 +187,7 @@ def fire_pending_messages(settings, db):
MAILER_DEFERE_SECONDS = settings.get('MAILER_DEFERE_SECONDS', (300, 600, 60*60, 60*60*24)) MAILER_DEFERE_SECONDS = settings.get('MAILER_DEFERE_SECONDS', (300, 600, 60*60, 60*60*24))
now = datetime.utcnow() now = datetime.utcnow()
query_or = [] query_or = []
for num, seconds in enumerate(MAILER_DEFERE_SECONDS): for num, seconds in enumerate(MAILER_DEFERE_SECONDS):
delta = timedelta(seconds=seconds) delta = timedelta(seconds=seconds)
epoch = now-delta epoch = now-delta
@ -198,10 +198,10 @@ def fire_pending_messages(settings, db):
WHERE (mailer_message.state = 'QUEUED' WHERE (mailer_message.state = 'QUEUED'
OR (mailer_message.state = 'DEFERRED' AND (%s))) LIMIT 1""" % ' OR '.join(query_or) OR (mailer_message.state = 'DEFERRED' AND (%s))) LIMIT 1""" % ' OR '.join(query_or)
return bool(db.query(query)) return bool(db.query(query))
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, async=True) proc = run(command, run_async=True)
yield proc yield proc

View File

@ -25,6 +25,7 @@ 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
@ -65,6 +66,7 @@ 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',
@ -84,6 +86,21 @@ 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 = [
@ -127,6 +144,24 @@ DATABASES = {
} }
# 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/
@ -168,22 +203,6 @@ 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'
@ -242,7 +261,6 @@ 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

@ -1,13 +0,0 @@
#!/usr/bin/env python3
import os
import sys
if __name__ == "__main__":
if sys.version_info < (3, 3):
cmd = ' '.join(sys.argv)
sys.stderr.write("Sorry, Orchestra requires at least Python 3.3, try with:\n$ python3 %s\n" % cmd)
sys.exit(1)
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{{ project_name }}.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)

View File

@ -1,261 +0,0 @@
"""
Django settings for {{ project_name }} project.
Generated by 'django-admin startproject' using Django {{ django_version }}.
For more information on this file, see
https://docs.djangoproject.com/en/{{ docs_version }}/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/{{ docs_version }}/ref/settings/
"""
import os
from decouple import config, Csv
from dj_database_url import parse as db_url
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/{{ docs_version }}/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = '{{ secret_key }}'
# SECRET_KEY = config('SECRET_KEY')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = config('DEBUG', default=False, cast=bool)
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default=[], cast=Csv())
# Application definition
INSTALLED_APPS = [
# django-orchestra apps
'orchestra',
'orchestra.contrib.accounts',
'orchestra.contrib.systemusers',
'orchestra.contrib.contacts',
'orchestra.contrib.orchestration',
'orchestra.contrib.bills',
'orchestra.contrib.payments',
'orchestra.contrib.tasks',
'orchestra.contrib.mailer',
'orchestra.contrib.history',
'orchestra.contrib.issues',
'orchestra.contrib.services',
'orchestra.contrib.plans',
'orchestra.contrib.orders',
'orchestra.contrib.domains',
'orchestra.contrib.mailboxes',
'orchestra.contrib.lists',
'orchestra.contrib.webapps',
'orchestra.contrib.websites',
'orchestra.contrib.letsencrypt',
'orchestra.contrib.databases',
'orchestra.contrib.vps',
'orchestra.contrib.saas',
'orchestra.contrib.miscellaneous',
# Third-party apps
'django_extensions',
'djcelery',
'fluent_dashboard',
'admin_tools',
'admin_tools.theming',
'admin_tools.menu',
'admin_tools.dashboard',
'rest_framework',
'rest_framework.authtoken',
'passlib.ext.django',
'django_countries',
# 'debug_toolbar',
# Django.contrib
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.admin.apps.SimpleAdminConfig',
# Last to load
'orchestra.contrib.resources',
'orchestra.contrib.settings',
# 'django_nose',
]
ROOT_URLCONF = '{{ project_name }}.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'orchestra.core.context_processors.site',
],
'loaders': [
'admin_tools.template_loaders.Loader',
'django.template.loaders.filesystem.Loader',
'django.template.loaders.app_directories.Loader',
],
},
},
]
WSGI_APPLICATION = '{{ project_name }}.wsgi.application'
# Database
# https://docs.djangoproject.com/en/{{ docs_version }}/ref/settings/#databases
DATABASES = {
'default': config(
'DATABASE_URL',
default='sqlite:///' + os.path.join(BASE_DIR, 'db.sqlite3'),
cast=db_url
)
}
# Internationalization
# https://docs.djangoproject.com/en/{{ docs_version }}/topics/i18n/
LANGUAGE_CODE = 'en-us'
try:
TIME_ZONE = open('/etc/timezone', 'r').read().strip()
except IOError:
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/{{ docs_version }}/howto/static-files/
STATIC_URL = '/static/'
# Absolute path to the directory static files should be collected to.
# Don't put anything in this directory yourself; store your static files
# in apps' "static/" subdirectories and in STATICFILES_DIRS.
# Example: "/home/media/media.lawrence.com/static/"
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
# Absolute filesystem path to the directory that will hold user-uploaded files.
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
# Path used for database translations files
LOCALE_PATHS = (
os.path.join(BASE_DIR, 'locale'),
)
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'
AUTHENTICATION_BACKENDS = [
'orchestra.permissions.auth.OrchestraPermissionBackend',
'django.contrib.auth.backends.ModelBackend',
]
EMAIL_BACKEND = 'orchestra.contrib.mailer.backends.EmailBackend'
# Needed for Bulk operations
DATA_UPLOAD_MAX_NUMBER_FIELDS = None
#################################
## 3RD PARTY APPS CONIGURATION ##
#################################
# Admin Tools
ADMIN_TOOLS_MENU = 'orchestra.admin.menu.OrchestraMenu'
# Fluent dashboard
ADMIN_TOOLS_INDEX_DASHBOARD = 'orchestra.admin.dashboard.OrchestraIndexDashboard'
FLUENT_DASHBOARD_ICON_THEME = '../orchestra/icons'
# Django-celery
import djcelery
djcelery.setup_loader()
CELERYBEAT_SCHEDULER = 'djcelery.schedulers.DatabaseScheduler'
CELERY_ALWAYS_EAGER = True
CELERY_TASK_ALWAYS_EAGER = True
task_always_eager = True
TASK_ALWAYS_EAGER = True
# rest_framework
REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': (
'orchestra.permissions.api.OrchestraPermissionBackend',
),
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.TokenAuthentication',
),
'DEFAULT_FILTER_BACKENDS': (
('rest_framework.filters.DjangoFilterBackend',)
),
}
# Use a UNIX compatible hash
PASSLIB_CONFIG = (
"[passlib]\n"
"schemes = sha512_crypt, django_pbkdf2_sha256, django_pbkdf2_sha1, "
" django_bcrypt, django_bcrypt_sha256, django_salted_sha1, des_crypt, "
" django_salted_md5, django_des_crypt, hex_md5, bcrypt, phpass\n"
"default = sha512_crypt\n"
"deprecated = django_pbkdf2_sha1, django_salted_sha1, django_salted_md5, "
" django_des_crypt, des_crypt, hex_md5\n"
"all__vary_rounds = 0.05\n"
"django_pbkdf2_sha256__min_rounds = 10000\n"
"sha512_crypt__min_rounds = 80000\n"
"staff__django_pbkdf2_sha256__default_rounds = 12500\n"
"staff__sha512_crypt__default_rounds = 100000\n"
"superuser__django_pbkdf2_sha256__default_rounds = 15000\n"
"superuser__sha512_crypt__default_rounds = 120000\n"
)
SHELL_PLUS_PRE_IMPORTS = (
('orchestra.contrib.orchestration.managers', ('orchestrate',)),
)

View File

@ -1,6 +0,0 @@
from django.conf.urls import include, url
urlpatterns = [
url(r'', include('orchestra.urls')),
]

View File

@ -1,14 +0,0 @@
"""
WSGI config for {{ project_name }} project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/{{ docs_version }}/howto/deployment/wsgi/
"""
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{{ project_name }}.settings")
from django.core.wsgi import get_wsgi_application
application = get_wsgi_application()

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.core.urlresolvers import reverse, NoReverseMatch from django.urls 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
@ -53,14 +53,14 @@ def service_report(modeladmin, request, queryset):
fields.append((model, name)) fields.append((model, name))
fields = sorted(fields, key=lambda f: f[0]._meta.verbose_name_plural.lower()) fields = sorted(fields, key=lambda f: f[0]._meta.verbose_name_plural.lower())
fields = [field for model, field in fields] fields = [field for model, field in fields]
for account in queryset.prefetch_related(*fields): for account in queryset.prefetch_related(*fields):
items = [] items = []
for field in fields: for field in fields:
related_manager = getattr(account, field) related_manager = getattr(account, field)
items.append((related_manager.model._meta, related_manager.all())) items.append((related_manager.model._meta, related_manager.all()))
accounts.append((account, items)) accounts.append((account, items))
context = { context = {
'accounts': accounts, 'accounts': accounts,
'date': timezone.now().today() 'date': timezone.now().today()
@ -71,21 +71,21 @@ def service_report(modeladmin, request, queryset):
def delete_related_services(modeladmin, request, queryset): def delete_related_services(modeladmin, request, queryset):
opts = modeladmin.model._meta opts = modeladmin.model._meta
app_label = opts.app_label app_label = opts.app_label
using = router.db_for_write(modeladmin.model) using = router.db_for_write(modeladmin.model)
collector = NestedObjects(using=using) collector = NestedObjects(using=using)
collector.collect(queryset) collector.collect(queryset)
registered_services = services.get() registered_services = services.get()
related_services = [] related_services = []
to_delete = [] to_delete = []
admin_site = modeladmin.admin_site admin_site = modeladmin.admin_site
def format(obj, account=False): def format(obj, account=False):
has_admin = obj.__class__ in admin_site._registry has_admin = obj.__class__ in admin_site._registry
opts = obj._meta opts = obj._meta
no_edit_link = '%s: %s' % (capfirst(opts.verbose_name), force_text(obj)) no_edit_link = '%s: %s' % (capfirst(opts.verbose_name), force_text(obj))
if has_admin: if has_admin:
try: try:
admin_url = reverse( admin_url = reverse(
@ -95,7 +95,7 @@ def delete_related_services(modeladmin, request, queryset):
except NoReverseMatch: except NoReverseMatch:
# Change url doesn't exist -- don't display link to edit # Change url doesn't exist -- don't display link to edit
return no_edit_link return no_edit_link
# Display a link to the admin page. # Display a link to the admin page.
context = (capfirst(opts.verbose_name), admin_url, obj) context = (capfirst(opts.verbose_name), admin_url, obj)
if account: if account:
@ -106,7 +106,7 @@ def delete_related_services(modeladmin, request, queryset):
# Don't display link to edit, because it either has no # Don't display link to edit, because it either has no
# admin or is edited inline. # admin or is edited inline.
return no_edit_link return no_edit_link
def format_nested(objs, result): def format_nested(objs, result):
if isinstance(objs, list): if isinstance(objs, list):
current = [] current = []
@ -115,7 +115,7 @@ def delete_related_services(modeladmin, request, queryset):
result.append(current) result.append(current)
else: else:
result.append(format(objs)) result.append(format(objs))
for nested in collector.nested(): for nested in collector.nested():
if isinstance(nested, list): if isinstance(nested, list):
# Is lists of objects # Is lists of objects
@ -141,7 +141,7 @@ def delete_related_services(modeladmin, request, queryset):
# Prevent the deletion of the main system user, which will delete the account # Prevent the deletion of the main system user, which will delete the account
main_systemuser = nested.main_systemuser main_systemuser = nested.main_systemuser
related_services.append(format(nested, account=True)) related_services.append(format(nested, account=True))
# The user has already confirmed the deletion. # The user has already confirmed the deletion.
# Do the deletion and return a None to display the change list view again. # Do the deletion and return a None to display the change list view again.
if request.POST.get('post'): if request.POST.get('post'):
@ -165,17 +165,17 @@ def delete_related_services(modeladmin, request, queryset):
modeladmin.message_user(request, msg, messages.SUCCESS) modeladmin.message_user(request, msg, messages.SUCCESS)
# Return None to display the change list page again. # Return None to display the change list page again.
return None return None
if len(queryset) == 1: if len(queryset) == 1:
objects_name = force_text(opts.verbose_name) objects_name = force_text(opts.verbose_name)
else: else:
objects_name = force_text(opts.verbose_name_plural) objects_name = force_text(opts.verbose_name_plural)
model_count = {} model_count = {}
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.rel.to: if model is modeladmin.model.main_systemuser.field.related_model:
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:
@ -220,10 +220,10 @@ def disable_selected(modeladmin, request, queryset, disable=True):
n) n)
) )
return None return None
user = request.user user = request.user
admin_site = modeladmin.admin_site admin_site = modeladmin.admin_site
def format(obj): def format(obj):
has_admin = obj.__class__ in admin_site._registry has_admin = obj.__class__ in admin_site._registry
opts = obj._meta opts = obj._meta
@ -238,7 +238,7 @@ def disable_selected(modeladmin, request, queryset, disable=True):
except NoReverseMatch: except NoReverseMatch:
# Change url doesn't exist -- don't display link to edit # Change url doesn't exist -- don't display link to edit
return no_edit_link return no_edit_link
p = '%s.%s' % (opts.app_label, get_permission_codename('delete', opts)) p = '%s.%s' % (opts.app_label, get_permission_codename('delete', opts))
if not user.has_perm(p): if not user.has_perm(p):
perms_needed.add(opts.verbose_name) perms_needed.add(opts.verbose_name)
@ -249,19 +249,19 @@ def disable_selected(modeladmin, request, queryset, disable=True):
# Don't display link to edit, because it either has no # Don't display link to edit, because it either has no
# admin or is edited inline. # admin or is edited inline.
return no_edit_link return no_edit_link
display = [] display = []
for account in queryset: for account in queryset:
current = [] current = []
for related in account.get_services_to_disable(): for related in account.get_services_to_disable():
current.append(format(related)) current.append(format(related))
display.append([format(account), current]) display.append([format(account), current])
if len(queryset) == 1: if len(queryset) == 1:
objects_name = force_text(opts.verbose_name) objects_name = force_text(opts.verbose_name)
else: else:
objects_name = force_text(opts.verbose_name_plural) objects_name = force_text(opts.verbose_name_plural)
context = dict( context = dict(
admin_site.each_context(request), admin_site.each_context(request),
action_name='disable_selected' if disable else 'enable_selected', action_name='disable_selected' if disable else 'enable_selected',

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.core.urlresolvers import reverse from django.urls 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
@ -71,15 +71,15 @@ class AccountAdmin(ChangePasswordAdminMixin, auth.UserAdmin, ExtendedModelAdmin)
) )
change_view_actions = (disable_selected, service_report, enable_selected) change_view_actions = (disable_selected, service_report, enable_selected)
ordering = () ordering = ()
main_systemuser_link = admin_link('main_systemuser') main_systemuser_link = admin_link('main_systemuser')
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 """
if db_field.name == 'comments': if db_field.name == 'comments':
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 4}) kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 4})
return super(AccountAdmin, self).formfield_for_dbfield(db_field, **kwargs) return super(AccountAdmin, self).formfield_for_dbfield(db_field, **kwargs)
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):
if not add: if not add:
if request.method == 'GET' and not obj.is_active: if request.method == 'GET' and not obj.is_active:
@ -96,7 +96,7 @@ class AccountAdmin(ChangePasswordAdminMixin, auth.UserAdmin, ExtendedModelAdmin)
}) })
return super(AccountAdmin, self).render_change_form( return super(AccountAdmin, self).render_change_form(
request, context, add, change, form_url, obj) request, context, add, change, form_url, obj)
def get_fieldsets(self, request, obj=None): def get_fieldsets(self, request, obj=None):
fieldsets = super(AccountAdmin, self).get_fieldsets(request, obj) fieldsets = super(AccountAdmin, self).get_fieldsets(request, obj)
if not obj: if not obj:
@ -106,7 +106,7 @@ class AccountAdmin(ChangePasswordAdminMixin, auth.UserAdmin, ExtendedModelAdmin)
fieldsets = list(fieldsets) fieldsets = list(fieldsets)
fieldsets.insert(1, (_("Related services"), {'fields': fields})) fieldsets.insert(1, (_("Related services"), {'fields': fields}))
return fieldsets return fieldsets
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
if not change: if not change:
form.save_model(obj) form.save_model(obj)
@ -133,7 +133,7 @@ class AccountAdmin(ChangePasswordAdminMixin, auth.UserAdmin, ExtendedModelAdmin)
if msg: if msg:
messages.warning(request, mark_safe(msg % context)) messages.warning(request, mark_safe(msg % context))
super(AccountAdmin, self).save_model(request, obj, form, change) super(AccountAdmin, self).save_model(request, obj, form, change)
def get_change_view_actions(self, obj=None): def get_change_view_actions(self, obj=None):
views = super().get_change_view_actions(obj=obj) views = super().get_change_view_actions(obj=obj)
if obj is not None: if obj is not None:
@ -141,7 +141,7 @@ class AccountAdmin(ChangePasswordAdminMixin, auth.UserAdmin, ExtendedModelAdmin)
return [view for view in views if view.url_name != 'enable'] return [view for view in views if view.url_name != 'enable']
return [view for view in views if view.url_name != 'disable'] return [view for view in views if view.url_name != 'disable']
return views return views
def get_actions(self, request): def get_actions(self, request):
actions = super().get_actions(request) actions = super().get_actions(request)
if 'delete_selected' in actions: if 'delete_selected' in actions:
@ -157,7 +157,8 @@ class AccountListAdmin(AccountAdmin):
list_display = ('select_account', 'username', 'type', 'username') list_display = ('select_account', 'username', 'type', 'username')
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 = {
@ -167,9 +168,8 @@ 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):
app_label = request.META['PATH_INFO'].split('/')[-5] app_label = request.META['PATH_INFO'].split('/')[-5]
model = request.META['PATH_INFO'].split('/')[-4] model = request.META['PATH_INFO'].split('/')[-4]
@ -206,7 +206,8 @@ class AccountAdminMixin(object):
change_form_template = 'admin/accounts/account/change_form.html' change_form_template = 'admin/accounts/account/change_form.html'
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')
@ -215,16 +216,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):
""" Warns user when object's account is disabled """ """ Warns user when object's account is disabled """
form = super(AccountAdminMixin, self).get_form(request, obj, **kwargs) form = super(AccountAdminMixin, self).get_form(request, obj, **kwargs)
@ -247,7 +246,7 @@ class AccountAdminMixin(object):
# Not available in POST # Not available in POST
form.initial_account = self.get_changeform_initial_data(request).get('account') form.initial_account = self.get_changeform_initial_data(request).get('account')
return form return form
def get_fields(self, request, obj=None): def get_fields(self, request, obj=None):
""" remove account or account_link depending on the case """ """ remove account or account_link depending on the case """
fields = super(AccountAdminMixin, self).get_fields(request, obj) fields = super(AccountAdminMixin, self).get_fields(request, obj)
@ -263,13 +262,13 @@ class AccountAdminMixin(object):
except ValueError: except ValueError:
pass pass
return fields return fields
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
""" provide account for filter_by_account_fields """ """ provide account for filter_by_account_fields """
if obj: if obj:
self.account = obj.account self.account = obj.account
return super(AccountAdminMixin, self).get_readonly_fields(request, obj) return super(AccountAdminMixin, self).get_readonly_fields(request, obj)
def formfield_for_dbfield(self, db_field, **kwargs): def formfield_for_dbfield(self, db_field, **kwargs):
""" Filter by account """ """ Filter by account """
formfield = super(AccountAdminMixin, self).formfield_for_dbfield(db_field, **kwargs) formfield = super(AccountAdminMixin, self).formfield_for_dbfield(db_field, **kwargs)
@ -277,14 +276,14 @@ class AccountAdminMixin(object):
if self.account: if self.account:
# Hack widget render in order to append ?account=id to the add url # Hack widget render in order to append ?account=id to the add url
old_render = formfield.widget.render old_render = formfield.widget.render
def render(*args, **kwargs): def render(*args, **kwargs):
output = old_render(*args, **kwargs) output = old_render(*args, **kwargs)
output = output.replace('/add/"', '/add/?account=%s"' % self.account.pk) output = output.replace('/add/"', '/add/?account=%s"' % self.account.pk)
with_qargs = r'/add/?\1&account=%s"' % self.account.pk with_qargs = r'/add/?\1&account=%s"' % self.account.pk
output = re.sub(r'/add/\?([^".]*)"', with_qargs, output) output = re.sub(r'/add/\?([^".]*)"', with_qargs, output)
return mark_safe(output) return mark_safe(output)
formfield.widget.render = render formfield.widget.render = render
# Filter related object by account # Filter related object by account
formfield.queryset = formfield.queryset.filter(account=self.account) formfield.queryset = formfield.queryset.filter(account=self.account)
@ -302,21 +301,21 @@ class AccountAdminMixin(object):
formfield.initial = 1 formfield.initial = 1
formfield.queryset = formfield.queryset.order_by('username') formfield.queryset = formfield.queryset.order_by('username')
return formfield return formfield
def get_formset(self, request, obj=None, **kwargs): def get_formset(self, request, obj=None, **kwargs):
""" provides form.account for convinience """ """ provides form.account for convinience """
formset = super(AccountAdminMixin, self).get_formset(request, obj, **kwargs) formset = super(AccountAdminMixin, self).get_formset(request, obj, **kwargs)
formset.form.account = self.account formset.form.account = self.account
formset.account = self.account formset.account = self.account
return formset return formset
def get_account_from_preserve_filters(self, request): def get_account_from_preserve_filters(self, request):
preserved_filters = self.get_preserved_filters(request) preserved_filters = self.get_preserved_filters(request)
preserved_filters = dict(parse_qsl(preserved_filters)) preserved_filters = dict(parse_qsl(preserved_filters))
cl_filters = preserved_filters.get('_changelist_filters') cl_filters = preserved_filters.get('_changelist_filters')
if cl_filters: if cl_filters:
return dict(parse_qsl(cl_filters)).get('account') return dict(parse_qsl(cl_filters)).get('account')
def changeform_view(self, request, object_id=None, form_url='', extra_context=None): def changeform_view(self, request, object_id=None, form_url='', extra_context=None):
account_id = self.get_account_from_preserve_filters(request) account_id = self.get_account_from_preserve_filters(request)
if not object_id: if not object_id:
@ -331,7 +330,7 @@ class AccountAdminMixin(object):
context.update(extra_context or {}) context.update(extra_context or {})
return super(AccountAdminMixin, self).changeform_view( return super(AccountAdminMixin, self).changeform_view(
request, object_id, form_url=form_url, extra_context=context) request, object_id, form_url=form_url, extra_context=context)
def changelist_view(self, request, extra_context=None): def changelist_view(self, request, extra_context=None):
account_id = request.GET.get('account') account_id = request.GET.get('account')
context = {} context = {}
@ -367,7 +366,7 @@ class SelectAccountAdminMixin(AccountAdminMixin):
account = Account.objects.get(pk=request.GET['account']) account = Account.objects.get(pk=request.GET['account'])
[setattr(inline, 'account', account) for inline in inlines] [setattr(inline, 'account', account) for inline in inlines]
return inlines return inlines
def get_urls(self): def get_urls(self):
""" Hooks select account url """ """ Hooks select account url """
urls = super(AccountAdminMixin, self).get_urls() urls = super(AccountAdminMixin, self).get_urls()
@ -381,7 +380,7 @@ class SelectAccountAdminMixin(AccountAdminMixin):
name='%s_%s_select_account' % info), name='%s_%s_select_account' % info),
] ]
return select_urls + urls return select_urls + urls
def add_view(self, request, form_url='', extra_context=None): def add_view(self, request, form_url='', extra_context=None):
""" Redirects to select account view if required """ """ Redirects to select account view if required """
if request.user.is_superuser: if request.user.is_superuser:
@ -406,7 +405,7 @@ class SelectAccountAdminMixin(AccountAdminMixin):
return super(AccountAdminMixin, self).add_view( return super(AccountAdminMixin, self).add_view(
request, form_url=form_url, extra_context=context) request, form_url=form_url, extra_context=context)
return HttpResponseRedirect('./select-account/?%s' % request.META['QUERY_STRING']) return HttpResponseRedirect('./select-account/?%s' % request.META['QUERY_STRING'])
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
""" """
Given a model instance save it to the database. Given a model instance save it to the database.

View File

@ -34,7 +34,7 @@ def create_account_creation_form():
fields[field_name] = forms.BooleanField( fields[field_name] = forms.BooleanField(
initial=True, required=False, label=label, help_text=help_text) initial=True, required=False, label=label, help_text=help_text)
create_related.append((model, key, kwargs, help_text)) create_related.append((model, key, kwargs, help_text))
def clean(self, create_related=create_related): def clean(self, create_related=create_related):
""" unique usernames between accounts and system users """ """ unique usernames between accounts and system users """
cleaned_data = UserCreationForm.clean(self) cleaned_data = UserCreationForm.clean(self)
@ -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.rel.to systemuser_model = Account.main_systemuser.field.related_model
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:
@ -62,11 +62,11 @@ def create_account_creation_form():
params={'type': verbose_name}) params={'type': verbose_name})
if errors: if errors:
raise ValidationError(errors) raise ValidationError(errors)
def save_model(self, account): def save_model(self, account):
enable_systemuser=self.cleaned_data['enable_systemuser'] enable_systemuser=self.cleaned_data['enable_systemuser']
account.save(active_systemuser=enable_systemuser) account.save(active_systemuser=enable_systemuser)
def save_related(self, account): def save_related(self, account):
for model, key, related_kwargs, __ in settings.ACCOUNTS_CREATE_RELATED: for model, key, related_kwargs, __ in settings.ACCOUNTS_CREATE_RELATED:
model = apps.get_model(model) model = apps.get_model(model)
@ -76,14 +76,14 @@ def create_account_creation_form():
key: eval(value, {'account': account}) for key, value in related_kwargs.items() key: eval(value, {'account': account}) for key, value in related_kwargs.items()
} }
model.objects.create(account=account, **kwargs) model.objects.create(account=account, **kwargs)
fields.update({ fields.update({
'create_related_fields': list(fields.keys()), 'create_related_fields': list(fields.keys()),
'clean': clean, 'clean': clean,
'save_model': save_model, 'save_model': save_model,
'save_related': save_related, 'save_related': save_related,
}) })
return type('AccountCreationForm', (UserCreationForm,), fields) return type('AccountCreationForm', (UserCreationForm,), fields)

View File

@ -3,6 +3,7 @@ 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
@ -32,7 +33,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, related_name='accounts_main')), ('main_systemuser', models.ForeignKey(to='systemusers.SystemUser', editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='accounts_main')),
], ],
options={ options={
'abstract': False, 'abstract': False,

View File

@ -0,0 +1,86 @@
# -*- 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

@ -0,0 +1,25 @@
# -*- 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) related_name='accounts_main', editable=False, on_delete=models.SET_NULL)
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"))
@ -46,27 +46,28 @@ class Account(auth.AbstractBaseUser):
help_text=_("Designates whether this account should be treated as active. " help_text=_("Designates whether this account should be treated as active. "
"Unselect this instead of deleting accounts.")) "Unselect this instead of deleting accounts."))
date_joined = models.DateTimeField(_("date joined"), default=timezone.now) date_joined = models.DateTimeField(_("date joined"), default=timezone.now)
objects = AccountManager() objects = AccountManager()
USERNAME_FIELD = 'username' USERNAME_FIELD = 'username'
REQUIRED_FIELDS = ['email'] REQUIRED_FIELDS = ['email']
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
# ignore `is_staff` kwarg because is handled with `is_superuser`
kwargs.pop('is_staff', None) kwargs.pop('is_staff', None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def __str__(self): def __str__(self):
return self.name return self.name
@property @property
def name(self): def name(self):
return self.username return self.username
@property @property
def is_staff(self): def is_staff(self):
return self.is_superuser return self.is_superuser
def save(self, active_systemuser=False, *args, **kwargs): def save(self, active_systemuser=False, *args, **kwargs):
created = not self.pk created = not self.pk
if not created: if not created:
@ -79,21 +80,21 @@ class Account(auth.AbstractBaseUser):
self.save(update_fields=('main_systemuser',)) self.save(update_fields=('main_systemuser',))
elif was_active != self.is_active: elif was_active != self.is_active:
self.notify_related() self.notify_related()
def clean(self): def clean(self):
self.short_name = self.short_name.strip() self.short_name = self.short_name.strip()
self.full_name = self.full_name.strip() self.full_name = self.full_name.strip()
def disable(self): def disable(self):
self.is_active = False self.is_active = False
self.save(update_fields=('is_active',)) self.save(update_fields=('is_active',))
self.notify_related() self.notify_related()
def enable(self): def enable(self):
self.is_active = True self.is_active = True
self.save(update_fields=('is_active',)) self.save(update_fields=('is_active',))
self.notify_related() self.notify_related()
def get_services_to_disable(self): def get_services_to_disable(self):
related_fields = [ related_fields = [
f for f in self._meta.get_fields() f for f in self._meta.get_fields()
@ -105,20 +106,20 @@ class Account(auth.AbstractBaseUser):
if source in core.services and hasattr(source, 'active'): if source in core.services and hasattr(source, 'active'):
for obj in getattr(self, rel.get_accessor_name()).all(): for obj in getattr(self, rel.get_accessor_name()).all():
yield obj yield obj
def notify_related(self): def notify_related(self):
""" Trigger save() on related objects that depend on this account """ """ Trigger save() on related objects that depend on this account """
for obj in self.get_services_to_disable(): for obj in self.get_services_to_disable():
signals.pre_save.send(sender=type(obj), instance=obj) signals.pre_save.send(sender=type(obj), instance=obj)
signals.post_save.send(sender=type(obj), instance=obj) signals.post_save.send(sender=type(obj), instance=obj)
# OperationsMiddleware.collect(Operation.SAVE, instance=obj, update_fields=()) # OperationsMiddleware.collect(Operation.SAVE, instance=obj, update_fields=())
def get_contacts_emails(self, usages=None): def get_contacts_emails(self, usages=None):
contacts = self.contacts.all() contacts = self.contacts.all()
if usages is not None: if usages is not None:
contactes = contacts.filter(email_usages=usages) contactes = contacts.filter(email_usages=usages)
return contacts.values_list('email', flat=True) return contacts.values_list('email', flat=True)
def send_email(self, template, context, email_from=None, usages=None, attachments=[], html=None): def send_email(self, template, context, email_from=None, usages=None, attachments=[], html=None):
contacts = self.contacts.filter(email_usages=usages) contacts = self.contacts.filter(email_usages=usages)
email_to = self.get_contacts_emails(usages) email_to = self.get_contacts_emails(usages)
@ -130,14 +131,14 @@ class Account(auth.AbstractBaseUser):
with translation.override(self.language): with translation.override(self.language):
send_email_template(template, extra_context, email_to, email_from=email_from, send_email_template(template, extra_context, email_to, email_from=email_from,
html=html, attachments=attachments) html=html, attachments=attachments)
def get_full_name(self): def get_full_name(self):
return self.full_name or self.short_name or self.username return self.full_name or self.short_name or self.username
def get_short_name(self): def get_short_name(self):
""" Returns the short name for the user """ """ Returns the short name for the user """
return self.short_name or self.username or self.full_name return self.short_name or self.username or self.full_name
def has_perm(self, perm, obj=None): def has_perm(self, perm, obj=None):
""" """
Returns True if the user has the specified permission. This method Returns True if the user has the specified permission. This method
@ -164,7 +165,7 @@ class Account(auth.AbstractBaseUser):
elif obj and getattr(obj, 'account', None) == self: elif obj and getattr(obj, 'account', None) == self:
return True return True
def has_perms(self, perm_list, obj=None): def has_perms(self, perm_list, obj=None):
""" """
Returns True if the user has each of the specified permissions. If Returns True if the user has each of the specified permissions. If
@ -175,7 +176,7 @@ class Account(auth.AbstractBaseUser):
if not self.has_perm(perm, obj): if not self.has_perm(perm, obj):
return False return False
return True return True
def has_module_perms(self, app_label): def has_module_perms(self, app_label):
""" """
Returns True if the user has any permissions in the given app label. Returns True if the user has any permissions in the given app label.

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.core.urlresolvers import reverse from django.urls 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
@ -179,7 +179,7 @@ def undo_billing(modeladmin, request, queryset):
group[line.order].append(line) group[line.order].append(line)
except KeyError: except KeyError:
group[line.order] = [line] group[line.order] = [line]
# Validate # Validate
for order, lines in group.items(): for order, lines in group.items():
prev = None prev = None
@ -211,7 +211,7 @@ def undo_billing(modeladmin, request, queryset):
messages.error(request, "Order does not have lines!.") messages.error(request, "Order does not have lines!.")
order.billed_until = billed_until order.billed_until = billed_until
order.billed_on = billed_on order.billed_on = billed_on
# Commit changes # Commit changes
norders, nlines = 0, 0 norders, nlines = 0, 0
for order, lines in group.items(): for order, lines in group.items():
@ -221,7 +221,7 @@ def undo_billing(modeladmin, request, queryset):
# TODO update order history undo billing # TODO update order history undo billing
order.save(update_fields=('billed_until', 'billed_on')) order.save(update_fields=('billed_until', 'billed_on'))
norders += 1 norders += 1
messages.success(request, _("%(norders)s orders and %(nlines)s lines undoed.") % { messages.success(request, _("%(norders)s orders and %(nlines)s lines undoed.") % {
'nlines': nlines, 'nlines': nlines,
'norders': norders 'norders': norders

View File

@ -2,11 +2,12 @@ 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.core.urlresolvers import reverse from django.urls 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
@ -15,7 +16,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,
@ -39,18 +40,18 @@ PAYMENT_STATE_COLORS = {
class BillSublineInline(admin.TabularInline): class BillSublineInline(admin.TabularInline):
model = BillSubline model = BillSubline
fields = ('description', 'total', 'type') fields = ('description', 'total', 'type')
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
fields = super().get_readonly_fields(request, obj) fields = super().get_readonly_fields(request, obj)
if obj and not obj.bill.is_open: if obj and not obj.bill.is_open:
return self.get_fields(request) return self.get_fields(request)
return fields return fields
def get_max_num(self, request, obj=None): def get_max_num(self, request, obj=None):
if obj and not obj.bill.is_open: if obj and not obj.bill.is_open:
return 0 return 0
return super().get_max_num(request, obj) return super().get_max_num(request, obj)
def has_delete_permission(self, request, obj=None): def has_delete_permission(self, request, obj=None):
if obj and not obj.bill.is_open: if obj and not obj.bill.is_open:
return False return False
@ -64,9 +65,10 @@ class BillLineInline(admin.TabularInline):
'subtotal', 'display_total', 'subtotal', 'display_total',
) )
readonly_fields = ('display_total', 'order_link') readonly_fields = ('display_total', 'order_link')
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()
@ -78,8 +80,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 """
if db_field.name == 'description': if db_field.name == 'description':
@ -87,7 +88,7 @@ class BillLineInline(admin.TabularInline):
elif db_field.name not in ('start_on', 'end_on'): elif db_field.name not in ('start_on', 'end_on'):
kwargs['widget'] = forms.TextInput(attrs={'size':'6'}) kwargs['widget'] = forms.TextInput(attrs={'size':'6'})
return super().formfield_for_dbfield(db_field, **kwargs) return super().formfield_for_dbfield(db_field, **kwargs)
def get_queryset(self, request): def get_queryset(self, request):
qs = super().get_queryset(request) qs = super().get_queryset(request)
return qs.prefetch_related('sublines').select_related('order') return qs.prefetch_related('sublines').select_related('order')
@ -96,36 +97,35 @@ class BillLineInline(admin.TabularInline):
class ClosedBillLineInline(BillLineInline): class ClosedBillLineInline(BillLineInline):
# TODO reimplement as nested inlines when upstream # TODO reimplement as nested inlines when upstream
# https://code.djangoproject.com/ticket/9025 # https://code.djangoproject.com/ticket/9025
fields = ( fields = (
'display_description', 'order_link', 'start_on', 'end_on', 'rate', 'quantity', 'tax', 'display_description', 'order_link', 'start_on', 'end_on', 'rate', 'quantity', 'tax',
'display_subtotal', 'display_total' 'display_subtotal', 'display_total'
) )
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
@ -158,28 +158,28 @@ class BillLineAdmin(admin.ModelAdmin):
list_select_related = ('bill', 'bill__account') list_select_related = ('bill', 'bill__account')
search_fields = ('description', 'bill__number') search_fields = ('description', 'bill__number')
inlines = (BillSublineInline,) inlines = (BillSublineInline,)
account_link = admin_link('bill__account') account_link = admin_link('bill__account')
bill_link = admin_link('bill') bill_link = admin_link('bill')
order_link = admin_link('order') order_link = admin_link('order')
amended_line_link = admin_link('amended_line') amended_line_link = admin_link('amended_line')
def display_is_open(self, instance): def display_is_open(self, instance):
return instance.bill.is_open return instance.bill.is_open
display_is_open.short_description = _("Is open") display_is_open.short_description = _("Is open")
display_is_open.boolean = True display_is_open.boolean = True
def display_sublinetotal(self, instance): def display_sublinetotal(self, instance):
total = instance.subline_total total = instance.subline_total
return total if total is not None else '---' return total if total is not None else '---'
display_sublinetotal.short_description = _("Sublines") display_sublinetotal.short_description = _("Sublines")
display_sublinetotal.admin_order_field = 'subline_total' display_sublinetotal.admin_order_field = 'subline_total'
def display_total(self, instance): def display_total(self, instance):
return round(instance.computed_total or 0, 2) return round(instance.computed_total or 0, 2)
display_total.short_description = _("Total") display_total.short_description = _("Total")
display_total.admin_order_field = 'computed_total' display_total.admin_order_field = 'computed_total'
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
fields = super().get_readonly_fields(request, obj) fields = super().get_readonly_fields(request, obj)
if obj and not obj.bill.is_open: if obj and not obj.bill.is_open:
@ -188,7 +188,7 @@ class BillLineAdmin(admin.ModelAdmin):
'subtotal', 'order_billed_on', 'order_billed_until' 'subtotal', 'order_billed_on', 'order_billed_until'
] ]
return fields return fields
def get_queryset(self, request): def get_queryset(self, request):
qs = super().get_queryset(request) qs = super().get_queryset(request)
qs = qs.annotate( qs = qs.annotate(
@ -196,7 +196,7 @@ class BillLineAdmin(admin.ModelAdmin):
computed_total=(F('subtotal') + Sum(Coalesce('sublines__total', 0))) * (1+F('tax')/100), computed_total=(F('subtotal') + Sum(Coalesce('sublines__total', 0))) * (1+F('tax')/100),
) )
return qs return qs
def has_delete_permission(self, request, obj=None): def has_delete_permission(self, request, obj=None):
if obj and not obj.bill.is_open: if obj and not obj.bill.is_open:
return False return False
@ -209,7 +209,7 @@ class BillLineManagerAdmin(BillLineAdmin):
if self.bill_ids: if self.bill_ids:
return qset.filter(bill_id__in=self.bill_ids) return qset.filter(bill_id__in=self.bill_ids)
return qset return qset
def changelist_view(self, request, extra_context=None): def changelist_view(self, request, extra_context=None):
GET_copy = request.GET.copy() GET_copy = request.GET.copy()
bill_ids = GET_copy.pop('ids', None) bill_ids = GET_copy.pop('ids', None)
@ -242,6 +242,7 @@ 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()
@ -251,10 +252,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
@ -276,7 +277,6 @@ 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):
@ -304,9 +304,9 @@ class AmendInline(BillAdminMixin, admin.TabularInline):
verbose_name_plural = _("Amends") verbose_name_plural = _("Amends")
can_delete = False can_delete = False
extra = 0 extra = 0
self_link = admin_link('__str__') self_link = admin_link('__str__')
def has_add_permission(self, *args, **kwargs): def has_add_permission(self, *args, **kwargs):
return False return False
@ -354,12 +354,12 @@ class BillAdmin(BillAdminMixin, ExtendedModelAdmin):
'closed_on_display', 'updated_on_display', 'display_total_with_subtotals', 'closed_on_display', 'updated_on_display', 'display_total_with_subtotals',
) )
date_hierarchy = 'closed_on' date_hierarchy = 'closed_on'
created_on_display = admin_date('created_on', short_description=_("Created")) created_on_display = admin_date('created_on', short_description=_("Created"))
closed_on_display = admin_date('closed_on', short_description=_("Closed")) closed_on_display = admin_date('closed_on', short_description=_("Closed"))
updated_on_display = admin_date('updated_on', short_description=_("Updated")) updated_on_display = admin_date('updated_on', short_description=_("Updated"))
amend_of_link = admin_link('amend_of') amend_of_link = admin_link('amend_of')
# def amend_links(self, bill): # def amend_links(self, bill):
# links = [] # links = []
# for amend in bill.amends.all(): # for amend in bill.amends.all():
@ -368,27 +368,25 @@ class BillAdmin(BillAdminMixin, ExtendedModelAdmin):
# return '<br>'.join(links) # return '<br>'.join(links)
# amend_links.short_description = _("Amends") # amend_links.short_description = _("Amends")
# amend_links.allow_tags = True # amend_links.allow_tags = True
def num_lines(self, bill): def num_lines(self, bill):
return bill.lines__count return bill.lines__count
num_lines.admin_order_field = 'lines__count' num_lines.admin_order_field = 'lines__count'
num_lines.short_description = _("lines") num_lines.short_description = _("lines")
def display_total(self, bill): def display_total(self, bill):
currency = settings.BILLS_CURRENCY.lower() currency = settings.BILLS_CURRENCY.lower()
return '%s &%s;' % (bill.compute_total(), currency) return format_html('{} &{};', 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 '<a href="%s">%s</a>' % (url, bill.get_type_display()) return format_html('<a href="{}">{}</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'
def get_urls(self): def get_urls(self):
""" Hook bill lines management URLs on bill admin """ """ Hook bill lines management URLs on bill admin """
urls = super().get_urls() urls = super().get_urls()
@ -399,13 +397,13 @@ class BillAdmin(BillAdminMixin, ExtendedModelAdmin):
name='bills_bill_manage_lines'), name='bills_bill_manage_lines'),
] ]
return extra_urls + urls return extra_urls + urls
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
fields = super().get_readonly_fields(request, obj) fields = super().get_readonly_fields(request, obj)
if obj and not obj.is_open: if obj and not obj.is_open:
fields += self.add_fields fields += self.add_fields
return fields return fields
def get_fieldsets(self, request, obj=None): def get_fieldsets(self, request, obj=None):
fieldsets = super().get_fieldsets(request, obj) fieldsets = super().get_fieldsets(request, obj)
if obj: if obj:
@ -418,7 +416,7 @@ class BillAdmin(BillAdminMixin, ExtendedModelAdmin):
if obj.is_open: if obj.is_open:
fieldsets = fieldsets[0:-1] fieldsets = fieldsets[0:-1]
return fieldsets return fieldsets
def get_change_view_actions(self, obj=None): def get_change_view_actions(self, obj=None):
actions = super().get_change_view_actions(obj) actions = super().get_change_view_actions(obj)
exclude = [] exclude = []
@ -428,7 +426,7 @@ class BillAdmin(BillAdminMixin, ExtendedModelAdmin):
if obj.type not in obj.AMEND_MAP: if obj.type not in obj.AMEND_MAP:
exclude += ['amend_bills'] exclude += ['amend_bills']
return [action for action in actions if action.__name__ not in exclude] return [action for action in actions if action.__name__ not in exclude]
def get_inline_instances(self, request, obj=None): def get_inline_instances(self, request, obj=None):
cls = type(self) cls = type(self)
if obj and not obj.is_open: if obj and not obj.is_open:
@ -439,7 +437,7 @@ class BillAdmin(BillAdminMixin, ExtendedModelAdmin):
else: else:
cls.inlines = [BillLineInline] cls.inlines = [BillLineInline]
return super().get_inline_instances(request, obj) return super().get_inline_instances(request, obj)
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 """
if db_field.name == 'comments': if db_field.name == 'comments':
@ -450,7 +448,7 @@ class BillAdmin(BillAdminMixin, ExtendedModelAdmin):
if db_field.name == 'amend_of': if db_field.name == 'amend_of':
formfield.queryset = formfield.queryset.filter(is_open=False) formfield.queryset = formfield.queryset.filter(is_open=False)
return formfield return formfield
def change_view(self, request, object_id, **kwargs): def change_view(self, request, object_id, **kwargs):
# TODO raise404, here and everywhere # TODO raise404, here and everywhere
bill = self.get_object(request, unquote(object_id)) bill = self.get_object(request, unquote(object_id))
@ -471,7 +469,7 @@ admin.site.register(BillLine, BillLineAdmin)
class BillContactInline(admin.StackedInline): class BillContactInline(admin.StackedInline):
model = BillContact model = BillContact
fields = ('name', 'address', ('city', 'zipcode'), 'country', 'vat') fields = ('name', 'address', ('city', 'zipcode'), 'country', 'vat')
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 """
if db_field.name == 'name': if db_field.name == 'name':
@ -479,7 +477,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 detail_route from rest_framework.decorators import action
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
@ -14,8 +14,8 @@ from .serializers import BillSerializer
class BillViewSet(LogApiMixin, AccountApiMixin, viewsets.ModelViewSet): class BillViewSet(LogApiMixin, AccountApiMixin, viewsets.ModelViewSet):
queryset = Bill.objects.all() queryset = Bill.objects.all()
serializer_class = BillSerializer serializer_class = BillSerializer
@detail_route(methods=['get']) @action(detail=True, 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.core.urlresolvers import reverse from django.urls 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 _
@ -11,11 +11,11 @@ class BillTypeListFilter(SimpleListFilter):
""" Filter tickets by created_by according to request.user """ """ Filter tickets by created_by according to request.user """
title = 'Type' title = 'Type'
parameter_name = '' parameter_name = ''
def __init__(self, request, *args, **kwargs): def __init__(self, request, *args, **kwargs):
super(BillTypeListFilter, self).__init__(request, *args, **kwargs) super(BillTypeListFilter, self).__init__(request, *args, **kwargs)
self.request = request self.request = request
def lookups(self, request, model_admin): def lookups(self, request, model_admin):
return ( return (
('bill', _("All")), ('bill', _("All")),
@ -25,13 +25,13 @@ class BillTypeListFilter(SimpleListFilter):
('amendmentfee', _("Amendment fee")), ('amendmentfee', _("Amendment fee")),
('amendmentinvoice', _("Amendment invoice")), ('amendmentinvoice', _("Amendment invoice")),
) )
def queryset(self, request, queryset): def queryset(self, request, queryset):
return queryset return queryset
def value(self): def value(self):
return self.request.path.split('/')[-2] return self.request.path.split('/')[-2]
def choices(self, cl): def choices(self, cl):
query = self.request.GET.urlencode() query = self.request.GET.urlencode()
for lookup, title in self.lookup_choices: for lookup, title in self.lookup_choices:
@ -45,7 +45,7 @@ class BillTypeListFilter(SimpleListFilter):
class TotalListFilter(SimpleListFilter): class TotalListFilter(SimpleListFilter):
title = _("total") title = _("total")
parameter_name = 'total' parameter_name = 'total'
def lookups(self, request, model_admin): def lookups(self, request, model_admin):
return ( return (
('gt', mark_safe("total &gt; 0")), ('gt', mark_safe("total &gt; 0")),
@ -53,7 +53,7 @@ class TotalListFilter(SimpleListFilter):
('eq', "total = 0"), ('eq', "total = 0"),
('ne', mark_safe("total &ne; 0")), ('ne', mark_safe("total &ne; 0")),
) )
def queryset(self, request, queryset): def queryset(self, request, queryset):
if self.value() == 'gt': if self.value() == 'gt':
return queryset.filter(approx_total__gt=0) return queryset.filter(approx_total__gt=0)
@ -70,13 +70,13 @@ class HasBillContactListFilter(SimpleListFilter):
""" Filter Nodes by group according to request.user """ """ Filter Nodes by group according to request.user """
title = _("has bill contact") title = _("has bill contact")
parameter_name = 'bill' parameter_name = 'bill'
def lookups(self, request, model_admin): def lookups(self, request, model_admin):
return ( return (
('True', _("Yes")), ('True', _("Yes")),
('False', _("No")), ('False', _("No")),
) )
def queryset(self, request, queryset): def queryset(self, request, queryset):
if self.value() == 'True': if self.value() == 'True':
return queryset.filter(billcontact__isnull=False) return queryset.filter(billcontact__isnull=False)
@ -87,7 +87,7 @@ class HasBillContactListFilter(SimpleListFilter):
class PaymentStateListFilter(SimpleListFilter): class PaymentStateListFilter(SimpleListFilter):
title = _("payment state") title = _("payment state")
parameter_name = 'payment_state' parameter_name = 'payment_state'
def lookups(self, request, model_admin): def lookups(self, request, model_admin):
return ( return (
('OPEN', _("Open")), ('OPEN', _("Open")),
@ -95,7 +95,7 @@ class PaymentStateListFilter(SimpleListFilter):
('PENDING', _("Pending")), ('PENDING', _("Pending")),
('BAD_DEBT', _("Bad debt")), ('BAD_DEBT', _("Bad debt")),
) )
def queryset(self, request, queryset): def queryset(self, request, queryset):
# FIXME use queryset.computed_total instead of approx_total, bills.admin.BillAdmin.get_queryset # FIXME use queryset.computed_total instead of approx_total, bills.admin.BillAdmin.get_queryset
Transaction = queryset.model.transactions.field.remote_field.related_model Transaction = queryset.model.transactions.field.remote_field.related_model
@ -137,7 +137,7 @@ class PaymentStateListFilter(SimpleListFilter):
class AmendedListFilter(SimpleListFilter): class AmendedListFilter(SimpleListFilter):
title = _("amended") title = _("amended")
parameter_name = 'amended' parameter_name = 'amended'
def lookups(self, request, model_admin): def lookups(self, request, model_admin):
return ( return (
('3', _("Closed amends")), ('3', _("Closed amends")),
@ -145,7 +145,7 @@ class AmendedListFilter(SimpleListFilter):
('1', _("Any amends")), ('1', _("Any amends")),
('0', _("No amends")), ('0', _("No amends")),
) )
def queryset(self, request, queryset): def queryset(self, request, queryset):
if self.value() is None: if self.value() is None:
return queryset return queryset

View File

@ -1,5 +1,5 @@
from django.contrib import messages from django.contrib import messages
from django.core.urlresolvers import reverse from django.urls 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.rel.to.objects.get_main() main = type(bill).account.field.related_model.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,6 +1,7 @@
# -*- 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
@ -14,7 +15,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, related_name='amends', verbose_name='amend of', null=True), field=models.ForeignKey(to='bills.Bill', blank=True, on_delete=django.db.models.deletion.CASCADE, 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.core.urlresolvers import reverse from django.urls 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, Context from django.template import loader
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') related_name='billcontact', on_delete=models.CASCADE)
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"))
@ -36,13 +36,13 @@ class BillContact(models.Model):
choices=settings.BILLS_CONTACT_COUNTRIES, choices=settings.BILLS_CONTACT_COUNTRIES,
default=settings.BILLS_CONTACT_DEFAULT_COUNTRY) default=settings.BILLS_CONTACT_DEFAULT_COUNTRY)
vat = models.CharField(_("VAT number"), max_length=64) vat = models.CharField(_("VAT number"), max_length=64)
def __str__(self): def __str__(self):
return self.name return self.name
def get_name(self): def get_name(self):
return self.name or self.account.get_full_name() return self.name or self.account.get_full_name()
def clean(self): def clean(self):
self.vat = self.vat.strip() self.vat = self.vat.strip()
self.city = self.city.strip() self.city = self.city.strip()
@ -99,12 +99,12 @@ class Bill(models.Model):
INVOICE: AMENDMENTINVOICE, INVOICE: AMENDMENTINVOICE,
FEE: AMENDMENTFEE, FEE: AMENDMENTFEE,
} }
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') related_name='%(class)s', on_delete=models.CASCADE)
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') related_name='amends', on_delete=models.SET_NULL)
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)
@ -115,37 +115,37 @@ class Bill(models.Model):
# total = models.DecimalField(max_digits=12, decimal_places=2, null=True) # total = models.DecimalField(max_digits=12, decimal_places=2, null=True)
comments = models.TextField(_("comments"), blank=True) comments = models.TextField(_("comments"), blank=True)
html = models.TextField(_("HTML"), blank=True) html = models.TextField(_("HTML"), blank=True)
objects = BillManager() objects = BillManager()
class Meta: class Meta:
get_latest_by = 'id' get_latest_by = 'id'
def __str__(self): def __str__(self):
return self.number return self.number
@classmethod @classmethod
def get_class_type(cls): def get_class_type(cls):
if cls is models.DEFERRED: if cls is models.DEFERRED:
cls = cls.__base__ cls = cls.__base__
return cls.__name__.upper() return cls.__name__.upper()
@cached_property @cached_property
def total(self): def total(self):
return self.compute_total() return self.compute_total()
@cached_property @cached_property
def seller(self): def seller(self):
return Account.objects.get_main().billcontact return Account.objects.get_main().billcontact
@cached_property @cached_property
def buyer(self): def buyer(self):
return self.account.billcontact return self.account.billcontact
@property @property
def has_multiple_pages(self): def has_multiple_pages(self):
return self.type != self.FEE return self.type != self.FEE
@cached_property @cached_property
def payment_state(self): def payment_state(self):
if self.is_open or self.get_type() == self.PROFORMA: if self.is_open or self.get_type() == self.PROFORMA:
@ -192,7 +192,7 @@ class Bill(models.Model):
elif executed: elif executed:
return self.EXECUTED return self.EXECUTED
return self.BAD_DEBT return self.BAD_DEBT
def clean(self): def clean(self):
if self.amend_of_id: if self.amend_of_id:
errors = {} errors = {}
@ -206,27 +206,27 @@ class Bill(models.Model):
errors['amend_of'] = _("Related invoice is an amendment.") errors['amend_of'] = _("Related invoice is an amendment.")
if errors: if errors:
raise ValidationError(errors) raise ValidationError(errors)
def get_payment_state_display(self): def get_payment_state_display(self):
value = self.payment_state value = self.payment_state
return force_text(dict(self.PAYMENT_STATES).get(value, value)) return force_text(dict(self.PAYMENT_STATES).get(value, value))
def get_current_transaction(self): def get_current_transaction(self):
return self.transactions.exclude_rejected().first() return self.transactions.exclude_rejected().first()
def get_type(self): def get_type(self):
return self.type or self.get_class_type() return self.type or self.get_class_type()
@property @property
def is_amend(self): def is_amend(self):
return self.type in self.AMEND_MAP.values() return self.type in self.AMEND_MAP.values()
def get_amend_type(self): def get_amend_type(self):
amend_type = self.AMEND_MAP.get(self.type) amend_type = self.AMEND_MAP.get(self.type)
if amend_type is None: if amend_type is None:
raise TypeError("%s has no associated amend type." % self.type) raise TypeError("%s has no associated amend type." % self.type)
return amend_type return amend_type
def get_number(self): def get_number(self):
cls = type(self) cls = type(self)
if cls is models.DEFERRED: if cls is models.DEFERRED:
@ -250,16 +250,16 @@ class Bill(models.Model):
zeros = (number_length - len(str(number))) * '0' zeros = (number_length - len(str(number))) * '0'
number = zeros + str(number) number = zeros + str(number)
return '{prefix}{year}{number}'.format(prefix=prefix, year=year, number=number) return '{prefix}{year}{number}'.format(prefix=prefix, year=year, number=number)
def get_due_date(self, payment=None): def get_due_date(self, payment=None):
now = timezone.now() now = timezone.now()
if payment: if payment:
return now + payment.get_due_delta() return now + payment.get_due_delta()
return now + relativedelta(months=1) return now + relativedelta(months=1)
def get_absolute_url(self): def get_absolute_url(self):
return reverse('admin:bills_bill_view', args=(self.pk,)) return reverse('admin:bills_bill_view', args=(self.pk,))
def close(self, payment=False): def close(self, payment=False):
if not self.is_open: if not self.is_open:
raise TypeError("Bill not in Open state.") raise TypeError("Bill not in Open state.")
@ -278,10 +278,10 @@ class Bill(models.Model):
self.html = self.render(payment=payment) self.html = self.render(payment=payment)
self.save() self.save()
return transaction return transaction
def get_billing_contact_emails(self): def get_billing_contact_emails(self):
return self.account.get_contacts_emails(usages=(Contact.BILLING,)) return self.account.get_contacts_emails(usages=(Contact.BILLING,))
def send(self): def send(self):
pdf = self.as_pdf() pdf = self.as_pdf()
self.account.send_email( self.account.send_email(
@ -298,12 +298,12 @@ class Bill(models.Model):
) )
self.is_sent = True self.is_sent = True
self.save(update_fields=['is_sent']) self.save(update_fields=['is_sent'])
def render(self, payment=False, language=None): def render(self, payment=False, language=None):
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,29 +318,29 @@ 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)
html = bill_template.render(context) html = bill_template.render(context)
html = html.replace('-pageskip-', '<pdf:nextpage />') html = html.replace('-pageskip-', '<pdf:nextpage />')
return html return html
def as_pdf(self): def as_pdf(self):
html = self.html or self.render() html = self.html or self.render()
return html_to_pdf(html, pagination=self.has_multiple_pages) return html_to_pdf(html, pagination=self.has_multiple_pages)
def updated(self): def updated(self):
self.updated_on = timezone.now() self.updated_on = timezone.now()
self.save(update_fields=('updated_on',)) self.save(update_fields=('updated_on',))
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.type: if not self.type:
self.type = self.get_type() self.type = self.get_type()
if not self.number: if not self.number:
self.number = self.get_number() self.number = self.get_number()
super(Bill, self).save(*args, **kwargs) super(Bill, self).save(*args, **kwargs)
@cached @cached
def compute_subtotals(self): def compute_subtotals(self):
subtotals = {} subtotals = {}
@ -354,21 +354,21 @@ class Bill(models.Model):
for tax, subtotal in subtotals.items(): for tax, subtotal in subtotals.items():
result[tax] = [subtotal, round(tax/100*subtotal, 2)] result[tax] = [subtotal, round(tax/100*subtotal, 2)]
return result return result
@cached @cached
def compute_base(self): def compute_base(self):
bases = self.lines.annotate( bases = self.lines.annotate(
bases=F('subtotal') + Sum(Coalesce('sublines__total', 0)) bases=F('subtotal') + Sum(Coalesce('sublines__total', 0))
) )
return round(bases.aggregate(Sum('bases'))['bases__sum'] or 0, 2) return round(bases.aggregate(Sum('bases'))['bases__sum'] or 0, 2)
@cached @cached
def compute_tax(self): def compute_tax(self):
taxes = self.lines.annotate( taxes = self.lines.annotate(
taxes=(F('subtotal') + Coalesce(Sum('sublines__total'), 0)) * (F('tax')/100) taxes=(F('subtotal') + Coalesce(Sum('sublines__total'), 0)) * (F('tax')/100)
) )
return round(taxes.aggregate(Sum('taxes'))['taxes__sum'] or 0, 2) return round(taxes.aggregate(Sum('taxes'))['taxes__sum'] or 0, 2)
@cached @cached
def compute_total(self): def compute_total(self):
if 'lines' in getattr(self, '_prefetched_objects_cache', ()): if 'lines' in getattr(self, '_prefetched_objects_cache', ()):
@ -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') bill = models.ForeignKey(Bill, verbose_name=_("bill"), related_name='lines', on_delete=models.CASCADE)
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,24 +434,24 @@ 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) related_name='amendment_lines', null=True, blank=True, on_delete=models.CASCADE)
class Meta: class Meta:
get_latest_by = 'id' get_latest_by = 'id'
def __str__(self): def __str__(self):
return "#%i" % self.pk if self.pk else self.description return "#%i" % self.pk if self.pk else self.description
def get_verbose_quantity(self): def get_verbose_quantity(self):
return self.verbose_quantity or self.quantity return self.verbose_quantity or self.quantity
def clean(self): def clean(self):
if not self.verbose_quantity: if not self.verbose_quantity:
quantity = str(self.quantity) quantity = str(self.quantity)
# Strip trailing zeros # Strip trailing zeros
if quantity.endswith('0'): if quantity.endswith('0'):
self.verbose_quantity = quantity.strip('0').strip('.') self.verbose_quantity = quantity.strip('0').strip('.')
def get_verbose_period(self): def get_verbose_period(self):
from django.template.defaultfilters import date from django.template.defaultfilters import date
date_format = "N 'y" date_format = "N 'y"
@ -467,7 +467,7 @@ class BillLine(models.Model):
if ini == end: if ini == end:
return ini return ini
return "{ini} / {end}".format(ini=ini, end=end) return "{ini} / {end}".format(ini=ini, end=end)
@cached @cached
def compute_total(self): def compute_total(self):
total = self.subtotal or 0 total = self.subtotal or 0
@ -478,7 +478,7 @@ class BillLine(models.Model):
else: else:
total += self.sublines.aggregate(sub_total=Sum('total'))['sub_total'] or 0 total += self.sublines.aggregate(sub_total=Sum('total'))['sub_total'] or 0
return round(total, 2) return round(total, 2)
def get_absolute_url(self): def get_absolute_url(self):
return change_url(self) return change_url(self)
@ -493,12 +493,12 @@ class BillSubline(models.Model):
(COMPENSATION, _("Compensation")), (COMPENSATION, _("Compensation")),
(OTHER, _("Other")), (OTHER, _("Other")),
) )
# TODO: order info for undoing # TODO: order info for undoing
line = models.ForeignKey(BillLine, verbose_name=_("bill line"), related_name='sublines') line = models.ForeignKey(BillLine, verbose_name=_("bill line"), related_name='sublines', on_delete=models.CASCADE)
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)
def __str__(self): def __str__(self):
return "%s %i" % (self.description, self.total) return "%s %i" % (self.description, self.total)

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
@ -61,18 +61,18 @@ class ContactAdmin(AccountAdminMixin, ExtendedModelAdmin):
}), }),
) )
actions = (SendEmail(), list_accounts) actions = (SendEmail(), list_accounts)
def dispaly_name(self, contact): def dispaly_name(self, contact):
return str(contact) return str(contact)
dispaly_name.short_description = _("Name") dispaly_name.short_description = _("Name")
dispaly_name.admin_order_field = 'short_name' dispaly_name.admin_order_field = 'short_name'
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 """
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)
@ -86,14 +86,14 @@ class ContactInline(admin.StackedInline):
fields = ( fields = (
('short_name', 'full_name'), 'email', 'email_usage', ('phone', 'phone2'), ('short_name', 'full_name'), 'email', 'email_usage', ('phone', 'phone2'),
) )
def get_extra(self, request, obj=None, **kwargs): def get_extra(self, request, obj=None, **kwargs):
return 0 if obj and obj.contacts.exists() else 1 return 0 if obj and obj.contacts.exists() else 1
def get_view_on_site_url(self, obj=None): def get_view_on_site_url(self, obj=None):
if obj: if obj:
return change_url(obj) return change_url(obj)
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 """
if db_field.name == 'short_name': if db_field.name == 'short_name':
@ -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

@ -29,11 +29,11 @@ class Contact(models.Model):
('ADDS', _("Announcements")), ('ADDS', _("Announcements")),
('EMERGENCY', _("Emergency contact")), ('EMERGENCY', _("Emergency contact")),
) )
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) related_name='contacts', null=True, on_delete=models.SET_NULL)
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()
@ -54,10 +54,10 @@ class Contact(models.Model):
country = models.CharField(_("country"), max_length=20, blank=True, country = models.CharField(_("country"), max_length=20, blank=True,
choices=settings.CONTACTS_COUNTRIES, choices=settings.CONTACTS_COUNTRIES,
default=settings.CONTACTS_DEFAULT_COUNTRY) default=settings.CONTACTS_DEFAULT_COUNTRY)
def __str__(self): def __str__(self):
return self.full_name or self.short_name return self.full_name or self.short_name
def clean(self): def clean(self):
self.short_name = self.short_name.strip() self.short_name = self.short_name.strip()
self.full_name = self.full_name.strip() self.full_name = self.full_name.strip()

View File

@ -1,6 +1,8 @@
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
@ -49,17 +51,17 @@ class DatabaseAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
filter_by_account_fields = ('users',) filter_by_account_fields = ('users',)
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 = '<a href="%s">%s</a>' % (change_url(user), user.username) link = format_html('<a href="{}">{}</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):
super(DatabaseAdmin, self).save_model(request, obj, form, change) super(DatabaseAdmin, self).save_model(request, obj, form, change)
if not change: if not change:
@ -98,24 +100,24 @@ class DatabaseUserAdmin(SelectAccountAdminMixin, ChangePasswordAdminMixin, Exten
filter_by_account_fields = ('databases',) filter_by_account_fields = ('databases',)
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 = '<a href="%s">%s</a>' % (change_url(db), db.name) link = format_html('<a href="{}">{}</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):
useradmin = UserAdmin(DatabaseUser, self.admin_site) useradmin = UserAdmin(DatabaseUser, self.admin_site)
return [ return [
url(r'^(\d+)/password/$', url(r'^(\d+)/password/$',
self.admin_site.admin_view(useradmin.user_change_password)) self.admin_site.admin_view(useradmin.user_change_password))
] + super(DatabaseUserAdmin, self).get_urls() ] + super(DatabaseUserAdmin, self).get_urls()
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
""" set password """ """ set password """
if not change: if not change:

View File

@ -17,11 +17,11 @@ class DatabaseUserCreationForm(forms.ModelForm):
password2 = forms.CharField(label=_("Password confirmation"), required=False, password2 = forms.CharField(label=_("Password confirmation"), required=False,
widget=forms.PasswordInput, widget=forms.PasswordInput,
help_text=_("Enter the same password as above, for verification.")) help_text=_("Enter the same password as above, for verification."))
class Meta: class Meta:
model = DatabaseUser model = DatabaseUser
fields = ('username', 'account', 'type') fields = ('username', 'account', 'type')
def clean_password2(self): def clean_password2(self):
password1 = self.cleaned_data.get("password1") password1 = self.cleaned_data.get("password1")
password2 = self.cleaned_data.get("password2") password2 = self.cleaned_data.get("password2")
@ -40,11 +40,11 @@ class DatabaseCreationForm(DatabaseUserCreationForm):
'invalid': _("This value may contain 16 characters or fewer, only letters, numbers and " 'invalid': _("This value may contain 16 characters or fewer, only letters, numbers and "
"@/./+/-/_ characters.")}) "@/./+/-/_ characters.")})
user = forms.ModelChoiceField(required=False, queryset=DatabaseUser.objects) user = forms.ModelChoiceField(required=False, queryset=DatabaseUser.objects)
class Meta: class Meta:
model = Database model = Database
fields = ('username', 'account', 'type') fields = ('username', 'account', 'type')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(DatabaseCreationForm, self).__init__(*args, **kwargs) super(DatabaseCreationForm, self).__init__(*args, **kwargs)
account_id = self.initial.get('account', self.initial_account) account_id = self.initial.get('account', self.initial_account)
@ -53,13 +53,13 @@ class DatabaseCreationForm(DatabaseUserCreationForm):
choices = [ (u.pk, "%s (%s)" % (u, u.get_type_display())) for u in qs ] choices = [ (u.pk, "%s (%s)" % (u, u.get_type_display())) for u in qs ]
self.fields['user'].queryset = qs self.fields['user'].queryset = qs
self.fields['user'].choices = [(None, '--------'),] + choices self.fields['user'].choices = [(None, '--------'),] + choices
def clean_username(self): def clean_username(self):
username = self.cleaned_data.get('username') username = self.cleaned_data.get('username')
if DatabaseUser.objects.filter(username=username).exists(): if DatabaseUser.objects.filter(username=username).exists():
raise ValidationError("Provided username already exists.") raise ValidationError("Provided username already exists.")
return username return username
def clean_password2(self): def clean_password2(self):
username = self.cleaned_data.get('username') username = self.cleaned_data.get('username')
password1 = self.cleaned_data.get('password1') password1 = self.cleaned_data.get('password1')
@ -70,14 +70,14 @@ class DatabaseCreationForm(DatabaseUserCreationForm):
msg = _("The two password fields didn't match.") msg = _("The two password fields didn't match.")
raise ValidationError(msg) raise ValidationError(msg)
return password2 return password2
def clean_user(self): def clean_user(self):
user = self.cleaned_data.get('user') user = self.cleaned_data.get('user')
if user and user.type != self.cleaned_data.get('type'): if user and user.type != self.cleaned_data.get('type'):
msg = _("Database type and user type doesn't match") msg = _("Database type and user type doesn't match")
raise ValidationError(msg) raise ValidationError(msg)
return user return user
def clean(self): def clean(self):
cleaned_data = super(DatabaseCreationForm, self).clean() cleaned_data = super(DatabaseCreationForm, self).clean()
if 'user' in cleaned_data and 'username' in cleaned_data: if 'user' in cleaned_data and 'username' in cleaned_data:
@ -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): def render(self, name, value, attrs, renderer=None):
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
@ -114,10 +114,10 @@ class DatabaseUserChangeForm(forms.ModelForm):
"this user's password, but you can change the password " "this user's password, but you can change the password "
"using <a href='../password/'>this form</a>. " "using <a href='../password/'>this form</a>. "
"<a onclick='return showAddAnotherPopup(this);' href='../hash/'>Show hash</a>.")) "<a onclick='return showAddAnotherPopup(this);' href='../hash/'>Show hash</a>."))
class Meta: class Meta:
model = DatabaseUser model = DatabaseUser
fields = ('username', 'password', 'type', 'account') fields = ('username', 'password', 'type', 'account')
def clean_password(self): def clean_password(self):
return self.initial["password"] return self.initial["password"]

View File

@ -3,6 +3,7 @@ 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
@ -19,7 +20,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(related_name='databases', verbose_name='Account', to=settings.AUTH_USER_MODEL)), ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='databases', verbose_name='Account', to=settings.AUTH_USER_MODEL)),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
@ -29,7 +30,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(related_name='databaseusers', verbose_name='Account', to=settings.AUTH_USER_MODEL)), ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, 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

@ -0,0 +1,82 @@
# -*- 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

@ -0,0 +1,30 @@
# -*- 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

@ -12,7 +12,7 @@ class Database(models.Model):
""" Represents a basic database for a web application """ """ Represents a basic database for a web application """
MYSQL = 'mysql' MYSQL = 'mysql'
POSTGRESQL = 'postgresql' POSTGRESQL = 'postgresql'
name = models.CharField(_("name"), max_length=64, # MySQL limit name = models.CharField(_("name"), max_length=64, # MySQL limit
validators=[validators.validate_name]) validators=[validators.validate_name])
users = models.ManyToManyField('databases.DatabaseUser', blank=True, users = models.ManyToManyField('databases.DatabaseUser', blank=True,
@ -20,16 +20,16 @@ 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', verbose_name=_("Account"), account = models.ForeignKey('accounts.Account', on_delete=models.CASCADE,
related_name='databases') verbose_name=_("Account"), related_name='databases')
comments = models.TextField(default="", blank=True) comments = models.TextField(default="", blank=True)
class Meta: class Meta:
unique_together = ('name', 'type') unique_together = ('name', 'type')
def __str__(self): def __str__(self):
return "%s" % self.name return "%s" % self.name
@property @property
def owner(self): def owner(self):
""" database owner is the first user related to it """ """ database owner is the first user related to it """
@ -39,7 +39,7 @@ class Database(models.Model):
if user is not None: if user is not None:
return user.databaseuser return user.databaseuser
return None return None
@property @property
def active(self): def active(self):
return self.account.is_active return self.account.is_active
@ -53,26 +53,26 @@ Database.users.through._meta.unique_together = (
class DatabaseUser(models.Model): class DatabaseUser(models.Model):
MYSQL = Database.MYSQL MYSQL = Database.MYSQL
POSTGRESQL = Database.POSTGRESQL POSTGRESQL = Database.POSTGRESQL
username = models.CharField(_("username"), max_length=16, # MySQL usernames 16 char long username = models.CharField(_("username"), max_length=16, # MySQL usernames 16 char long
validators=[validators.validate_name]) validators=[validators.validate_name])
password = models.CharField(_("password"), max_length=256) password = models.CharField(_("password"), max_length=256)
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', verbose_name=_("Account"), account = models.ForeignKey('accounts.Account', on_delete=models.CASCADE,
related_name='databaseusers') verbose_name=_("Account"), related_name='databaseusers')
class Meta: class Meta:
verbose_name_plural = _("DB users") verbose_name_plural = _("DB users")
unique_together = ('username', 'type') unique_together = ('username', 'type')
def __str__(self): def __str__(self):
return self.username return self.username
def get_username(self): def get_username(self):
return self.username return self.username
def set_password(self, password): def set_password(self, password):
if self.type == self.MYSQL: if self.type == self.MYSQL:
# MySQL stores sha1(sha1(password).binary).hex # MySQL stores sha1(sha1(password).binary).hex

View File

@ -1,22 +1,24 @@
import os import os
import socket import socket
import time import time
import unittest
from unittest import skip 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.core.urlresolvers import reverse from django.urls 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.orchestration.models import Server, Route from orchestra.contrib.orchestration.models import Route, Server
from orchestra.utils.sys import sshrun from orchestra.utils.sys import sshrun
from orchestra.utils.tests import (BaseLiveServerTestCase, random_ascii, save_response_on_error, from orchestra.utils.tests import (BaseLiveServerTestCase, random_ascii,
snapshot_on_error) save_response_on_error, snapshot_on_error)
from selenium.webdriver.support.select import Select
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')
@ -24,42 +26,40 @@ class DatabaseTestMixin(object):
'orchestra.contrib.orchestration', 'orchestra.contrib.orchestration',
'orcgestra.apps.databases', 'orcgestra.apps.databases',
) )
def setUp(self): def setUp(self):
super(DatabaseTestMixin, self).setUp() super(DatabaseTestMixin, self).setUp()
self.add_route() self.add_route()
djsettings.DEBUG = True djsettings.DEBUG = True
def add_route(self): def add_route(self):
raise NotImplementedError raise NotImplementedError
def save(self): def save(self):
raise NotImplementedError raise NotImplementedError
def add(self): def add(self):
raise NotImplementedError raise NotImplementedError
def delete(self): def delete(self):
raise NotImplementedError raise NotImplementedError
def update(self): def update(self):
raise NotImplementedError raise NotImplementedError
def disable(self): def disable(self):
raise NotImplementedError raise NotImplementedError
def add_group(self, username, groupname): def add_group(self, username, groupname):
raise NotImplementedError raise NotImplementedError
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_add(self): def test_add(self):
dbname = '%s_database' % random_ascii(5) dbname = '%s_database' % random_ascii(5)
username = '%s_dbuser' % random_ascii(5) username = '%s_dbuser' % random_ascii(5)
password = '@!?%spppP001' % random_ascii(5) password = '@!?%spppP001' % random_ascii(5)
self.add(dbname, username, password) self.add(dbname, username, password)
self.validate_create_table(dbname, username, password) self.validate_create_table(dbname, username, password)
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_delete(self): def test_delete(self):
dbname = '%s_database' % random_ascii(5) dbname = '%s_database' % random_ascii(5)
username = '%s_dbuser' % random_ascii(5) username = '%s_dbuser' % random_ascii(5)
@ -70,8 +70,7 @@ class DatabaseTestMixin(object):
self.delete_user(username) self.delete_user(username)
self.validate_delete(dbname, username, password) self.validate_delete(dbname, username, password)
self.validate_delete_user(dbname, username) self.validate_delete_user(dbname, username)
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_change_password(self): def test_change_password(self):
dbname = '%s_database' % random_ascii(5) dbname = '%s_database' % random_ascii(5)
username = '%s_dbuser' % random_ascii(5) username = '%s_dbuser' % random_ascii(5)
@ -84,8 +83,7 @@ class DatabaseTestMixin(object):
self.change_password(username, new_password) self.change_password(username, new_password)
self.validate_login_error(dbname, username, password) self.validate_login_error(dbname, username, password)
self.validate_create_table(dbname, username, new_password) self.validate_create_table(dbname, username, new_password)
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_add_user(self): def test_add_user(self):
dbname = '%s_database' % random_ascii(5) dbname = '%s_database' % random_ascii(5)
username = '%s_dbuser' % random_ascii(5) username = '%s_dbuser' % random_ascii(5)
@ -102,8 +100,7 @@ class DatabaseTestMixin(object):
self.add_user_to_db(username2, dbname) self.add_user_to_db(username2, dbname)
self.validate_create_table(dbname, username, password) self.validate_create_table(dbname, username, password)
self.validate_create_table(dbname, username2, password2) self.validate_create_table(dbname, username2, password2)
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_delete_user(self): def test_delete_user(self):
dbname = '%s_database' % random_ascii(5) dbname = '%s_database' % random_ascii(5)
username = '%s_dbuser' % random_ascii(5) username = '%s_dbuser' % random_ascii(5)
@ -122,8 +119,7 @@ class DatabaseTestMixin(object):
self.delete_user(username2) self.delete_user(username2)
self.validate_login_error(dbname, username2, password2) self.validate_login_error(dbname, username2, password2)
self.validate_delete_user(username2, password2) self.validate_delete_user(username2, password2)
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_swap_user(self): def test_swap_user(self):
dbname = '%s_database' % random_ascii(5) dbname = '%s_database' % random_ascii(5)
username = '%s_dbuser' % random_ascii(5) username = '%s_dbuser' % random_ascii(5)
@ -143,7 +139,7 @@ class DatabaseTestMixin(object):
class MySQLControllerMixin(object): class MySQLControllerMixin(object):
db_type = 'mysql' db_type = 'mysql'
def setUp(self): def setUp(self):
super(MySQLControllerMixin, self).setUp() super(MySQLControllerMixin, self).setUp()
# Get local ip address used to reach self.MASTER_SERVER # Get local ip address used to reach self.MASTER_SERVER
@ -151,7 +147,7 @@ class MySQLControllerMixin(object):
s.connect((self.MASTER_SERVER, 22)) s.connect((self.MASTER_SERVER, 22))
settings.DATABASES_DEFAULT_HOST = s.getsockname()[0] settings.DATABASES_DEFAULT_HOST = s.getsockname()[0]
s.close() s.close()
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.MySQLController.get_name() backend = backends.MySQLController.get_name()
@ -160,22 +156,22 @@ class MySQLControllerMixin(object):
match = "databaseuser.type == '%s'" % self.db_type match = "databaseuser.type == '%s'" % self.db_type
backend = backends.MySQLUserController.get_name() backend = backends.MySQLUserController.get_name()
Route.objects.create(backend=backend, match=match, host=server) Route.objects.create(backend=backend, match=match, host=server)
def validate_create_table(self, name, username, password): def validate_create_table(self, name, username, password):
db = MySQLdb.connect(host=self.MASTER_SERVER, port=3306, user=username, passwd=password, db=name) db = MySQLdb.connect(host=self.MASTER_SERVER, port=3306, user=username, passwd=password, db=name)
cur = db.cursor() cur = db.cursor()
cur.execute('CREATE TABLE table_%s ( id INT ) ;' % random_ascii(10)) cur.execute('CREATE TABLE table_%s ( id INT ) ;' % random_ascii(10))
def validate_login_error(self, dbname, username, password): def validate_login_error(self, dbname, username, password):
self.assertRaises(MySQLdb.OperationalError, self.assertRaises(MySQLdb.OperationalError,
self.validate_create_table, dbname, username, password self.validate_create_table, dbname, username, password
) )
def validate_delete(self, dbname, username, password): def validate_delete(self, dbname, username, password):
self.validate_login_error(dbname, username, password) self.validate_login_error(dbname, username, password)
self.assertRaises(CommandError, self.assertRaises(CommandError,
sshrun, self.MASTER_SERVER, 'mysql %s' % dbname, display=False) sshrun, self.MASTER_SERVER, 'mysql %s' % dbname, display=False)
def validate_delete_user(self, name, username): def validate_delete_user(self, name, username):
context = { context = {
'name': name, 'name': name,
@ -187,11 +183,12 @@ 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()
self.rest_login() self.rest_login()
@save_response_on_error @save_response_on_error
def add(self, dbname, username, password): def add(self, dbname, username, password):
user = self.rest.databaseusers.create(username=username, password=password, type=self.db_type) user = self.rest.databaseusers.create(username=username, password=password, type=self.db_type)
@ -199,31 +196,31 @@ class RESTDatabaseMixin(DatabaseTestMixin):
'username': user.username 'username': user.username
}] }]
self.rest.databases.create(name=dbname, users=users, type=self.db_type) self.rest.databases.create(name=dbname, users=users, type=self.db_type)
@save_response_on_error @save_response_on_error
def delete(self, dbname): def delete(self, dbname):
self.rest.databases.retrieve(name=dbname).delete() self.rest.databases.retrieve(name=dbname).delete()
@save_response_on_error @save_response_on_error
def change_password(self, username, password): def change_password(self, username, password):
user = self.rest.databaseusers.retrieve(username=username).get() user = self.rest.databaseusers.retrieve(username=username).get()
user.set_password(password) user.set_password(password)
@save_response_on_error @save_response_on_error
def add_user(self, username, password): def add_user(self, username, password):
self.rest.databaseusers.create(username=username, password=password, type=self.db_type) self.rest.databaseusers.create(username=username, password=password, type=self.db_type)
@save_response_on_error @save_response_on_error
def add_user_to_db(self, username, dbname): def add_user_to_db(self, username, dbname):
user = self.rest.databaseusers.retrieve(username=username).get() user = self.rest.databaseusers.retrieve(username=username).get()
db = self.rest.databases.retrieve(name=dbname).get() db = self.rest.databases.retrieve(name=dbname).get()
db.users.append(user) db.users.append(user)
db.save() db.save()
@save_response_on_error @save_response_on_error
def delete_user(self, username): def delete_user(self, username):
self.rest.databaseusers.retrieve(username=username).delete() self.rest.databaseusers.retrieve(username=username).delete()
@save_response_on_error @save_response_on_error
def swap_user(self, username, username2, dbname): def swap_user(self, username, username2, dbname):
user = self.rest.databaseusers.retrieve(username=username2).get() user = self.rest.databaseusers.retrieve(username=username2).get()
@ -237,84 +234,84 @@ class AdminDatabaseMixin(DatabaseTestMixin):
def setUp(self): def setUp(self):
super(AdminDatabaseMixin, self).setUp() super(AdminDatabaseMixin, self).setUp()
self.admin_login() self.admin_login()
@snapshot_on_error @snapshot_on_error
def add(self, dbname, username, password): def add(self, dbname, username, password):
url = self.live_server_url + reverse('admin:databases_database_add') url = self.live_server_url + reverse('admin:databases_database_add')
self.selenium.get(url) self.selenium.get(url)
type_input = self.selenium.find_element_by_id('id_type') type_input = self.selenium.find_element_by_id('id_type')
type_select = Select(type_input) type_select = Select(type_input)
type_select.select_by_value(self.db_type) type_select.select_by_value(self.db_type)
name_field = self.selenium.find_element_by_id('id_name') name_field = self.selenium.find_element_by_id('id_name')
name_field.send_keys(dbname) name_field.send_keys(dbname)
username_field = self.selenium.find_element_by_id('id_username') username_field = self.selenium.find_element_by_id('id_username')
username_field.send_keys(username) username_field.send_keys(username)
password_field = self.selenium.find_element_by_id('id_password1') password_field = self.selenium.find_element_by_id('id_password1')
password_field.send_keys(password) password_field.send_keys(password)
password_field = self.selenium.find_element_by_id('id_password2') password_field = self.selenium.find_element_by_id('id_password2')
password_field.send_keys(password) password_field.send_keys(password)
name_field.submit() name_field.submit()
self.assertNotEqual(url, self.selenium.current_url) self.assertNotEqual(url, self.selenium.current_url)
@snapshot_on_error @snapshot_on_error
def delete(self, dbname): def delete(self, dbname):
db = Database.objects.get(name=dbname) db = Database.objects.get(name=dbname)
self.admin_delete(db) self.admin_delete(db)
@snapshot_on_error @snapshot_on_error
def change_password(self, username, password): def change_password(self, username, password):
user = DatabaseUser.objects.get(username=username) user = DatabaseUser.objects.get(username=username)
self.admin_change_password(user, password) self.admin_change_password(user, password)
@snapshot_on_error @snapshot_on_error
def add_user(self, username, password): def add_user(self, username, password):
url = self.live_server_url + reverse('admin:databases_databaseuser_add') url = self.live_server_url + reverse('admin:databases_databaseuser_add')
self.selenium.get(url) self.selenium.get(url)
type_input = self.selenium.find_element_by_id('id_type') type_input = self.selenium.find_element_by_id('id_type')
type_select = Select(type_input) type_select = Select(type_input)
type_select.select_by_value(self.db_type) type_select.select_by_value(self.db_type)
username_field = self.selenium.find_element_by_id('id_username') username_field = self.selenium.find_element_by_id('id_username')
username_field.send_keys(username) username_field.send_keys(username)
password_field = self.selenium.find_element_by_id('id_password1') password_field = self.selenium.find_element_by_id('id_password1')
password_field.send_keys(password) password_field.send_keys(password)
password_field = self.selenium.find_element_by_id('id_password2') password_field = self.selenium.find_element_by_id('id_password2')
password_field.send_keys(password) password_field.send_keys(password)
username_field.submit() username_field.submit()
self.assertNotEqual(url, self.selenium.current_url) self.assertNotEqual(url, self.selenium.current_url)
@snapshot_on_error @snapshot_on_error
def add_user_to_db(self, username, dbname): def add_user_to_db(self, username, dbname):
database = Database.objects.get(name=dbname, type=self.db_type) database = Database.objects.get(name=dbname, type=self.db_type)
url = self.live_server_url + change_url(database) url = self.live_server_url + change_url(database)
self.selenium.get(url) self.selenium.get(url)
user = DatabaseUser.objects.get(username=username, type=self.db_type) user = DatabaseUser.objects.get(username=username, type=self.db_type)
users_from = self.selenium.find_element_by_id('id_users_from') users_from = self.selenium.find_element_by_id('id_users_from')
users_select = Select(users_from) users_select = Select(users_from)
users_select.select_by_value(str(user.pk)) users_select.select_by_value(str(user.pk))
add_user = self.selenium.find_element_by_id('id_users_add_link') add_user = self.selenium.find_element_by_id('id_users_add_link')
add_user.click() add_user.click()
save = self.selenium.find_element_by_name('_save') save = self.selenium.find_element_by_name('_save')
save.submit() save.submit()
self.assertNotEqual(url, self.selenium.current_url) self.assertNotEqual(url, self.selenium.current_url)
@snapshot_on_error @snapshot_on_error
def swap_user(self, username, username2, dbname): def swap_user(self, username, username2, dbname):
database = Database.objects.get(name=dbname, type=self.db_type) database = Database.objects.get(name=dbname, type=self.db_type)
url = self.live_server_url + change_url(database) url = self.live_server_url + change_url(database)
self.selenium.get(url) self.selenium.get(url)
# remove user "username" # remove user "username"
user = DatabaseUser.objects.get(username=username, type=self.db_type) user = DatabaseUser.objects.get(username=username, type=self.db_type)
users_to = self.selenium.find_element_by_id('id_users_to') users_to = self.selenium.find_element_by_id('id_users_to')
@ -323,7 +320,7 @@ class AdminDatabaseMixin(DatabaseTestMixin):
remove_user = self.selenium.find_element_by_id('id_users_remove_link') remove_user = self.selenium.find_element_by_id('id_users_remove_link')
remove_user.click() remove_user.click()
time.sleep(0.2) time.sleep(0.2)
# add user "username2" # add user "username2"
user = DatabaseUser.objects.get(username=username2, type=self.db_type) user = DatabaseUser.objects.get(username=username2, type=self.db_type)
users_from = self.selenium.find_element_by_id('id_users_from') users_from = self.selenium.find_element_by_id('id_users_from')
@ -332,11 +329,11 @@ class AdminDatabaseMixin(DatabaseTestMixin):
add_user = self.selenium.find_element_by_id('id_users_add_link') add_user = self.selenium.find_element_by_id('id_users_add_link')
add_user.click() add_user.click()
time.sleep(0.2) time.sleep(0.2)
save = self.selenium.find_element_by_name('_save') save = self.selenium.find_element_by_name('_save')
save.submit() save.submit()
self.assertNotEqual(url, self.selenium.current_url) self.assertNotEqual(url, self.selenium.current_url)
@snapshot_on_error @snapshot_on_error
def delete_user(self, username): def delete_user(self, username):
user = DatabaseUser.objects.get(username=username) user = DatabaseUser.objects.get(username=username)

View File

@ -1,8 +1,10 @@
from django.contrib import admin from django.contrib import admin
from django.core.urlresolvers import reverse from django.urls 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
@ -32,18 +34,18 @@ class DomainInline(admin.TabularInline):
readonly_fields = ('domain_link', 'display_records', 'account_link') readonly_fields = ('domain_link', 'display_records', 'account_link')
extra = 0 extra = 0
verbose_name_plural = _("Subdomains") verbose_name_plural = _("Subdomains")
domain_link = admin_link('__str__') domain_link = admin_link('__str__')
domain_link.short_description = _("Name") domain_link.short_description = _("Name")
account_link = admin_link('account') account_link = admin_link('account')
def display_records(self, domain): def display_records(self, domain):
return ', '.join([record.type for record in domain.records.all()]) return ', '.join([record.type for record in domain.records.all()])
display_records.short_description = _("Declared records") display_records.short_description = _("Declared records")
def has_add_permission(self, *args, **kwargs): def has_add_permission(self, *args, **kwargs):
return False return False
def get_queryset(self, request): def get_queryset(self, request):
""" Order by structured name and imporve performance """ """ Order by structured name and imporve performance """
qs = super(DomainInline, self).get_queryset(request) qs = super(DomainInline, self).get_queryset(request)
@ -66,23 +68,23 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
add_form = BatchDomainCreationAdminForm add_form = BatchDomainCreationAdminForm
actions = (edit_records, set_soa, list_accounts) actions = (edit_records, set_soa, list_accounts)
change_view_actions = (view_zone, edit_records) change_view_actions = (view_zone, edit_records)
top_link = admin_link('top') top_link = admin_link('top')
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 '&nbsp;'*4 + domain.name return mark_safe('&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):
return domain.is_top return domain.is_top
display_is_top.short_description = _("Is top") display_is_top.short_description = _("Is top")
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()
@ -92,22 +94,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 = '<a href="%s" title="%s">%s %s</a>' % ( link = format_html('<a href="{}" title="{}">{} {}</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)
image = '<img src="%s"></img>' % static('orchestra/images/add.png') add_link = format_html(
add_link = '<a href="%s" title="%s">%s</a>' % ( '<a href="{}" title="{}"><img src="{}" /></a>', add_url,
add_url, _("Add website"), image _("Add website"), static('orchestra/images/add.png'),
) )
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')
@ -126,10 +128,9 @@ 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 = []
@ -141,15 +142,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 = '<strike>%s</strike>' % line line = format_html('<strike>{}</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 """
fieldsets = super(DomainAdmin, self).get_fieldsets(request, obj) fieldsets = super(DomainAdmin, self).get_fieldsets(request, obj)
@ -175,13 +175,13 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
if 'top_link' not in existing: if 'top_link' not in existing:
fieldsets[0][1]['fields'].insert(2, 'top_link') fieldsets[0][1]['fields'].insert(2, 'top_link')
return fieldsets return fieldsets
def get_inline_instances(self, request, obj=None): def get_inline_instances(self, request, obj=None):
inlines = super(DomainAdmin, self).get_inline_instances(request, obj) inlines = super(DomainAdmin, self).get_inline_instances(request, obj)
if not obj or not obj.is_top: if not obj or not obj.is_top:
return [inline for inline in inlines if type(inline) != DomainInline] return [inline for inline in inlines if type(inline) != DomainInline]
return inlines return inlines
def get_queryset(self, request): def get_queryset(self, request):
""" Order by structured name and imporve performance """ """ Order by structured name and imporve performance """
qs = super(DomainAdmin, self).get_queryset(request) qs = super(DomainAdmin, self).get_queryset(request)
@ -196,7 +196,7 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
if apps.isinstalled('orchestra.contrib.mailboxes'): if apps.isinstalled('orchestra.contrib.mailboxes'):
qs = qs.annotate(models.Count('addresses')) qs = qs.annotate(models.Count('addresses'))
return qs return qs
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
""" batch domain creation support """ """ batch domain creation support """
super(DomainAdmin, self).save_model(request, obj, form, change) super(DomainAdmin, self).save_model(request, obj, form, change)
@ -205,7 +205,7 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
for name in form.extra_names: for name in form.extra_names:
domain = Domain.objects.create(name=name, account_id=obj.account_id) domain = Domain.objects.create(name=name, account_id=obj.account_id)
self.extra_domains.append(domain) self.extra_domains.append(domain)
def save_related(self, request, form, formsets, change): def save_related(self, request, form, formsets, change):
""" batch domain creation support """ """ batch domain creation support """
super(DomainAdmin, self).save_related(request, form, formsets, change) super(DomainAdmin, self).save_related(request, form, formsets, change)

View File

@ -1,5 +1,5 @@
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.decorators import detail_route from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from orchestra.api import router from orchestra.api import router
@ -14,18 +14,18 @@ class DomainViewSet(AccountApiMixin, viewsets.ModelViewSet):
serializer_class = DomainSerializer serializer_class = DomainSerializer
filter_fields = ('name',) filter_fields = ('name',)
queryset = Domain.objects.all() queryset = Domain.objects.all()
def get_queryset(self): def get_queryset(self):
qs = super(DomainViewSet, self).get_queryset() qs = super(DomainViewSet, self).get_queryset()
return qs.prefetch_related('records') return qs.prefetch_related('records')
@detail_route() @action(detail=True)
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({
'zone': domain.render_zone() 'zone': domain.render_zone()
}) })
def options(self, request): def options(self, request):
metadata = super(DomainViewSet, self).options(request) metadata = super(DomainViewSet, self).options(request)
names = ['DOMAINS_DEFAULT_A', 'DOMAINS_DEFAULT_MX', 'DOMAINS_DEFAULT_NS'] names = ['DOMAINS_DEFAULT_A', 'DOMAINS_DEFAULT_MX', 'DOMAINS_DEFAULT_NS']

View File

@ -2,6 +2,7 @@
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
@ -20,8 +21,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(related_name='domains', help_text='Automatically selected for subdomains.', to=settings.AUTH_USER_MODEL, verbose_name='Account', blank=True)), ('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)),
('top', models.ForeignKey(null=True, to='domains.Domain', editable=False, related_name='subdomain_set')), ('top', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, null=True, to='domains.Domain', editable=False, related_name='subdomain_set')),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
@ -31,7 +32,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(related_name='records', to='domains.Domain', verbose_name='domain')), ('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='records', to='domains.Domain', verbose_name='domain')),
], ],
), ),
] ]

View File

@ -0,0 +1,83 @@
# -*- 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,6 +2,7 @@
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
@ -20,7 +21,7 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='domain', model_name='domain',
name='top', name='top',
field=models.ForeignKey(editable=False, verbose_name='top domain', related_name='subdomain_set', to='domains.Domain', null=True), 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),
), ),
migrations.AlterField( migrations.AlterField(
model_name='record', model_name='record',

View File

@ -0,0 +1,26 @@
# -*- 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', help_text=_("Automatically selected for subdomains.")) related_name='domains', on_delete=models.CASCADE, 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")) editable=False, verbose_name=_("top domain"), on_delete=models.CASCADE)
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,
@ -69,16 +69,16 @@ class Domain(models.Model):
blank=True, blank=True,
help_text="A bind-9 'address_match_list' that will be granted permission to perform " 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.") "dns2136 updates. Chiefly used to enable Let's Encrypt self-service validation.")
objects = DomainQuerySet.as_manager() objects = DomainQuerySet.as_manager()
def __str__(self): def __str__(self):
return self.name return self.name
@property @property
def origin(self): def origin(self):
return self.top or self return self.top or self
@property @property
def is_top(self): def is_top(self):
# don't cache, don't replace by top_id # don't cache, don't replace by top_id
@ -86,14 +86,14 @@ class Domain(models.Model):
return not bool(self.top) return not bool(self.top)
except Domain.DoesNotExist: except Domain.DoesNotExist:
return False return False
@property @property
def subdomains(self): def subdomains(self):
return Domain.objects.filter(name__regex='\.%s$' % self.name) return Domain.objects.filter(name__regex='\.%s$' % self.name)
def clean(self): def clean(self):
self.name = self.name.lower() self.name = self.name.lower()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" create top relation """ """ create top relation """
update = False update = False
@ -110,7 +110,7 @@ class Domain(models.Model):
# queryset.update() is not used because we want to trigger backend to delete ex-topdomains # queryset.update() is not used because we want to trigger backend to delete ex-topdomains
domain.top = self domain.top = self
domain.save(update_fields=('top',)) domain.save(update_fields=('top',))
def get_description(self): def get_description(self):
if self.is_top: if self.is_top:
num = self.subdomains.count() num = self.subdomains.count()
@ -119,21 +119,21 @@ class Domain(models.Model):
_("top domain with %d subdomains") % num, _("top domain with %d subdomains") % num,
num) num)
return _("subdomain") return _("subdomain")
def get_absolute_url(self): def get_absolute_url(self):
return 'http://%s' % self.name return 'http://%s' % self.name
def get_declared_records(self): def get_declared_records(self):
""" proxy method, needed for input validation, see helpers.domain_for_validation """ """ proxy method, needed for input validation, see helpers.domain_for_validation """
return self.records.all() return self.records.all()
def get_subdomains(self): def get_subdomains(self):
""" proxy method, needed for input validation, see helpers.domain_for_validation """ """ proxy method, needed for input validation, see helpers.domain_for_validation """
return self.origin.subdomain_set.all().prefetch_related('records') return self.origin.subdomain_set.all().prefetch_related('records')
def get_parent(self, top=False): def get_parent(self, top=False):
return type(self).objects.get_parent(self.name, top=top) return type(self).objects.get_parent(self.name, top=top)
def render_zone(self): def render_zone(self):
origin = self.origin origin = self.origin
zone = origin.render_records() zone = origin.render_records()
@ -147,7 +147,7 @@ class Domain(models.Model):
for subdomain in sorted(tail, key=lambda x: len(x.name), reverse=True): for subdomain in sorted(tail, key=lambda x: len(x.name), reverse=True):
zone += subdomain.render_records() zone += subdomain.render_records()
return zone.strip() return zone.strip()
def refresh_serial(self): def refresh_serial(self):
""" Increases the domain serial number by one """ """ Increases the domain serial number by one """
serial = utils.generate_zone_serial() serial = utils.generate_zone_serial()
@ -159,7 +159,7 @@ class Domain(models.Model):
serial = int(serial) serial = int(serial)
self.serial = serial self.serial = serial
self.save(update_fields=('serial',)) self.save(update_fields=('serial',))
def get_default_soa(self): def get_default_soa(self):
return ' '.join([ return ' '.join([
"%s." % settings.DOMAINS_DEFAULT_NAME_SERVER, "%s." % settings.DOMAINS_DEFAULT_NAME_SERVER,
@ -170,7 +170,7 @@ class Domain(models.Model):
self.expire or settings.DOMAINS_DEFAULT_EXPIRE, self.expire or settings.DOMAINS_DEFAULT_EXPIRE,
self.min_ttl or settings.DOMAINS_DEFAULT_MIN_TTL, self.min_ttl or settings.DOMAINS_DEFAULT_MIN_TTL,
]) ])
def get_default_records(self): def get_default_records(self):
defaults = [] defaults = []
if self.is_top: if self.is_top:
@ -202,7 +202,7 @@ class Domain(models.Model):
value=default_aaaa value=default_aaaa
)) ))
return defaults return defaults
def record_is_implicit(self, record, types): def record_is_implicit(self, record, types):
if record.type not in types: if record.type not in types:
if record.type is Record.NS: if record.type is Record.NS:
@ -221,7 +221,7 @@ class Domain(models.Model):
elif not has_a and not has_aaaa: elif not has_a and not has_aaaa:
return True return True
return False return False
def get_records(self): def get_records(self):
types = set() types = set()
records = utils.RecordStorage() records = utils.RecordStorage()
@ -249,7 +249,7 @@ class Domain(models.Model):
else: else:
records.append(record) records.append(record)
return records return records
def render_records(self): def render_records(self):
result = '' result = ''
for record in self.get_records(): for record in self.get_records():
@ -273,7 +273,7 @@ class Domain(models.Model):
value=record.value value=record.value
) )
return result return result
def has_default_mx(self): def has_default_mx(self):
records = self.get_records() records = self.get_records()
for record in records.by_type('MX'): for record in records.by_type('MX'):
@ -294,7 +294,7 @@ class Record(models.Model):
TXT = 'TXT' TXT = 'TXT'
SPF = 'SPF' SPF = 'SPF'
SOA = 'SOA' SOA = 'SOA'
TYPE_CHOICES = ( TYPE_CHOICES = (
(MX, "MX"), (MX, "MX"),
(NS, "NS"), (NS, "NS"),
@ -305,7 +305,7 @@ class Record(models.Model):
(TXT, "TXT"), (TXT, "TXT"),
(SPF, "SPF"), (SPF, "SPF"),
) )
VALIDATORS = { VALIDATORS = {
MX: (validators.validate_mx_record,), MX: (validators.validate_mx_record,),
NS: (validators.validate_zone_label,), NS: (validators.validate_zone_label,),
@ -317,8 +317,8 @@ class Record(models.Model):
SRV: (validators.validate_srv_record,), SRV: (validators.validate_srv_record,),
SOA: (validators.validate_soa_record,), SOA: (validators.validate_soa_record,),
} }
domain = models.ForeignKey(Domain, verbose_name=_("domain"), related_name='records') domain = models.ForeignKey(Domain, verbose_name=_("domain"), related_name='records', on_delete=models.CASCADE)
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])
@ -326,10 +326,10 @@ class Record(models.Model):
# max_length bumped from 256 to 1024 (arbitrary) on August 2019. # max_length bumped from 256 to 1024 (arbitrary) on August 2019.
value = models.CharField(_("value"), max_length=1024, value = models.CharField(_("value"), max_length=1024,
help_text=_("MX, NS and CNAME records sould end with a dot.")) help_text=_("MX, NS and CNAME records sould end with a dot."))
def __str__(self): def __str__(self):
return "%s %s IN %s %s" % (self.domain, self.get_ttl(), self.type, self.value) return "%s %s IN %s %s" % (self.domain, self.get_ttl(), self.type, self.value)
def clean(self): def clean(self):
""" validates record value based on its type """ """ validates record value based on its type """
# validate value # validate value
@ -343,6 +343,6 @@ class Record(models.Model):
raise ValidationError({ raise ValidationError({
'value': error, 'value': error,
}) })
def get_ttl(self): def get_ttl(self):
return self.ttl or settings.DOMAINS_DEFAULT_TTL return self.ttl or settings.DOMAINS_DEFAULT_TTL

View File

@ -2,10 +2,9 @@ import os
import time import time
import socket import socket
from functools import partial from functools import partial
from unittest import skip
from django.conf import settings as djsettings from django.conf import settings as djsettings
from django.core.urlresolvers import reverse from django.urls 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
@ -21,10 +20,10 @@ run = partial(run, display=False)
class DomainTestMixin(object): class DomainTestMixin(object):
MASTER_SERVER = os.environ.get('ORCHESTRA_MASTER_SERVER', 'localhost') MASTER_SERVER = os.environ.get('ORCHESTRA_MASTER_SERVER', 'localhost')
SLAVE_SERVER = os.environ.get('ORCHESTRA_SLAVE_SERVER', 'localhost2') SLAVE_SERVER = os.environ.get('ORCHESTRA_SLAVE_SERVER', 'localhost')
MASTER_SERVER_ADDR = socket.gethostbyname(MASTER_SERVER) MASTER_SERVER_ADDR = socket.gethostbyname(MASTER_SERVER)
SLAVE_SERVER_ADDR = '127.0.0.2' SLAVE_SERVER_ADDR = socket.gethostbyname(SLAVE_SERVER)
def setUp(self): def setUp(self):
djsettings.DEBUG = True djsettings.DEBUG = True
super(DomainTestMixin, self).setUp() super(DomainTestMixin, self).setUp()
@ -54,19 +53,19 @@ class DomainTestMixin(object):
(Record.CNAME, 'external.server.org.'), (Record.CNAME, 'external.server.org.'),
) )
self.django_domain_name = 'django%s.lan' % random_ascii(10) self.django_domain_name = 'django%s.lan' % random_ascii(10)
def add_route(self): def add_route(self):
raise NotImplementedError raise NotImplementedError
def add(self, domain_name, records): def add(self, domain_name, records):
raise NotImplementedError raise NotImplementedError
def delete(self, domain_name, records): def delete(self, domain_name, records):
raise NotImplementedError raise NotImplementedError
def update(self, domain_name, records): def update(self, domain_name, records):
raise NotImplementedError raise NotImplementedError
def validate_add(self, server_addr, domain_name): def validate_add(self, server_addr, domain_name):
context = { context = {
'domain_name': domain_name, 'domain_name': domain_name,
@ -82,7 +81,7 @@ class DomainTestMixin(object):
self.assertEqual('%s.' % settings.DOMAINS_DEFAULT_NAME_SERVER, soa[4]) self.assertEqual('%s.' % settings.DOMAINS_DEFAULT_NAME_SERVER, soa[4])
hostmaster = utils.format_hostmaster(settings.DOMAINS_DEFAULT_HOSTMASTER) hostmaster = utils.format_hostmaster(settings.DOMAINS_DEFAULT_HOSTMASTER)
self.assertEqual(hostmaster, soa[5]) self.assertEqual(hostmaster, soa[5])
dig_ns = 'dig @%(server_addr)s %(domain_name)s NS|grep "\sNS\s"' dig_ns = 'dig @%(server_addr)s %(domain_name)s NS|grep "\sNS\s"'
name_servers = run(dig_ns % context).stdout name_servers = run(dig_ns % context).stdout
# testdomain.org. 3600 IN NS ns1.orchestra.lan. # testdomain.org. 3600 IN NS ns1.orchestra.lan.
@ -96,7 +95,7 @@ class DomainTestMixin(object):
self.assertEqual('IN', ns[2]) self.assertEqual('IN', ns[2])
self.assertEqual('NS', ns[3]) self.assertEqual('NS', ns[3])
self.assertIn(ns[4], ns_records) self.assertIn(ns[4], ns_records)
dig_mx = 'dig @%(server_addr)s %(domain_name)s MX|grep "\sMX\s"' dig_mx = 'dig @%(server_addr)s %(domain_name)s MX|grep "\sMX\s"'
mail_servers = run(dig_mx % context).stdout mail_servers = run(dig_mx % context).stdout
for mx in mail_servers.splitlines(): for mx in mail_servers.splitlines():
@ -108,7 +107,7 @@ class DomainTestMixin(object):
self.assertEqual('MX', mx[3]) self.assertEqual('MX', mx[3])
self.assertIn(mx[4], ['10', '20']) self.assertIn(mx[4], ['10', '20'])
self.assertIn(mx[5], ['mail2.orchestra.lan.', 'mail.orchestra.lan.']) self.assertIn(mx[5], ['mail2.orchestra.lan.', 'mail.orchestra.lan.'])
def validate_delete(self, server_addr, domain_name): def validate_delete(self, server_addr, domain_name):
context = { context = {
'domain_name': domain_name, 'domain_name': domain_name,
@ -123,7 +122,7 @@ class DomainTestMixin(object):
self.assertNotEqual('%s.' % settings.DOMAINS_DEFAULT_NAME_SERVER, soa[4]) self.assertNotEqual('%s.' % settings.DOMAINS_DEFAULT_NAME_SERVER, soa[4])
hostmaster = utils.format_hostmaster(settings.DOMAINS_DEFAULT_HOSTMASTER) hostmaster = utils.format_hostmaster(settings.DOMAINS_DEFAULT_HOSTMASTER)
self.assertNotEqual(hostmaster, soa[5]) self.assertNotEqual(hostmaster, soa[5])
def validate_update(self, server_addr, domain_name): def validate_update(self, server_addr, domain_name):
context = { context = {
'domain_name': domain_name, 'domain_name': domain_name,
@ -139,7 +138,7 @@ class DomainTestMixin(object):
self.assertEqual('%s.' % settings.DOMAINS_DEFAULT_NAME_SERVER, soa[4]) self.assertEqual('%s.' % settings.DOMAINS_DEFAULT_NAME_SERVER, soa[4])
hostmaster = utils.format_hostmaster(settings.DOMAINS_DEFAULT_HOSTMASTER) hostmaster = utils.format_hostmaster(settings.DOMAINS_DEFAULT_HOSTMASTER)
self.assertEqual(hostmaster, soa[5]) self.assertEqual(hostmaster, soa[5])
dig_ns = 'dig @%(server_addr)s %(domain_name)s NS |grep "\sNS\s"' dig_ns = 'dig @%(server_addr)s %(domain_name)s NS |grep "\sNS\s"'
name_servers = run(dig_ns % context).stdout name_servers = run(dig_ns % context).stdout
ns_records = ['ns1.%s.' % self.domain_name, 'ns2.%s.' % self.domain_name] ns_records = ['ns1.%s.' % self.domain_name, 'ns2.%s.' % self.domain_name]
@ -152,7 +151,7 @@ class DomainTestMixin(object):
self.assertEqual('IN', ns[2]) self.assertEqual('IN', ns[2])
self.assertEqual('NS', ns[3]) self.assertEqual('NS', ns[3])
self.assertIn(ns[4], ns_records) self.assertIn(ns[4], ns_records)
dig_mx = 'dig @%(server_addr)s %(domain_name)s MX | grep "\sMX\s"' dig_mx = 'dig @%(server_addr)s %(domain_name)s MX | grep "\sMX\s"'
mx = run(dig_mx % context).stdout.split() mx = run(dig_mx % context).stdout.split()
# testdomain.org. 3600 IN MX 10 orchestra.lan. # testdomain.org. 3600 IN MX 10 orchestra.lan.
@ -162,7 +161,7 @@ class DomainTestMixin(object):
self.assertEqual('MX', mx[3]) self.assertEqual('MX', mx[3])
self.assertIn(mx[4], ['30', '40']) self.assertIn(mx[4], ['30', '40'])
self.assertIn(mx[5], ['mail3.orchestra.lan.', 'mail4.orchestra.lan.']) self.assertIn(mx[5], ['mail3.orchestra.lan.', 'mail4.orchestra.lan.'])
def validate_www_update(self, server_addr, domain_name): def validate_www_update(self, server_addr, domain_name):
context = { context = {
'domain_name': domain_name, 'domain_name': domain_name,
@ -176,8 +175,7 @@ class DomainTestMixin(object):
self.assertEqual('IN', cname[2]) self.assertEqual('IN', cname[2])
self.assertEqual('CNAME', cname[3]) self.assertEqual('CNAME', cname[3])
self.assertEqual('external.server.org.', cname[4]) self.assertEqual('external.server.org.', cname[4])
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_add(self): def test_add(self):
self.add(self.ns1_name, self.ns1_records) self.add(self.ns1_name, self.ns1_records)
self.add(self.ns2_name, self.ns2_records) self.add(self.ns2_name, self.ns2_records)
@ -186,8 +184,7 @@ class DomainTestMixin(object):
self.validate_add(self.MASTER_SERVER_ADDR, self.domain_name) self.validate_add(self.MASTER_SERVER_ADDR, self.domain_name)
time.sleep(1) time.sleep(1)
self.validate_add(self.SLAVE_SERVER_ADDR, self.domain_name) self.validate_add(self.SLAVE_SERVER_ADDR, self.domain_name)
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_delete(self): def test_delete(self):
self.add(self.ns1_name, self.ns1_records) self.add(self.ns1_name, self.ns1_records)
self.add(self.ns2_name, self.ns2_records) self.add(self.ns2_name, self.ns2_records)
@ -196,8 +193,7 @@ class DomainTestMixin(object):
for name in [self.domain_name, self.ns1_name, self.ns2_name]: for name in [self.domain_name, self.ns1_name, self.ns2_name]:
self.validate_delete(self.MASTER_SERVER_ADDR, name) self.validate_delete(self.MASTER_SERVER_ADDR, name)
self.validate_delete(self.SLAVE_SERVER_ADDR, name) self.validate_delete(self.SLAVE_SERVER_ADDR, name)
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_update(self): def test_update(self):
self.add(self.ns1_name, self.ns1_records) self.add(self.ns1_name, self.ns1_records)
self.add(self.ns2_name, self.ns2_records) self.add(self.ns2_name, self.ns2_records)
@ -213,8 +209,7 @@ class DomainTestMixin(object):
self.validate_www_update(self.MASTER_SERVER_ADDR, self.domain_name) self.validate_www_update(self.MASTER_SERVER_ADDR, self.domain_name)
time.sleep(5) time.sleep(5)
self.validate_www_update(self.SLAVE_SERVER_ADDR, self.domain_name) self.validate_www_update(self.SLAVE_SERVER_ADDR, self.domain_name)
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_add_add_delete_delete(self): def test_add_add_delete_delete(self):
self.add(self.ns1_name, self.ns1_records) self.add(self.ns1_name, self.ns1_records)
self.add(self.ns2_name, self.ns2_records) self.add(self.ns2_name, self.ns2_records)
@ -226,8 +221,7 @@ class DomainTestMixin(object):
self.delete(self.django_domain_name) self.delete(self.django_domain_name)
self.validate_delete(self.MASTER_SERVER_ADDR, self.django_domain_name) self.validate_delete(self.MASTER_SERVER_ADDR, self.django_domain_name)
self.validate_delete(self.SLAVE_SERVER_ADDR, self.django_domain_name) self.validate_delete(self.SLAVE_SERVER_ADDR, self.django_domain_name)
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_bad_creation(self): def test_bad_creation(self):
self.assertRaises((self.rest.ResponseStatusError, AssertionError), self.assertRaises((self.rest.ResponseStatusError, AssertionError),
self.add, self.domain_name, self.domain_records) self.add, self.domain_name, self.domain_records)
@ -238,7 +232,7 @@ class AdminDomainMixin(DomainTestMixin):
super(AdminDomainMixin, self).setUp() super(AdminDomainMixin, self).setUp()
self.add_route() self.add_route()
self.admin_login() self.admin_login()
def _add_records(self, records): def _add_records(self, records):
self.selenium.find_element_by_link_text('Add another Record').click() self.selenium.find_element_by_link_text('Add another Record').click()
for i, record in zip(range(0, len(records)), records): for i, record in zip(range(0, len(records)), records):
@ -250,29 +244,29 @@ class AdminDomainMixin(DomainTestMixin):
value_input.clear() value_input.clear()
value_input.send_keys(value) value_input.send_keys(value)
return value_input return value_input
@snapshot_on_error @snapshot_on_error
def add(self, domain_name, records): def add(self, domain_name, records):
add = reverse('admin:domains_domain_add') add = reverse('admin:domains_domain_add')
url = self.live_server_url + add url = self.live_server_url + add
self.selenium.get(url) self.selenium.get(url)
name = self.selenium.find_element_by_id('id_name') name = self.selenium.find_element_by_id('id_name')
name.send_keys(domain_name) name.send_keys(domain_name)
account_input = self.selenium.find_element_by_id('id_account') account_input = self.selenium.find_element_by_id('id_account')
account_select = Select(account_input) account_select = Select(account_input)
account_select.select_by_value(str(self.account.pk)) account_select.select_by_value(str(self.account.pk))
value_input = self._add_records(records) value_input = self._add_records(records)
value_input.submit() value_input.submit()
self.assertNotEqual(url, self.selenium.current_url) self.assertNotEqual(url, self.selenium.current_url)
@snapshot_on_error @snapshot_on_error
def delete(self, domain_name): def delete(self, domain_name):
domain = Domain.objects.get(name=domain_name) domain = Domain.objects.get(name=domain_name)
self.admin_delete(domain) self.admin_delete(domain)
@snapshot_on_error @snapshot_on_error
def update(self, domain_name, records): def update(self, domain_name, records):
domain = Domain.objects.get(name=domain_name) domain = Domain.objects.get(name=domain_name)
@ -289,18 +283,18 @@ class RESTDomainMixin(DomainTestMixin):
super(RESTDomainMixin, self).setUp() super(RESTDomainMixin, self).setUp()
self.rest_login() self.rest_login()
self.add_route() self.add_route()
@save_response_on_error @save_response_on_error
def add(self, domain_name, records): def add(self, domain_name, records):
records = [ dict(type=type, value=value) for type,value in records ] records = [ dict(type=type, value=value) for type,value in records ]
self.rest.domains.create(name=domain_name, records=records) self.rest.domains.create(name=domain_name, records=records)
@save_response_on_error @save_response_on_error
def delete(self, domain_name): def delete(self, domain_name):
domain = Domain.objects.get(name=domain_name) domain = Domain.objects.get(name=domain_name)
domain = self.rest.domains.retrieve(id=domain.pk) domain = self.rest.domains.retrieve(id=domain.pk)
domain.delete() domain.delete()
@save_response_on_error @save_response_on_error
def update(self, domain_name, records): def update(self, domain_name, records):
records = [ dict(type=type, value=value) for type,value in records ] records = [ dict(type=type, value=value) for type,value in records ]
@ -313,7 +307,7 @@ class Bind9BackendMixin(object):
DEPENDENCIES = ( DEPENDENCIES = (
'orchestra.contrib.orchestration', 'orchestra.contrib.orchestration',
) )
def add_route(self): def add_route(self):
master = Server.objects.create(name=self.MASTER_SERVER, address=self.MASTER_SERVER_ADDR) master = Server.objects.create(name=self.MASTER_SERVER, address=self.MASTER_SERVER_ADDR)
backend = backends.Bind9MasterDomainController.get_name() backend = backends.Bind9MasterDomainController.get_name()

View File

@ -1,12 +1,14 @@
from django.contrib import admin from django.contrib import admin
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 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 orchestra.admin.utils import admin_link, admin_date from orchestra.admin.utils import admin_date, admin_link
class LogEntryAdmin(admin.ModelAdmin): class LogEntryAdmin(admin.ModelAdmin):
@ -30,15 +32,16 @@ class LogEntryAdmin(admin.ModelAdmin):
actions = None actions = None
list_select_related = ('user', 'content_type') list_select_related = ('user', 'content_type')
list_display_links = None list_display_links = None
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 = '<a href="%(url)s"><img src="%(img)s"></img></a>' % { edit = format_html('<a href="{url}"><img src="{img}"></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),
@ -57,8 +60,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():
return _("Added") return _("Added")
@ -67,7 +69,7 @@ class LogEntryAdmin(admin.ModelAdmin):
return _("Deleted") return _("Deleted")
display_action.short_description = _("Action") display_action.short_description = _("Action")
display_action.admin_order_field = 'action_flag' display_action.admin_order_field = 'action_flag'
def content_object_link(self, log): def content_object_link(self, log):
ct = log.content_type ct = log.content_type
view = 'admin:%s_%s_change' % (ct.app_label, ct.model) view = 'admin:%s_%s_change' % (ct.app_label, ct.model)
@ -75,11 +77,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 '<a href="%s">%s</a>' % (url, log.object_repr) return format_html('<a href="{}">{}</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 """
if not add and 'edit' in request.GET.urlencode(): if not add and 'edit' in request.GET.urlencode():
@ -89,14 +90,14 @@ class LogEntryAdmin(admin.ModelAdmin):
}) })
return super(LogEntryAdmin, self).render_change_form( return super(LogEntryAdmin, self).render_change_form(
request, context, add, change, form_url, obj) request, context, add, change, form_url, obj)
def response_change(self, request, obj): def response_change(self, request, obj):
""" save and continue preserve edit query string """ """ save and continue preserve edit query string """
response = super(LogEntryAdmin, self).response_change(request, obj) response = super(LogEntryAdmin, self).response_change(request, obj)
if 'edit' in request.GET.urlencode() and 'edit' not in response.url: if 'edit' in request.GET.urlencode() and 'edit' not in response.url:
return HttpResponseRedirect(response.url + '?edit=True') return HttpResponseRedirect(response.url + '?edit=True')
return response return response
def response_post_save_change(self, request, obj): def response_post_save_change(self, request, obj):
""" save redirect to object history """ """ save redirect to object history """
if 'edit' in request.GET.urlencode(): if 'edit' in request.GET.urlencode():
@ -109,19 +110,19 @@ class LogEntryAdmin(admin.ModelAdmin):
}, post_url) }, post_url)
return HttpResponseRedirect(post_url) return HttpResponseRedirect(post_url)
return super(LogEntryAdmin, self).response_post_save_change(request, obj) return super(LogEntryAdmin, self).response_post_save_change(request, obj)
def has_add_permission(self, *args, **kwargs): def has_add_permission(self, *args, **kwargs):
return False return False
def has_delete_permission(self, *args, **kwargs): def has_delete_permission(self, *args, **kwargs):
return False return False
def log_addition(self, *args, **kwargs): def log_addition(self, *args, **kwargs):
pass pass
def log_change(self, *args, **kwargs): def log_change(self, *args, **kwargs):
pass pass
def log_deletion(self, *args, **kwargs): def log_deletion(self, *args, **kwargs):
pass pass

View File

@ -1,11 +1,12 @@
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.core.urlresolvers import reverse from django.urls 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 strip_tags from django.utils.html import format_html, 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
@ -21,14 +22,14 @@ from .helpers import get_ticket_changes, markdown_formated_changes, filter_actio
from .models import Ticket, Queue, Message from .models import Ticket, Queue, Message
PRIORITY_COLORS = { PRIORITY_COLORS = {
Ticket.HIGH: 'red', Ticket.HIGH: 'red',
Ticket.MEDIUM: 'darkorange', Ticket.MEDIUM: 'darkorange',
Ticket.LOW: 'green', Ticket.LOW: 'green',
} }
STATE_COLORS = { STATE_COLORS = {
Ticket.NEW: 'grey', Ticket.NEW: 'grey',
Ticket.IN_PROGRESS: 'darkorange', Ticket.IN_PROGRESS: 'darkorange',
Ticket.FEEDBACK: 'purple', Ticket.FEEDBACK: 'purple',
@ -44,12 +45,13 @@ class MessageReadOnlyInline(admin.TabularInline):
can_delete = False can_delete = False
fields = ('content_html',) fields = ('content_html',)
readonly_fields = ('content_html',) readonly_fields = ('content_html',)
class Media: class Media:
css = { css = {
'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,
@ -58,16 +60,17 @@ 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
def has_delete_permission(self, request, obj=None): def has_delete_permission(self, request, obj=None):
return False return False
@ -79,12 +82,12 @@ class MessageInline(admin.TabularInline):
form = MessageInlineForm form = MessageInlineForm
can_delete = False can_delete = False
fields = ('content',) fields = ('content',)
def get_formset(self, request, obj=None, **kwargs): def get_formset(self, request, obj=None, **kwargs):
""" hook request.user on the inline form """ """ hook request.user on the inline form """
self.form.user = request.user self.form.user = request.user
return super(MessageInline, self).get_formset(request, obj, **kwargs) return super(MessageInline, self).get_formset(request, obj, **kwargs)
def get_queryset(self, request): def get_queryset(self, request):
""" Don't show any message """ """ Don't show any message """
qs = super(MessageInline, self).get_queryset(request) qs = super(MessageInline, self).get_queryset(request)
@ -103,18 +106,18 @@ class TicketInline(admin.TabularInline):
model = Ticket model = Ticket
extra = 0 extra = 0
max_num = 0 max_num = 0
creator_link = admin_link('creator') creator_link = admin_link('creator')
owner_link = admin_link('owner') owner_link = admin_link('owner')
created = admin_link('created_at') created = admin_link('created_at')
updated = admin_link('updated_at') updated = admin_link('updated_at')
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):
@ -135,7 +138,7 @@ class TicketAdmin(ExtendedModelAdmin):
'owner__username' 'owner__username'
) )
actions = ( actions = (
mark_as_unread, mark_as_read, 'delete_selected', reject_tickets, mark_as_unread, mark_as_read, reject_tickets,
resolve_tickets, close_tickets, take_tickets resolve_tickets, close_tickets, take_tickets
) )
sudo_actions = ('delete_selected',) sudo_actions = ('delete_selected',)
@ -176,7 +179,7 @@ class TicketAdmin(ExtendedModelAdmin):
}), }),
) )
list_select_related = ('queue', 'owner', 'creator') list_select_related = ('queue', 'owner', 'creator')
class Media: class Media:
css = { css = {
'all': ('issues/css/ticket-admin.css',) 'all': ('issues/css/ticket-admin.css',)
@ -184,14 +187,15 @@ class TicketAdmin(ExtendedModelAdmin):
js = ( js = (
'issues/js/ticket-admin.js', 'issues/js/ticket-admin.js',
) )
display_creator = admin_link('creator') display_creator = admin_link('creator')
display_queue = admin_link('queue') display_queue = admin_link('queue')
display_owner = admin_link('owner') display_owner = admin_link('owner')
updated = admin_date('updated_at') updated = admin_date('updated_at')
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,
@ -207,50 +211,47 @@ 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 '<span style="font-weight:normal;font-size:11px;">%s</span>' % ticket.pk return format_html('<span style="font-weight:normal;font-size:11px;">{}</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'
def bold_subject(self, ticket): def bold_subject(self, ticket):
""" 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 "<strong class='unread'>%s</strong>" % ticket.subject return format_html("<strong class='unread'>{}</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'
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 """
if db_field.name == 'subject': if db_field.name == 'subject':
kwargs['widget'] = forms.TextInput(attrs={'size':'120'}) kwargs['widget'] = forms.TextInput(attrs={'size':'120'})
return super(TicketAdmin, self).formfield_for_dbfield(db_field, **kwargs) return super(TicketAdmin, self).formfield_for_dbfield(db_field, **kwargs)
def save_model(self, request, obj, *args, **kwargs): def save_model(self, request, obj, *args, **kwargs):
""" Define creator for new tickets """ """ Define creator for new tickets """
if not obj.pk: if not obj.pk:
obj.creator = request.user obj.creator = request.user
super(TicketAdmin, self).save_model(request, obj, *args, **kwargs) super(TicketAdmin, self).save_model(request, obj, *args, **kwargs)
obj.mark_as_read_by(request.user) obj.mark_as_read_by(request.user)
def get_urls(self): def get_urls(self):
""" add markdown preview url """ """ add markdown preview url """
return [ return [
url(r'^preview/$', url(r'^preview/$',
wrap_admin_view(self, self.message_preview_view)) wrap_admin_view(self, self.message_preview_view))
] + super(TicketAdmin, self).get_urls() ] + super(TicketAdmin, self).get_urls()
def add_view(self, request, form_url='', extra_context=None): def add_view(self, request, form_url='', extra_context=None):
""" Do not sow message inlines """ """ Do not sow message inlines """
return super(TicketAdmin, self).add_view(request, form_url, extra_context) return super(TicketAdmin, self).add_view(request, form_url, extra_context)
def change_view(self, request, object_id, form_url='', extra_context=None): def change_view(self, request, object_id, form_url='', extra_context=None):
""" Change view actions based on ticket state """ """ Change view actions based on ticket state """
ticket = get_object_or_404(Ticket, pk=object_id) ticket = get_object_or_404(Ticket, pk=object_id)
@ -269,12 +270,12 @@ class TicketAdmin(ExtendedModelAdmin):
context.update(extra_context or {}) context.update(extra_context or {})
return super(TicketAdmin, self).change_view(request, object_id, form_url=form_url, return super(TicketAdmin, self).change_view(request, object_id, form_url=form_url,
extra_context=context) extra_context=context)
def changelist_view(self, request, extra_context=None): def changelist_view(self, request, extra_context=None):
# Hook user for bold_subject # Hook user for bold_subject
self.user = request.user self.user = request.user
return super(TicketAdmin,self).changelist_view(request, extra_context=extra_context) return super(TicketAdmin,self).changelist_view(request, extra_context=extra_context)
def message_preview_view(self, request): def message_preview_view(self, request):
""" markdown preview render via ajax """ """ markdown preview render via ajax """
data = request.POST.get("data") data = request.POST.get("data")
@ -287,21 +288,20 @@ class QueueAdmin(admin.ModelAdmin):
actions = (set_default_queue,) actions = (set_default_queue,)
inlines = (TicketInline,) inlines = (TicketInline,)
ordering = ('name',) ordering = ('name',)
class Media: class Media:
css = { css = {
'all': ('orchestra/css/hide-inline-id.css',) 'all': ('orchestra/css/hide-inline-id.css',)
} }
def num_tickets(self, queue): def num_tickets(self, queue):
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 '<a href="%s">%d</a>' % (url, num) return format_html('<a href="{}">{}</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 """
list_display = list(self.list_display) list_display = list(self.list_display)
@ -312,7 +312,7 @@ class QueueAdmin(admin.ModelAdmin):
display_notify.boolean = True display_notify.boolean = True
list_display.append(display_notify) list_display.append(display_notify)
return list_display return list_display
def get_queryset(self, request): def get_queryset(self, request):
qs = super(QueueAdmin, self).get_queryset(request) qs = super(QueueAdmin, self).get_queryset(request)
qs = qs.annotate(models.Count('tickets')) qs = qs.annotate(models.Count('tickets'))

View File

@ -1,5 +1,5 @@
from rest_framework import viewsets, mixins from rest_framework import viewsets, mixins
from rest_framework.decorators import detail_route from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from orchestra.api import router, LogApiMixin from orchestra.api import router, LogApiMixin
@ -12,19 +12,19 @@ from .serializers import TicketSerializer, QueueSerializer
class TicketViewSet(LogApiMixin, viewsets.ModelViewSet): class TicketViewSet(LogApiMixin, viewsets.ModelViewSet):
queryset = Ticket.objects.all() queryset = Ticket.objects.all()
serializer_class = TicketSerializer serializer_class = TicketSerializer
@detail_route() @action(detail=True)
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'})
@detail_route() @action(detail=True)
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)
return Response({'status': 'Ticket marked as unread'}) return Response({'status': 'Ticket marked as unread'})
def get_queryset(self): def get_queryset(self):
qs = super(TicketViewSet, self).get_queryset() qs = super(TicketViewSet, self).get_queryset()
qs = qs.select_related('creator', 'queue') qs = qs.select_related('creator', 'queue')

View File

@ -13,7 +13,7 @@ from .models import Queue, Ticket
class MarkDownWidget(forms.Textarea): class MarkDownWidget(forms.Textarea):
""" MarkDown textarea widget with syntax preview """ """ MarkDown textarea widget with syntax preview """
markdown_url = static('issues/markdown_syntax.html') markdown_url = static('issues/markdown_syntax.html')
markdown_help_text = ( markdown_help_text = (
'<a href="%s" onclick=\'window.open("%s", "", "resizable=yes, ' '<a href="%s" onclick=\'window.open("%s", "", "resizable=yes, '
@ -21,8 +21,8 @@ class MarkDownWidget(forms.Textarea):
'return false;\'>markdown format</a>' % (markdown_url, markdown_url) 'return false;\'>markdown format</a>' % (markdown_url, markdown_url)
) )
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): def render(self, name, value, attrs, renderer=None):
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>'\
@ -35,18 +35,18 @@ class MessageInlineForm(forms.ModelForm):
""" Add message form """ """ Add message form """
created_on = forms.CharField(label="Created On", required=False) created_on = forms.CharField(label="Created On", required=False)
content = forms.CharField(widget=MarkDownWidget(), required=False) content = forms.CharField(widget=MarkDownWidget(), required=False)
class Meta: class Meta:
fields = ('author', 'author_name', 'created_on', 'content') fields = ('author', 'author_name', 'created_on', 'content')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(MessageInlineForm, self).__init__(*args, **kwargs) super(MessageInlineForm, self).__init__(*args, **kwargs)
self.fields['created_on'].widget = SpanWidget(display='') self.fields['created_on'].widget = SpanWidget(display='')
def clean_content(self): def clean_content(self):
""" clean HTML tags """ """ clean HTML tags """
return strip_tags(self.cleaned_data['content']) return strip_tags(self.cleaned_data['content'])
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.instance.pk is None: if self.instance.pk is None:
self.instance.author = self.user self.instance.author = self.user
@ -58,7 +58,7 @@ class UsersIterator(forms.models.ModelChoiceIterator):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.ticket = kwargs.pop('ticket', False) self.ticket = kwargs.pop('ticket', False)
super(forms.models.ModelChoiceIterator, self).__init__(*args, **kwargs) super(forms.models.ModelChoiceIterator, self).__init__(*args, **kwargs)
def __iter__(self): def __iter__(self):
yield ('', '---------') yield ('', '---------')
users = get_user_model().objects.exclude(is_active=False).order_by('name') users = get_user_model().objects.exclude(is_active=False).order_by('name')
@ -73,14 +73,14 @@ class UsersIterator(forms.models.ModelChoiceIterator):
class TicketForm(forms.ModelForm): class TicketForm(forms.ModelForm):
display_description = forms.CharField(label=_("Description"), required=False) display_description = forms.CharField(label=_("Description"), required=False)
description = forms.CharField(widget=MarkDownWidget(attrs={'class':'vLargeTextField'})) description = forms.CharField(widget=MarkDownWidget(attrs={'class':'vLargeTextField'}))
class Meta: class Meta:
model = Ticket model = Ticket
fields = ( fields = (
'creator', 'creator_name', 'owner', 'queue', 'subject', 'description', 'creator', 'creator_name', 'owner', 'queue', 'subject', 'description',
'priority', 'state', 'cc', 'display_description' 'priority', 'state', 'cc', 'display_description'
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(TicketForm, self).__init__(*args, **kwargs) super(TicketForm, self).__init__(*args, **kwargs)
ticket = kwargs.get('instance', False) ticket = kwargs.get('instance', False)
@ -101,7 +101,7 @@ class TicketForm(forms.ModelForm):
description = '<div style="padding-left: 95px;">%s</div>' % description description = '<div style="padding-left: 95px;">%s</div>' % description
widget = SpanWidget(display=description) widget = SpanWidget(display=description)
self.fields['display_description'].widget = widget self.fields['display_description'].widget = widget
def clean_description(self): def clean_description(self):
""" clean HTML tags """ """ clean HTML tags """
return strip_tags(self.cleaned_data['description']) return strip_tags(self.cleaned_data['description'])

View File

@ -1,6 +1,7 @@
# -*- 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
@ -20,7 +21,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(related_name='ticket_messages', to=settings.AUTH_USER_MODEL, verbose_name='author')), ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_messages', to=settings.AUTH_USER_MODEL, verbose_name='author')),
], ],
options={ options={
'get_latest_by': 'id', 'get_latest_by': 'id',
@ -48,9 +49,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(related_name='tickets_created', null=True, to=settings.AUTH_USER_MODEL, verbose_name='created by')), ('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')),
('owner', models.ForeignKey(blank=True, related_name='tickets_owned', null=True, to=settings.AUTH_USER_MODEL, verbose_name='assigned to')), ('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')),
('queue', models.ForeignKey(blank=True, related_name='tickets', null=True, to='issues.Queue')), ('queue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, blank=True, related_name='tickets', null=True, to='issues.Queue')),
], ],
options={ options={
'ordering': ['-updated_at'], 'ordering': ['-updated_at'],
@ -60,14 +61,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(related_name='trackers', to='issues.Ticket', verbose_name='ticket')), ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trackers', to='issues.Ticket', verbose_name='ticket')),
('user', models.ForeignKey(related_name='ticket_trackers', to=settings.AUTH_USER_MODEL, verbose_name='user')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, 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(related_name='messages', to='issues.Ticket', verbose_name='ticket'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='issues.Ticket', verbose_name='ticket'),
), ),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
name='tickettracker', name='tickettracker',

View File

@ -0,0 +1,114 @@
# -*- 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

@ -19,10 +19,10 @@ class Queue(models.Model):
choices=Contact.EMAIL_USAGES, choices=Contact.EMAIL_USAGES,
default=contacts_settings.CONTACTS_DEFAULT_EMAIL_USAGES, default=contacts_settings.CONTACTS_DEFAULT_EMAIL_USAGES,
help_text=_("Contacts to notify by email")) help_text=_("Contacts to notify by email"))
def __str__(self): def __str__(self):
return self.verbose_name or self.name return self.verbose_name or self.name
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" mark as default queue if needed """ """ mark as default queue if needed """
existing_default = Queue.objects.filter(default=True) existing_default = Queue.objects.filter(default=True)
@ -48,7 +48,7 @@ class Ticket(models.Model):
(MEDIUM, 'Medium'), (MEDIUM, 'Medium'),
(LOW, 'Low'), (LOW, 'Low'),
) )
NEW = 'NEW' NEW = 'NEW'
IN_PROGRESS = 'IN_PROGRESS' IN_PROGRESS = 'IN_PROGRESS'
RESOLVED = 'RESOLVED' RESOLVED = 'RESOLVED'
@ -63,7 +63,7 @@ class Ticket(models.Model):
(REJECTED, 'Rejected'), (REJECTED, 'Rejected'),
(CLOSED, 'Closed'), (CLOSED, 'Closed'),
) )
creator = models.ForeignKey(djsettings.AUTH_USER_MODEL, verbose_name=_("created by"), creator = models.ForeignKey(djsettings.AUTH_USER_MODEL, verbose_name=_("created by"),
related_name='tickets_created', null=True, on_delete=models.SET_NULL) related_name='tickets_created', null=True, on_delete=models.SET_NULL)
creator_name = models.CharField(_("creator name"), max_length=256, blank=True) creator_name = models.CharField(_("creator name"), max_length=256, blank=True)
@ -79,15 +79,15 @@ class Ticket(models.Model):
created_at = models.DateTimeField(_("created"), auto_now_add=True, db_index=True) created_at = models.DateTimeField(_("created"), auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(_("modified"), auto_now=True) updated_at = models.DateTimeField(_("modified"), auto_now=True)
cc = models.TextField("CC", help_text=_("emails to send a carbon copy to"), blank=True) cc = models.TextField("CC", help_text=_("emails to send a carbon copy to"), blank=True)
objects = TicketQuerySet.as_manager() objects = TicketQuerySet.as_manager()
class Meta: class Meta:
ordering = ['-updated_at'] ordering = ['-updated_at']
def __str__(self): def __str__(self):
return str(self.pk) return str(self.pk)
def get_notification_emails(self): def get_notification_emails(self):
""" Get emails of the users related to the ticket """ """ Get emails of the users related to the ticket """
emails = list(settings.ISSUES_SUPPORT_EMAILS) emails = list(settings.ISSUES_SUPPORT_EMAILS)
@ -100,7 +100,7 @@ class Ticket(models.Model):
for message in self.messages.distinct('author'): for message in self.messages.distinct('author'):
emails.append(message.author.email) emails.append(message.author.email)
return set(emails + self.get_cc_emails()) return set(emails + self.get_cc_emails())
def notify(self, message=None, content=None): def notify(self, message=None, content=None):
""" Send an email to ticket stakeholders notifying an state update """ """ Send an email to ticket stakeholders notifying an state update """
emails = self.get_notification_emails() emails = self.get_notification_emails()
@ -111,7 +111,7 @@ class Ticket(models.Model):
'ticket_message': message 'ticket_message': message
} }
send_email_template(template, context, emails, html=html_template) send_email_template(template, context, emails, html=html_template)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" notify stakeholders of new ticket """ """ notify stakeholders of new ticket """
new_issue = not self.pk new_issue = not self.pk
@ -121,60 +121,60 @@ class Ticket(models.Model):
if new_issue: if new_issue:
# PK should be available for rendering the template # PK should be available for rendering the template
self.notify() self.notify()
def is_involved_by(self, user): def is_involved_by(self, user):
""" returns whether user has participated or is referenced on the ticket """ returns whether user has participated or is referenced on the ticket
as owner or member of the group as owner or member of the group
""" """
return Ticket.objects.filter(pk=self.pk).involved_by(user).exists() return Ticket.objects.filter(pk=self.pk).involved_by(user).exists()
def get_cc_emails(self): def get_cc_emails(self):
return self.cc.split(',') if self.cc else [] return self.cc.split(',') if self.cc else []
def mark_as_read_by(self, user): def mark_as_read_by(self, user):
self.trackers.get_or_create(user=user) self.trackers.get_or_create(user=user)
def mark_as_unread_by(self, user): def mark_as_unread_by(self, user):
self.trackers.filter(user=user).delete() self.trackers.filter(user=user).delete()
def mark_as_unread(self): def mark_as_unread(self):
self.trackers.all().delete() self.trackers.all().delete()
def is_read_by(self, user): def is_read_by(self, user):
return self.trackers.filter(user=user).exists() return self.trackers.filter(user=user).exists()
def reject(self): def reject(self):
self.state = Ticket.REJECTED self.state = Ticket.REJECTED
self.save(update_fields=('state', 'updated_at')) self.save(update_fields=('state', 'updated_at'))
def resolve(self): def resolve(self):
self.state = Ticket.RESOLVED self.state = Ticket.RESOLVED
self.save(update_fields=('state', 'updated_at')) self.save(update_fields=('state', 'updated_at'))
def close(self): def close(self):
self.state = Ticket.CLOSED self.state = Ticket.CLOSED
self.save(update_fields=('state', 'updated_at')) self.save(update_fields=('state', 'updated_at'))
def take(self, user): def take(self, user):
self.owner = user self.owner = user
self.save(update_fields=('state', 'updated_at')) self.save(update_fields=('state', 'updated_at'))
class Message(models.Model): class Message(models.Model):
ticket = models.ForeignKey('issues.Ticket', verbose_name=_("ticket"), ticket = models.ForeignKey('issues.Ticket', on_delete=models.CASCADE,
related_name='messages') verbose_name=_("ticket"), related_name='messages')
author = models.ForeignKey(djsettings.AUTH_USER_MODEL, verbose_name=_("author"), author = models.ForeignKey(djsettings.AUTH_USER_MODEL, on_delete=models.CASCADE,
related_name='ticket_messages') verbose_name=_("author"), 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)
class Meta: class Meta:
get_latest_by = 'id' get_latest_by = 'id'
def __str__(self): def __str__(self):
return "#%i" % self.id return "#%i" % self.id
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" notify stakeholders of ticket update """ """ notify stakeholders of ticket update """
if not self.pk: if not self.pk:
@ -183,7 +183,7 @@ class Message(models.Model):
self.ticket.notify(message=self) self.ticket.notify(message=self)
self.author_name = self.author.get_full_name() self.author_name = self.author.get_full_name()
super(Message, self).save(*args, **kwargs) super(Message, self).save(*args, **kwargs)
@property @property
def number(self): def number(self):
return self.ticket.messages.filter(id__lte=self.id).count() return self.ticket.messages.filter(id__lte=self.id).count()
@ -191,10 +191,11 @@ 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, verbose_name=_("ticket"), related_name='trackers') ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE,
user = models.ForeignKey(djsettings.AUTH_USER_MODEL, verbose_name=_("user"), verbose_name=_("ticket"), related_name='trackers')
related_name='ticket_trackers') user = models.ForeignKey(djsettings.AUTH_USER_MODEL, on_delete=models.CASCADE,
verbose_name=_("user"), related_name='ticket_trackers')
class Meta: class Meta:
unique_together = ( unique_together = (
('ticket', 'user'), ('ticket', 'user'),

View File

@ -3,6 +3,7 @@ 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
@ -22,8 +23,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(related_name='lists', to=settings.AUTH_USER_MODEL, verbose_name='Account')), ('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(null=True, blank=True, to='domains.Domain', verbose_name='address domain')), ('address_domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, null=True, blank=True, to='domains.Domain', verbose_name='address domain')),
], ],
), ),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(

View File

@ -0,0 +1,69 @@
# -*- 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

@ -0,0 +1,21 @@
# -*- 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,54 +30,54 @@ 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') related_name='lists', on_delete=models.CASCADE)
# 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. "
"Unselect this instead of deleting accounts.")) "Unselect this instead of deleting accounts."))
password = None password = None
objects = ListQuerySet.as_manager() objects = ListQuerySet.as_manager()
class Meta: class Meta:
unique_together = ('address_name', 'address_domain') unique_together = ('address_name', 'address_domain')
def __str__(self): def __str__(self):
return self.name return self.name
@property @property
def address(self): def address(self):
if self.address_name and self.address_domain: if self.address_name and self.address_domain:
return "%s@%s" % (self.address_name, self.address_domain) return "%s@%s" % (self.address_name, self.address_domain)
return '' return ''
@cached_property @cached_property
def active(self): def active(self):
return self.is_active and self.account.is_active return self.is_active and self.account.is_active
def clean(self): def clean(self):
if self.address_name and not self.address_domain_id: if self.address_name and not self.address_domain_id:
raise ValidationError({ raise ValidationError({
'address_domain': _("Domain should be selected for provided address name."), 'address_domain': _("Domain should be selected for provided address name."),
}) })
def disable(self): def disable(self):
self.is_active = False self.is_active = False
self.save(update_fields=('is_active',)) self.save(update_fields=('is_active',))
def enable(self): def enable(self):
self.is_active = False self.is_active = False
self.save(update_fields=('is_active',)) self.save(update_fields=('is_active',))
def get_address_name(self): def get_address_name(self):
return self.address_name or self.name return self.address_name or self.name
def get_username(self): def get_username(self):
return self.name return self.name
def set_password(self, password): def set_password(self, password):
self.password = password self.password = password
def get_absolute_url(self): def get_absolute_url(self):
context = { context = {
'name': self.name 'name': self.name

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.rel.to model = List.address_domain.field.related_model
fields = ('url', 'id', 'name') fields = ('url', 'id', 'name')
@ -26,14 +26,14 @@ class ListSerializer(AccountSerializerMixin, SetPasswordHyperlinkedSerializer):
'This value may contain any ascii character except for ' 'This value may contain any ascii character except for '
' \'/"/\\/ characters.'), 'invalid'), ' \'/"/\\/ characters.'), 'invalid'),
]) ])
address_domain = RelatedDomainSerializer(required=False) address_domain = RelatedDomainSerializer(required=False)
class Meta: class Meta:
model = List model = List
fields = ('url', 'id', 'name', 'password', 'address_name', 'address_domain', 'admin_email', 'is_active',) fields = ('url', 'id', 'name', 'password', 'address_name', 'address_domain', 'admin_email', 'is_active',)
postonly_fields = ('name', 'password') postonly_fields = ('name', 'password')
def validate_address_domain(self, address_name): def validate_address_domain(self, address_name):
if self.instance: if self.instance:
address_domain = address_domain or self.instance.address_domain address_domain = address_domain or self.instance.address_domain

View File

@ -1,25 +1,26 @@
import os import os
import smtplib import smtplib
import time import time
import requests import unittest
from unittest import skip
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.core.urlresolvers import reverse from django.urls 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 Server, Route from orchestra.contrib.orchestration.models import Route, Server
from orchestra.utils.sys import sshrun from orchestra.utils.sys import sshrun
from orchestra.utils.tests import (BaseLiveServerTestCase, random_ascii, snapshot_on_error, from orchestra.utils.tests import (BaseLiveServerTestCase, random_ascii,
save_response_on_error) save_response_on_error, snapshot_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')
@ -28,15 +29,12 @@ class ListMixin(object):
'orchestra.contrib.domains', 'orchestra.contrib.domains',
'orchestra.contrib.lists', 'orchestra.contrib.lists',
) )
def setUp(self): def setUp(self):
super(ListMixin, self).setUp() super(ListMixin, self).setUp()
djsettings.DEBUG = True
djsettings.CELERY_ALWAYS_EAGER = True
djsettings.CELERY_TASK_ALWAYS_EAGER = True
# import pdb; pdb.set_trace()
self.add_route() self.add_route()
djsettings.DEBUG = True
def validate_add(self, name, address=None): def validate_add(self, name, address=None):
sshrun(self.MASTER_SERVER, 'list_members %s' % name, display=False) sshrun(self.MASTER_SERVER, 'list_members %s' % name, display=False)
if not address: if not address:
@ -48,11 +46,11 @@ class ListMixin(object):
sshrun(self.MASTER_SERVER, sshrun(self.MASTER_SERVER,
'grep -v ":\|^\s\|^$\|-\|\.\|\s" /var/spool/mail/nobody | base64 -d | grep "%s"' 'grep -v ":\|^\s\|^$\|-\|\.\|\s" /var/spool/mail/nobody | base64 -d | grep "%s"'
% request_address, display=False) % request_address, display=False)
def validate_login(self, name, password): def validate_login(self, name, password):
url = 'http://%s/cgi-bin/mailman/admin/%s' % (settings.LISTS_DEFAULT_DOMAIN, name) url = 'http://%s/cgi-bin/mailman/admin/%s' % (settings.LISTS_DEFAULT_DOMAIN, name)
self.assertEqual(200, requests.post(url, data={'adminpw': password}).status_code) self.assertEqual(200, requests.post(url, data={'adminpw': password}).status_code)
def validate_delete(self, name): def validate_delete(self, name):
context = { context = {
'name': name, 'name': name,
@ -66,7 +64,7 @@ class ListMixin(object):
'grep "^\s*$(domain)s\s*$" %(virtual_domain)s' % context, display=False) 'grep "^\s*$(domain)s\s*$" %(virtual_domain)s' % context, display=False)
self.assertRaises(CommandError, sshrun, self.MASTER_SERVER, self.assertRaises(CommandError, sshrun, self.MASTER_SERVER,
'list_lists | grep -i "^\s*%(name)s\s"' % context, display=False) 'list_lists | grep -i "^\s*%(name)s\s"' % context, display=False)
def subscribe(self, subscribe_address): def subscribe(self, subscribe_address):
msg = MIMEText('') msg = MIMEText('')
msg['To'] = subscribe_address msg['To'] = subscribe_address
@ -80,13 +78,12 @@ class ListMixin(object):
server.sendmail(msg['From'], msg['To'], msg.as_string()) server.sendmail(msg['From'], msg['To'], msg.as_string())
finally: finally:
server.quit() server.quit()
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.MailmanController.get_name() backend = backends.MailmanController.get_name()
Route.objects.create(backend=backend, match=True, host=server) Route.objects.create(backend=backend, match=True, host=server)
# @skip("Skip because not exists get_auth_token in orm.api.Api")
def test_add(self): def test_add(self):
name = '%s_list' % random_ascii(10) name = '%s_list' % random_ascii(10)
password = '@!?%spppP001' % random_ascii(5) password = '@!?%spppP001' % random_ascii(5)
@ -95,8 +92,7 @@ class ListMixin(object):
self.validate_add(name) self.validate_add(name)
self.validate_login(name, password) self.validate_login(name, password)
self.addCleanup(self.delete, name) self.addCleanup(self.delete, name)
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_add_with_address(self): def test_add_with_address(self):
name = '%s_list' % random_ascii(10) name = '%s_list' % random_ascii(10)
password = '@!?%spppP001' % random_ascii(5) password = '@!?%spppP001' % random_ascii(5)
@ -108,8 +104,7 @@ class ListMixin(object):
self.addCleanup(self.delete, name) self.addCleanup(self.delete, name)
# Mailman doesn't support changing the address, only the domain # Mailman doesn't support changing the address, only the domain
self.validate_add(name, address="%s@%s" % (address_name, address_domain)) self.validate_add(name, address="%s@%s" % (address_name, address_domain))
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_change_password(self): def test_change_password(self):
name = '%s_list' % random_ascii(10) name = '%s_list' % random_ascii(10)
password = '@!?%spppP001' % random_ascii(5) password = '@!?%spppP001' % random_ascii(5)
@ -120,8 +115,7 @@ class ListMixin(object):
new_password = '@!?%spppP001' % random_ascii(5) new_password = '@!?%spppP001' % random_ascii(5)
self.change_password(name, new_password) self.change_password(name, new_password)
self.validate_login(name, new_password) self.validate_login(name, new_password)
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_change_domain(self): def test_change_domain(self):
name = '%s_list' % random_ascii(10) name = '%s_list' % random_ascii(10)
password = '@!?%spppP001' % random_ascii(5) password = '@!?%spppP001' % random_ascii(5)
@ -136,8 +130,7 @@ class ListMixin(object):
address_domain = Domain.objects.create(name=domain_name, account=self.account) address_domain = Domain.objects.create(name=domain_name, account=self.account)
self.update_domain(name, domain_name) self.update_domain(name, domain_name)
self.validate_add(name, address="%s@%s" % (address_name, address_domain)) self.validate_add(name, address="%s@%s" % (address_name, address_domain))
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_change_address_name(self): def test_change_address_name(self):
name = '%s_list' % random_ascii(10) name = '%s_list' % random_ascii(10)
password = '@!?%spppP001' % random_ascii(5) password = '@!?%spppP001' % random_ascii(5)
@ -151,8 +144,7 @@ class ListMixin(object):
address_name = '%s_name' % random_ascii(10) address_name = '%s_name' % random_ascii(10)
self.update_address_name(name, address_name) self.update_address_name(name, address_name)
self.validate_add(name, address="%s@%s" % (address_name, address_domain)) self.validate_add(name, address="%s@%s" % (address_name, address_domain))
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_delete(self): def test_delete(self):
name = '%s_list' % random_ascii(10) name = '%s_list' % random_ascii(10)
password = '@!?%spppP001' % random_ascii(5) password = '@!?%spppP001' % random_ascii(5)
@ -160,8 +152,7 @@ class ListMixin(object):
address_name = '%s_name' % random_ascii(10) address_name = '%s_name' % random_ascii(10)
domain_name = '%sdomain.lan' % random_ascii(10) domain_name = '%sdomain.lan' % random_ascii(10)
address_domain = Domain.objects.create(name=domain_name, account=self.account) address_domain = Domain.objects.create(name=domain_name, account=self.account)
self.add(name, password, admin_email, address_name=address_name, self.add(name, password, admin_email, address_name=address_name, address_domain=address_domain)
address_domain=address_domain)
# Mailman doesn't support changing the address, only the domain # Mailman doesn't support changing the address, only the domain
self.validate_add(name, address="%s@%s" % (address_name, address_domain)) self.validate_add(name, address="%s@%s" % (address_name, address_domain))
self.delete(name) self.delete(name)
@ -169,11 +160,12 @@ 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()
self.rest_login() self.rest_login()
@save_response_on_error @save_response_on_error
def add(self, name, password, admin_email, address_name=None, address_domain=None): def add(self, name, password, admin_email, address_name=None, address_domain=None):
extra = {} extra = {}
@ -183,22 +175,22 @@ class RESTListMixin(ListMixin):
'address_domain': self.rest.domains.retrieve(name=address_domain.name).get(), 'address_domain': self.rest.domains.retrieve(name=address_domain.name).get(),
}) })
self.rest.lists.create(name=name, password=password, admin_email=admin_email, **extra) self.rest.lists.create(name=name, password=password, admin_email=admin_email, **extra)
@save_response_on_error @save_response_on_error
def delete(self, name): def delete(self, name):
self.rest.lists.retrieve(name=name).delete() self.rest.lists.retrieve(name=name).delete()
@save_response_on_error @save_response_on_error
def change_password(self, name, password): def change_password(self, name, password):
mail_list = self.rest.lists.retrieve(name=name).get() mail_list = self.rest.lists.retrieve(name=name).get()
mail_list.set_password(password) mail_list.set_password(password)
@save_response_on_error @save_response_on_error
def update_domain(self, name, domain_name): def update_domain(self, name, domain_name):
mail_list = self.rest.lists.retrieve(name=name).get() mail_list = self.rest.lists.retrieve(name=name).get()
domain = self.rest.domains.retrieve(name=domain_name).get() domain = self.rest.domains.retrieve(name=domain_name).get()
mail_list.update(address_domain=domain) mail_list.update(address_domain=domain)
@save_response_on_error @save_response_on_error
def update_address_name(self, name, address_name): def update_address_name(self, name, address_name):
mail_list = self.rest.lists.retrieve(name=name).get() mail_list = self.rest.lists.retrieve(name=name).get()
@ -236,8 +228,6 @@ class AdminListMixin(ListMixin):
domain_select.select_by_value(str(domain.pk)) domain_select.select_by_value(str(domain.pk))
name_field.submit() name_field.submit()
# import pdb; pdb.set_trace()
# oop = Server.objects.all()
self.assertNotEqual(url, self.selenium.current_url) self.assertNotEqual(url, self.selenium.current_url)
@snapshot_on_error @snapshot_on_error
@ -255,26 +245,26 @@ class AdminListMixin(ListMixin):
mail_list = List.objects.get(name=name) mail_list = List.objects.get(name=name)
url = self.live_server_url + change_url(mail_list) url = self.live_server_url + change_url(mail_list)
self.selenium.get(url) self.selenium.get(url)
domain = Domain.objects.get(name=domain_name) domain = Domain.objects.get(name=domain_name)
domain_input = self.selenium.find_element_by_id('id_address_domain') domain_input = self.selenium.find_element_by_id('id_address_domain')
domain_select = Select(domain_input) domain_select = Select(domain_input)
domain_select.select_by_value(str(domain.pk)) domain_select.select_by_value(str(domain.pk))
save = self.selenium.find_element_by_name('_save') save = self.selenium.find_element_by_name('_save')
save.submit() save.submit()
self.assertNotEqual(url, self.selenium.current_url) self.assertNotEqual(url, self.selenium.current_url)
@snapshot_on_error @snapshot_on_error
def update_address_name(self, name, address_name): def update_address_name(self, name, address_name):
mail_list = List.objects.get(name=name) mail_list = List.objects.get(name=name)
url = self.live_server_url + change_url(mail_list) url = self.live_server_url + change_url(mail_list)
self.selenium.get(url) self.selenium.get(url)
address_name_field = self.selenium.find_element_by_id('id_address_name') address_name_field = self.selenium.find_element_by_id('id_address_name')
address_name_field.clear() address_name_field.clear()
address_name_field.send_keys(address_name) address_name_field.send_keys(address_name)
save = self.selenium.find_element_by_name('_save') save = self.selenium.find_element_by_name('_save')
save.submit() save.submit()
self.assertNotEqual(url, self.selenium.current_url) self.assertNotEqual(url, self.selenium.current_url)

View File

@ -3,9 +3,10 @@ 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.core.urlresolvers import reverse from django.urls 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 _
@ -28,7 +29,7 @@ from .widgets import OpenCustomFilteringOnSelect
class AutoresponseInline(admin.StackedInline): class AutoresponseInline(admin.StackedInline):
model = Autoresponse model = Autoresponse
verbose_name_plural = _("autoresponse") verbose_name_plural = _("autoresponse")
def formfield_for_dbfield(self, db_field, **kwargs): def formfield_for_dbfield(self, db_field, **kwargs):
if db_field.name == 'subject': if db_field.name == 'subject':
kwargs['widget'] = forms.TextInput(attrs={'size':'118'}) kwargs['widget'] = forms.TextInput(attrs={'size':'118'})
@ -76,12 +77,13 @@ class MailboxAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedMo
form = MailboxChangeForm form = MailboxChangeForm
list_prefetch_related = ('addresses__domain',) list_prefetch_related = ('addresses__domain',)
actions = (disable, enable, list_accounts) actions = (disable, enable, list_accounts)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(MailboxAdmin, self).__init__(*args, **kwargs) super(MailboxAdmin, self).__init__(*args, **kwargs)
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()
@ -93,7 +95,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 = '<a href="%s">%s</a>' % (url, email) link = format_html('<a href="{}">{}</a>', url, email)
try: try:
cached_forwards[mbox].append(link) cached_forwards[mbox].append(link)
except KeyError: except KeyError:
@ -107,32 +109,29 @@ 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('<a href="%s">%s</a>' % (url, addr.email)) addresses.append(format_html('<a href="{}">{}</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 = [] forwards = mailbox.get_forwards()
for addr in mailbox.get_forwards(): return format_html_join(
url = change_url(addr) '<br>', '<a href="{}">{}</a>',
forwards.append('<a href="%s">%s</a>' % (url, addr.email)) [(change_url(addr), addr.email) for addr in forwards]
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':
kwargs['widget'] = OpenCustomFilteringOnSelect() kwargs['widget'] = OpenCustomFilteringOnSelect()
return super(MailboxAdmin, self).formfield_for_dbfield(db_field, **kwargs) return super(MailboxAdmin, self).formfield_for_dbfield(db_field, **kwargs)
def get_fieldsets(self, request, obj=None): def get_fieldsets(self, request, obj=None):
fieldsets = super(MailboxAdmin, self).get_fieldsets(request, obj) fieldsets = super(MailboxAdmin, self).get_fieldsets(request, obj)
if obj and obj.filtering == obj.CUSTOM: if obj and obj.filtering == obj.CUSTOM:
@ -144,31 +143,31 @@ class MailboxAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedMo
fieldsets = list(copy.deepcopy(fieldsets)) fieldsets = list(copy.deepcopy(fieldsets))
fieldsets.pop(-1) fieldsets.pop(-1)
return fieldsets return fieldsets
def get_form(self, *args, **kwargs): def get_form(self, *args, **kwargs):
form = super(MailboxAdmin, self).get_form(*args, **kwargs) form = super(MailboxAdmin, self).get_form(*args, **kwargs)
form.modeladmin = self form.modeladmin = self
return form return form
def get_search_results(self, request, queryset, search_term): def get_search_results(self, request, queryset, search_term):
# Remove local domain from the search term if present (implicit local addreç) # Remove local domain from the search term if present (implicit local addreç)
search_term = search_term.replace('@'+settings.MAILBOXES_LOCAL_DOMAIN, '') search_term = search_term.replace('@'+settings.MAILBOXES_LOCAL_DOMAIN, '')
# Split address name from domain in order to support address searching # Split address name from domain in order to support address searching
search_term = search_term.replace('@', ' ') search_term = search_term.replace('@', ' ')
return super(MailboxAdmin, self).get_search_results(request, queryset, search_term) return super(MailboxAdmin, self).get_search_results(request, queryset, search_term)
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):
if not add: if not add:
self.check_unrelated_address(request, obj) self.check_unrelated_address(request, obj)
self.check_matching_address(request, obj) self.check_matching_address(request, obj)
return super(MailboxAdmin, self).render_change_form( return super(MailboxAdmin, self).render_change_form(
request, context, add, change, form_url, obj) request, context, add, change, form_url, obj)
def log_addition(self, request, object, *args, **kwargs): def log_addition(self, request, object, *args, **kwargs):
self.check_unrelated_address(request, object) self.check_unrelated_address(request, object)
self.check_matching_address(request, object) self.check_matching_address(request, object)
return super(MailboxAdmin, self).log_addition(request, object, *args, **kwargs) return super(MailboxAdmin, self).log_addition(request, object, *args, **kwargs)
def check_matching_address(self, request, obj): def check_matching_address(self, request, obj):
local_domain = settings.MAILBOXES_LOCAL_DOMAIN local_domain = settings.MAILBOXES_LOCAL_DOMAIN
if obj.name and local_domain: if obj.name and local_domain:
@ -183,7 +182,7 @@ class MailboxAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedMo
"selecting it makes sense.") % (obj, addr) "selecting it makes sense.") % (obj, addr)
if msg not in (m.message for m in messages.get_messages(request)): if msg not in (m.message for m in messages.get_messages(request)):
self.message_user(request, msg, level=messages.WARNING) self.message_user(request, msg, level=messages.WARNING)
def check_unrelated_address(self, request, obj): def check_unrelated_address(self, request, obj):
# Check if there exists an unrelated local Address for this mbox # Check if there exists an unrelated local Address for this mbox
local_domain = settings.MAILBOXES_LOCAL_DOMAIN local_domain = settings.MAILBOXES_LOCAL_DOMAIN
@ -204,7 +203,7 @@ class MailboxAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedMo
# Prevent duplication (add_view+continue) # Prevent duplication (add_view+continue)
if msg not in (m.message for m in messages.get_messages(request)): if msg not in (m.message for m in messages.get_messages(request)):
self.message_user(request, msg, level=messages.WARNING) self.message_user(request, msg, level=messages.WARNING)
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
""" save hacky mailbox.addresses and local domain clashing """ """ save hacky mailbox.addresses and local domain clashing """
if obj.filtering != obj.CUSTOM: if obj.filtering != obj.CUSTOM:
@ -217,7 +216,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 = form.cleaned_data['addresses'] obj.addresses.set(form.cleaned_data['addresses'])
class AddressAdmin(SelectAccountAdminMixin, ExtendedModelAdmin): class AddressAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
@ -237,39 +236,37 @@ class AddressAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
filter_horizontal = ['mailboxes'] filter_horizontal = ['mailboxes']
form = AddressForm form = AddressForm
list_prefetch_related = ('mailboxes', 'domain') list_prefetch_related = ('mailboxes', 'domain')
domain_link = admin_link('domain', order='domain__name') domain_link = admin_link('domain', order='domain__name')
def display_email(self, address): def display_email(self, address):
return address.computed_email return address.computed_email
display_email.short_description = _("Email") display_email.short_description = _("Email")
display_email.admin_order_field = 'computed_email' display_email.admin_order_field = 'computed_email'
def email_link(self, address): def email_link(self, address):
link = self.domain_link(address) link = self.domain_link(address)
return "%s@%s" % (address.name, link) return format_html("{}@{}", 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 = [] boxes = address.mailboxes.all()
for mailbox in address.mailboxes.all(): return format_html_join(
url = change_url(mailbox) mark_safe('<br>'), '<a href="{}">{}</a>',
boxes.append('<a href="%s">%s</a>' % (url, mailbox.name)) [(change_url(mailbox), mailbox.name) for mailbox in boxes]
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 = [] boxes = address.get_mailboxes()
for mailbox in address.get_mailboxes(): return format_html_join(
url = change_url(mailbox) mark_safe('<br>'), '<a href="{}">{}</a>',
boxes.append('<a href="%s">%s</a>' % (url, mailbox.name)) [(change_url(mailbox), mailbox.name) for mailbox in boxes]
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 = []
@ -281,14 +278,13 @@ 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):
if db_field.name == 'forward': if db_field.name == 'forward':
kwargs['widget'] = forms.TextInput(attrs={'size':'118'}) kwargs['widget'] = forms.TextInput(attrs={'size':'118'})
return super(AddressAdmin, self).formfield_for_dbfield(db_field, **kwargs) return super(AddressAdmin, self).formfield_for_dbfield(db_field, **kwargs)
def get_fields(self, request, obj=None): def get_fields(self, request, obj=None):
""" Remove mailboxes field when creating address from a popup i.e. from mailbox add form """ """ Remove mailboxes field when creating address from a popup i.e. from mailbox add form """
fields = super(AddressAdmin, self).get_fields(request, obj) fields = super(AddressAdmin, self).get_fields(request, obj)
@ -297,22 +293,22 @@ class AddressAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
fields = list(fields) fields = list(fields)
fields.remove('mailboxes') fields.remove('mailboxes')
return fields return fields
def get_queryset(self, request): def get_queryset(self, request):
qs = super(AddressAdmin, self).get_queryset(request) qs = super(AddressAdmin, self).get_queryset(request)
qs = qs.annotate(computed_email=Concat(F('name'), V('@'), F('domain__name'))) qs = qs.annotate(computed_email=Concat(F('name'), V('@'), F('domain__name')))
return qs.annotate(Count('mailboxes')) return qs.annotate(Count('mailboxes'))
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):
if not add: if not add:
self.check_matching_mailbox(request, obj) self.check_matching_mailbox(request, obj)
return super(AddressAdmin, self).render_change_form( return super(AddressAdmin, self).render_change_form(
request, context, add, change, form_url, obj) request, context, add, change, form_url, obj)
def log_addition(self, request, object, *args, **kwargs): def log_addition(self, request, object, *args, **kwargs):
self.check_matching_mailbox(request, object) self.check_matching_mailbox(request, object)
return super(AddressAdmin, self).log_addition(request, object, *args, **kwargs) return super(AddressAdmin, self).log_addition(request, object, *args, **kwargs)
def check_matching_mailbox(self, request, obj): def check_matching_mailbox(self, request, obj):
# Check if new addresse matches with a mbox because of having a local domain # Check if new addresse matches with a mbox because of having a local domain
if obj.name and obj.domain and obj.domain.name == settings.MAILBOXES_LOCAL_DOMAIN: if obj.name and obj.domain and obj.domain.name == settings.MAILBOXES_LOCAL_DOMAIN:

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 from .serializers import AddressSerializer, MailboxSerializer, MailboxWritableSerializer
class AddressViewSet(LogApiMixin, AccountApiMixin, viewsets.ModelViewSet): class AddressViewSet(LogApiMixin, AccountApiMixin, viewsets.ModelViewSet):
@ -17,6 +17,12 @@ 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,6 +3,7 @@ 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
@ -21,8 +22,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(to=settings.AUTH_USER_MODEL, related_name='addresses', verbose_name='Account')), ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, related_name='addresses', verbose_name='Account')),
('domain', models.ForeignKey(to='domains.Domain', related_name='addresses', verbose_name='domain')), ('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='domains.Domain', related_name='addresses', verbose_name='domain')),
], ],
options={ options={
'verbose_name_plural': 'addresses', 'verbose_name_plural': 'addresses',
@ -35,7 +36,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(to='mailboxes.Address', related_name='autoresponse', verbose_name='address')), ('address', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='mailboxes.Address', related_name='autoresponse', verbose_name='address')),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
@ -47,7 +48,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(to=settings.AUTH_USER_MODEL, related_name='mailboxes', verbose_name='account')), ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, related_name='mailboxes', verbose_name='account')),
], ],
options={ options={
'verbose_name_plural': 'mailboxes', 'verbose_name_plural': 'mailboxes',

View File

@ -0,0 +1,71 @@
# -*- 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

@ -13,7 +13,7 @@ from . import validators, settings
class Mailbox(models.Model): class Mailbox(models.Model):
CUSTOM = 'CUSTOM' CUSTOM = 'CUSTOM'
name = models.CharField(_("name"), unique=True, db_index=True, name = models.CharField(_("name"), unique=True, db_index=True,
max_length=settings.MAILBOXES_NAME_MAX_LENGTH, max_length=settings.MAILBOXES_NAME_MAX_LENGTH,
help_text=_("Required. %s characters or fewer. Letters, digits and ./-/_ only.") % help_text=_("Required. %s characters or fewer. Letters, digits and ./-/_ only.") %
@ -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') related_name='mailboxes', on_delete=models.CASCADE)
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())])
@ -33,59 +33,59 @@ class Mailbox(models.Model):
"<a href='https://tty1.net/blog/2011/sieve-tutorial_en.html'>sieve language</a>. " "<a href='https://tty1.net/blog/2011/sieve-tutorial_en.html'>sieve language</a>. "
"This overrides any automatic junk email filtering")) "This overrides any automatic junk email filtering"))
is_active = models.BooleanField(_("active"), default=True) is_active = models.BooleanField(_("active"), default=True)
class Meta: class Meta:
verbose_name_plural = _("mailboxes") verbose_name_plural = _("mailboxes")
def __str__(self): def __str__(self):
return self.name return self.name
@cached_property @cached_property
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.rel.to.DoesNotExist: except type(self).account.field.related_model.DoesNotExist:
return self.is_active return self.is_active
def disable(self): def disable(self):
self.is_active = False self.is_active = False
self.save(update_fields=('is_active',)) self.save(update_fields=('is_active',))
def enable(self): def enable(self):
self.is_active = False self.is_active = False
self.save(update_fields=('is_active',)) self.save(update_fields=('is_active',))
def set_password(self, raw_password): def set_password(self, raw_password):
self.password = make_password(raw_password) self.password = make_password(raw_password)
def get_home(self): def get_home(self):
context = { context = {
'name': self.name, 'name': self.name,
'username': self.name, 'username': self.name,
} }
return os.path.normpath(settings.MAILBOXES_HOME % context) return os.path.normpath(settings.MAILBOXES_HOME % context)
def clean(self): def clean(self):
if self.filtering == self.CUSTOM and not self.custom_filtering: if self.filtering == self.CUSTOM and not self.custom_filtering:
raise ValidationError({ raise ValidationError({
'custom_filtering': _("Custom filtering is selected but not provided.") 'custom_filtering': _("Custom filtering is selected but not provided.")
}) })
def get_filtering(self): def get_filtering(self):
name, content = settings.MAILBOXES_MAILBOX_FILTERINGS[self.filtering] name, content = settings.MAILBOXES_MAILBOX_FILTERINGS[self.filtering]
if callable(content): if callable(content):
# Custom filtering # Custom filtering
content = content(self) content = content(self)
return (name, content) return (name, content)
def get_local_address(self): def get_local_address(self):
if not settings.MAILBOXES_LOCAL_DOMAIN: if not settings.MAILBOXES_LOCAL_DOMAIN:
raise AttributeError("Mailboxes do not have a defined local address domain.") raise AttributeError("Mailboxes do not have a defined local address domain.")
return '@'.join((self.name, settings.MAILBOXES_LOCAL_DOMAIN)) return '@'.join((self.name, settings.MAILBOXES_LOCAL_DOMAIN))
def get_forwards(self): def get_forwards(self):
return Address.objects.filter(forward__regex=r'(^|.*\s)%s(\s.*|$)' % self.name) return Address.objects.filter(forward__regex=r'(^|.*\s)%s(\s.*|$)' % self.name)
def get_addresses(self): def get_addresses(self):
mboxes = self.addresses.all() mboxes = self.addresses.all()
forwards = self.get_forwards() forwards = self.get_forwards()
@ -97,33 +97,33 @@ 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') verbose_name=_("domain"), related_name='addresses', on_delete=models.CASCADE)
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') related_name='addresses', on_delete=models.CASCADE)
class Meta: class Meta:
verbose_name_plural = _("addresses") verbose_name_plural = _("addresses")
unique_together = ('name', 'domain') unique_together = ('name', 'domain')
def __str__(self): def __str__(self):
return self.email return self.email
@property @property
def email(self): def email(self):
return "%s@%s" % (self.name, self.domain) return "%s@%s" % (self.name, self.domain)
@cached_property @cached_property
def destination(self): def destination(self):
destinations = list(self.mailboxes.values_list('name', flat=True)) destinations = list(self.mailboxes.values_list('name', flat=True))
if self.forward: if self.forward:
destinations += self.forward.split() destinations += self.forward.split()
return ' '.join(destinations) return ' '.join(destinations)
def clean(self): def clean(self):
errors = defaultdict(list) errors = defaultdict(list)
local_domain = settings.MAILBOXES_LOCAL_DOMAIN local_domain = settings.MAILBOXES_LOCAL_DOMAIN
@ -149,7 +149,7 @@ class Address(models.Model):
) )
if errors: if errors:
raise ValidationError(errors) raise ValidationError(errors)
def get_forward_mailboxes(self): def get_forward_mailboxes(self):
rm_local_domain = re.compile(r'@%s$' % settings.MAILBOXES_LOCAL_DOMAIN) rm_local_domain = re.compile(r'@%s$' % settings.MAILBOXES_LOCAL_DOMAIN)
mailboxes = [] mailboxes = []
@ -158,7 +158,7 @@ class Address(models.Model):
if '@' not in forward: if '@' not in forward:
mailboxes.append(forward) mailboxes.append(forward)
return Mailbox.objects.filter(name__in=mailboxes) return Mailbox.objects.filter(name__in=mailboxes)
def get_mailboxes(self): def get_mailboxes(self):
for mailbox in self.mailboxes.all(): for mailbox in self.mailboxes.all():
yield mailbox yield mailbox
@ -168,11 +168,11 @@ 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') related_name='autoresponse', on_delete=models.CASCADE)
# 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"))
enabled = models.BooleanField(_("enabled"), default=False) enabled = models.BooleanField(_("enabled"), default=False)
def __str__(self): def __str__(self):
return self.address return self.address

View File

@ -1,3 +1,4 @@
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
@ -8,17 +9,17 @@ from .models import Mailbox, Address
class RelatedDomainSerializer(AccountSerializerMixin, RelatedHyperlinkedModelSerializer): class RelatedDomainSerializer(AccountSerializerMixin, RelatedHyperlinkedModelSerializer):
class Meta: class Meta:
model = Address.domain.field.rel.to model = Address.domain.field.related_model
fields = ('url', 'id', 'name') fields = ('url', 'id', 'name')
class RelatedAddressSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer): class RelatedAddressSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer):
domain = RelatedDomainSerializer() domain = RelatedDomainSerializer()
class Meta: class Meta:
model = Address model = Address
fields = ('url', 'id', 'name', 'domain', 'forward') fields = ('url', 'id', 'name', 'domain', 'forward')
# #
# def from_native(self, data, files=None): # def from_native(self, data, files=None):
# queryset = self.opts.model.objects.filter(account=self.account) # queryset = self.opts.model.objects.filter(account=self.account)
# return get_object_or_404(queryset, name=data['name']) # return get_object_or_404(queryset, name=data['name'])
@ -26,7 +27,7 @@ class RelatedAddressSerializer(AccountSerializerMixin, serializers.HyperlinkedMo
class MailboxSerializer(AccountSerializerMixin, SetPasswordHyperlinkedSerializer): class MailboxSerializer(AccountSerializerMixin, SetPasswordHyperlinkedSerializer):
addresses = RelatedAddressSerializer(many=True, read_only=True) addresses = RelatedAddressSerializer(many=True, read_only=True)
class Meta: class Meta:
model = Mailbox model = Mailbox
fields = ( fields = (
@ -35,6 +36,41 @@ 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
@ -43,14 +79,29 @@ class RelatedMailboxSerializer(AccountSerializerMixin, RelatedHyperlinkedModelSe
class AddressSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer): class AddressSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer):
domain = RelatedDomainSerializer() domain = RelatedDomainSerializer()
mailboxes = RelatedMailboxSerializer(many=True, required=False) #allow_add_remove=True mailboxes = RelatedMailboxSerializer(many=True, required=False)
class Meta: class Meta:
model = Address model = Address
fields = ('url', 'id', 'name', 'domain', 'mailboxes', 'forward') fields = ('url', 'id', 'name', 'domain', 'mailboxes', 'forward')
def validate(self, attrs): def validate(self, attrs):
attrs = super(AddressSerializer, self).validate(attrs) attrs = super(AddressSerializer, self).validate(attrs)
if not attrs['mailboxes'] and not attrs['forward']: mailboxes = attrs.get('mailboxes', [])
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').rel.to Domain = Address._meta.get_field('domain').remote_field.model
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,14 @@ 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 unittest import skip
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.core.urlresolvers import reverse from django.urls 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,6 +22,8 @@ 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')
@ -30,63 +32,64 @@ class MailboxMixin(object):
'orchestra.contrib.mails', 'orchestra.contrib.mails',
'orchestra.contrib.resources', 'orchestra.contrib.resources',
) )
def setUp(self): def setUp(self):
super(MailboxMixin, self).setUp() super(MailboxMixin, self).setUp()
self.add_route() self.add_route()
# clean resource relation from other tests # clean resource relation from other tests
apps.get_app_config('resources').reload_relations() apps.get_app_config('resources').reload_relations()
djsettings.DEBUG = True djsettings.DEBUG = True
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.RoundcubeIdentityController.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)
def add_quota_resource(self): def add_quota_resource(self):
Resource.objects.create( Resource.objects.create(
name='disk', name='disk',
content_type=ContentType.objects.get_for_model(Mailbox), content_type=ContentType.objects.get_for_model(Mailbox),
period=Resource.LAST,
verbose_name='Mail quota', verbose_name='Mail quota',
unit='MB', unit='MB',
scale=10**6, scale=10**6,
on_demand=False, on_demand=False,
default_allocation=2000 default_allocation=2000
) )
def save(self): def save(self):
raise NotImplementedError raise NotImplementedError
def add(self): def add(self):
raise NotImplementedError raise NotImplementedError
def delete(self): def delete(self):
raise NotImplementedError raise NotImplementedError
def update(self): def update(self):
raise NotImplementedError raise NotImplementedError
def disable(self): def disable(self):
raise NotImplementedError raise NotImplementedError
def add_group(self, username, groupname): def add_group(self, username, groupname):
raise NotImplementedError raise NotImplementedError
def login_imap(self, username, password): def login_imap(self, username, password):
mail = imaplib.IMAP4_SSL(self.MASTER_SERVER) mail = imaplib.IMAP4_SSL(self.MASTER_SERVER)
status, msg = mail.login(username, password) status, msg = mail.login(username, password)
self.assertEqual('OK', status) self.assertEqual('OK', status)
self.assertEqual(['Logged in'], msg) self.assertEqual(['Logged in'], msg)
return mail return mail
def login_pop3(self, username, password): def login_pop3(self, username, password):
pop = poplib.POP3(self.MASTER_SERVER) pop = poplib.POP3(self.MASTER_SERVER)
pop.user(username) pop.user(username)
pop.pass_(password) pop.pass_(password)
return pop return pop
def send_email(self, to, token): def send_email(self, to, token):
msg = MIMEText(token) msg = MIMEText(token)
msg['To'] = to msg['To'] = to
@ -100,15 +103,14 @@ class MailboxMixin(object):
server.sendmail(msg['From'], msg['To'], msg.as_string()) server.sendmail(msg['From'], msg['To'], msg.as_string())
finally: finally:
server.quit() server.quit()
def validate_mailbox(self, username): def validate_mailbox(self, username):
sshrun(self.MASTER_SERVER, "doveadm search -u %s ALL" % username, display=False) sshrun(self.MASTER_SERVER, "doveadm search -u %s ALL" % username, display=False)
def validate_email(self, username, token): def validate_email(self, username, token):
home = Mailbox.objects.get(name=username).get_home() home = Mailbox.objects.get(name=username).get_home()
sshrun(self.MASTER_SERVER, "grep '%s' %s/Maildir/new/*" % (token, home), display=False) sshrun(self.MASTER_SERVER, "grep '%s' %s/Maildir/new/*" % (token, home), display=False)
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_add(self): def test_add(self):
username = '%s_mailbox' % random_ascii(10) username = '%s_mailbox' % random_ascii(10)
password = '@!?%spppP001' % random_ascii(5) password = '@!?%spppP001' % random_ascii(5)
@ -116,8 +118,7 @@ class MailboxMixin(object):
self.addCleanup(self.delete, username) self.addCleanup(self.delete, username)
imap = self.login_imap(username, password) imap = self.login_imap(username, password)
self.validate_mailbox(username) self.validate_mailbox(username)
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_change_password(self): def test_change_password(self):
username = '%s_systemuser' % random_ascii(10) username = '%s_systemuser' % random_ascii(10)
password = '@!?%spppP001' % random_ascii(5) password = '@!?%spppP001' % random_ascii(5)
@ -127,8 +128,7 @@ class MailboxMixin(object):
new_password = '@!?%spppP001' % random_ascii(5) new_password = '@!?%spppP001' % random_ascii(5)
self.change_password(username, new_password) self.change_password(username, new_password)
imap = self.login_imap(username, new_password) imap = self.login_imap(username, new_password)
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_quota(self): def test_quota(self):
username = '%s_mailbox' % random_ascii(10) username = '%s_mailbox' % random_ascii(10)
password = '@!?%spppP001' % random_ascii(5) password = '@!?%spppP001' % random_ascii(5)
@ -142,8 +142,7 @@ class MailboxMixin(object):
imap = self.login_imap(username, password) imap = self.login_imap(username, password)
imap_quota = int(imap.getquotaroot("INBOX")[1][1][0].split(' ')[-1].split(')')[0]) imap_quota = int(imap.getquotaroot("INBOX")[1][1][0].split(' ')[-1].split(')')[0])
self.assertEqual(quota*1024, imap_quota) self.assertEqual(quota*1024, imap_quota)
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_send_email(self): def test_send_email(self):
username = '%s_mailbox' % random_ascii(10) username = '%s_mailbox' % random_ascii(10)
password = '@!?%spppP001' % random_ascii(5) password = '@!?%spppP001' % random_ascii(5)
@ -159,8 +158,7 @@ class MailboxMixin(object):
server.sendmail(msg['From'], msg['To'], msg.as_string()) server.sendmail(msg['From'], msg['To'], msg.as_string())
finally: finally:
server.quit() server.quit()
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_address(self): def test_address(self):
username = '%s_mailbox' % random_ascii(10) username = '%s_mailbox' % random_ascii(10)
password = '@!?%spppP001' % random_ascii(5) password = '@!?%spppP001' % random_ascii(5)
@ -173,8 +171,7 @@ class MailboxMixin(object):
token = random_ascii(100) token = random_ascii(100)
self.send_email("%s@%s" % (name, domain), token) self.send_email("%s@%s" % (name, domain), token)
self.validate_email(username, token) self.validate_email(username, token)
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_disable(self): def test_disable(self):
username = '%s_systemuser' % random_ascii(10) username = '%s_systemuser' % random_ascii(10)
password = '@!?%spppP001' % random_ascii(5) password = '@!?%spppP001' % random_ascii(5)
@ -184,8 +181,7 @@ class MailboxMixin(object):
imap = self.login_imap(username, password) imap = self.login_imap(username, password)
self.disable(username) self.disable(username)
self.assertRaises(imap.error, self.login_imap, username, password) self.assertRaises(imap.error, self.login_imap, username, password)
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_delete(self): def test_delete(self):
username = '%s_systemuser' % random_ascii(10) username = '%s_systemuser' % random_ascii(10)
password = '@!?%sppppP001' % random_ascii(5) password = '@!?%sppppP001' % random_ascii(5)
@ -200,8 +196,7 @@ class MailboxMixin(object):
self.assertRaises(imap.error, self.login_imap, username, password) self.assertRaises(imap.error, self.login_imap, username, password)
self.assertRaises(CommandError, self.assertRaises(CommandError,
sshrun, self.MASTER_SERVER, 'ls %s' % home, display=False) sshrun, self.MASTER_SERVER, 'ls %s' % home, display=False)
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_delete_address(self): def test_delete_address(self):
username = '%s_mailbox' % random_ascii(10) username = '%s_mailbox' % random_ascii(10)
password = '@!?%spppP001' % random_ascii(5) password = '@!?%spppP001' % random_ascii(5)
@ -217,15 +212,14 @@ class MailboxMixin(object):
self.delete_address(username) self.delete_address(username)
self.send_email("%s@%s" % (name, domain), token) self.send_email("%s@%s" % (name, domain), token)
self.validate_email(username, token) self.validate_email(username, token)
@skip("Skip because not exists get_auth_token in orm.api.Api")
def test_custom_filtering(self): def test_custom_filtering(self):
username = '%s_mailbox' % random_ascii(10) username = '%s_mailbox' % random_ascii(10)
password = '@!?%spppP001' % random_ascii(5) password = '@!?%spppP001' % random_ascii(5)
folder = random_ascii(5) folder = random_ascii(5)
filtering = textwrap.dedent(""" filtering = textwrap.dedent("""
require "fileinto"; require "fileinto";
if true { if true {
fileinto "%s"; fileinto "%s";
stop; stop;
}""" % folder) }""" % folder)
@ -244,11 +238,12 @@ 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()
self.rest_login() self.rest_login()
@save_response_on_error @save_response_on_error
def add(self, username, password, quota=None, filtering=None): def add(self, username, password, quota=None, filtering=None):
extra = {} extra = {}
@ -267,28 +262,28 @@ class RESTMailboxMixin(MailboxMixin):
'custom_filtering': filtering, 'custom_filtering': filtering,
}) })
self.rest.mailboxes.create(name=username, password=password, **extra) self.rest.mailboxes.create(name=username, password=password, **extra)
@save_response_on_error @save_response_on_error
def delete(self, username): def delete(self, username):
mailbox = self.rest.mailboxes.retrieve(name=username).get() mailbox = self.rest.mailboxes.retrieve(name=username).get()
mailbox.delete() mailbox.delete()
@save_response_on_error @save_response_on_error
def change_password(self, username, password): def change_password(self, username, password):
mailbox = self.rest.mailboxes.retrieve(name=username).get() mailbox = self.rest.mailboxes.retrieve(name=username).get()
mailbox.change_password(password) mailbox.change_password(password)
@save_response_on_error @save_response_on_error
def add_address(self, username, name, domain): def add_address(self, username, name, domain):
mailbox = self.rest.mailboxes.retrieve(name=username).get() mailbox = self.rest.mailboxes.retrieve(name=username).get()
domain = self.rest.domains.retrieve(name=domain.name).get() domain = self.rest.domains.retrieve(name=domain.name).get()
self.rest.addresses.create(name=name, domain=domain, mailboxes=[mailbox]) self.rest.addresses.create(name=name, domain=domain, mailboxes=[mailbox])
@save_response_on_error @save_response_on_error
def delete_address(self, username): def delete_address(self, username):
mailbox = self.rest.mailboxes.retrieve(name=username).get() mailbox = self.rest.mailboxes.retrieve(name=username).get()
self.rest.addresses.delete() self.rest.addresses.delete()
@save_response_on_error @save_response_on_error
def disable(self, username): def disable(self, username):
mailbox = self.rest.mailboxes.retrieve(name=username).get() mailbox = self.rest.mailboxes.retrieve(name=username).get()
@ -299,30 +294,30 @@ class AdminMailboxMixin(MailboxMixin):
def setUp(self): def setUp(self):
super(AdminMailboxMixin, self).setUp() super(AdminMailboxMixin, self).setUp()
self.admin_login() self.admin_login()
@snapshot_on_error @snapshot_on_error
def add(self, username, password, quota=None, filtering=None): def add(self, username, password, quota=None, filtering=None):
url = self.live_server_url + reverse('admin:mailboxes_mailbox_add') url = self.live_server_url + reverse('admin:mailboxes_mailbox_add')
self.selenium.get(url) self.selenium.get(url)
# account_input = self.selenium.find_element_by_id('id_account') # account_input = self.selenium.find_element_by_id('id_account')
# account_select = Select(account_input) # account_select = Select(account_input)
# account_select.select_by_value(str(self.account.pk)) # account_select.select_by_value(str(self.account.pk))
name_field = self.selenium.find_element_by_id('id_name') name_field = self.selenium.find_element_by_id('id_name')
name_field.send_keys(username) name_field.send_keys(username)
password_field = self.selenium.find_element_by_id('id_password1') password_field = self.selenium.find_element_by_id('id_password1')
password_field.send_keys(password) password_field.send_keys(password)
password_field = self.selenium.find_element_by_id('id_password2') password_field = self.selenium.find_element_by_id('id_password2')
password_field.send_keys(password) password_field.send_keys(password)
if quota is not None: if quota is not None:
quota_id = 'id_resources-resourcedata-content_type-object_id-0-allocated' quota_id = 'id_resources-resourcedata-content_type-object_id-0-allocated'
quota_field = self.selenium.find_element_by_id(quota_id) quota_field = self.selenium.find_element_by_id(quota_id)
quota_field.clear() quota_field.clear()
quota_field.send_keys(quota) quota_field.send_keys(quota)
if filtering is not None: if filtering is not None:
filtering_input = self.selenium.find_element_by_id('id_filtering') filtering_input = self.selenium.find_element_by_id('id_filtering')
filtering_select = Select(filtering_input) filtering_select = Select(filtering_input)
@ -332,45 +327,45 @@ class AdminMailboxMixin(MailboxMixin):
time.sleep(0.5) time.sleep(0.5)
filtering_field = self.selenium.find_element_by_id('id_custom_filtering') filtering_field = self.selenium.find_element_by_id('id_custom_filtering')
filtering_field.send_keys(filtering) filtering_field.send_keys(filtering)
name_field.submit() name_field.submit()
self.assertNotEqual(url, self.selenium.current_url) self.assertNotEqual(url, self.selenium.current_url)
@snapshot_on_error @snapshot_on_error
def delete(self, username): def delete(self, username):
mailbox = Mailbox.objects.get(name=username) mailbox = Mailbox.objects.get(name=username)
self.admin_delete(mailbox) self.admin_delete(mailbox)
@snapshot_on_error @snapshot_on_error
def change_password(self, username, password): def change_password(self, username, password):
mailbox = Mailbox.objects.get(name=username) mailbox = Mailbox.objects.get(name=username)
self.admin_change_password(mailbox, password) self.admin_change_password(mailbox, password)
@snapshot_on_error @snapshot_on_error
def add_address(self, username, name, domain): def add_address(self, username, name, domain):
url = self.live_server_url + reverse('admin:mailboxes_address_add') url = self.live_server_url + reverse('admin:mailboxes_address_add')
self.selenium.get(url) self.selenium.get(url)
name_field = self.selenium.find_element_by_id('id_name') name_field = self.selenium.find_element_by_id('id_name')
name_field.send_keys(name) name_field.send_keys(name)
domain_input = self.selenium.find_element_by_id('id_domain') domain_input = self.selenium.find_element_by_id('id_domain')
domain_select = Select(domain_input) domain_select = Select(domain_input)
domain_select.select_by_value(str(domain.pk)) domain_select.select_by_value(str(domain.pk))
mailboxes = self.selenium.find_element_by_id('id_mailboxes_add_all_link') mailboxes = self.selenium.find_element_by_id('id_mailboxes_add_all_link')
mailboxes.click() mailboxes.click()
time.sleep(0.5) time.sleep(0.5)
name_field.submit() name_field.submit()
self.assertNotEqual(url, self.selenium.current_url) self.assertNotEqual(url, self.selenium.current_url)
@snapshot_on_error @snapshot_on_error
def delete_address(self, username): def delete_address(self, username):
mailbox = Mailbox.objects.get(name=username) mailbox = Mailbox.objects.get(name=username)
address = mailbox.addresses.get() address = mailbox.addresses.get()
self.admin_delete(address) self.admin_delete(address)
@snapshot_on_error @snapshot_on_error
def disable(self, username): def disable(self, username):
mailbox = Mailbox.objects.get(name=username) mailbox = Mailbox.objects.get(name=username)

View File

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

View File

@ -3,9 +3,11 @@ import email
from django import forms from django import forms
from django.contrib import admin from django.contrib import admin
from django.core.urlresolvers import reverse from django.urls 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
@ -52,20 +54,19 @@ class MessageAdmin(ExtendedModelAdmin):
) )
date_hierarchy = 'created_at' date_hierarchy = 'created_at'
change_view_actions = (last,) change_view_actions = (last,)
colored_state = admin_colored('state', colors=COLORS) colored_state = admin_colored('state', colors=COLORS)
created_at_delta = admin_date('created_at') created_at_delta = admin_date('created_at')
last_try_delta = admin_date('last_try') last_try_delta = admin_date('last_try')
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 subject[:64] + '&hellip;' return mark_safe(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
if num_logs == 1: if num_logs == 1:
@ -74,11 +75,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 '<a href="%s" onclick="return showAddAnotherPopup(this);">%d</a>' % (url, instance.retries) return format_html('<a href="{}" onclick="return showAddAnotherPopup(this);">{}</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)
payload = part.get_payload() payload = part.get_payload()
@ -99,22 +99,21 @@ 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 payload return mark_safe(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
display_full_subject.short_description = _("Subject") display_full_subject.short_description = _("Subject")
def display_from(self, instance): def display_from(self, instance):
return instance.from_address return instance.from_address
display_from.short_description = _("From") display_from.short_description = _("From")
def display_to(self, instance): def display_to(self, instance):
return instance.to_address return instance.to_address
display_to.short_description = _("To") display_to.short_description = _("To")
def get_urls(self): def get_urls(self):
from django.conf.urls import url from django.conf.urls import url
urls = super().get_urls() urls = super().get_urls()
@ -125,16 +124,16 @@ class MessageAdmin(ExtendedModelAdmin):
name='%s_%s_send_pending' % info) name='%s_%s_send_pending' % info)
) )
return urls return urls
def get_queryset(self, request): def get_queryset(self, request):
qs = super().get_queryset(request) qs = super().get_queryset(request)
return qs.annotate(Count('logs')).defer('content') return qs.annotate(Count('logs')).defer('content')
def send_pending_view(self, request): def send_pending_view(self, request):
task(send_pending).apply_async() task(send_pending).apply_async()
self.message_user(request, _("Pending messages are being sent on the background.")) self.message_user(request, _("Pending messages are being sent on the background."))
return redirect('..') return redirect('..')
def formfield_for_dbfield(self, db_field, **kwargs): def formfield_for_dbfield(self, db_field, **kwargs):
if db_field.name == 'subject': if db_field.name == 'subject':
kwargs['widget'] = forms.TextInput(attrs={'size':'100'}) kwargs['widget'] = forms.TextInput(attrs={'size':'100'})
@ -148,7 +147,7 @@ class SMTPLogAdmin(admin.ModelAdmin):
list_filter = ('result',) list_filter = ('result',)
fields = ('message_link', 'colored_result', 'date_delta', 'log_message') fields = ('message_link', 'colored_result', 'date_delta', 'log_message')
readonly_fields = fields readonly_fields = fields
message_link = admin_link('message') message_link = admin_link('message')
colored_result = admin_colored('result', colors=COLORS, bold=False) colored_result = admin_colored('result', colors=COLORS, bold=False)
date_delta = admin_date('date') date_delta = admin_date('date')

View File

@ -1,6 +1,7 @@
# -*- 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
@ -32,7 +33,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(to='mailer.Message', editable=False, related_name='logs')), ('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='mailer.Message', editable=False, related_name='logs')),
], ],
), ),
] ]

View File

@ -0,0 +1,89 @@
# -*- 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

@ -15,7 +15,7 @@ class Message(models.Model):
(DEFERRED, _("Deferred")), (DEFERRED, _("Deferred")),
(FAILED, _("Failed")), (FAILED, _("Failed")),
) )
CRITICAL = 0 CRITICAL = 0
HIGH = 1 HIGH = 1
NORMAL = 2 NORMAL = 2
@ -26,7 +26,7 @@ class Message(models.Model):
(NORMAL, _("Normal")), (NORMAL, _("Normal")),
(LOW, _("Low")), (LOW, _("Low")),
) )
state = models.CharField(_("State"), max_length=16, choices=STATES, default=QUEUED, state = models.CharField(_("State"), max_length=16, choices=STATES, default=QUEUED,
db_index=True) db_index=True)
priority = models.PositiveIntegerField(_("Priority"), choices=PRIORITIES, default=NORMAL, priority = models.PositiveIntegerField(_("Priority"), choices=PRIORITIES, default=NORMAL,
@ -38,21 +38,21 @@ class Message(models.Model):
created_at = models.DateTimeField(_("created at"), auto_now_add=True) created_at = models.DateTimeField(_("created at"), auto_now_add=True)
retries = models.PositiveIntegerField(_("retries"), default=0, db_index=True) retries = models.PositiveIntegerField(_("retries"), default=0, db_index=True)
last_try = models.DateTimeField(_("last try"), null=True, db_index=True) last_try = models.DateTimeField(_("last try"), null=True, db_index=True)
def __str__(self): def __str__(self):
return '%s to %s' % (self.subject, self.to_address) return '%s to %s' % (self.subject, self.to_address)
def defer(self): def defer(self):
self.state = self.DEFERRED self.state = self.DEFERRED
# Max tries # Max tries
if self.retries >= len(settings.MAILER_DEFERE_SECONDS): if self.retries >= len(settings.MAILER_DEFERE_SECONDS):
self.state = self.FAILED self.state = self.FAILED
self.save(update_fields=('state',)) self.save(update_fields=('state',))
def sent(self): def sent(self):
self.state = self.SENT self.state = self.SENT
self.save(update_fields=('state',)) self.save(update_fields=('state',))
def log(self, error): def log(self, error):
result = SMTPLog.SUCCESS result = SMTPLog.SUCCESS
if error: if error:
@ -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') message = models.ForeignKey(Message, editable=False, related_name='logs', on_delete=models.CASCADE)
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,7 +1,8 @@
from django import forms from django import forms
from django.contrib import admin from django.contrib import admin
from django.core.urlresolvers import reverse from django.urls 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 _
@ -36,19 +37,17 @@ class MiscServiceAdmin(ExtendedModelAdmin):
prepopulated_fields = {'name': ('verbose_name',)} prepopulated_fields = {'name': ('verbose_name',)}
change_readonly_fields = ('name',) change_readonly_fields = ('name',)
actions = (disable, enable) actions = (disable, enable)
def display_name(self, misc): def display_name(self, misc):
return '<span title="%s">%s</span>' % (misc.description, misc.name) return format_html('<span title="{}">{}</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 '<span title="%s">%s</span>' % (misc.description, misc.verbose_name) return format_html('<span title="{}">{}</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):
""" return num slivers as a link to slivers changelist view """ """ return num slivers as a link to slivers changelist view """
num = misc.instances__count num = misc.instances__count
@ -57,11 +56,11 @@ class MiscServiceAdmin(ExtendedModelAdmin):
return mark_safe('<a href="{0}">{1}</a>'.format(url, num)) return mark_safe('<a href="{0}">{1}</a>'.format(url, num))
num_instances.short_description = _("Instances") num_instances.short_description = _("Instances")
num_instances.admin_order_field = 'instances__count' num_instances.admin_order_field = 'instances__count'
def get_queryset(self, request): def get_queryset(self, request):
qs = super(MiscServiceAdmin, self).get_queryset(request) qs = super(MiscServiceAdmin, self).get_queryset(request)
return qs.annotate(models.Count('instances', distinct=True)) return qs.annotate(models.Count('instances', distinct=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 """
if db_field.name == 'description': if db_field.name == 'description':
@ -83,21 +82,21 @@ class MiscellaneousAdmin(SelectPluginAdminMixin, AccountAdminMixin, ExtendedMode
actions = (disable, enable) actions = (disable, enable)
plugin_field = 'service' plugin_field = 'service'
plugin = MiscServicePlugin plugin = MiscServicePlugin
service_link = admin_link('service') service_link = admin_link('service')
def dispaly_active(self, instance): def dispaly_active(self, instance):
return instance.active return instance.active
dispaly_active.short_description = _("Active") dispaly_active.short_description = _("Active")
dispaly_active.boolean = True dispaly_active.boolean = True
dispaly_active.admin_order_field = 'is_active' dispaly_active.admin_order_field = 'is_active'
def get_service(self, obj): def get_service(self, obj):
if obj is None: if obj is None:
return self.plugin.get(self.plugin_value).related_instance return self.plugin.get(self.plugin_value).related_instance
else: else:
return obj.service return obj.service
def get_fieldsets(self, request, obj=None): def get_fieldsets(self, request, obj=None):
fieldsets = super().get_fieldsets(request, obj) fieldsets = super().get_fieldsets(request, obj)
fields = list(fieldsets[0][1]['fields']) fields = list(fieldsets[0][1]['fields'])
@ -110,7 +109,7 @@ class MiscellaneousAdmin(SelectPluginAdminMixin, AccountAdminMixin, ExtendedMode
fields.insert(2, 'identifier') fields.insert(2, 'identifier')
fieldsets[0][1]['fields'] = fields fieldsets[0][1]['fields'] = fields
return fieldsets return fieldsets
def get_form(self, request, obj=None, **kwargs): def get_form(self, request, obj=None, **kwargs):
if obj: if obj:
plugin = self.plugin.get(obj.service.name)() plugin = self.plugin.get(obj.service.name)()
@ -127,16 +126,16 @@ class MiscellaneousAdmin(SelectPluginAdminMixin, AccountAdminMixin, ExtendedMode
validator = import_class(validator_path) validator = import_class(validator_path)
validator(identifier) validator(identifier)
return identifier return identifier
form.clean_identifier = clean_identifier form.clean_identifier = clean_identifier
return form return form
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 """
if db_field.name == 'description': if db_field.name == 'description':
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 4}) kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 4})
return super(MiscellaneousAdmin, self).formfield_for_dbfield(db_field, **kwargs) return super(MiscellaneousAdmin, self).formfield_for_dbfield(db_field, **kwargs)
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
if not change: if not change:
plugin = self.plugin plugin = self.plugin

View File

@ -2,6 +2,7 @@
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
@ -22,7 +23,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(related_name='miscellaneous', verbose_name='account', to=settings.AUTH_USER_MODEL)), ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='miscellaneous', verbose_name='account', to=settings.AUTH_USER_MODEL)),
], ],
options={ options={
'verbose_name_plural': 'miscellaneous', 'verbose_name_plural': 'miscellaneous',
@ -43,6 +44,6 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='miscellaneous', model_name='miscellaneous',
name='service', name='service',
field=models.ForeignKey(related_name='instances', verbose_name='service', to='miscellaneous.MiscService'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='instances', verbose_name='service', to='miscellaneous.MiscService'),
), ),
] ]

View File

@ -0,0 +1,59 @@
# -*- 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

@ -22,30 +22,30 @@ class MiscService(models.Model):
is_active = models.BooleanField(_("active"), default=True, is_active = models.BooleanField(_("active"), default=True,
help_text=_("Whether new instances of this service can be created " help_text=_("Whether new instances of this service can be created "
"or not. Unselect this instead of deleting services.")) "or not. Unselect this instead of deleting services."))
def __str__(self): def __str__(self):
return self.name return self.name
def clean(self): def clean(self):
self.verbose_name = self.verbose_name.strip() self.verbose_name = self.verbose_name.strip()
def get_verbose_name(self): def get_verbose_name(self):
return self.verbose_name or self.name return self.verbose_name or self.name
def disable(self): def disable(self):
self.is_active = False self.is_active = False
self.save(update_fields=('is_active',)) self.save(update_fields=('is_active',))
def enable(self): def enable(self):
self.is_active = False self.is_active = False
self.save(update_fields=('is_active',)) self.save(update_fields=('is_active',))
class Miscellaneous(models.Model): class Miscellaneous(models.Model):
service = models.ForeignKey(MiscService, verbose_name=_("service"), service = models.ForeignKey(MiscService, on_delete=models.CASCADE,
related_name='instances') verbose_name=_("service"), related_name='instances')
account = models.ForeignKey('accounts.Account', verbose_name=_("account"), account = models.ForeignKey('accounts.Account', on_delete=models.CASCADE,
related_name='miscellaneous') verbose_name=_("account"), 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)
@ -53,32 +53,32 @@ class Miscellaneous(models.Model):
is_active = models.BooleanField(_("active"), default=True, is_active = models.BooleanField(_("active"), default=True,
help_text=_("Designates whether this service should be treated as " help_text=_("Designates whether this service should be treated as "
"active. Unselect this instead of deleting services.")) "active. Unselect this instead of deleting services."))
class Meta: class Meta:
verbose_name_plural = _("miscellaneous") verbose_name_plural = _("miscellaneous")
def __str__(self): def __str__(self):
return self.identifier or self.description[:32] or str(self.service) return self.identifier or self.description[:32] or str(self.service)
@cached_property @cached_property
def active(self): def active(self):
return self.is_active and self.service.is_active and self.account.is_active return self.is_active and self.service.is_active and self.account.is_active
def get_description(self): def get_description(self):
return ' '.join((str(self.amount), self.service.description or self.service.verbose_name)) return ' '.join((str(self.amount), self.service.description or self.service.verbose_name))
def disable(self): def disable(self):
self.is_active = False self.is_active = False
self.save(update_fields=('is_active',)) self.save(update_fields=('is_active',))
def enable(self): def enable(self):
self.is_active = False self.is_active = False
self.save(update_fields=('is_active',)) self.save(update_fields=('is_active',))
@cached_property @cached_property
def service_class(self): def service_class(self):
return self.service return self.service
def clean(self): def clean(self):
if self.identifier: if self.identifier:
self.identifier = self.identifier.strip().lower() self.identifier = self.identifier.strip().lower()

View File

@ -15,21 +15,21 @@ class Operation():
MONITOR = 'monitor' MONITOR = 'monitor'
EXCEEDED = 'exceeded' EXCEEDED = 'exceeded'
RECOVERY = 'recovery' RECOVERY = 'recovery'
def __str__(self): def __str__(self):
return '%s.%s(%s)' % (self.backend, self.action, self.instance) return '%s.%s(%s)' % (self.backend, self.action, self.instance)
def __repr__(self): def __repr__(self):
return str(self) return str(self)
def __hash__(self): def __hash__(self):
""" set() """ """ set() """
return hash((self.backend, self.instance, self.action)) return hash((self.backend, self.instance, self.action))
def __eq__(self, operation): def __eq__(self, operation):
""" set() """ """ set() """
return hash(self) == hash(operation) return hash(self) == hash(operation)
def __init__(self, backend, instance, action, routes=None): def __init__(self, backend, instance, action, routes=None):
self.backend = backend self.backend = backend
# instance should maintain any dynamic attribute until backend execution # instance should maintain any dynamic attribute until backend execution
@ -37,13 +37,13 @@ class Operation():
self.instance = copy.deepcopy(instance) self.instance = copy.deepcopy(instance)
self.action = action self.action = action
self.routes = routes self.routes = routes
@classmethod @classmethod
def execute(cls, operations, serialize=False, async=None): def execute(cls, operations, serialize=False, run_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), async=async) return manager.execute(scripts, serialize=(serialize or backend_serialize), run_async=run_async)
@classmethod @classmethod
def create_for_action(cls, instances, action): def create_for_action(cls, instances, action):
if not isinstance(instances, collections.Iterable): if not isinstance(instances, collections.Iterable):
@ -56,13 +56,13 @@ class Operation():
cls(backend_cls, instance, action) cls(backend_cls, instance, action)
) )
return operations return operations
@classmethod @classmethod
def execute_action(cls, instances, action): def execute_action(cls, instances, action):
""" instances can be an object or an iterable for batch processing """ """ instances can be an object or an iterable for batch processing """
operations = cls.create_for_action(instances, action) operations = cls.create_for_action(instances, action)
return cls.execute(operations) return cls.execute(operations)
def preload_context(self): def preload_context(self):
""" """
Heuristic: Running get_context will prevent most of related objects do not exist errors Heuristic: Running get_context will prevent most of related objects do not exist errors
@ -70,7 +70,7 @@ class Operation():
if self.action == self.DELETE: if self.action == self.DELETE:
if hasattr(self.backend, 'get_context'): if hasattr(self.backend, 'get_context'):
self.backend().get_context(self.instance) self.backend().get_context(self.instance)
def store(self, log): def store(self, log):
from .models import BackendOperation from .models import BackendOperation
return BackendOperation.objects.create( return BackendOperation.objects.create(
@ -79,7 +79,7 @@ class Operation():
instance=self.instance, instance=self.instance,
action=self.action, action=self.action,
) )
@classmethod @classmethod
def load(cls, operation, log=None): def load(cls, operation, log=None):
routes = None routes = None
@ -88,4 +88,4 @@ class Operation():
(operation.backend, operation.action): AttrDict(host=log.server) (operation.backend, operation.action): AttrDict(host=log.server)
} }
return cls(operation.backend_class, operation.instance, operation.action, routes=routes) return cls(operation.backend_class, operation.instance, operation.action, routes=routes)

View File

@ -30,41 +30,40 @@ STATE_COLORS = {
class RouteAdmin(ExtendedModelAdmin): class RouteAdmin(ExtendedModelAdmin):
list_display = ( list_display = (
'display_backend', 'host', 'match', 'display_model', 'display_actions', 'async', 'display_backend', 'host', 'match', 'display_model', 'display_actions', 'run_async',
'is_active' 'is_active'
) )
list_editable = ('host', 'match', 'async', 'is_active') list_editable = ('host', 'match', 'run_async', 'is_active')
list_filter = ('host', 'is_active', 'async', 'backend') list_filter = ('host', 'is_active', 'run_async', 'backend')
list_prefetch_related = ('host',) list_prefetch_related = ('host',)
ordering = ('backend',) ordering = ('backend',)
add_fields = ('backend', 'host', 'match', 'async', 'is_active') add_fields = ('backend', 'host', 'match', 'run_async', 'is_active')
change_form = RouteForm change_form = RouteForm
actions = (orchestrate,) actions = (orchestrate,)
change_view_actions = actions change_view_actions = actions
BACKEND_HELP_TEXT = helpers.get_backends_help_text(ServiceBackend.get_backends()) BACKEND_HELP_TEXT = helpers.get_backends_help_text(ServiceBackend.get_backends())
DEFAULT_MATCH = { DEFAULT_MATCH = {
backend.get_name(): backend.default_route_match for backend in ServiceBackend.get_backends() backend.get_name(): backend.default_route_match for backend in ServiceBackend.get_backends()
} }
display_backend = display_plugin_field('backend') display_backend = display_plugin_field('backend')
def display_model(self, route): def display_model(self, route):
try: try:
return escape(route.backend_class.model) return route.backend_class.model
except KeyError: except KeyError:
return "<span style='color: red;'>NOT AVAILABLE</span>" return mark_safe("<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 """
if db_field.name == 'backend': if db_field.name == 'backend':
@ -79,23 +78,23 @@ class RouteAdmin(ExtendedModelAdmin):
request._host_choices_cache = choices = list(field.choices) request._host_choices_cache = choices = list(field.choices)
field.choices = choices field.choices = choices
return field return field
def get_form(self, request, obj=None, **kwargs): def get_form(self, request, obj=None, **kwargs):
""" Include dynamic help text for existing objects """ """ Include dynamic help text for existing objects """
form = super(RouteAdmin, self).get_form(request, obj, **kwargs) form = super(RouteAdmin, self).get_form(request, obj, **kwargs)
if obj: if obj:
form.base_fields['backend'].help_text = self.BACKEND_HELP_TEXT.get(obj.backend, '') form.base_fields['backend'].help_text = self.BACKEND_HELP_TEXT.get(obj.backend, '')
return form return form
def show_orchestration_disabled(self, request): def show_orchestration_disabled(self, request):
if settings.ORCHESTRATION_DISABLE_EXECUTION: if settings.ORCHESTRATION_DISABLE_EXECUTION:
msg = _("Orchestration execution is disabled by <tt>ORCHESTRATION_DISABLE_EXECUTION</tt> setting.") msg = _("Orchestration execution is disabled by <tt>ORCHESTRATION_DISABLE_EXECUTION</tt> setting.")
self.message_user(request, mark_safe(msg), messages.WARNING) self.message_user(request, mark_safe(msg), messages.WARNING)
def changelist_view(self, request, extra_context=None): def changelist_view(self, request, extra_context=None):
self.show_orchestration_disabled(request) self.show_orchestration_disabled(request)
return super(RouteAdmin, self).changelist_view(request, extra_context) return super(RouteAdmin, self).changelist_view(request, extra_context)
def changeform_view(self, request, object_id=None, form_url='', extra_context=None): def changeform_view(self, request, object_id=None, form_url='', extra_context=None):
self.show_orchestration_disabled(request) self.show_orchestration_disabled(request)
return super(RouteAdmin, self).changeform_view( return super(RouteAdmin, self).changeform_view(
@ -108,24 +107,23 @@ class BackendOperationInline(admin.TabularInline):
readonly_fields = ('action', 'instance_link') readonly_fields = ('action', 'instance_link')
extra = 0 extra = 0
can_delete = False can_delete = False
class Media: class Media:
css = { css = {
'all': ('orchestra/css/hide-inline-id.css',) 'all': ('orchestra/css/hide-inline-id.css',)
} }
def instance_link(self, operation): def instance_link(self, operation):
link = admin_link('instance')(self, operation) link = admin_link('instance')(self, operation)
if link == '---': if link == '---':
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):
return False return False
def get_queryset(self, request): def get_queryset(self, request):
queryset = super(BackendOperationInline, self).get_queryset(request) queryset = super(BackendOperationInline, self).get_queryset(request)
return queryset.prefetch_related('instance') return queryset.prefetch_related('instance')
@ -149,7 +147,7 @@ class BackendLogAdmin(ChangeViewActionsMixin, admin.ModelAdmin):
readonly_fields = fields readonly_fields = fields
actions = (retry_backend,) actions = (retry_backend,)
change_view_actions = actions change_view_actions = actions
server_link = admin_link('server') server_link = admin_link('server')
display_created = admin_date('created_at', short_description=_("Created")) display_created = admin_date('created_at', short_description=_("Created"))
display_state = admin_colored('state', colors=STATE_COLORS) display_state = admin_colored('state', colors=STATE_COLORS)
@ -157,17 +155,17 @@ class BackendLogAdmin(ChangeViewActionsMixin, admin.ModelAdmin):
mono_stdout = display_mono('stdout') mono_stdout = display_mono('stdout')
mono_stderr = display_mono('stderr') mono_stderr = display_mono('stderr')
mono_traceback = display_mono('traceback') mono_traceback = display_mono('traceback')
class Media: class Media:
css = { css = {
'all': ('orchestra/css/pygments/github.css',) 'all': ('orchestra/css/pygments/github.css',)
} }
def get_queryset(self, request): def get_queryset(self, request):
""" Order by structured name and imporve performance """ """ Order by structured name and imporve performance """
qs = super(BackendLogAdmin, self).get_queryset(request) qs = super(BackendLogAdmin, self).get_queryset(request)
return qs.select_related('server').defer('script', 'stdout') return qs.select_related('server').defer('script', 'stdout')
def has_add_permission(self, *args, **kwargs): def has_add_permission(self, *args, **kwargs):
return False return False
@ -177,17 +175,15 @@ class ServerAdmin(ExtendedModelAdmin):
list_filter = ('os',) list_filter = ('os',)
actions = (orchestrate,) actions = (orchestrate,)
change_view_actions = actions change_view_actions = actions
def display_ping(self, instance): def display_ping(self, instance):
return self._remote_state[instance.pk][0] return mark_safe(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 self._remote_state[instance.pk][1] return mark_safe(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 """
qs = super(ServerAdmin, self).get_queryset(request) qs = super(ServerAdmin, self).get_queryset(request)

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