Compare commits
128 Commits
dependabot
...
master
Author | SHA1 | Date |
---|---|---|
Santiago L | 5ab4779e1a | |
Santiago L | 5e6cd2f147 | |
Santiago L | 03666d8ed0 | |
Santiago L | e88e27a56e | |
Santiago L | 9a4f4ee17c | |
Santiago L | 008f49100f | |
Santiago L | b0f77ad591 | |
Santiago L | 639ecdde58 | |
Santiago L | 361b4b41a8 | |
Santiago L | 6720df314b | |
Santiago L | 9f80c75da7 | |
Santiago L | 1258a27688 | |
Santiago L | a400c25de9 | |
Santiago L | e3ec82a182 | |
Santiago L | cda47e2fb6 | |
Santiago L | d3e5ea59a9 | |
Santiago L | b37d9cc515 | |
Santiago L | 1faab905d6 | |
Santiago L | de26baf75a | |
Santiago L | 50f916fa4d | |
Santiago L | c21a52a756 | |
Santiago L | a90e500186 | |
Santiago L | 7d6a2474ab | |
Santiago L | b365580165 | |
Santiago L | bcfed9cb79 | |
Santiago L | 867d9afe65 | |
Santiago L | e1d71fa620 | |
Santiago L | 70f7551e7d | |
Santiago L | 81c67778e5 | |
Santiago L | 9a3b6dcbc3 | |
Santiago L | 5e7a823205 | |
Santiago L | e1224ddd57 | |
Santiago L | 7b59931bcf | |
Santiago L | 0e10d2b142 | |
Santiago L | 47eb0f1efe | |
Santiago L | 28c03ac6c8 | |
Santiago L | 9953124a95 | |
Santiago L | 06c226d302 | |
Santiago L | 4f695c2e6e | |
Santiago L | e6495a967b | |
Santiago L | 6d8a2ced53 | |
Santiago L | a2927f7616 | |
Santiago L | f13fea5030 | |
Santiago L | f0683660ae | |
Santiago L | b24ddf7546 | |
Santiago L | 3b4bb51925 | |
Santiago L | a6c5aa32df | |
Santiago L | 13b4ac5eee | |
Santiago L | 8dc792b851 | |
Santiago L | 5a21f766b4 | |
Santiago L | 7183174f4c | |
Santiago L | 48ef1f21e3 | |
Santiago L | aebbd424fc | |
Santiago L | 5389f425ce | |
Santiago L | ed9bfc0eb7 | |
Santiago L | 0095da61ea | |
Santiago L | 58be94bde2 | |
Santiago L | be5e06129a | |
Santiago L | 69df9780bf | |
Santiago L | 18a41d507b | |
Santiago L | f7627926cb | |
Santiago L | ffd08459c4 | |
Santiago L | 085b8f85bd | |
Santiago L | d5fce3b6e2 | |
Santiago L | 777a7f6de5 | |
Santiago L | 422305a636 | |
Santiago L | d6cebf66a2 | |
Santiago L | 0338b927cf | |
Santiago L | 97f1c7ef2b | |
Santiago L | b6cf0c34f5 | |
Santiago L | 7fa7106d72 | |
Santiago L | 6ef7f921e9 | |
Santiago L | a8b17da992 | |
Santiago L | c689a6e44c | |
Santiago L | de979011f9 | |
Santiago L | 7d975637d5 | |
Santiago L | d863598d81 | |
Santiago L | eadc06d4c5 | |
Santiago L | 2b06652a5b | |
Santiago L | dc722ec17a | |
Santiago L | e7aabf4799 | |
Cayo Puigdefabregas | fa8a895299 | |
Cayo Puigdefabregas | 091120d3c2 | |
Cayo Puigdefabregas | c952d782cd | |
Cayo Puigdefabregas | 226327cacf | |
Cayo Puigdefabregas | 6f043cd272 | |
Cayo Puigdefabregas | 0633df114e | |
Cayo Puigdefabregas | a53b71bab1 | |
Cayo Puigdefabregas | c010c10157 | |
Santiago L | acac7727c2 | |
Cayo Puigdefabregas | 48ef6d63bc | |
Santiago L | 45bf31c9da | |
Santiago L | 08a76a8de4 | |
Santiago L | 14fbd98e33 | |
Santiago L | 58395147c9 | |
Santiago L | c505f9a3c6 | |
Santiago L | f4c0a7413c | |
Santiago L | 9d2d0befc4 | |
cayop | 350d93f820 | |
Cayo Puigdefabregas | cedb8d690b | |
Cayo Puigdefabregas | 883cf631e2 | |
Cayo Puigdefabregas | 898c6882c8 | |
Cayo Puigdefabregas | a236bbdf5d | |
Cayo Puigdefabregas | 6ce4d6b877 | |
Cayo Puigdefabregas | 8da89ae22a | |
Cayo Puigdefabregas | 78db4fb8d5 | |
Cayo Puigdefabregas | 7c62092faa | |
Cayo Puigdefabregas | d0050f81b7 | |
Cayo Puigdefabregas | 6450d0d749 | |
Cayo Puigdefabregas | 2619a50410 | |
Cayo Puigdefabregas | 24e75bc07f | |
Cayo Puigdefabregas | 0cde41042f | |
Cayo Puigdefabregas | 5b4b7310e6 | |
Cayo Puigdefabregas | 38275847d9 | |
Cayo Puigdefabregas | a4c3b00205 | |
Cayo Puigdefabregas | c386b10bc8 | |
Cayo Puigdefabregas | 0b937bfb4f | |
Cayo Puigdefabregas | 30bd1ad816 | |
Cayo Puigdefabregas | 44ebd42942 | |
Cayo Puigdefabregas | e2ef8823f8 | |
Cayo Puigdefabregas | f0fadf8bba | |
Santiago L | e6e434f525 | |
Santiago L | 43d8c9471b | |
Marc Aymerich | ea9c398de4 | |
Marc Aymerich | 6fadf0c631 | |
Marc Aymerich | a1f73d883a | |
Marc Aymerich | 0c1b4c7f4a | |
Marc Aymerich | 25fbc6a088 |
|
@ -11,7 +11,7 @@ If you are planing to do some development you may want to consider doing it unde
|
|||
|
||||
2. Build a new image, create and start a container
|
||||
```bash
|
||||
curl -L http://git.io/orchestra-Dockerfile > /tmp/Dockerfile
|
||||
curl -L https://raw.githubusercontent.com/ribaguifi/django-orchestra/master/scripts/containers/Dockerfile > /tmp/Dockerfile
|
||||
docker build -t orchestra /tmp/
|
||||
docker create --name orchestra -i -t -u orchestra -w /home/orchestra orchestra bash
|
||||
docker start orchestra
|
||||
|
@ -21,12 +21,13 @@ If you are planing to do some development you may want to consider doing it unde
|
|||
|
||||
3. Deploy django-orchestra development environment, inside the container
|
||||
```bash
|
||||
bash <( curl -L http://git.io/orchestra-deploy ) --dev
|
||||
bash <( curl -L https://raw.githubusercontent.com/ribaguifi/django-orchestra/master/scripts/containers/deploy.sh ) --dev
|
||||
```
|
||||
|
||||
3. Nginx should be serving on port 80, but Django's development server can be used as well:
|
||||
```bash
|
||||
cd panel
|
||||
python3 manage.py migrate
|
||||
python3 manage.py runserver 0.0.0.0:8888
|
||||
```
|
||||
|
||||
|
@ -34,5 +35,5 @@ If you are planing to do some development you may want to consider doing it unde
|
|||
5. To upgrade to current master just re-run the deploy script
|
||||
```bash
|
||||
git pull origin master
|
||||
bash <( curl -L http://git.io/orchestra-deploy ) --dev
|
||||
bash <( curl -L https://raw.githubusercontent.com/ribaguifi/django-orchestra/master/scripts/containers/deploy.sh ) --dev
|
||||
```
|
||||
|
|
|
@ -0,0 +1,132 @@
|
|||
# System requirements:
|
||||
The most important requirement is use python3.6
|
||||
we need install this packages:
|
||||
```
|
||||
bind9utils
|
||||
ca-certificates
|
||||
gettext
|
||||
libcrack2-dev
|
||||
libxml2-dev
|
||||
libxslt1-dev
|
||||
python3
|
||||
python3-pip
|
||||
python3-dev
|
||||
ssh-client
|
||||
wget
|
||||
xvfb
|
||||
zlib1g-dev
|
||||
git
|
||||
iceweasel
|
||||
dnsutils
|
||||
```
|
||||
We need install too a *wkhtmltopdf* package
|
||||
You can use one of your OS or get it from original.
|
||||
This it is in https://wkhtmltopdf.org/downloads.html
|
||||
|
||||
# pip installations
|
||||
We need install this packages:
|
||||
```
|
||||
Django==1.10.5
|
||||
django-fluent-dashboard==0.6.1
|
||||
django-admin-tools==0.8.0
|
||||
django-extensions==1.7.4
|
||||
django-celery==3.1.17
|
||||
celery==3.1.23
|
||||
kombu==3.0.35
|
||||
billiard==3.3.0.23
|
||||
Markdown==2.4
|
||||
djangorestframework==3.4.7
|
||||
ecdsa==0.11
|
||||
Pygments==1.6
|
||||
django-filter==0.15.2
|
||||
jsonfield==0.9.22
|
||||
python-dateutil==2.2
|
||||
https://github.com/glic3rinu/passlib/archive/master.zip
|
||||
django-iban==0.3.0
|
||||
requests
|
||||
phonenumbers
|
||||
django-countries
|
||||
django-localflavor
|
||||
amqp
|
||||
anyjson
|
||||
pytz
|
||||
cracklib
|
||||
lxml==3.3.5
|
||||
selenium
|
||||
xvfbwrapper
|
||||
freezegun
|
||||
coverage
|
||||
flake8
|
||||
django-debug-toolbar==1.3.0
|
||||
django-nose==1.4.4
|
||||
sqlparse
|
||||
pyinotify
|
||||
PyMySQL
|
||||
```
|
||||
|
||||
If you want to use Orchestra you need to install from pip like this:
|
||||
```
|
||||
pip3 install http://git.io/django-orchestra-dev
|
||||
```
|
||||
|
||||
But if you want develop orquestra you need to do this:
|
||||
```
|
||||
git clone https://github.com/ribaguifi/django-orchestra
|
||||
pip install -e django-orchestra
|
||||
```
|
||||
|
||||
# Database
|
||||
For default use sqlite3 if you want to use postgresql you need install this packages:
|
||||
|
||||
```
|
||||
psycopg2 postgresql
|
||||
```
|
||||
|
||||
You can use it for debian or ubuntu:
|
||||
|
||||
```
|
||||
sudo apt-get install python3-psycopg2 postgresql-contrib
|
||||
```
|
||||
|
||||
Remember create a database for your project and give permitions for the correct user like this:
|
||||
|
||||
```
|
||||
psql -U postgres
|
||||
psql (12.4)
|
||||
Digite «help» para obtener ayuda.
|
||||
|
||||
postgres=# CREATE database orchesta;
|
||||
postgres=# CREATE USER orchesta WITH PASSWORD 'orquesta';
|
||||
postgres=# GRANT ALL PRIVILEGES ON DATABASE orchesta TO orchesta;
|
||||
```
|
||||
|
||||
# Create new project
|
||||
You can use orchestra-admin for create your new project
|
||||
```
|
||||
orchestra-admin startproject <project_name> # e.g. panel
|
||||
cd <project_name>
|
||||
```
|
||||
|
||||
Next we need change the settings.py for configure the correct database
|
||||
|
||||
In settings.py we need change the DATABASE section like this:
|
||||
|
||||
```
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql_psycopg2',
|
||||
'NAME': 'orchestra'
|
||||
'USER': 'orchestra',
|
||||
'PASSWORD': 'orchestra',
|
||||
'HOST': 'localhost',
|
||||
'PORT': '5432',
|
||||
'CONN_MAX_AGE': 60*10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For end you need to do the migrations:
|
||||
|
||||
```
|
||||
python3 manage.py migrate
|
||||
```
|
|
@ -3,7 +3,7 @@ from collections import OrderedDict
|
|||
from functools import update_wrapper
|
||||
|
||||
from django.contrib import admin
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.urls import reverse
|
||||
from django.shortcuts import render, redirect
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
@ -56,7 +56,7 @@ def search(request):
|
|||
if service.search:
|
||||
models.add(service.model)
|
||||
model_name_map[service.model._meta.model_name] = service.model
|
||||
|
||||
|
||||
# Account direct access
|
||||
if search_term.endswith('!'):
|
||||
from ..contrib.accounts.models import Account
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
from django.core.urlresolvers import reverse
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from fluent_dashboard import dashboard, appsettings
|
||||
from fluent_dashboard.modules import CmsAppIconList
|
||||
|
@ -11,7 +11,7 @@ class AppDefaultIconList(CmsAppIconList):
|
|||
def __init__(self, *args, **kwargs):
|
||||
self.icons = kwargs.pop('icons')
|
||||
super(AppDefaultIconList, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
def get_icon_for_model(self, app_name, model_name, default=None):
|
||||
icon = self.icons.get('.'.join((app_name, model_name)))
|
||||
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):
|
||||
""" Gets application modules from services, accounts and administration registries """
|
||||
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super(dashboard.FluentIndexDashboard, self).__init__(**kwargs)
|
||||
self.children.append(self.get_personal_module())
|
||||
|
@ -27,7 +27,7 @@ class OrchestraIndexDashboard(dashboard.FluentIndexDashboard):
|
|||
recent_actions = self.get_recent_actions_module()
|
||||
recent_actions.enabled = True
|
||||
self.children.append(recent_actions)
|
||||
|
||||
|
||||
def process_registered_view(self, module, view_name, options):
|
||||
app_name, name = view_name.split('_')[:-1]
|
||||
module.icons['.'.join((app_name, name))] = options.get('icon')
|
||||
|
@ -47,7 +47,7 @@ class OrchestraIndexDashboard(dashboard.FluentIndexDashboard):
|
|||
'title': options.get('verbose_name_plural'),
|
||||
'url': add_url,
|
||||
})
|
||||
|
||||
|
||||
def get_application_modules(self):
|
||||
modules = []
|
||||
# Honor settings override, hacky. I Know
|
||||
|
|
|
@ -5,7 +5,7 @@ from django import forms
|
|||
from django.contrib.admin import helpers
|
||||
from django.core import validators
|
||||
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 orchestra.forms.widgets import SpanWidget
|
||||
|
@ -28,9 +28,9 @@ class AdminFormMixin(object):
|
|||
' {% include "admin/includes/fieldset.html" %}'
|
||||
'{% endfor %}'
|
||||
)
|
||||
context = Context({
|
||||
context = {
|
||||
'adminform': adminform
|
||||
})
|
||||
}
|
||||
return template.render(context)
|
||||
|
||||
|
||||
|
@ -71,9 +71,9 @@ class AdminFormSet(BaseModelFormSet):
|
|||
</div>
|
||||
</div>""")
|
||||
)
|
||||
context = Context({
|
||||
context = {
|
||||
'formset': self
|
||||
})
|
||||
}
|
||||
return template.render(context)
|
||||
|
||||
|
||||
|
@ -93,7 +93,7 @@ class AdminPasswordChangeForm(forms.Form):
|
|||
required=False, validators=[validate_password])
|
||||
password2 = forms.CharField(label=_("Password (again)"), widget=forms.PasswordInput,
|
||||
required=False)
|
||||
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
self.related = kwargs.pop('related', [])
|
||||
self.raw = kwargs.pop('raw', False)
|
||||
|
@ -109,7 +109,7 @@ class AdminPasswordChangeForm(forms.Form):
|
|||
self.fields['password2_%i' % ix] = forms.CharField(label=_("Password (again)"),
|
||||
widget=forms.PasswordInput, required=False)
|
||||
setattr(self, 'clean_password2_%i' % ix, partial(self.clean_password2, ix=ix))
|
||||
|
||||
|
||||
def clean_password2(self, ix=''):
|
||||
if ix != '':
|
||||
ix = '_%i' % ix
|
||||
|
@ -129,7 +129,7 @@ class AdminPasswordChangeForm(forms.Form):
|
|||
code='password_mismatch',
|
||||
)
|
||||
return password2
|
||||
|
||||
|
||||
def clean_password(self, ix=''):
|
||||
if ix != '':
|
||||
ix = '_%i' % ix
|
||||
|
@ -146,14 +146,14 @@ class AdminPasswordChangeForm(forms.Form):
|
|||
code='bad_hash',
|
||||
)
|
||||
return password
|
||||
|
||||
|
||||
def clean(self):
|
||||
if not self.password_provided:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['password_missing'],
|
||||
code='password_missing',
|
||||
)
|
||||
|
||||
|
||||
def save(self, commit=True):
|
||||
"""
|
||||
Saves the new password.
|
||||
|
@ -182,7 +182,7 @@ class AdminPasswordChangeForm(forms.Form):
|
|||
if commit:
|
||||
rel.save(update_fields=['password'])
|
||||
return self.user
|
||||
|
||||
|
||||
def _get_changed_data(self):
|
||||
data = super().changed_data
|
||||
for name in self.fields.keys():
|
||||
|
@ -202,7 +202,7 @@ class SendEmailForm(forms.Form):
|
|||
widget=forms.TextInput(attrs={'size': '118'}))
|
||||
message = forms.CharField(label=_("Message"),
|
||||
widget=forms.Textarea(attrs={'cols': 118, 'rows': 15}))
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
initial = kwargs.get('initial')
|
||||
|
@ -210,7 +210,7 @@ class SendEmailForm(forms.Form):
|
|||
self.fields['to'].widget = SpanWidget(original=initial['to'])
|
||||
else:
|
||||
self.fields.pop('to')
|
||||
|
||||
|
||||
def clean_comma_separated_emails(self, value):
|
||||
clean_value = []
|
||||
for email in value.split(','):
|
||||
|
@ -222,7 +222,7 @@ class SendEmailForm(forms.Form):
|
|||
raise validators.ValidationError("Comma separated email addresses.")
|
||||
clean_value.append(email)
|
||||
return clean_value
|
||||
|
||||
|
||||
def clean_extra_to(self):
|
||||
extra_to = self.cleaned_data['extra_to']
|
||||
return self.clean_comma_separated_emails(extra_to)
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
from copy import deepcopy
|
||||
|
||||
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.translation import ugettext_lazy as _
|
||||
|
||||
|
@ -16,7 +16,7 @@ def api_link(context):
|
|||
opts = context['cl'].opts
|
||||
else:
|
||||
return reverse('api-root')
|
||||
if 'object_id' in context:
|
||||
if 'object_id' in context:
|
||||
object_id = context['object_id']
|
||||
try:
|
||||
return reverse('%s-detail' % opts.model_name, args=[object_id])
|
||||
|
@ -42,7 +42,7 @@ def process_registry(register):
|
|||
item = items.MenuItem(name, url)
|
||||
item.options = options
|
||||
return item
|
||||
|
||||
|
||||
childrens = {}
|
||||
for model, options in register.get().items():
|
||||
if options.get('menu', True):
|
||||
|
@ -68,7 +68,7 @@ def process_registry(register):
|
|||
|
||||
class OrchestraMenu(Menu):
|
||||
template = 'admin/orchestra/menu.html'
|
||||
|
||||
|
||||
def init_with_context(self, context):
|
||||
self.children = [
|
||||
# items.MenuItem(
|
||||
|
|
|
@ -6,11 +6,11 @@ from functools import wraps
|
|||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
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.shortcuts import redirect
|
||||
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 orchestra.models.utils import get_field_value
|
||||
|
@ -113,21 +113,21 @@ def admin_link(*args, **kwargs):
|
|||
return '---'
|
||||
if not getattr(obj, 'pk', None):
|
||||
return '---'
|
||||
display = kwargs.get('display')
|
||||
if display:
|
||||
display = getattr(obj, display, display)
|
||||
display_ = kwargs.get('display')
|
||||
if display_:
|
||||
display_ = getattr(obj, display_, display_)
|
||||
else:
|
||||
display = obj
|
||||
display_ = obj
|
||||
try:
|
||||
url = change_url(obj)
|
||||
except NoReverseMatch:
|
||||
# Does not has admin
|
||||
return str(display)
|
||||
return str(display_)
|
||||
extra = ''
|
||||
if kwargs['popup']:
|
||||
extra = 'onclick="return showAddAnotherPopup(this);"'
|
||||
extra = mark_safe('onclick="return showAddAnotherPopup(this);"')
|
||||
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
|
||||
|
@ -158,7 +158,7 @@ def admin_date(*args, **kwargs):
|
|||
date = date.strftime("%Y-%m-%d %H:%M:%S %Z")
|
||||
else:
|
||||
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):
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
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 .serializers import SetPasswordSerializer
|
||||
|
||||
|
||||
class SetPasswordApiMixin(object):
|
||||
@detail_route(methods=['post'], serializer_class=SetPasswordSerializer)
|
||||
@action(detail=True, methods=['post'], serializer_class=SetPasswordSerializer)
|
||||
def set_password(self, request, pk):
|
||||
obj = self.get_object()
|
||||
data = request.DATA
|
||||
data = request.data
|
||||
if isinstance(data, str):
|
||||
data = {
|
||||
'password': data
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
from django.core.urlresolvers import NoReverseMatch
|
||||
from django.urls import NoReverseMatch
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
|
||||
|
@ -23,16 +23,16 @@ def link_wrap(view, view_names):
|
|||
return wrapper
|
||||
|
||||
|
||||
def insert_links(viewset, base_name):
|
||||
collection_links = ['api-root', '%s-list' % base_name]
|
||||
object_links = ['api-root', '%s-list' % base_name, '%s-detail' % base_name]
|
||||
def insert_links(viewset, basename):
|
||||
collection_links = ['api-root', '%s-list' % basename]
|
||||
object_links = ['api-root', '%s-list' % basename, '%s-detail' % basename]
|
||||
exception_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
|
||||
for methodname in dir(viewset):
|
||||
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'):
|
||||
list_links.append(view_name)
|
||||
retrieve_links.append(view_name)
|
||||
|
|
|
@ -18,33 +18,33 @@ class LogApiMixin(object):
|
|||
message = _('Added.')
|
||||
self.log(request, message, ADDITION, instance=self.serializer.instance)
|
||||
return response
|
||||
|
||||
|
||||
def perform_create(self, serializer):
|
||||
""" stores serializer for accessing instance on create() """
|
||||
super(LogApiMixin, self).perform_create(serializer)
|
||||
self.serializer = serializer
|
||||
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
from django.contrib.admin.models import CHANGE
|
||||
response = super(LogApiMixin, self).update(request, *args, **kwargs)
|
||||
message = _('Changed data')
|
||||
self.log(request, message, CHANGE)
|
||||
return response
|
||||
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
from django.contrib.admin.models import CHANGE
|
||||
response = super(LogApiMixin, self).partial_update(request, *args, **kwargs)
|
||||
message = _('Changed %s') % response.data
|
||||
self.log(request, message, CHANGE)
|
||||
return response
|
||||
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
from django.contrib.admin.models import DELETION
|
||||
message = _('Deleted')
|
||||
self.log(request, message, DELETION)
|
||||
response = super(LogApiMixin, self).destroy(request, *args, **kwargs)
|
||||
return response
|
||||
|
||||
|
||||
def log(self, request, message, action, instance=None):
|
||||
from django.contrib.admin.models import LogEntry
|
||||
instance = instance or self.get_object()
|
||||
|
@ -64,21 +64,21 @@ class LinkHeaderRouter(DefaultRouter):
|
|||
APIRoot = import_class(settings.ORCHESTRA_API_ROOT_VIEW)
|
||||
APIRoot.router = self
|
||||
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 """
|
||||
if base_name is None:
|
||||
base_name = self.get_default_base_name(viewset)
|
||||
insert_links(viewset, base_name)
|
||||
self.registry.append((prefix, viewset, base_name))
|
||||
|
||||
if basename is None:
|
||||
basename = self.get_default_basename(viewset)
|
||||
insert_links(viewset, basename)
|
||||
self.registry.append((prefix, viewset, basename))
|
||||
|
||||
def get_viewset(self, prefix_or_model):
|
||||
for _prefix, viewset, __ in self.registry:
|
||||
if _prefix == prefix_or_model or viewset.queryset.model == prefix_or_model:
|
||||
return viewset
|
||||
msg = "%s does not have a regiestered viewset" % prefix_or_model
|
||||
raise KeyError(msg)
|
||||
|
||||
|
||||
def insert(self, prefix_or_model, name, field, **kwargs):
|
||||
""" Dynamically add new fields to an existing serializer """
|
||||
viewset = self.get_viewset(prefix_or_model)
|
||||
|
|
|
@ -11,7 +11,7 @@ class APIRoot(views.APIView):
|
|||
'ORCHESTRA_SITE_NAME',
|
||||
'ORCHESTRA_SITE_VERBOSE_NAME'
|
||||
)
|
||||
|
||||
|
||||
def get(self, request, format=None):
|
||||
root_url = reverse('api-root', request=request, format=format)
|
||||
token_url = reverse('api-token-auth', request=request, format=format)
|
||||
|
@ -23,7 +23,7 @@ class APIRoot(views.APIView):
|
|||
'accountancy': {},
|
||||
'services': {},
|
||||
}
|
||||
if not request.user.is_anonymous():
|
||||
if not request.user.is_anonymous:
|
||||
list_name = '{basename}-list'
|
||||
detail_name = '{basename}-detail'
|
||||
for prefix, viewset, basename in self.router.registry:
|
||||
|
@ -60,7 +60,7 @@ class APIRoot(views.APIView):
|
|||
for name in self.names
|
||||
})
|
||||
return Response(body, headers=headers)
|
||||
|
||||
|
||||
def options(self, request):
|
||||
metadata = super(APIRoot, self).options(request)
|
||||
metadata.data['settings'] = {
|
||||
|
|
|
@ -17,7 +17,7 @@ class SetPasswordSerializer(serializers.Serializer):
|
|||
|
||||
class HyperlinkedModelSerializer(serializers.HyperlinkedModelSerializer):
|
||||
""" support for postonly_fields, fields whose value can only be set on post """
|
||||
|
||||
|
||||
def validate(self, attrs):
|
||||
""" calls model.clean() """
|
||||
attrs = super(HyperlinkedModelSerializer, self).validate(attrs)
|
||||
|
@ -39,7 +39,7 @@ class HyperlinkedModelSerializer(serializers.HyperlinkedModelSerializer):
|
|||
instance = ModelClass(**validated_data)
|
||||
instance.clean()
|
||||
return attrs
|
||||
|
||||
|
||||
def post_only_cleanning(self, instance, validated_data):
|
||||
""" removes postonly_fields from attrs """
|
||||
model_attrs = dict(**validated_data)
|
||||
|
@ -49,12 +49,12 @@ class HyperlinkedModelSerializer(serializers.HyperlinkedModelSerializer):
|
|||
if attr in post_only_fields:
|
||||
model_attrs.pop(attr)
|
||||
return model_attrs
|
||||
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
""" removes postonly_fields from attrs when not posting """
|
||||
model_attrs = self.post_only_cleanning(instance, validated_data)
|
||||
return super(HyperlinkedModelSerializer, self).update(instance, model_attrs)
|
||||
|
||||
|
||||
def partial_update(self, instance, validated_data):
|
||||
""" removes postonly_fields from attrs when not posting """
|
||||
model_attrs = self.post_only_cleanning(instance, validated_data)
|
||||
|
@ -64,7 +64,10 @@ class HyperlinkedModelSerializer(serializers.HyperlinkedModelSerializer):
|
|||
class RelatedHyperlinkedModelSerializer(HyperlinkedModelSerializer):
|
||||
""" returns object on to_internal_value based on URL """
|
||||
def to_internal_value(self, data):
|
||||
url = data.get('url')
|
||||
try:
|
||||
url = data.get('url')
|
||||
except AttributeError:
|
||||
url = None
|
||||
if not url:
|
||||
raise ValidationError({
|
||||
'url': "URL is required."
|
||||
|
@ -80,16 +83,16 @@ class SetPasswordHyperlinkedSerializer(HyperlinkedModelSerializer):
|
|||
password = serializers.CharField(max_length=128, label=_('Password'),
|
||||
validators=[validate_password], write_only=True, required=False,
|
||||
style={'widget': widgets.PasswordInput})
|
||||
|
||||
def validate_password(self, attrs, source):
|
||||
|
||||
def validate_password(self, value):
|
||||
""" POST only password """
|
||||
if self.instance:
|
||||
if 'password' in attrs:
|
||||
if value:
|
||||
raise serializers.ValidationError(_("Can not set password"))
|
||||
elif 'password' not in attrs:
|
||||
elif not value:
|
||||
raise serializers.ValidationError(_("Password required"))
|
||||
return attrs
|
||||
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
""" remove password in case is not a real model field """
|
||||
try:
|
||||
|
@ -98,11 +101,11 @@ class SetPasswordHyperlinkedSerializer(HyperlinkedModelSerializer):
|
|||
pass
|
||||
else:
|
||||
password = attrs.pop('password', None)
|
||||
attrs = super(SetPasswordSerializer, self).validate()
|
||||
attrs = super().validate(attrs)
|
||||
if password is not None:
|
||||
attrs['password'] = password
|
||||
return attrs
|
||||
|
||||
|
||||
def create(self, validated_data):
|
||||
password = validated_data.pop('password')
|
||||
instance = self.Meta.model(**validated_data)
|
||||
|
|
|
@ -21,22 +21,22 @@ function help () {
|
|||
|
||||
function print_help () {
|
||||
cat <<- EOF
|
||||
|
||||
|
||||
${bold}NAME${normal}
|
||||
${bold}orchestra-admin${normal} - Orchetsra administration script
|
||||
|
||||
|
||||
${bold}OPTIONS${normal}
|
||||
${bold}install_requirements${normal}
|
||||
Installs Orchestra requirements using apt-get and pip
|
||||
|
||||
|
||||
${bold}startproject${normal}
|
||||
Creates a new Django-orchestra instance
|
||||
|
||||
|
||||
${bold}help${normal}
|
||||
Displays this help text or related help page as argument
|
||||
for example:
|
||||
${bold}orchestra-admin help startproject${normal}
|
||||
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
|
@ -73,17 +73,17 @@ export -f get_orchestra_dir
|
|||
|
||||
function print_install_requirements_help () {
|
||||
cat <<- EOF
|
||||
|
||||
|
||||
${bold}NAME${normal}
|
||||
${bold}orchetsra-admin install_requirements${normal} - Installs all Orchestra requirements using apt-get and pip
|
||||
|
||||
|
||||
${bold}OPTIONS${normal}
|
||||
${bold}-t, --testing${normal}
|
||||
Install Orchestra normal requirements plus those needed for running functional tests
|
||||
|
||||
|
||||
${bold}-h, --help${normal}
|
||||
Displays this help text
|
||||
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
|
@ -92,7 +92,7 @@ function install_requirements () {
|
|||
opts=$(getopt -o h,t -l help,testing -- "$@") || exit 1
|
||||
set -- $opts
|
||||
testing=false
|
||||
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
-h|--help) print_deploy_help; exit 0 ;;
|
||||
|
@ -105,17 +105,17 @@ function install_requirements () {
|
|||
done
|
||||
unset OPTIND
|
||||
unset opt
|
||||
|
||||
|
||||
check_root || true
|
||||
ORCHESTRA_PATH=$(get_orchestra_dir) || true
|
||||
|
||||
|
||||
# Make sure locales are in place before installing postgres
|
||||
if [[ $({ perl --help > /dev/null; } 2>&1|grep 'locale failed') ]]; then
|
||||
run sed -i "s/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/" /etc/locale.gen
|
||||
run locale-gen
|
||||
update-locale LANG=en_US.UTF-8
|
||||
fi
|
||||
|
||||
|
||||
# lxml: libxml2-dev, libxslt1-dev, zlib1g-dev
|
||||
APT="bind9utils \
|
||||
ca-certificates \
|
||||
|
@ -136,10 +136,10 @@ function install_requirements () {
|
|||
iceweasel \
|
||||
dnsutils"
|
||||
fi
|
||||
|
||||
|
||||
run apt-get update
|
||||
run apt-get install -y $APT
|
||||
|
||||
|
||||
# Install ca certificates before executing pip install
|
||||
if [[ ! -e /usr/local/share/ca-certificates/cacert.org ]]; then
|
||||
mkdir -p /usr/local/share/ca-certificates/cacert.org
|
||||
|
@ -148,7 +148,7 @@ function install_requirements () {
|
|||
http://www.cacert.org/certs/class3.crt
|
||||
update-ca-certificates
|
||||
fi
|
||||
|
||||
|
||||
# cracklib and lxml are excluded on the requirements.txt because they need unconvinient system dependencies
|
||||
PIP="$(wget http://git.io/orchestra-requirements.txt -O - | tr '\n' ' ') \
|
||||
cracklib \
|
||||
|
@ -157,7 +157,7 @@ function install_requirements () {
|
|||
PIP="${PIP} \
|
||||
selenium \
|
||||
xvfbwrapper \
|
||||
freezegun \
|
||||
freezegun==0.3.14 \
|
||||
coverage \
|
||||
flake8 \
|
||||
django-debug-toolbar==1.3.0 \
|
||||
|
@ -166,15 +166,15 @@ function install_requirements () {
|
|||
pyinotify \
|
||||
PyMySQL"
|
||||
fi
|
||||
|
||||
|
||||
run pip3 install $PIP
|
||||
|
||||
|
||||
# Install a more recent version of wkhtmltopdf (0.12.2) (PDF page number support)
|
||||
wkhtmltox_version=$(dpkg --list | grep wkhtmltox | awk {'print $3'})
|
||||
minor=$(echo -e "$wkhtmltox_version\n0.12.2.1" | sort -V | head -n 1)
|
||||
if [[ ! $wkhtmltox_version ]] || [[ $wkhtmltox_version != 0.12.2.1 && $minor == ${wkhtmltox_version} ]]; then
|
||||
wkhtmltox=$(mktemp)
|
||||
wget http://download.gna.org/wkhtmltopdf/0.12/0.12.2.1/wkhtmltox-0.12.2.1_linux-jessie-amd64.deb -O ${wkhtmltox}
|
||||
wget https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.buster_amd64.deb -O ${wkhtmltox}
|
||||
dpkg -i ${wkhtmltox} || { echo "Installing missing dependencies for wkhtmltox..." && apt-get -f -y install; }
|
||||
fi
|
||||
}
|
||||
|
@ -183,30 +183,30 @@ export -f install_requirements
|
|||
|
||||
print_startproject_help () {
|
||||
cat <<- EOF
|
||||
|
||||
|
||||
${bold}NAME${normal}
|
||||
${bold}orchestra-admin startproject${normal} - Create a new Django-Orchestra instance
|
||||
|
||||
|
||||
${bold}SYNOPSIS${normal}
|
||||
Options: [ -h ]
|
||||
|
||||
|
||||
${bold}OPTIONS${normal}
|
||||
${bold}-h, --help${normal}
|
||||
This help message
|
||||
|
||||
|
||||
${bold}EXAMPLES${normal}
|
||||
orchestra-admin startproject controlpanel
|
||||
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
|
||||
function startproject () {
|
||||
local PROJECT_NAME="$2"; shift
|
||||
|
||||
|
||||
opts=$(getopt -o h -l help -- "$@") || exit 1
|
||||
set -- $opts
|
||||
|
||||
|
||||
set -- $opts
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
|
@ -217,10 +217,10 @@ function startproject () {
|
|||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
|
||||
unset OPTIND
|
||||
unset opt
|
||||
|
||||
|
||||
[ $(whoami) == 'root' ] && { echo -e "\nYou don't want to run this as root\n" >&2; exit 1; }
|
||||
ORCHESTRA_PATH=$(get_orchestra_dir) || { echo "Error getting orchestra dir"; exit 1; }
|
||||
if [[ ! -e $PROJECT_NAME/manage.py ]]; then
|
||||
|
|
|
@ -27,7 +27,7 @@ class crontab_parser(object):
|
|||
_range = r'(\w+?)-(\w+)'
|
||||
_steps = r'/(\w+)?'
|
||||
_star = r'\*'
|
||||
|
||||
|
||||
def __init__(self, max_=60, min_=0):
|
||||
self.max_ = max_
|
||||
self.min_ = min_
|
||||
|
@ -45,14 +45,14 @@ class crontab_parser(object):
|
|||
raise self.ParseException('empty part')
|
||||
acc |= set(self._parse_part(part))
|
||||
return acc
|
||||
|
||||
|
||||
def _parse_part(self, part):
|
||||
for regex, handler in self.pats:
|
||||
m = regex.match(part)
|
||||
if m:
|
||||
return handler(m.groups())
|
||||
return self._expand_range((part, ))
|
||||
|
||||
|
||||
def _expand_range(self, toks):
|
||||
fr = self._expand_number(toks[0])
|
||||
if len(toks) > 1:
|
||||
|
@ -62,19 +62,19 @@ class crontab_parser(object):
|
|||
list(range(self.min_, to + 1)))
|
||||
return list(range(fr, to + 1))
|
||||
return [fr]
|
||||
|
||||
|
||||
def _range_steps(self, toks):
|
||||
if len(toks) != 3 or not toks[2]:
|
||||
raise self.ParseException('empty filter')
|
||||
return self._expand_range(toks[:2])[::int(toks[2])]
|
||||
|
||||
|
||||
def _star_steps(self, toks):
|
||||
if not toks or not toks[0]:
|
||||
raise self.ParseException('empty filter')
|
||||
return self._expand_star()[::int(toks[0])]
|
||||
def _expand_star(self, *args):
|
||||
return list(range(self.min_, self.max_ + self.min_))
|
||||
|
||||
|
||||
def _expand_number(self, s):
|
||||
if isinstance(s, str) and s[0] == '-':
|
||||
raise self.ParseException('negative numbers not supported')
|
||||
|
@ -99,7 +99,7 @@ class Setting(object):
|
|||
def __init__(self, manage):
|
||||
self.manage = manage
|
||||
self.settings_file = self.get_settings_file(manage)
|
||||
|
||||
|
||||
def get_settings(self):
|
||||
""" get db settings from settings.py file without importing """
|
||||
settings = {'__file__': self.settings_file}
|
||||
|
@ -111,7 +111,7 @@ class Setting(object):
|
|||
content += line
|
||||
exec(content, settings)
|
||||
return settings
|
||||
|
||||
|
||||
def get_settings_file(self, manage):
|
||||
with open(manage, 'r') as handler:
|
||||
regex = re.compile(r'"DJANGO_SETTINGS_MODULE"\s*,\s*"([^"]+)"')
|
||||
|
@ -128,7 +128,7 @@ class Setting(object):
|
|||
class DB(object):
|
||||
def __init__(self, settings):
|
||||
self.settings = settings['DATABASES']['default']
|
||||
|
||||
|
||||
def connect(self):
|
||||
if self.settings['ENGINE'] == 'django.db.backends.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))
|
||||
else:
|
||||
raise ValueError("%s engine not supported." % self.settings['ENGINE'])
|
||||
|
||||
|
||||
def query(self, query):
|
||||
cur = self.conn.cursor()
|
||||
try:
|
||||
|
@ -147,7 +147,7 @@ class DB(object):
|
|||
finally:
|
||||
cur.close()
|
||||
return result
|
||||
|
||||
|
||||
def close(self):
|
||||
self.conn.close()
|
||||
|
||||
|
@ -161,7 +161,7 @@ def fire_pending_tasks(manage, db):
|
|||
"WHERE p.crontab_id = c.id AND p.enabled = {}"
|
||||
).format(enabled)
|
||||
return db.query(query)
|
||||
|
||||
|
||||
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
|
||||
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_month_of_year in crontab_parser(12, 1).parse(month_of_year)
|
||||
)
|
||||
|
||||
|
||||
now = datetime.utcnow()
|
||||
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):
|
||||
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(
|
||||
manage=manage, task_id=task_id)
|
||||
proc = run(command, async=True)
|
||||
proc = run(command, run_async=True)
|
||||
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))
|
||||
now = datetime.utcnow()
|
||||
query_or = []
|
||||
|
||||
|
||||
for num, seconds in enumerate(MAILER_DEFERE_SECONDS):
|
||||
delta = timedelta(seconds=seconds)
|
||||
epoch = now-delta
|
||||
|
@ -198,10 +198,10 @@ def fire_pending_messages(settings, db):
|
|||
WHERE (mailer_message.state = 'QUEUED'
|
||||
OR (mailer_message.state = 'DEFERRED' AND (%s))) LIMIT 1""" % ' OR '.join(query_or)
|
||||
return bool(db.query(query))
|
||||
|
||||
|
||||
if has_pending_messages(settings, db):
|
||||
command = 'python3 -W ignore::DeprecationWarning {manage} sendpendingmessages'.format(manage=manage)
|
||||
proc = run(command, async=True)
|
||||
proc = run(command, run_async=True)
|
||||
yield proc
|
||||
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ SECRET_KEY = '{{ secret_key }}'
|
|||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = []
|
||||
|
||||
# Application definition
|
||||
|
||||
|
@ -65,6 +66,7 @@ INSTALLED_APPS = [
|
|||
'admin_tools.dashboard',
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
'django_filters',
|
||||
'passlib.ext.django',
|
||||
'django_countries',
|
||||
# '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'
|
||||
|
||||
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
|
||||
# https://docs.djangoproject.com/en/{{ docs_version }}/topics/i18n/
|
||||
|
||||
|
@ -168,22 +203,6 @@ LOCALE_PATHS = (
|
|||
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'
|
||||
|
||||
|
||||
|
@ -228,7 +247,7 @@ REST_FRAMEWORK = {
|
|||
'rest_framework.authentication.TokenAuthentication',
|
||||
),
|
||||
'DEFAULT_FILTER_BACKENDS': (
|
||||
('rest_framework.filters.DjangoFilterBackend',)
|
||||
('django_filters.rest_framework.DjangoFilterBackend',)
|
||||
),
|
||||
}
|
||||
|
||||
|
@ -242,7 +261,6 @@ PASSLIB_CONFIG = (
|
|||
"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"
|
||||
|
|
|
@ -4,7 +4,7 @@ from django.contrib import messages
|
|||
from django.contrib.admin import helpers
|
||||
from django.contrib.admin.utils import NestedObjects, quote
|
||||
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.shortcuts import redirect, render
|
||||
from django.template.response import TemplateResponse
|
||||
|
@ -53,14 +53,14 @@ def service_report(modeladmin, request, queryset):
|
|||
fields.append((model, name))
|
||||
fields = sorted(fields, key=lambda f: f[0]._meta.verbose_name_plural.lower())
|
||||
fields = [field for model, field in fields]
|
||||
|
||||
|
||||
for account in queryset.prefetch_related(*fields):
|
||||
items = []
|
||||
for field in fields:
|
||||
related_manager = getattr(account, field)
|
||||
items.append((related_manager.model._meta, related_manager.all()))
|
||||
accounts.append((account, items))
|
||||
|
||||
|
||||
context = {
|
||||
'accounts': accounts,
|
||||
'date': timezone.now().today()
|
||||
|
@ -71,21 +71,21 @@ def service_report(modeladmin, request, queryset):
|
|||
def delete_related_services(modeladmin, request, queryset):
|
||||
opts = modeladmin.model._meta
|
||||
app_label = opts.app_label
|
||||
|
||||
|
||||
using = router.db_for_write(modeladmin.model)
|
||||
collector = NestedObjects(using=using)
|
||||
collector.collect(queryset)
|
||||
registered_services = services.get()
|
||||
related_services = []
|
||||
to_delete = []
|
||||
|
||||
|
||||
admin_site = modeladmin.admin_site
|
||||
|
||||
|
||||
def format(obj, account=False):
|
||||
has_admin = obj.__class__ in admin_site._registry
|
||||
opts = obj._meta
|
||||
no_edit_link = '%s: %s' % (capfirst(opts.verbose_name), force_text(obj))
|
||||
|
||||
|
||||
if has_admin:
|
||||
try:
|
||||
admin_url = reverse(
|
||||
|
@ -95,7 +95,7 @@ def delete_related_services(modeladmin, request, queryset):
|
|||
except NoReverseMatch:
|
||||
# Change url doesn't exist -- don't display link to edit
|
||||
return no_edit_link
|
||||
|
||||
|
||||
# Display a link to the admin page.
|
||||
context = (capfirst(opts.verbose_name), admin_url, obj)
|
||||
if account:
|
||||
|
@ -106,7 +106,7 @@ def delete_related_services(modeladmin, request, queryset):
|
|||
# Don't display link to edit, because it either has no
|
||||
# admin or is edited inline.
|
||||
return no_edit_link
|
||||
|
||||
|
||||
def format_nested(objs, result):
|
||||
if isinstance(objs, list):
|
||||
current = []
|
||||
|
@ -115,7 +115,7 @@ def delete_related_services(modeladmin, request, queryset):
|
|||
result.append(current)
|
||||
else:
|
||||
result.append(format(objs))
|
||||
|
||||
|
||||
for nested in collector.nested():
|
||||
if isinstance(nested, list):
|
||||
# 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
|
||||
main_systemuser = nested.main_systemuser
|
||||
related_services.append(format(nested, account=True))
|
||||
|
||||
|
||||
# The user has already confirmed the deletion.
|
||||
# Do the deletion and return a None to display the change list view again.
|
||||
if request.POST.get('post'):
|
||||
|
@ -165,17 +165,17 @@ def delete_related_services(modeladmin, request, queryset):
|
|||
modeladmin.message_user(request, msg, messages.SUCCESS)
|
||||
# Return None to display the change list page again.
|
||||
return None
|
||||
|
||||
|
||||
if len(queryset) == 1:
|
||||
objects_name = force_text(opts.verbose_name)
|
||||
else:
|
||||
objects_name = force_text(opts.verbose_name_plural)
|
||||
|
||||
|
||||
model_count = {}
|
||||
for model, objs in collector.model_objs.items():
|
||||
count = 0
|
||||
# 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
|
||||
# Discount account
|
||||
elif model is not modeladmin.model and model in registered_services:
|
||||
|
@ -220,10 +220,10 @@ def disable_selected(modeladmin, request, queryset, disable=True):
|
|||
n)
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
user = request.user
|
||||
admin_site = modeladmin.admin_site
|
||||
|
||||
|
||||
def format(obj):
|
||||
has_admin = obj.__class__ in admin_site._registry
|
||||
opts = obj._meta
|
||||
|
@ -238,7 +238,7 @@ def disable_selected(modeladmin, request, queryset, disable=True):
|
|||
except NoReverseMatch:
|
||||
# Change url doesn't exist -- don't display link to edit
|
||||
return no_edit_link
|
||||
|
||||
|
||||
p = '%s.%s' % (opts.app_label, get_permission_codename('delete', opts))
|
||||
if not user.has_perm(p):
|
||||
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
|
||||
# admin or is edited inline.
|
||||
return no_edit_link
|
||||
|
||||
|
||||
display = []
|
||||
for account in queryset:
|
||||
current = []
|
||||
for related in account.get_services_to_disable():
|
||||
current.append(format(related))
|
||||
display.append([format(account), current])
|
||||
|
||||
|
||||
if len(queryset) == 1:
|
||||
objects_name = force_text(opts.verbose_name)
|
||||
else:
|
||||
objects_name = force_text(opts.verbose_name_plural)
|
||||
|
||||
|
||||
context = dict(
|
||||
admin_site.each_context(request),
|
||||
action_name='disable_selected' if disable else 'enable_selected',
|
||||
|
|
|
@ -8,7 +8,7 @@ from django.conf.urls import url
|
|||
from django.contrib import admin, messages
|
||||
from django.contrib.admin.utils import unquote
|
||||
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.templatetags.static import static
|
||||
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)
|
||||
ordering = ()
|
||||
|
||||
|
||||
main_systemuser_link = admin_link('main_systemuser')
|
||||
|
||||
|
||||
def formfield_for_dbfield(self, db_field, **kwargs):
|
||||
""" Make value input widget bigger """
|
||||
if db_field.name == 'comments':
|
||||
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 4})
|
||||
return super(AccountAdmin, self).formfield_for_dbfield(db_field, **kwargs)
|
||||
|
||||
|
||||
def render_change_form(self, request, context, add=False, change=False, form_url='', obj=None):
|
||||
if not add:
|
||||
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(
|
||||
request, context, add, change, form_url, obj)
|
||||
|
||||
|
||||
def get_fieldsets(self, request, obj=None):
|
||||
fieldsets = super(AccountAdmin, self).get_fieldsets(request, obj)
|
||||
if not obj:
|
||||
|
@ -106,7 +106,7 @@ class AccountAdmin(ChangePasswordAdminMixin, auth.UserAdmin, ExtendedModelAdmin)
|
|||
fieldsets = list(fieldsets)
|
||||
fieldsets.insert(1, (_("Related services"), {'fields': fields}))
|
||||
return fieldsets
|
||||
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not change:
|
||||
form.save_model(obj)
|
||||
|
@ -133,7 +133,7 @@ class AccountAdmin(ChangePasswordAdminMixin, auth.UserAdmin, ExtendedModelAdmin)
|
|||
if msg:
|
||||
messages.warning(request, mark_safe(msg % context))
|
||||
super(AccountAdmin, self).save_model(request, obj, form, change)
|
||||
|
||||
|
||||
def get_change_view_actions(self, obj=None):
|
||||
views = super().get_change_view_actions(obj=obj)
|
||||
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 != 'disable']
|
||||
return views
|
||||
|
||||
|
||||
def get_actions(self, request):
|
||||
actions = super().get_actions(request)
|
||||
if 'delete_selected' in actions:
|
||||
|
@ -157,7 +157,8 @@ class AccountListAdmin(AccountAdmin):
|
|||
list_display = ('select_account', 'username', 'type', 'username')
|
||||
actions = None
|
||||
change_list_template = 'admin/accounts/account/select_account_list.html'
|
||||
|
||||
|
||||
@mark_safe
|
||||
def select_account(self, instance):
|
||||
# TODO get query string from request.META['QUERY_STRING'] to preserve filters
|
||||
context = {
|
||||
|
@ -167,9 +168,8 @@ class AccountListAdmin(AccountAdmin):
|
|||
}
|
||||
return _('<a href="%(url)s">%(plus)s Add to %(name)s</a>') % context
|
||||
select_account.short_description = _("account")
|
||||
select_account.allow_tags = True
|
||||
select_account.admin_order_field = 'username'
|
||||
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
app_label = request.META['PATH_INFO'].split('/')[-5]
|
||||
model = request.META['PATH_INFO'].split('/')[-4]
|
||||
|
@ -206,7 +206,8 @@ class AccountAdminMixin(object):
|
|||
change_form_template = 'admin/accounts/account/change_form.html'
|
||||
account = None
|
||||
list_select_related = ('account',)
|
||||
|
||||
|
||||
@mark_safe
|
||||
def display_active(self, instance):
|
||||
if not instance.is_active:
|
||||
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 src="%s" alt="False">' % static('admin/img/icon-yes.svg')
|
||||
display_active.short_description = _("active")
|
||||
display_active.allow_tags = True
|
||||
display_active.admin_order_field = 'is_active'
|
||||
|
||||
|
||||
def account_link(self, instance):
|
||||
account = instance.account if instance.pk else self.account
|
||||
return admin_link()(account)
|
||||
account_link.short_description = _("account")
|
||||
account_link.allow_tags = True
|
||||
account_link.admin_order_field = 'account__username'
|
||||
|
||||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
""" Warns user when object's account is disabled """
|
||||
form = super(AccountAdminMixin, self).get_form(request, obj, **kwargs)
|
||||
|
@ -247,7 +246,7 @@ class AccountAdminMixin(object):
|
|||
# Not available in POST
|
||||
form.initial_account = self.get_changeform_initial_data(request).get('account')
|
||||
return form
|
||||
|
||||
|
||||
def get_fields(self, request, obj=None):
|
||||
""" remove account or account_link depending on the case """
|
||||
fields = super(AccountAdminMixin, self).get_fields(request, obj)
|
||||
|
@ -263,13 +262,13 @@ class AccountAdminMixin(object):
|
|||
except ValueError:
|
||||
pass
|
||||
return fields
|
||||
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
""" provide account for filter_by_account_fields """
|
||||
if obj:
|
||||
self.account = obj.account
|
||||
return super(AccountAdminMixin, self).get_readonly_fields(request, obj)
|
||||
|
||||
|
||||
def formfield_for_dbfield(self, db_field, **kwargs):
|
||||
""" Filter by account """
|
||||
formfield = super(AccountAdminMixin, self).formfield_for_dbfield(db_field, **kwargs)
|
||||
|
@ -277,14 +276,14 @@ class AccountAdminMixin(object):
|
|||
if self.account:
|
||||
# Hack widget render in order to append ?account=id to the add url
|
||||
old_render = formfield.widget.render
|
||||
|
||||
|
||||
def render(*args, **kwargs):
|
||||
output = old_render(*args, **kwargs)
|
||||
output = output.replace('/add/"', '/add/?account=%s"' % self.account.pk)
|
||||
with_qargs = r'/add/?\1&account=%s"' % self.account.pk
|
||||
output = re.sub(r'/add/\?([^".]*)"', with_qargs, output)
|
||||
return mark_safe(output)
|
||||
|
||||
|
||||
formfield.widget.render = render
|
||||
# Filter related object by account
|
||||
formfield.queryset = formfield.queryset.filter(account=self.account)
|
||||
|
@ -302,21 +301,21 @@ class AccountAdminMixin(object):
|
|||
formfield.initial = 1
|
||||
formfield.queryset = formfield.queryset.order_by('username')
|
||||
return formfield
|
||||
|
||||
|
||||
def get_formset(self, request, obj=None, **kwargs):
|
||||
""" provides form.account for convinience """
|
||||
formset = super(AccountAdminMixin, self).get_formset(request, obj, **kwargs)
|
||||
formset.form.account = self.account
|
||||
formset.account = self.account
|
||||
return formset
|
||||
|
||||
|
||||
def get_account_from_preserve_filters(self, request):
|
||||
preserved_filters = self.get_preserved_filters(request)
|
||||
preserved_filters = dict(parse_qsl(preserved_filters))
|
||||
cl_filters = preserved_filters.get('_changelist_filters')
|
||||
if cl_filters:
|
||||
return dict(parse_qsl(cl_filters)).get('account')
|
||||
|
||||
|
||||
def changeform_view(self, request, object_id=None, form_url='', extra_context=None):
|
||||
account_id = self.get_account_from_preserve_filters(request)
|
||||
if not object_id:
|
||||
|
@ -331,7 +330,7 @@ class AccountAdminMixin(object):
|
|||
context.update(extra_context or {})
|
||||
return super(AccountAdminMixin, self).changeform_view(
|
||||
request, object_id, form_url=form_url, extra_context=context)
|
||||
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
account_id = request.GET.get('account')
|
||||
context = {}
|
||||
|
@ -367,7 +366,7 @@ class SelectAccountAdminMixin(AccountAdminMixin):
|
|||
account = Account.objects.get(pk=request.GET['account'])
|
||||
[setattr(inline, 'account', account) for inline in inlines]
|
||||
return inlines
|
||||
|
||||
|
||||
def get_urls(self):
|
||||
""" Hooks select account url """
|
||||
urls = super(AccountAdminMixin, self).get_urls()
|
||||
|
@ -381,7 +380,7 @@ class SelectAccountAdminMixin(AccountAdminMixin):
|
|||
name='%s_%s_select_account' % info),
|
||||
]
|
||||
return select_urls + urls
|
||||
|
||||
|
||||
def add_view(self, request, form_url='', extra_context=None):
|
||||
""" Redirects to select account view if required """
|
||||
if request.user.is_superuser:
|
||||
|
@ -406,7 +405,7 @@ class SelectAccountAdminMixin(AccountAdminMixin):
|
|||
return super(AccountAdminMixin, self).add_view(
|
||||
request, form_url=form_url, extra_context=context)
|
||||
return HttpResponseRedirect('./select-account/?%s' % request.META['QUERY_STRING'])
|
||||
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""
|
||||
Given a model instance save it to the database.
|
||||
|
|
|
@ -34,7 +34,7 @@ def create_account_creation_form():
|
|||
fields[field_name] = forms.BooleanField(
|
||||
initial=True, required=False, label=label, help_text=help_text)
|
||||
create_related.append((model, key, kwargs, help_text))
|
||||
|
||||
|
||||
def clean(self, create_related=create_related):
|
||||
""" unique usernames between accounts and system users """
|
||||
cleaned_data = UserCreationForm.clean(self)
|
||||
|
@ -47,7 +47,7 @@ def create_account_creation_form():
|
|||
# Previous validation error
|
||||
return
|
||||
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():
|
||||
errors['username'] = _("A system user with this name already exists.")
|
||||
for model, key, related_kwargs, __ in create_related:
|
||||
|
@ -62,11 +62,11 @@ def create_account_creation_form():
|
|||
params={'type': verbose_name})
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
|
||||
def save_model(self, account):
|
||||
enable_systemuser=self.cleaned_data['enable_systemuser']
|
||||
account.save(active_systemuser=enable_systemuser)
|
||||
|
||||
|
||||
def save_related(self, account):
|
||||
for model, key, related_kwargs, __ in settings.ACCOUNTS_CREATE_RELATED:
|
||||
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()
|
||||
}
|
||||
model.objects.create(account=account, **kwargs)
|
||||
|
||||
|
||||
fields.update({
|
||||
'create_related_fields': list(fields.keys()),
|
||||
'clean': clean,
|
||||
'save_model': save_model,
|
||||
'save_related': save_related,
|
||||
})
|
||||
|
||||
|
||||
return type('AccountCreationForm', (UserCreationForm,), fields)
|
||||
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ from __future__ import unicode_literals
|
|||
|
||||
from django.db import models, migrations
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
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_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)),
|
||||
('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={
|
||||
'abstract': False,
|
||||
|
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,38 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.5 on 2017-05-28 18:05
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import orchestra.contrib.accounts.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
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'),
|
||||
),
|
||||
]
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -9,7 +9,7 @@ from django.utils.translation import ugettext_lazy as _
|
|||
|
||||
#from orchestra.contrib.orchestration.middlewares import OperationsMiddleware
|
||||
#from orchestra.contrib.orchestration import Operation
|
||||
from orchestra.core import services
|
||||
from orchestra import core
|
||||
from orchestra.models.utils import has_db_field
|
||||
from orchestra.utils.mail import send_email_template
|
||||
|
||||
|
@ -29,7 +29,7 @@ class Account(auth.AbstractBaseUser):
|
|||
validators.RegexValidator(r'^[\w.-]+$', _("Enter a valid username."), 'invalid')
|
||||
])
|
||||
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)
|
||||
full_name = models.CharField(_("full name"), max_length=256)
|
||||
email = models.EmailField(_('email address'), help_text=_("Used for password recovery"))
|
||||
|
@ -46,23 +46,28 @@ class Account(auth.AbstractBaseUser):
|
|||
help_text=_("Designates whether this account should be treated as active. "
|
||||
"Unselect this instead of deleting accounts."))
|
||||
date_joined = models.DateTimeField(_("date joined"), default=timezone.now)
|
||||
|
||||
|
||||
objects = AccountManager()
|
||||
|
||||
|
||||
USERNAME_FIELD = 'username'
|
||||
REQUIRED_FIELDS = ['email']
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# ignore `is_staff` kwarg because is handled with `is_superuser`
|
||||
kwargs.pop('is_staff', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.username
|
||||
|
||||
|
||||
@property
|
||||
def is_staff(self):
|
||||
return self.is_superuser
|
||||
|
||||
|
||||
def save(self, active_systemuser=False, *args, **kwargs):
|
||||
created = not self.pk
|
||||
if not created:
|
||||
|
@ -75,21 +80,21 @@ class Account(auth.AbstractBaseUser):
|
|||
self.save(update_fields=('main_systemuser',))
|
||||
elif was_active != self.is_active:
|
||||
self.notify_related()
|
||||
|
||||
|
||||
def clean(self):
|
||||
self.short_name = self.short_name.strip()
|
||||
self.full_name = self.full_name.strip()
|
||||
|
||||
|
||||
def disable(self):
|
||||
self.is_active = False
|
||||
self.save(update_fields=('is_active',))
|
||||
self.notify_related()
|
||||
|
||||
|
||||
def enable(self):
|
||||
self.is_active = True
|
||||
self.save(update_fields=('is_active',))
|
||||
self.notify_related()
|
||||
|
||||
|
||||
def get_services_to_disable(self):
|
||||
related_fields = [
|
||||
f for f in self._meta.get_fields()
|
||||
|
@ -98,23 +103,23 @@ class Account(auth.AbstractBaseUser):
|
|||
]
|
||||
for rel in related_fields:
|
||||
source = getattr(rel, 'related_model', rel.model)
|
||||
if source in services and hasattr(source, 'active'):
|
||||
if source in core.services and hasattr(source, 'active'):
|
||||
for obj in getattr(self, rel.get_accessor_name()).all():
|
||||
yield obj
|
||||
|
||||
|
||||
def notify_related(self):
|
||||
""" Trigger save() on related objects that depend on this account """
|
||||
for obj in self.get_services_to_disable():
|
||||
signals.pre_save.send(sender=type(obj), instance=obj)
|
||||
signals.post_save.send(sender=type(obj), instance=obj)
|
||||
# OperationsMiddleware.collect(Operation.SAVE, instance=obj, update_fields=())
|
||||
|
||||
|
||||
def get_contacts_emails(self, usages=None):
|
||||
contacts = self.contacts.all()
|
||||
if usages is not None:
|
||||
contactes = contacts.filter(email_usages=usages)
|
||||
return contacts.values_list('email', flat=True)
|
||||
|
||||
|
||||
def send_email(self, template, context, email_from=None, usages=None, attachments=[], html=None):
|
||||
contacts = self.contacts.filter(email_usages=usages)
|
||||
email_to = self.get_contacts_emails(usages)
|
||||
|
@ -126,14 +131,14 @@ class Account(auth.AbstractBaseUser):
|
|||
with translation.override(self.language):
|
||||
send_email_template(template, extra_context, email_to, email_from=email_from,
|
||||
html=html, attachments=attachments)
|
||||
|
||||
|
||||
def get_full_name(self):
|
||||
return self.full_name or self.short_name or self.username
|
||||
|
||||
|
||||
def get_short_name(self):
|
||||
""" Returns the short name for the user """
|
||||
return self.short_name or self.username or self.full_name
|
||||
|
||||
|
||||
def has_perm(self, perm, obj=None):
|
||||
"""
|
||||
Returns True if the user has the specified permission. This method
|
||||
|
@ -141,13 +146,26 @@ class Account(auth.AbstractBaseUser):
|
|||
backend returns True. Thus, a user who has permission from a single
|
||||
auth backend is assumed to have permission in general. If an object is
|
||||
provided, permissions for this specific object are checked.
|
||||
applabel.action_modelname
|
||||
"""
|
||||
if not self.is_active:
|
||||
return False
|
||||
# Active superusers have all permissions.
|
||||
if self.is_active and self.is_superuser:
|
||||
if self.is_superuser:
|
||||
return True
|
||||
# Otherwise we need to check the backends.
|
||||
return auth._user_has_perm(self, perm, obj)
|
||||
|
||||
app, action_model = perm.split('.')
|
||||
action, model = action_model.split('_', 1)
|
||||
service_apps = set(model._meta.app_label for model in core.services.get().keys())
|
||||
accounting_apps = set(model._meta.app_label for model in core.accounts.get().keys())
|
||||
import inspect
|
||||
if ((app in service_apps or (action == 'view' and app in accounting_apps))):
|
||||
# class-level permissions
|
||||
if inspect.isclass(obj):
|
||||
return True
|
||||
elif obj and getattr(obj, 'account', None) == self:
|
||||
return True
|
||||
|
||||
|
||||
def has_perms(self, perm_list, obj=None):
|
||||
"""
|
||||
Returns True if the user has each of the specified permissions. If
|
||||
|
@ -158,7 +176,7 @@ class Account(auth.AbstractBaseUser):
|
|||
if not self.has_perm(perm, obj):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def has_module_perms(self, app_label):
|
||||
"""
|
||||
Returns True if the user has any permissions in the given app label.
|
||||
|
@ -167,8 +185,7 @@ class Account(auth.AbstractBaseUser):
|
|||
# Active superusers have all permissions.
|
||||
if self.is_active and self.is_superuser:
|
||||
return True
|
||||
return auth._user_has_module_perms(self, app_label)
|
||||
|
||||
|
||||
def get_related_passwords(self, db_field=False):
|
||||
related = [
|
||||
self.main_systemuser,
|
||||
|
|
|
@ -7,7 +7,7 @@ class AccountSerializer(serializers.HyperlinkedModelSerializer):
|
|||
class Meta:
|
||||
model = Account
|
||||
fields = (
|
||||
'url', 'id', 'username', 'type', 'language', 'short_name', 'full_name', 'date_joined',
|
||||
'url', 'id', 'username', 'type', 'language', 'short_name', 'full_name', 'date_joined', 'last_login',
|
||||
'is_active'
|
||||
)
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ from datetime import date
|
|||
from django.contrib import messages
|
||||
from django.contrib.admin import helpers
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.urls import reverse
|
||||
from django.db import transaction
|
||||
from django.forms.models import modelformset_factory
|
||||
from django.http import HttpResponse, HttpResponseRedirect
|
||||
|
@ -179,7 +179,7 @@ def undo_billing(modeladmin, request, queryset):
|
|||
group[line.order].append(line)
|
||||
except KeyError:
|
||||
group[line.order] = [line]
|
||||
|
||||
|
||||
# Validate
|
||||
for order, lines in group.items():
|
||||
prev = None
|
||||
|
@ -211,7 +211,7 @@ def undo_billing(modeladmin, request, queryset):
|
|||
messages.error(request, "Order does not have lines!.")
|
||||
order.billed_until = billed_until
|
||||
order.billed_on = billed_on
|
||||
|
||||
|
||||
# Commit changes
|
||||
norders, nlines = 0, 0
|
||||
for order, lines in group.items():
|
||||
|
@ -221,7 +221,7 @@ def undo_billing(modeladmin, request, queryset):
|
|||
# TODO update order history undo billing
|
||||
order.save(update_fields=('billed_until', 'billed_on'))
|
||||
norders += 1
|
||||
|
||||
|
||||
messages.success(request, _("%(norders)s orders and %(nlines)s lines undoed.") % {
|
||||
'nlines': nlines,
|
||||
'norders': norders
|
||||
|
|
|
@ -2,11 +2,12 @@ from django import forms
|
|||
from django.conf.urls import url
|
||||
from django.contrib import admin, messages
|
||||
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.models import F, Sum, Prefetch
|
||||
from django.db.models.functions import Coalesce
|
||||
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_lazy as _
|
||||
from django.shortcuts import redirect
|
||||
|
@ -15,12 +16,12 @@ from orchestra.admin import ExtendedModelAdmin
|
|||
from orchestra.admin.utils import admin_date, insertattr, admin_link, change_url
|
||||
from orchestra.contrib.accounts.actions import list_accounts
|
||||
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 .filters import (BillTypeListFilter, HasBillContactListFilter, TotalListFilter,
|
||||
PaymentStateListFilter, AmendedListFilter)
|
||||
from .models import (Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, ProForma, BillLine,
|
||||
from .models import (Bill, Invoice, AmendmentInvoice, AbonoInvoice, Fee, AmendmentFee, ProForma, BillLine,
|
||||
BillSubline, BillContact)
|
||||
|
||||
|
||||
|
@ -39,18 +40,18 @@ PAYMENT_STATE_COLORS = {
|
|||
class BillSublineInline(admin.TabularInline):
|
||||
model = BillSubline
|
||||
fields = ('description', 'total', 'type')
|
||||
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
fields = super().get_readonly_fields(request, obj)
|
||||
if obj and not obj.bill.is_open:
|
||||
return self.get_fields(request)
|
||||
return fields
|
||||
|
||||
|
||||
def get_max_num(self, request, obj=None):
|
||||
if obj and not obj.bill.is_open:
|
||||
return 0
|
||||
return super().get_max_num(request, obj)
|
||||
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
if obj and not obj.bill.is_open:
|
||||
return False
|
||||
|
@ -64,9 +65,10 @@ class BillLineInline(admin.TabularInline):
|
|||
'subtotal', 'display_total',
|
||||
)
|
||||
readonly_fields = ('display_total', 'order_link')
|
||||
|
||||
|
||||
order_link = admin_link('order', display='pk')
|
||||
|
||||
|
||||
@mark_safe
|
||||
def display_total(self, line):
|
||||
if line.pk:
|
||||
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">%s</a>' % (url, total)
|
||||
display_total.short_description = _("Total")
|
||||
display_total.allow_tags = True
|
||||
|
||||
|
||||
def formfield_for_dbfield(self, db_field, **kwargs):
|
||||
""" Make value input widget bigger """
|
||||
if db_field.name == 'description':
|
||||
|
@ -87,7 +88,7 @@ class BillLineInline(admin.TabularInline):
|
|||
elif db_field.name not in ('start_on', 'end_on'):
|
||||
kwargs['widget'] = forms.TextInput(attrs={'size':'6'})
|
||||
return super().formfield_for_dbfield(db_field, **kwargs)
|
||||
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
return qs.prefetch_related('sublines').select_related('order')
|
||||
|
@ -96,36 +97,35 @@ class BillLineInline(admin.TabularInline):
|
|||
class ClosedBillLineInline(BillLineInline):
|
||||
# TODO reimplement as nested inlines when upstream
|
||||
# https://code.djangoproject.com/ticket/9025
|
||||
|
||||
|
||||
fields = (
|
||||
'display_description', 'order_link', 'start_on', 'end_on', 'rate', 'quantity', 'tax',
|
||||
'display_subtotal', 'display_total'
|
||||
)
|
||||
readonly_fields = fields
|
||||
can_delete = False
|
||||
|
||||
|
||||
@mark_safe
|
||||
def display_description(self, line):
|
||||
descriptions = [line.description]
|
||||
for subline in line.sublines.all():
|
||||
descriptions.append(' '*4+subline.description)
|
||||
descriptions.append(' ' * 4 + subline.description)
|
||||
return '<br>'.join(descriptions)
|
||||
display_description.short_description = _("Description")
|
||||
display_description.allow_tags = True
|
||||
|
||||
|
||||
@mark_safe
|
||||
def display_subtotal(self, line):
|
||||
subtotals = [' ' + str(line.subtotal)]
|
||||
for subline in line.sublines.all():
|
||||
subtotals.append(str(subline.total))
|
||||
return '<br>'.join(subtotals)
|
||||
display_subtotal.short_description = _("Subtotal")
|
||||
display_subtotal.allow_tags = True
|
||||
|
||||
|
||||
def display_total(self, line):
|
||||
if line.pk:
|
||||
return line.compute_total()
|
||||
display_total.short_description = _("Total")
|
||||
display_total.allow_tags = True
|
||||
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
|
@ -158,28 +158,28 @@ class BillLineAdmin(admin.ModelAdmin):
|
|||
list_select_related = ('bill', 'bill__account')
|
||||
search_fields = ('description', 'bill__number')
|
||||
inlines = (BillSublineInline,)
|
||||
|
||||
|
||||
account_link = admin_link('bill__account')
|
||||
bill_link = admin_link('bill')
|
||||
order_link = admin_link('order')
|
||||
amended_line_link = admin_link('amended_line')
|
||||
|
||||
|
||||
def display_is_open(self, instance):
|
||||
return instance.bill.is_open
|
||||
display_is_open.short_description = _("Is open")
|
||||
display_is_open.boolean = True
|
||||
|
||||
|
||||
def display_sublinetotal(self, instance):
|
||||
total = instance.subline_total
|
||||
return total if total is not None else '---'
|
||||
display_sublinetotal.short_description = _("Sublines")
|
||||
display_sublinetotal.admin_order_field = 'subline_total'
|
||||
|
||||
|
||||
def display_total(self, instance):
|
||||
return round(instance.computed_total or 0, 2)
|
||||
display_total.short_description = _("Total")
|
||||
display_total.admin_order_field = 'computed_total'
|
||||
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
fields = super().get_readonly_fields(request, obj)
|
||||
if obj and not obj.bill.is_open:
|
||||
|
@ -188,7 +188,7 @@ class BillLineAdmin(admin.ModelAdmin):
|
|||
'subtotal', 'order_billed_on', 'order_billed_until'
|
||||
]
|
||||
return fields
|
||||
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
qs = qs.annotate(
|
||||
|
@ -196,7 +196,7 @@ class BillLineAdmin(admin.ModelAdmin):
|
|||
computed_total=(F('subtotal') + Sum(Coalesce('sublines__total', 0))) * (1+F('tax')/100),
|
||||
)
|
||||
return qs
|
||||
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
if obj and not obj.bill.is_open:
|
||||
return False
|
||||
|
@ -209,7 +209,7 @@ class BillLineManagerAdmin(BillLineAdmin):
|
|||
if self.bill_ids:
|
||||
return qset.filter(bill_id__in=self.bill_ids)
|
||||
return qset
|
||||
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
GET_copy = request.GET.copy()
|
||||
bill_ids = GET_copy.pop('ids', None)
|
||||
|
@ -242,6 +242,7 @@ class BillLineManagerAdmin(BillLineAdmin):
|
|||
|
||||
|
||||
class BillAdminMixin(AccountAdminMixin):
|
||||
@mark_safe
|
||||
def display_total_with_subtotals(self, bill):
|
||||
if bill.pk:
|
||||
currency = settings.BILLS_CURRENCY.lower()
|
||||
|
@ -251,10 +252,10 @@ class BillAdminMixin(AccountAdminMixin):
|
|||
subtotals.append(_("Taxes %s%% VAT %s &%s;") % (tax, subtotal[1], currency))
|
||||
subtotals = '\n'.join(subtotals)
|
||||
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.admin_order_field = 'approx_total'
|
||||
|
||||
@mark_safe
|
||||
def display_payment_state(self, bill):
|
||||
if bill.pk:
|
||||
t_opts = bill.transactions.model._meta
|
||||
|
@ -276,7 +277,6 @@ class BillAdminMixin(AccountAdminMixin):
|
|||
color = PAYMENT_STATE_COLORS.get(bill.payment_state, 'grey')
|
||||
return '<a href="{url}" style="color:{color}" title="{title}">{name}</a>'.format(
|
||||
url=url, color=color, name=state, title=title)
|
||||
display_payment_state.allow_tags = True
|
||||
display_payment_state.short_description = _("Payment")
|
||||
|
||||
def get_queryset(self, request):
|
||||
|
@ -304,9 +304,9 @@ class AmendInline(BillAdminMixin, admin.TabularInline):
|
|||
verbose_name_plural = _("Amends")
|
||||
can_delete = False
|
||||
extra = 0
|
||||
|
||||
|
||||
self_link = admin_link('__str__')
|
||||
|
||||
|
||||
def has_add_permission(self, *args, **kwargs):
|
||||
return False
|
||||
|
||||
|
@ -354,12 +354,12 @@ class BillAdmin(BillAdminMixin, ExtendedModelAdmin):
|
|||
'closed_on_display', 'updated_on_display', 'display_total_with_subtotals',
|
||||
)
|
||||
date_hierarchy = 'closed_on'
|
||||
|
||||
|
||||
created_on_display = admin_date('created_on', short_description=_("Created"))
|
||||
closed_on_display = admin_date('closed_on', short_description=_("Closed"))
|
||||
updated_on_display = admin_date('updated_on', short_description=_("Updated"))
|
||||
amend_of_link = admin_link('amend_of')
|
||||
|
||||
|
||||
# def amend_links(self, bill):
|
||||
# links = []
|
||||
# for amend in bill.amends.all():
|
||||
|
@ -368,27 +368,25 @@ class BillAdmin(BillAdminMixin, ExtendedModelAdmin):
|
|||
# return '<br>'.join(links)
|
||||
# amend_links.short_description = _("Amends")
|
||||
# amend_links.allow_tags = True
|
||||
|
||||
|
||||
def num_lines(self, bill):
|
||||
return bill.lines__count
|
||||
num_lines.admin_order_field = 'lines__count'
|
||||
num_lines.short_description = _("lines")
|
||||
|
||||
|
||||
def display_total(self, bill):
|
||||
currency = settings.BILLS_CURRENCY.lower()
|
||||
return '%s &%s;' % (bill.compute_total(), currency)
|
||||
display_total.allow_tags = True
|
||||
return format_html('{} &{};', bill.compute_total(), currency)
|
||||
display_total.short_description = _("total")
|
||||
display_total.admin_order_field = 'approx_total'
|
||||
|
||||
|
||||
def type_link(self, bill):
|
||||
bill_type = bill.type.lower()
|
||||
url = reverse('admin:bills_%s_changelist' % bill_type)
|
||||
return '<a href="%s">%s</a>' % (url, bill.get_type_display())
|
||||
type_link.allow_tags = True
|
||||
return format_html('<a href="{}">{}</a>', url, bill.get_type_display())
|
||||
type_link.short_description = _("type")
|
||||
type_link.admin_order_field = 'type'
|
||||
|
||||
|
||||
def get_urls(self):
|
||||
""" Hook bill lines management URLs on bill admin """
|
||||
urls = super().get_urls()
|
||||
|
@ -399,13 +397,13 @@ class BillAdmin(BillAdminMixin, ExtendedModelAdmin):
|
|||
name='bills_bill_manage_lines'),
|
||||
]
|
||||
return extra_urls + urls
|
||||
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
fields = super().get_readonly_fields(request, obj)
|
||||
if obj and not obj.is_open:
|
||||
fields += self.add_fields
|
||||
return fields
|
||||
|
||||
|
||||
def get_fieldsets(self, request, obj=None):
|
||||
fieldsets = super().get_fieldsets(request, obj)
|
||||
if obj:
|
||||
|
@ -418,7 +416,7 @@ class BillAdmin(BillAdminMixin, ExtendedModelAdmin):
|
|||
if obj.is_open:
|
||||
fieldsets = fieldsets[0:-1]
|
||||
return fieldsets
|
||||
|
||||
|
||||
def get_change_view_actions(self, obj=None):
|
||||
actions = super().get_change_view_actions(obj)
|
||||
exclude = []
|
||||
|
@ -428,7 +426,7 @@ class BillAdmin(BillAdminMixin, ExtendedModelAdmin):
|
|||
if obj.type not in obj.AMEND_MAP:
|
||||
exclude += ['amend_bills']
|
||||
return [action for action in actions if action.__name__ not in exclude]
|
||||
|
||||
|
||||
def get_inline_instances(self, request, obj=None):
|
||||
cls = type(self)
|
||||
if obj and not obj.is_open:
|
||||
|
@ -439,7 +437,7 @@ class BillAdmin(BillAdminMixin, ExtendedModelAdmin):
|
|||
else:
|
||||
cls.inlines = [BillLineInline]
|
||||
return super().get_inline_instances(request, obj)
|
||||
|
||||
|
||||
def formfield_for_dbfield(self, db_field, **kwargs):
|
||||
""" Make value input widget bigger """
|
||||
if db_field.name == 'comments':
|
||||
|
@ -450,7 +448,7 @@ class BillAdmin(BillAdminMixin, ExtendedModelAdmin):
|
|||
if db_field.name == 'amend_of':
|
||||
formfield.queryset = formfield.queryset.filter(is_open=False)
|
||||
return formfield
|
||||
|
||||
|
||||
def change_view(self, request, object_id, **kwargs):
|
||||
# TODO raise404, here and everywhere
|
||||
bill = self.get_object(request, unquote(object_id))
|
||||
|
@ -461,6 +459,7 @@ class BillAdmin(BillAdminMixin, ExtendedModelAdmin):
|
|||
admin.site.register(Bill, BillAdmin)
|
||||
admin.site.register(Invoice, BillAdmin)
|
||||
admin.site.register(AmendmentInvoice, BillAdmin)
|
||||
admin.site.register(AbonoInvoice, BillAdmin)
|
||||
admin.site.register(Fee, BillAdmin)
|
||||
admin.site.register(AmendmentFee, BillAdmin)
|
||||
admin.site.register(ProForma, BillAdmin)
|
||||
|
@ -470,7 +469,7 @@ admin.site.register(BillLine, BillLineAdmin)
|
|||
class BillContactInline(admin.StackedInline):
|
||||
model = BillContact
|
||||
fields = ('name', 'address', ('city', 'zipcode'), 'country', 'vat')
|
||||
|
||||
|
||||
def formfield_for_dbfield(self, db_field, **kwargs):
|
||||
""" Make value input widget bigger """
|
||||
if db_field.name == 'name':
|
||||
|
@ -478,7 +477,7 @@ class BillContactInline(admin.StackedInline):
|
|||
if db_field.name == 'address':
|
||||
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 2})
|
||||
if db_field.name == 'email_usage':
|
||||
kwargs['widget'] = paddingCheckboxSelectMultiple(45)
|
||||
kwargs['widget'] = PaddingCheckboxSelectMultiple(45)
|
||||
return super().formfield_for_dbfield(db_field, **kwargs)
|
||||
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from django.http import HttpResponse
|
||||
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.contrib.accounts.api import AccountApiMixin
|
||||
|
@ -14,8 +14,8 @@ from .serializers import BillSerializer
|
|||
class BillViewSet(LogApiMixin, AccountApiMixin, viewsets.ModelViewSet):
|
||||
queryset = Bill.objects.all()
|
||||
serializer_class = BillSerializer
|
||||
|
||||
@detail_route(methods=['get'])
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def document(self, request, pk):
|
||||
bill = self.get_object()
|
||||
content_type = request.META.get('HTTP_ACCEPT')
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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.utils.safestring import mark_safe
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
@ -11,11 +11,11 @@ class BillTypeListFilter(SimpleListFilter):
|
|||
""" Filter tickets by created_by according to request.user """
|
||||
title = 'Type'
|
||||
parameter_name = ''
|
||||
|
||||
|
||||
def __init__(self, request, *args, **kwargs):
|
||||
super(BillTypeListFilter, self).__init__(request, *args, **kwargs)
|
||||
self.request = request
|
||||
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (
|
||||
('bill', _("All")),
|
||||
|
@ -25,13 +25,13 @@ class BillTypeListFilter(SimpleListFilter):
|
|||
('amendmentfee', _("Amendment fee")),
|
||||
('amendmentinvoice', _("Amendment invoice")),
|
||||
)
|
||||
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
return queryset
|
||||
|
||||
|
||||
def value(self):
|
||||
return self.request.path.split('/')[-2]
|
||||
|
||||
|
||||
def choices(self, cl):
|
||||
query = self.request.GET.urlencode()
|
||||
for lookup, title in self.lookup_choices:
|
||||
|
@ -45,7 +45,7 @@ class BillTypeListFilter(SimpleListFilter):
|
|||
class TotalListFilter(SimpleListFilter):
|
||||
title = _("total")
|
||||
parameter_name = 'total'
|
||||
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (
|
||||
('gt', mark_safe("total > 0")),
|
||||
|
@ -53,7 +53,7 @@ class TotalListFilter(SimpleListFilter):
|
|||
('eq', "total = 0"),
|
||||
('ne', mark_safe("total ≠ 0")),
|
||||
)
|
||||
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if self.value() == 'gt':
|
||||
return queryset.filter(approx_total__gt=0)
|
||||
|
@ -70,13 +70,13 @@ class HasBillContactListFilter(SimpleListFilter):
|
|||
""" Filter Nodes by group according to request.user """
|
||||
title = _("has bill contact")
|
||||
parameter_name = 'bill'
|
||||
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (
|
||||
('True', _("Yes")),
|
||||
('False', _("No")),
|
||||
)
|
||||
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if self.value() == 'True':
|
||||
return queryset.filter(billcontact__isnull=False)
|
||||
|
@ -87,7 +87,7 @@ class HasBillContactListFilter(SimpleListFilter):
|
|||
class PaymentStateListFilter(SimpleListFilter):
|
||||
title = _("payment state")
|
||||
parameter_name = 'payment_state'
|
||||
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (
|
||||
('OPEN', _("Open")),
|
||||
|
@ -95,7 +95,7 @@ class PaymentStateListFilter(SimpleListFilter):
|
|||
('PENDING', _("Pending")),
|
||||
('BAD_DEBT', _("Bad debt")),
|
||||
)
|
||||
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
# FIXME use queryset.computed_total instead of approx_total, bills.admin.BillAdmin.get_queryset
|
||||
Transaction = queryset.model.transactions.field.remote_field.related_model
|
||||
|
@ -137,7 +137,7 @@ class PaymentStateListFilter(SimpleListFilter):
|
|||
class AmendedListFilter(SimpleListFilter):
|
||||
title = _("amended")
|
||||
parameter_name = 'amended'
|
||||
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (
|
||||
('3', _("Closed amends")),
|
||||
|
@ -145,7 +145,7 @@ class AmendedListFilter(SimpleListFilter):
|
|||
('1', _("Any amends")),
|
||||
('0', _("No amends")),
|
||||
)
|
||||
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if self.value() is None:
|
||||
return queryset
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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.html import format_html
|
||||
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)
|
||||
send(request, mark_safe(message))
|
||||
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'):
|
||||
account = force_text(main)
|
||||
url = reverse('admin:accounts_account_change', args=(main.id,))
|
||||
|
|
Binary file not shown.
|
@ -8,7 +8,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2015-10-29 10:51+0000\n"
|
||||
"POT-Creation-Date: 2019-12-20 11:56+0100\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
@ -18,33 +18,33 @@ msgstr ""
|
|||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#: actions.py:31
|
||||
#: actions.py:33
|
||||
msgid "View"
|
||||
msgstr "Vista"
|
||||
|
||||
#: actions.py:42
|
||||
#: actions.py:45
|
||||
msgid "Selected bills should be in open state"
|
||||
msgstr "Les factures seleccionades han d'estar en estat obert"
|
||||
|
||||
#: actions.py:57
|
||||
#: actions.py:60
|
||||
msgid "Selected bills have been closed"
|
||||
msgstr "Les factures seleccionades han estat tancades"
|
||||
|
||||
#: actions.py:70
|
||||
#: actions.py:73
|
||||
#, python-format
|
||||
msgid "<a href=\"%(url)s\">One related transaction</a> has been created"
|
||||
msgstr "S'ha creat una <a href=\"%(url)s\">transacció</a>"
|
||||
|
||||
#: actions.py:71
|
||||
#: actions.py:74
|
||||
#, python-format
|
||||
msgid "<a href=\"%(url)s\">%(num)i related transactions</a> have been created"
|
||||
msgstr "S'han creat les <a href=\"%(url)s\">%(num)i següents transaccions</a>"
|
||||
|
||||
#: actions.py:77
|
||||
#: actions.py:80
|
||||
msgid "Are you sure about closing the following bills?"
|
||||
msgstr "Estàs a punt de tancar les següents factures, estàs segur?"
|
||||
|
||||
#: actions.py:78
|
||||
#: actions.py:81
|
||||
msgid ""
|
||||
"Once a bill is closed it can not be further modified.</p><p>Please select a "
|
||||
"payment source for the selected bills"
|
||||
|
@ -52,174 +52,205 @@ msgstr ""
|
|||
"Una vegada la factura estigui tancada no podrà ser modificada.</p><p>Si us "
|
||||
"plau selecciona un mètode de pagament per les factures seleccionades"
|
||||
|
||||
#: actions.py:91
|
||||
#: actions.py:97
|
||||
msgid "Close"
|
||||
msgstr "Tanca"
|
||||
|
||||
#: actions.py:109
|
||||
#: actions.py:115
|
||||
msgid "One bill has been sent."
|
||||
msgstr "S'ha creat una factura"
|
||||
|
||||
#: actions.py:110
|
||||
#: actions.py:116
|
||||
#, python-format
|
||||
msgid "%i bills have been sent."
|
||||
msgstr "S'han enviat %i factures."
|
||||
|
||||
#: actions.py:117
|
||||
#: actions.py:123
|
||||
msgid "Resend"
|
||||
msgstr "Reenviat"
|
||||
|
||||
#: actions.py:137
|
||||
#: actions.py:146
|
||||
msgid "Download"
|
||||
msgstr "Descarrega"
|
||||
|
||||
#: actions.py:153
|
||||
#: actions.py:162
|
||||
msgid "C.S.D."
|
||||
msgstr ""
|
||||
|
||||
#: actions.py:155
|
||||
#: actions.py:164
|
||||
msgid "Close, send and download bills in one shot."
|
||||
msgstr ""
|
||||
|
||||
#: actions.py:216
|
||||
#: actions.py:225
|
||||
#, python-format
|
||||
msgid "%(norders)s orders and %(nlines)s lines undoed."
|
||||
msgstr "%(norders)s ordres i %(nlines)s línies desfetes."
|
||||
|
||||
#: actions.py:235
|
||||
#: actions.py:244
|
||||
msgid "Lines moved"
|
||||
msgstr "Línies mogudes"
|
||||
|
||||
#: actions.py:248
|
||||
#: actions.py:257
|
||||
msgid "Selected bills should be in closed state"
|
||||
msgstr "Les factures seleccionades han d'estar en estat obert"
|
||||
|
||||
#: actions.py:265
|
||||
#: actions.py:259
|
||||
#, python-format
|
||||
msgid "%s can not be amended."
|
||||
msgstr ""
|
||||
|
||||
#: actions.py:279
|
||||
#, python-format
|
||||
msgid "%(type)s of %(related_type)s %(number)s and creation date %(date)s"
|
||||
msgstr "%(type)s de %(related_type)s %(number)s amb data de creació %(date)s"
|
||||
|
||||
#: actions.py:272
|
||||
#: actions.py:286
|
||||
#, python-format
|
||||
msgid "%(related_type)s %(number)s subtotal for tax %(tax)s%%"
|
||||
msgstr "%(related_type)s %(number)s subtotal %(tax)s%%"
|
||||
|
||||
#: actions.py:288
|
||||
#: actions.py:303
|
||||
#, python-format
|
||||
msgid "<a href=\"%(url)s\">One amendment bill</a> have been generated."
|
||||
msgstr "S'ha creat una <a href=\"%(url)s\">transacció</a>"
|
||||
|
||||
#: actions.py:289
|
||||
#: actions.py:304
|
||||
#, python-format
|
||||
msgid "<a href=\"%(url)s\">%(num)i amendment bills</a> have been generated."
|
||||
msgstr "S'han creat les <a href=\"%(url)s\">%(num)i següents transaccions</a>"
|
||||
|
||||
#: actions.py:292
|
||||
#: actions.py:307
|
||||
msgid "Amend"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:58 admin.py:103 admin.py:140 forms.py:11
|
||||
#: admin.py:80 admin.py:126 admin.py:180 forms.py:11
|
||||
#: templates/admin/bills/bill/report.html:43
|
||||
#: templates/admin/bills/bill/report.html:70
|
||||
msgid "Total"
|
||||
msgstr "Total"
|
||||
|
||||
#: admin.py:89
|
||||
#: admin.py:112
|
||||
msgid "Description"
|
||||
msgstr "Descripció"
|
||||
|
||||
#: admin.py:97
|
||||
#: admin.py:120
|
||||
msgid "Subtotal"
|
||||
msgstr "Subtotal"
|
||||
|
||||
#: admin.py:130
|
||||
#: admin.py:146
|
||||
#, fuzzy
|
||||
#| msgid "Total"
|
||||
msgid "Totals"
|
||||
msgstr "Total"
|
||||
|
||||
#: admin.py:150
|
||||
msgid "Order"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:169
|
||||
msgid "Is open"
|
||||
msgstr "És oberta"
|
||||
|
||||
#: admin.py:135
|
||||
msgid "Subline"
|
||||
#: admin.py:175
|
||||
#, fuzzy
|
||||
#| msgid "Subline"
|
||||
msgid "Sublines"
|
||||
msgstr "Sublínia"
|
||||
|
||||
#: admin.py:167
|
||||
#: admin.py:221
|
||||
msgid "No bills selected."
|
||||
msgstr "No hi ha factures seleccionades"
|
||||
|
||||
#: admin.py:174
|
||||
#, python-format
|
||||
msgid "Manage %s bill lines."
|
||||
#: admin.py:229
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Manage %s bill lines."
|
||||
msgid "Manage %s bill lines"
|
||||
msgstr "Gestiona %s línies de factura."
|
||||
|
||||
#: admin.py:176
|
||||
#: admin.py:231
|
||||
msgid "Bill not in open state."
|
||||
msgstr "La factura no està en estat obert"
|
||||
|
||||
#: admin.py:179
|
||||
#: admin.py:234
|
||||
msgid "Not all bills are in open state."
|
||||
msgstr "No totes les factures estan en estat obert"
|
||||
|
||||
#: admin.py:180
|
||||
msgid "Manage bill lines of multiple bills."
|
||||
#: admin.py:235
|
||||
#, fuzzy
|
||||
#| msgid "Manage bill lines of multiple bills."
|
||||
msgid "Manage bill lines of multiple bills"
|
||||
msgstr "Gestiona línies de factura de multiples factures."
|
||||
|
||||
#: admin.py:204
|
||||
msgid "Dates"
|
||||
#: admin.py:250
|
||||
#, python-format
|
||||
msgid "Subtotal %s%% VAT %s &%s;"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:209
|
||||
msgid "Raw"
|
||||
msgstr "Raw"
|
||||
#: admin.py:251
|
||||
#, python-format
|
||||
msgid "Taxes %s%% VAT %s &%s;"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:235 models.py:73
|
||||
msgid "Created"
|
||||
msgstr "Creada"
|
||||
#: admin.py:255 admin.py:381 filters.py:46
|
||||
#: templates/bills/microspective.html:123
|
||||
msgid "total"
|
||||
msgstr "total"
|
||||
|
||||
#: admin.py:236
|
||||
#, fuzzy
|
||||
#| msgid "Close"
|
||||
msgid "Closed"
|
||||
msgstr "Tanca"
|
||||
#: admin.py:275
|
||||
msgid "This bill has been amended, this value may not be valid."
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:237
|
||||
#, fuzzy
|
||||
#| msgid "updated on"
|
||||
msgid "Updated"
|
||||
msgstr "actualitzada el"
|
||||
#: admin.py:280
|
||||
msgid "Payment"
|
||||
msgstr "Pagament"
|
||||
|
||||
#: admin.py:246
|
||||
#: admin.py:304
|
||||
#, fuzzy
|
||||
#| msgid "amended line"
|
||||
msgid "Amends"
|
||||
msgstr "línia rectificada"
|
||||
|
||||
#: admin.py:252
|
||||
#: admin.py:330
|
||||
msgid "Dates"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:335
|
||||
msgid "Raw"
|
||||
msgstr "Raw"
|
||||
|
||||
#: admin.py:358 models.py:75
|
||||
msgid "Created"
|
||||
msgstr "Creada"
|
||||
|
||||
#: admin.py:359
|
||||
#, fuzzy
|
||||
#| msgid "Close"
|
||||
msgid "Closed"
|
||||
msgstr "Tanca"
|
||||
|
||||
#: admin.py:360
|
||||
#, fuzzy
|
||||
#| msgid "updated on"
|
||||
msgid "Updated"
|
||||
msgstr "actualitzada el"
|
||||
|
||||
#: admin.py:375
|
||||
msgid "lines"
|
||||
msgstr "línies"
|
||||
|
||||
#: admin.py:257 filters.py:46 templates/bills/microspective.html:118
|
||||
msgid "total"
|
||||
msgstr "total"
|
||||
|
||||
#: admin.py:265 models.py:104 models.py:460
|
||||
#: admin.py:389 models.py:108 models.py:501
|
||||
msgid "type"
|
||||
msgstr "tipus"
|
||||
|
||||
#: admin.py:282
|
||||
msgid "This bill has been amended, this value may not be valid."
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:287
|
||||
msgid "Payment"
|
||||
msgstr "Pagament"
|
||||
|
||||
#: filters.py:21
|
||||
msgid "All"
|
||||
msgstr "Tot"
|
||||
|
||||
#: filters.py:22 models.py:88
|
||||
#: filters.py:22 models.py:91
|
||||
msgid "Invoice"
|
||||
msgstr "Factura"
|
||||
|
||||
#: filters.py:23 models.py:90
|
||||
#: filters.py:23 models.py:93
|
||||
msgid "Fee"
|
||||
msgstr "Quota de soci"
|
||||
|
||||
|
@ -231,65 +262,67 @@ msgstr "Pro-forma"
|
|||
msgid "Amendment fee"
|
||||
msgstr "Rectificació de quota de soci"
|
||||
|
||||
#: filters.py:26 models.py:89
|
||||
#: filters.py:26 models.py:92
|
||||
msgid "Amendment invoice"
|
||||
msgstr "Factura rectificativa"
|
||||
|
||||
#: filters.py:68
|
||||
#: filters.py:71
|
||||
msgid "has bill contact"
|
||||
msgstr "té contacte de facturació"
|
||||
|
||||
#: filters.py:73
|
||||
#: filters.py:76
|
||||
msgid "Yes"
|
||||
msgstr "Si"
|
||||
|
||||
#: filters.py:74
|
||||
#: filters.py:77
|
||||
msgid "No"
|
||||
msgstr "No"
|
||||
|
||||
#: filters.py:85
|
||||
#: filters.py:88
|
||||
msgid "payment state"
|
||||
msgstr "Pagament"
|
||||
|
||||
#: filters.py:90 models.py:72
|
||||
#: filters.py:93 models.py:74
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: filters.py:91 models.py:76
|
||||
#: filters.py:94 models.py:78
|
||||
msgid "Paid"
|
||||
msgstr "Pagat"
|
||||
|
||||
#: filters.py:92
|
||||
#: filters.py:95
|
||||
msgid "Pending"
|
||||
msgstr "Pendent"
|
||||
|
||||
#: filters.py:93 models.py:79
|
||||
#: filters.py:96 models.py:81
|
||||
msgid "Bad debt"
|
||||
msgstr "Incobrable"
|
||||
|
||||
#: filters.py:135
|
||||
#: filters.py:138
|
||||
#, fuzzy
|
||||
#| msgid "amended line"
|
||||
msgid "amended"
|
||||
msgstr "línia rectificada"
|
||||
|
||||
#: filters.py:140
|
||||
#: filters.py:143
|
||||
#, fuzzy
|
||||
#| msgid "Due date"
|
||||
msgid "Closed amends"
|
||||
msgstr "Data de pagament"
|
||||
|
||||
#: filters.py:141
|
||||
msgid "Open or closed amends"
|
||||
msgstr ""
|
||||
|
||||
#: filters.py:142
|
||||
#: filters.py:144
|
||||
#, fuzzy
|
||||
#| msgid "closed on"
|
||||
msgid "No closed amends"
|
||||
msgstr "tancat el"
|
||||
#| msgid "Due date"
|
||||
msgid "Open amends"
|
||||
msgstr "Data de pagament"
|
||||
|
||||
#: filters.py:143
|
||||
#: filters.py:145
|
||||
#, fuzzy
|
||||
#| msgid "amended line"
|
||||
msgid "Any amends"
|
||||
msgstr "línia rectificada"
|
||||
|
||||
#: filters.py:146
|
||||
msgid "No amends"
|
||||
msgstr ""
|
||||
|
||||
|
@ -309,7 +342,7 @@ msgstr "Tipus"
|
|||
msgid "Source"
|
||||
msgstr "Font"
|
||||
|
||||
#: helpers.py:10
|
||||
#: helpers.py:14
|
||||
msgid ""
|
||||
"{relation} account \"{account}\" does not have a declared invoice contact. "
|
||||
"You should <a href=\"{url}#invoicecontact-group\">provide one</a>"
|
||||
|
@ -317,213 +350,235 @@ msgstr ""
|
|||
"{relation} compte \"{account}\" no te un contacte de facturació. Hauries de "
|
||||
"<a href=\"{url}#invoicecontact-group\">proporcionar un</a>"
|
||||
|
||||
#: helpers.py:17
|
||||
#: helpers.py:21
|
||||
msgid "Related"
|
||||
msgstr "Relacionat"
|
||||
|
||||
#: helpers.py:24
|
||||
#: helpers.py:28
|
||||
msgid "Main"
|
||||
msgstr "Principal"
|
||||
|
||||
#: models.py:24 models.py:100
|
||||
#: models.py:26 models.py:104
|
||||
msgid "account"
|
||||
msgstr "compte"
|
||||
|
||||
#: models.py:26
|
||||
#: models.py:28
|
||||
msgid "name"
|
||||
msgstr "nom"
|
||||
|
||||
#: models.py:27
|
||||
#: models.py:29
|
||||
msgid "Account full name will be used when left blank."
|
||||
msgstr "S'emprarà el nom complet del compte quan es deixi en blanc."
|
||||
|
||||
#: models.py:28
|
||||
#: models.py:30
|
||||
msgid "address"
|
||||
msgstr "adreça"
|
||||
|
||||
#: models.py:29
|
||||
#: models.py:31
|
||||
msgid "city"
|
||||
msgstr "ciutat"
|
||||
|
||||
#: models.py:31
|
||||
#: models.py:33
|
||||
msgid "zip code"
|
||||
msgstr "codi postal"
|
||||
|
||||
#: models.py:32
|
||||
#: models.py:34
|
||||
msgid "Enter a valid zipcode."
|
||||
msgstr "Introdueix un codi postal vàlid."
|
||||
|
||||
#: models.py:33
|
||||
#: models.py:35
|
||||
msgid "country"
|
||||
msgstr "país"
|
||||
|
||||
#: models.py:36 templates/admin/bills/bill/report.html:65
|
||||
#: models.py:38 templates/admin/bills/bill/report.html:65
|
||||
msgid "VAT number"
|
||||
msgstr "NIF"
|
||||
|
||||
#: models.py:74
|
||||
#: models.py:76
|
||||
msgid "Processed"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:75
|
||||
#: models.py:77
|
||||
#, fuzzy
|
||||
#| msgid "amended line"
|
||||
msgid "Amended"
|
||||
msgstr "línia rectificada"
|
||||
|
||||
#: models.py:77
|
||||
#: models.py:79
|
||||
msgid "Incomplete"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:78
|
||||
#: models.py:80
|
||||
msgid "Executed"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:91
|
||||
#: models.py:94
|
||||
msgid "Amendment Fee"
|
||||
msgstr "Rectificació de quota de soci"
|
||||
|
||||
#: models.py:92
|
||||
#: models.py:95
|
||||
#, fuzzy
|
||||
#| msgid "Invoice"
|
||||
msgid "Abono Invoice"
|
||||
msgstr "Abonament"
|
||||
|
||||
#: models.py:96
|
||||
msgid "Pro forma"
|
||||
msgstr "Pro forma"
|
||||
|
||||
#: models.py:99
|
||||
#: models.py:103
|
||||
msgid "number"
|
||||
msgstr "número"
|
||||
|
||||
#: models.py:102
|
||||
#: models.py:106
|
||||
#, fuzzy
|
||||
#| msgid "amended line"
|
||||
msgid "amend of"
|
||||
msgstr "línia rectificada"
|
||||
|
||||
#: models.py:105
|
||||
#: models.py:109
|
||||
msgid "created on"
|
||||
msgstr "creat el"
|
||||
|
||||
#: models.py:106
|
||||
#: models.py:110
|
||||
msgid "closed on"
|
||||
msgstr "tancat el"
|
||||
|
||||
#: models.py:107
|
||||
#: models.py:111
|
||||
msgid "open"
|
||||
msgstr "obert"
|
||||
|
||||
#: models.py:108
|
||||
#: models.py:112
|
||||
msgid "sent"
|
||||
msgstr "enviat"
|
||||
|
||||
#: models.py:109
|
||||
#: models.py:113
|
||||
msgid "due on"
|
||||
msgstr "es deu"
|
||||
|
||||
#: models.py:110
|
||||
#: models.py:114
|
||||
msgid "updated on"
|
||||
msgstr "actualitzada el"
|
||||
|
||||
#: models.py:112
|
||||
#: models.py:116
|
||||
msgid "comments"
|
||||
msgstr "comentaris"
|
||||
|
||||
#: models.py:113
|
||||
#: models.py:117
|
||||
msgid "HTML"
|
||||
msgstr "HTML"
|
||||
|
||||
#: models.py:194
|
||||
#: models.py:200
|
||||
#, python-format
|
||||
msgid "Type %s is not an amendment."
|
||||
msgstr ""
|
||||
|
||||
#: models.py:196
|
||||
#: models.py:202
|
||||
msgid "Amend of related account doesn't match bill account."
|
||||
msgstr ""
|
||||
|
||||
#: models.py:198
|
||||
#: models.py:204
|
||||
#, fuzzy
|
||||
#| msgid "Bill not in open state."
|
||||
msgid "Related invoice is in open state."
|
||||
msgstr "La factura no està en estat obert"
|
||||
|
||||
#: models.py:200
|
||||
#: models.py:206
|
||||
msgid "Related invoice is an amendment."
|
||||
msgstr ""
|
||||
|
||||
#: models.py:392
|
||||
#: models.py:419
|
||||
msgid "bill"
|
||||
msgstr "factura"
|
||||
|
||||
#: models.py:393 models.py:458 templates/bills/microspective.html:73
|
||||
#: models.py:420 models.py:499 templates/bills/microspective.html:75
|
||||
msgid "description"
|
||||
msgstr "descripció"
|
||||
|
||||
#: models.py:394
|
||||
#: models.py:421
|
||||
msgid "rate"
|
||||
msgstr "tarifa"
|
||||
|
||||
#: models.py:395
|
||||
#: models.py:422
|
||||
msgid "quantity"
|
||||
msgstr "quantitat"
|
||||
|
||||
#: models.py:397
|
||||
#: models.py:424
|
||||
#, fuzzy
|
||||
#| msgid "quantity"
|
||||
msgid "Verbose quantity"
|
||||
msgstr "quantitat"
|
||||
|
||||
#: models.py:398 templates/admin/bills/bill/report.html:47
|
||||
#: templates/bills/microspective.html:77
|
||||
#: templates/bills/microspective.html:111
|
||||
#: models.py:425 templates/admin/bills/bill/report.html:47
|
||||
#: templates/bills/microspective.html:79
|
||||
#: templates/bills/microspective.html:116
|
||||
msgid "subtotal"
|
||||
msgstr "subtotal"
|
||||
|
||||
#: models.py:399
|
||||
#: models.py:426
|
||||
msgid "tax"
|
||||
msgstr "impostos"
|
||||
|
||||
#: models.py:400
|
||||
#: models.py:427
|
||||
msgid "start"
|
||||
msgstr "iniciar"
|
||||
|
||||
#: models.py:401
|
||||
#: models.py:428
|
||||
msgid "end"
|
||||
msgstr "finalitzar"
|
||||
|
||||
#: models.py:403
|
||||
#: models.py:431
|
||||
msgid "Informative link back to the order"
|
||||
msgstr "Enllaç informatiu de l'ordre"
|
||||
|
||||
#: models.py:404
|
||||
#: models.py:432
|
||||
msgid "order billed"
|
||||
msgstr "ordre facturada"
|
||||
|
||||
#: models.py:405
|
||||
#: models.py:433
|
||||
msgid "order billed until"
|
||||
msgstr "ordre facturada fins a"
|
||||
|
||||
#: models.py:406
|
||||
#: models.py:434
|
||||
msgid "created"
|
||||
msgstr "creada"
|
||||
|
||||
#: models.py:408
|
||||
#: models.py:436
|
||||
msgid "amended line"
|
||||
msgstr "línia rectificada"
|
||||
|
||||
#: models.py:451
|
||||
#: models.py:492
|
||||
msgid "Volume"
|
||||
msgstr "Volum"
|
||||
|
||||
#: models.py:452
|
||||
#: models.py:493
|
||||
msgid "Compensation"
|
||||
msgstr "Compensació"
|
||||
|
||||
#: models.py:453
|
||||
#: models.py:494
|
||||
msgid "Other"
|
||||
msgstr "Altre"
|
||||
|
||||
#: models.py:457
|
||||
#: models.py:498
|
||||
msgid "bill line"
|
||||
msgstr "línia de factura"
|
||||
|
||||
#: templates/admin/bills/bill/change_list.html:9
|
||||
#, fuzzy
|
||||
#| msgid "lines"
|
||||
msgid "Lines"
|
||||
msgstr "línies"
|
||||
|
||||
#: templates/admin/bills/bill/change_list.html:15
|
||||
#, fuzzy
|
||||
#| msgid "bill"
|
||||
msgid "Add bill"
|
||||
msgstr "factura"
|
||||
|
||||
#: templates/admin/bills/bill/close_send_download_bills.html:57
|
||||
msgid "Yes, I'm sure"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/bill/report.html:42
|
||||
msgid "Summary"
|
||||
msgstr ""
|
||||
|
@ -531,19 +586,19 @@ msgstr ""
|
|||
#: templates/admin/bills/bill/report.html:47
|
||||
#: templates/admin/bills/bill/report.html:51
|
||||
#: templates/admin/bills/bill/report.html:69
|
||||
#: templates/bills/microspective.html:111
|
||||
#: templates/bills/microspective.html:114
|
||||
#: templates/bills/microspective.html:116
|
||||
#: templates/bills/microspective.html:119
|
||||
msgid "VAT"
|
||||
msgstr "IVA"
|
||||
|
||||
#: templates/admin/bills/bill/report.html:51
|
||||
#: templates/bills/microspective.html:114
|
||||
#: templates/bills/microspective.html:119
|
||||
msgid "taxes"
|
||||
msgstr "impostos"
|
||||
|
||||
#: templates/admin/bills/bill/report.html:56
|
||||
#: templates/admin/bills/billline/report.html:60
|
||||
#: templates/bills/microspective.html:53
|
||||
#: templates/bills/microspective.html:54
|
||||
msgid "TOTAL"
|
||||
msgstr "TOTAL"
|
||||
|
||||
|
@ -561,8 +616,20 @@ msgstr "Data de pagament"
|
|||
msgid "Base"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/change_list.html:6
|
||||
msgid "Home"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/change_list.html:8
|
||||
msgid "Bills"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/change_list.html:9
|
||||
msgid "Multiple bills"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/report.html:42
|
||||
msgid "Services"
|
||||
msgid "Service"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/report.html:43
|
||||
|
@ -587,27 +654,21 @@ msgstr "quantitat"
|
|||
msgid "Profit"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/change_list.html:9
|
||||
#, fuzzy
|
||||
#| msgid "bill"
|
||||
msgid "Add bill"
|
||||
msgstr "factura"
|
||||
|
||||
#: templates/bills/microspective-fee.html:107
|
||||
#: templates/bills/microspective-fee.html:115
|
||||
msgid "Due date"
|
||||
msgstr "Data de pagament"
|
||||
|
||||
#: templates/bills/microspective-fee.html:108
|
||||
#: templates/bills/microspective-fee.html:116
|
||||
#, python-format
|
||||
msgid "On %(bank_account)s"
|
||||
msgstr "Al %(bank_account)s"
|
||||
|
||||
#: templates/bills/microspective-fee.html:114
|
||||
#: templates/bills/microspective-fee.html:122
|
||||
#, python-format
|
||||
msgid "From %(ini)s to %(end)s"
|
||||
msgstr "De %(ini)s a %(end)s"
|
||||
|
||||
#: templates/bills/microspective-fee.html:121
|
||||
#: templates/bills/microspective-fee.html:144
|
||||
msgid ""
|
||||
"\n"
|
||||
"<strong>With your membership</strong> you are supporting ...\n"
|
||||
|
@ -615,36 +676,36 @@ msgstr ""
|
|||
"\n"
|
||||
"<strong>Amb la teva quota de soci</strong> estàs donant suport ...\n"
|
||||
|
||||
#: templates/bills/microspective.html:49
|
||||
#: templates/bills/microspective.html:50
|
||||
msgid "DUE DATE"
|
||||
msgstr "VENCIMENT"
|
||||
|
||||
#: templates/bills/microspective.html:57
|
||||
#: templates/bills/microspective.html:58
|
||||
#, python-format
|
||||
msgid "%(bill_type)s DATE"
|
||||
msgstr "DATA %(bill_type)s"
|
||||
|
||||
#: templates/bills/microspective.html:74
|
||||
#: templates/bills/microspective.html:76
|
||||
msgid "period"
|
||||
msgstr "període"
|
||||
|
||||
#: templates/bills/microspective.html:75
|
||||
#: templates/bills/microspective.html:77
|
||||
msgid "hrs/qty"
|
||||
msgstr "hrs/qnt"
|
||||
|
||||
#: templates/bills/microspective.html:76
|
||||
#: templates/bills/microspective.html:78
|
||||
msgid "rate/price"
|
||||
msgstr "tarifa/preu"
|
||||
|
||||
#: templates/bills/microspective.html:131
|
||||
#: templates/bills/microspective.html:137
|
||||
msgid "COMMENTS"
|
||||
msgstr "COMENTARIS"
|
||||
|
||||
#: templates/bills/microspective.html:138
|
||||
#: templates/bills/microspective.html:145
|
||||
msgid "PAYMENT"
|
||||
msgstr "PAGAMENT"
|
||||
|
||||
#: templates/bills/microspective.html:142
|
||||
#: templates/bills/microspective.html:149
|
||||
#, python-format
|
||||
msgid ""
|
||||
"\n"
|
||||
|
@ -658,11 +719,11 @@ msgstr ""
|
|||
"Pots pagar aquesta <i>%(type)s</i> per transferència bancaria.<br>Inclou el "
|
||||
"teu nom i el número de <i>%(type)s</i>. El nostre compte bancari és"
|
||||
|
||||
#: templates/bills/microspective.html:151
|
||||
#: templates/bills/microspective.html:160
|
||||
msgid "QUESTIONS"
|
||||
msgstr "PREGUNTES"
|
||||
|
||||
#: templates/bills/microspective.html:152
|
||||
#: templates/bills/microspective.html:161
|
||||
#, python-format
|
||||
msgid ""
|
||||
"\n"
|
||||
|
@ -679,5 +740,10 @@ msgstr ""
|
|||
"ràpidament possible.\n"
|
||||
" "
|
||||
|
||||
#, fuzzy
|
||||
#~| msgid "closed on"
|
||||
#~ msgid "No closed amends"
|
||||
#~ msgstr "tancat el"
|
||||
|
||||
#~ msgid "positive price"
|
||||
#~ msgstr "preu positiu"
|
||||
|
|
Binary file not shown.
|
@ -8,7 +8,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2015-10-29 10:51+0000\n"
|
||||
"POT-Creation-Date: 2019-12-20 11:56+0100\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
@ -18,33 +18,33 @@ msgstr ""
|
|||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#: actions.py:31
|
||||
#: actions.py:33
|
||||
msgid "View"
|
||||
msgstr "Vista"
|
||||
|
||||
#: actions.py:42
|
||||
#: actions.py:45
|
||||
msgid "Selected bills should be in open state"
|
||||
msgstr "Las facturas seleccionadas están en estado abierto"
|
||||
|
||||
#: actions.py:57
|
||||
#: actions.py:60
|
||||
msgid "Selected bills have been closed"
|
||||
msgstr "Las facturas seleccionadas han sido cerradas"
|
||||
|
||||
#: actions.py:70
|
||||
#: actions.py:73
|
||||
#, python-format
|
||||
msgid "<a href=\"%(url)s\">One related transaction</a> has been created"
|
||||
msgstr "Se ha creado una <a href=\"%(url)s\">transacción</a>"
|
||||
|
||||
#: actions.py:71
|
||||
#: actions.py:74
|
||||
#, python-format
|
||||
msgid "<a href=\"%(url)s\">%(num)i related transactions</a> have been created"
|
||||
msgstr "Se han creado <a href=\"%(url)s\">%(num)i transacciones</a>"
|
||||
|
||||
#: actions.py:77
|
||||
#: actions.py:80
|
||||
msgid "Are you sure about closing the following bills?"
|
||||
msgstr "Estás a punto de cerrar las sigüientes facturas. ¿Estás seguro?"
|
||||
|
||||
#: actions.py:78
|
||||
#: actions.py:81
|
||||
msgid ""
|
||||
"Once a bill is closed it can not be further modified.</p><p>Please select a "
|
||||
"payment source for the selected bills"
|
||||
|
@ -52,174 +52,199 @@ msgstr ""
|
|||
"Una vez cerrada la factura ya no se podrá modificar.</p><p>Por favor "
|
||||
"seleciona un metodo de pago para las facturas seleccionadas"
|
||||
|
||||
#: actions.py:91
|
||||
#: actions.py:97
|
||||
msgid "Close"
|
||||
msgstr "Cerrar"
|
||||
|
||||
#: actions.py:109
|
||||
#: actions.py:115
|
||||
msgid "One bill has been sent."
|
||||
msgstr "Se ha enviado una factura"
|
||||
|
||||
#: actions.py:110
|
||||
#: actions.py:116
|
||||
#, python-format
|
||||
msgid "%i bills have been sent."
|
||||
msgstr ""
|
||||
|
||||
#: actions.py:117
|
||||
#: actions.py:123
|
||||
msgid "Resend"
|
||||
msgstr ""
|
||||
|
||||
#: actions.py:137
|
||||
#: actions.py:146
|
||||
msgid "Download"
|
||||
msgstr "Descarga"
|
||||
|
||||
#: actions.py:153
|
||||
#: actions.py:162
|
||||
msgid "C.S.D."
|
||||
msgstr ""
|
||||
|
||||
#: actions.py:155
|
||||
#: actions.py:164
|
||||
msgid "Close, send and download bills in one shot."
|
||||
msgstr ""
|
||||
|
||||
#: actions.py:216
|
||||
#: actions.py:225
|
||||
#, python-format
|
||||
msgid "%(norders)s orders and %(nlines)s lines undoed."
|
||||
msgstr ""
|
||||
|
||||
#: actions.py:235
|
||||
#: actions.py:244
|
||||
msgid "Lines moved"
|
||||
msgstr ""
|
||||
|
||||
#: actions.py:248
|
||||
#: actions.py:257
|
||||
msgid "Selected bills should be in closed state"
|
||||
msgstr "Las facturas seleccionadas están en estado abierto"
|
||||
|
||||
#: actions.py:265
|
||||
#: actions.py:259
|
||||
#, python-format
|
||||
msgid "%s can not be amended."
|
||||
msgstr ""
|
||||
|
||||
#: actions.py:279
|
||||
#, python-format
|
||||
msgid "%(type)s of %(related_type)s %(number)s and creation date %(date)s"
|
||||
msgstr "%(type)s de %(related_type)s %(number)s con fecha de creación %(date)s"
|
||||
|
||||
#: actions.py:272
|
||||
#: actions.py:286
|
||||
#, python-format
|
||||
msgid "%(related_type)s %(number)s subtotal for tax %(tax)s%%"
|
||||
msgstr "%(related_type)s %(number)s subtotal %(tax)s%%"
|
||||
|
||||
#: actions.py:288
|
||||
#: actions.py:303
|
||||
#, python-format
|
||||
msgid "<a href=\"%(url)s\">One amendment bill</a> have been generated."
|
||||
msgstr "Se ha creado una <a href=\"%(url)s\">transacción</a>"
|
||||
|
||||
#: actions.py:289
|
||||
#: actions.py:304
|
||||
#, python-format
|
||||
msgid "<a href=\"%(url)s\">%(num)i amendment bills</a> have been generated."
|
||||
msgstr "Se han creado <a href=\"%(url)s\">%(num)i transacciones</a>"
|
||||
|
||||
#: actions.py:292
|
||||
#: actions.py:307
|
||||
msgid "Amend"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:58 admin.py:103 admin.py:140 forms.py:11
|
||||
#: admin.py:80 admin.py:126 admin.py:180 forms.py:11
|
||||
#: templates/admin/bills/bill/report.html:43
|
||||
#: templates/admin/bills/bill/report.html:70
|
||||
msgid "Total"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:89
|
||||
#: admin.py:112
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:97
|
||||
#: admin.py:120
|
||||
msgid "Subtotal"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:130
|
||||
#: admin.py:146
|
||||
msgid "Totals"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:150
|
||||
msgid "Order"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:169
|
||||
msgid "Is open"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:135
|
||||
msgid "Subline"
|
||||
#: admin.py:175
|
||||
msgid "Sublines"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:167
|
||||
#: admin.py:221
|
||||
msgid "No bills selected."
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:174
|
||||
#, python-format
|
||||
msgid "Manage %s bill lines."
|
||||
msgstr ""
|
||||
#: admin.py:229
|
||||
#, fuzzy, python-format
|
||||
#| msgid "bill line"
|
||||
msgid "Manage %s bill lines"
|
||||
msgstr "linea de factura"
|
||||
|
||||
#: admin.py:176
|
||||
#: admin.py:231
|
||||
msgid "Bill not in open state."
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:179
|
||||
#: admin.py:234
|
||||
msgid "Not all bills are in open state."
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:180
|
||||
msgid "Manage bill lines of multiple bills."
|
||||
#: admin.py:235
|
||||
msgid "Manage bill lines of multiple bills"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:204
|
||||
msgid "Dates"
|
||||
#: admin.py:250
|
||||
#, python-format
|
||||
msgid "Subtotal %s%% VAT %s &%s;"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:209
|
||||
msgid "Raw"
|
||||
#: admin.py:251
|
||||
#, python-format
|
||||
msgid "Taxes %s%% VAT %s &%s;"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:235 models.py:73
|
||||
msgid "Created"
|
||||
#: admin.py:255 admin.py:381 filters.py:46
|
||||
#: templates/bills/microspective.html:123
|
||||
msgid "total"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:236
|
||||
#, fuzzy
|
||||
#| msgid "Close"
|
||||
msgid "Closed"
|
||||
msgstr "Cerrar"
|
||||
#: admin.py:275
|
||||
msgid "This bill has been amended, this value may not be valid."
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:237
|
||||
#, fuzzy
|
||||
#| msgid "updated on"
|
||||
msgid "Updated"
|
||||
msgstr "actualizada en"
|
||||
#: admin.py:280
|
||||
msgid "Payment"
|
||||
msgstr "Pago"
|
||||
|
||||
#: admin.py:246
|
||||
#: admin.py:304
|
||||
#, fuzzy
|
||||
#| msgid "Amended"
|
||||
msgid "Amends"
|
||||
msgstr "Quota rectificativa"
|
||||
|
||||
#: admin.py:252
|
||||
#: admin.py:330
|
||||
msgid "Dates"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:335
|
||||
msgid "Raw"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:358 models.py:75
|
||||
msgid "Created"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:359
|
||||
#, fuzzy
|
||||
#| msgid "Close"
|
||||
msgid "Closed"
|
||||
msgstr "Cerrar"
|
||||
|
||||
#: admin.py:360
|
||||
#, fuzzy
|
||||
#| msgid "updated on"
|
||||
msgid "Updated"
|
||||
msgstr "actualizada en"
|
||||
|
||||
#: admin.py:375
|
||||
msgid "lines"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:257 filters.py:46 templates/bills/microspective.html:118
|
||||
msgid "total"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:265 models.py:104 models.py:460
|
||||
#: admin.py:389 models.py:108 models.py:501
|
||||
msgid "type"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:282
|
||||
msgid "This bill has been amended, this value may not be valid."
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:287
|
||||
msgid "Payment"
|
||||
msgstr "Pago"
|
||||
|
||||
#: filters.py:21
|
||||
msgid "All"
|
||||
msgstr ""
|
||||
|
||||
#: filters.py:22 models.py:88
|
||||
#: filters.py:22 models.py:91
|
||||
msgid "Invoice"
|
||||
msgstr "Factura"
|
||||
|
||||
#: filters.py:23 models.py:90
|
||||
#: filters.py:23 models.py:93
|
||||
msgid "Fee"
|
||||
msgstr "Cuota de socio"
|
||||
|
||||
|
@ -231,65 +256,67 @@ msgstr ""
|
|||
msgid "Amendment fee"
|
||||
msgstr "Cuota rectificativa"
|
||||
|
||||
#: filters.py:26 models.py:89
|
||||
#: filters.py:26 models.py:92
|
||||
msgid "Amendment invoice"
|
||||
msgstr "Factura rectificativa"
|
||||
|
||||
#: filters.py:68
|
||||
#: filters.py:71
|
||||
msgid "has bill contact"
|
||||
msgstr ""
|
||||
|
||||
#: filters.py:73
|
||||
#: filters.py:76
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
#: filters.py:74
|
||||
#: filters.py:77
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
|
||||
#: filters.py:85
|
||||
#: filters.py:88
|
||||
msgid "payment state"
|
||||
msgstr "Pago"
|
||||
|
||||
#: filters.py:90 models.py:72
|
||||
#: filters.py:93 models.py:74
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: filters.py:91 models.py:76
|
||||
#: filters.py:94 models.py:78
|
||||
msgid "Paid"
|
||||
msgstr ""
|
||||
|
||||
#: filters.py:92
|
||||
#: filters.py:95
|
||||
msgid "Pending"
|
||||
msgstr ""
|
||||
|
||||
#: filters.py:93 models.py:79
|
||||
#: filters.py:96 models.py:81
|
||||
msgid "Bad debt"
|
||||
msgstr ""
|
||||
|
||||
#: filters.py:135
|
||||
#: filters.py:138
|
||||
#, fuzzy
|
||||
#| msgid "Amended"
|
||||
msgid "amended"
|
||||
msgstr "Quota rectificativa"
|
||||
|
||||
#: filters.py:140
|
||||
#: filters.py:143
|
||||
#, fuzzy
|
||||
#| msgid "Due date"
|
||||
msgid "Closed amends"
|
||||
msgstr "Fecha de pago"
|
||||
|
||||
#: filters.py:141
|
||||
msgid "Open or closed amends"
|
||||
msgstr ""
|
||||
|
||||
#: filters.py:142
|
||||
#: filters.py:144
|
||||
#, fuzzy
|
||||
#| msgid "closed on"
|
||||
msgid "No closed amends"
|
||||
msgstr "cerrada en"
|
||||
#| msgid "Due date"
|
||||
msgid "Open amends"
|
||||
msgstr "Fecha de pago"
|
||||
|
||||
#: filters.py:143
|
||||
#: filters.py:145
|
||||
#, fuzzy
|
||||
#| msgid "Amended"
|
||||
msgid "Any amends"
|
||||
msgstr "Quota rectificativa"
|
||||
|
||||
#: filters.py:146
|
||||
msgid "No amends"
|
||||
msgstr ""
|
||||
|
||||
|
@ -309,213 +336,233 @@ msgstr ""
|
|||
msgid "Source"
|
||||
msgstr ""
|
||||
|
||||
#: helpers.py:10
|
||||
#: helpers.py:14
|
||||
msgid ""
|
||||
"{relation} account \"{account}\" does not have a declared invoice contact. "
|
||||
"You should <a href=\"{url}#invoicecontact-group\">provide one</a>"
|
||||
msgstr ""
|
||||
|
||||
#: helpers.py:17
|
||||
#: helpers.py:21
|
||||
msgid "Related"
|
||||
msgstr ""
|
||||
|
||||
#: helpers.py:24
|
||||
#: helpers.py:28
|
||||
msgid "Main"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:24 models.py:100
|
||||
#: models.py:26 models.py:104
|
||||
msgid "account"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:26
|
||||
#: models.py:28
|
||||
msgid "name"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:27
|
||||
#: models.py:29
|
||||
msgid "Account full name will be used when left blank."
|
||||
msgstr ""
|
||||
|
||||
#: models.py:28
|
||||
#: models.py:30
|
||||
msgid "address"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:29
|
||||
#: models.py:31
|
||||
msgid "city"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:31
|
||||
#: models.py:33
|
||||
msgid "zip code"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:32
|
||||
#: models.py:34
|
||||
msgid "Enter a valid zipcode."
|
||||
msgstr ""
|
||||
|
||||
#: models.py:33
|
||||
#: models.py:35
|
||||
msgid "country"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:36 templates/admin/bills/bill/report.html:65
|
||||
#: models.py:38 templates/admin/bills/bill/report.html:65
|
||||
msgid "VAT number"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:74
|
||||
#: models.py:76
|
||||
msgid "Processed"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:75
|
||||
#: models.py:77
|
||||
msgid "Amended"
|
||||
msgstr "Quota rectificativa"
|
||||
|
||||
#: models.py:77
|
||||
#: models.py:79
|
||||
msgid "Incomplete"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:78
|
||||
#: models.py:80
|
||||
msgid "Executed"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:91
|
||||
#: models.py:94
|
||||
msgid "Amendment Fee"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:92
|
||||
#: models.py:95
|
||||
#, fuzzy
|
||||
#| msgid "Invoice"
|
||||
msgid "Abono Invoice"
|
||||
msgstr "Abono"
|
||||
|
||||
#: models.py:96
|
||||
msgid "Pro forma"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:99
|
||||
#: models.py:103
|
||||
msgid "number"
|
||||
msgstr "número"
|
||||
|
||||
#: models.py:102
|
||||
#: models.py:106
|
||||
msgid "amend of"
|
||||
msgstr "rectificación de"
|
||||
|
||||
#: models.py:105
|
||||
#: models.py:109
|
||||
msgid "created on"
|
||||
msgstr "creado en"
|
||||
|
||||
#: models.py:106
|
||||
#: models.py:110
|
||||
msgid "closed on"
|
||||
msgstr "cerrada en"
|
||||
|
||||
#: models.py:107
|
||||
#: models.py:111
|
||||
msgid "open"
|
||||
msgstr "abierta"
|
||||
|
||||
#: models.py:108
|
||||
#: models.py:112
|
||||
msgid "sent"
|
||||
msgstr "enviada"
|
||||
|
||||
#: models.py:109
|
||||
#: models.py:113
|
||||
msgid "due on"
|
||||
msgstr "vencimiento"
|
||||
|
||||
#: models.py:110
|
||||
#: models.py:114
|
||||
msgid "updated on"
|
||||
msgstr "actualizada en"
|
||||
|
||||
#: models.py:112
|
||||
#: models.py:116
|
||||
msgid "comments"
|
||||
msgstr "comentarios"
|
||||
|
||||
#: models.py:113
|
||||
#: models.py:117
|
||||
msgid "HTML"
|
||||
msgstr "HTML"
|
||||
|
||||
#: models.py:194
|
||||
#: models.py:200
|
||||
#, python-format
|
||||
msgid "Type %s is not an amendment."
|
||||
msgstr ""
|
||||
|
||||
#: models.py:196
|
||||
#: models.py:202
|
||||
msgid "Amend of related account doesn't match bill account."
|
||||
msgstr ""
|
||||
|
||||
#: models.py:198
|
||||
#: models.py:204
|
||||
#, fuzzy
|
||||
#| msgid "Selected bills should be in open state"
|
||||
msgid "Related invoice is in open state."
|
||||
msgstr "Las facturas seleccionadas están en estado abierto"
|
||||
|
||||
#: models.py:200
|
||||
#: models.py:206
|
||||
msgid "Related invoice is an amendment."
|
||||
msgstr ""
|
||||
|
||||
#: models.py:392
|
||||
#: models.py:419
|
||||
msgid "bill"
|
||||
msgstr "factura"
|
||||
|
||||
#: models.py:393 models.py:458 templates/bills/microspective.html:73
|
||||
#: models.py:420 models.py:499 templates/bills/microspective.html:75
|
||||
msgid "description"
|
||||
msgstr "descripción"
|
||||
|
||||
#: models.py:394
|
||||
#: models.py:421
|
||||
msgid "rate"
|
||||
msgstr "tarifa"
|
||||
|
||||
#: models.py:395
|
||||
#: models.py:422
|
||||
msgid "quantity"
|
||||
msgstr "cantidad"
|
||||
|
||||
#: models.py:397
|
||||
#: models.py:424
|
||||
msgid "Verbose quantity"
|
||||
msgstr "Cantidad"
|
||||
|
||||
#: models.py:398 templates/admin/bills/bill/report.html:47
|
||||
#: templates/bills/microspective.html:77
|
||||
#: templates/bills/microspective.html:111
|
||||
#: models.py:425 templates/admin/bills/bill/report.html:47
|
||||
#: templates/bills/microspective.html:79
|
||||
#: templates/bills/microspective.html:116
|
||||
msgid "subtotal"
|
||||
msgstr "subtotal"
|
||||
|
||||
#: models.py:399
|
||||
#: models.py:426
|
||||
msgid "tax"
|
||||
msgstr "impuesto"
|
||||
|
||||
#: models.py:400
|
||||
#: models.py:427
|
||||
msgid "start"
|
||||
msgstr "inicio"
|
||||
|
||||
#: models.py:401
|
||||
#: models.py:428
|
||||
msgid "end"
|
||||
msgstr "fín"
|
||||
|
||||
#: models.py:403
|
||||
#: models.py:431
|
||||
msgid "Informative link back to the order"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:404
|
||||
#: models.py:432
|
||||
msgid "order billed"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:405
|
||||
#: models.py:433
|
||||
msgid "order billed until"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:406
|
||||
#: models.py:434
|
||||
msgid "created"
|
||||
msgstr "creado"
|
||||
|
||||
#: models.py:408
|
||||
#: models.py:436
|
||||
msgid "amended line"
|
||||
msgstr "linea rectificativa"
|
||||
|
||||
#: models.py:451
|
||||
#: models.py:492
|
||||
msgid "Volume"
|
||||
msgstr "Volumen"
|
||||
|
||||
#: models.py:452
|
||||
#: models.py:493
|
||||
msgid "Compensation"
|
||||
msgstr "Compensación"
|
||||
|
||||
#: models.py:453
|
||||
#: models.py:494
|
||||
msgid "Other"
|
||||
msgstr "Otro"
|
||||
|
||||
#: models.py:457
|
||||
#: models.py:498
|
||||
msgid "bill line"
|
||||
msgstr "linea de factura"
|
||||
|
||||
#: templates/admin/bills/bill/change_list.html:9
|
||||
msgid "Lines"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/bill/change_list.html:15
|
||||
#, fuzzy
|
||||
#| msgid "bill"
|
||||
msgid "Add bill"
|
||||
msgstr "factura"
|
||||
|
||||
#: templates/admin/bills/bill/close_send_download_bills.html:57
|
||||
msgid "Yes, I'm sure"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/bill/report.html:42
|
||||
msgid "Summary"
|
||||
msgstr ""
|
||||
|
@ -523,19 +570,19 @@ msgstr ""
|
|||
#: templates/admin/bills/bill/report.html:47
|
||||
#: templates/admin/bills/bill/report.html:51
|
||||
#: templates/admin/bills/bill/report.html:69
|
||||
#: templates/bills/microspective.html:111
|
||||
#: templates/bills/microspective.html:114
|
||||
#: templates/bills/microspective.html:116
|
||||
#: templates/bills/microspective.html:119
|
||||
msgid "VAT"
|
||||
msgstr "IVA"
|
||||
|
||||
#: templates/admin/bills/bill/report.html:51
|
||||
#: templates/bills/microspective.html:114
|
||||
#: templates/bills/microspective.html:119
|
||||
msgid "taxes"
|
||||
msgstr "impuestos"
|
||||
|
||||
#: templates/admin/bills/bill/report.html:56
|
||||
#: templates/admin/bills/billline/report.html:60
|
||||
#: templates/bills/microspective.html:53
|
||||
#: templates/bills/microspective.html:54
|
||||
msgid "TOTAL"
|
||||
msgstr "TOTAL"
|
||||
|
||||
|
@ -553,8 +600,20 @@ msgstr "Fecha de pago"
|
|||
msgid "Base"
|
||||
msgstr "Base"
|
||||
|
||||
#: templates/admin/bills/billline/change_list.html:6
|
||||
msgid "Home"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/change_list.html:8
|
||||
msgid "Bills"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/change_list.html:9
|
||||
msgid "Multiple bills"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/report.html:42
|
||||
msgid "Services"
|
||||
msgid "Service"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/report.html:43
|
||||
|
@ -579,62 +638,56 @@ msgstr "cantidad"
|
|||
msgid "Profit"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/change_list.html:9
|
||||
#, fuzzy
|
||||
#| msgid "bill"
|
||||
msgid "Add bill"
|
||||
msgstr "factura"
|
||||
|
||||
#: templates/bills/microspective-fee.html:107
|
||||
#: templates/bills/microspective-fee.html:115
|
||||
msgid "Due date"
|
||||
msgstr "Fecha de pago"
|
||||
|
||||
#: templates/bills/microspective-fee.html:108
|
||||
#: templates/bills/microspective-fee.html:116
|
||||
#, python-format
|
||||
msgid "On %(bank_account)s"
|
||||
msgstr "En %(bank_account)s"
|
||||
|
||||
#: templates/bills/microspective-fee.html:114
|
||||
#: templates/bills/microspective-fee.html:122
|
||||
#, python-format
|
||||
msgid "From %(ini)s to %(end)s"
|
||||
msgstr "Desde %(ini)s hasta %(end)s"
|
||||
|
||||
#: templates/bills/microspective-fee.html:121
|
||||
#: templates/bills/microspective-fee.html:144
|
||||
msgid ""
|
||||
"\n"
|
||||
"<strong>With your membership</strong> you are supporting ...\n"
|
||||
msgstr ""
|
||||
|
||||
#: templates/bills/microspective.html:49
|
||||
#: templates/bills/microspective.html:50
|
||||
msgid "DUE DATE"
|
||||
msgstr "VENCIMIENTO"
|
||||
|
||||
#: templates/bills/microspective.html:57
|
||||
#: templates/bills/microspective.html:58
|
||||
#, python-format
|
||||
msgid "%(bill_type)s DATE"
|
||||
msgstr "FECHA %(bill_type)s"
|
||||
|
||||
#: templates/bills/microspective.html:74
|
||||
#: templates/bills/microspective.html:76
|
||||
msgid "period"
|
||||
msgstr "periodo"
|
||||
|
||||
#: templates/bills/microspective.html:75
|
||||
#: templates/bills/microspective.html:77
|
||||
msgid "hrs/qty"
|
||||
msgstr "hrs/cant"
|
||||
|
||||
#: templates/bills/microspective.html:76
|
||||
#: templates/bills/microspective.html:78
|
||||
msgid "rate/price"
|
||||
msgstr "tarifa/precio"
|
||||
|
||||
#: templates/bills/microspective.html:131
|
||||
#: templates/bills/microspective.html:137
|
||||
msgid "COMMENTS"
|
||||
msgstr "COMENTARIOS"
|
||||
|
||||
#: templates/bills/microspective.html:138
|
||||
#: templates/bills/microspective.html:145
|
||||
msgid "PAYMENT"
|
||||
msgstr "PAGO"
|
||||
|
||||
#: templates/bills/microspective.html:142
|
||||
#: templates/bills/microspective.html:149
|
||||
#, python-format
|
||||
msgid ""
|
||||
"\n"
|
||||
|
@ -648,11 +701,11 @@ msgstr ""
|
|||
"Puedes pagar esta <i>%(type)s</i> por transferencia bancaria.<br>Incluye tu "
|
||||
"nombre y el número de <i>%(type)s</i>. Nuestra cuenta bancaria es"
|
||||
|
||||
#: templates/bills/microspective.html:151
|
||||
#: templates/bills/microspective.html:160
|
||||
msgid "QUESTIONS"
|
||||
msgstr "PREGUNTAS"
|
||||
|
||||
#: templates/bills/microspective.html:152
|
||||
#: templates/bills/microspective.html:161
|
||||
#, python-format
|
||||
msgid ""
|
||||
"\n"
|
||||
|
@ -668,3 +721,8 @@ msgstr ""
|
|||
" contacta con nosotros en %(email)s. Te responderemos lo más "
|
||||
"rapidamente posible.\n"
|
||||
" "
|
||||
|
||||
#, fuzzy
|
||||
#~| msgid "closed on"
|
||||
#~ msgid "No closed amends"
|
||||
#~ msgstr "cerrada en"
|
||||
|
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -1,6 +1,7 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
|
@ -14,7 +15,7 @@ class Migration(migrations.Migration):
|
|||
migrations.AddField(
|
||||
model_name='bill',
|
||||
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(
|
||||
model_name='billcontact',
|
||||
|
|
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
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
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
File diff suppressed because one or more lines are too long
|
@ -1,12 +1,12 @@
|
|||
import datetime
|
||||
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.db import models
|
||||
from django.db.models import F, Sum
|
||||
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.encoding import force_text
|
||||
from django.utils.functional import cached_property
|
||||
|
@ -24,7 +24,7 @@ from . import settings
|
|||
|
||||
class BillContact(models.Model):
|
||||
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,
|
||||
help_text=_("Account full name will be used when left blank."))
|
||||
address = models.TextField(_("address"))
|
||||
|
@ -36,13 +36,13 @@ class BillContact(models.Model):
|
|||
choices=settings.BILLS_CONTACT_COUNTRIES,
|
||||
default=settings.BILLS_CONTACT_DEFAULT_COUNTRY)
|
||||
vat = models.CharField(_("VAT number"), max_length=64)
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
def get_name(self):
|
||||
return self.name or self.account.get_full_name()
|
||||
|
||||
|
||||
def clean(self):
|
||||
self.vat = self.vat.strip()
|
||||
self.city = self.city.strip()
|
||||
|
@ -86,23 +86,25 @@ class Bill(models.Model):
|
|||
FEE = 'FEE'
|
||||
AMENDMENTFEE = 'AMENDMENTFEE'
|
||||
PROFORMA = 'PROFORMA'
|
||||
ABONOINVOICE = 'ABONOINVOICE'
|
||||
TYPES = (
|
||||
(INVOICE, _("Invoice")),
|
||||
(AMENDMENTINVOICE, _("Amendment invoice")),
|
||||
(FEE, _("Fee")),
|
||||
(AMENDMENTFEE, _("Amendment Fee")),
|
||||
(ABONOINVOICE, _("Abono Invoice")),
|
||||
(PROFORMA, _("Pro forma")),
|
||||
)
|
||||
AMEND_MAP = {
|
||||
INVOICE: AMENDMENTINVOICE,
|
||||
FEE: AMENDMENTFEE,
|
||||
}
|
||||
|
||||
|
||||
number = models.CharField(_("number"), max_length=16, unique=True, blank=True)
|
||||
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"),
|
||||
related_name='amends')
|
||||
related_name='amends', on_delete=models.SET_NULL)
|
||||
type = models.CharField(_("type"), max_length=16, choices=TYPES)
|
||||
created_on = models.DateField(_("created on"), auto_now_add=True)
|
||||
closed_on = models.DateField(_("closed on"), blank=True, null=True, db_index=True)
|
||||
|
@ -113,37 +115,37 @@ class Bill(models.Model):
|
|||
# total = models.DecimalField(max_digits=12, decimal_places=2, null=True)
|
||||
comments = models.TextField(_("comments"), blank=True)
|
||||
html = models.TextField(_("HTML"), blank=True)
|
||||
|
||||
|
||||
objects = BillManager()
|
||||
|
||||
|
||||
class Meta:
|
||||
get_latest_by = 'id'
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return self.number
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_class_type(cls):
|
||||
if cls is models.DEFERRED:
|
||||
cls = cls.__base__
|
||||
return cls.__name__.upper()
|
||||
|
||||
|
||||
@cached_property
|
||||
def total(self):
|
||||
return self.compute_total()
|
||||
|
||||
|
||||
@cached_property
|
||||
def seller(self):
|
||||
return Account.objects.get_main().billcontact
|
||||
|
||||
|
||||
@cached_property
|
||||
def buyer(self):
|
||||
return self.account.billcontact
|
||||
|
||||
|
||||
@property
|
||||
def has_multiple_pages(self):
|
||||
return self.type != self.FEE
|
||||
|
||||
|
||||
@cached_property
|
||||
def payment_state(self):
|
||||
if self.is_open or self.get_type() == self.PROFORMA:
|
||||
|
@ -190,7 +192,7 @@ class Bill(models.Model):
|
|||
elif executed:
|
||||
return self.EXECUTED
|
||||
return self.BAD_DEBT
|
||||
|
||||
|
||||
def clean(self):
|
||||
if self.amend_of_id:
|
||||
errors = {}
|
||||
|
@ -204,27 +206,27 @@ class Bill(models.Model):
|
|||
errors['amend_of'] = _("Related invoice is an amendment.")
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
|
||||
def get_payment_state_display(self):
|
||||
value = self.payment_state
|
||||
return force_text(dict(self.PAYMENT_STATES).get(value, value))
|
||||
|
||||
|
||||
def get_current_transaction(self):
|
||||
return self.transactions.exclude_rejected().first()
|
||||
|
||||
|
||||
def get_type(self):
|
||||
return self.type or self.get_class_type()
|
||||
|
||||
|
||||
@property
|
||||
def is_amend(self):
|
||||
return self.type in self.AMEND_MAP.values()
|
||||
|
||||
|
||||
def get_amend_type(self):
|
||||
amend_type = self.AMEND_MAP.get(self.type)
|
||||
if amend_type is None:
|
||||
raise TypeError("%s has no associated amend type." % self.type)
|
||||
return amend_type
|
||||
|
||||
|
||||
def get_number(self):
|
||||
cls = type(self)
|
||||
if cls is models.DEFERRED:
|
||||
|
@ -248,16 +250,16 @@ class Bill(models.Model):
|
|||
zeros = (number_length - len(str(number))) * '0'
|
||||
number = zeros + str(number)
|
||||
return '{prefix}{year}{number}'.format(prefix=prefix, year=year, number=number)
|
||||
|
||||
|
||||
def get_due_date(self, payment=None):
|
||||
now = timezone.now()
|
||||
if payment:
|
||||
return now + payment.get_due_delta()
|
||||
return now + relativedelta(months=1)
|
||||
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('admin:bills_bill_view', args=(self.pk,))
|
||||
|
||||
|
||||
def close(self, payment=False):
|
||||
if not self.is_open:
|
||||
raise TypeError("Bill not in Open state.")
|
||||
|
@ -276,10 +278,10 @@ class Bill(models.Model):
|
|||
self.html = self.render(payment=payment)
|
||||
self.save()
|
||||
return transaction
|
||||
|
||||
|
||||
def get_billing_contact_emails(self):
|
||||
return self.account.get_contacts_emails(usages=(Contact.BILLING,))
|
||||
|
||||
|
||||
def send(self):
|
||||
pdf = self.as_pdf()
|
||||
self.account.send_email(
|
||||
|
@ -296,12 +298,12 @@ class Bill(models.Model):
|
|||
)
|
||||
self.is_sent = True
|
||||
self.save(update_fields=['is_sent'])
|
||||
|
||||
|
||||
def render(self, payment=False, language=None):
|
||||
with translation.override(language or self.account.language):
|
||||
if payment is False:
|
||||
payment = self.account.paymentsources.get_default()
|
||||
context = Context({
|
||||
context = {
|
||||
'bill': self,
|
||||
'lines': self.lines.all().prefetch_related('sublines'),
|
||||
'seller': self.seller,
|
||||
|
@ -316,29 +318,29 @@ class Bill(models.Model):
|
|||
'payment': payment and payment.get_bill_context(),
|
||||
'default_due_date': self.get_due_date(payment=payment),
|
||||
'now': timezone.now(),
|
||||
})
|
||||
}
|
||||
template_name = 'BILLS_%s_TEMPLATE' % self.get_type()
|
||||
template = getattr(settings, template_name, settings.BILLS_DEFAULT_TEMPLATE)
|
||||
bill_template = loader.get_template(template)
|
||||
html = bill_template.render(context)
|
||||
html = html.replace('-pageskip-', '<pdf:nextpage />')
|
||||
return html
|
||||
|
||||
|
||||
def as_pdf(self):
|
||||
html = self.html or self.render()
|
||||
return html_to_pdf(html, pagination=self.has_multiple_pages)
|
||||
|
||||
|
||||
def updated(self):
|
||||
self.updated_on = timezone.now()
|
||||
self.save(update_fields=('updated_on',))
|
||||
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.type:
|
||||
self.type = self.get_type()
|
||||
if not self.number:
|
||||
self.number = self.get_number()
|
||||
super(Bill, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
@cached
|
||||
def compute_subtotals(self):
|
||||
subtotals = {}
|
||||
|
@ -352,21 +354,21 @@ class Bill(models.Model):
|
|||
for tax, subtotal in subtotals.items():
|
||||
result[tax] = [subtotal, round(tax/100*subtotal, 2)]
|
||||
return result
|
||||
|
||||
|
||||
@cached
|
||||
def compute_base(self):
|
||||
bases = self.lines.annotate(
|
||||
bases=F('subtotal') + Sum(Coalesce('sublines__total', 0))
|
||||
)
|
||||
return round(bases.aggregate(Sum('bases'))['bases__sum'] or 0, 2)
|
||||
|
||||
|
||||
@cached
|
||||
def compute_tax(self):
|
||||
taxes = self.lines.annotate(
|
||||
taxes=(F('subtotal') + Coalesce(Sum('sublines__total'), 0)) * (F('tax')/100)
|
||||
)
|
||||
return round(taxes.aggregate(Sum('taxes'))['taxes__sum'] or 0, 2)
|
||||
|
||||
|
||||
@cached
|
||||
def compute_total(self):
|
||||
if 'lines' in getattr(self, '_prefetched_objects_cache', ()):
|
||||
|
@ -392,6 +394,11 @@ class AmendmentInvoice(Bill):
|
|||
proxy = True
|
||||
|
||||
|
||||
class AbonoInvoice(Bill):
|
||||
class Meta:
|
||||
proxy = True
|
||||
|
||||
|
||||
class Fee(Bill):
|
||||
class Meta:
|
||||
proxy = True
|
||||
|
@ -409,7 +416,7 @@ class ProForma(Bill):
|
|||
|
||||
class BillLine(models.Model):
|
||||
""" 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)
|
||||
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,
|
||||
|
@ -427,24 +434,24 @@ class BillLine(models.Model):
|
|||
created_on = models.DateField(_("created"), auto_now_add=True)
|
||||
# Amendment
|
||||
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:
|
||||
get_latest_by = 'id'
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return "#%i" % self.pk if self.pk else self.description
|
||||
|
||||
|
||||
def get_verbose_quantity(self):
|
||||
return self.verbose_quantity or self.quantity
|
||||
|
||||
|
||||
def clean(self):
|
||||
if not self.verbose_quantity:
|
||||
quantity = str(self.quantity)
|
||||
# Strip trailing zeros
|
||||
if quantity.endswith('0'):
|
||||
self.verbose_quantity = quantity.strip('0').strip('.')
|
||||
|
||||
|
||||
def get_verbose_period(self):
|
||||
from django.template.defaultfilters import date
|
||||
date_format = "N 'y"
|
||||
|
@ -460,7 +467,7 @@ class BillLine(models.Model):
|
|||
if ini == end:
|
||||
return ini
|
||||
return "{ini} / {end}".format(ini=ini, end=end)
|
||||
|
||||
|
||||
@cached
|
||||
def compute_total(self):
|
||||
total = self.subtotal or 0
|
||||
|
@ -471,7 +478,7 @@ class BillLine(models.Model):
|
|||
else:
|
||||
total += self.sublines.aggregate(sub_total=Sum('total'))['sub_total'] or 0
|
||||
return round(total, 2)
|
||||
|
||||
|
||||
def get_absolute_url(self):
|
||||
return change_url(self)
|
||||
|
||||
|
@ -486,12 +493,12 @@ class BillSubline(models.Model):
|
|||
(COMPENSATION, _("Compensation")),
|
||||
(OTHER, _("Other")),
|
||||
)
|
||||
|
||||
|
||||
# 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)
|
||||
total = models.DecimalField(max_digits=12, decimal_places=2)
|
||||
type = models.CharField(_("type"), max_length=16, choices=TYPES, default=OTHER)
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return "%s %i" % (self.description, self.total)
|
||||
|
|
|
@ -18,6 +18,9 @@ BILLS_AMENDMENT_INVOICE_NUMBER_PREFIX = Setting('BILLS_AMENDMENT_INVOICE_NUMBER_
|
|||
'A'
|
||||
)
|
||||
|
||||
BILLS_ABONOINVOICE_NUMBER_PREFIX = Setting('BILLS_ABONOINVOICE_NUMBER_PREFIX',
|
||||
'AB'
|
||||
)
|
||||
|
||||
BILLS_FEE_NUMBER_PREFIX = Setting('BILLS_FEE_NUMBER_PREFIX',
|
||||
'F'
|
||||
|
|
|
@ -282,3 +282,17 @@ a:hover {
|
|||
#questions {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
|
||||
#watermark {
|
||||
color: #d0d0d0;
|
||||
font-size: 100pt;
|
||||
-webkit-transform: rotate(-45deg);
|
||||
-moz-transform: rotate(-45deg);
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
z-index: -1;
|
||||
max-width: 593px;
|
||||
}
|
|
@ -12,6 +12,12 @@
|
|||
{% block body %}
|
||||
<div class="wrapper">
|
||||
<div class="content">
|
||||
{% if bill.is_open %}
|
||||
<!-- TODO DANIEL: falta arreglar el css d'aquesta cosa -->
|
||||
<div id="watermark">
|
||||
<p>ESBORRANY - DRAFT - BORRADOR</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% block header %}
|
||||
<div id="logo">
|
||||
{% block logo %}
|
||||
|
|
|
@ -7,7 +7,7 @@ from orchestra.admin.actions import SendEmail
|
|||
from orchestra.admin.utils import insertattr, change_url
|
||||
from orchestra.contrib.accounts.actions import list_accounts
|
||||
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 .models import Contact
|
||||
|
@ -61,18 +61,18 @@ class ContactAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
|||
}),
|
||||
)
|
||||
actions = (SendEmail(), list_accounts)
|
||||
|
||||
|
||||
def dispaly_name(self, contact):
|
||||
return str(contact)
|
||||
dispaly_name.short_description = _("Name")
|
||||
dispaly_name.admin_order_field = 'short_name'
|
||||
|
||||
|
||||
def formfield_for_dbfield(self, db_field, **kwargs):
|
||||
""" Make value input widget bigger """
|
||||
if db_field.name == 'address':
|
||||
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 2})
|
||||
if db_field.name == 'email_usage':
|
||||
kwargs['widget'] = paddingCheckboxSelectMultiple(130)
|
||||
kwargs['widget'] = PaddingCheckboxSelectMultiple(130)
|
||||
return super(ContactAdmin, self).formfield_for_dbfield(db_field, **kwargs)
|
||||
|
||||
|
||||
|
@ -86,14 +86,14 @@ class ContactInline(admin.StackedInline):
|
|||
fields = (
|
||||
('short_name', 'full_name'), 'email', 'email_usage', ('phone', 'phone2'),
|
||||
)
|
||||
|
||||
|
||||
def get_extra(self, request, obj=None, **kwargs):
|
||||
return 0 if obj and obj.contacts.exists() else 1
|
||||
|
||||
|
||||
def get_view_on_site_url(self, obj=None):
|
||||
if obj:
|
||||
return change_url(obj)
|
||||
|
||||
|
||||
def formfield_for_dbfield(self, db_field, **kwargs):
|
||||
""" Make value input widget bigger """
|
||||
if db_field.name == 'short_name':
|
||||
|
@ -101,7 +101,7 @@ class ContactInline(admin.StackedInline):
|
|||
if db_field.name == 'address':
|
||||
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 2})
|
||||
if db_field.name == 'email_usage':
|
||||
kwargs['widget'] = paddingCheckboxSelectMultiple(45)
|
||||
kwargs['widget'] = PaddingCheckboxSelectMultiple(45)
|
||||
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
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
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
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
|
@ -29,11 +29,11 @@ class Contact(models.Model):
|
|||
('ADDS', _("Announcements")),
|
||||
('EMERGENCY', _("Emergency contact")),
|
||||
)
|
||||
|
||||
|
||||
objects = ContactQuerySet.as_manager()
|
||||
|
||||
|
||||
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)
|
||||
full_name = models.CharField(_("full name"), max_length=256, blank=True)
|
||||
email = models.EmailField()
|
||||
|
@ -54,10 +54,10 @@ class Contact(models.Model):
|
|||
country = models.CharField(_("country"), max_length=20, blank=True,
|
||||
choices=settings.CONTACTS_COUNTRIES,
|
||||
default=settings.CONTACTS_DEFAULT_COUNTRY)
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return self.full_name or self.short_name
|
||||
|
||||
|
||||
def clean(self):
|
||||
self.short_name = self.short_name.strip()
|
||||
self.full_name = self.full_name.strip()
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
from django.conf.urls import url
|
||||
from django.contrib import admin
|
||||
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 orchestra.admin import ExtendedModelAdmin, ChangePasswordAdminMixin
|
||||
|
@ -12,6 +14,10 @@ from .filters import HasUserListFilter, HasDatabaseListFilter
|
|||
from .forms import DatabaseCreationForm, DatabaseUserChangeForm, DatabaseUserCreationForm
|
||||
from .models import Database, DatabaseUser
|
||||
|
||||
def save_selected(modeladmin, request, queryset):
|
||||
for selected in queryset:
|
||||
selected.save()
|
||||
save_selected.short_description = "Re-save selected objects"
|
||||
|
||||
class DatabaseAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
|
||||
list_display = ('name', 'type', 'display_users', 'account_link')
|
||||
|
@ -22,7 +28,7 @@ class DatabaseAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
|
|||
fieldsets = (
|
||||
(None, {
|
||||
'classes': ('extrapretty',),
|
||||
'fields': ('account_link', 'name', 'type', 'users', 'display_users'),
|
||||
'fields': ('account_link', 'name', 'type', 'users', 'display_users', 'comments'),
|
||||
}),
|
||||
)
|
||||
add_fieldsets = (
|
||||
|
@ -44,18 +50,18 @@ class DatabaseAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
|
|||
filter_horizontal = ['users']
|
||||
filter_by_account_fields = ('users',)
|
||||
list_prefetch_related = ('users',)
|
||||
actions = (list_accounts,)
|
||||
|
||||
actions = (list_accounts, save_selected)
|
||||
|
||||
@mark_safe
|
||||
def display_users(self, db):
|
||||
links = []
|
||||
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)
|
||||
return '<br>'.join(links)
|
||||
display_users.short_description = _("Users")
|
||||
display_users.allow_tags = True
|
||||
display_users.admin_order_field = 'users__username'
|
||||
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
super(DatabaseAdmin, self).save_model(request, obj, form, change)
|
||||
if not change:
|
||||
|
@ -93,25 +99,25 @@ class DatabaseUserAdmin(SelectAccountAdminMixin, ChangePasswordAdminMixin, Exten
|
|||
readonly_fields = ('account_link', 'display_databases',)
|
||||
filter_by_account_fields = ('databases',)
|
||||
list_prefetch_related = ('databases',)
|
||||
actions = (list_accounts,)
|
||||
|
||||
actions = (list_accounts, save_selected)
|
||||
|
||||
@mark_safe
|
||||
def display_databases(self, user):
|
||||
links = []
|
||||
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)
|
||||
return '<br>'.join(links)
|
||||
display_databases.short_description = _("Databases")
|
||||
display_databases.allow_tags = True
|
||||
display_databases.admin_order_field = 'databases__name'
|
||||
|
||||
|
||||
def get_urls(self):
|
||||
useradmin = UserAdmin(DatabaseUser, self.admin_site)
|
||||
return [
|
||||
url(r'^(\d+)/password/$',
|
||||
self.admin_site.admin_view(useradmin.user_change_password))
|
||||
] + super(DatabaseUserAdmin, self).get_urls()
|
||||
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
""" set password """
|
||||
if not change:
|
||||
|
|
|
@ -17,11 +17,11 @@ class DatabaseUserCreationForm(forms.ModelForm):
|
|||
password2 = forms.CharField(label=_("Password confirmation"), required=False,
|
||||
widget=forms.PasswordInput,
|
||||
help_text=_("Enter the same password as above, for verification."))
|
||||
|
||||
|
||||
class Meta:
|
||||
model = DatabaseUser
|
||||
fields = ('username', 'account', 'type')
|
||||
|
||||
|
||||
def clean_password2(self):
|
||||
password1 = self.cleaned_data.get("password1")
|
||||
password2 = self.cleaned_data.get("password2")
|
||||
|
@ -40,11 +40,11 @@ class DatabaseCreationForm(DatabaseUserCreationForm):
|
|||
'invalid': _("This value may contain 16 characters or fewer, only letters, numbers and "
|
||||
"@/./+/-/_ characters.")})
|
||||
user = forms.ModelChoiceField(required=False, queryset=DatabaseUser.objects)
|
||||
|
||||
|
||||
class Meta:
|
||||
model = Database
|
||||
fields = ('username', 'account', 'type')
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(DatabaseCreationForm, self).__init__(*args, **kwargs)
|
||||
account_id = self.initial.get('account', self.initial_account)
|
||||
|
@ -53,13 +53,13 @@ class DatabaseCreationForm(DatabaseUserCreationForm):
|
|||
choices = [ (u.pk, "%s (%s)" % (u, u.get_type_display())) for u in qs ]
|
||||
self.fields['user'].queryset = qs
|
||||
self.fields['user'].choices = [(None, '--------'),] + choices
|
||||
|
||||
|
||||
def clean_username(self):
|
||||
username = self.cleaned_data.get('username')
|
||||
if DatabaseUser.objects.filter(username=username).exists():
|
||||
raise ValidationError("Provided username already exists.")
|
||||
return username
|
||||
|
||||
|
||||
def clean_password2(self):
|
||||
username = self.cleaned_data.get('username')
|
||||
password1 = self.cleaned_data.get('password1')
|
||||
|
@ -70,14 +70,14 @@ class DatabaseCreationForm(DatabaseUserCreationForm):
|
|||
msg = _("The two password fields didn't match.")
|
||||
raise ValidationError(msg)
|
||||
return password2
|
||||
|
||||
|
||||
def clean_user(self):
|
||||
user = self.cleaned_data.get('user')
|
||||
if user and user.type != self.cleaned_data.get('type'):
|
||||
msg = _("Database type and user type doesn't match")
|
||||
raise ValidationError(msg)
|
||||
return user
|
||||
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super(DatabaseCreationForm, self).clean()
|
||||
if 'user' in cleaned_data and 'username' in cleaned_data:
|
||||
|
@ -91,7 +91,7 @@ class DatabaseCreationForm(DatabaseUserCreationForm):
|
|||
|
||||
class ReadOnlySQLPasswordHashField(ReadOnlyPasswordHashField):
|
||||
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)
|
||||
if 'Invalid' not in original:
|
||||
return original
|
||||
|
@ -114,10 +114,10 @@ class DatabaseUserChangeForm(forms.ModelForm):
|
|||
"this user's password, but you can change the password "
|
||||
"using <a href='../password/'>this form</a>. "
|
||||
"<a onclick='return showAddAnotherPopup(this);' href='../hash/'>Show hash</a>."))
|
||||
|
||||
|
||||
class Meta:
|
||||
model = DatabaseUser
|
||||
fields = ('username', 'password', 'type', 'account')
|
||||
|
||||
|
||||
def clean_password(self):
|
||||
return self.initial["password"]
|
||||
|
|
|
@ -3,6 +3,7 @@ from __future__ import unicode_literals
|
|||
|
||||
from django.db import models, migrations
|
||||
from django.conf import settings
|
||||
import django.db.models.deletion
|
||||
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)),
|
||||
('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)),
|
||||
('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(
|
||||
|
@ -29,7 +30,7 @@ class Migration(migrations.Migration):
|
|||
('username', models.CharField(verbose_name='username', max_length=16, validators=[orchestra.core.validators.validate_name])),
|
||||
('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)),
|
||||
('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={
|
||||
'verbose_name_plural': 'DB users',
|
||||
|
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,25 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.5 on 2017-05-28 18:05
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('databases', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
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'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,20 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.5 on 2020-02-04 11:21
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('databases', '0002_auto_20170528_2005'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='database',
|
||||
name='comments',
|
||||
field=models.TextField(default=''),
|
||||
),
|
||||
]
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -12,7 +12,7 @@ class Database(models.Model):
|
|||
""" Represents a basic database for a web application """
|
||||
MYSQL = 'mysql'
|
||||
POSTGRESQL = 'postgresql'
|
||||
|
||||
|
||||
name = models.CharField(_("name"), max_length=64, # MySQL limit
|
||||
validators=[validators.validate_name])
|
||||
users = models.ManyToManyField('databases.DatabaseUser', blank=True,
|
||||
|
@ -20,15 +20,16 @@ class Database(models.Model):
|
|||
type = models.CharField(_("type"), max_length=32,
|
||||
choices=settings.DATABASES_TYPE_CHOICES,
|
||||
default=settings.DATABASES_DEFAULT_TYPE)
|
||||
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
|
||||
related_name='databases')
|
||||
|
||||
account = models.ForeignKey('accounts.Account', on_delete=models.CASCADE,
|
||||
verbose_name=_("Account"), related_name='databases')
|
||||
comments = models.TextField(default="", blank=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('name', 'type')
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return "%s" % self.name
|
||||
|
||||
|
||||
@property
|
||||
def owner(self):
|
||||
""" database owner is the first user related to it """
|
||||
|
@ -38,7 +39,7 @@ class Database(models.Model):
|
|||
if user is not None:
|
||||
return user.databaseuser
|
||||
return None
|
||||
|
||||
|
||||
@property
|
||||
def active(self):
|
||||
return self.account.is_active
|
||||
|
@ -52,26 +53,26 @@ Database.users.through._meta.unique_together = (
|
|||
class DatabaseUser(models.Model):
|
||||
MYSQL = Database.MYSQL
|
||||
POSTGRESQL = Database.POSTGRESQL
|
||||
|
||||
|
||||
username = models.CharField(_("username"), max_length=16, # MySQL usernames 16 char long
|
||||
validators=[validators.validate_name])
|
||||
password = models.CharField(_("password"), max_length=256)
|
||||
type = models.CharField(_("type"), max_length=32,
|
||||
choices=settings.DATABASES_TYPE_CHOICES,
|
||||
default=settings.DATABASES_DEFAULT_TYPE)
|
||||
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
|
||||
related_name='databaseusers')
|
||||
|
||||
account = models.ForeignKey('accounts.Account', on_delete=models.CASCADE,
|
||||
verbose_name=_("Account"), related_name='databaseusers')
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = _("DB users")
|
||||
unique_together = ('username', 'type')
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
||||
|
||||
|
||||
def get_username(self):
|
||||
return self.username
|
||||
|
||||
|
||||
def set_password(self, password):
|
||||
if self.type == self.MYSQL:
|
||||
# MySQL stores sha1(sha1(password).binary).hex
|
||||
|
|
|
@ -20,7 +20,7 @@ DATABASES_DEFAULT_TYPE = Setting('DATABASES_DEFAULT_TYPE',
|
|||
|
||||
DATABASES_DEFAULT_HOST = Setting('DATABASES_DEFAULT_HOST',
|
||||
'localhost',
|
||||
validators=[validate_hostname],
|
||||
# validators=[validate_hostname],
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -1,22 +1,24 @@
|
|||
import MySQLdb
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import MySQLdb
|
||||
from django.conf import settings as djsettings
|
||||
from django.core.management.base import CommandError
|
||||
from django.core.urlresolvers import reverse
|
||||
from selenium.webdriver.support.select import Select
|
||||
|
||||
from django.urls import reverse
|
||||
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.tests import (BaseLiveServerTestCase, random_ascii, save_response_on_error,
|
||||
snapshot_on_error)
|
||||
from orchestra.utils.tests import (BaseLiveServerTestCase, random_ascii,
|
||||
save_response_on_error, snapshot_on_error)
|
||||
from selenium.webdriver.support.select import Select
|
||||
|
||||
from ... import backends, settings
|
||||
from ...models import Database, DatabaseUser
|
||||
|
||||
TEST_REST_API = int(os.getenv('TEST_REST_API', '0'))
|
||||
|
||||
|
||||
class DatabaseTestMixin(object):
|
||||
MASTER_SERVER = os.environ.get('ORCHESTRA_SECOND_SERVER', 'localhost')
|
||||
|
@ -24,40 +26,40 @@ class DatabaseTestMixin(object):
|
|||
'orchestra.contrib.orchestration',
|
||||
'orcgestra.apps.databases',
|
||||
)
|
||||
|
||||
|
||||
def setUp(self):
|
||||
super(DatabaseTestMixin, self).setUp()
|
||||
self.add_route()
|
||||
djsettings.DEBUG = True
|
||||
|
||||
|
||||
def add_route(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def save(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def add(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def delete(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def update(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def disable(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def add_group(self, username, groupname):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def test_add(self):
|
||||
dbname = '%s_database' % random_ascii(5)
|
||||
username = '%s_dbuser' % random_ascii(5)
|
||||
password = '@!?%spppP001' % random_ascii(5)
|
||||
self.add(dbname, username, password)
|
||||
self.validate_create_table(dbname, username, password)
|
||||
|
||||
|
||||
def test_delete(self):
|
||||
dbname = '%s_database' % random_ascii(5)
|
||||
username = '%s_dbuser' % random_ascii(5)
|
||||
|
@ -68,7 +70,7 @@ class DatabaseTestMixin(object):
|
|||
self.delete_user(username)
|
||||
self.validate_delete(dbname, username, password)
|
||||
self.validate_delete_user(dbname, username)
|
||||
|
||||
|
||||
def test_change_password(self):
|
||||
dbname = '%s_database' % random_ascii(5)
|
||||
username = '%s_dbuser' % random_ascii(5)
|
||||
|
@ -81,7 +83,7 @@ class DatabaseTestMixin(object):
|
|||
self.change_password(username, new_password)
|
||||
self.validate_login_error(dbname, username, password)
|
||||
self.validate_create_table(dbname, username, new_password)
|
||||
|
||||
|
||||
def test_add_user(self):
|
||||
dbname = '%s_database' % random_ascii(5)
|
||||
username = '%s_dbuser' % random_ascii(5)
|
||||
|
@ -98,7 +100,7 @@ class DatabaseTestMixin(object):
|
|||
self.add_user_to_db(username2, dbname)
|
||||
self.validate_create_table(dbname, username, password)
|
||||
self.validate_create_table(dbname, username2, password2)
|
||||
|
||||
|
||||
def test_delete_user(self):
|
||||
dbname = '%s_database' % random_ascii(5)
|
||||
username = '%s_dbuser' % random_ascii(5)
|
||||
|
@ -117,7 +119,7 @@ class DatabaseTestMixin(object):
|
|||
self.delete_user(username2)
|
||||
self.validate_login_error(dbname, username2, password2)
|
||||
self.validate_delete_user(username2, password2)
|
||||
|
||||
|
||||
def test_swap_user(self):
|
||||
dbname = '%s_database' % random_ascii(5)
|
||||
username = '%s_dbuser' % random_ascii(5)
|
||||
|
@ -137,7 +139,7 @@ class DatabaseTestMixin(object):
|
|||
|
||||
class MySQLControllerMixin(object):
|
||||
db_type = 'mysql'
|
||||
|
||||
|
||||
def setUp(self):
|
||||
super(MySQLControllerMixin, self).setUp()
|
||||
# Get local ip address used to reach self.MASTER_SERVER
|
||||
|
@ -145,7 +147,7 @@ class MySQLControllerMixin(object):
|
|||
s.connect((self.MASTER_SERVER, 22))
|
||||
settings.DATABASES_DEFAULT_HOST = s.getsockname()[0]
|
||||
s.close()
|
||||
|
||||
|
||||
def add_route(self):
|
||||
server = Server.objects.create(name=self.MASTER_SERVER)
|
||||
backend = backends.MySQLController.get_name()
|
||||
|
@ -154,22 +156,22 @@ class MySQLControllerMixin(object):
|
|||
match = "databaseuser.type == '%s'" % self.db_type
|
||||
backend = backends.MySQLUserController.get_name()
|
||||
Route.objects.create(backend=backend, match=match, host=server)
|
||||
|
||||
|
||||
def validate_create_table(self, name, username, password):
|
||||
db = MySQLdb.connect(host=self.MASTER_SERVER, port=3306, user=username, passwd=password, db=name)
|
||||
cur = db.cursor()
|
||||
cur.execute('CREATE TABLE table_%s ( id INT ) ;' % random_ascii(10))
|
||||
|
||||
|
||||
def validate_login_error(self, dbname, username, password):
|
||||
self.assertRaises(MySQLdb.OperationalError,
|
||||
self.validate_create_table, dbname, username, password
|
||||
)
|
||||
|
||||
|
||||
def validate_delete(self, dbname, username, password):
|
||||
self.validate_login_error(dbname, username, password)
|
||||
self.assertRaises(CommandError,
|
||||
sshrun, self.MASTER_SERVER, 'mysql %s' % dbname, display=False)
|
||||
|
||||
|
||||
def validate_delete_user(self, name, username):
|
||||
context = {
|
||||
'name': name,
|
||||
|
@ -181,11 +183,12 @@ class MySQLControllerMixin(object):
|
|||
"""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):
|
||||
def setUp(self):
|
||||
super(RESTDatabaseMixin, self).setUp()
|
||||
self.rest_login()
|
||||
|
||||
|
||||
@save_response_on_error
|
||||
def add(self, dbname, username, password):
|
||||
user = self.rest.databaseusers.create(username=username, password=password, type=self.db_type)
|
||||
|
@ -193,31 +196,31 @@ class RESTDatabaseMixin(DatabaseTestMixin):
|
|||
'username': user.username
|
||||
}]
|
||||
self.rest.databases.create(name=dbname, users=users, type=self.db_type)
|
||||
|
||||
|
||||
@save_response_on_error
|
||||
def delete(self, dbname):
|
||||
self.rest.databases.retrieve(name=dbname).delete()
|
||||
|
||||
|
||||
@save_response_on_error
|
||||
def change_password(self, username, password):
|
||||
user = self.rest.databaseusers.retrieve(username=username).get()
|
||||
user.set_password(password)
|
||||
|
||||
|
||||
@save_response_on_error
|
||||
def add_user(self, username, password):
|
||||
self.rest.databaseusers.create(username=username, password=password, type=self.db_type)
|
||||
|
||||
|
||||
@save_response_on_error
|
||||
def add_user_to_db(self, username, dbname):
|
||||
user = self.rest.databaseusers.retrieve(username=username).get()
|
||||
db = self.rest.databases.retrieve(name=dbname).get()
|
||||
db.users.append(user)
|
||||
db.save()
|
||||
|
||||
|
||||
@save_response_on_error
|
||||
def delete_user(self, username):
|
||||
self.rest.databaseusers.retrieve(username=username).delete()
|
||||
|
||||
|
||||
@save_response_on_error
|
||||
def swap_user(self, username, username2, dbname):
|
||||
user = self.rest.databaseusers.retrieve(username=username2).get()
|
||||
|
@ -231,84 +234,84 @@ class AdminDatabaseMixin(DatabaseTestMixin):
|
|||
def setUp(self):
|
||||
super(AdminDatabaseMixin, self).setUp()
|
||||
self.admin_login()
|
||||
|
||||
|
||||
@snapshot_on_error
|
||||
def add(self, dbname, username, password):
|
||||
url = self.live_server_url + reverse('admin:databases_database_add')
|
||||
self.selenium.get(url)
|
||||
|
||||
|
||||
type_input = self.selenium.find_element_by_id('id_type')
|
||||
type_select = Select(type_input)
|
||||
type_select.select_by_value(self.db_type)
|
||||
|
||||
|
||||
name_field = self.selenium.find_element_by_id('id_name')
|
||||
name_field.send_keys(dbname)
|
||||
|
||||
|
||||
username_field = self.selenium.find_element_by_id('id_username')
|
||||
username_field.send_keys(username)
|
||||
|
||||
|
||||
password_field = self.selenium.find_element_by_id('id_password1')
|
||||
password_field.send_keys(password)
|
||||
password_field = self.selenium.find_element_by_id('id_password2')
|
||||
password_field.send_keys(password)
|
||||
|
||||
|
||||
name_field.submit()
|
||||
self.assertNotEqual(url, self.selenium.current_url)
|
||||
|
||||
|
||||
@snapshot_on_error
|
||||
def delete(self, dbname):
|
||||
db = Database.objects.get(name=dbname)
|
||||
self.admin_delete(db)
|
||||
|
||||
|
||||
@snapshot_on_error
|
||||
def change_password(self, username, password):
|
||||
user = DatabaseUser.objects.get(username=username)
|
||||
self.admin_change_password(user, password)
|
||||
|
||||
|
||||
@snapshot_on_error
|
||||
def add_user(self, username, password):
|
||||
url = self.live_server_url + reverse('admin:databases_databaseuser_add')
|
||||
self.selenium.get(url)
|
||||
|
||||
|
||||
type_input = self.selenium.find_element_by_id('id_type')
|
||||
type_select = Select(type_input)
|
||||
type_select.select_by_value(self.db_type)
|
||||
|
||||
|
||||
username_field = self.selenium.find_element_by_id('id_username')
|
||||
username_field.send_keys(username)
|
||||
|
||||
|
||||
password_field = self.selenium.find_element_by_id('id_password1')
|
||||
password_field.send_keys(password)
|
||||
password_field = self.selenium.find_element_by_id('id_password2')
|
||||
password_field.send_keys(password)
|
||||
|
||||
|
||||
username_field.submit()
|
||||
self.assertNotEqual(url, self.selenium.current_url)
|
||||
|
||||
|
||||
@snapshot_on_error
|
||||
def add_user_to_db(self, username, dbname):
|
||||
database = Database.objects.get(name=dbname, type=self.db_type)
|
||||
url = self.live_server_url + change_url(database)
|
||||
self.selenium.get(url)
|
||||
|
||||
|
||||
user = DatabaseUser.objects.get(username=username, type=self.db_type)
|
||||
users_from = self.selenium.find_element_by_id('id_users_from')
|
||||
users_select = Select(users_from)
|
||||
users_select.select_by_value(str(user.pk))
|
||||
|
||||
|
||||
add_user = self.selenium.find_element_by_id('id_users_add_link')
|
||||
add_user.click()
|
||||
|
||||
|
||||
save = self.selenium.find_element_by_name('_save')
|
||||
save.submit()
|
||||
self.assertNotEqual(url, self.selenium.current_url)
|
||||
|
||||
|
||||
@snapshot_on_error
|
||||
def swap_user(self, username, username2, dbname):
|
||||
database = Database.objects.get(name=dbname, type=self.db_type)
|
||||
url = self.live_server_url + change_url(database)
|
||||
self.selenium.get(url)
|
||||
|
||||
|
||||
# remove user "username"
|
||||
user = DatabaseUser.objects.get(username=username, type=self.db_type)
|
||||
users_to = self.selenium.find_element_by_id('id_users_to')
|
||||
|
@ -317,7 +320,7 @@ class AdminDatabaseMixin(DatabaseTestMixin):
|
|||
remove_user = self.selenium.find_element_by_id('id_users_remove_link')
|
||||
remove_user.click()
|
||||
time.sleep(0.2)
|
||||
|
||||
|
||||
# add user "username2"
|
||||
user = DatabaseUser.objects.get(username=username2, type=self.db_type)
|
||||
users_from = self.selenium.find_element_by_id('id_users_from')
|
||||
|
@ -326,11 +329,11 @@ class AdminDatabaseMixin(DatabaseTestMixin):
|
|||
add_user = self.selenium.find_element_by_id('id_users_add_link')
|
||||
add_user.click()
|
||||
time.sleep(0.2)
|
||||
|
||||
|
||||
save = self.selenium.find_element_by_name('_save')
|
||||
save.submit()
|
||||
self.assertNotEqual(url, self.selenium.current_url)
|
||||
|
||||
|
||||
@snapshot_on_error
|
||||
def delete_user(self, username):
|
||||
user = DatabaseUser.objects.get(username=username)
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
from django.contrib import admin
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.urls import reverse
|
||||
from django.db import models
|
||||
from django.db.models.functions import Concat, Coalesce
|
||||
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 orchestra.admin import ExtendedModelAdmin
|
||||
|
@ -32,18 +34,18 @@ class DomainInline(admin.TabularInline):
|
|||
readonly_fields = ('domain_link', 'display_records', 'account_link')
|
||||
extra = 0
|
||||
verbose_name_plural = _("Subdomains")
|
||||
|
||||
|
||||
domain_link = admin_link('__str__')
|
||||
domain_link.short_description = _("Name")
|
||||
account_link = admin_link('account')
|
||||
|
||||
|
||||
def display_records(self, domain):
|
||||
return ', '.join([record.type for record in domain.records.all()])
|
||||
display_records.short_description = _("Declared records")
|
||||
|
||||
|
||||
def has_add_permission(self, *args, **kwargs):
|
||||
return False
|
||||
|
||||
|
||||
def get_queryset(self, request):
|
||||
""" Order by structured name and imporve performance """
|
||||
qs = super(DomainInline, self).get_queryset(request)
|
||||
|
@ -55,7 +57,7 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
|||
'structured_name', 'display_is_top', 'display_websites', 'display_addresses', 'account_link'
|
||||
)
|
||||
add_fields = ('name', 'account')
|
||||
fields = ('name', 'account_link', 'display_websites', 'display_addresses')
|
||||
fields = ('name', 'account_link', 'display_websites', 'display_addresses', 'dns2136_address_match_list')
|
||||
readonly_fields = (
|
||||
'account_link', 'top_link', 'display_websites', 'display_addresses', 'implicit_records'
|
||||
)
|
||||
|
@ -66,23 +68,23 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
|||
add_form = BatchDomainCreationAdminForm
|
||||
actions = (edit_records, set_soa, list_accounts)
|
||||
change_view_actions = (view_zone, edit_records)
|
||||
|
||||
|
||||
top_link = admin_link('top')
|
||||
|
||||
|
||||
def structured_name(self, domain):
|
||||
if domain.is_top:
|
||||
return domain.name
|
||||
return ' '*4 + domain.name
|
||||
return mark_safe(' '*4 + domain.name)
|
||||
structured_name.short_description = _("name")
|
||||
structured_name.allow_tags = True
|
||||
structured_name.admin_order_field = 'structured_name'
|
||||
|
||||
|
||||
def display_is_top(self, domain):
|
||||
return domain.is_top
|
||||
display_is_top.short_description = _("Is top")
|
||||
display_is_top.boolean = True
|
||||
display_is_top.admin_order_field = 'top'
|
||||
|
||||
|
||||
@mark_safe
|
||||
def display_websites(self, domain):
|
||||
if apps.isinstalled('orchestra.contrib.websites'):
|
||||
websites = domain.websites.all()
|
||||
|
@ -92,22 +94,22 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
|||
site_link = get_on_site_link(website.get_absolute_url())
|
||||
admin_url = change_url(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)
|
||||
links.append(link)
|
||||
return '<br>'.join(links)
|
||||
add_url = reverse('admin:websites_website_add')
|
||||
add_url += '?account=%i&domains=%i' % (domain.account_id, domain.pk)
|
||||
image = '<img src="%s"></img>' % static('orchestra/images/add.png')
|
||||
add_link = '<a href="%s" title="%s">%s</a>' % (
|
||||
add_url, _("Add website"), image
|
||||
add_link = format_html(
|
||||
'<a href="{}" title="{}"><img src="{}" /></a>', add_url,
|
||||
_("Add website"), static('orchestra/images/add.png'),
|
||||
)
|
||||
return _("No website %s") % (add_link)
|
||||
return '---'
|
||||
display_websites.admin_order_field = 'websites__name'
|
||||
display_websites.short_description = _("Websites")
|
||||
display_websites.allow_tags = True
|
||||
|
||||
|
||||
@mark_safe
|
||||
def display_addresses(self, domain):
|
||||
if apps.isinstalled('orchestra.contrib.mailboxes'):
|
||||
add_url = reverse('admin:mailboxes_address_add')
|
||||
|
@ -126,10 +128,9 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
|||
return '---'
|
||||
display_addresses.short_description = _("Addresses")
|
||||
display_addresses.admin_order_field = 'addresses__count'
|
||||
display_addresses.allow_tags = True
|
||||
|
||||
|
||||
@mark_safe
|
||||
def implicit_records(self, domain):
|
||||
defaults = []
|
||||
types = set(domain.records.values_list('type', flat=True))
|
||||
ttl = settings.DOMAINS_DEFAULT_TTL
|
||||
lines = []
|
||||
|
@ -141,15 +142,14 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
|||
value=record.value
|
||||
)
|
||||
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:
|
||||
lines.insert(0, line)
|
||||
else:
|
||||
lines.append(line)
|
||||
return '<br>'.join(lines)
|
||||
implicit_records.short_description = _("Implicit records")
|
||||
implicit_records.allow_tags = True
|
||||
|
||||
|
||||
def get_fieldsets(self, request, obj=None):
|
||||
""" Add SOA fields when domain is top """
|
||||
fieldsets = super(DomainAdmin, self).get_fieldsets(request, obj)
|
||||
|
@ -175,13 +175,13 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
|||
if 'top_link' not in existing:
|
||||
fieldsets[0][1]['fields'].insert(2, 'top_link')
|
||||
return fieldsets
|
||||
|
||||
|
||||
def get_inline_instances(self, request, obj=None):
|
||||
inlines = super(DomainAdmin, self).get_inline_instances(request, obj)
|
||||
if not obj or not obj.is_top:
|
||||
return [inline for inline in inlines if type(inline) != DomainInline]
|
||||
return inlines
|
||||
|
||||
|
||||
def get_queryset(self, request):
|
||||
""" Order by structured name and imporve performance """
|
||||
qs = super(DomainAdmin, self).get_queryset(request)
|
||||
|
@ -196,7 +196,7 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
|||
if apps.isinstalled('orchestra.contrib.mailboxes'):
|
||||
qs = qs.annotate(models.Count('addresses'))
|
||||
return qs
|
||||
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
""" batch domain creation support """
|
||||
super(DomainAdmin, self).save_model(request, obj, form, change)
|
||||
|
@ -205,7 +205,7 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
|||
for name in form.extra_names:
|
||||
domain = Domain.objects.create(name=name, account_id=obj.account_id)
|
||||
self.extra_domains.append(domain)
|
||||
|
||||
|
||||
def save_related(self, request, form, formsets, change):
|
||||
""" batch domain creation support """
|
||||
super(DomainAdmin, self).save_related(request, form, formsets, change)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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 orchestra.api import router
|
||||
|
@ -14,18 +14,18 @@ class DomainViewSet(AccountApiMixin, viewsets.ModelViewSet):
|
|||
serializer_class = DomainSerializer
|
||||
filter_fields = ('name',)
|
||||
queryset = Domain.objects.all()
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super(DomainViewSet, self).get_queryset()
|
||||
return qs.prefetch_related('records')
|
||||
|
||||
@detail_route()
|
||||
|
||||
@action(detail=True)
|
||||
def view_zone(self, request, pk=None):
|
||||
domain = self.get_object()
|
||||
return Response({
|
||||
'zone': domain.render_zone()
|
||||
})
|
||||
|
||||
|
||||
def options(self, request):
|
||||
metadata = super(DomainViewSet, self).options(request)
|
||||
names = ['DOMAINS_DEFAULT_A', 'DOMAINS_DEFAULT_MX', 'DOMAINS_DEFAULT_NS']
|
||||
|
|
|
@ -102,7 +102,7 @@ class Bind9MasterDomainController(ServiceController):
|
|||
self.append(textwrap.dedent("""
|
||||
# Apply changes
|
||||
if [[ $UPDATED == 1 ]]; then
|
||||
service bind9 reload
|
||||
rm /etc/bind/master/*jnl || true; service bind9 restart
|
||||
fi""")
|
||||
)
|
||||
|
||||
|
@ -158,6 +158,7 @@ class Bind9MasterDomainController(ServiceController):
|
|||
'slaves': '; '.join(slaves) or 'none',
|
||||
'also_notify': '; '.join(slaves) + ';' if slaves else '',
|
||||
'conf_path': self.CONF_PATH,
|
||||
'dns2136_address_match_list': domain.dns2136_address_match_list
|
||||
}
|
||||
context['conf'] = textwrap.dedent("""\
|
||||
zone "%(name)s" {
|
||||
|
@ -166,6 +167,7 @@ class Bind9MasterDomainController(ServiceController):
|
|||
file "%(zone_path)s";
|
||||
allow-transfer { %(slaves)s; };
|
||||
also-notify { %(also_notify)s };
|
||||
allow-update { %(dns2136_address_match_list)s };
|
||||
notify yes;
|
||||
};""") % context
|
||||
return context
|
||||
|
|
|
@ -14,7 +14,7 @@ class BatchDomainCreationAdminForm(forms.ModelForm):
|
|||
name = forms.CharField(label=_("Names"), widget=forms.Textarea(attrs={'rows': 5, 'cols': 50}),
|
||||
help_text=_("Fully qualified domain name per line. "
|
||||
"All domains will have the provided account and records."))
|
||||
|
||||
|
||||
def clean_name(self):
|
||||
self.extra_names = []
|
||||
target = None
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
import django.db.models.deletion
|
||||
import orchestra.contrib.domains.utils
|
||||
import orchestra.contrib.domains.validators
|
||||
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')),
|
||||
('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')),
|
||||
('account', models.ForeignKey(related_name='domains', help_text='Automatically selected for subdomains.', to=settings.AUTH_USER_MODEL, verbose_name='Account', blank=True)),
|
||||
('top', models.ForeignKey(null=True, to='domains.Domain', editable=False, related_name='subdomain_set')),
|
||||
('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(on_delete=django.db.models.deletion.CASCADE, null=True, to='domains.Domain', editable=False, related_name='subdomain_set')),
|
||||
],
|
||||
),
|
||||
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)),
|
||||
('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')),
|
||||
('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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -2,6 +2,7 @@
|
|||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import orchestra.contrib.domains.validators
|
||||
|
||||
|
||||
|
@ -20,7 +21,7 @@ class Migration(migrations.Migration):
|
|||
migrations.AlterField(
|
||||
model_name='domain',
|
||||
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(
|
||||
model_name='record',
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.5 on 2017-05-28 18:11
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import orchestra.contrib.domains.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('domains', '0005_auto_20160219_1034'),
|
||||
]
|
||||
|
||||
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>30m</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 30m', max_length=8, validators=[orchestra.contrib.domains.validators.validate_zone_interval], verbose_name='TTL'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='record',
|
||||
name='type',
|
||||
field=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'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,20 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.5 on 2019-08-05 09:34
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('domains', '0006_auto_20170528_2011'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='record',
|
||||
name='value',
|
||||
field=models.CharField(help_text='MX, NS and CNAME records sould end with a dot.', max_length=1024, verbose_name='value'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,20 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.5 on 2019-09-20 07:21
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('domains', '0007_auto_20190805_1134'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='domain',
|
||||
name='dns2136_address_match_list',
|
||||
field=models.CharField(blank=True, default='none;', 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),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,20 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.5 on 2020-02-04 11:17
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('domains', '0008_domain_dns2136_address_match_list'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
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),
|
||||
),
|
||||
]
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -31,9 +31,9 @@ class Domain(models.Model):
|
|||
validators.validate_allowed_domain
|
||||
])
|
||||
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',
|
||||
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,
|
||||
help_text=_("A revision number that changes whenever this domain is updated."))
|
||||
refresh = models.CharField(_("refresh"), max_length=16, blank=True,
|
||||
|
@ -65,16 +65,20 @@ class Domain(models.Model):
|
|||
"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>%s</tt>.") % settings.DOMAINS_DEFAULT_MIN_TTL)
|
||||
|
||||
dns2136_address_match_list = models.CharField(max_length=80, default=settings.DOMAINS_DEFAULT_DNS2136,
|
||||
blank=True,
|
||||
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.")
|
||||
|
||||
objects = DomainQuerySet.as_manager()
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
@property
|
||||
def origin(self):
|
||||
return self.top or self
|
||||
|
||||
|
||||
@property
|
||||
def is_top(self):
|
||||
# don't cache, don't replace by top_id
|
||||
|
@ -82,14 +86,14 @@ class Domain(models.Model):
|
|||
return not bool(self.top)
|
||||
except Domain.DoesNotExist:
|
||||
return False
|
||||
|
||||
|
||||
@property
|
||||
def subdomains(self):
|
||||
return Domain.objects.filter(name__regex='\.%s$' % self.name)
|
||||
|
||||
|
||||
def clean(self):
|
||||
self.name = self.name.lower()
|
||||
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" create top relation """
|
||||
update = False
|
||||
|
@ -106,7 +110,7 @@ class Domain(models.Model):
|
|||
# queryset.update() is not used because we want to trigger backend to delete ex-topdomains
|
||||
domain.top = self
|
||||
domain.save(update_fields=('top',))
|
||||
|
||||
|
||||
def get_description(self):
|
||||
if self.is_top:
|
||||
num = self.subdomains.count()
|
||||
|
@ -115,21 +119,21 @@ class Domain(models.Model):
|
|||
_("top domain with %d subdomains") % num,
|
||||
num)
|
||||
return _("subdomain")
|
||||
|
||||
|
||||
def get_absolute_url(self):
|
||||
return 'http://%s' % self.name
|
||||
|
||||
|
||||
def get_declared_records(self):
|
||||
""" proxy method, needed for input validation, see helpers.domain_for_validation """
|
||||
return self.records.all()
|
||||
|
||||
|
||||
def get_subdomains(self):
|
||||
""" proxy method, needed for input validation, see helpers.domain_for_validation """
|
||||
return self.origin.subdomain_set.all().prefetch_related('records')
|
||||
|
||||
|
||||
def get_parent(self, top=False):
|
||||
return type(self).objects.get_parent(self.name, top=top)
|
||||
|
||||
|
||||
def render_zone(self):
|
||||
origin = self.origin
|
||||
zone = origin.render_records()
|
||||
|
@ -143,7 +147,7 @@ class Domain(models.Model):
|
|||
for subdomain in sorted(tail, key=lambda x: len(x.name), reverse=True):
|
||||
zone += subdomain.render_records()
|
||||
return zone.strip()
|
||||
|
||||
|
||||
def refresh_serial(self):
|
||||
""" Increases the domain serial number by one """
|
||||
serial = utils.generate_zone_serial()
|
||||
|
@ -155,7 +159,7 @@ class Domain(models.Model):
|
|||
serial = int(serial)
|
||||
self.serial = serial
|
||||
self.save(update_fields=('serial',))
|
||||
|
||||
|
||||
def get_default_soa(self):
|
||||
return ' '.join([
|
||||
"%s." % settings.DOMAINS_DEFAULT_NAME_SERVER,
|
||||
|
@ -166,7 +170,7 @@ class Domain(models.Model):
|
|||
self.expire or settings.DOMAINS_DEFAULT_EXPIRE,
|
||||
self.min_ttl or settings.DOMAINS_DEFAULT_MIN_TTL,
|
||||
])
|
||||
|
||||
|
||||
def get_default_records(self):
|
||||
defaults = []
|
||||
if self.is_top:
|
||||
|
@ -198,7 +202,7 @@ class Domain(models.Model):
|
|||
value=default_aaaa
|
||||
))
|
||||
return defaults
|
||||
|
||||
|
||||
def record_is_implicit(self, record, types):
|
||||
if record.type not in types:
|
||||
if record.type is Record.NS:
|
||||
|
@ -217,7 +221,7 @@ class Domain(models.Model):
|
|||
elif not has_a and not has_aaaa:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_records(self):
|
||||
types = set()
|
||||
records = utils.RecordStorage()
|
||||
|
@ -245,7 +249,7 @@ class Domain(models.Model):
|
|||
else:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
def render_records(self):
|
||||
result = ''
|
||||
for record in self.get_records():
|
||||
|
@ -269,7 +273,7 @@ class Domain(models.Model):
|
|||
value=record.value
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def has_default_mx(self):
|
||||
records = self.get_records()
|
||||
for record in records.by_type('MX'):
|
||||
|
@ -290,7 +294,7 @@ class Record(models.Model):
|
|||
TXT = 'TXT'
|
||||
SPF = 'SPF'
|
||||
SOA = 'SOA'
|
||||
|
||||
|
||||
TYPE_CHOICES = (
|
||||
(MX, "MX"),
|
||||
(NS, "NS"),
|
||||
|
@ -301,7 +305,7 @@ class Record(models.Model):
|
|||
(TXT, "TXT"),
|
||||
(SPF, "SPF"),
|
||||
)
|
||||
|
||||
|
||||
VALIDATORS = {
|
||||
MX: (validators.validate_mx_record,),
|
||||
NS: (validators.validate_zone_label,),
|
||||
|
@ -313,18 +317,19 @@ class Record(models.Model):
|
|||
SRV: (validators.validate_srv_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,
|
||||
help_text=_("Record TTL, defaults to %s") % settings.DOMAINS_DEFAULT_TTL,
|
||||
validators=[validators.validate_zone_interval])
|
||||
type = models.CharField(_("type"), max_length=32, choices=TYPE_CHOICES)
|
||||
value = models.CharField(_("value"), max_length=256,
|
||||
# max_length bumped from 256 to 1024 (arbitrary) on August 2019.
|
||||
value = models.CharField(_("value"), max_length=1024,
|
||||
help_text=_("MX, NS and CNAME records sould end with a dot."))
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return "%s %s IN %s %s" % (self.domain, self.get_ttl(), self.type, self.value)
|
||||
|
||||
|
||||
def clean(self):
|
||||
""" validates record value based on its type """
|
||||
# validate value
|
||||
|
@ -338,6 +343,6 @@ class Record(models.Model):
|
|||
raise ValidationError({
|
||||
'value': error,
|
||||
})
|
||||
|
||||
|
||||
def get_ttl(self):
|
||||
return self.ttl or settings.DOMAINS_DEFAULT_TTL
|
||||
|
|
|
@ -122,3 +122,6 @@ DOMAINS_MASTERS = Setting('DOMAINS_MASTERS',
|
|||
validators=[lambda masters: list(map(validate_ip_address, masters))],
|
||||
help_text="Additional master server ip addresses other than autodiscovered by router.get_servers()."
|
||||
)
|
||||
|
||||
#TODO remove pangea-specific default
|
||||
DOMAINS_DEFAULT_DNS2136 = "key pangea.key;"
|
||||
|
|
|
@ -4,7 +4,7 @@ import socket
|
|||
from functools import partial
|
||||
|
||||
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 orchestra.contrib.orchestration.models import Server, Route
|
||||
|
@ -23,7 +23,7 @@ class DomainTestMixin(object):
|
|||
SLAVE_SERVER = os.environ.get('ORCHESTRA_SLAVE_SERVER', 'localhost')
|
||||
MASTER_SERVER_ADDR = socket.gethostbyname(MASTER_SERVER)
|
||||
SLAVE_SERVER_ADDR = socket.gethostbyname(SLAVE_SERVER)
|
||||
|
||||
|
||||
def setUp(self):
|
||||
djsettings.DEBUG = True
|
||||
super(DomainTestMixin, self).setUp()
|
||||
|
@ -53,19 +53,19 @@ class DomainTestMixin(object):
|
|||
(Record.CNAME, 'external.server.org.'),
|
||||
)
|
||||
self.django_domain_name = 'django%s.lan' % random_ascii(10)
|
||||
|
||||
|
||||
def add_route(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def add(self, domain_name, records):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def delete(self, domain_name, records):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def update(self, domain_name, records):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def validate_add(self, server_addr, domain_name):
|
||||
context = {
|
||||
'domain_name': domain_name,
|
||||
|
@ -81,7 +81,7 @@ class DomainTestMixin(object):
|
|||
self.assertEqual('%s.' % settings.DOMAINS_DEFAULT_NAME_SERVER, soa[4])
|
||||
hostmaster = utils.format_hostmaster(settings.DOMAINS_DEFAULT_HOSTMASTER)
|
||||
self.assertEqual(hostmaster, soa[5])
|
||||
|
||||
|
||||
dig_ns = 'dig @%(server_addr)s %(domain_name)s NS|grep "\sNS\s"'
|
||||
name_servers = run(dig_ns % context).stdout
|
||||
# testdomain.org. 3600 IN NS ns1.orchestra.lan.
|
||||
|
@ -95,7 +95,7 @@ class DomainTestMixin(object):
|
|||
self.assertEqual('IN', ns[2])
|
||||
self.assertEqual('NS', ns[3])
|
||||
self.assertIn(ns[4], ns_records)
|
||||
|
||||
|
||||
dig_mx = 'dig @%(server_addr)s %(domain_name)s MX|grep "\sMX\s"'
|
||||
mail_servers = run(dig_mx % context).stdout
|
||||
for mx in mail_servers.splitlines():
|
||||
|
@ -107,7 +107,7 @@ class DomainTestMixin(object):
|
|||
self.assertEqual('MX', mx[3])
|
||||
self.assertIn(mx[4], ['10', '20'])
|
||||
self.assertIn(mx[5], ['mail2.orchestra.lan.', 'mail.orchestra.lan.'])
|
||||
|
||||
|
||||
def validate_delete(self, server_addr, domain_name):
|
||||
context = {
|
||||
'domain_name': domain_name,
|
||||
|
@ -122,7 +122,7 @@ class DomainTestMixin(object):
|
|||
self.assertNotEqual('%s.' % settings.DOMAINS_DEFAULT_NAME_SERVER, soa[4])
|
||||
hostmaster = utils.format_hostmaster(settings.DOMAINS_DEFAULT_HOSTMASTER)
|
||||
self.assertNotEqual(hostmaster, soa[5])
|
||||
|
||||
|
||||
def validate_update(self, server_addr, domain_name):
|
||||
context = {
|
||||
'domain_name': domain_name,
|
||||
|
@ -138,7 +138,7 @@ class DomainTestMixin(object):
|
|||
self.assertEqual('%s.' % settings.DOMAINS_DEFAULT_NAME_SERVER, soa[4])
|
||||
hostmaster = utils.format_hostmaster(settings.DOMAINS_DEFAULT_HOSTMASTER)
|
||||
self.assertEqual(hostmaster, soa[5])
|
||||
|
||||
|
||||
dig_ns = 'dig @%(server_addr)s %(domain_name)s NS |grep "\sNS\s"'
|
||||
name_servers = run(dig_ns % context).stdout
|
||||
ns_records = ['ns1.%s.' % self.domain_name, 'ns2.%s.' % self.domain_name]
|
||||
|
@ -151,7 +151,7 @@ class DomainTestMixin(object):
|
|||
self.assertEqual('IN', ns[2])
|
||||
self.assertEqual('NS', ns[3])
|
||||
self.assertIn(ns[4], ns_records)
|
||||
|
||||
|
||||
dig_mx = 'dig @%(server_addr)s %(domain_name)s MX | grep "\sMX\s"'
|
||||
mx = run(dig_mx % context).stdout.split()
|
||||
# testdomain.org. 3600 IN MX 10 orchestra.lan.
|
||||
|
@ -161,7 +161,7 @@ class DomainTestMixin(object):
|
|||
self.assertEqual('MX', mx[3])
|
||||
self.assertIn(mx[4], ['30', '40'])
|
||||
self.assertIn(mx[5], ['mail3.orchestra.lan.', 'mail4.orchestra.lan.'])
|
||||
|
||||
|
||||
def validate_www_update(self, server_addr, domain_name):
|
||||
context = {
|
||||
'domain_name': domain_name,
|
||||
|
@ -175,7 +175,7 @@ class DomainTestMixin(object):
|
|||
self.assertEqual('IN', cname[2])
|
||||
self.assertEqual('CNAME', cname[3])
|
||||
self.assertEqual('external.server.org.', cname[4])
|
||||
|
||||
|
||||
def test_add(self):
|
||||
self.add(self.ns1_name, self.ns1_records)
|
||||
self.add(self.ns2_name, self.ns2_records)
|
||||
|
@ -184,7 +184,7 @@ class DomainTestMixin(object):
|
|||
self.validate_add(self.MASTER_SERVER_ADDR, self.domain_name)
|
||||
time.sleep(1)
|
||||
self.validate_add(self.SLAVE_SERVER_ADDR, self.domain_name)
|
||||
|
||||
|
||||
def test_delete(self):
|
||||
self.add(self.ns1_name, self.ns1_records)
|
||||
self.add(self.ns2_name, self.ns2_records)
|
||||
|
@ -193,7 +193,7 @@ class DomainTestMixin(object):
|
|||
for name in [self.domain_name, self.ns1_name, self.ns2_name]:
|
||||
self.validate_delete(self.MASTER_SERVER_ADDR, name)
|
||||
self.validate_delete(self.SLAVE_SERVER_ADDR, name)
|
||||
|
||||
|
||||
def test_update(self):
|
||||
self.add(self.ns1_name, self.ns1_records)
|
||||
self.add(self.ns2_name, self.ns2_records)
|
||||
|
@ -209,7 +209,7 @@ class DomainTestMixin(object):
|
|||
self.validate_www_update(self.MASTER_SERVER_ADDR, self.domain_name)
|
||||
time.sleep(5)
|
||||
self.validate_www_update(self.SLAVE_SERVER_ADDR, self.domain_name)
|
||||
|
||||
|
||||
def test_add_add_delete_delete(self):
|
||||
self.add(self.ns1_name, self.ns1_records)
|
||||
self.add(self.ns2_name, self.ns2_records)
|
||||
|
@ -221,7 +221,7 @@ class DomainTestMixin(object):
|
|||
self.delete(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)
|
||||
|
||||
|
||||
def test_bad_creation(self):
|
||||
self.assertRaises((self.rest.ResponseStatusError, AssertionError),
|
||||
self.add, self.domain_name, self.domain_records)
|
||||
|
@ -232,7 +232,7 @@ class AdminDomainMixin(DomainTestMixin):
|
|||
super(AdminDomainMixin, self).setUp()
|
||||
self.add_route()
|
||||
self.admin_login()
|
||||
|
||||
|
||||
def _add_records(self, records):
|
||||
self.selenium.find_element_by_link_text('Add another Record').click()
|
||||
for i, record in zip(range(0, len(records)), records):
|
||||
|
@ -244,29 +244,29 @@ class AdminDomainMixin(DomainTestMixin):
|
|||
value_input.clear()
|
||||
value_input.send_keys(value)
|
||||
return value_input
|
||||
|
||||
|
||||
@snapshot_on_error
|
||||
def add(self, domain_name, records):
|
||||
add = reverse('admin:domains_domain_add')
|
||||
url = self.live_server_url + add
|
||||
self.selenium.get(url)
|
||||
|
||||
|
||||
name = self.selenium.find_element_by_id('id_name')
|
||||
name.send_keys(domain_name)
|
||||
|
||||
|
||||
account_input = self.selenium.find_element_by_id('id_account')
|
||||
account_select = Select(account_input)
|
||||
account_select.select_by_value(str(self.account.pk))
|
||||
|
||||
|
||||
value_input = self._add_records(records)
|
||||
value_input.submit()
|
||||
self.assertNotEqual(url, self.selenium.current_url)
|
||||
|
||||
|
||||
@snapshot_on_error
|
||||
def delete(self, domain_name):
|
||||
domain = Domain.objects.get(name=domain_name)
|
||||
self.admin_delete(domain)
|
||||
|
||||
|
||||
@snapshot_on_error
|
||||
def update(self, domain_name, records):
|
||||
domain = Domain.objects.get(name=domain_name)
|
||||
|
@ -283,18 +283,18 @@ class RESTDomainMixin(DomainTestMixin):
|
|||
super(RESTDomainMixin, self).setUp()
|
||||
self.rest_login()
|
||||
self.add_route()
|
||||
|
||||
|
||||
@save_response_on_error
|
||||
def add(self, domain_name, records):
|
||||
records = [ dict(type=type, value=value) for type,value in records ]
|
||||
self.rest.domains.create(name=domain_name, records=records)
|
||||
|
||||
|
||||
@save_response_on_error
|
||||
def delete(self, domain_name):
|
||||
domain = Domain.objects.get(name=domain_name)
|
||||
domain = self.rest.domains.retrieve(id=domain.pk)
|
||||
domain.delete()
|
||||
|
||||
|
||||
@save_response_on_error
|
||||
def update(self, domain_name, records):
|
||||
records = [ dict(type=type, value=value) for type,value in records ]
|
||||
|
@ -307,7 +307,7 @@ class Bind9BackendMixin(object):
|
|||
DEPENDENCIES = (
|
||||
'orchestra.contrib.orchestration',
|
||||
)
|
||||
|
||||
|
||||
def add_route(self):
|
||||
master = Server.objects.create(name=self.MASTER_SERVER, address=self.MASTER_SERVER_ADDR)
|
||||
backend = backends.Bind9MasterDomainController.get_name()
|
||||
|
|
|
@ -60,7 +60,7 @@ def validate_zone_label(value):
|
|||
if not value.endswith('.'):
|
||||
msg = _("Use a fully expanded domain name ending with a dot.")
|
||||
raise ValidationError(msg)
|
||||
if len(value) > 63:
|
||||
if len(value) > 254:
|
||||
raise ValidationError(_("Labels must be 63 characters or less."))
|
||||
|
||||
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
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_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):
|
||||
|
@ -30,15 +32,16 @@ class LogEntryAdmin(admin.ModelAdmin):
|
|||
actions = None
|
||||
list_select_related = ('user', 'content_type')
|
||||
list_display_links = None
|
||||
|
||||
|
||||
user_link = admin_link('user')
|
||||
display_action_time = admin_date('action_time', short_description=_("Time"))
|
||||
|
||||
|
||||
@mark_safe
|
||||
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,)),
|
||||
'img': static('admin/img/icon-changelink.svg'),
|
||||
}
|
||||
})
|
||||
if log.is_addition():
|
||||
return _('Added "%(link)s". %(edit)s') % {
|
||||
'link': self.content_object_link(log),
|
||||
|
@ -57,8 +60,7 @@ class LogEntryAdmin(admin.ModelAdmin):
|
|||
}
|
||||
display_message.short_description = _("Message")
|
||||
display_message.admin_order_field = 'action_flag'
|
||||
display_message.allow_tags = True
|
||||
|
||||
|
||||
def display_action(self, log):
|
||||
if log.is_addition():
|
||||
return _("Added")
|
||||
|
@ -67,7 +69,7 @@ class LogEntryAdmin(admin.ModelAdmin):
|
|||
return _("Deleted")
|
||||
display_action.short_description = _("Action")
|
||||
display_action.admin_order_field = 'action_flag'
|
||||
|
||||
|
||||
def content_object_link(self, log):
|
||||
ct = log.content_type
|
||||
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,))
|
||||
except NoReverseMatch:
|
||||
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.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):
|
||||
""" Add rel_opts and object to context """
|
||||
if not add and 'edit' in request.GET.urlencode():
|
||||
|
@ -89,14 +90,14 @@ class LogEntryAdmin(admin.ModelAdmin):
|
|||
})
|
||||
return super(LogEntryAdmin, self).render_change_form(
|
||||
request, context, add, change, form_url, obj)
|
||||
|
||||
|
||||
def response_change(self, request, obj):
|
||||
""" save and continue preserve edit query string """
|
||||
response = super(LogEntryAdmin, self).response_change(request, obj)
|
||||
if 'edit' in request.GET.urlencode() and 'edit' not in response.url:
|
||||
return HttpResponseRedirect(response.url + '?edit=True')
|
||||
return response
|
||||
|
||||
|
||||
def response_post_save_change(self, request, obj):
|
||||
""" save redirect to object history """
|
||||
if 'edit' in request.GET.urlencode():
|
||||
|
@ -109,19 +110,19 @@ class LogEntryAdmin(admin.ModelAdmin):
|
|||
}, post_url)
|
||||
return HttpResponseRedirect(post_url)
|
||||
return super(LogEntryAdmin, self).response_post_save_change(request, obj)
|
||||
|
||||
|
||||
def has_add_permission(self, *args, **kwargs):
|
||||
return False
|
||||
|
||||
|
||||
def has_delete_permission(self, *args, **kwargs):
|
||||
return False
|
||||
|
||||
|
||||
def log_addition(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
def log_change(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
def log_deletion(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
from django import forms
|
||||
from django.conf.urls import url
|
||||
from django.contrib import admin
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.urls import reverse
|
||||
from django.db import models
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.html import strip_tags
|
||||
from django.utils.html import format_html, strip_tags
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
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
|
||||
|
||||
|
||||
PRIORITY_COLORS = {
|
||||
PRIORITY_COLORS = {
|
||||
Ticket.HIGH: 'red',
|
||||
Ticket.MEDIUM: 'darkorange',
|
||||
Ticket.LOW: 'green',
|
||||
}
|
||||
|
||||
|
||||
STATE_COLORS = {
|
||||
STATE_COLORS = {
|
||||
Ticket.NEW: 'grey',
|
||||
Ticket.IN_PROGRESS: 'darkorange',
|
||||
Ticket.FEEDBACK: 'purple',
|
||||
|
@ -44,12 +45,13 @@ class MessageReadOnlyInline(admin.TabularInline):
|
|||
can_delete = False
|
||||
fields = ('content_html',)
|
||||
readonly_fields = ('content_html',)
|
||||
|
||||
|
||||
class Media:
|
||||
css = {
|
||||
'all': ('orchestra/css/hide-inline-id.css',)
|
||||
}
|
||||
|
||||
|
||||
@mark_safe
|
||||
def content_html(self, msg):
|
||||
context = {
|
||||
'number': msg.number,
|
||||
|
@ -58,16 +60,17 @@ class MessageReadOnlyInline(admin.TabularInline):
|
|||
}
|
||||
summary = _("#%(number)i Updated by %(author)s about %(time)s") % context
|
||||
header = '<strong style="color:#666;">%s</strong><hr />' % summary
|
||||
|
||||
content = markdown(msg.content)
|
||||
content = content.replace('>\n', '>')
|
||||
content = '<div style="padding-left:20px;">%s</div>' % content
|
||||
|
||||
return header + content
|
||||
content_html.short_description = _("Content")
|
||||
content_html.allow_tags = True
|
||||
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
@ -79,12 +82,12 @@ class MessageInline(admin.TabularInline):
|
|||
form = MessageInlineForm
|
||||
can_delete = False
|
||||
fields = ('content',)
|
||||
|
||||
|
||||
def get_formset(self, request, obj=None, **kwargs):
|
||||
""" hook request.user on the inline form """
|
||||
self.form.user = request.user
|
||||
return super(MessageInline, self).get_formset(request, obj, **kwargs)
|
||||
|
||||
|
||||
def get_queryset(self, request):
|
||||
""" Don't show any message """
|
||||
qs = super(MessageInline, self).get_queryset(request)
|
||||
|
@ -103,18 +106,18 @@ class TicketInline(admin.TabularInline):
|
|||
model = Ticket
|
||||
extra = 0
|
||||
max_num = 0
|
||||
|
||||
|
||||
creator_link = admin_link('creator')
|
||||
owner_link = admin_link('owner')
|
||||
created = admin_link('created_at')
|
||||
updated = admin_link('updated_at')
|
||||
colored_state = admin_colored('state', colors=STATE_COLORS, bold=False)
|
||||
colored_priority = admin_colored('priority', colors=PRIORITY_COLORS, bold=False)
|
||||
|
||||
|
||||
@mark_safe
|
||||
def ticket_id(self, instance):
|
||||
return '<b>%s</b>' % admin_link()(instance)
|
||||
ticket_id.short_description = '#'
|
||||
ticket_id.allow_tags = True
|
||||
|
||||
|
||||
class TicketAdmin(ExtendedModelAdmin):
|
||||
|
@ -135,7 +138,7 @@ class TicketAdmin(ExtendedModelAdmin):
|
|||
'owner__username'
|
||||
)
|
||||
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
|
||||
)
|
||||
sudo_actions = ('delete_selected',)
|
||||
|
@ -176,7 +179,7 @@ class TicketAdmin(ExtendedModelAdmin):
|
|||
}),
|
||||
)
|
||||
list_select_related = ('queue', 'owner', 'creator')
|
||||
|
||||
|
||||
class Media:
|
||||
css = {
|
||||
'all': ('issues/css/ticket-admin.css',)
|
||||
|
@ -184,14 +187,15 @@ class TicketAdmin(ExtendedModelAdmin):
|
|||
js = (
|
||||
'issues/js/ticket-admin.js',
|
||||
)
|
||||
|
||||
|
||||
display_creator = admin_link('creator')
|
||||
display_queue = admin_link('queue')
|
||||
display_owner = admin_link('owner')
|
||||
updated = admin_date('updated_at')
|
||||
display_state = admin_colored('state', colors=STATE_COLORS, bold=False)
|
||||
display_priority = admin_colored('priority', colors=PRIORITY_COLORS, bold=False)
|
||||
|
||||
|
||||
@mark_safe
|
||||
def display_summary(self, ticket):
|
||||
context = {
|
||||
'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
|
||||
return '<h4>Added by %(creator)s about %(created)s%(updated)s</h4>' % context
|
||||
display_summary.short_description = 'Summary'
|
||||
display_summary.allow_tags = True
|
||||
|
||||
|
||||
def unbold_id(self, ticket):
|
||||
""" Unbold id if ticket is read """
|
||||
if ticket.is_read_by(self.user):
|
||||
return '<span style="font-weight:normal;font-size:11px;">%s</span>' % ticket.pk
|
||||
return format_html('<span style="font-weight:normal;font-size:11px;">{}</span>', ticket.pk)
|
||||
return ticket.pk
|
||||
unbold_id.allow_tags = True
|
||||
unbold_id.short_description = "#"
|
||||
unbold_id.admin_order_field = 'id'
|
||||
|
||||
|
||||
def bold_subject(self, ticket):
|
||||
""" Bold subject when tickets are unread for request.user """
|
||||
if ticket.is_read_by(self.user):
|
||||
return ticket.subject
|
||||
return "<strong class='unread'>%s</strong>" % ticket.subject
|
||||
bold_subject.allow_tags = True
|
||||
return format_html("<strong class='unread'>{}</strong>", ticket.subject)
|
||||
bold_subject.short_description = _("Subject")
|
||||
bold_subject.admin_order_field = 'subject'
|
||||
|
||||
|
||||
def formfield_for_dbfield(self, db_field, **kwargs):
|
||||
""" Make value input widget bigger """
|
||||
if db_field.name == 'subject':
|
||||
kwargs['widget'] = forms.TextInput(attrs={'size':'120'})
|
||||
return super(TicketAdmin, self).formfield_for_dbfield(db_field, **kwargs)
|
||||
|
||||
|
||||
def save_model(self, request, obj, *args, **kwargs):
|
||||
""" Define creator for new tickets """
|
||||
if not obj.pk:
|
||||
obj.creator = request.user
|
||||
super(TicketAdmin, self).save_model(request, obj, *args, **kwargs)
|
||||
obj.mark_as_read_by(request.user)
|
||||
|
||||
|
||||
def get_urls(self):
|
||||
""" add markdown preview url """
|
||||
return [
|
||||
url(r'^preview/$',
|
||||
wrap_admin_view(self, self.message_preview_view))
|
||||
] + super(TicketAdmin, self).get_urls()
|
||||
|
||||
|
||||
def add_view(self, request, form_url='', extra_context=None):
|
||||
""" Do not sow message inlines """
|
||||
return super(TicketAdmin, self).add_view(request, form_url, extra_context)
|
||||
|
||||
|
||||
def change_view(self, request, object_id, form_url='', extra_context=None):
|
||||
""" Change view actions based on ticket state """
|
||||
ticket = get_object_or_404(Ticket, pk=object_id)
|
||||
|
@ -269,12 +270,12 @@ class TicketAdmin(ExtendedModelAdmin):
|
|||
context.update(extra_context or {})
|
||||
return super(TicketAdmin, self).change_view(request, object_id, form_url=form_url,
|
||||
extra_context=context)
|
||||
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
# Hook user for bold_subject
|
||||
self.user = request.user
|
||||
return super(TicketAdmin,self).changelist_view(request, extra_context=extra_context)
|
||||
|
||||
|
||||
def message_preview_view(self, request):
|
||||
""" markdown preview render via ajax """
|
||||
data = request.POST.get("data")
|
||||
|
@ -287,21 +288,20 @@ class QueueAdmin(admin.ModelAdmin):
|
|||
actions = (set_default_queue,)
|
||||
inlines = (TicketInline,)
|
||||
ordering = ('name',)
|
||||
|
||||
|
||||
class Media:
|
||||
css = {
|
||||
'all': ('orchestra/css/hide-inline-id.css',)
|
||||
}
|
||||
|
||||
|
||||
def num_tickets(self, queue):
|
||||
num = queue.tickets__count
|
||||
url = reverse('admin:issues_ticket_changelist')
|
||||
url += '?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.admin_order_field = 'tickets__count'
|
||||
num_tickets.allow_tags = True
|
||||
|
||||
|
||||
def get_list_display(self, request):
|
||||
""" show notifications """
|
||||
list_display = list(self.list_display)
|
||||
|
@ -312,7 +312,7 @@ class QueueAdmin(admin.ModelAdmin):
|
|||
display_notify.boolean = True
|
||||
list_display.append(display_notify)
|
||||
return list_display
|
||||
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super(QueueAdmin, self).get_queryset(request)
|
||||
qs = qs.annotate(models.Count('tickets'))
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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 orchestra.api import router, LogApiMixin
|
||||
|
@ -12,19 +12,19 @@ from .serializers import TicketSerializer, QueueSerializer
|
|||
class TicketViewSet(LogApiMixin, viewsets.ModelViewSet):
|
||||
queryset = Ticket.objects.all()
|
||||
serializer_class = TicketSerializer
|
||||
|
||||
@detail_route()
|
||||
|
||||
@action(detail=True)
|
||||
def mark_as_read(self, request, pk=None):
|
||||
ticket = self.get_object()
|
||||
ticket.mark_as_read_by(request.user)
|
||||
return Response({'status': 'Ticket marked as read'})
|
||||
|
||||
@detail_route()
|
||||
|
||||
@action(detail=True)
|
||||
def mark_as_unread(self, request, pk=None):
|
||||
ticket = self.get_object()
|
||||
ticket.mark_as_unread_by(request.user)
|
||||
return Response({'status': 'Ticket marked as unread'})
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super(TicketViewSet, self).get_queryset()
|
||||
qs = qs.select_related('creator', 'queue')
|
||||
|
|
|
@ -13,7 +13,7 @@ from .models import Queue, Ticket
|
|||
|
||||
class MarkDownWidget(forms.Textarea):
|
||||
""" MarkDown textarea widget with syntax preview """
|
||||
|
||||
|
||||
markdown_url = static('issues/markdown_syntax.html')
|
||||
markdown_help_text = (
|
||||
'<a href="%s" onclick=\'window.open("%s", "", "resizable=yes, '
|
||||
|
@ -21,8 +21,8 @@ class MarkDownWidget(forms.Textarea):
|
|||
'return false;\'>markdown format</a>' % (markdown_url, markdown_url)
|
||||
)
|
||||
markdown_help_text = 'HTML not allowed, you can use %s' % markdown_help_text
|
||||
|
||||
def render(self, name, value, attrs):
|
||||
|
||||
def render(self, name, value, attrs, renderer=None):
|
||||
widget_id = attrs['id'] if attrs and 'id' in attrs else 'id_%s' % name
|
||||
textarea = super(MarkDownWidget, self).render(name, value, attrs)
|
||||
preview = ('<a class="load-preview" href="#" data-field="{0}">preview</a>'\
|
||||
|
@ -35,18 +35,18 @@ class MessageInlineForm(forms.ModelForm):
|
|||
""" Add message form """
|
||||
created_on = forms.CharField(label="Created On", required=False)
|
||||
content = forms.CharField(widget=MarkDownWidget(), required=False)
|
||||
|
||||
|
||||
class Meta:
|
||||
fields = ('author', 'author_name', 'created_on', 'content')
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(MessageInlineForm, self).__init__(*args, **kwargs)
|
||||
self.fields['created_on'].widget = SpanWidget(display='')
|
||||
|
||||
|
||||
def clean_content(self):
|
||||
""" clean HTML tags """
|
||||
return strip_tags(self.cleaned_data['content'])
|
||||
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.instance.pk is None:
|
||||
self.instance.author = self.user
|
||||
|
@ -58,7 +58,7 @@ class UsersIterator(forms.models.ModelChoiceIterator):
|
|||
def __init__(self, *args, **kwargs):
|
||||
self.ticket = kwargs.pop('ticket', False)
|
||||
super(forms.models.ModelChoiceIterator, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
def __iter__(self):
|
||||
yield ('', '---------')
|
||||
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):
|
||||
display_description = forms.CharField(label=_("Description"), required=False)
|
||||
description = forms.CharField(widget=MarkDownWidget(attrs={'class':'vLargeTextField'}))
|
||||
|
||||
|
||||
class Meta:
|
||||
model = Ticket
|
||||
fields = (
|
||||
'creator', 'creator_name', 'owner', 'queue', 'subject', 'description',
|
||||
'priority', 'state', 'cc', 'display_description'
|
||||
)
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(TicketForm, self).__init__(*args, **kwargs)
|
||||
ticket = kwargs.get('instance', False)
|
||||
|
@ -101,7 +101,7 @@ class TicketForm(forms.ModelForm):
|
|||
description = '<div style="padding-left: 95px;">%s</div>' % description
|
||||
widget = SpanWidget(display=description)
|
||||
self.fields['display_description'].widget = widget
|
||||
|
||||
|
||||
def clean_description(self):
|
||||
""" clean HTML tags """
|
||||
return strip_tags(self.cleaned_data['description'])
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import models, migrations
|
||||
import orchestra.models.fields
|
||||
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')),
|
||||
('content', models.TextField(verbose_name='content')),
|
||||
('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={
|
||||
'get_latest_by': 'id',
|
||||
|
@ -48,9 +49,9 @@ class Migration(migrations.Migration):
|
|||
('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(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')),
|
||||
('queue', models.ForeignKey(blank=True, related_name='tickets', null=True, to='issues.Queue')),
|
||||
('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(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(on_delete=django.db.models.deletion.CASCADE, blank=True, related_name='tickets', null=True, to='issues.Queue')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-updated_at'],
|
||||
|
@ -60,14 +61,14 @@ class Migration(migrations.Migration):
|
|||
name='TicketTracker',
|
||||
fields=[
|
||||
('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')),
|
||||
('user', models.ForeignKey(related_name='ticket_trackers', to=settings.AUTH_USER_MODEL, verbose_name='user')),
|
||||
('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(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(
|
||||
name='tickettracker',
|
||||
|
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,32 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.5 on 2017-05-28 18:11
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('issues', '0003_auto_20160320_1127'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
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'),
|
||||
),
|
||||
]
|
|
@ -19,10 +19,10 @@ class Queue(models.Model):
|
|||
choices=Contact.EMAIL_USAGES,
|
||||
default=contacts_settings.CONTACTS_DEFAULT_EMAIL_USAGES,
|
||||
help_text=_("Contacts to notify by email"))
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return self.verbose_name or self.name
|
||||
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" mark as default queue if needed """
|
||||
existing_default = Queue.objects.filter(default=True)
|
||||
|
@ -48,7 +48,7 @@ class Ticket(models.Model):
|
|||
(MEDIUM, 'Medium'),
|
||||
(LOW, 'Low'),
|
||||
)
|
||||
|
||||
|
||||
NEW = 'NEW'
|
||||
IN_PROGRESS = 'IN_PROGRESS'
|
||||
RESOLVED = 'RESOLVED'
|
||||
|
@ -63,7 +63,7 @@ class Ticket(models.Model):
|
|||
(REJECTED, 'Rejected'),
|
||||
(CLOSED, 'Closed'),
|
||||
)
|
||||
|
||||
|
||||
creator = models.ForeignKey(djsettings.AUTH_USER_MODEL, verbose_name=_("created by"),
|
||||
related_name='tickets_created', null=True, on_delete=models.SET_NULL)
|
||||
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)
|
||||
updated_at = models.DateTimeField(_("modified"), auto_now=True)
|
||||
cc = models.TextField("CC", help_text=_("emails to send a carbon copy to"), blank=True)
|
||||
|
||||
|
||||
objects = TicketQuerySet.as_manager()
|
||||
|
||||
|
||||
class Meta:
|
||||
ordering = ['-updated_at']
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return str(self.pk)
|
||||
|
||||
|
||||
def get_notification_emails(self):
|
||||
""" Get emails of the users related to the ticket """
|
||||
emails = list(settings.ISSUES_SUPPORT_EMAILS)
|
||||
|
@ -100,7 +100,7 @@ class Ticket(models.Model):
|
|||
for message in self.messages.distinct('author'):
|
||||
emails.append(message.author.email)
|
||||
return set(emails + self.get_cc_emails())
|
||||
|
||||
|
||||
def notify(self, message=None, content=None):
|
||||
""" Send an email to ticket stakeholders notifying an state update """
|
||||
emails = self.get_notification_emails()
|
||||
|
@ -111,7 +111,7 @@ class Ticket(models.Model):
|
|||
'ticket_message': message
|
||||
}
|
||||
send_email_template(template, context, emails, html=html_template)
|
||||
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" notify stakeholders of new ticket """
|
||||
new_issue = not self.pk
|
||||
|
@ -121,60 +121,60 @@ class Ticket(models.Model):
|
|||
if new_issue:
|
||||
# PK should be available for rendering the template
|
||||
self.notify()
|
||||
|
||||
|
||||
def is_involved_by(self, user):
|
||||
""" returns whether user has participated or is referenced on the ticket
|
||||
as owner or member of the group
|
||||
"""
|
||||
return Ticket.objects.filter(pk=self.pk).involved_by(user).exists()
|
||||
|
||||
|
||||
def get_cc_emails(self):
|
||||
return self.cc.split(',') if self.cc else []
|
||||
|
||||
|
||||
def mark_as_read_by(self, user):
|
||||
self.trackers.get_or_create(user=user)
|
||||
|
||||
|
||||
def mark_as_unread_by(self, user):
|
||||
self.trackers.filter(user=user).delete()
|
||||
|
||||
|
||||
def mark_as_unread(self):
|
||||
self.trackers.all().delete()
|
||||
|
||||
|
||||
def is_read_by(self, user):
|
||||
return self.trackers.filter(user=user).exists()
|
||||
|
||||
|
||||
def reject(self):
|
||||
self.state = Ticket.REJECTED
|
||||
self.save(update_fields=('state', 'updated_at'))
|
||||
|
||||
|
||||
def resolve(self):
|
||||
self.state = Ticket.RESOLVED
|
||||
self.save(update_fields=('state', 'updated_at'))
|
||||
|
||||
|
||||
def close(self):
|
||||
self.state = Ticket.CLOSED
|
||||
self.save(update_fields=('state', 'updated_at'))
|
||||
|
||||
|
||||
def take(self, user):
|
||||
self.owner = user
|
||||
self.save(update_fields=('state', 'updated_at'))
|
||||
|
||||
|
||||
class Message(models.Model):
|
||||
ticket = models.ForeignKey('issues.Ticket', verbose_name=_("ticket"),
|
||||
related_name='messages')
|
||||
author = models.ForeignKey(djsettings.AUTH_USER_MODEL, verbose_name=_("author"),
|
||||
related_name='ticket_messages')
|
||||
ticket = models.ForeignKey('issues.Ticket', on_delete=models.CASCADE,
|
||||
verbose_name=_("ticket"), related_name='messages')
|
||||
author = models.ForeignKey(djsettings.AUTH_USER_MODEL, on_delete=models.CASCADE,
|
||||
verbose_name=_("author"), related_name='ticket_messages')
|
||||
author_name = models.CharField(_("author name"), max_length=256, blank=True)
|
||||
content = models.TextField(_("content"))
|
||||
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
get_latest_by = 'id'
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return "#%i" % self.id
|
||||
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" notify stakeholders of ticket update """
|
||||
if not self.pk:
|
||||
|
@ -183,7 +183,7 @@ class Message(models.Model):
|
|||
self.ticket.notify(message=self)
|
||||
self.author_name = self.author.get_full_name()
|
||||
super(Message, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
@property
|
||||
def number(self):
|
||||
return self.ticket.messages.filter(id__lte=self.id).count()
|
||||
|
@ -191,10 +191,11 @@ class Message(models.Model):
|
|||
|
||||
class TicketTracker(models.Model):
|
||||
""" Keeps track of user read tickets """
|
||||
ticket = models.ForeignKey(Ticket, verbose_name=_("ticket"), related_name='trackers')
|
||||
user = models.ForeignKey(djsettings.AUTH_USER_MODEL, verbose_name=_("user"),
|
||||
related_name='ticket_trackers')
|
||||
|
||||
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE,
|
||||
verbose_name=_("ticket"), related_name='trackers')
|
||||
user = models.ForeignKey(djsettings.AUTH_USER_MODEL, on_delete=models.CASCADE,
|
||||
verbose_name=_("user"), related_name='ticket_trackers')
|
||||
|
||||
class Meta:
|
||||
unique_together = (
|
||||
('ticket', 'user'),
|
||||
|
|
|
@ -10,7 +10,7 @@ from .serializers import ListSerializer
|
|||
class ListViewSet(LogApiMixin, AccountApiMixin, SetPasswordApiMixin, viewsets.ModelViewSet):
|
||||
queryset = List.objects.all()
|
||||
serializer_class = ListSerializer
|
||||
filter_fields = ('name',)
|
||||
filter_fields = ('name', 'address_domain')
|
||||
|
||||
|
||||
router.register(r'lists', ListViewSet)
|
||||
|
|
|
@ -48,20 +48,14 @@ class MailmanVirtualDomainController(ServiceController):
|
|||
|
||||
def save(self, mail_list):
|
||||
context = self.get_context(mail_list)
|
||||
self.include_virtual_alias_domain(context)
|
||||
#self.include_virtual_alias_domain(context)
|
||||
|
||||
def delete(self, mail_list):
|
||||
context = self.get_context(mail_list)
|
||||
self.exclude_virtual_alias_domain(context)
|
||||
#self.exclude_virtual_alias_domain(context)
|
||||
|
||||
def commit(self):
|
||||
context = self.get_context_files()
|
||||
self.append(textwrap.dedent("""
|
||||
# Apply changes if needed
|
||||
if [[ $UPDATED_VIRTUAL_ALIAS_DOMAINS == 1 ]]; then
|
||||
service postfix reload
|
||||
fi""") % context
|
||||
)
|
||||
super(MailmanVirtualDomainController, self).commit()
|
||||
|
||||
def get_context_files(self):
|
||||
|
@ -107,7 +101,7 @@ class MailmanController(MailmanVirtualDomainController):
|
|||
for suffix in self.address_suffixes:
|
||||
context['suffix'] = suffix
|
||||
# Because mailman doesn't properly handle lists aliases we need two virtual aliases
|
||||
aliases.append("%(address_name)s%(suffix)s@%(domain)s\t%(name)s%(suffix)s" % context)
|
||||
aliases.append("%(address_name)s%(suffix)s@%(domain)s\t%(name)s%(suffix)s@grups.pangea.org" % context)
|
||||
if context['address_name'] != context['name']:
|
||||
# And another with the original list name; Mailman generates links with it
|
||||
aliases.append("%(name)s%(suffix)s@%(domain)s\t%(name)s%(suffix)s" % context)
|
||||
|
@ -116,85 +110,22 @@ class MailmanController(MailmanVirtualDomainController):
|
|||
def save(self, mail_list):
|
||||
context = self.get_context(mail_list)
|
||||
# Create list
|
||||
self.append(textwrap.dedent("""
|
||||
# Create list %(name)s
|
||||
[[ ! -e '%(mailman_root)s/lists/%(name)s' ]] && {
|
||||
newlist --quiet --emailhost='%(domain)s' '%(name)s' '%(admin)s' '%(password)s'
|
||||
}""") % context)
|
||||
# Custom domain
|
||||
if mail_list.address:
|
||||
context.update({
|
||||
'aliases': self.get_virtual_aliases(context),
|
||||
'num_entries': 2 if context['address_name'] != context['name'] else 1,
|
||||
})
|
||||
self.append(textwrap.dedent("""\
|
||||
# Create list alias for custom domain
|
||||
aliases='%(aliases)s'
|
||||
if ! grep '\s\s*%(name)s\s*$' %(virtual_alias)s > /dev/null; then
|
||||
echo "${aliases}" >> %(virtual_alias)s
|
||||
UPDATED_VIRTUAL_ALIAS=1
|
||||
else
|
||||
existing=$({ grep -E '^\s*(%(address_name)s|%(name)s)@%(address_domain)s\s\s*%(name)s\s*$' %(virtual_alias)s || test $? -lt 2; }|wc -l)
|
||||
if [[ $existing -ne %(num_entries)s ]]; then
|
||||
sed -i -e '/^.*\s%(name)s\(%(suffixes_regex)s\)\s*$/d' \\
|
||||
-e 'N; /^\s*\\n\s*$/d; P; D' %(virtual_alias)s
|
||||
echo "${aliases}" >> %(virtual_alias)s
|
||||
UPDATED_VIRTUAL_ALIAS=1
|
||||
fi
|
||||
fi
|
||||
echo "require_explicit_destination = 0" | \\
|
||||
%(mailman_root)s/bin/config_list -i /dev/stdin %(name)s
|
||||
echo "host_name = '%(address_domain)s'" | \\
|
||||
%(mailman_root)s/bin/config_list -i /dev/stdin %(name)s""") % context
|
||||
)
|
||||
else:
|
||||
self.append(textwrap.dedent("""\
|
||||
# Cleanup possible ex-custom domain
|
||||
if ! grep '\s\s*%(name)s\s*$' %(virtual_alias)s > /dev/null; then
|
||||
sed -i "/^.*\s%(name)s\s*$/d" %(virtual_alias)s
|
||||
fi""") % context
|
||||
)
|
||||
# Update
|
||||
if context['password'] is not None:
|
||||
self.append(textwrap.dedent("""\
|
||||
# Re-set password
|
||||
%(mailman_root)s/bin/change_pw --listname="%(name)s" --password="%(password)s"\
|
||||
""") % context
|
||||
)
|
||||
self.include_virtual_alias_domain(context)
|
||||
if mail_list.active:
|
||||
self.append('chmod 775 %(mailman_root)s/lists/%(name)s' % context)
|
||||
else:
|
||||
self.append('chmod 000 %(mailman_root)s/lists/%(name)s' % context)
|
||||
|
||||
cmd = "/opt/mailman/venv/bin/python /usr/local/admin/orchestra_mailman3/save.py %(name)s %(admin)s %(address_name)s@%(domain)s" % context
|
||||
if not mail_list.active:
|
||||
cmd += ' --inactive'
|
||||
self.append(cmd)
|
||||
|
||||
def delete(self, mail_list):
|
||||
context = self.get_context(mail_list)
|
||||
self.exclude_virtual_alias_domain(context)
|
||||
self.append(textwrap.dedent("""
|
||||
# Remove list %(name)s
|
||||
sed -i -e '/^.*\s%(name)s\(%(suffixes_regex)s\)\s*$/d' \\
|
||||
-e 'N; /^\s*\\n\s*$/d; P; D' %(virtual_alias)s
|
||||
# Non-existent list archives produce exit code 1
|
||||
exit_code=0
|
||||
rmlist -a %(name)s || exit_code=$?
|
||||
if [[ $exit_code != 0 && $exit_code != 1 ]]; then
|
||||
exit $exit_code
|
||||
fi""") % context
|
||||
)
|
||||
# Delete list
|
||||
cmd = "/opt/mailman/venv/bin/python /usr/local/admin/orchestra_mailman3/delete.py %(name)s %(admin)s %(address_name)s@%(domain)s" % context
|
||||
if not mail_list.active:
|
||||
cmd += ' --inactive'
|
||||
self.append(cmd)
|
||||
|
||||
def commit(self):
|
||||
context = self.get_context_files()
|
||||
self.append(textwrap.dedent("""
|
||||
# Apply changes if needed
|
||||
if [[ $UPDATED_VIRTUAL_ALIAS == 1 ]]; then
|
||||
postmap %(virtual_alias)s
|
||||
fi
|
||||
if [[ $UPDATED_VIRTUAL_ALIAS_DOMAINS == 1 ]]; then
|
||||
service postfix reload
|
||||
fi
|
||||
exit $exit_code""") % context
|
||||
)
|
||||
|
||||
pass
|
||||
|
||||
def get_context_files(self):
|
||||
return {
|
||||
'virtual_alias': settings.LISTS_VIRTUAL_ALIAS_PATH,
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue