Compare commits

..

3 commits

291 changed files with 2624 additions and 6945 deletions

View file

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

View file

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

View file

@ -3,7 +3,7 @@ from collections import OrderedDict
from functools import update_wrapper
from django.contrib import admin
from django.urls import reverse
from django.core.urlresolvers 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

View file

@ -1,4 +1,4 @@
from django.urls import reverse
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
from 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

View file

@ -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
from django.template import Template, Context
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)

View file

@ -1,7 +1,7 @@
from copy import deepcopy
from admin_tools.menu import items, Menu
from django.urls import reverse
from django.core.urlresolvers import reverse
from django.utils.text import capfirst
from django.utils.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(

View file

@ -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.urls import reverse, NoReverseMatch
from django.core.urlresolvers import reverse, NoReverseMatch
from django.db import models
from django.shortcuts import redirect
from django.utils import timezone
from django.utils.html import escape, format_html
from django.utils.html import escape
from django.utils.safestring import mark_safe
from 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 = mark_safe('onclick="return showAddAnotherPopup(this);"')
extra = 'onclick="return showAddAnotherPopup(this);"'
title = "Change %s" % obj._meta.verbose_name
return format_html('<a href="{}" title="{}" {}>{}</a>', url, title, extra, display_)
return mark_safe('<a href="%s" title="%s" %s>%s</a>' % (url, title, extra, display))
@admin_field
@ -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 format_html('<span title="{0}">{1}</span>', date, natural)
return '<span title="{0}">{1}</span>'.format(date, escape(natural))
def get_object_from_url(modeladmin, request):

View file

@ -1,15 +1,15 @@
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.decorators import detail_route
from rest_framework.response import Response
from .serializers import SetPasswordSerializer
class SetPasswordApiMixin(object):
@action(detail=True, methods=['post'], serializer_class=SetPasswordSerializer)
@detail_route(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

View file

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

View file

@ -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, basename=None):
def register(self, prefix, viewset, base_name=None):
""" inserts link headers on every viewset """
if basename is None:
basename = self.get_default_basename(viewset)
insert_links(viewset, basename)
self.registry.append((prefix, viewset, basename))
if base_name is None:
base_name = self.get_default_base_name(viewset)
insert_links(viewset, base_name)
self.registry.append((prefix, viewset, base_name))
def 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)

View file

@ -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'] = {

View file

@ -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,10 +64,7 @@ class HyperlinkedModelSerializer(serializers.HyperlinkedModelSerializer):
class RelatedHyperlinkedModelSerializer(HyperlinkedModelSerializer):
""" returns object on to_internal_value based on URL """
def to_internal_value(self, data):
try:
url = data.get('url')
except AttributeError:
url = None
url = data.get('url')
if not url:
raise ValidationError({
'url': "URL is required."
@ -83,16 +80,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, value):
def validate_password(self, attrs, source):
""" POST only password """
if self.instance:
if value:
if 'password' in attrs:
raise serializers.ValidationError(_("Can not set password"))
elif not value:
elif 'password' not in attrs:
raise serializers.ValidationError(_("Password required"))
return value
return attrs
def validate(self, attrs):
""" remove password in case is not a real model field """
try:
@ -101,11 +98,11 @@ class SetPasswordHyperlinkedSerializer(HyperlinkedModelSerializer):
pass
else:
password = attrs.pop('password', None)
attrs = super().validate(attrs)
attrs = super(SetPasswordSerializer, self).validate()
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)

View file

@ -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==0.3.14 \
freezegun \
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 https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.buster_amd64.deb -O ${wkhtmltox}
wget http://download.gna.org/wkhtmltopdf/0.12/0.12.2.1/wkhtmltox-0.12.2.1_linux-jessie-amd64.deb -O ${wkhtmltox}
dpkg -i ${wkhtmltox} || { echo "Installing missing dependencies for wkhtmltox..." && apt-get -f -y install; }
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

View file

@ -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, run_async=True)
proc = run(command, 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, run_async=True)
proc = run(command, async=True)
yield proc

View file

@ -25,7 +25,6 @@ SECRET_KEY = '{{ secret_key }}'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
@ -66,9 +65,9 @@ INSTALLED_APPS = [
'admin_tools.dashboard',
'rest_framework',
'rest_framework.authtoken',
'django_filters',
'passlib.ext.django',
'django_countries',
'rest_framework_swagger',
# 'debug_toolbar',
# Django.contrib
@ -86,21 +85,6 @@ INSTALLED_APPS = [
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'orchestra.core.caches.RequestCacheMiddleware',
# also handles transations, ATOMIC_REQUESTS does not wrap middlewares
'orchestra.contrib.orchestration.middlewares.OperationsMiddleware',
]
ROOT_URLCONF = '{{ project_name }}.urls'
TEMPLATES = [
@ -144,24 +128,6 @@ 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/
@ -203,6 +169,22 @@ 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'
@ -247,7 +229,8 @@ REST_FRAMEWORK = {
'rest_framework.authentication.TokenAuthentication',
),
'DEFAULT_FILTER_BACKENDS': (
('django_filters.rest_framework.DjangoFilterBackend',)
# TODO(@slamora): commented to be able to run rest swagger
#('rest_framework.filters.DjangoFilterBackend',)
),
}
@ -261,6 +244,7 @@ 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"

View file

@ -1,6 +1,11 @@
from django.conf.urls import include, url
from rest_framework_swagger.views import get_swagger_view
schema_view = get_swagger_view(title='Orchestra API')
urlpatterns = [
url(r'^swagger/$', schema_view),
url(r'', include('orchestra.urls')),
]

View file

@ -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.urls import reverse, NoReverseMatch
from django.core.urlresolvers 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.related_model:
if model is modeladmin.model.main_systemuser.field.rel.to:
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',

View file

@ -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.urls import reverse
from django.core.urlresolvers 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,8 +157,7 @@ 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 = {
@ -168,8 +167,9 @@ 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,8 +206,7 @@ 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')
@ -216,14 +215,16 @@ 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)
@ -246,7 +247,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)
@ -262,13 +263,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)
@ -276,14 +277,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)
@ -301,21 +302,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:
@ -330,7 +331,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 = {}
@ -366,7 +367,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()
@ -380,7 +381,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:
@ -405,7 +406,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.

View file

@ -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.related_model
systemuser_model = Account.main_systemuser.field.rel.to
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)

View file

@ -3,7 +3,6 @@ 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
@ -33,7 +32,7 @@ class Migration(migrations.Migration):
('is_superuser', models.BooleanField(help_text='Designates that this user has all permissions without explicitly assigning them.', default=False, verbose_name='superuser status')),
('is_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, on_delete=django.db.models.deletion.CASCADE, related_name='accounts_main')),
('main_systemuser', models.ForeignKey(to='systemusers.SystemUser', editable=False, null=True, related_name='accounts_main')),
],
options={
'abstract': False,

View file

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

View file

@ -1,38 +0,0 @@
# -*- 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'),
),
]

View file

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

View file

@ -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 import core
from orchestra.core import services
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, on_delete=models.SET_NULL)
related_name='accounts_main', editable=False)
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,28 +46,23 @@ 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:
@ -80,21 +75,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()
@ -103,23 +98,23 @@ class Account(auth.AbstractBaseUser):
]
for rel in related_fields:
source = getattr(rel, 'related_model', rel.model)
if source in core.services and hasattr(source, 'active'):
if source in 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)
@ -131,14 +126,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
@ -146,26 +141,13 @@ 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_superuser:
if self.is_active and self.is_superuser:
return True
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
# Otherwise we need to check the backends.
return auth._user_has_perm(self, perm, obj)
def has_perms(self, perm_list, obj=None):
"""
Returns True if the user has each of the specified permissions. If
@ -176,7 +158,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.
@ -185,7 +167,8 @@ 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,

View file

@ -7,7 +7,7 @@ class AccountSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Account
fields = (
'url', 'id', 'username', 'type', 'language', 'short_name', 'full_name', 'date_joined', 'last_login',
'url', 'id', 'username', 'type', 'language', 'short_name', 'full_name', 'date_joined',
'is_active'
)

View file

@ -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.urls import reverse
from django.core.urlresolvers 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

View file

@ -2,12 +2,11 @@ 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.urls import reverse
from django.core.urlresolvers 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
@ -16,12 +15,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, AbonoInvoice, Fee, AmendmentFee, ProForma, BillLine,
from .models import (Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, ProForma, BillLine,
BillSubline, BillContact)
@ -40,18 +39,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
@ -65,10 +64,9 @@ 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()
@ -80,7 +78,8 @@ 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':
@ -88,7 +87,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')
@ -97,35 +96,36 @@ 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('&nbsp;' * 4 + subline.description)
descriptions.append('&nbsp;'*4+subline.description)
return '<br>'.join(descriptions)
display_description.short_description = _("Description")
@mark_safe
display_description.allow_tags = True
def display_subtotal(self, line):
subtotals = ['&nbsp;' + 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,7 +242,6 @@ class BillLineManagerAdmin(BillLineAdmin):
class BillAdminMixin(AccountAdminMixin):
@mark_safe
def display_total_with_subtotals(self, bill):
if bill.pk:
currency = settings.BILLS_CURRENCY.lower()
@ -252,10 +251,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
@ -277,6 +276,7 @@ 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,25 +368,27 @@ 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 format_html('{} &{};', bill.compute_total(), currency)
return '%s &%s;' % (bill.compute_total(), currency)
display_total.allow_tags = True
display_total.short_description = _("total")
display_total.admin_order_field = 'approx_total'
def type_link(self, bill):
bill_type = bill.type.lower()
url = reverse('admin:bills_%s_changelist' % bill_type)
return format_html('<a href="{}">{}</a>', url, bill.get_type_display())
return '<a href="%s">%s</a>' % (url, bill.get_type_display())
type_link.allow_tags = True
type_link.short_description = _("type")
type_link.admin_order_field = 'type'
def get_urls(self):
""" Hook bill lines management URLs on bill admin """
urls = super().get_urls()
@ -397,13 +399,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:
@ -416,7 +418,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 = []
@ -426,7 +428,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:
@ -437,7 +439,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':
@ -448,7 +450,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))
@ -459,7 +461,6 @@ 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)
@ -469,7 +470,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':
@ -477,7 +478,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)

View file

@ -1,6 +1,6 @@
from django.http import HttpResponse
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.decorators import detail_route
from orchestra.api import router, LogApiMixin
from orchestra.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
@action(detail=True, methods=['get'])
@detail_route(methods=['get'])
def document(self, request, pk):
bill = self.get_object()
content_type = request.META.get('HTTP_ACCEPT')

View file

@ -1,5 +1,5 @@
from django.contrib.admin import SimpleListFilter
from django.urls import reverse
from django.core.urlresolvers import reverse
from django.db.models import Q
from django.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 &gt; 0")),
@ -53,7 +53,7 @@ class TotalListFilter(SimpleListFilter):
('eq', "total = 0"),
('ne', mark_safe("total &ne; 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

View file

@ -1,5 +1,5 @@
from django.contrib import messages
from django.urls import reverse
from django.core.urlresolvers import reverse
from django.utils.encoding import force_text
from django.utils.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.related_model.objects.get_main()
main = type(bill).account.field.rel.to.objects.get_main()
if not hasattr(main, 'billcontact'):
account = force_text(main)
url = reverse('admin:accounts_account_change', args=(main.id,))

View file

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2019-12-20 11:56+0100\n"
"POT-Creation-Date: 2015-10-29 10:51+0000\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:33
#: actions.py:31
msgid "View"
msgstr "Vista"
#: actions.py:45
#: actions.py:42
msgid "Selected bills should be in open state"
msgstr "Les factures seleccionades han d'estar en estat obert"
#: actions.py:60
#: actions.py:57
msgid "Selected bills have been closed"
msgstr "Les factures seleccionades han estat tancades"
#: actions.py:73
#: actions.py:70
#, 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:74
#: actions.py:71
#, 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:80
#: actions.py:77
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:81
#: actions.py:78
msgid ""
"Once a bill is closed it can not be further modified.</p><p>Please select a "
"payment source for the selected bills"
@ -52,205 +52,174 @@ 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:97
#: actions.py:91
msgid "Close"
msgstr "Tanca"
#: actions.py:115
#: actions.py:109
msgid "One bill has been sent."
msgstr "S'ha creat una factura"
#: actions.py:116
#: actions.py:110
#, python-format
msgid "%i bills have been sent."
msgstr "S'han enviat %i factures."
#: actions.py:123
#: actions.py:117
msgid "Resend"
msgstr "Reenviat"
#: actions.py:146
#: actions.py:137
msgid "Download"
msgstr "Descarrega"
#: actions.py:162
#: actions.py:153
msgid "C.S.D."
msgstr ""
#: actions.py:164
#: actions.py:155
msgid "Close, send and download bills in one shot."
msgstr ""
#: actions.py:225
#: actions.py:216
#, python-format
msgid "%(norders)s orders and %(nlines)s lines undoed."
msgstr "%(norders)s ordres i %(nlines)s línies desfetes."
#: actions.py:244
#: actions.py:235
msgid "Lines moved"
msgstr "Línies mogudes"
#: actions.py:257
#: actions.py:248
msgid "Selected bills should be in closed state"
msgstr "Les factures seleccionades han d'estar en estat obert"
#: actions.py:259
#, python-format
msgid "%s can not be amended."
msgstr ""
#: actions.py:279
#: actions.py:265
#, 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:286
#: actions.py:272
#, python-format
msgid "%(related_type)s %(number)s subtotal for tax %(tax)s%%"
msgstr "%(related_type)s %(number)s subtotal %(tax)s%%"
#: actions.py:303
#: actions.py:288
#, 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:304
#: actions.py:289
#, 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:307
#: actions.py:292
msgid "Amend"
msgstr ""
#: admin.py:80 admin.py:126 admin.py:180 forms.py:11
#: admin.py:58 admin.py:103 admin.py:140 forms.py:11
#: templates/admin/bills/bill/report.html:43
#: templates/admin/bills/bill/report.html:70
msgid "Total"
msgstr "Total"
#: admin.py:112
#: admin.py:89
msgid "Description"
msgstr "Descripció"
#: admin.py:120
#: admin.py:97
msgid "Subtotal"
msgstr "Subtotal"
#: admin.py:146
#, fuzzy
#| msgid "Total"
msgid "Totals"
msgstr "Total"
#: admin.py:150
msgid "Order"
msgstr ""
#: admin.py:169
#: admin.py:130
msgid "Is open"
msgstr "És oberta"
#: admin.py:175
#, fuzzy
#| msgid "Subline"
msgid "Sublines"
#: admin.py:135
msgid "Subline"
msgstr "Sublínia"
#: admin.py:221
#: admin.py:167
msgid "No bills selected."
msgstr "No hi ha factures seleccionades"
#: admin.py:229
#, fuzzy, python-format
#| msgid "Manage %s bill lines."
msgid "Manage %s bill lines"
#: admin.py:174
#, python-format
msgid "Manage %s bill lines."
msgstr "Gestiona %s línies de factura."
#: admin.py:231
#: admin.py:176
msgid "Bill not in open state."
msgstr "La factura no està en estat obert"
#: admin.py:234
#: admin.py:179
msgid "Not all bills are in open state."
msgstr "No totes les factures estan en estat obert"
#: admin.py:235
#, fuzzy
#| msgid "Manage bill lines of multiple bills."
msgid "Manage bill lines of multiple bills"
#: admin.py:180
msgid "Manage bill lines of multiple bills."
msgstr "Gestiona línies de factura de multiples factures."
#: admin.py:250
#, python-format
msgid "Subtotal %s%% VAT %s &%s;"
msgstr ""
#: admin.py:251
#, python-format
msgid "Taxes %s%% VAT %s &%s;"
msgstr ""
#: admin.py:255 admin.py:381 filters.py:46
#: templates/bills/microspective.html:123
msgid "total"
msgstr "total"
#: admin.py:275
msgid "This bill has been amended, this value may not be valid."
msgstr ""
#: admin.py:280
msgid "Payment"
msgstr "Pagament"
#: admin.py:304
#, fuzzy
#| msgid "amended line"
msgid "Amends"
msgstr "línia rectificada"
#: admin.py:330
#: admin.py:204
msgid "Dates"
msgstr ""
#: admin.py:335
#: admin.py:209
msgid "Raw"
msgstr "Raw"
#: admin.py:358 models.py:75
#: admin.py:235 models.py:73
msgid "Created"
msgstr "Creada"
#: admin.py:359
#: admin.py:236
#, fuzzy
#| msgid "Close"
msgid "Closed"
msgstr "Tanca"
#: admin.py:360
#: admin.py:237
#, fuzzy
#| msgid "updated on"
msgid "Updated"
msgstr "actualitzada el"
#: admin.py:375
#: admin.py:246
#, fuzzy
#| msgid "amended line"
msgid "Amends"
msgstr "línia rectificada"
#: admin.py:252
msgid "lines"
msgstr "línies"
#: admin.py:389 models.py:108 models.py:501
#: admin.py:257 filters.py:46 templates/bills/microspective.html:118
msgid "total"
msgstr "total"
#: admin.py:265 models.py:104 models.py:460
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:91
#: filters.py:22 models.py:88
msgid "Invoice"
msgstr "Factura"
#: filters.py:23 models.py:93
#: filters.py:23 models.py:90
msgid "Fee"
msgstr "Quota de soci"
@ -262,67 +231,65 @@ msgstr "Pro-forma"
msgid "Amendment fee"
msgstr "Rectificació de quota de soci"
#: filters.py:26 models.py:92
#: filters.py:26 models.py:89
msgid "Amendment invoice"
msgstr "Factura rectificativa"
#: filters.py:71
#: filters.py:68
msgid "has bill contact"
msgstr "té contacte de facturació"
#: filters.py:76
#: filters.py:73
msgid "Yes"
msgstr "Si"
#: filters.py:77
#: filters.py:74
msgid "No"
msgstr "No"
#: filters.py:88
#: filters.py:85
msgid "payment state"
msgstr "Pagament"
#: filters.py:93 models.py:74
#: filters.py:90 models.py:72
msgid "Open"
msgstr ""
#: filters.py:94 models.py:78
#: filters.py:91 models.py:76
msgid "Paid"
msgstr "Pagat"
#: filters.py:95
#: filters.py:92
msgid "Pending"
msgstr "Pendent"
#: filters.py:96 models.py:81
#: filters.py:93 models.py:79
msgid "Bad debt"
msgstr "Incobrable"
#: filters.py:138
#: filters.py:135
#, fuzzy
#| msgid "amended line"
msgid "amended"
msgstr "línia rectificada"
#: filters.py:143
#: filters.py:140
#, fuzzy
#| msgid "Due date"
msgid "Closed amends"
msgstr "Data de pagament"
#: filters.py:144
#, fuzzy
#| msgid "Due date"
msgid "Open amends"
msgstr "Data de pagament"
#: filters.py:141
msgid "Open or closed amends"
msgstr ""
#: filters.py:145
#: filters.py:142
#, fuzzy
#| msgid "amended line"
msgid "Any amends"
msgstr "línia rectificada"
#| msgid "closed on"
msgid "No closed amends"
msgstr "tancat el"
#: filters.py:146
#: filters.py:143
msgid "No amends"
msgstr ""
@ -342,7 +309,7 @@ msgstr "Tipus"
msgid "Source"
msgstr "Font"
#: helpers.py:14
#: helpers.py:10
msgid ""
"{relation} account \"{account}\" does not have a declared invoice contact. "
"You should <a href=\"{url}#invoicecontact-group\">provide one</a>"
@ -350,235 +317,213 @@ msgstr ""
"{relation} compte \"{account}\" no te un contacte de facturació. Hauries de "
"<a href=\"{url}#invoicecontact-group\">proporcionar un</a>"
#: helpers.py:21
#: helpers.py:17
msgid "Related"
msgstr "Relacionat"
#: helpers.py:28
#: helpers.py:24
msgid "Main"
msgstr "Principal"
#: models.py:26 models.py:104
#: models.py:24 models.py:100
msgid "account"
msgstr "compte"
#: models.py:28
#: models.py:26
msgid "name"
msgstr "nom"
#: models.py:29
#: models.py:27
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:30
#: models.py:28
msgid "address"
msgstr "adreça"
#: models.py:31
#: models.py:29
msgid "city"
msgstr "ciutat"
#: models.py:33
#: models.py:31
msgid "zip code"
msgstr "codi postal"
#: models.py:34
#: models.py:32
msgid "Enter a valid zipcode."
msgstr "Introdueix un codi postal vàlid."
#: models.py:35
#: models.py:33
msgid "country"
msgstr "país"
#: models.py:38 templates/admin/bills/bill/report.html:65
#: models.py:36 templates/admin/bills/bill/report.html:65
msgid "VAT number"
msgstr "NIF"
#: models.py:76
#: models.py:74
msgid "Processed"
msgstr ""
#: models.py:77
#: models.py:75
#, fuzzy
#| msgid "amended line"
msgid "Amended"
msgstr "línia rectificada"
#: models.py:79
#: models.py:77
msgid "Incomplete"
msgstr ""
#: models.py:80
#: models.py:78
msgid "Executed"
msgstr ""
#: models.py:94
#: models.py:91
msgid "Amendment Fee"
msgstr "Rectificació de quota de soci"
#: models.py:95
#, fuzzy
#| msgid "Invoice"
msgid "Abono Invoice"
msgstr "Abonament"
#: models.py:96
#: models.py:92
msgid "Pro forma"
msgstr "Pro forma"
#: models.py:103
#: models.py:99
msgid "number"
msgstr "número"
#: models.py:106
#: models.py:102
#, fuzzy
#| msgid "amended line"
msgid "amend of"
msgstr "línia rectificada"
#: models.py:109
#: models.py:105
msgid "created on"
msgstr "creat el"
#: models.py:110
#: models.py:106
msgid "closed on"
msgstr "tancat el"
#: models.py:111
#: models.py:107
msgid "open"
msgstr "obert"
#: models.py:112
#: models.py:108
msgid "sent"
msgstr "enviat"
#: models.py:113
#: models.py:109
msgid "due on"
msgstr "es deu"
#: models.py:114
#: models.py:110
msgid "updated on"
msgstr "actualitzada el"
#: models.py:116
#: models.py:112
msgid "comments"
msgstr "comentaris"
#: models.py:117
#: models.py:113
msgid "HTML"
msgstr "HTML"
#: models.py:200
#: models.py:194
#, python-format
msgid "Type %s is not an amendment."
msgstr ""
#: models.py:202
#: models.py:196
msgid "Amend of related account doesn't match bill account."
msgstr ""
#: models.py:204
#: models.py:198
#, fuzzy
#| msgid "Bill not in open state."
msgid "Related invoice is in open state."
msgstr "La factura no està en estat obert"
#: models.py:206
#: models.py:200
msgid "Related invoice is an amendment."
msgstr ""
#: models.py:419
#: models.py:392
msgid "bill"
msgstr "factura"
#: models.py:420 models.py:499 templates/bills/microspective.html:75
#: models.py:393 models.py:458 templates/bills/microspective.html:73
msgid "description"
msgstr "descripció"
#: models.py:421
#: models.py:394
msgid "rate"
msgstr "tarifa"
#: models.py:422
#: models.py:395
msgid "quantity"
msgstr "quantitat"
#: models.py:424
#: models.py:397
#, fuzzy
#| msgid "quantity"
msgid "Verbose quantity"
msgstr "quantitat"
#: models.py:425 templates/admin/bills/bill/report.html:47
#: templates/bills/microspective.html:79
#: templates/bills/microspective.html:116
#: models.py:398 templates/admin/bills/bill/report.html:47
#: templates/bills/microspective.html:77
#: templates/bills/microspective.html:111
msgid "subtotal"
msgstr "subtotal"
#: models.py:426
#: models.py:399
msgid "tax"
msgstr "impostos"
#: models.py:427
#: models.py:400
msgid "start"
msgstr "iniciar"
#: models.py:428
#: models.py:401
msgid "end"
msgstr "finalitzar"
#: models.py:431
#: models.py:403
msgid "Informative link back to the order"
msgstr "Enllaç informatiu de l'ordre"
#: models.py:432
#: models.py:404
msgid "order billed"
msgstr "ordre facturada"
#: models.py:433
#: models.py:405
msgid "order billed until"
msgstr "ordre facturada fins a"
#: models.py:434
#: models.py:406
msgid "created"
msgstr "creada"
#: models.py:436
#: models.py:408
msgid "amended line"
msgstr "línia rectificada"
#: models.py:492
#: models.py:451
msgid "Volume"
msgstr "Volum"
#: models.py:493
#: models.py:452
msgid "Compensation"
msgstr "Compensació"
#: models.py:494
#: models.py:453
msgid "Other"
msgstr "Altre"
#: models.py:498
#: models.py:457
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 ""
@ -586,19 +531,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:116
#: templates/bills/microspective.html:119
#: templates/bills/microspective.html:111
#: templates/bills/microspective.html:114
msgid "VAT"
msgstr "IVA"
#: templates/admin/bills/bill/report.html:51
#: templates/bills/microspective.html:119
#: templates/bills/microspective.html:114
msgid "taxes"
msgstr "impostos"
#: templates/admin/bills/bill/report.html:56
#: templates/admin/bills/billline/report.html:60
#: templates/bills/microspective.html:54
#: templates/bills/microspective.html:53
msgid "TOTAL"
msgstr "TOTAL"
@ -616,20 +561,8 @@ 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 "Service"
msgid "Services"
msgstr ""
#: templates/admin/bills/billline/report.html:43
@ -654,21 +587,27 @@ msgstr "quantitat"
msgid "Profit"
msgstr ""
#: templates/bills/microspective-fee.html:115
#: templates/admin/bills/change_list.html:9
#, fuzzy
#| msgid "bill"
msgid "Add bill"
msgstr "factura"
#: templates/bills/microspective-fee.html:107
msgid "Due date"
msgstr "Data de pagament"
#: templates/bills/microspective-fee.html:116
#: templates/bills/microspective-fee.html:108
#, python-format
msgid "On %(bank_account)s"
msgstr "Al %(bank_account)s"
#: templates/bills/microspective-fee.html:122
#: templates/bills/microspective-fee.html:114
#, python-format
msgid "From %(ini)s to %(end)s"
msgstr "De %(ini)s a %(end)s"
#: templates/bills/microspective-fee.html:144
#: templates/bills/microspective-fee.html:121
msgid ""
"\n"
"<strong>With your membership</strong> you are supporting ...\n"
@ -676,36 +615,36 @@ msgstr ""
"\n"
"<strong>Amb la teva quota de soci</strong> estàs donant suport ...\n"
#: templates/bills/microspective.html:50
#: templates/bills/microspective.html:49
msgid "DUE DATE"
msgstr "VENCIMENT"
#: templates/bills/microspective.html:58
#: templates/bills/microspective.html:57
#, python-format
msgid "%(bill_type)s DATE"
msgstr "DATA %(bill_type)s"
#: templates/bills/microspective.html:76
#: templates/bills/microspective.html:74
msgid "period"
msgstr "període"
#: templates/bills/microspective.html:77
#: templates/bills/microspective.html:75
msgid "hrs/qty"
msgstr "hrs/qnt"
#: templates/bills/microspective.html:78
#: templates/bills/microspective.html:76
msgid "rate/price"
msgstr "tarifa/preu"
#: templates/bills/microspective.html:137
#: templates/bills/microspective.html:131
msgid "COMMENTS"
msgstr "COMENTARIS"
#: templates/bills/microspective.html:145
#: templates/bills/microspective.html:138
msgid "PAYMENT"
msgstr "PAGAMENT"
#: templates/bills/microspective.html:149
#: templates/bills/microspective.html:142
#, python-format
msgid ""
"\n"
@ -719,11 +658,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:160
#: templates/bills/microspective.html:151
msgid "QUESTIONS"
msgstr "PREGUNTES"
#: templates/bills/microspective.html:161
#: templates/bills/microspective.html:152
#, python-format
msgid ""
"\n"
@ -740,10 +679,5 @@ msgstr ""
"ràpidament possible.\n"
" "
#, fuzzy
#~| msgid "closed on"
#~ msgid "No closed amends"
#~ msgstr "tancat el"
#~ msgid "positive price"
#~ msgstr "preu positiu"

View file

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2019-12-20 11:56+0100\n"
"POT-Creation-Date: 2015-10-29 10:51+0000\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:33
#: actions.py:31
msgid "View"
msgstr "Vista"
#: actions.py:45
#: actions.py:42
msgid "Selected bills should be in open state"
msgstr "Las facturas seleccionadas están en estado abierto"
#: actions.py:60
#: actions.py:57
msgid "Selected bills have been closed"
msgstr "Las facturas seleccionadas han sido cerradas"
#: actions.py:73
#: actions.py:70
#, 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:74
#: actions.py:71
#, 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:80
#: actions.py:77
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:81
#: actions.py:78
msgid ""
"Once a bill is closed it can not be further modified.</p><p>Please select a "
"payment source for the selected bills"
@ -52,199 +52,174 @@ 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:97
#: actions.py:91
msgid "Close"
msgstr "Cerrar"
#: actions.py:115
#: actions.py:109
msgid "One bill has been sent."
msgstr "Se ha enviado una factura"
#: actions.py:116
#: actions.py:110
#, python-format
msgid "%i bills have been sent."
msgstr ""
#: actions.py:123
#: actions.py:117
msgid "Resend"
msgstr ""
#: actions.py:146
#: actions.py:137
msgid "Download"
msgstr "Descarga"
#: actions.py:162
#: actions.py:153
msgid "C.S.D."
msgstr ""
#: actions.py:164
#: actions.py:155
msgid "Close, send and download bills in one shot."
msgstr ""
#: actions.py:225
#: actions.py:216
#, python-format
msgid "%(norders)s orders and %(nlines)s lines undoed."
msgstr ""
#: actions.py:244
#: actions.py:235
msgid "Lines moved"
msgstr ""
#: actions.py:257
#: actions.py:248
msgid "Selected bills should be in closed state"
msgstr "Las facturas seleccionadas están en estado abierto"
#: actions.py:259
#, python-format
msgid "%s can not be amended."
msgstr ""
#: actions.py:279
#: actions.py:265
#, 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:286
#: actions.py:272
#, python-format
msgid "%(related_type)s %(number)s subtotal for tax %(tax)s%%"
msgstr "%(related_type)s %(number)s subtotal %(tax)s%%"
#: actions.py:303
#: actions.py:288
#, 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:304
#: actions.py:289
#, 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:307
#: actions.py:292
msgid "Amend"
msgstr ""
#: admin.py:80 admin.py:126 admin.py:180 forms.py:11
#: admin.py:58 admin.py:103 admin.py:140 forms.py:11
#: templates/admin/bills/bill/report.html:43
#: templates/admin/bills/bill/report.html:70
msgid "Total"
msgstr ""
#: admin.py:112
#: admin.py:89
msgid "Description"
msgstr ""
#: admin.py:120
#: admin.py:97
msgid "Subtotal"
msgstr ""
#: admin.py:146
msgid "Totals"
msgstr ""
#: admin.py:150
msgid "Order"
msgstr ""
#: admin.py:169
#: admin.py:130
msgid "Is open"
msgstr ""
#: admin.py:175
msgid "Sublines"
#: admin.py:135
msgid "Subline"
msgstr ""
#: admin.py:221
#: admin.py:167
msgid "No bills selected."
msgstr ""
#: admin.py:229
#, fuzzy, python-format
#| msgid "bill line"
msgid "Manage %s bill lines"
msgstr "linea de factura"
#: admin.py:174
#, python-format
msgid "Manage %s bill lines."
msgstr ""
#: admin.py:231
#: admin.py:176
msgid "Bill not in open state."
msgstr ""
#: admin.py:234
#: admin.py:179
msgid "Not all bills are in open state."
msgstr ""
#: admin.py:235
msgid "Manage bill lines of multiple bills"
#: admin.py:180
msgid "Manage bill lines of multiple bills."
msgstr ""
#: admin.py:250
#, python-format
msgid "Subtotal %s%% VAT %s &%s;"
msgstr ""
#: admin.py:251
#, python-format
msgid "Taxes %s%% VAT %s &%s;"
msgstr ""
#: admin.py:255 admin.py:381 filters.py:46
#: templates/bills/microspective.html:123
msgid "total"
msgstr ""
#: admin.py:275
msgid "This bill has been amended, this value may not be valid."
msgstr ""
#: admin.py:280
msgid "Payment"
msgstr "Pago"
#: admin.py:304
#, fuzzy
#| msgid "Amended"
msgid "Amends"
msgstr "Quota rectificativa"
#: admin.py:330
#: admin.py:204
msgid "Dates"
msgstr ""
#: admin.py:335
#: admin.py:209
msgid "Raw"
msgstr ""
#: admin.py:358 models.py:75
#: admin.py:235 models.py:73
msgid "Created"
msgstr ""
#: admin.py:359
#: admin.py:236
#, fuzzy
#| msgid "Close"
msgid "Closed"
msgstr "Cerrar"
#: admin.py:360
#: admin.py:237
#, fuzzy
#| msgid "updated on"
msgid "Updated"
msgstr "actualizada en"
#: admin.py:375
#: admin.py:246
#, fuzzy
#| msgid "Amended"
msgid "Amends"
msgstr "Quota rectificativa"
#: admin.py:252
msgid "lines"
msgstr ""
#: admin.py:389 models.py:108 models.py:501
#: admin.py:257 filters.py:46 templates/bills/microspective.html:118
msgid "total"
msgstr ""
#: admin.py:265 models.py:104 models.py:460
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:91
#: filters.py:22 models.py:88
msgid "Invoice"
msgstr "Factura"
#: filters.py:23 models.py:93
#: filters.py:23 models.py:90
msgid "Fee"
msgstr "Cuota de socio"
@ -256,67 +231,65 @@ msgstr ""
msgid "Amendment fee"
msgstr "Cuota rectificativa"
#: filters.py:26 models.py:92
#: filters.py:26 models.py:89
msgid "Amendment invoice"
msgstr "Factura rectificativa"
#: filters.py:71
#: filters.py:68
msgid "has bill contact"
msgstr ""
#: filters.py:76
#: filters.py:73
msgid "Yes"
msgstr ""
#: filters.py:77
#: filters.py:74
msgid "No"
msgstr ""
#: filters.py:88
#: filters.py:85
msgid "payment state"
msgstr "Pago"
#: filters.py:93 models.py:74
#: filters.py:90 models.py:72
msgid "Open"
msgstr ""
#: filters.py:94 models.py:78
#: filters.py:91 models.py:76
msgid "Paid"
msgstr ""
#: filters.py:95
#: filters.py:92
msgid "Pending"
msgstr ""
#: filters.py:96 models.py:81
#: filters.py:93 models.py:79
msgid "Bad debt"
msgstr ""
#: filters.py:138
#: filters.py:135
#, fuzzy
#| msgid "Amended"
msgid "amended"
msgstr "Quota rectificativa"
#: filters.py:143
#: filters.py:140
#, fuzzy
#| msgid "Due date"
msgid "Closed amends"
msgstr "Fecha de pago"
#: filters.py:144
#, fuzzy
#| msgid "Due date"
msgid "Open amends"
msgstr "Fecha de pago"
#: filters.py:141
msgid "Open or closed amends"
msgstr ""
#: filters.py:145
#: filters.py:142
#, fuzzy
#| msgid "Amended"
msgid "Any amends"
msgstr "Quota rectificativa"
#| msgid "closed on"
msgid "No closed amends"
msgstr "cerrada en"
#: filters.py:146
#: filters.py:143
msgid "No amends"
msgstr ""
@ -336,233 +309,213 @@ msgstr ""
msgid "Source"
msgstr ""
#: helpers.py:14
#: helpers.py:10
msgid ""
"{relation} account \"{account}\" does not have a declared invoice contact. "
"You should <a href=\"{url}#invoicecontact-group\">provide one</a>"
msgstr ""
#: helpers.py:21
#: helpers.py:17
msgid "Related"
msgstr ""
#: helpers.py:28
#: helpers.py:24
msgid "Main"
msgstr ""
#: models.py:26 models.py:104
#: models.py:24 models.py:100
msgid "account"
msgstr ""
#: models.py:28
#: models.py:26
msgid "name"
msgstr ""
#: models.py:29
#: models.py:27
msgid "Account full name will be used when left blank."
msgstr ""
#: models.py:30
#: models.py:28
msgid "address"
msgstr ""
#: models.py:31
#: models.py:29
msgid "city"
msgstr ""
#: models.py:33
#: models.py:31
msgid "zip code"
msgstr ""
#: models.py:34
#: models.py:32
msgid "Enter a valid zipcode."
msgstr ""
#: models.py:35
#: models.py:33
msgid "country"
msgstr ""
#: models.py:38 templates/admin/bills/bill/report.html:65
#: models.py:36 templates/admin/bills/bill/report.html:65
msgid "VAT number"
msgstr ""
#: models.py:76
#: models.py:74
msgid "Processed"
msgstr ""
#: models.py:77
#: models.py:75
msgid "Amended"
msgstr "Quota rectificativa"
#: models.py:79
#: models.py:77
msgid "Incomplete"
msgstr ""
#: models.py:80
#: models.py:78
msgid "Executed"
msgstr ""
#: models.py:94
#: models.py:91
msgid "Amendment Fee"
msgstr ""
#: models.py:95
#, fuzzy
#| msgid "Invoice"
msgid "Abono Invoice"
msgstr "Abono"
#: models.py:96
#: models.py:92
msgid "Pro forma"
msgstr ""
#: models.py:103
#: models.py:99
msgid "number"
msgstr "número"
#: models.py:106
#: models.py:102
msgid "amend of"
msgstr "rectificación de"
#: models.py:109
#: models.py:105
msgid "created on"
msgstr "creado en"
#: models.py:110
#: models.py:106
msgid "closed on"
msgstr "cerrada en"
#: models.py:111
#: models.py:107
msgid "open"
msgstr "abierta"
#: models.py:112
#: models.py:108
msgid "sent"
msgstr "enviada"
#: models.py:113
#: models.py:109
msgid "due on"
msgstr "vencimiento"
#: models.py:114
#: models.py:110
msgid "updated on"
msgstr "actualizada en"
#: models.py:116
#: models.py:112
msgid "comments"
msgstr "comentarios"
#: models.py:117
#: models.py:113
msgid "HTML"
msgstr "HTML"
#: models.py:200
#: models.py:194
#, python-format
msgid "Type %s is not an amendment."
msgstr ""
#: models.py:202
#: models.py:196
msgid "Amend of related account doesn't match bill account."
msgstr ""
#: models.py:204
#: models.py:198
#, 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:206
#: models.py:200
msgid "Related invoice is an amendment."
msgstr ""
#: models.py:419
#: models.py:392
msgid "bill"
msgstr "factura"
#: models.py:420 models.py:499 templates/bills/microspective.html:75
#: models.py:393 models.py:458 templates/bills/microspective.html:73
msgid "description"
msgstr "descripción"
#: models.py:421
#: models.py:394
msgid "rate"
msgstr "tarifa"
#: models.py:422
#: models.py:395
msgid "quantity"
msgstr "cantidad"
#: models.py:424
#: models.py:397
msgid "Verbose quantity"
msgstr "Cantidad"
#: models.py:425 templates/admin/bills/bill/report.html:47
#: templates/bills/microspective.html:79
#: templates/bills/microspective.html:116
#: models.py:398 templates/admin/bills/bill/report.html:47
#: templates/bills/microspective.html:77
#: templates/bills/microspective.html:111
msgid "subtotal"
msgstr "subtotal"
#: models.py:426
#: models.py:399
msgid "tax"
msgstr "impuesto"
#: models.py:427
#: models.py:400
msgid "start"
msgstr "inicio"
#: models.py:428
#: models.py:401
msgid "end"
msgstr "fín"
#: models.py:431
#: models.py:403
msgid "Informative link back to the order"
msgstr ""
#: models.py:432
#: models.py:404
msgid "order billed"
msgstr ""
#: models.py:433
#: models.py:405
msgid "order billed until"
msgstr ""
#: models.py:434
#: models.py:406
msgid "created"
msgstr "creado"
#: models.py:436
#: models.py:408
msgid "amended line"
msgstr "linea rectificativa"
#: models.py:492
#: models.py:451
msgid "Volume"
msgstr "Volumen"
#: models.py:493
#: models.py:452
msgid "Compensation"
msgstr "Compensación"
#: models.py:494
#: models.py:453
msgid "Other"
msgstr "Otro"
#: models.py:498
#: models.py:457
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 ""
@ -570,19 +523,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:116
#: templates/bills/microspective.html:119
#: templates/bills/microspective.html:111
#: templates/bills/microspective.html:114
msgid "VAT"
msgstr "IVA"
#: templates/admin/bills/bill/report.html:51
#: templates/bills/microspective.html:119
#: templates/bills/microspective.html:114
msgid "taxes"
msgstr "impuestos"
#: templates/admin/bills/bill/report.html:56
#: templates/admin/bills/billline/report.html:60
#: templates/bills/microspective.html:54
#: templates/bills/microspective.html:53
msgid "TOTAL"
msgstr "TOTAL"
@ -600,20 +553,8 @@ 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 "Service"
msgid "Services"
msgstr ""
#: templates/admin/bills/billline/report.html:43
@ -638,56 +579,62 @@ msgstr "cantidad"
msgid "Profit"
msgstr ""
#: templates/bills/microspective-fee.html:115
#: templates/admin/bills/change_list.html:9
#, fuzzy
#| msgid "bill"
msgid "Add bill"
msgstr "factura"
#: templates/bills/microspective-fee.html:107
msgid "Due date"
msgstr "Fecha de pago"
#: templates/bills/microspective-fee.html:116
#: templates/bills/microspective-fee.html:108
#, python-format
msgid "On %(bank_account)s"
msgstr "En %(bank_account)s"
#: templates/bills/microspective-fee.html:122
#: templates/bills/microspective-fee.html:114
#, python-format
msgid "From %(ini)s to %(end)s"
msgstr "Desde %(ini)s hasta %(end)s"
#: templates/bills/microspective-fee.html:144
#: templates/bills/microspective-fee.html:121
msgid ""
"\n"
"<strong>With your membership</strong> you are supporting ...\n"
msgstr ""
#: templates/bills/microspective.html:50
#: templates/bills/microspective.html:49
msgid "DUE DATE"
msgstr "VENCIMIENTO"
#: templates/bills/microspective.html:58
#: templates/bills/microspective.html:57
#, python-format
msgid "%(bill_type)s DATE"
msgstr "FECHA %(bill_type)s"
#: templates/bills/microspective.html:76
#: templates/bills/microspective.html:74
msgid "period"
msgstr "periodo"
#: templates/bills/microspective.html:77
#: templates/bills/microspective.html:75
msgid "hrs/qty"
msgstr "hrs/cant"
#: templates/bills/microspective.html:78
#: templates/bills/microspective.html:76
msgid "rate/price"
msgstr "tarifa/precio"
#: templates/bills/microspective.html:137
#: templates/bills/microspective.html:131
msgid "COMMENTS"
msgstr "COMENTARIOS"
#: templates/bills/microspective.html:145
#: templates/bills/microspective.html:138
msgid "PAYMENT"
msgstr "PAGO"
#: templates/bills/microspective.html:149
#: templates/bills/microspective.html:142
#, python-format
msgid ""
"\n"
@ -701,11 +648,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:160
#: templates/bills/microspective.html:151
msgid "QUESTIONS"
msgstr "PREGUNTAS"
#: templates/bills/microspective.html:161
#: templates/bills/microspective.html:152
#, python-format
msgid ""
"\n"
@ -721,8 +668,3 @@ 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

View file

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

View file

@ -1,12 +1,12 @@
import datetime
from dateutil.relativedelta import relativedelta
from django.urls import reverse
from django.core.urlresolvers import reverse
from django.core.validators import ValidationError, RegexValidator
from django.db import models
from django.db.models import F, Sum
from django.db.models.functions import Coalesce
from django.template import loader
from django.template import loader, Context
from django.utils import timezone, translation
from django.utils.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', on_delete=models.CASCADE)
related_name='billcontact')
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,25 +86,23 @@ 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', on_delete=models.CASCADE)
related_name='%(class)s')
amend_of = models.ForeignKey('self', null=True, blank=True, verbose_name=_("amend of"),
related_name='amends', on_delete=models.SET_NULL)
related_name='amends')
type = models.CharField(_("type"), max_length=16, choices=TYPES)
created_on = models.DateField(_("created on"), auto_now_add=True)
closed_on = models.DateField(_("closed on"), blank=True, null=True, db_index=True)
@ -115,37 +113,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:
@ -192,7 +190,7 @@ class Bill(models.Model):
elif executed:
return self.EXECUTED
return self.BAD_DEBT
def clean(self):
if self.amend_of_id:
errors = {}
@ -206,27 +204,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:
@ -250,16 +248,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.")
@ -278,10 +276,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(
@ -298,12 +296,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,
@ -318,29 +316,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 = {}
@ -354,21 +352,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', ()):
@ -394,11 +392,6 @@ class AmendmentInvoice(Bill):
proxy = True
class AbonoInvoice(Bill):
class Meta:
proxy = True
class Fee(Bill):
class Meta:
proxy = True
@ -416,7 +409,7 @@ class ProForma(Bill):
class BillLine(models.Model):
""" Base model for bill item representation """
bill = models.ForeignKey(Bill, verbose_name=_("bill"), related_name='lines', on_delete=models.CASCADE)
bill = models.ForeignKey(Bill, verbose_name=_("bill"), related_name='lines')
description = models.CharField(_("description"), max_length=256)
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,
@ -434,24 +427,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, on_delete=models.CASCADE)
related_name='amendment_lines', null=True, blank=True)
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"
@ -467,7 +460,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
@ -478,7 +471,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)
@ -493,12 +486,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', on_delete=models.CASCADE)
line = models.ForeignKey(BillLine, verbose_name=_("bill line"), related_name='sublines')
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)

View file

@ -18,9 +18,6 @@ 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'

View file

@ -282,17 +282,3 @@ 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;
}

View file

@ -12,12 +12,6 @@
{% 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 %}

View file

@ -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

View file

@ -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, on_delete=models.SET_NULL)
related_name='contacts', null=True)
short_name = models.CharField(_("short name"), max_length=128)
full_name = models.CharField(_("full name"), max_length=256, blank=True)
email = models.EmailField()
@ -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()

View file

@ -1,8 +1,6 @@
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
@ -14,10 +12,6 @@ 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')
@ -28,7 +22,7 @@ class DatabaseAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
fieldsets = (
(None, {
'classes': ('extrapretty',),
'fields': ('account_link', 'name', 'type', 'users', 'display_users', 'comments'),
'fields': ('account_link', 'name', 'type', 'users', 'display_users'),
}),
)
add_fieldsets = (
@ -50,18 +44,18 @@ class DatabaseAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
filter_horizontal = ['users']
filter_by_account_fields = ('users',)
list_prefetch_related = ('users',)
actions = (list_accounts, save_selected)
@mark_safe
actions = (list_accounts,)
def display_users(self, db):
links = []
for user in db.users.all():
link = format_html('<a href="{}">{}</a>', change_url(user), user.username)
link = '<a href="%s">%s</a>' % (change_url(user), user.username)
links.append(link)
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:
@ -99,25 +93,25 @@ class DatabaseUserAdmin(SelectAccountAdminMixin, ChangePasswordAdminMixin, Exten
readonly_fields = ('account_link', 'display_databases',)
filter_by_account_fields = ('databases',)
list_prefetch_related = ('databases',)
actions = (list_accounts, save_selected)
@mark_safe
actions = (list_accounts,)
def display_databases(self, user):
links = []
for db in user.databases.all():
link = format_html('<a href="{}">{}</a>', change_url(db), db.name)
link = '<a href="%s">%s</a>' % (change_url(db), db.name)
links.append(link)
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:

View file

@ -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, renderer=None):
def render(self, name, value, attrs):
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"]

View file

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

View file

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

View file

@ -1,25 +0,0 @@
# -*- 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'),
),
]

View file

@ -1,20 +0,0 @@
# -*- 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=''),
),
]

View file

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

View file

@ -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,16 +20,15 @@ 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', on_delete=models.CASCADE,
verbose_name=_("Account"), related_name='databases')
comments = models.TextField(default="", blank=True)
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
related_name='databases')
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 """
@ -39,7 +38,7 @@ class Database(models.Model):
if user is not None:
return user.databaseuser
return None
@property
def active(self):
return self.account.is_active
@ -53,26 +52,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', on_delete=models.CASCADE,
verbose_name=_("Account"), related_name='databaseusers')
account = models.ForeignKey('accounts.Account', 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

View file

@ -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],
)

View file

@ -1,24 +1,22 @@
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.urls import reverse
from orchestra.admin.utils import change_url
from orchestra.contrib.orchestration.models import Route, Server
from orchestra.utils.sys import sshrun
from orchestra.utils.tests import (BaseLiveServerTestCase, random_ascii,
save_response_on_error, snapshot_on_error)
from django.core.urlresolvers import reverse
from selenium.webdriver.support.select import Select
from orchestra.admin.utils import change_url
from orchestra.contrib.orchestration.models import Server, Route
from orchestra.utils.sys import sshrun
from orchestra.utils.tests import (BaseLiveServerTestCase, random_ascii, save_response_on_error,
snapshot_on_error)
from ... import backends, settings
from ...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')
@ -26,40 +24,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)
@ -70,7 +68,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)
@ -83,7 +81,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)
@ -100,7 +98,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)
@ -119,7 +117,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)
@ -139,7 +137,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
@ -147,7 +145,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()
@ -156,22 +154,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,
@ -183,12 +181,11 @@ 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)
@ -196,31 +193,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()
@ -234,84 +231,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')
@ -320,7 +317,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')
@ -329,11 +326,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)

View file

@ -1,10 +1,8 @@
from django.contrib import admin
from django.urls import reverse
from django.core.urlresolvers 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
@ -34,18 +32,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)
@ -57,7 +55,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', 'dns2136_address_match_list')
fields = ('name', 'account_link', 'display_websites', 'display_addresses')
readonly_fields = (
'account_link', 'top_link', 'display_websites', 'display_addresses', 'implicit_records'
)
@ -68,23 +66,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 mark_safe('&nbsp;'*4 + domain.name)
return '&nbsp;'*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()
@ -94,22 +92,22 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
site_link = get_on_site_link(website.get_absolute_url())
admin_url = change_url(website)
title = _("Edit website")
link = format_html('<a href="{}" title="{}">{} {}</a>',
link = '<a href="%s" title="%s">%s %s</a>' % (
admin_url, title, website.name, site_link)
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)
add_link = format_html(
'<a href="{}" title="{}"><img src="{}" /></a>', add_url,
_("Add website"), static('orchestra/images/add.png'),
image = '<img src="%s"></img>' % static('orchestra/images/add.png')
add_link = '<a href="%s" title="%s">%s</a>' % (
add_url, _("Add website"), image
)
return _("No website %s") % (add_link)
return '---'
display_websites.admin_order_field = 'websites__name'
display_websites.short_description = _("Websites")
@mark_safe
display_websites.allow_tags = True
def display_addresses(self, domain):
if apps.isinstalled('orchestra.contrib.mailboxes'):
add_url = reverse('admin:mailboxes_address_add')
@ -128,9 +126,10 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
return '---'
display_addresses.short_description = _("Addresses")
display_addresses.admin_order_field = 'addresses__count'
@mark_safe
display_addresses.allow_tags = True
def implicit_records(self, domain):
defaults = []
types = set(domain.records.values_list('type', flat=True))
ttl = settings.DOMAINS_DEFAULT_TTL
lines = []
@ -142,14 +141,15 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
value=record.value
)
if not domain.record_is_implicit(record, types):
line = format_html('<strike>{}</strike>', line)
line = '<strike>%s</strike>' % line
if record.type is Record.SOA:
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)

View file

@ -1,5 +1,5 @@
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.decorators import detail_route
from rest_framework.response import Response
from 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')
@action(detail=True)
@detail_route()
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']

View file

@ -102,7 +102,7 @@ class Bind9MasterDomainController(ServiceController):
self.append(textwrap.dedent("""
# Apply changes
if [[ $UPDATED == 1 ]]; then
rm /etc/bind/master/*jnl || true; service bind9 restart
service bind9 reload
fi""")
)
@ -158,7 +158,6 @@ 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" {
@ -167,7 +166,6 @@ 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

View file

@ -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

View file

@ -2,7 +2,6 @@
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
@ -21,8 +20,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(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')),
('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')),
],
),
migrations.CreateModel(
@ -32,7 +31,7 @@ class Migration(migrations.Migration):
('ttl', models.CharField(help_text='Record TTL, defaults to 1h', max_length=8, validators=[orchestra.contrib.domains.validators.validate_zone_interval], verbose_name='TTL', blank=True)),
('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(on_delete=django.db.models.deletion.CASCADE, related_name='records', to='domains.Domain', verbose_name='domain')),
('domain', models.ForeignKey(related_name='records', to='domains.Domain', verbose_name='domain')),
],
),
]

View file

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

View file

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

View file

@ -1,31 +0,0 @@
# -*- 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'),
),
]

View file

@ -1,20 +0,0 @@
# -*- 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'),
),
]

View file

@ -1,20 +0,0 @@
# -*- 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),
),
]

View file

@ -1,20 +0,0 @@
# -*- 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),
),
]

View file

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

View file

@ -31,9 +31,9 @@ class Domain(models.Model):
validators.validate_allowed_domain
])
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), blank=True,
related_name='domains', on_delete=models.CASCADE, help_text=_("Automatically selected for subdomains."))
related_name='domains', help_text=_("Automatically selected for subdomains."))
top = models.ForeignKey('domains.Domain', null=True, related_name='subdomain_set',
editable=False, verbose_name=_("top domain"), on_delete=models.CASCADE)
editable=False, verbose_name=_("top domain"))
serial = models.IntegerField(_("serial"), default=utils.generate_zone_serial, editable=False,
help_text=_("A revision number that changes whenever this domain is updated."))
refresh = models.CharField(_("refresh"), max_length=16, blank=True,
@ -65,20 +65,16 @@ 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
@ -86,14 +82,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
@ -110,7 +106,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()
@ -119,21 +115,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()
@ -147,7 +143,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()
@ -159,7 +155,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,
@ -170,7 +166,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:
@ -202,7 +198,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:
@ -221,7 +217,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()
@ -249,7 +245,7 @@ class Domain(models.Model):
else:
records.append(record)
return records
def render_records(self):
result = ''
for record in self.get_records():
@ -273,7 +269,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'):
@ -294,7 +290,7 @@ class Record(models.Model):
TXT = 'TXT'
SPF = 'SPF'
SOA = 'SOA'
TYPE_CHOICES = (
(MX, "MX"),
(NS, "NS"),
@ -305,7 +301,7 @@ class Record(models.Model):
(TXT, "TXT"),
(SPF, "SPF"),
)
VALIDATORS = {
MX: (validators.validate_mx_record,),
NS: (validators.validate_zone_label,),
@ -317,19 +313,18 @@ class Record(models.Model):
SRV: (validators.validate_srv_record,),
SOA: (validators.validate_soa_record,),
}
domain = models.ForeignKey(Domain, verbose_name=_("domain"), related_name='records', on_delete=models.CASCADE)
domain = models.ForeignKey(Domain, verbose_name=_("domain"), related_name='records')
ttl = models.CharField(_("TTL"), max_length=8, blank=True,
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)
# max_length bumped from 256 to 1024 (arbitrary) on August 2019.
value = models.CharField(_("value"), max_length=1024,
value = models.CharField(_("value"), max_length=256,
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
@ -343,6 +338,6 @@ class Record(models.Model):
raise ValidationError({
'value': error,
})
def get_ttl(self):
return self.ttl or settings.DOMAINS_DEFAULT_TTL

View file

@ -122,6 +122,3 @@ 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;"

View file

@ -4,7 +4,7 @@ import socket
from functools import partial
from django.conf import settings as djsettings
from django.urls import reverse
from django.core.urlresolvers import reverse
from selenium.webdriver.support.select import Select
from 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()

View file

@ -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) > 254:
if len(value) > 63:
raise ValidationError(_("Labels must be 63 characters or less."))

View file

@ -1,14 +1,12 @@
from django.contrib import admin
from django.contrib.admin.templatetags.admin_static import static
from django.contrib.admin.templatetags.admin_urls import add_preserved_filters
from django.contrib.admin.utils import unquote
from django.http import HttpResponseRedirect
from django.urls import NoReverseMatch, reverse
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from django.core.urlresolvers import reverse, NoReverseMatch
from django.contrib.admin.templatetags.admin_urls import add_preserved_filters
from django.http import HttpResponseRedirect
from django.contrib.admin.utils import unquote
from django.contrib.admin.templatetags.admin_static import static
from orchestra.admin.utils import admin_date, admin_link
from orchestra.admin.utils import admin_link, admin_date
class LogEntryAdmin(admin.ModelAdmin):
@ -32,16 +30,15 @@ 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 = format_html('<a href="{url}"><img src="{img}"></img></a>', **{
edit = '<a href="%(url)s"><img src="%(img)s"></img></a>' % {
'url': reverse('admin:admin_logentry_change', args=(log.pk,)),
'img': static('admin/img/icon-changelink.svg'),
})
}
if log.is_addition():
return _('Added "%(link)s". %(edit)s') % {
'link': self.content_object_link(log),
@ -60,7 +57,8 @@ 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")
@ -69,7 +67,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)
@ -77,10 +75,11 @@ class LogEntryAdmin(admin.ModelAdmin):
url = reverse(view, args=(log.object_id,))
except NoReverseMatch:
return log.object_repr
return format_html('<a href="{}">{}</a>', url, log.object_repr)
return '<a href="%s">%s</a>' % (url, log.object_repr)
content_object_link.short_description = _("Content object")
content_object_link.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():
@ -90,14 +89,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():
@ -110,19 +109,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

View file

@ -1,12 +1,11 @@
from django import forms
from django.conf.urls import url
from django.contrib import admin
from django.urls import reverse
from django.core.urlresolvers import reverse
from django.db import models
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from django.utils.html import format_html, strip_tags
from django.utils.safestring import mark_safe
from django.utils.html import strip_tags
from django.utils.translation import ugettext_lazy as _
from markdown import markdown
@ -22,14 +21,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',
@ -45,13 +44,12 @@ 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,
@ -60,17 +58,16 @@ 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
@ -82,12 +79,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)
@ -106,18 +103,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):
@ -138,7 +135,7 @@ class TicketAdmin(ExtendedModelAdmin):
'owner__username'
)
actions = (
mark_as_unread, mark_as_read, reject_tickets,
mark_as_unread, mark_as_read, 'delete_selected', reject_tickets,
resolve_tickets, close_tickets, take_tickets
)
sudo_actions = ('delete_selected',)
@ -179,7 +176,7 @@ class TicketAdmin(ExtendedModelAdmin):
}),
)
list_select_related = ('queue', 'owner', 'creator')
class Media:
css = {
'all': ('issues/css/ticket-admin.css',)
@ -187,15 +184,14 @@ 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,
@ -211,47 +207,50 @@ 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 format_html('<span style="font-weight:normal;font-size:11px;">{}</span>', ticket.pk)
return '<span style="font-weight:normal;font-size:11px;">%s</span>' % ticket.pk
return ticket.pk
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 format_html("<strong class='unread'>{}</strong>", ticket.subject)
return "<strong class='unread'>%s</strong>" % ticket.subject
bold_subject.allow_tags = True
bold_subject.short_description = _("Subject")
bold_subject.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)
@ -270,12 +269,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")
@ -288,20 +287,21 @@ 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 format_html('<a href="{}">{}</a>', url, num)
return '<a href="%s">%d</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'))

View file

@ -1,5 +1,5 @@
from rest_framework import viewsets, mixins
from rest_framework.decorators import action
from rest_framework.decorators import detail_route
from rest_framework.response import Response
from 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
@action(detail=True)
@detail_route()
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'})
@action(detail=True)
@detail_route()
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')

View file

@ -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, renderer=None):
def render(self, name, value, attrs):
widget_id = attrs['id'] if attrs and 'id' in attrs else 'id_%s' % name
textarea = super(MarkDownWidget, self).render(name, value, attrs)
preview = ('<a class="load-preview" href="#" data-field="{0}">preview</a>'\
@ -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'])

View file

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

View file

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

View file

@ -1,32 +0,0 @@
# -*- 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'),
),
]

View file

@ -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', 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')
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')
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,11 +191,10 @@ class Message(models.Model):
class TicketTracker(models.Model):
""" Keeps track of user read tickets """
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')
ticket = models.ForeignKey(Ticket, verbose_name=_("ticket"), related_name='trackers')
user = models.ForeignKey(djsettings.AUTH_USER_MODEL, verbose_name=_("user"),
related_name='ticket_trackers')
class Meta:
unique_together = (
('ticket', 'user'),

View file

@ -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', 'address_domain')
filter_fields = ('name',)
router.register(r'lists', ListViewSet)

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