adding repo

This commit is contained in:
swasp 2023-08-29 10:50:23 +02:00
commit 3458c091a4
738 changed files with 148221 additions and 0 deletions

106
INSTALL.md Normal file
View File

@ -0,0 +1,106 @@
Installation
============
Django-orchestra ships with a set of management commands for automating some of the installation steps.
These commands are meant to be run within a **clean** Debian-like distribution, you should be specially careful while following this guide on a customized system.
Django-orchestra can be manually installed on any Linux system, however it is **strongly recommended** to chose the reference platform for your deployment (Debian 8.0 Jessie and Python 3.4).
1. Create a system user for running Orchestra
```bash
adduser orchestra
# not required but it will be very handy
sudo adduser orchestra sudo
su - orchestra
```
2. Install django-orchestra's source code
```bash
sudo apt-get install python3-pip
sudo pip3 install http://git.io/django-orchestra-dev
```
3. Install requirements
```bash
sudo orchestra-admin install_requirements
```
4. Create a new project
```bash
cd ~orchestra
orchestra-admin startproject <project_name> # e.g. panel
cd <project_name>
```
5. Create and configure a Postgres database
```bash
sudo apt-get install python3-psycopg2 postgresql
sudo python3 manage.py setuppostgres --db_password <password>
python3 manage.py migrate
```
6. Configure periodic execution of tasks (choose one)
1. Use cron (recommended)
```bash
python3 manage.py setupcronbeat
python3 manage.py syncperiodictasks
```
2. Use celeryd
```bash
sudo apt-get install rabbitmq-server
sudo python3 manage.py setupcelery --username orchestra
```
7. (Optional) Configure logging
```bash
sudo python3 manage.py setuplog
```
8. Configure the web server:
```bash
python3 manage.py collectstatic --noinput
sudo apt-get install nginx-full uwsgi uwsgi-plugin-python3
sudo python3 manage.py setupnginx --user orchestra
```
6. See the Django deployment checklist
```bash
python3 manage.py check --deploy
```
9. Start all services:
```bash
sudo python3 manage.py startservices
```
Upgrade
=======
To upgrade your Orchestra installation to the last release you can use `upgradeorchestra` management command. Before rolling the upgrade it is strongly recommended to check the [release notes](http://django-orchestra.readthedocs.org/en/latest/).
```bash
sudo python3 manage.py upgradeorchestra
```
Current in *development* version (master branch) can be installed by
```bash
sudo python3 manage.py upgradeorchestra dev
```
Additionally the following command can be used in order to determine the currently installed version:
```bash
python3 manage.py orchestraversion
```
Extra
=====
1. Generate a passwordless ssh key for orchestra user
ssh-keygen
2. Copy this key to all servers orchestra will manage, including itself is neccessary
ssh-copy-id root@<server-address>

39
INSTALLDEV.md Normal file
View File

@ -0,0 +1,39 @@
Development and Testing Setup
-----------------------------
If you are planing to do some development you may want to consider doing it under the following setup
1. Install Docker
```sh
curl https://get.docker.com/ | sh
```
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
docker build -t orchestra /tmp/
docker create --name orchestra -i -t -u orchestra -w /home/orchestra orchestra bash
docker start orchestra
docker attach orchestra
```
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
```
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
```
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
```

35
LICENSE Normal file
View File

@ -0,0 +1,35 @@
Copyright (c) 2014 Marc Aymerich and individual contributors
All Rights Reserved.
Django-orchestra is licensed under The BSD License (3 Clause, also known as
the new BSD license). The license is an OSI approved Open Source
license and is GPL-compatible(1).
The license text can also be found here:
http://www.opensource.org/licenses/BSD-3-Clause
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of Ask Solem, nor the
names of its contributors may be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL Ask Solem OR CONTRIBUTORS
BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.

8
MANIFEST.in Normal file
View File

@ -0,0 +1,8 @@
recursive-include orchestra *
recursive-exclude * __pycache__
recursive-exclude * *.py[co]
recursive-exclude * *~
recursive-exclude * *.save
recursive-exclude * *.svg

121
README.md Normal file
View File

@ -0,0 +1,121 @@
![](orchestra/static/orchestra/icons/Emblem-important.png) **This project is in early development stage**
Django Orchestra
================
Orchestra is a Django-based framework for building web hosting control panels.
* [Installation](#fast-deployment-setup)
* [Roadmap](ROADMAP.md)
Motivation
----------
There are a lot of widely used open source hosting control panels, however, none of them seems apropiate when you already have an existing service infrastructure or simply you want your services to run on a particular architecture.
The goal of this project is to provide the tools for easily build a fully featured control panel that is not tied to any particular service architecture.
Overview
--------
* The **admin interface** is based on [Django Admin](https://docs.djangoproject.com/en/dev/ref/contrib/admin/). The resulting interface is very model-centric with a limited workflow pattern: change lists, add and change forms. The advantage is that only little declarative code is required.
* It does **not** provide a **customer-facing interface**, but provides a REST API that allows you to build one.
* Service [orchestration](orchestra/contrib/orchestration), [resource management](orchestra/contrib/resources), [billing](orchestra/contrib/bills), [accountancy](orchestra/contrib/orders) is provided in a decoupled way, meaning:
* You can [develop new services](docs/create-services.md) without worring about those parts
* You can replace any of these parts by your own implementation without carring about the others
* You can reuse any of those modules on your Django projects
* Be advised, because its flexibility Orchestra may be more tedious to deploy than traditional web hosting control panels.
![](docs/images/index-screenshot.png)
Fast Deployment Setup
---------------------
This deployment is **not suitable for production** but more than enough for checking out this project. For other deployments checkout these links:
* [Development](INSTALLDEV.md)
* [Production](INSTALL.md)
```bash
# Create and activate a Python virtualenv
# Make sure python3.x-venv package is installed on your system
python3 -mvenv env-django-orchestra
source env-django-orchestra/bin/activate
# Install Orchestra and its dependencies
pip3 install http://git.io/django-orchestra-dev
# The only non-pip required dependency for runing pip3 install is python3-dev
sudo apt-get install python3-dev
pip3 install -r http://git.io/orchestra-requirements.txt
# Create a new Orchestra site
orchestra-admin startproject panel
python3 panel/manage.py migrate
python3 panel/manage.py runserver
```
Now you can see the web interface on `http://localhost:8000/admin/`
Quick Start
-----------
0. Install django-orchestra following any of these methods:
1. [PIP-only, Fast deployment setup (demo)](#fast-deployment-setup)
2. [Docker container (development)](INSTALLDEV.md)
3. [Install on current system (production)](INSTALL.md)
1. Generate a password-less SSH key for user `orchestra` and transfer it to your servers:
```bash
orchestra@panel:~ ssh-keygen
orchestra@panel:~ ssh-copy-id root@server.address
```
Now add the servers using the web interface `/admin/orchestration/servers`, check that the SSH connection is working and Orchestra is able to report servers uptimes.
2. Configure your services, one at a time, staring with domains, databases, webapps, websites, ...
1. Add related [routes](orchestra/contrib/orchestration) via `/admin/orchestration/route/`
2. Configure related settings on `/admin/settings/setting/`
3. If required, configure related [resources](orchestra/contrib/resources) like *account disk limit*, *VPS traffic*, etc `/resources/resource/`
3. Test if create and delete service instances works as expected
4. Do the same for the remaining services. You can disable services that you don't want by editing `INSTALLED_APPS` setting
3. Configure billing by adding [services](orchestra/contrib/services) `/admin/services/service/add/` and [plans](orchestra/contrib/plans) `/admin/plans/plan/`. Once a service is created hit the *Update orders* button to create orders for existing service instances, orders for new instances will be automatically created.
License
-------
Copyright (c) 2014 - Marc Aymerich and individual contributors.
All Rights Reserved.
Django-orchestra is licensed under The BSD License (3 Clause, also known as
the new BSD license). The license is an OSI approved Open Source
license and is GPL-compatible(1).
The license text can also be found here:
http://www.opensource.org/licenses/BSD-3-Clause
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of Marc Aymerich, nor the
names of its contributors may be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL Ask Solem OR CONTRIBUTORS
BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.

69
ROADMAP.md Normal file
View File

@ -0,0 +1,69 @@
# Roadmap
Note `*` _for sustancial progress_
### 1.0a1 Milestone (first alpha release on ~~Oct '14~~ Apr '15)
1. [x] Automated deployment of the development environment
2. [x] Automated installation and upgrading
2. ~~[ ] Testing framework for running unittests and functional tests with LXC containers~~
2. [ ] Continuous integration with Jenkins
2. [x] Admin interface based on django.contrib.admin
3. [x] REST API for users
2. [x] [Orchestra-orm](https://github.com/glic3rinu/orchestra-orm) a Python library for easily interacting with Orchestra REST API
3. [x] Service orchestration framework
4. [x] Data model, crazy input validation, admin and REST interfaces, permissions, unit and functional tests, service management, migration scripts and documentation of:
1. [x] PHP/static Web applications
1. [x] Websites with Apache
2. [x] FTP/rsync/scp/shell system accounts
2. [x] Databases and database users with MySQL
1. [x] Mail accounts, aliases, forwards with Postfix and Dovecot
1. [x] DNS with Bind
1. [x] Mailing lists with Mailman
1. [x] Contact management and service contraction
1. [ ] *Unittests of the bussines logic
2. [x] Functional tests of Admin UI and REST interations
1. [ ] Initial documentation
### 1.0b1 Milestone (first beta release on ~~Dec '14~~ Jun '15)
1. [x] Resource allocation and monitoring
1. [x] Order tracking
2. [x] Service definition framework, service plans and pricing
3. [ ] *Billing
3. [x] Invoice
3. [x] Membership fee
3. [x] Amendment invoice
3. [x] Amendment fee
3. [x] Pro Forma
3. [ ] *Advanced bill handling (move lines, undo billing, ...)
1. [x] Payment methods
1. [x] SEPA Direct Debit
2. [x] SEPA Credit Transfer
2. [ ] Additional services
2. [ ] *VPS with Proxmox/OpenVZ
2. [x] SaaS (Software as a Service) Gitlab/phpList/BSCW/Wordpress/Moodle/Drupal
2. [x] Wordpress webapps
3. [ ] uwsgi-emperor Python webapps
2. [x] Miscellaneous services
2. [x] Issue tracking system
### 1.0 Milestone (first stable release on Sep '15)
1. [ ] Stabilize data model, internal APIs and REST API
3. [ ] Spanish and Catalan translations
1. [ ] Complete documentation for developers
### 2.0 Milestone (unscheduled)
1. [ ] Integration with third-party service providers, e.g. Gandi
2. [ ] Scheduling of service cancellations and deactivations
1. [ ] Object-level permission system
2. [ ] REST API functionality for superusers
3. [ ] Responsive user interface, based on a JS framework.
4. [ ] Full development documentation
5. [ ] [Ansible](http://www.ansible.com/home) orchestration method, which synchronizes the whole service config everytime instead of incremental changes.

472
TODO.md Normal file
View File

@ -0,0 +1,472 @@
==== TODO ====
* use format_html_join for orchestration email alerts
* enforce an emergency email contact and account to contact contacts about problems when mailserver is down
* add `BackendLog` retry action
* webmail identities and addresses
* Permissions .filter_queryset()
* env vars instead of multiple settings files: https://devcenter.heroku.com/articles/config-vars ?
* backend logs with hal logo
* help_text on readonly_fields specialy Bill.state. (eg. A bill is in OPEN state when bla bla )
* order.register_at
@property
def register_on(self):
return order.register_at.date()
* mail backend related_models = ('resources__content_type') ??
* Maildir billing tests/ webdisk billing tests (avg metric)
* when using modeladmin to store shit like self.account, make sure to have a cleanslate in each request? no, better reuse the last one
* jabber with mailbox accounts (dovecot mail notification)
* rename accounts register to "account", and reated api and admin references
* AccountAdminMixin auto adds 'account__name' on searchfields
* What fields we really need on contacts? name email phone and what more?
* DOC: Complitely decouples scripts execution, billing, service definition
* init.d celery scripts
-# Required-Start: $network $local_fs $remote_fs postgresql celeryd
-# Required-Stop: $network $local_fs $remote_fs postgresql celeryd
* regenerate virtual_domains every time (configure a separate file for orchestra on postfix)
* Backend optimization
* fields = ()
* ignore_fields = ()
* based on a merge set of save(update_fields)
* proforma without billing contact?
* print open invoices as proforma?
* env ORCHESTRA_MASTER_SERVER='test1.orchestra.lan' ORCHESTRA_SECOND_SERVER='test2.orchestra.lan' ORCHESTRA_SLAVE_SERVER='test3.orchestra.lan' python3 manage.py test orchestra.contrib.domains.tests.functional_tests.tests:AdminBind9BackendDomainTest --nologcapture --keepdb
* ForeignKey.swappable
* REST PERMISSIONS
* Databases.User add reverse M2M databases widget (like mailbox.addresses)
* Make one dedicated CGI user for each account only for CGI execution (fpm/fcgid). Different from the files owner, and without W permissions, so attackers can not inject backdors and malware.
* resource min max allocation with validation
* domain validation parse named-checzone output to assign errors to fields
* Directory Protection on webapp and use webapp path as base path (validate)
* webapp backend option compatibility check? raise exception, missconfigured error
* Resource used_list_display=True, allocated_list_displat=True, allow resources to show up on list_display
* BackendLog.updated_at (tasks that run over several minutes when finished they do not appear first on the changelist) (like celery tasks.when)
* Create an admin service_view with icons (like SaaS app)
* prevent @pangea.org email addresses on contacts, enforce at least one email without @pangea.org
ln -s /proc/self/fd /dev/fd
POST INSTALL
------------
* Generate a password-less ssh key, and copy it to the servers you want to orchestrate.
ssh-keygen
ssh-copy-id root@<server-address>
Php binaries should have this format: /usr/bin/php5.2-cgi
* logs on panel/logs/ ? mkdir ~webapps, backend post save signal?
* <IfModule security2_module> and other IfModule on backend SecRule
# Orchestra global search box on the page head, based https://github.com/django/django/blob/master/django/contrib/admin/options.py#L866 and iterating over all registered services and inspectin its admin.search_fields
* contain error on plugin missing key (plugin dissabled): NOP, fail hard is better than silently, perhaps fail at starttime? apploading machinary
* contact.alternative_phone on a phone.tooltip, email:to
* make sure that you understand the risks
* full support for deactivation of services/accounts
* Display admin.is_active (disabled account special icon and order by support)
* lock resource monitoring
* -EXecCGI in common CMS upload locations /wp-upload/upload/uploads
* cgi user / pervent shell access
* prevent stderr when users exists on backend i.e. mysql user create
* disable anonymized list options (mailman)
* tags = GenericRelation(TaggedItem, related_query_name='bookmarks')
* user provided crons
* ```<?php
$moodle_host = $SERVER[HTTP_HOST];
require_once(/etc/moodles/.$moodle_host.config.php);``` moodle/drupla/php-list multi-tenancy
* make account available on all admin forms
* more robust backend error handling, continue executing but exit code > 0 if failure: failing_cmd || exit_code=1 and don't forget to call super.commit()!!
* website directives uniquenes validation on serializers
+ is_Active custom filter with support for instance.account.is_Active annotate with F() needed (django 1.8)
* document service help things: discount/refound/compensation effect and metric table
* Document metric interpretation help_text
* document plugin serialization, data_serializer?
* Document strong input validation
# bill line managemente, remove, undo (only when possible), move, copy, paste
* budgets: no undo feature
* Autocomplete admin fields like <site_name>.phplist... with js
* allow empty metric pack for default rates? changes on rating algo
* payment methods icons
* use server.name | server.address on python backends, like gitlab instead of settings?
* TODO raise404, here and everywhere
* update service orders on a celery task? because it take alot
# FIXME do more test, make sure billed until doesn't get uodated whhen services are billed with les metric, and don't upgrade billed_until when undoing under this circumstances
# * line 513: change threshold and one time service metric change should update last value if not billed, only record for recurring invoicing. postpay services should store the last metric for pricing period.
# * add ini, end dates on bill lines and breakup quanity into size(defaut:1) and metric
# * threshold for significative metric accountancy on services.handler
# * http://orchestra.pangea.org/admin/orders/order/6418/
* move normurlpath to orchestra.utils from websites.utils
* write down insights
* websites directives get_location() and use it on last change view validation stage to compare with contents.location and also on the backend ?
* modeladmin Default filter + search isn't working, prepend filter when searching
* create service help templates based on urlqwargs with the most basic services.
Translation
-----------
mkdir locale
django-admin.py makemessages -l ca
django-admin.py compilemessages -l ca
https://docs.djangoproject.com/en/1.7/topics/i18n/translation/#joining-strings-string-concat
from django.utils.translation import gettext
from django.utils import translation
translation.activate('ca')
gettext("Description")
* saas validate_creation generic approach, for all backends. standard output
# create orchestrate databases.Database pk=1 -n --dry-run | --noinput --action save (default)|delete --backend name (limit to this backend) --help
* postupgradeorchestra send signals in order to hook custom stuff
* gevent is not ported to python3 :'(
# FIXME account deletion generates an integrity error
https://code.djangoproject.com/ticket/24576
# FIXME what to do when deleting accounts? set fk null and fill a username charfield? issues, invoices.. we whant all this to go away?
* implement delete All related services
* read https://docs.djangoproject.com/en/dev/releases/1.8/ and fix deprecation warnings
* create nice fieldsets for SaaS, WebApp types and services, and helptexts too!
* replace make_option in management commands
# FIXME model contact info and account info (email, name, etc) correctly/unredundant/dry
* Use the new django.contrib.admin.RelatedOnlyFieldListFilter in ModelAdmin.list_filter to limit the list_filter choices to foreign objects which are attached to those from the ModelAdmin.
+ Query Expressions, Conditional Expressions, and Database Functions¶
* forms: You can now pass a callable that returns an iterable of choices when instantiating a ChoiceField.
* move all tests to django-orchestra/tests
* *natural keys: those fields that uniquely identify a service, list.name, website.name, webapp.name+account, make sure rest api can not edit thos things
* MultiCHoiceField proper serialization
* replace unique_name by natural_key?
* do not require contact or create default
* abstract model classes that enabling overriding, and ORCHESTRA_DATABASE_MODEL settings + orchestra.get_database_model() instead of explicitly importing from orchestra.contrib.databases.models import Database.. (Admin and REST API are fucked then?)
# billing order list filter detect metrics that are greater from those of billing_date
# Ignore superusers & co on billing: list filter doesn't work nor ignore detection
# bill.totals make it 100% computed?
* joomla: wget https://github.com/joomla/joomla-cms/releases/download/3.4.1/Joomla_3.4.1-Stable-Full_Package.tar.gz -O - | tar xvfz -
# Amend lines???
# orders currency setting
# Determine the difference between data serializer used for validation and used for the rest API!
# Make PluginApiView that fills metadata and other stuff like modeladmin plugin support
# reset setting button
# admin edit relevant djanog settings
# django SITE_NAME vs ORCHESTRA_SITE_NAME ?
# TASKS_ENABLE_UWSGI_CRON_BEAT (default) for production + system check --deploy
if 'wsgi' in sys.argv and settings.TASKS_ENABLE_UWSGI_CRON_BEAT:
import uwsgi
def uwsgi_beat(signum):
print "It's 5 o'clock of the first day of the month."
uwsgi.register_signal(99, '', uwsgi_beat)
uwsgi.add_timer(99, 60)
# TASK_BEAT_BACKEND = ('cron', 'celerybeat', 'uwsgi')
# Ship orchestra production-ready (no DEBUG etc)
# reload generic admin view ?redirect=http...
# inspecting django db connection for asserting db readines? or performing a query
* wake up django mailer on send_mail
from orchestra.contrib.tasks import task
import time, sys
@task(name='rata')
def counter(num, log):
for i in range(1, num):
with open(log, 'a') as handler:
handler.write(str(i))
sys.stderr.write('hola\n')
time.sleep(1)
counter.apply_async(10, '/tmp/kakas')
* Provide some fixtures with mocked data
TODO http://wiki2.dovecot.org/HowTo/SimpleVirtualInstall
TODO http://wiki2.dovecot.org/HowTo/VirtualUserFlatFilesPostfix
TODO mount the filesystem with "nosuid" option
* uwse uwsgi cron: decorator or config cron = 59 2 -1 -1 -1 %(virtualenv)/bin/python manage.py runmyfunnytask
# mailboxes.address settings multiple local domains, not only one?
# backend.context = self.get_context() or save(obj, context=None) ?? more like form.cleaned_data
# smtplib.SMTPConnectError: (421, b'4.7.0 mail.pangea.org Error: too many connections from 77.246.181.209')
# rename virtual_maps to virtual_alias_maps and remove virtual_alias_domains ?
# virtdomains file is not ideal, prevent user provided fake/error domains there! and make sure to chekc if this file is required!
# Deprecate restart/start/stop services (do touch wsgi.py and fuck celery)
orchestra-beat support for uwsgi cron
make django admin taskstate uncollapse fucking traceback, ( if exists ?)
# form for custom message on admin save "comment & save"?
# backend.context and backned.instance provided when an action is called? like forms.cleaned_data: do it on manager.generation(backend.context = backend.get_context()) or in backend.__getattr__ ? also backend.head,tail,content switching on manager.generate()?
resorce monitoring more efficient, less mem an better queries for calc current data
# bill this https://orchestra.pangea.org/admin/orders/order/8236/ should be already billed, <= vs <
# Convert rating method from function to PluginClass
# autoresponses on mailboxes, not addresses or remove them
# force save and continue on routes (and others?)
# gevent for python3
apt-get install cython3
export CYTHON='cython3'
pip3 install https://github.com/fantix/gevent/archive/master.zip
# SIgnal handler for notify workers to reload stuff, like resource sync: https://docs.python.org/2/library/signal.html
# BUG Delete related services also deletes account!
# get_related service__rates__isnull=TRue is that correct?
# uwsgi hot reload? http://uwsgi-docs.readthedocs.org/en/latest/articles/TheArtOfGracefulReloading.html
# change mailer.message.priority by, queue/sent inmediatelly or rename critical to noq
method(
arg, arg, arg)
Bash/Python/PHPController
# services.handler as generator in order to save memory? not swell like a balloon
import uwsgi
from uwsgidecorators import timer
from django.utils import autoreload
@timer(3)
def change_code_gracefull_reload(sig):
if autoreload.code_changed():
uwsgi.reload()
# using kill to send the signal
kill -HUP `cat /tmp/project-master.pid`
# or the convenience option --reload
uwsgi --reload /tmp/project-master.pid
# or if uwsgi was started with touch-reload=/tmp/somefile
touch /tmp/somefile
# Serializers.validation migration to DRF3: grep -r 'attrs, source' *|grep -v '~'
serailzer self.instance on create.
* check certificate: websites directive ssl + domains search on miscellaneous
# billing invoice link on related invoices not overflow nginx GET vars
* backendLog store method and language... and use it for display_script with correct lexer
@register.filter
def comma(value):
value = str(value)
if '.' in value:
left, right = str(value).split('.')
return ','.join((left, right))
return value
# payment/bill report allow to change template using a setting variable
# Payment transaction stats, graphs over time
reporter.stories_filed = F('stories_filed') + 1
reporter.save()
In order to access the new value that has been saved in this way, the object will need to be reloaded:
https://docs.djangoproject.com/en/dev/ref/models/conditional-expressions/
Greatest
Colaesce('total', 'computed_total')
Case
# SQL case on payment transaction state ? case when trans.amount >
# Resource inline links point to custom changelist view that preserve state (breadcrumbs, title, etc) rather than generic changeview with queryarg filtering
# ORDER diff Pending vs ALL
# DELETING RESOURCE RELATED OBJECT SHOULD NOT delete related monitor data for traffic accountancy
# round decimals on every billing operation
# use "su $user --shell /bin/bash" on backends for security : MKDIR -p...
# model.field.flatchoices
* This is beta software, please test thoroughly before putting into production and report back any issues.
# messages SMTP errors: temporary->deferre else Failed
# Don't enforce one contact per account? remove account.email in favour of contacts?
# Mailer: mark as sent
# Mailer: download attachments
# Enable/disable ignore period orders list filter
# Modsecurity rules template by cms (wordpress, joomla, dokuwiki (973337 973338 973347 958057), ...
deploy --dev
deploy.sh and deploy-dev.sh autoupgrade
short URLS: https://github.com/rsvp/gitio
link backend help text variables to settings/#var_name
mkhomedir_helper or create ssh homes with bash.rc and such
# warnings if some plugins are disabled, like make routes red
# replace show emails by https://docs.python.org/3/library/email.contentmanager.html#module-email.contentmanager
# setupforbiddendomains --url alexa -n 5000
* remove welcome box on dashboard?
# account contacts inline, show provided fields and ignore the rest?
# email usage -webkit-column-count:3;-moz-column-count:3;column-count:3;
# validate_user on saas.wordpress to detect if username already exists before attempting to create a blog
# webapps don't override owner and permissions on every save(), just on create
# webapps php fpm allow pool config to be overriden. template + pool inheriting template?
# get_context signal to overridaconfiguration? best practice: all context on get_context, ever use other context. template rendering as backend generator: proof of concept
# if not database_ready(): schedule a retry in 60 seconds, otherwise resources and other dynamic content gets fucked, maybe attach some 'signal' when first query goes trough
with database_ready:
shit_happend, otherwise schedule for first query
# Entry.objects.filter()[:1].first() (LIMIT 1)
# Reverse lOgHistory order by date (lastest first)
* setuppostgres use porject_name for db name and user instead of orchestra
# POSTFIX web traffic monitor '": uid=" from=<%(user)s>'
# Automatically re-run backends until success? only timedout executions?
# TODO save serialized versions ob backendoperation.instance in order to allow backend reexecution of deleted objects
# lets encrypt: DNS vs HTTP challange
# lets enctypt: autorenew
# Warning websites with ssl options without https protocol
# Schedule cancellation
# Multiple domains wordpress
# Reversion
# Disable/enable SaaS and VPS
# Don't show lines with size 0?
# pending orders with recharge do not show up
# Traffic of disabled accounts doesn't get disabled
# URL encode "Order description" on clone
# Service CLONE METRIC doesn't work
# Show warning when saving order and metricstorage date is inconistent with registered date!
# exclude from change list action, support for multiple exclusion
# breadcrumbs https://orchestra.pangea.org/admin/domains/domain/?account_id=930
with open(file) as handler:
os.unlink(file)
# Mark transaction process as executed should not override higher transaction states
# Bill amend and related transaction, what to do? allow edit transaction ammount of amends when their are pending execution
# DASHBOARD: Show owned tickets, scheduled actions, maintenance operations (diff domains)
# Add confirmation step on transaction actions like process transaction
# SAVE INISTIAL PASSWORD from all services, and just use it to create the service, never update it
# Don't use system groups for unixmailbackends
# trigger a reload_relations on updates on monitors on all processes, not just current one. Alt. restart service

222
docs/API.rst Normal file
View File

@ -0,0 +1,222 @@
=================================
Orchestra REST API Specification
=================================
:Version: 0.1
Resources
---------
.. contents::
:local:
Panel [application/vnd.orchestra.Panel+json]
============================================
A Panel represents a user's view of all accessible resources.
A "Panel" resource model contains the following fields:
========================== ============ ========== ===========================
**Field name** **Type** **Occurs** **Description**
========================== ============ ========== ===========================
uri URI 1 A GET against this URI refreshes the client representation of the resources accessible to this user.
services Object[] 0..1 {'DNS': {'names': "names_URI", 'zones': "zones_URI}, {'Mail': {'Virtual_user': "virtual_user_URI" ....
accountancy Object[] 0..1
administration Object[] 0..1
========================== ============ ========== ===========================
Contact [application/vnd.orchestra.Contact+json]
================================================
A Contact represents
========================== ============ ========== ===========================
**Field name** **Type** **Occurs** **Description**
========================== ============ ========== ===========================
uri URI 1
name String 1
surname String 0..1
second_surname String 0..1
national_id String 1
type String 1
language String 1
address String 1
city String 1
zipcode Number 1
province String 1
country String 1
fax String 0..1
emails String[] 1
phones String[] 1
billing_contact Contact 0..1
technical_contact Contact 0..1
administrative_contact Contact 0..1
========================== ============ ========== ===========================
TODO: phone and emails for this contacts this contacts should be equal to Contact on Django models
User [application/vnd.orchestra.User+json]
==========================================
A User represents
========================== ============ ========== ===========================
**Field name** **Type** **Occurs** **Description**
========================== ============ ========== ===========================
username String
uri URI 1
contact Contact
password String
first_name String
last_name String
email_address String
active Boolean
staff_status Boolean
superuser_status Boolean
groups Group
user_permissions Permission[]
last_login String
date_joined String
system_user SystemUser
virtual_user VirtualUser
========================== ============ ========== ===========================
SystemUser [application/vnd.orchestra.SystemUser+json]
======================================================
========================== =========== ========== ===========================
**Field name** **Type** **Occurs** **Description**
========================== =========== ========== ===========================
user User
uri URI 1
user_shell String
user_uid Number
primary_group Group
homedir String
only_ftp Boolean
========================== =========== ========== ===========================
VirtualUser [application/vnd.orchestra.VirtualUser+json]
========================================================
========================== ============ ========== ===========================
**Field name** **Type** **Occurs** **Description**
========================== ============ ========== ===========================
user User
uri URI 1
emailname String
domain Name <VirtualDomain?>
home String
========================== ============ ========== ===========================
Zone [application/vnd.orchestra.Zone+json]
==========================================
========================== ============ ========== ===========================
**Field name** **Type** **Occurs** **Description**
========================== ============ ========== ===========================
origin String
uri URI 1
contact Contact
primary_ns String
hostmaster_email String
serial Number
slave_refresh Number
slave_retry Number
slave_expiration Number
min_caching_time Number
records Object[] Domain record i.e. {'name': ('type', 'value') }
========================== ============ ========== ===========================
Name [application/vnd.orchestra.Name+json]
==========================================
========================== ============ ========== ===========================
**Field name** **Type** **Occurs** **Description**
========================== ============ ========== ===========================
name String
extension String
uri URI 1
contact Contact
register_provider String
name_server Object[] Name server key/value i.e. {'ns1.pangea.org': '1.1.1.1'}
virtual_domain Boolean <TODO: is redundant with virtual domain type?>
virtual_domain_type String
zone Zone
========================== ============ ========== ===========================
VirtualHost [application/vnd.orchestra.VirtualHost+json]
========================================================
<TODO: REST and dynamic attributes (resources, contacts)>
A VirtualHost represents an Apache-like virtualhost configuration, which is useful for generating all the configuration files on the web server.
A VirtualHost resource model contains the following fields:
========================== ============ ========== ===========================
**Field name** **Type** **Occurs** **Description**
========================== ============ ========== ===========================
server_name String
uri URI
contact Contact
ip String
port Number
domains Name[]
document_root String
custom_directives String[]
fcgid_user String
fcgid_group string String
fcgid_directives Object Fcgid custom directives represented on a key/value pairs i.e. {'FcgidildeTimeout': 1202}
php_version String
php_directives Object PHP custom directives represented on key/value pairs i.e. {'display errors': 'True'}
resource_swap_current Number PHP custom directives represented on key/value pairs i.e. {'display errors': 'True'}
resource_swap_limit Number PHP custom directives represented on key/value pairs i.e. {'display errors': 'True'}
resource_cpu_current Number
resource_cpu_limit Number
========================== ============ ========== ===========================
Daemon [application/vnd.orchestra.Daemon+json]
==============================================
========================== ============ ========== ===========================
**Field name** **Type** **Occurs** **Description**
========================== ============ ========== ===========================
name String
uri URI 1
content_type String
active Boolean
save_template String
save_method String
delete_template String
delete_method String
daemon_instances Object[] {'host': 'expression'}
========================== ============ ========== ===========================
Monitor [application/vnd.orchestra.Monitor+json]
================================================
========================== ============ ========== ===========================
**Field name** **Type** **Occurs** **Description**
========================== ============ ========== ===========================
uri URI 1
daemon Daemon
resource String
monitoring_template String
monitoring method String
exceed_template String <TODO: rename on monitor django model>
exceed_method String
recover_template String
recover_method String
allow_limit Boolean
allow_unlimit Boolean
default_initial Number
block_size Number
algorithm String
period String
interval String 0..1
crontab String 0..1
========================== ============ ========== ===========================
#Layout inspired from http://kenai.com/projects/suncloudapis/pages/CloudAPISpecificationResourceModels

153
docs/Makefile Normal file
View File

@ -0,0 +1,153 @@
# Makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
PAPER =
BUILDDIR = _build
# Internal variables.
PAPEROPT_a4 = -D latex_paper_size=a4
PAPEROPT_letter = -D latex_paper_size=letter
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
# the i18n builder cannot share the environment and doctrees with the others
I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
help:
@echo "Please use \`make <target>' where <target> is one of"
@echo " html to make standalone HTML files"
@echo " dirhtml to make HTML files named index.html in directories"
@echo " singlehtml to make a single large HTML file"
@echo " pickle to make pickle files"
@echo " json to make JSON files"
@echo " htmlhelp to make HTML files and a HTML help project"
@echo " qthelp to make HTML files and a qthelp project"
@echo " devhelp to make HTML files and a Devhelp project"
@echo " epub to make an epub"
@echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
@echo " latexpdf to make LaTeX files and run them through pdflatex"
@echo " text to make text files"
@echo " man to make manual pages"
@echo " texinfo to make Texinfo files"
@echo " info to make Texinfo files and run them through makeinfo"
@echo " gettext to make PO message catalogs"
@echo " changes to make an overview of all changed/added/deprecated items"
@echo " linkcheck to check all external links for integrity"
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
clean:
-rm -rf $(BUILDDIR)/*
html:
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
dirhtml:
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
singlehtml:
$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
@echo
@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
pickle:
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
@echo
@echo "Build finished; now you can process the pickle files."
json:
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
@echo
@echo "Build finished; now you can process the JSON files."
htmlhelp:
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
@echo
@echo "Build finished; now you can run HTML Help Workshop with the" \
".hhp project file in $(BUILDDIR)/htmlhelp."
qthelp:
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
@echo
@echo "Build finished; now you can run "qcollectiongenerator" with the" \
".qhcp project file in $(BUILDDIR)/qthelp, like this:"
@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-orchestra.qhcp"
@echo "To view the help file:"
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-orchestra.qhc"
devhelp:
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
@echo
@echo "Build finished."
@echo "To view the help file:"
@echo "# mkdir -p $$HOME/.local/share/devhelp/django-orchestra"
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-orchestra"
@echo "# devhelp"
epub:
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
@echo
@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
latex:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
@echo "Run \`make' in that directory to run these through (pdf)latex" \
"(use \`make latexpdf' here to do that automatically)."
latexpdf:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo "Running LaTeX files through pdflatex..."
$(MAKE) -C $(BUILDDIR)/latex all-pdf
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
text:
$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
@echo
@echo "Build finished. The text files are in $(BUILDDIR)/text."
man:
$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
@echo
@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
texinfo:
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo
@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
@echo "Run \`make' in that directory to run these through makeinfo" \
"(use \`make info' here to do that automatically)."
info:
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo "Running Texinfo files through makeinfo..."
make -C $(BUILDDIR)/texinfo info
@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
gettext:
$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
@echo
@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
changes:
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
@echo
@echo "The overview file is in $(BUILDDIR)/changes."
linkcheck:
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
@echo
@echo "Link check complete; look for any errors in the above output " \
"or in $(BUILDDIR)/linkcheck/output.txt."
doctest:
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
@echo "Testing of doctests in the sources finished, look at the " \
"results in $(BUILDDIR)/doctest/output.txt."

10
docs/README.md Normal file
View File

@ -0,0 +1,10 @@
# Documentation
### Architecture
* [Orchestration](../orchestra/contrib/orchestration)
* [Orders](../orchestra/contrib/orders)
* [Resources](../orchestra/contrib/resources)

244
docs/conf.py Normal file
View File

@ -0,0 +1,244 @@
from __future__ import unicode_literals
# -*- coding: utf-8 -*-
#
# django-orchestra documentation build configuration file, created by
# sphinx-quickstart on Wed Aug 8 11:07:40 2012.
#
# This file is execfile()d with the current directory set to its containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
import sys, os
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#sys.path.insert(0, os.path.abspath('.'))
# -- General configuration -----------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = ['sphinx.ext.autodoc']
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix of source filenames.
source_suffix = '.rst'
# The encoding of source files.
#source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = u'django-orchestra'
copyright = u'2012, Marc Aymerich'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = '0.1'
# The full version, including alpha/beta/rc tags.
release = '0.1'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#today = ''
# Else, today_fmt is used as the format for a strftime call.
#today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ['_build']
# The reST default role (used for this markup: `text`) to use for all documents.
#default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
#add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
#add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
#modindex_common_prefix = []
# -- Options for HTML output ---------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
html_theme = 'default'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#html_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory.
#html_theme_path = []
# The name for this set of Sphinx documents. If None, it defaults to
# "<project> v<release> documentation".
#html_title = None
# A shorter title for the navigation bar. Default is the same as html_title.
#html_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
#html_logo = None
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
#html_favicon = None
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
#html_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
#html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
#html_sidebars = {}
# Additional templates that should be rendered to pages, maps page names to
# template names.
#html_additional_pages = {}
# If false, no module index is generated.
#html_domain_indices = True
# If false, no index is generated.
#html_use_index = True
# If true, the index is split into individual pages for each letter.
#html_split_index = False
# If true, links to the reST sources are added to the pages.
#html_show_sourcelink = True
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#html_show_sphinx = True
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
#html_show_copyright = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
#html_use_opensearch = ''
# This is the file name suffix for HTML files (e.g. ".xhtml").
#html_file_suffix = None
# Output file base name for HTML help builder.
htmlhelp_basename = 'django-orchestradoc'
# -- Options for LaTeX output --------------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#'preamble': '',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]).
latex_documents = [
('index', 'django-orchestra.tex', u'django-orchestra Documentation',
u'Marc Aymerich', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
#latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
#latex_use_parts = False
# If true, show page references after internal links.
#latex_show_pagerefs = False
# If true, show URL addresses after external links.
#latex_show_urls = False
# Documents to append as an appendix to all manuals.
#latex_appendices = []
# If false, no module index is generated.
#latex_domain_indices = True
# -- Options for manual page output --------------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('index', 'django-orchestra', u'django-orchestra Documentation',
[u'Marc Aymerich'], 1)
]
# If true, show URL addresses after external links.
#man_show_urls = False
# -- Options for Texinfo output ------------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
('index', 'django-orchestra', u'django-orchestra Documentation',
u'Marc Aymerich', 'django-orchestra', 'One line description of project.',
'Miscellaneous'),
]
# Documents to append as an appendix to all manuals.
#texinfo_appendices = []
# If false, no module index is generated.
#texinfo_domain_indices = True
# How to display URL addresses: 'footnote', 'no', or 'inline'.
#texinfo_show_urls = 'footnote'

94
docs/create-services.md Normal file
View File

@ -0,0 +1,94 @@
# Creating New Services
1. Think about if the service can fit into one of the existing service models like: SaaS or WebApps, refere to the related documentation if that is the case.
2. Create a new django app using `startapp` management command. For ilustrational purposes we will create a crontab services that will allow orchestra to manage user-based crontabs.
```bash
python3 manage.py startapp crontabs
```
3. Add the new *crontabs* app to the `INSTALLED_APPS` in your project's `settings.py`
3. Create a `models.py` file with the data your service needs to keep in order to be managed by orchestra
```python
from django.db import models
class CrontabSchedule(models.Model):
account = models.ForeignKey('accounts.Account', verbose_name=_("account"))
minute = models.CharField(_("minute"), max_length=64, default='*')
hour = models.CharField(_("hour"), max_length=64, default='*')
day_of_week = models.CharField(_("day of week"), max_length=64, default='*')
day_of_month = models.CharField(_("day of month"), max_length=64, default='*')
month_of_year = models.CharField(_("month of year"), max_length=64, default='*')
class Meta:
ordering = ('month_of_year', 'day_of_month', 'day_of_week', 'hour', 'minute')
def __str__(self):
rfield = lambda f: f and str(f).replace(' ', '') or '*'
return "{0} {1} {2} {3} {4} (m/h/d/dM/MY)".format(
rfield(self.minute), rfield(self.hour), rfield(self.day_of_week),
rfield(self.day_of_month), rfield(self.month_of_year),
)
class Crontab(models.Model):
account = models.ForeignKey('accounts.Account', verbose_name=_("account"))
schedule = models.ForeignKey(CrontabSchedule, verbose_name=_("schedule"))
description = models.CharField(_("description"), max_length=256, blank=True)
command = models.TextField(_("content"))
def __str__(self):
return (self.description or self.command)[:32]
```
4. Create a `admin.py` to enable the admin interface, refere to [Django Admin documentation](https://docs.djangoproject.com/en/1.9/ref/contrib/admin/) for further customization.
```python
from django.contrib import admin
from .models import CrontabSchedule, Crontab
class CrontabScheduleAdmin(admin.ModelAdmin):
pass
class CrontabAdmin(admin.ModelAdmin):
pass
admin.site.register(CrontabSchedule, CrontabScheduleAdmin)
admin.site.register(Crontab, CrontabAdmin)
```
5. Create a `api.py` to enable the REST API.
6. Create a `backends.py` fiel with the needed backends for service orchestration and monitoring.
```python
import os
import textwrap
from django.utils.translation import gettext_lazy as _
from orchestra.contrib.orchestration import ServiceController, replace
from orchestra.contrib.resources import ServiceMonitor
class UNIXCronBackend(ServiceController):
"""
Basic UNIX cron support.
"""
verbose_name = _("UNIX cron")
model = 'crons.CronTab'
def prepare(self):
super(UNIXCronBackend, self).prepare()
self.accounts = set()
def save(self, crontab):
self.accounts.add(crontab.account)
def delete(self, crontab):
self.accounts.add(crontab.account)
def commit(self):
for account in self.accounts:
crontab = None
self.append("echo '' > %(crontab_path)s" % context)
for crontab in account.crontabs.all():
self.append("
```
7. Configure the routing

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 103 KiB

482
docs/images/services.svg Normal file
View File

@ -0,0 +1,482 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="1052.3622"
height="744.09448"
id="svg2"
version="1.1"
inkscape:version="0.48.3.1 r9886"
sodipodi:docname="services.svg">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.70710678"
inkscape:cx="559.86324"
inkscape:cy="278.12745"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
showguides="true"
inkscape:guide-bbox="true"
inkscape:window-width="1920"
inkscape:window-height="1024"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1" />
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-308.2677)">
<text
xml:space="preserve"
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:FreeMono Bold"
x="132.85733"
y="526.45612"
id="text2985"
sodipodi:linespacing="94.999999%"><tspan
sodipodi:role="line"
id="tspan2987"
x="132.85733"
y="526.45612">Orders</tspan></text>
<text
xml:space="preserve"
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
x="133.94112"
y="853.0473"
id="text2989"
sodipodi:linespacing="94.999999%"><tspan
sodipodi:role="line"
id="tspan2991"
x="133.94112"
y="853.0473"
style="font-size:22px;text-align:center;line-height:94.99999880999999391%;text-anchor:middle">Metric</tspan></text>
<text
xml:space="preserve"
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
x="294.6925"
y="431.67795"
id="text2993"
sodipodi:linespacing="94.999999%"><tspan
sodipodi:role="line"
id="tspan2995"
x="294.6925"
y="431.67795">Periodic</tspan><tspan
sodipodi:role="line"
x="294.6925"
y="453.67804"
id="tspan2997">billing</tspan></text>
<text
xml:space="preserve"
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
x="294.77344"
y="597.10419"
id="text2999"
sodipodi:linespacing="94.999999%"><tspan
sodipodi:role="line"
id="tspan3001"
x="294.77344"
y="597.10419">One-time</tspan><tspan
sodipodi:role="line"
x="294.77344"
y="619.10431"
id="tspan3003">service</tspan></text>
<text
xml:space="preserve"
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
x="488.67383"
y="472.50183"
id="text3005"
sodipodi:linespacing="94.999999%"><tspan
sodipodi:role="line"
id="tspan3007"
x="488.67383"
y="472.50183">Pricing</tspan><tspan
sodipodi:role="line"
x="488.67383"
y="494.50192"
id="tspan3009">period</tspan></text>
<text
xml:space="preserve"
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
x="488.91711"
y="390.854"
id="text3011"
sodipodi:linespacing="94.999999%"><tspan
sodipodi:role="line"
id="tspan3013"
x="488.91711"
y="390.854">No pricing</tspan><tspan
sodipodi:role="line"
x="488.91711"
y="412.8541"
id="tspan3015">period</tspan></text>
<text
xml:space="preserve"
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
x="294.6925"
y="758.26892"
id="text2993-8"
sodipodi:linespacing="94.999999%"><tspan
sodipodi:role="line"
id="tspan2995-2"
x="294.6925"
y="758.26892">Periodic</tspan><tspan
sodipodi:role="line"
x="294.6925"
y="780.26904"
id="tspan2997-4">billing</tspan></text>
<text
xml:space="preserve"
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
x="294.77344"
y="923.69543"
id="text2999-4"
sodipodi:linespacing="94.999999%"><tspan
sodipodi:role="line"
id="tspan3001-2"
x="294.77344"
y="923.69543">One-time</tspan><tspan
sodipodi:role="line"
x="294.77344"
y="945.69556"
id="tspan3003-9">service</tspan></text>
<text
xml:space="preserve"
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
x="488.67383"
y="554.14972"
id="text3005-7"
sodipodi:linespacing="94.999999%"><tspan
sodipodi:role="line"
id="tspan3007-9"
x="488.67383"
y="554.14972">Pricing</tspan><tspan
sodipodi:role="line"
x="488.67383"
y="576.14984"
id="tspan3009-3">period</tspan></text>
<text
xml:space="preserve"
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
x="488.91711"
y="635.79749"
id="text3011-6"
sodipodi:linespacing="94.999999%"><tspan
sodipodi:role="line"
id="tspan3013-2"
x="488.91711"
y="635.79749">No pricing</tspan><tspan
sodipodi:role="line"
x="488.91711"
y="657.79761"
id="tspan3015-0">period</tspan></text>
<text
xml:space="preserve"
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
x="488.67383"
y="799.09296"
id="text3005-6"
sodipodi:linespacing="94.999999%"><tspan
sodipodi:role="line"
id="tspan3007-8"
x="488.67383"
y="799.09296">Pricing</tspan><tspan
sodipodi:role="line"
x="488.67383"
y="821.09308"
id="tspan3009-2">period</tspan></text>
<text
xml:space="preserve"
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
x="488.91711"
y="717.44519"
id="text3011-1"
sodipodi:linespacing="94.999999%"><tspan
sodipodi:role="line"
id="tspan3013-6"
x="488.91711"
y="717.44519">No pricing</tspan><tspan
sodipodi:role="line"
x="488.91711"
y="739.44531"
id="tspan3015-3">period</tspan></text>
<text
xml:space="preserve"
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
x="488.67383"
y="962.38898"
id="text3005-1"
sodipodi:linespacing="94.999999%"><tspan
sodipodi:role="line"
id="tspan3007-3"
x="488.67383"
y="962.38898">Pricing</tspan><tspan
sodipodi:role="line"
x="488.67383"
y="984.3891"
id="tspan3009-7">period</tspan></text>
<text
xml:space="preserve"
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
x="488.91711"
y="880.74097"
id="text3011-64"
sodipodi:linespacing="94.999999%"><tspan
sodipodi:role="line"
id="tspan3013-1"
x="488.91711"
y="880.74097">No pricing</tspan><tspan
sodipodi:role="line"
x="488.91711"
y="902.74109"
id="tspan3015-08">period</tspan></text>
<text
xml:space="preserve"
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans"
x="583.38361"
y="379.86563"
id="text3898"
sodipodi:linespacing="94.999999%"><tspan
sodipodi:role="line"
id="tspan3900"
x="583.38361"
y="379.86563"
style="font-weight:bold;font-size:22px">Mail accounts</tspan><tspan
sodipodi:role="line"
x="583.38361"
y="401.86572"
id="tspan3912">Concurrent (changes)</tspan><tspan
sodipodi:role="line"
x="583.38361"
y="423.86581"
id="tspan3902">Compensate on <tspan
style="font-weight:bold;line-height:94.99999880999999391%;-inkscape-font-specification:FreeMono Bold;font-size:22px"
id="tspan3904">prepay</tspan></tspan></text>
<text
xml:space="preserve"
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans"
x="586.6123"
y="461.51324"
id="text3906"
sodipodi:linespacing="94.999999%"><tspan
sodipodi:role="line"
id="tspan3908"
x="586.6123"
y="461.51324"
style="font-weight:bold;font-size:22px">Domains</tspan><tspan
sodipodi:role="line"
x="586.6123"
y="483.51334"
id="tspan3914">Register or renew events</tspan><tspan
sodipodi:role="line"
x="586.6123"
y="505.51343"
id="tspan3910">Compensate on <tspan
style="font-weight:bold;line-height:94.99999880999999391%;font-size:22px"
id="tspan3993">prepay</tspan></tspan></text>
<text
xml:space="preserve"
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans"
x="590.04663"
y="554.09149"
id="text3916"
sodipodi:linespacing="94.999999%"><tspan
sodipodi:role="line"
id="tspan3918"
x="590.04663"
y="554.09149"
style="font-weight:bold;font-size:22px">Plans</tspan><tspan
sodipodi:role="line"
x="590.04663"
y="576.09161"
id="tspan3920">Always one order</tspan></text>
<text
xml:space="preserve"
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans"
x="590.58252"
y="635.97125"
id="text3922"
sodipodi:linespacing="94.999999%"><tspan
sodipodi:role="line"
x="590.58252"
y="635.97125"
id="tspan3926"
style="font-weight:bold;font-size:22px">CMS installation</tspan><tspan
sodipodi:role="line"
x="590.58252"
y="657.97137"
id="tspan3930">Register or renew events</tspan><tspan
sodipodi:role="line"
x="590.58252"
y="679.97144"
id="tspan3932" /></text>
<text
xml:space="preserve"
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans"
x="591.32349"
y="777.26685"
id="text3934"
sodipodi:linespacing="94.999999%"><tspan
sodipodi:role="line"
id="tspan3936"
x="591.32349"
y="777.26685"
style="font-weight:bold;font-size:22px">Traffic consumption</tspan><tspan
sodipodi:role="line"
x="591.32349"
y="799.26697"
id="tspan3938">Metric period lookup</tspan><tspan
sodipodi:role="line"
x="591.32349"
y="821.26703"
id="tspan3940">Prepay and != billing_period</tspan><tspan
sodipodi:role="line"
x="591.32349"
y="843.26715"
id="tspan3995"
style="font-style:italic;-inkscape-font-specification:FreeMono Italic;font-size:22px"> NotImplemented</tspan></text>
<text
xml:space="preserve"
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans"
x="591.32349"
y="717.61884"
id="text3942"
sodipodi:linespacing="94.999999%"><tspan
sodipodi:role="line"
id="tspan3944"
x="591.32349"
y="717.61884"
style="font-weight:bold;font-size:22px">Mailbox size</tspan><tspan
sodipodi:role="line"
x="591.32349"
y="739.61896"
id="tspan3946">Concurrent (changes)</tspan></text>
<text
xml:space="preserve"
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans"
x="590.1192"
y="882.65179"
id="text3942-8"
sodipodi:linespacing="94.999999%"><tspan
sodipodi:role="line"
id="tspan3944-2"
x="590.1192"
y="882.65179"
style="font-weight:bold;font-size:22px">Jobs</tspan><tspan
sodipodi:role="line"
x="590.1192"
y="904.65192"
id="tspan3946-6">Last known metric</tspan></text>
<text
xml:space="preserve"
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans"
x="591.06866"
y="973.33081"
id="text3972"
sodipodi:linespacing="94.999999%"><tspan
sodipodi:role="line"
id="tspan3974"
x="591.06866"
y="973.33081"
style="font-style:italic;-inkscape-font-specification:FreeMono Italic;font-size:22px">NotImplement</tspan></text>
<path
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 228.73934,436.50836 -23.61913,0 0,164.75267 23.53877,0"
id="path4013"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 205.55487,521.14184 -23.98356,0"
id="path4019"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 228.73934,764.42561 -23.61913,0 0,164.7526 23.53877,0"
id="path4013-2"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 205.55487,849.05914 -23.98356,0"
id="path4019-1"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 407.81779,398.68928 -23.61908,0 0,80.25672 23.5387,0"
id="path4013-6"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 384.6333,438.12728 -23.98362,0"
id="path4019-18"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 407.81779,561.72165 -23.61908,0 0,80.25664 23.5387,0"
id="path4013-6-63"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 384.6333,601.15965 -23.98362,0"
id="path4019-18-4"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 407.81779,726.60658 -23.61908,0 0,80.25672 23.5387,0"
id="path4013-6-8"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 384.6333,766.04458 -23.98362,0"
id="path4019-18-3"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 407.81779,889.63886 -23.61908,0 0,80.25667 23.5387,0"
id="path4013-6-0"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 384.6333,929.07689 -23.98362,0"
id="path4019-18-8"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 21 KiB

21
docs/index.rst Normal file
View File

@ -0,0 +1,21 @@
.. django-orchestra documentation master file, created by
sphinx-quickstart on Wed Aug 8 11:07:40 2012.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Welcome to django-orchestra's documentation!
============================================
Contents:
.. toctree::
:maxdepth: 2
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

190
docs/make.bat Normal file
View File

@ -0,0 +1,190 @@
@ECHO OFF
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set BUILDDIR=_build
set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% .
set I18NSPHINXOPTS=%SPHINXOPTS% .
if NOT "%PAPER%" == "" (
set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
)
if "%1" == "" goto help
if "%1" == "help" (
:help
echo.Please use `make ^<target^>` where ^<target^> is one of
echo. html to make standalone HTML files
echo. dirhtml to make HTML files named index.html in directories
echo. singlehtml to make a single large HTML file
echo. pickle to make pickle files
echo. json to make JSON files
echo. htmlhelp to make HTML files and a HTML help project
echo. qthelp to make HTML files and a qthelp project
echo. devhelp to make HTML files and a Devhelp project
echo. epub to make an epub
echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
echo. text to make text files
echo. man to make manual pages
echo. texinfo to make Texinfo files
echo. gettext to make PO message catalogs
echo. changes to make an overview over all changed/added/deprecated items
echo. linkcheck to check all external links for integrity
echo. doctest to run all doctests embedded in the documentation if enabled
goto end
)
if "%1" == "clean" (
for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
del /q /s %BUILDDIR%\*
goto end
)
if "%1" == "html" (
%SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The HTML pages are in %BUILDDIR%/html.
goto end
)
if "%1" == "dirhtml" (
%SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
goto end
)
if "%1" == "singlehtml" (
%SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
goto end
)
if "%1" == "pickle" (
%SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can process the pickle files.
goto end
)
if "%1" == "json" (
%SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can process the JSON files.
goto end
)
if "%1" == "htmlhelp" (
%SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can run HTML Help Workshop with the ^
.hhp project file in %BUILDDIR%/htmlhelp.
goto end
)
if "%1" == "qthelp" (
%SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can run "qcollectiongenerator" with the ^
.qhcp project file in %BUILDDIR%/qthelp, like this:
echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-orchestra.qhcp
echo.To view the help file:
echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-orchestra.ghc
goto end
)
if "%1" == "devhelp" (
%SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
if errorlevel 1 exit /b 1
echo.
echo.Build finished.
goto end
)
if "%1" == "epub" (
%SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The epub file is in %BUILDDIR%/epub.
goto end
)
if "%1" == "latex" (
%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
if errorlevel 1 exit /b 1
echo.
echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
goto end
)
if "%1" == "text" (
%SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The text files are in %BUILDDIR%/text.
goto end
)
if "%1" == "man" (
%SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The manual pages are in %BUILDDIR%/man.
goto end
)
if "%1" == "texinfo" (
%SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
goto end
)
if "%1" == "gettext" (
%SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
goto end
)
if "%1" == "changes" (
%SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
if errorlevel 1 exit /b 1
echo.
echo.The overview file is in %BUILDDIR%/changes.
goto end
)
if "%1" == "linkcheck" (
%SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
if errorlevel 1 exit /b 1
echo.
echo.Link check complete; look for any errors in the above output ^
or in %BUILDDIR%/linkcheck/output.txt.
goto end
)
if "%1" == "doctest" (
%SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
if errorlevel 1 exit /b 1
echo.
echo.Testing of doctests in the sources finished, look at the ^
results in %BUILDDIR%/doctest/output.txt.
goto end
)
:end

132
install_manually.md Normal file
View File

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

25
orchestra/__init__.py Normal file
View File

@ -0,0 +1,25 @@
default_app_config = 'orchestra.apps.OrchestraConfig'
VERSION = (0, 0, 1, 'alpha', 1)
def get_version():
"Returns a PEP 386-compliant version number from VERSION."
assert len(VERSION) == 5
assert VERSION[3] in ('alpha', 'beta', 'rc', 'final')
# Now build the two parts of the version number:
# main = X.Y[.Z]
# sub = .devN - for pre-alpha releases
# | {a|b|c}N - for alpha, beta and rc releases
parts = 2 if VERSION[2] == 0 else 3
main = '.'.join(str(x) for x in VERSION[:parts])
sub = ''
if VERSION[3] != 'final':
mapping = {'alpha': 'a', 'beta': 'b', 'rc': 'c'}
sub = mapping[VERSION[3]] + str(VERSION[4])
return str(main + sub)

121
orchestra/admin/__init__.py Normal file
View File

@ -0,0 +1,121 @@
import itertools
from collections import OrderedDict
from functools import update_wrapper
from django.contrib import admin
from django.urls import reverse
from django.shortcuts import render, redirect
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from .dashboard import *
from .options import *
from ..core import accounts, services
# monkey-patch admin.site in order to porvide some extra admin urls
urls = []
def register_url(pattern, view, name=""):
global urls
urls.append((pattern, view, name))
admin.site.register_url = register_url
site_get_urls = admin.site.get_urls
def get_urls():
def wrap(view, cacheable=False):
def wrapper(*args, **kwargs):
return admin.site.admin_view(view, cacheable)(*args, **kwargs)
wrapper.admin_site = admin.site
return update_wrapper(wrapper, view)
global urls
extra_patterns = []
for pattern, view, name in urls:
extra_patterns.append(
url(pattern, wrap(view), name=name)
)
return site_get_urls() + extra_patterns
admin.site.get_urls = get_urls
def get_model(model_name, model_name_map):
try:
return model_name_map[model_name.lower()]
except KeyError:
return
def search(request):
query = request.GET.get('q', '')
search_term = query
models = set()
selected_models = set()
model_name_map = {}
for service in itertools.chain(services, accounts):
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
search_term = search_term.replace('!', '')
try:
account = Account.objects.get(username=search_term)
except Account.DoesNotExist:
pass
else:
account_url = reverse('admin:accounts_account_change', args=(account.pk,))
return redirect(account_url)
# Search for specific model
elif ':' in search_term:
new_search_term = []
for part in search_term.split():
if ':' in part:
model_name, term = part.split(':')
model = get_model(model_name, model_name_map)
# Retry with singular version
if model is None and model_name.endswith('s'):
model = get_model(model_name[:-1], model_name_map)
if model is None:
new_search_term.append(':'.join((model_name, term)))
else:
selected_models.add(model)
new_search_term.append(term)
else:
new_search_term.append(part)
search_term = ' '.join(new_search_term)
if selected_models:
models = selected_models
results = OrderedDict()
models = sorted(models, key=lambda m: m._meta.verbose_name_plural.lower())
total = 0
for model in models:
try:
modeladmin = admin.site._registry[model]
except KeyError:
pass
else:
qs = modeladmin.get_queryset(request)
qs, search_use_distinct = modeladmin.get_search_results(request, qs, search_term)
if search_use_distinct:
qs = qs.distinct()
num = len(qs)
if num:
total += num
results[model._meta] = qs
title = _("{total} search results for '<tt>{query}</tt>'").format(total=total, query=query)
context = {
'title': mark_safe(title),
'total': total,
'columns': min(int(total/17), 3),
'query': query,
'search_term': search_term,
'results': results,
'search_autofocus': True,
}
return render(request, 'admin/orchestra/search.html', context)
admin.site.register_url(r'^search/$', search, 'orchestra_search_view')

145
orchestra/admin/actions.py Normal file
View File

@ -0,0 +1,145 @@
from functools import partial
from django.contrib import admin
from django.core.mail import send_mass_mail
from django.shortcuts import render
from django.utils.translation import ngettext, gettext_lazy as _
from .. import settings
from .decorators import action_with_confirmation
from .forms import SendEmailForm
class SendEmail(object):
""" Form wizard for billing orders admin action """
short_description = _("Send email")
form = SendEmailForm
template = 'admin/orchestra/generic_confirmation.html'
default_from = settings.ORCHESTRA_DEFAULT_SUPPORT_FROM_EMAIL
__name__ = 'semd_email'
def __call__(self, modeladmin, request, queryset):
""" make this monster behave like a function """
self.modeladmin = modeladmin
self.queryset = queryset
self.opts = modeladmin.model._meta
app_label = self.opts.app_label
self.context = {
'action_name': _("Send email"),
'action_value': self.__name__,
'opts': self.opts,
'app_label': app_label,
'queryset': queryset,
'action_checkbox_name': admin.helpers.ACTION_CHECKBOX_NAME,
}
return self.write_email(request)
def write_email(self, request):
if not request.user.is_superuser:
raise PermissionDenied
initial={
'email_from': self.default_from,
'to': ' '.join(self.get_email_addresses())
}
form = self.form(initial=initial)
if request.POST.get('post'):
form = self.form(request.POST, initial=initial)
if form.is_valid():
options = {
'email_from': form.cleaned_data['email_from'],
'extra_to': form.cleaned_data['extra_to'],
'subject': form.cleaned_data['subject'],
'message': form.cleaned_data['message'],
}
return self.confirm_email(request, **options)
self.context.update({
'title': _("Send e-mail to %s") % self.opts.verbose_name_plural,
'content_title': "",
'form': form,
'submit_value': _("Continue"),
})
# Display confirmation page
return render(request, self.template, self.context)
def get_email_addresses(self):
return self.queryset.values_list('email', flat=True)
def confirm_email(self, request, **options):
email_from = options['email_from']
extra_to = options['extra_to']
subject = options['subject']
message = options['message']
# The user has already confirmed
if request.POST.get('post') == 'email_confirmation':
emails = []
num = 0
for email in self.get_email_addresses():
emails.append((subject, message, email_from, [email]))
num += 1
if extra_to:
emails.append((subject, message, email_from, extra_to))
send_mass_mail(emails, fail_silently=False)
msg = ngettext(
_("Message has been sent to one %s.") % self.opts.verbose_name_plural,
_("Message has been sent to %i %s.") % (num, self.opts.verbose_name_plural),
num
)
self.modeladmin.message_user(request, msg)
return None
form = self.form(initial={
'email_from': email_from,
'extra_to': ', '.join(extra_to),
'subject': subject,
'message': message
})
self.context.update({
'title': _("Are you sure?"),
'content_message': _(
"Are you sure you want to send the following message to the following %s?"
) % self.opts.verbose_name_plural,
'display_objects': ["%s (%s)" % (contact, email) for contact, email in zip(self.queryset, self.get_email_addresses())],
'form': form,
'subject': subject,
'message': message,
'post_value': 'email_confirmation',
})
# Display the confirmation page
return render(request, self.template, self.context)
def base_disable(modeladmin, request, queryset, disable=True):
num = 0
action_name = _("disabled") if disable else _("enabled")
for obj in queryset:
obj.disable() if disable else obj.enable()
modeladmin.log_change(request, obj, action_name.capitalize())
num += 1
opts = modeladmin.model._meta
context = {
'action_name': action_name,
'verbose_name': opts.verbose_name,
'verbose_name_plural': opts.verbose_name_plural,
'num': num
}
msg = ngettext(
_("Selected %(verbose_name)s and related services has been %(action_name)s.") % context,
_("%(num)s selected %(verbose_name_plural)s and related services have been %(action_name)s.") % context,
num)
modeladmin.message_user(request, msg)
@action_with_confirmation()
def disable(modeladmin, request, queryset):
return base_disable(modeladmin, request, queryset)
disable.url_name = 'disable'
disable.short_description = _("Disable")
@action_with_confirmation()
def enable(modeladmin, request, queryset):
return base_disable(modeladmin, request, queryset, disable=False)
enable.url_name = 'enable'
enable.short_description = _("Enable")

View File

@ -0,0 +1,74 @@
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from fluent_dashboard import dashboard, appsettings
from fluent_dashboard.modules import CmsAppIconList
from orchestra.core import services, accounts, administration
class AppDefaultIconList(CmsAppIconList):
""" Provides support for custom default icons """
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)
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())
self.children.extend(self.get_application_modules())
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')
url = reverse('admin:' + view_name)
add_url = '/'.join(url.split('/')[:-2])
module.children.append({
'models': [
{
'add_url': add_url,
'app_name': app_name,
'change_url': url,
'name': name,
'title': options.get('verbose_name_plural')
}
],
'name': app_name,
'title': options.get('verbose_name_plural'),
'url': add_url,
})
def get_application_modules(self):
modules = []
# Honor settings override, hacky. I Know
if appsettings.FLUENT_DASHBOARD_APP_GROUPS[0][0] != _('CMS'):
modules = super(OrchestraIndexDashboard, self).get_application_modules()
for register in (accounts, services, administration):
title = register.verbose_name
models = []
icons = {}
views = []
for model, options in register.get().items():
if isinstance(model, str):
views.append((model, options))
elif options.get('dashboard', True):
opts = model._meta
label = "%s.%s" % (model.__module__, opts.object_name)
models.append(label)
label = '.'.join((opts.app_label, opts.model_name))
icons[label] = options.get('icon')
module = AppDefaultIconList(title, models=models, icons=icons, collapsible=True)
for view_name, options in views:
self.process_registered_view(module, view_name, options)
modules.append(module)
return modules

View File

@ -0,0 +1,101 @@
from functools import wraps, partial, update_wrapper
from django.contrib import messages
from django.contrib.admin import helpers
from django.core.exceptions import ValidationError
from django.template.response import TemplateResponse
from django.utils.encoding import force_str
from django.utils.html import format_html
from django.utils.text import capfirst
from django.utils.translation import gettext_lazy as _
def admin_field(method):
""" Wraps a function to be used as a ModelAdmin method field """
def admin_field_wrapper(*args, **kwargs):
""" utility function for creating admin links """
kwargs['field'] = args[0] if args else '__str__'
kwargs['order'] = kwargs.get('order', kwargs['field'])
kwargs['popup'] = kwargs.get('popup', False)
# TODO get field verbose name
kwargs['short_description'] = kwargs.get('short_description',
kwargs['field'].split('__')[-1].replace('_', ' ').capitalize())
admin_method = partial(method, **kwargs)
admin_method = update_wrapper(admin_method, method)
admin_method.short_description = kwargs['short_description']
admin_method.allow_tags = True
admin_method.admin_order_field = kwargs['order']
return admin_method
return admin_field_wrapper
def format_display_objects(modeladmin, request, queryset):
from .utils import change_url
opts = modeladmin.model._meta
objects = []
for obj in queryset:
objects.append(format_html('{0}: <a href="{1}">{2}</a>',
capfirst(opts.verbose_name), change_url(obj), obj)
)
return objects
def action_with_confirmation(action_name=None, extra_context=None, validator=None,
template='admin/orchestra/generic_confirmation.html'):
"""
Generic pattern for actions that needs confirmation step
If custom template is provided the form must contain:
<input type="hidden" name="post" value="generic_confirmation" />
"""
def decorator(func, extra_context=extra_context, template=template, action_name=action_name, validatior=validator):
@wraps(func)
def inner(modeladmin, request, queryset, action_name=action_name, extra_context=extra_context, validator=validator):
if validator is not None:
try:
validator(queryset)
except ValidationError as e:
messages.error(request, '<br>'.join(e))
return
# The user has already confirmed the action.
if request.POST.get('post') == 'generic_confirmation':
stay = func(modeladmin, request, queryset)
if not stay:
return
opts = modeladmin.model._meta
app_label = opts.app_label
action_value = func.__name__
if len(queryset) == 1:
objects_name = force_str(opts.verbose_name)
obj = queryset.get()
else:
objects_name = force_str(opts.verbose_name_plural)
obj = None
if not action_name:
action_name = func.__name__
context = {
'title': _("Are you sure?"),
'content_message': _("Are you sure you want to {action} the selected {item}?").format(
action=action_name, item=objects_name),
'action_name': action_name.capitalize(),
'action_value': action_value,
'queryset': queryset,
'opts': opts,
'obj': obj,
'app_label': app_label,
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
}
if callable(extra_context):
extra_context = extra_context(modeladmin, request, queryset)
context.update(extra_context or {})
if 'display_objects' not in context:
# Compute it only when necessary
context['display_objects'] = format_display_objects(modeladmin, request, queryset)
# Display the confirmation page
return TemplateResponse(request, template, context)
return inner
return decorator

228
orchestra/admin/forms.py Normal file
View File

@ -0,0 +1,228 @@
import textwrap
from functools import partial
from django import forms
from django.contrib.admin import helpers
from django.core import validators
from django.forms.models import modelformset_factory, BaseModelFormSet
from django.template import Template, Context
from django.utils.translation import gettext_lazy as _
from orchestra.forms.widgets import SpanWidget
from ..core.validators import validate_password
class AdminFormMixin(object):
""" Provides a method for rendering a form just like in Django Admin """
def as_admin(self):
prepopulated_fields = {}
fieldsets = [
(None, {
'fields': list(self.fields.keys())
}),
]
adminform = helpers.AdminForm(self, fieldsets, prepopulated_fields)
template = Template(
'{% for fieldset in adminform %}'
' {% include "admin/includes/fieldset.html" %}'
'{% endfor %}'
)
context = {
'adminform': adminform
}
return template.render(Context(context))
class AdminFormSet(BaseModelFormSet):
def as_admin(self):
template = Template(textwrap.dedent("""\
<div class="inline-group">
<div class="tabular inline-related last-related">
{{ formset.management_form }}
<fieldset class="module">
{{ formset.non_form_errors.as_ul }}
<table id="formset" class="form">
{% for form in formset.forms %}
{% if forloop.first %}
<thead><tr>
{% for field in form.visible_fields %}
<th>{{ field.label|capfirst }}</th>
{% endfor %}
</tr></thead>
{% endif %}
<tr class="{% cycle 'row1' 'row2' %}">
{% for field in form.visible_fields %}
<td>
{# Include the hidden fields in the form #}
{% if forloop.first %}
{% for hidden in form.hidden_fields %}
{{ hidden }}
{% endfor %}
{% endif %}
{{ field.errors.as_ul }}
{{ field }}
</td>
{% endfor %}
</tr>
{% endfor %}
</table>
</fieldset>
</div>
</div>""")
)
context = {
'formset': self
}
return template.render(Context(context))
class AdminPasswordChangeForm(forms.Form):
"""
A form used to change the password of a user in the admin interface.
"""
error_messages = {
'password_mismatch': _("The two password fields didn't match."),
'password_missing': _("No password has been provided."),
'bad_hash': _("Invalid password format or unknown hashing algorithm."),
}
required_css_class = 'required'
password = forms.CharField(label=_("Password"), required=False,
widget=forms.TextInput(attrs={'size':'120'}))
password1 = forms.CharField(label=_("Password"), widget=forms.PasswordInput,
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)
self.user = user
super().__init__(*args, **kwargs)
self.password_provided = False
for ix, rel in enumerate(self.related):
self.fields['password_%i' % ix] = forms.CharField(label=_("Password"), required=False,
widget=forms.TextInput(attrs={'size':'120'}))
setattr(self, 'clean_password_%i' % ix, partial(self.clean_password, ix=ix))
self.fields['password1_%i' % ix] = forms.CharField(label=_("Password"),
widget=forms.PasswordInput, required=False)
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
password1 = self.cleaned_data.get('password1%s' % ix)
password2 = self.cleaned_data.get('password2%s' % ix)
if password1 and password2:
self.password_provided = True
if password1 != password2:
raise forms.ValidationError(
self.error_messages['password_mismatch'],
code='password_mismatch',
)
elif password1 or password2:
self.password_provided = True
raise forms.ValidationError(
self.error_messages['password_mismatch'],
code='password_mismatch',
)
return password2
def clean_password(self, ix=''):
if ix != '':
ix = '_%i' % ix
password = self.cleaned_data.get('password%s' % ix)
if password:
# lazy loading because of passlib
from django.contrib.auth.hashers import identify_hasher
self.password_provided = True
try:
identify_hasher(password)
except ValueError:
raise forms.ValidationError(
self.error_messages['bad_hash'],
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.
"""
field_name = 'password' if self.raw else 'password1'
password = self.cleaned_data[field_name]
if password:
if self.raw:
self.user.password = password
else:
self.user.set_password(password)
if commit:
try:
self.user.save(update_fields=['password'])
except ValueError:
# password is not a field but an attribute
self.user.save() # Trigger the backend
for ix, rel in enumerate(self.related):
password = self.cleaned_data['%s_%s' % (field_name, ix)]
if password:
if self.raw:
rel.password = password
else:
set_password = getattr(rel, 'set_password')
set_password(password)
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():
if name not in data:
return []
return ['password']
changed_data = property(_get_changed_data)
class SendEmailForm(forms.Form):
email_from = forms.EmailField(label=_("From"),
widget=forms.TextInput(attrs={'size': '118'}))
to = forms.CharField(label="To", required=False)
extra_to = forms.CharField(label="To (extra)", required=False,
widget=forms.TextInput(attrs={'size': '118'}))
subject = forms.CharField(label=_("Subject"),
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')
if 'to' in initial:
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(','):
email = email.strip()
if email:
try:
validators.validate_email(email)
except validators.ValidationError:
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)

20
orchestra/admin/html.py Normal file
View File

@ -0,0 +1,20 @@
from django.utils.safestring import mark_safe
MONOSPACE_FONTS = ('Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,'
'Bitstream Vera Sans Mono,Courier New,monospace')
def monospace_format(text):
style="font-family:%s;padding-left:110px;white-space:pre-wrap;" % MONOSPACE_FONTS
return mark_safe('<pre style="%s">%s</pre>' % (style, text))
def code_format(text, language='bash'):
from pygments import highlight
from pygments.lexers import get_lexer_by_name
from pygments.formatters import HtmlFormatter
lexer = get_lexer_by_name(language, stripall=True)
formatter = HtmlFormatter(linenos=True)
code = highlight(text, lexer, formatter)
return mark_safe('<div style="padding-left:110px;">%s</div>' % code)

100
orchestra/admin/menu.py Normal file
View File

@ -0,0 +1,100 @@
from copy import deepcopy
from admin_tools.menu import items, Menu
from django.urls import reverse
from django.utils.text import capfirst
from django.utils.translation import gettext_lazy as _
from orchestra.core import services, accounts, administration
def api_link(context):
""" Dynamically generates API related URL """
if 'opts' in context:
opts = context['opts']
elif 'cl' in context:
opts = context['cl'].opts
else:
return reverse('api-root')
if 'object_id' in context:
object_id = context['object_id']
try:
return reverse('%s-detail' % opts.model_name, args=[object_id])
except:
return reverse('api-root')
try:
return reverse('%s-list' % opts.model_name)
except:
return reverse('api-root')
def process_registry(register):
def get_item(model, options, name=None):
if name is None:
name = capfirst(options.get('verbose_name_plural'))
if isinstance(model, str):
url = reverse('admin:'+model)
else:
opts = model._meta
url = reverse('admin:{}_{}_changelist'.format(
opts.app_label, opts.model_name)
)
item = items.MenuItem(name, url)
item.options = options
return item
childrens = {}
for model, options in register.get().items():
if options.get('menu', True):
parent = options.get('parent')
if parent:
name = capfirst(model._meta.app_label)
parent_item = childrens.get(parent)
if parent_item:
if not parent_item.children:
parent_item.children.append(deepcopy(parent_item))
parent_item.title = name
else:
parent_item = get_item(parent, register[parent], name=name)
parent_item.children = []
parent_item.children.append(get_item(model, options))
childrens[parent] = parent_item
elif model not in childrens:
childrens[model] = get_item(model, options)
else:
childrens[model].children.insert(0, get_item(model, options))
return sorted(childrens.values(), key=lambda i: i.title)
class OrchestraMenu(Menu):
template = 'admin/orchestra/menu.html'
def init_with_context(self, context):
self.children = [
# items.MenuItem(
# mark_safe('{site_name} <span style="{version_style}">v{version}</span>'.format(
# site_name=force_str(settings.SITE_VERBOSE_NAME),
# version_style="text-transform:none; float:none; font-size:smaller; background:none;",
# version=get_version())),
# reverse('admin:index')
# ),
# items.MenuItem(
# _('Dashboard'),
# reverse('admin:index')
# ),
# items.Bookmarks(),
items.MenuItem(
_("Services"),
children=process_registry(services)
),
items.MenuItem(
_("Accounts"),
reverse('admin:accounts_account_changelist'),
children=process_registry(accounts)
),
items.MenuItem(
_("Administration"),
children=process_registry(administration)
),
items.MenuItem("API", api_link(context)),
]

339
orchestra/admin/options.py Normal file
View File

@ -0,0 +1,339 @@
from urllib import parse
from django import forms
from django.urls import re_path as url
from django.contrib import admin, messages
from django.contrib.admin.options import IS_POPUP_VAR
from django.contrib.admin.utils import unquote
from django.contrib.auth import update_session_auth_hash
from django.core.exceptions import PermissionDenied
from django.http import HttpResponseRedirect, Http404, HttpResponse
from django.forms.models import BaseInlineFormSet
from django.shortcuts import get_object_or_404
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.utils.encoding import force_str
from django.utils.html import escape
from django.utils.translation import gettext_lazy as _
from django.views.decorators.debug import sensitive_post_parameters
from orchestra.models.utils import has_db_field
from ..utils.python import random_ascii, pairwise
from .forms import AdminPasswordChangeForm
#, AdminRawPasswordChangeForm
#from django.contrib.auth.forms import AdminPasswordChangeForm
from .utils import action_to_view
sensitive_post_parameters_m = method_decorator(sensitive_post_parameters())
class ChangeListDefaultFilter(object):
"""
Enables support for default filtering on admin change list pages
Your model admin class should define an default_changelist_filters attribute
default_changelist_filters = (('my_nodes', 'True'),)
"""
default_changelist_filters = ()
def changelist_view(self, request, extra_context=None):
# defaults = []
# for key, value in self.default_changelist_filters:
# set_url_query(request, key, value)
# defaults.append(key)
# # hack response cl context in order to hook default filter awaearness
# # into search_form.html template
# response = super(ChangeListDefaultFilter, self).changelist_view(request, extra_context)
# if hasattr(response, 'context_data') and 'cl' in response.context_data:
# response.context_data['cl'].default_changelist_filters = defaults
# return response
querystring = request.META['QUERY_STRING']
querydict = parse.parse_qs(querystring)
redirect = False
for field, value in self.default_changelist_filters:
if field not in querydict:
redirect = True
querydict[field] = value
if redirect:
querystring = parse.urlencode(querydict, doseq=True)
return HttpResponseRedirect(request.path + '?%s' % querystring)
return super(ChangeListDefaultFilter, self).changelist_view(request, extra_context=extra_context)
class AtLeastOneRequiredInlineFormSet(BaseInlineFormSet):
def clean(self):
"""Check that at least one service has been entered."""
super(AtLeastOneRequiredInlineFormSet, self).clean()
if any(self.errors):
return
if not any(cleaned_data and not cleaned_data.get('DELETE', False)
for cleaned_data in self.cleaned_data):
raise forms.ValidationError('At least one item required.')
class EnhaceSearchMixin(object):
def lookup_allowed(self, lookup, value):
""" allows any lookup """
if 'password' in lookup:
return False
return True
def get_search_results(self, request, queryset, search_term):
""" allows to specify field <field_name>:<search_term> """
search_fields = self.get_search_fields(request)
if '=' in search_term:
fields = {field.split('__')[0]: field for field in search_fields}
new_search_term = []
for part in search_term.split():
field = None
if '=' in part:
field, term = part.split('=')
kwarg = '%s__icontains'
c_term = term
if term.startswith(('"', "'")) and term.endswith(('"', "'")):
c_term = term[1:-1]
kwarg = '%s__iexact'
if field in fields:
queryset = queryset.filter(**{kwarg % fields[field]: c_term})
else:
new_search_term.append('='.join((field, term)))
else:
new_search_term.append(part)
search_term = ' '.join(new_search_term)
return super(EnhaceSearchMixin, self).get_search_results(request, queryset, search_term)
class ChangeViewActionsMixin(object):
""" Makes actions visible on the admin change view page. """
change_view_actions = ()
change_form_template = 'orchestra/admin/change_form.html'
def get_urls(self):
"""Returns the additional urls for the change view links"""
urls = super(ChangeViewActionsMixin, self).get_urls()
admin_site = self.admin_site
opts = self.model._meta
new_urls = []
for action in self.get_change_view_actions():
new_urls.append(
url('^(\d+)/%s/$' % action.url_name,
admin_site.admin_view(action),
name='%s_%s_%s' % (opts.app_label, opts.model_name, action.url_name)
)
)
return new_urls + urls
def get_change_view_actions(self, obj=None):
""" allow customization on modelamdin """
views = []
for action in self.change_view_actions:
if isinstance(action, str):
action = getattr(self, action)
view = action_to_view(action, self)
view.url_name = getattr(action, 'url_name', action.__name__)
tool_description = getattr(action, 'tool_description', '')
if not tool_description:
tool_description = getattr(action, 'short_description',
view.url_name.capitalize().replace('_', ' '))
if hasattr(tool_description, '__call__'):
tool_description = tool_description(obj)
view.tool_description = tool_description
view.css_class = getattr(action, 'css_class', 'historylink')
view.help_text = getattr(action, 'help_text', '')
view.hidden = getattr(action, 'hidden', False)
views.append(view)
return views
def change_view(self, request, object_id, **kwargs):
if kwargs.get('extra_context', None) is None:
kwargs['extra_context'] = {}
obj = self.get_object(request, unquote(object_id))
kwargs['extra_context']['object_tools_items'] = [
action.__dict__ for action in self.get_change_view_actions(obj) if not action.hidden
]
return super().change_view(request, object_id, **kwargs)
class ChangeAddFieldsMixin(object):
""" Enables to specify different set of fields for change and add views """
add_fields = ()
add_fieldsets = ()
add_form = None
add_prepopulated_fields = {}
change_readonly_fields = ()
change_form = None
add_inlines = None
def get_prepopulated_fields(self, request, obj=None):
if not obj:
return super(ChangeAddFieldsMixin, self).get_prepopulated_fields(request, obj)
return {}
def get_change_readonly_fields(self, request, obj=None):
return self.change_readonly_fields
def get_readonly_fields(self, request, obj=None):
fields = super(ChangeAddFieldsMixin, self).get_readonly_fields(request, obj)
if obj:
return fields + self.get_change_readonly_fields(request, obj)
return fields
def get_fieldsets(self, request, obj=None):
if not obj:
if self.add_fieldsets:
return self.add_fieldsets
elif self.add_fields:
return [(None, {'fields': self.add_fields})]
return super(ChangeAddFieldsMixin, self).get_fieldsets(request, obj)
def get_inline_instances(self, request, obj=None):
""" add_inlines and inline.parent_object """
if obj:
self.inlines = type(self).inlines
else:
self.inlines = self.inlines if self.add_inlines is None else self.add_inlines
inlines = super(ChangeAddFieldsMixin, self).get_inline_instances(request, obj)
for inline in inlines:
inline.parent_object = obj
return inlines
def get_form(self, request, obj=None, **kwargs):
""" Use special form during user creation """
defaults = {}
if obj is None:
if self.add_form:
defaults['form'] = self.add_form
else:
if self.change_form:
defaults['form'] = self.change_form
defaults.update(kwargs)
return super(ChangeAddFieldsMixin, self).get_form(request, obj, **defaults)
class ExtendedModelAdmin(ChangeViewActionsMixin,
ChangeAddFieldsMixin,
ChangeListDefaultFilter,
EnhaceSearchMixin,
admin.ModelAdmin):
list_prefetch_related = None
def get_queryset(self, request):
qs = super(ExtendedModelAdmin, self).get_queryset(request)
if self.list_prefetch_related:
qs = qs.prefetch_related(*self.list_prefetch_related)
return qs
def get_object(self, request, object_id, from_field=None):
obj = super(ExtendedModelAdmin, self).get_object(request, object_id, from_field)
if obj is None:
opts = self.model._meta
raise Http404(_('%(name)s object with primary key %(key)r does not exist.') % {
'name': force_str(opts.verbose_name), 'key': escape(object_id)})
return obj
class ChangePasswordAdminMixin(object):
change_password_form = AdminPasswordChangeForm
change_user_password_template = 'admin/orchestra/change_password.html'
def get_urls(self):
opts = self.model._meta
info = opts.app_label, opts.model_name
return [
url(r'^(\d+)/password/$',
self.admin_site.admin_view(self.change_password),
name='%s_%s_change_password' % info),
url(r'^(\d+)/hash/$',
self.admin_site.admin_view(self.show_hash),
name='%s_%s_show_hash' % info)
] + super().get_urls()
def get_change_password_username(self, obj):
return str(obj)
@sensitive_post_parameters_m
def change_password(self, request, id, form_url=''):
if not self.has_change_permission(request):
raise PermissionDenied
# TODO use this insetad of self.get_object(), in other places
obj = get_object_or_404(self.get_queryset(request), pk=id)
raw = request.GET.get('raw', '0') == '1'
can_raw = has_db_field(obj, 'password')
if raw and not can_raw:
raise TypeError("%s has no password db field for raw password edditing." % obj)
related = []
for obj_name_attr in ('username', 'name', 'hostname'):
try:
obj_name = getattr(obj, obj_name_attr)
except AttributeError:
pass
else:
break
if hasattr(obj, 'account'):
account = obj.account
if obj.account.username == obj_name:
related.append(obj.account)
else:
account = obj
if account.username == obj_name:
for rel in account.get_related_passwords(db_field=raw):
if not isinstance(obj, type(rel)):
related.append(rel)
if request.method == 'POST':
form = self.change_password_form(obj, request.POST, related=related, raw=raw)
if form.is_valid():
form.save()
self.log_change(request, obj, _("Password changed."))
msg = _('Password changed successfully.')
messages.success(request, msg)
update_session_auth_hash(request, form.user) # This is safe
return HttpResponseRedirect('..')
else:
form = self.change_password_form(obj, related=related, raw=raw)
fieldsets = [
(obj._meta.verbose_name.capitalize(), {
'classes': ('wide',),
'fields': ('password',) if raw else ('password1', 'password2'),
}),
]
for ix, rel in enumerate(related):
fieldsets.append((rel._meta.verbose_name.capitalize(), {
'classes': ('wide',),
'fields': ('password_%i' % ix,) if raw else ('password1_%i' % ix, 'password2_%i' % ix)
}))
obj_username = self.get_change_password_username(obj)
adminForm = admin.helpers.AdminForm(form, fieldsets, {})
context = {
'title': _('Change password: %s') % obj_username,
'adminform': adminForm,
'raw': raw,
'can_raw': can_raw,
'errors': admin.helpers.AdminErrorList(form, []),
'form_url': form_url,
'is_popup': (IS_POPUP_VAR in request.POST or
IS_POPUP_VAR in request.GET),
'add': True,
'change': False,
'has_delete_permission': False,
'has_change_permission': True,
'has_absolute_url': False,
'opts': self.model._meta,
'original': obj,
'obj_username': obj_username,
'save_as': False,
'show_save': True,
'password': random_ascii(10),
}
context.update(admin.site.each_context(request))
return TemplateResponse(request, self.change_user_password_template, context)
def show_hash(self, request, id):
if not request.user.is_superuser:
raise PermissionDenied
obj = get_object_or_404(self.get_queryset(request), pk=id)
return HttpResponse(obj.password)

185
orchestra/admin/utils.py Normal file
View File

@ -0,0 +1,185 @@
import datetime
import importlib
import inspect
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.db import models
from django.shortcuts import redirect
from django.utils import timezone
from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe
from orchestra.models.utils import get_field_value
from orchestra.utils import humanize
from .decorators import admin_field
from .html import monospace_format, code_format
def get_modeladmin(model, import_module=True):
""" returns the modeladmin registred for model """
for k,v in admin.site._registry.items():
if k is model:
return v
if import_module:
# Sometimes the admin module is not yet imported
app_label = model._meta.app_label
for app in settings.INSTALLED_APPS:
if app.endswith(app_label):
app_label = app
importlib.import_module('%s.%s' % (app_label, 'admin'))
return get_modeladmin(model, import_module=False)
def insertattr(model, name, value):
""" Inserts attribute to a modeladmin """
modeladmin = None
if issubclass(model, models.Model):
modeladmin = get_modeladmin(model)
modeladmin_class = type(modeladmin)
elif not inspect.isclass(model):
modeladmin = model
modeladmin_class = type(modeladmin)
else:
modeladmin_class = model
# Avoid inlines defined on parent class be shared between subclasses
# Seems that if we use tuples they are lost in some conditions like changing
# the tuple in modeladmin.__init__
if not getattr(modeladmin_class, name):
setattr(modeladmin_class, name, [])
setattr(modeladmin_class, name, list(getattr(modeladmin_class, name))+[value])
if modeladmin:
# make sure class and object share the same attribute, to avoid wierd bugs
setattr(modeladmin, name, getattr(modeladmin_class, name))
def wrap_admin_view(modeladmin, view):
""" Add admin authentication to view """
@wraps(view)
def wrapper(*args, **kwargs):
return modeladmin.admin_site.admin_view(view)(*args, **kwargs)
return wrapper
def set_url_query(request, key, value):
""" set default filters for changelist_view """
if key not in request.GET:
request_copy = request.GET.copy()
if callable(value):
value = value(request)
request_copy[key] = value
request.GET = request_copy
request.META['QUERY_STRING'] = request.GET.urlencode()
def action_to_view(action, modeladmin):
""" Converts modeladmin action to view function """
@wraps(action)
def action_view(request, object_id=1, modeladmin=modeladmin, action=action):
queryset = modeladmin.model.objects.filter(pk=object_id)
response = action(modeladmin, request, queryset)
if not response:
opts = modeladmin.model._meta
url = 'admin:%s_%s_change' % (opts.app_label, opts.model_name)
return redirect(url, object_id)
return response
return action_view
def change_url(obj):
if obj is not None:
cls = type(obj)
opts = obj._meta
if cls is models.DEFERRED:
opts = cls.__base__._meta
view_name = 'admin:%s_%s_change' % (opts.app_label, opts.model_name)
return reverse(view_name, args=(obj.pk,))
raise NoReverseMatch
@admin_field
def admin_link(*args, **kwargs):
instance = args[-1]
if kwargs['field'] in ('id', 'pk', '__str__'):
obj = instance
else:
try:
obj = get_field_value(instance, kwargs['field'])
except ObjectDoesNotExist:
return '---'
if not getattr(obj, 'pk', None):
return '---'
display_ = kwargs.get('display')
if display_:
display_ = getattr(obj, display_, display_)
else:
display_ = obj
try:
url = change_url(obj)
except NoReverseMatch:
# Does not has admin
return str(display_)
extra = ''
if kwargs['popup']:
extra = mark_safe('onclick="return showAddAnotherPopup(this);"')
title = "Change %s" % obj._meta.verbose_name
return format_html('<a href="{}" title="{}" {}>{}</a>', url, title, extra, display_)
@admin_field
def admin_colored(*args, **kwargs):
instance = args[-1]
field = kwargs['field']
value = escape(get_field_value(instance, field))
color = kwargs.get('colors', {}).get(value, 'black')
value = getattr(instance, 'get_%s_display' % field)().upper()
colored_value = '<span style="color: %s;">%s</span>' % (color, value)
if kwargs.get('bold', True):
colored_value = '<b>%s</b>' % colored_value
return mark_safe(colored_value)
@admin_field
def admin_date(*args, **kwargs):
instance = args[-1]
date = get_field_value(instance, kwargs['field'])
if not date:
return kwargs.get('default', '')
if isinstance(date, datetime.datetime):
natural = humanize.naturaldatetime(date)
else:
natural = humanize.naturaldate(date)
if hasattr(date, 'hour'):
date = timezone.localtime(date)
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)
def get_object_from_url(modeladmin, request):
try:
object_id = int(request.path.split('/')[-3])
except ValueError:
return None
else:
return modeladmin.model.objects.get(pk=object_id)
def display_mono(field):
def display(self, log):
content = getattr(log, field)
return monospace_format(escape(content))
display.short_description = field
return display
def display_code(field):
def display(self, log):
return code_format(getattr(log, field))
display.short_description = field
return display

View File

@ -0,0 +1,2 @@
from .options import *
from .actions import *

30
orchestra/api/actions.py Normal file
View File

@ -0,0 +1,30 @@
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.response import Response
from .serializers import SetPasswordSerializer
class SetPasswordApiMixin(object):
@action(detail=True, methods=['post'], serializer_class=SetPasswordSerializer)
def set_password(self, request, pk):
obj = self.get_object()
data = request.data
if isinstance(data, str):
data = {
'password': data
}
serializer = SetPasswordSerializer(data=data)
if serializer.is_valid():
obj.set_password(serializer.data['password'])
try:
obj.save(update_fields=['password'])
except ValueError:
# Some services don't store the password on the database
# update_fields=[] doesn't trigger post save!
obj.save()
return Response({
'status': 'password changed'
})
else:
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

45
orchestra/api/helpers.py Normal file
View File

@ -0,0 +1,45 @@
from django.urls import NoReverseMatch
from rest_framework.reverse import reverse
def link_wrap(view, view_names):
def wrapper(self, request, *args, **kwargs):
""" wrapper function that inserts HTTP links on view """
links = []
for name in view_names:
try:
url = reverse(name, request=self.request)
except NoReverseMatch:
url = reverse(name, args, kwargs, request=request)
links.append('<%s>; rel="%s"' % (url, name))
response = view(self, request, *args, **kwargs)
response['Link'] = ', '.join(links)
return response
for attr in dir(view):
try:
setattr(wrapper, attr, getattr(view, attr))
except:
pass
return wrapper
def insert_links(viewset, basename):
collection_links = ['api-root', '%s-list' % basename]
object_links = ['api-root', '%s-list' % basename, '%s-detail' % basename]
exception_links = ['api-root']
list_links = ['api-root']
retrieve_links = ['api-root', '%s-list' % basename]
# 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('_', '-'))
if hasattr(method, 'collection_bind_to_methods'):
list_links.append(view_name)
retrieve_links.append(view_name)
setattr(viewset, methodname, link_wrap(method, collection_links))
elif hasattr(method, 'bind_to_methods'):
retrieve_links.append(view_name)
setattr(viewset, methodname, link_wrap(method, object_links))
viewset.handle_exception = link_wrap(viewset.handle_exception, exception_links)
viewset.list = link_wrap(viewset.list, list_links)
viewset.retrieve = link_wrap(viewset.retrieve, retrieve_links)

94
orchestra/api/options.py Normal file
View File

@ -0,0 +1,94 @@
from django.contrib.admin.options import get_content_type_for_model
from django.conf import settings as django_settings
from django.utils.encoding import force_str
from django.utils.module_loading import autodiscover_modules
from django.utils.translation import gettext as _
from rest_framework.routers import DefaultRouter
from orchestra import settings
from orchestra.utils.python import import_class
from .helpers import insert_links
class LogApiMixin(object):
def create(self, request, *args, **kwargs):
from django.contrib.admin.models import ADDITION
response = super(LogApiMixin, self).create(request, *args, **kwargs)
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()
LogEntry.objects.log_action(
user_id=request.user.pk,
content_type_id=get_content_type_for_model(instance).pk,
object_id=instance.pk,
object_repr=force_str(instance),
action_flag=action,
change_message=message,
)
class LinkHeaderRouter(DefaultRouter):
def get_api_root_view(self, api_urls=None):
""" returns the root view, with all the linked collections """
APIRoot = import_class(settings.ORCHESTRA_API_ROOT_VIEW)
APIRoot.router = self
return APIRoot.as_view()
def register(self, prefix, viewset, basename=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))
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)
if viewset.serializer_class is None:
viewset.serializer_class = viewset().get_serializer_class()
viewset.serializer_class._declared_fields.update({name: field(**kwargs)})
viewset.serializer_class.Meta.fields += (name,)
# Create a router and register our viewsets with it.
router = LinkHeaderRouter(trailing_slash=django_settings.APPEND_SLASH)
autodiscover = lambda: (autodiscover_modules('api'), autodiscover_modules('serializers'))

70
orchestra/api/root.py Normal file
View File

@ -0,0 +1,70 @@
from rest_framework import views
from rest_framework.response import Response
from rest_framework.reverse import reverse
from .. import settings
from ..core import services, accounts
class APIRoot(views.APIView):
names = (
'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)
links = [
'<%s>; rel="%s"' % (root_url, 'api-root'),
'<%s>; rel="%s"' % (token_url, 'api-get-auth-token'),
]
body = {
'accountancy': {},
'services': {},
}
if not request.user.is_anonymous:
list_name = '{basename}-list'
detail_name = '{basename}-detail'
for prefix, viewset, basename in self.router.registry:
singleton_pk = getattr(viewset, 'singleton_pk', False)
if singleton_pk:
url_name = detail_name.format(basename=basename)
kwargs = {
'pk': singleton_pk(viewset(), request)
}
else:
url_name = list_name.format(basename=basename)
kwargs = {}
url = reverse(url_name, request=request, format=format, kwargs=kwargs)
links.append('<%s>; rel="%s"' % (url, url_name))
model = viewset.queryset.model
group = None
if model in services:
group = 'services'
menu = services[model].menu
if model in accounts:
group = 'accountancy'
menu = accounts[model].menu
if group and menu:
body[group][basename] = {
'url': url,
'verbose_name': model._meta.verbose_name,
'verbose_name_plural': model._meta.verbose_name_plural,
}
headers = {
'Link': ', '.join(links)
}
body.update({
name.lower(): getattr(settings, name, None)
for name in self.names
})
return Response(body, headers=headers)
def options(self, request):
metadata = super(APIRoot, self).options(request)
metadata.data['settings'] = {
name.lower(): getattr(settings, name, None)
for name in self.names
}
return metadata

View File

@ -0,0 +1,114 @@
import copy
from django.core.exceptions import ValidationError
from django.db import models
from django.forms import widgets
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from rest_framework.utils import model_meta
from ..core.validators import validate_password
class SetPasswordSerializer(serializers.Serializer):
password = serializers.CharField(max_length=128, label=_('Password'),
style={'widget': widgets.PasswordInput}, validators=[validate_password])
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)
if isinstance(attrs, models.Model):
return attrs
validated_data = dict(attrs)
ModelClass = self.Meta.model
# Remove many-to-many relationships from validated_data.
info = model_meta.get_field_info(ModelClass)
for field_name, relation_info in info.relations.items():
if relation_info.to_many and (field_name in validated_data):
validated_data.pop(field_name)
if self.instance:
# on update: Merge provided fields with instance field
instance = copy.deepcopy(self.instance)
for key, value in validated_data.items():
setattr(instance, key, value)
else:
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)
post_only_fields = getattr(self, 'post_only_fields', None)
if instance is not None and post_only_fields:
for attr, value in validated_data.items():
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)
return super(HyperlinkedModelSerializer, self).partial_update(instance, model_attrs)
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
if not url:
raise ValidationError({
'url': "URL is required."
})
account = self.get_account()
queryset = self.Meta.model.objects.filter(account=account)
self.fields['url'].queryset = queryset
obj = self.fields['url'].to_internal_value(url)
return obj
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):
""" POST only password """
if self.instance:
if value:
raise serializers.ValidationError(_("Can not set password"))
elif not value:
raise serializers.ValidationError(_("Password required"))
return value
def validate(self, attrs):
""" remove password in case is not a real model field """
try:
self.Meta.model._meta.get_field('password')
except models.FieldDoesNotExist:
pass
else:
password = attrs.pop('password', None)
attrs = super().validate(attrs)
if password is not None:
attrs['password'] = password
return attrs
def create(self, validated_data):
password = validated_data.pop('password')
instance = self.Meta.model(**validated_data)
instance.set_password(password)
instance.save()
return instance

6
orchestra/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class OrchestraConfig(AppConfig):
name = 'orchestra'
verbose_name = 'Orchestra'

285
orchestra/bin/celerybeat Executable file
View File

@ -0,0 +1,285 @@
#!/bin/bash
# =========================================================
# celerybeat - Starts the Celery periodic task scheduler.
# =========================================================
#
# :Usage: /etc/init.d/celerybeat {start|stop|force-reload|restart|try-restart|status}
# :Configuration file: /etc/default/celerybeat or /etc/default/celeryd
#
# See http://docs.celeryproject.org/en/latest/tutorials/daemonizing.html#generic-init-scripts
### BEGIN INIT INFO
# Provides: celerybeat
# Required-Start: $network $local_fs $remote_fs
# Required-Stop: $network $local_fs $remote_fs
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: celery periodic task scheduler
### END INIT INFO
# Cannot use set -e/bash -e since the kill -0 command will abort
# abnormally in the absence of a valid process ID.
#set -e
VERSION=10.0
echo "celery init v${VERSION}."
if [ $(id -u) -ne 0 ]; then
echo "Error: This program can only be used by the root user."
echo " Unpriviliged users must use 'celery beat --detach'"
exit 1
fi
# May be a runlevel symlink (e.g. S02celeryd)
if [ -L "$0" ]; then
SCRIPT_FILE=$(readlink "$0")
else
SCRIPT_FILE="$0"
fi
SCRIPT_NAME="$(basename "$SCRIPT_FILE")"
# /etc/init.d/celerybeat: start and stop the celery periodic task scheduler daemon.
# Make sure executable configuration script is owned by root
_config_sanity() {
local path="$1"
local owner=$(ls -ld "$path" | awk '{print $3}')
local iwgrp=$(ls -ld "$path" | cut -b 6)
local iwoth=$(ls -ld "$path" | cut -b 9)
if [ "$(id -u $owner)" != "0" ]; then
echo "Error: Config script '$path' must be owned by root!"
echo
echo "Resolution:"
echo "Review the file carefully and make sure it has not been "
echo "modified with mailicious intent. When sure the "
echo "script is safe to execute with superuser privileges "
echo "you can change ownership of the script:"
echo " $ sudo chown root '$path'"
exit 1
fi
if [ "$iwoth" != "-" ]; then # S_IWOTH
echo "Error: Config script '$path' cannot be writable by others!"
echo
echo "Resolution:"
echo "Review the file carefully and make sure it has not been "
echo "modified with malicious intent. When sure the "
echo "script is safe to execute with superuser privileges "
echo "you can change the scripts permissions:"
echo " $ sudo chmod 640 '$path'"
exit 1
fi
if [ "$iwgrp" != "-" ]; then # S_IWGRP
echo "Error: Config script '$path' cannot be writable by group!"
echo
echo "Resolution:"
echo "Review the file carefully and make sure it has not been "
echo "modified with malicious intent. When sure the "
echo "script is safe to execute with superuser privileges "
echo "you can change the scripts permissions:"
echo " $ sudo chmod 640 '$path'"
exit 1
fi
}
scripts=""
if test -f /etc/default/celeryd; then
scripts="/etc/default/celeryd"
_config_sanity /etc/default/celeryd
. /etc/default/celeryd
fi
EXTRA_CONFIG="/etc/default/${SCRIPT_NAME}"
if test -f "$EXTRA_CONFIG"; then
scripts="$scripts, $EXTRA_CONFIG"
_config_sanity "$EXTRA_CONFIG"
. "$EXTRA_CONFIG"
fi
echo "Using configuration: $scripts"
CELERY_BIN=${CELERY_BIN:-"celery"}
DEFAULT_USER="celery"
DEFAULT_PID_FILE="/var/run/celery/beat.pid"
DEFAULT_LOG_FILE="/var/log/celery/beat.log"
DEFAULT_LOG_LEVEL="INFO"
DEFAULT_CELERYBEAT="$CELERY_BIN beat"
CELERYBEAT=${CELERYBEAT:-$DEFAULT_CELERYBEAT}
CELERYBEAT_LOG_LEVEL=${CELERYBEAT_LOG_LEVEL:-${CELERYBEAT_LOGLEVEL:-$DEFAULT_LOG_LEVEL}}
# Sets --app argument for CELERY_BIN
CELERY_APP_ARG=""
if [ ! -z "$CELERY_APP" ]; then
CELERY_APP_ARG="--app=$CELERY_APP"
fi
CELERYBEAT_USER=${CELERYBEAT_USER:-${CELERYD_USER:-$DEFAULT_USER}}
# Set CELERY_CREATE_DIRS to always create log/pid dirs.
CELERY_CREATE_DIRS=${CELERY_CREATE_DIRS:-0}
CELERY_CREATE_RUNDIR=$CELERY_CREATE_DIRS
CELERY_CREATE_LOGDIR=$CELERY_CREATE_DIRS
if [ -z "$CELERYBEAT_PID_FILE" ]; then
CELERYBEAT_PID_FILE="$DEFAULT_PID_FILE"
CELERY_CREATE_RUNDIR=1
fi
if [ -z "$CELERYBEAT_LOG_FILE" ]; then
CELERYBEAT_LOG_FILE="$DEFAULT_LOG_FILE"
CELERY_CREATE_LOGDIR=1
fi
export CELERY_LOADER
CELERYBEAT_OPTS="$CELERYBEAT_OPTS -f $CELERYBEAT_LOG_FILE -l $CELERYBEAT_LOG_LEVEL"
if [ -n "$2" ]; then
CELERYBEAT_OPTS="$CELERYBEAT_OPTS $2"
fi
CELERYBEAT_LOG_DIR=`dirname $CELERYBEAT_LOG_FILE`
CELERYBEAT_PID_DIR=`dirname $CELERYBEAT_PID_FILE`
# Extra start-stop-daemon options, like user/group.
CELERYBEAT_CHDIR=${CELERYBEAT_CHDIR:-$CELERYD_CHDIR}
if [ -n "$CELERYBEAT_CHDIR" ]; then
DAEMON_OPTS="$DAEMON_OPTS --workdir=$CELERYBEAT_CHDIR"
fi
export PATH="${PATH:+$PATH:}/usr/sbin:/sbin"
check_dev_null() {
if [ ! -c /dev/null ]; then
echo "/dev/null is not a character device!"
exit 75 # EX_TEMPFAIL
fi
}
maybe_die() {
if [ $? -ne 0 ]; then
echo "Exiting: $*"
exit 77 # EX_NOPERM
fi
}
create_default_dir() {
if [ ! -d "$1" ]; then
echo "- Creating default directory: '$1'"
mkdir -p "$1"
maybe_die "Couldn't create directory $1"
echo "- Changing permissions of '$1' to 02755"
chmod 02755 "$1"
maybe_die "Couldn't change permissions for $1"
if [ -n "$CELERYBEAT_USER" ]; then
echo "- Changing owner of '$1' to '$CELERYBEAT_USER'"
chown "$CELERYBEAT_USER" "$1"
maybe_die "Couldn't change owner of $1"
fi
if [ -n "$CELERYBEAT_GROUP" ]; then
echo "- Changing group of '$1' to '$CELERYBEAT_GROUP'"
chgrp "$CELERYBEAT_GROUP" "$1"
maybe_die "Couldn't change group of $1"
fi
fi
}
check_paths() {
if [ $CELERY_CREATE_LOGDIR -eq 1 ]; then
create_default_dir "$CELERYBEAT_LOG_DIR"
fi
if [ $CELERY_CREATE_RUNDIR -eq 1 ]; then
create_default_dir "$CELERYBEAT_PID_DIR"
fi
}
create_paths () {
create_default_dir "$CELERYBEAT_LOG_DIR"
create_default_dir "$CELERYBEAT_PID_DIR"
}
wait_pid () {
pid=$1
forever=1
i=0
while [ $forever -gt 0 ]; do
kill -0 $pid 1>/dev/null 2>&1
if [ $? -eq 1 ]; then
echo "OK"
forever=0
else
kill -TERM "$pid"
i=$((i + 1))
if [ $i -gt 60 ]; then
echo "ERROR"
echo "Timed out while stopping (30s)"
forever=0
else
sleep 0.5
fi
fi
done
}
stop_beat () {
echo -n "Stopping ${SCRIPT_NAME}... "
if [ -f "$CELERYBEAT_PID_FILE" ]; then
wait_pid $(cat "$CELERYBEAT_PID_FILE")
else
echo "NOT RUNNING"
fi
}
_chuid () {
su "$CELERYBEAT_USER" -c "$CELERYBEAT $*"
}
start_beat () {
echo "Starting ${SCRIPT_NAME}..."
_chuid $CELERY_APP_ARG $CELERYBEAT_OPTS $DAEMON_OPTS --detach \
--pidfile="$CELERYBEAT_PID_FILE"
}
case "$1" in
start)
check_dev_null
check_paths
start_beat
;;
stop)
check_paths
stop_beat
;;
reload|force-reload)
echo "Use start+stop"
;;
restart)
echo "Restarting celery periodic task scheduler"
check_paths
stop_beat
check_dev_null
start_beat
;;
create-paths)
check_dev_null
create_paths
;;
check-paths)
check_dev_null
check_paths
;;
*)
echo "Usage: /etc/init.d/${SCRIPT_NAME} {start|stop|restart|create-paths}"
exit 64 # EX_USAGE
;;
esac
exit 0

387
orchestra/bin/celeryd Executable file
View File

@ -0,0 +1,387 @@
#!/bin/sh -e
# ============================================
# celeryd - Starts the Celery worker daemon.
# ============================================
#
# :Usage: /etc/init.d/celeryd {start|stop|force-reload|restart|try-restart|status}
# :Configuration file: /etc/default/celeryd
#
# See http://docs.celeryproject.org/en/latest/tutorials/daemonizing.html#generic-init-scripts
### BEGIN INIT INFO
# Provides: celeryd
# Required-Start: $network $local_fs $remote_fs
# Required-Stop: $network $local_fs $remote_fs
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: celery task worker daemon
### END INIT INFO
#
#
# To implement separate init scripts, copy this script and give it a different
# name:
# I.e., if my new application, "little-worker" needs an init, I
# should just use:
#
# cp /etc/init.d/celeryd /etc/init.d/little-worker
#
# You can then configure this by manipulating /etc/default/little-worker.
#
VERSION=10.0
echo "celery init v${VERSION}."
if [ $(id -u) -ne 0 ]; then
echo "Error: This program can only be used by the root user."
echo " Unprivileged users must use the 'celery multi' utility, "
echo " or 'celery worker --detach'."
exit 1
fi
# Can be a runlevel symlink (e.g. S02celeryd)
if [ -L "$0" ]; then
SCRIPT_FILE=$(readlink "$0")
else
SCRIPT_FILE="$0"
fi
SCRIPT_NAME="$(basename "$SCRIPT_FILE")"
DEFAULT_USER="celery"
DEFAULT_PID_FILE="/var/run/celery/%n.pid"
DEFAULT_LOG_FILE="/var/log/celery/%n%I.log"
DEFAULT_LOG_LEVEL="INFO"
DEFAULT_NODES="celery"
DEFAULT_CELERYD="-m celery worker --detach"
CELERY_DEFAULTS=${CELERY_DEFAULTS:-"/etc/default/${SCRIPT_NAME}"}
# Make sure executable configuration script is owned by root
_config_sanity() {
local path="$1"
local owner=$(ls -ld "$path" | awk '{print $3}')
local iwgrp=$(ls -ld "$path" | cut -b 6)
local iwoth=$(ls -ld "$path" | cut -b 9)
if [ "$(id -u $owner)" != "0" ]; then
echo "Error: Config script '$path' must be owned by root!"
echo
echo "Resolution:"
echo "Review the file carefully and make sure it has not been "
echo "modified with mailicious intent. When sure the "
echo "script is safe to execute with superuser privileges "
echo "you can change ownership of the script:"
echo " $ sudo chown root '$path'"
exit 1
fi
if [ "$iwoth" != "-" ]; then # S_IWOTH
echo "Error: Config script '$path' cannot be writable by others!"
echo
echo "Resolution:"
echo "Review the file carefully and make sure it has not been "
echo "modified with malicious intent. When sure the "
echo "script is safe to execute with superuser privileges "
echo "you can change the scripts permissions:"
echo " $ sudo chmod 640 '$path'"
exit 1
fi
if [ "$iwgrp" != "-" ]; then # S_IWGRP
echo "Error: Config script '$path' cannot be writable by group!"
echo
echo "Resolution:"
echo "Review the file carefully and make sure it has not been "
echo "modified with malicious intent. When sure the "
echo "script is safe to execute with superuser privileges "
echo "you can change the scripts permissions:"
echo " $ sudo chmod 640 '$path'"
exit 1
fi
}
if [ -f "$CELERY_DEFAULTS" ]; then
_config_sanity "$CELERY_DEFAULTS"
echo "Using config script: $CELERY_DEFAULTS"
. "$CELERY_DEFAULTS"
fi
# Sets --app argument for CELERY_BIN
CELERY_APP_ARG=""
if [ ! -z "$CELERY_APP" ]; then
CELERY_APP_ARG="--app=$CELERY_APP"
fi
CELERYD_USER=${CELERYD_USER:-$DEFAULT_USER}
# Set CELERY_CREATE_DIRS to always create log/pid dirs.
CELERY_CREATE_DIRS=${CELERY_CREATE_DIRS:-0}
CELERY_CREATE_RUNDIR=$CELERY_CREATE_DIRS
CELERY_CREATE_LOGDIR=$CELERY_CREATE_DIRS
if [ -z "$CELERYD_PID_FILE" ]; then
CELERYD_PID_FILE="$DEFAULT_PID_FILE"
CELERY_CREATE_RUNDIR=1
fi
if [ -z "$CELERYD_LOG_FILE" ]; then
CELERYD_LOG_FILE="$DEFAULT_LOG_FILE"
CELERY_CREATE_LOGDIR=1
fi
CELERYD_LOG_LEVEL=${CELERYD_LOG_LEVEL:-${CELERYD_LOGLEVEL:-$DEFAULT_LOG_LEVEL}}
CELERY_BIN=${CELERY_BIN:-"celery"}
CELERYD_MULTI=${CELERYD_MULTI:-"$CELERY_BIN multi"}
CELERYD_NODES=${CELERYD_NODES:-$DEFAULT_NODES}
export CELERY_LOADER
if [ -n "$2" ]; then
CELERYD_OPTS="$CELERYD_OPTS $2"
fi
CELERYD_LOG_DIR=`dirname $CELERYD_LOG_FILE`
CELERYD_PID_DIR=`dirname $CELERYD_PID_FILE`
# Extra start-stop-daemon options, like user/group.
if [ -n "$CELERYD_CHDIR" ]; then
DAEMON_OPTS="$DAEMON_OPTS --workdir=$CELERYD_CHDIR"
fi
check_dev_null() {
if [ ! -c /dev/null ]; then
echo "/dev/null is not a character device!"
exit 75 # EX_TEMPFAIL
fi
}
maybe_die() {
if [ $? -ne 0 ]; then
echo "Exiting: $* (errno $?)"
exit 77 # EX_NOPERM
fi
}
create_default_dir() {
if [ ! -d "$1" ]; then
echo "- Creating default directory: '$1'"
mkdir -p "$1"
maybe_die "Couldn't create directory $1"
echo "- Changing permissions of '$1' to 02755"
chmod 02755 "$1"
maybe_die "Couldn't change permissions for $1"
if [ -n "$CELERYD_USER" ]; then
echo "- Changing owner of '$1' to '$CELERYD_USER'"
chown "$CELERYD_USER" "$1"
maybe_die "Couldn't change owner of $1"
fi
if [ -n "$CELERYD_GROUP" ]; then
echo "- Changing group of '$1' to '$CELERYD_GROUP'"
chgrp "$CELERYD_GROUP" "$1"
maybe_die "Couldn't change group of $1"
fi
fi
}
check_paths() {
if [ $CELERY_CREATE_LOGDIR -eq 1 ]; then
create_default_dir "$CELERYD_LOG_DIR"
fi
if [ $CELERY_CREATE_RUNDIR -eq 1 ]; then
create_default_dir "$CELERYD_PID_DIR"
fi
}
create_paths() {
create_default_dir "$CELERYD_LOG_DIR"
create_default_dir "$CELERYD_PID_DIR"
}
export PATH="${PATH:+$PATH:}/usr/sbin:/sbin"
_get_pids() {
found_pids=0
my_exitcode=0
for pid_file in "$CELERYD_PID_DIR"/*.pid; do
local pid=`cat "$pid_file"`
local cleaned_pid=`echo "$pid" | sed -e 's/[^0-9]//g'`
if [ -z "$pid" ] || [ "$cleaned_pid" != "$pid" ]; then
echo "bad pid file ($pid_file)"
one_failed=true
my_exitcode=1
else
found_pids=1
echo "$pid"
fi
if [ $found_pids -eq 0 ]; then
echo "${SCRIPT_NAME}: All nodes down"
exit $my_exitcode
fi
done
}
_chuid () {
su "$CELERYD_USER" -c "$CELERYD_MULTI $*"
}
start_workers () {
if [ ! -z "$CELERYD_ULIMIT" ]; then
ulimit $CELERYD_ULIMIT
fi
_chuid $* start $CELERYD_NODES $DAEMON_OPTS \
--pidfile="$CELERYD_PID_FILE" \
--logfile="$CELERYD_LOG_FILE" \
--loglevel="$CELERYD_LOG_LEVEL" \
$CELERY_APP_ARG \
$CELERYD_OPTS
}
dryrun () {
(C_FAKEFORK=1 start_workers --verbose)
}
stop_workers () {
_chuid stopwait $CELERYD_NODES --pidfile="$CELERYD_PID_FILE"
}
restart_workers () {
_chuid restart $CELERYD_NODES $DAEMON_OPTS \
--pidfile="$CELERYD_PID_FILE" \
--logfile="$CELERYD_LOG_FILE" \
--loglevel="$CELERYD_LOG_LEVEL" \
$CELERY_APP_ARG \
$CELERYD_OPTS
}
kill_workers() {
_chuid kill $CELERYD_NODES --pidfile="$CELERYD_PID_FILE"
}
restart_workers_graceful () {
local worker_pids=
worker_pids=`_get_pids`
[ "$one_failed" ] && exit 1
for worker_pid in $worker_pids; do
local failed=
kill -HUP $worker_pid 2> /dev/null || failed=true
if [ "$failed" ]; then
echo "${SCRIPT_NAME} worker (pid $worker_pid) could not be restarted"
one_failed=true
else
echo "${SCRIPT_NAME} worker (pid $worker_pid) received SIGHUP"
fi
done
[ "$one_failed" ] && exit 1 || exit 0
}
check_status () {
my_exitcode=0
found_pids=0
local one_failed=
for pid_file in "$CELERYD_PID_DIR"/*.pid; do
if [ ! -r $pid_file ]; then
echo "${SCRIPT_NAME} is stopped: no pids were found"
one_failed=true
break
fi
local node=`basename "$pid_file" .pid`
local pid=`cat "$pid_file"`
local cleaned_pid=`echo "$pid" | sed -e 's/[^0-9]//g'`
if [ -z "$pid" ] || [ "$cleaned_pid" != "$pid" ]; then
echo "bad pid file ($pid_file)"
one_failed=true
else
local failed=
kill -0 $pid 2> /dev/null || failed=true
if [ "$failed" ]; then
echo "${SCRIPT_NAME} (node $node) (pid $pid) is stopped, but pid file exists!"
one_failed=true
else
echo "${SCRIPT_NAME} (node $node) (pid $pid) is running..."
fi
fi
done
[ "$one_failed" ] && exit 1 || exit 0
}
case "$1" in
start)
check_dev_null
check_paths
start_workers
;;
stop)
check_dev_null
check_paths
stop_workers
;;
reload|force-reload)
echo "Use restart"
;;
status)
check_status
;;
restart)
check_dev_null
check_paths
restart_workers
;;
graceful)
check_dev_null
restart_workers_graceful
;;
kill)
check_dev_null
kill_workers
;;
dryrun)
check_dev_null
dryrun
;;
try-restart)
check_dev_null
check_paths
restart_workers
;;
create-paths)
check_dev_null
create_paths
;;
check-paths)
check_dev_null
check_paths
;;
*)
echo "Usage: /etc/init.d/${SCRIPT_NAME} {start|stop|restart|graceful|kill|dryrun|create-paths}"
exit 64 # EX_USAGE
;;
esac
exit 0

226
orchestra/bin/celeryevcam Executable file
View File

@ -0,0 +1,226 @@
#!/bin/bash
# ============================================
# celeryd - Starts the Celery worker daemon.
# ============================================
#
# :Usage: /etc/init.d/celeryd {start|stop|force-reload|restart|try-restart|status}
#
# :Configuration file: /etc/default/celeryev | /etc/default/celeryd
#
# To configure celeryd you probably need to tell it where to chdir.
#
# EXAMPLE CONFIGURATION
# =====================
#
# this is an example configuration for a Python project:
#
# /etc/default/celeryd:
#
# # Where to chdir at start.
# CELERYD_CHDIR="/opt/Myproject/"
#
# # Extra arguments to celeryev
# CELERYEV_OPTS="-x"
#
# # Name of the celery config module.#
# CELERY_CONFIG_MODULE="celeryconfig"
#
# # Camera class to use (required)
# CELERYEV_CAM = "myapp.Camera"
#
# EXAMPLE DJANGO CONFIGURATION
# ============================
#
# # Where the Django project is.
# CELERYD_CHDIR="/opt/Project/"
#
# # Name of the projects settings module.
# export DJANGO_SETTINGS_MODULE="MyProject.settings"
#
# # Path to celeryd
# CELERYEV="/opt/Project/manage.py"
#
# # Extra arguments to manage.py
# CELERYEV_OPTS="celeryev"
#
# # Camera class to use (required)
# CELERYEV_CAM="djcelery.snapshot.Camera"
#
# AVAILABLE OPTIONS
# =================
#
# * CELERYEV_OPTS
# Additional arguments to celeryd, see `celeryd --help` for a list.
#
# * CELERYD_CHDIR
# Path to chdir at start. Default is to stay in the current directory.
#
# * CELERYEV_PID_FILE
# Full path to the pidfile. Default is /var/run/celeryd.pid.
#
# * CELERYEV_LOG_FILE
# Full path to the celeryd logfile. Default is /var/log/celeryd.log
#
# * CELERYEV_LOG_LEVEL
# Log level to use for celeryd. Default is INFO.
#
# * CELERYEV
# Path to the celeryev program. Default is `celeryev`.
# You can point this to an virtualenv, or even use manage.py for django.
#
# * CELERYEV_USER
# User to run celeryev as. Default is current user.
#
# * CELERYEV_GROUP
# Group to run celeryev as. Default is current user.
#
# * VIRTUALENV
# Full path to the virtualenv environment to activate. Default is none.
### BEGIN INIT INFO
# Provides: celeryev
# Required-Start: $network $local_fs $remote_fs postgresql rabbitmq-server
# Required-Stop: $network $local_fs $remote_fs postgresql rabbitmq-server
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: celery event snapshots
### END INIT INFO
# Cannot use set -e/bash -e since the kill -0 command will abort
# abnormally in the absence of a valid process ID.
#set -e
DEFAULT_PID_FILE="/var/run/celeryev.pid"
DEFAULT_LOG_FILE="/var/log/celeryev.log"
DEFAULT_LOG_LEVEL="INFO"
DEFAULT_CELERYEV="/usr/bin/celeryev"
if test -f /etc/default/celeryd; then
. /etc/default/celeryd
fi
if test -f /etc/default/celeryev; then
. /etc/default/celeryev
fi
CELERYEV=${CELERYEV:-$DEFAULT_CELERYEV}
CELERYEV_PID_FILE=${CELERYEV_PID_FILE:-${CELERYEV_PIDFILE:-$DEFAULT_PID_FILE}}
CELERYEV_LOG_FILE=${CELERYEV_LOG_FILE:-${CELERYEV_LOGFILE:-$DEFAULT_LOG_FILE}}
CELERYEV_LOG_LEVEL=${CELERYEV_LOG_LEVEL:-${CELERYEV_LOG_LEVEL:-$DEFAULT_LOG_LEVEL}}
export CELERY_LOADER
if [ -z "$CELERYEV_CAM" ]; then
echo "Missing CELERYEV_CAM variable" 1>&2
exit
fi
CELERYEV_OPTS="$CELERYEV_OPTS -f $CELERYEV_LOG_FILE -l $CELERYEV_LOG_LEVEL -c $CELERYEV_CAM"
if [ -n "$2" ]; then
CELERYEV_OPTS="$CELERYEV_OPTS $2"
fi
CELERYEV_LOG_DIR=`dirname $CELERYEV_LOG_FILE`
CELERYEV_PID_DIR=`dirname $CELERYEV_PID_FILE`
if [ ! -d "$CELERYEV_LOG_DIR" ]; then
mkdir -p $CELERYEV_LOG_DIR
fi
if [ ! -d "$CELERYEV_PID_DIR" ]; then
mkdir -p $CELERYEV_PID_DIR
fi
# Extra start-stop-daemon options, like user/group.
if [ -n "$CELERYEV_USER" ]; then
DAEMON_OPTS="$DAEMON_OPTS --uid $CELERYEV_USER"
chown "$CELERYEV_USER" $CELERYEV_LOG_DIR $CELERYEV_PID_DIR
fi
if [ -n "$CELERYEV_GROUP" ]; then
DAEMON_OPTS="$DAEMON_OPTS --gid $CELERYEV_GROUP"
chgrp "$CELERYEV_GROUP" $CELERYEV_LOG_DIR $CELERYEV_PID_DIR
fi
CELERYEV_CHDIR=${CELERYEV_CHDIR:-$CELERYD_CHDIR}
if [ -n "$CELERYEV_CHDIR" ]; then
DAEMON_OPTS="$DAEMON_OPTS --workdir $CELERYEV_CHDIR"
fi
export PATH="${PATH:+$PATH:}/usr/sbin:/sbin"
check_dev_null() {
if [ ! -c /dev/null ]; then
echo "/dev/null is not a character device!"
exit 1
fi
}
wait_pid () {
pid=$1
forever=1
i=0
while [ $forever -gt 0 ]; do
kill -0 $pid 1>/dev/null 2>&1
if [ $? -eq 1 ]; then
echo "OK"
forever=0
else
kill -TERM "$pid"
i=$((i + 1))
if [ $i -gt 60 ]; then
echo "ERROR"
echo "Timed out while stopping (30s)"
forever=0
else
sleep 0.5
fi
fi
done
}
stop_evcam () {
echo -n "Stopping celeryev..."
if [ -f "$CELERYEV_PID_FILE" ]; then
wait_pid $(cat "$CELERYEV_PID_FILE")
else
echo "NOT RUNNING"
fi
}
start_evcam () {
echo "Starting celeryev..."
if [ -n "$VIRTUALENV" ]; then
source $VIRTUALENV/bin/activate
fi
$CELERYEV $CELERYEV_OPTS $DAEMON_OPTS --detach \
--pidfile="$CELERYEV_PID_FILE"
}
case "$1" in
start)
check_dev_null
start_evcam
;;
stop)
stop_evcam
;;
reload|force-reload)
echo "Use start+stop"
;;
restart)
echo "Restarting celery event snapshots" "celeryev"
stop_evcam
check_dev_null
start_evcam
;;
*)
echo "Usage: /etc/init.d/celeryev {start|stop|restart}"
exit 1
esac
exit 0

View File

@ -0,0 +1,72 @@
# #########################################################################
# This bash script adds tab-completion feature to django-admin.py and
# manage.py.
#
# Testing it out without installing
# =================================
#
# To test out the completion without "installing" this, just run this file
# directly, like so:
#
# . ~/path/to/django_bash_completion
#
# Note: There's a dot ('.') at the beginning of that command.
#
# After you do that, tab completion will immediately be made available in your
# current Bash shell. But it won't be available next time you log in.
#
# Installing
# ==========
#
# To install this, point to this file from your .bash_profile, like so:
#
# . ~/path/to/django_bash_completion
#
# Do the same in your .bashrc if .bashrc doesn't invoke .bash_profile.
#
# Settings will take effect the next time you log in.
#
# Uninstalling
# ============
#
# To uninstall, just remove the line from your .bash_profile and .bashrc.
_django_completion()
{
COMPREPLY=( $( COMP_WORDS="${COMP_WORDS[*]}" \
COMP_CWORD=$COMP_CWORD \
DJANGO_AUTO_COMPLETE=1 $1 ) )
}
complete -F _django_completion -o default django-admin.py manage.py django-admin
_python_django_completion()
{
if [[ ${COMP_CWORD} -ge 2 ]]; then
PYTHON_EXE=${COMP_WORDS[0]##*/}
echo $PYTHON_EXE | egrep "python([2-9]\.[0-9])?" >/dev/null 2>&1
if [[ $? == 0 ]]; then
PYTHON_SCRIPT=${COMP_WORDS[1]##*/}
echo $PYTHON_SCRIPT | egrep "manage\.py|django-admin(\.py)?" >/dev/null 2>&1
if [[ $? == 0 ]]; then
COMPREPLY=( $( COMP_WORDS="${COMP_WORDS[*]:1}" \
COMP_CWORD=$(( COMP_CWORD-1 )) \
DJANGO_AUTO_COMPLETE=1 ${COMP_WORDS[*]} ) )
fi
fi
fi
}
# Support for multiple interpreters.
unset pythons
if command -v whereis &>/dev/null; then
python_interpreters=$(whereis python | cut -d " " -f 2-)
for python in $python_interpreters; do
pythons="${pythons} ${python##*/}"
done
pythons=$(echo $pythons | tr " " "\n" | sort -u | tr "\n" " ")
else
pythons=python
fi
complete -F _python_django_completion -o default $pythons

246
orchestra/bin/orchestra-admin Executable file
View File

@ -0,0 +1,246 @@
#!/bin/bash
set -u
set -e
bold=$(tput -T ${TERM:-xterm} bold)
normal=$(tput -T ${TERM:-xterm} sgr0)
PYTHON_BIN='python3'
function help () {
if [[ $# -gt 1 ]]; then
CMD="print_${2}_help"
$CMD
else
print_help
fi
}
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
}
show () {
echo " ${bold}\$ ${@}${normal}"
}
export -f show
run () {
show "${@}"
"${@}"
}
export -f run
check_root () {
[ $(whoami) != 'root' ] && { echo -e "\nErr. This should be run as root\n" >&2; exit 1; }
}
export -f check_root
get_orchestra_dir () {
if ! $(echo "import orchestra" | $PYTHON_BIN 2> /dev/null); then
echo -e "\norchestra not installed.\n" >&2
exit 1
fi
PATH=$(echo "import orchestra, os; print(os.path.dirname(os.path.realpath(orchestra.__file__)))" | $PYTHON_BIN)
echo $PATH
}
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
}
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 ;;
-t|--testing) testing=true; shift ;;
(--) shift; break;;
(-*) echo "$0: Err. - unrecognized option $1" 1>&2; exit 1;;
(*) break;;
esac
shift
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 \
gettext \
libcrack2-dev \
libxml2-dev \
libxslt1-dev \
python3 \
python3-pip \
python3-dev \
ssh-client \
wget \
xvfb \
zlib1g-dev"
if $testing; then
APT="${APT} \
git \
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
wget -P /usr/local/share/ca-certificates/cacert.org \
http://www.cacert.org/certs/root.crt \
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 \
lxml==3.3.5"
if $testing; then
PIP="${PIP} \
selenium \
xvfbwrapper \
freezegun==0.3.14 \
coverage \
flake8 \
django-debug-toolbar==1.3.0 \
django-nose==1.4.4 \
sqlparse \
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}
dpkg -i ${wkhtmltox} || { echo "Installing missing dependencies for wkhtmltox..." && apt-get -f -y install; }
fi
}
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
-h|--help) print_startproject_help; exit 0 ;;
(--) shift; break;;
(-*) echo "$0: Err. - unrecognized option $1" 1>&2; exit 1;;
(*) break;;
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
run django-admin.py startproject $PROJECT_NAME --template="${ORCHESTRA_PATH}/conf/project_template"
# This is a workaround for this issue https://github.com/pypa/pip/issues/317
run chmod +x $PROJECT_NAME/manage.py
# End of workaround ###
else
echo "Not cloning: $PROJECT_NAME already exists."
fi
# Install bash autocompletition for django commands
if [[ ! $(grep 'source $HOME/.django_bash_completion.sh' ~/.bashrc &> /dev/null) ]]; then
# run wget https://raw.github.com/django/django/master/extras/django_bash_completion \
# --no-check-certificate -O ~/.django_bash_completion.sh
cp ${ORCHESTRA_PATH}/bin/django_bash_completion.sh ~/.django_bash_completion.sh
echo 'source $HOME/.django_bash_completion.sh' >> ~/.bashrc
fi
}
export -f startproject
[ $# -lt 1 ] && { print_help; exit 1; }
$1 "${@}"

226
orchestra/bin/orchestra-beat Executable file
View File

@ -0,0 +1,226 @@
#!/usr/bin/env python3
# High performance alternative to beat management command
# Looks for pending work before firing up all the Django machinery on separate processes
#
# Handles orchestra.contrib.tasks periodic_tasks and orchestra.contrib.mailer queued mails
#
# USAGE: beat /path/to/project/manage.py
import json
import os
import re
import sys
from datetime import datetime, timedelta
from orchestra.utils.sys import run, join, LockFile
class crontab_parser(object):
"""
from celery.schedules import crontab_parser
Too expensive to import celery
"""
ParseException = ValueError
_range = r'(\w+?)-(\w+)'
_steps = r'/(\w+)?'
_star = r'\*'
def __init__(self, max_=60, min_=0):
self.max_ = max_
self.min_ = min_
self.pats = (
(re.compile(self._range + self._steps), self._range_steps),
(re.compile(self._range), self._expand_range),
(re.compile(self._star + self._steps), self._star_steps),
(re.compile('^' + self._star + '$'), self._expand_star),
)
def parse(self, spec):
acc = set()
for part in spec.split(','):
if not part:
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:
to = self._expand_number(toks[1])
if to < fr: # Wrap around max_ if necessary
return (list(range(fr, self.min_ + self.max_)) +
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')
try:
i = int(s)
except ValueError:
try:
i = weekday(s)
except KeyError:
raise ValueError('Invalid weekday literal {0!r}.'.format(s))
max_val = self.min_ + self.max_ - 1
if i > max_val:
raise ValueError(
'Invalid end range: {0} > {1}.'.format(i, max_val))
if i < self.min_:
raise ValueError(
'Invalid beginning range: {0} < {1}.'.format(i, self.min_))
return i
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}
with open(self.settings_file) as f:
content = ''
for line in f.readlines():
# This is very costly, skip
if not line.startswith(('import djcelery', 'djcelery.setup_loader()')):
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*"([^"]+)"')
for line in handler.readlines():
match = regex.search(line)
if match:
settings_module = match.groups()[0]
settings_file = os.path.join(*settings_module.split('.')) + '.py'
settings_file = os.path.join(os.path.dirname(manage), settings_file)
return settings_file
raise ValueError("settings module not found in %s" % manage)
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
self.conn = sqlite3.connect(self.settings['NAME'])
elif self.settings['ENGINE'] == 'django.db.backends.postgresql_psycopg2':
import psycopg2
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:
cur.execute(query)
result = cur.fetchall()
finally:
cur.close()
return result
def close(self):
self.conn.close()
def fire_pending_tasks(manage, db):
def get_tasks(db):
enabled = 1 if 'sqlite' in db.settings['ENGINE'] else True
query = (
"SELECT c.minute, c.hour, c.day_of_week, c.day_of_month, c.month_of_year, p.id "
"FROM djcelery_periodictask as p, djcelery_crontabschedule as c "
"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 (
n_minute in crontab_parser(60).parse(minute) and
n_hour in crontab_parser(24).parse(hour) and
n_day_of_week in crontab_parser(7).parse(day_of_week) and
n_day_of_month in crontab_parser(31, 1).parse(day_of_month) and
n_month_of_year in crontab_parser(12, 1).parse(month_of_year)
)
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)
yield proc
def fire_pending_messages(settings, db):
def has_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
query_or.append("""(mailer_message.retries = %i AND mailer_message.last_try <= '%s')"""
% (num, epoch.isoformat().replace('T', ' ')))
query = """\
SELECT 1 FROM mailer_message
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)
yield proc
if __name__ == "__main__":
with LockFile('/dev/shm/beat.lock', expire=20):
manage = sys.argv[1]
procs = []
settings = Setting(manage).get_settings()
db = DB(settings)
db.connect()
try:
# Non-blocking loop, we need to finish this in time for the next minute.
if 'orchestra.contrib.tasks' in settings['INSTALLED_APPS']:
if settings.get('TASKS_BACKEND', 'thread') in ('thread', 'process'):
for proc in fire_pending_tasks(manage, db):
procs.append(proc)
if 'orchestra.contrib.mailer' in settings['INSTALLED_APPS']:
for proc in fire_pending_messages(settings, db):
procs.append(proc)
finally:
db.close()
sys.exit(0)

BIN
orchestra/bin/sieve-test Executable file

Binary file not shown.

View File

View File

View File

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

View File

View File

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

View File

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

View File

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

View File

View File

@ -0,0 +1 @@
default_app_config = 'orchestra.contrib.accounts.apps.AccountConfig'

View File

@ -0,0 +1,287 @@
from functools import partial, wraps
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.db import router
from django.shortcuts import redirect, render
from django.template.response import TemplateResponse
from django.utils import timezone
from django.utils.encoding import force_str
from django.utils.html import format_html
from django.utils.text import capfirst
from django.utils.translation import ngettext, gettext_lazy as _
from orchestra.core import services
from . import settings
def list_contacts(modeladmin, request, queryset):
ids = queryset.order_by().values_list('id', flat=True).distinct()
if not ids:
messages.warning(request, "Select at least one account.")
return
url = reverse('admin:contacts_contact_changelist')
url += '?account__in=%s' % ','.join(map(str, ids))
return redirect(url)
list_contacts.short_description = _("List contacts")
def list_accounts(modeladmin, request, queryset):
accounts = queryset.order_by().values_list('account_id', flat=True).distinct()
if not accounts:
messages.warning(request, "Select at least one instance.")
return
url = reverse('admin:contacts_contact_changelist')
url += '?id__in=%s' % ','.join(map(str, accounts))
return redirect(url)
list_accounts.short_description = _("List accounts")
def service_report(modeladmin, request, queryset):
# TODO resources
accounts = []
fields = []
registered_services = services.get()
# First we get related manager names to fire a prefetch related
for name, field in queryset.model._meta.fields_map.items():
model = field.related_model
if model in registered_services and model != queryset.model:
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()
}
return render(request, settings.ACCOUNTS_SERVICE_REPORT_TEMPLATE, context)
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_str(obj))
if has_admin:
try:
admin_url = reverse(
'admin:%s_%s_change' % (opts.app_label, opts.model_name),
None, (quote(obj._get_pk_val()),)
)
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:
context += (_("services to delete:"),)
return format_html('{} <a href="{}">{}</a> {}', *context)
return format_html('{}: <a href="{}">{}</a>', *context)
else:
# 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 = []
for obj in objs:
format_nested(obj, current)
result.append(current)
else:
result.append(format(objs))
for nested in collector.nested():
if isinstance(nested, list):
# Is lists of objects
current = []
is_service = False
for service in nested:
if type(service) in registered_services:
if service == main_systemuser:
continue
current.append(format(service))
to_delete.append(service)
is_service = True
elif is_service and isinstance(service, list):
nested = []
format_nested(service, nested)
current.append(nested[0])
is_service = False
else:
is_service = False
related_services.append(current)
elif isinstance(nested, modeladmin.model):
# Is account
# 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'):
accounts = len(queryset)
msg = _("Related services deleted and account disabled.")
for account in queryset:
account.is_active = False
account.save(update_fields=('is_active',))
modeladmin.log_change(request, account, msg)
if accounts:
relateds = len(to_delete)
for obj in to_delete:
obj_display = force_str(obj)
modeladmin.log_deletion(request, obj, obj_display)
obj.delete()
context = {
'accounts': accounts,
'relateds': relateds,
}
msg = _("Successfully disabled %(accounts)d account and deleted %(relateds)d related services.") % context
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_str(opts.verbose_name)
else:
objects_name = force_str(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:
count = len(objs) - 1
# Discount account
elif model is not modeladmin.model and model in registered_services:
count = len(objs)
if count:
model_count[model._meta.verbose_name_plural] = count
if not model_count:
modeladmin.message_user(request, _("Nothing to delete"), messages.WARNING)
return None
context = dict(
admin_site.each_context(request),
title=_("Are you sure?"),
objects_name=objects_name,
deletable_objects=[related_services],
model_count=dict(model_count).items(),
queryset=queryset,
opts=opts,
action_checkbox_name=helpers.ACTION_CHECKBOX_NAME,
)
request.current_app = admin_site.name
# Display the confirmation page
template = 'admin/%s/%s/delete_related_services_confirmation.html' % (app_label, opts.model_name)
return TemplateResponse(request, template, context)
delete_related_services.short_description = _("Delete related services")
def disable_selected(modeladmin, request, queryset, disable=True):
opts = modeladmin.model._meta
app_label = opts.app_label
verbose_action_name = _("disabled") if disable else _("enabled")
# The user has already confirmed the deletion.
# Do the disable and return a None to display the change list view again.
if request.POST.get('post'):
n = 0
for account in queryset:
account.disable() if disable else account.enable()
modeladmin.log_change(request, account, verbose_action_name.capitalize())
n += 1
modeladmin.message_user(request, ngettext(
_("One account has been successfully %s.") % verbose_action_name,
_("%i accounts have been successfully %s.") % (n, verbose_action_name),
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
no_edit_link = '%s: %s' % (capfirst(opts.verbose_name), force_str(obj))
if has_admin:
try:
admin_url = reverse(
'admin:%s_%s_change' % (opts.app_label, opts.model_name),
None,
(quote(obj._get_pk_val()),)
)
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)
# Display a link to the admin page.
context = (capfirst(opts.verbose_name), admin_url, obj)
return format_html('{}: <a href="{}">{}</a>', *context)
else:
# 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_str(opts.verbose_name)
else:
objects_name = force_str(opts.verbose_name_plural)
context = dict(
admin_site.each_context(request),
action_name='disable_selected' if disable else 'enable_selected',
disable=disable,
title=_("Are you sure?"),
objects_name=objects_name,
deletable_objects=display,
queryset=queryset,
opts=opts,
action_checkbox_name=helpers.ACTION_CHECKBOX_NAME,
)
request.current_app = admin_site.name
template = 'admin/%s/%s/disable_selected_confirmation.html' % (app_label, opts.model_name)
return TemplateResponse(request, template, context)
disable_selected.short_description = _("Disable selected accounts")
disable_selected.url_name = 'disable'
disable_selected.tool_description = _("Disable")
enable_selected = partial(disable_selected, disable=False)
enable_selected.__name__ = 'enable_selected'
enable_selected.url_name = 'enable'
enable_selected.tool_description = _("Enable")

View File

@ -0,0 +1,415 @@
import copy
import re
from urllib.parse import parse_qsl
from django import forms
from django.apps import apps
from django.urls import re_path as 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.http import HttpResponseRedirect
from django.templatetags.static import static
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from orchestra.admin import ExtendedModelAdmin, ChangePasswordAdminMixin
from orchestra.admin.actions import SendEmail
from orchestra.admin.utils import wrap_admin_view, admin_link, set_url_query
from orchestra.contrib.services.settings import SERVICES_IGNORE_ACCOUNT_TYPE
from orchestra.core import services, accounts
from orchestra.forms import UserChangeForm
from orchestra.utils.apps import isinstalled
from .actions import (list_contacts, service_report, delete_related_services, disable_selected,
enable_selected)
from .forms import AccountCreationForm
from .models import Account
class AccountAdmin(ChangePasswordAdminMixin, auth.UserAdmin, ExtendedModelAdmin):
list_display = ('username', 'full_name', 'type', 'is_active')
list_filter = (
'type', 'is_active',
)
add_fieldsets = (
(_("User"), {
'fields': ('username', 'password1', 'password2',),
}),
(_("Personal info"), {
'fields': ('short_name', 'full_name', 'email', ('type', 'language'), 'comments'),
}),
(_("Permissions"), {
'fields': ('is_superuser',)
}),
)
fieldsets = (
(_("User"), {
'fields': ('username', 'password', 'main_systemuser_link')
}),
(_("Personal info"), {
'fields': ('short_name', 'full_name', 'email', ('type', 'language'), 'comments'),
}),
(_("Permissions"), {
'fields': ('is_superuser', 'is_active')
}),
(_("Important dates"), {
'classes': ('collapse',),
'fields': ('last_login', 'date_joined')
}),
)
search_fields = ('username', 'short_name', 'full_name')
add_form = AccountCreationForm
form = UserChangeForm
filter_horizontal = ()
change_readonly_fields = ('username', 'main_systemuser_link', 'is_active')
change_form_template = 'admin/accounts/account/change_form.html'
actions = (
disable_selected, enable_selected, delete_related_services, list_contacts, service_report,
SendEmail()
)
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:
messages.warning(request, 'This account is disabled.')
context.update({
'services': sorted(
[model._meta for model in services.get() if model is not Account],
key=lambda i: i.verbose_name_plural.lower()
),
'accounts': sorted(
[model._meta for model in accounts.get() if model is not Account],
key=lambda i: i.verbose_name_plural.lower()
)
})
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:
fields = AccountCreationForm.create_related_fields
if fields:
fieldsets = copy.deepcopy(fieldsets)
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)
form.save_related(obj)
else:
if isinstalled('orchestra.contrib.orders') and isinstalled('orchestra.contrib.services'):
if 'type' in form.changed_data:
old_type = Account.objects.get(pk=obj.pk).type
new_type = form.cleaned_data['type']
context = {
'from': old_type.lower(),
'to': new_type.lower(),
'url': reverse('admin:orders_order_changelist'),
}
msg = ''
if old_type in SERVICES_IGNORE_ACCOUNT_TYPE and new_type not in SERVICES_IGNORE_ACCOUNT_TYPE:
context['url'] += '?account=%i&ignore=1' % obj.pk
msg = _("Account type has been changed from <i>%(from)s</i> to <i>%(to)s</i>. "
"You may want to mark <a href='%(url)s'>existing ignored orders</a> as not ignored.")
elif old_type not in SERVICES_IGNORE_ACCOUNT_TYPE and new_type in SERVICES_IGNORE_ACCOUNT_TYPE:
context['url'] += '?account=%i&ignore=0' % obj.pk
msg = _("Account type has been changed from <i>%(from)s</i> to <i>%(to)s</i>. "
"You may want to ignore <a href='%(url)s'>existing not ignored orders</a>.")
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:
if obj.is_active:
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:
del actions['delete_selected']
return actions
admin.site.register(Account, AccountAdmin)
class AccountListAdmin(AccountAdmin):
""" Account list to allow account selection when creating new services """
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 = {
'url': '../?account=' + str(instance.pk),
'name': instance.username,
'plus': '<strong style="color:green; font-size:12px">+</strong>',
}
return _('<a href="%(url)s">%(plus)s Add to %(name)s</a>') % context
select_account.short_description = _("account")
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]
model = apps.get_model(app_label, model)
opts = model._meta
context = {
'title': _("Select account for adding a new %s") % (opts.verbose_name),
'original_opts': opts,
}
context.update(extra_context or {})
response = super(AccountListAdmin, self).changelist_view(request, extra_context=context)
if hasattr(response, 'context_data'):
# user has submitted a change list change, we redirect directly to the add view
# if there is only one result
query = request.GET.get('q', '')
if query:
try:
account = Account.objects.get(username=query)
except Account.DoesNotExist:
pass
else:
return HttpResponseRedirect('../?account=%i' % account.pk)
queryset = response.context_data['cl'].queryset
if len(queryset) == 1:
return HttpResponseRedirect('../?account=%i' % queryset[0].pk)
return response
class AccountAdminMixin(object):
""" Provide basic account support to ModelAdmin and AdminInline classes """
readonly_fields = ('account_link',)
filter_by_account_fields = []
change_list_template = 'admin/accounts/account/change_list.html'
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')
elif not instance.account.is_active:
msg = _("Account disabled")
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.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.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)
try:
field = form.base_fields['is_active']
except KeyError:
pass
else:
opts = self.model._meta
help_text = _(
"Designates whether this %(name)s should be treated as active. "
"Unselect this instead of deleting %(plural_name)s."
) % {
'name': opts.verbose_name,
'plural_name': opts.verbose_name_plural,
}
if obj and not obj.account.is_active:
help_text += "<br><b style='color:red;'>This user's account is dissabled</b>"
field.help_text = _(help_text)
# 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)
fields = list(fields)
if obj is not None or getattr(self, 'account_id', None):
try:
fields.remove('account')
except ValueError:
pass
else:
try:
fields.remove('account_link')
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)
if db_field.name in self.filter_by_account_fields:
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)
# Apply heuristic order by
if not formfield.queryset.query.order_by:
related_fields = [f.name for f in db_field.related_model._meta.get_fields()]
if 'name' in related_fields:
formfield.queryset = formfield.queryset.order_by('name')
elif 'username' in related_fields:
formfield.queryset = formfield.queryset.order_by('username')
elif db_field.name == 'account':
if self.account:
formfield.initial = self.account.pk
elif Account.objects.count() == 1:
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:
if account_id:
# Preselect account
set_url_query(request, 'account', account_id)
context = {
'from_account': bool(account_id),
'account': not account_id or Account.objects.get(pk=account_id),
'account_opts': Account._meta,
}
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 = {}
if account_id:
opts = self.model._meta
account = Account.objects.get(pk=account_id)
context = {
'account': not account_id or Account.objects.get(pk=account_id),
'account_opts': Account._meta,
'all_selected': True,
}
if not request.GET.get('all'):
context.update({
'all_selected': False,
'title': _("Select %s to change for %s") % (
opts.verbose_name, account.username),
})
else:
request_copy = request.GET.copy()
request_copy.pop('account')
request.GET = request_copy
context.update(extra_context or {})
return super(AccountAdminMixin, self).changelist_view(request, extra_context=context)
class SelectAccountAdminMixin(AccountAdminMixin):
""" Provides support for accounts on ModelAdmin """
def get_inline_instances(self, request, obj=None):
inlines = super(AccountAdminMixin, self).get_inline_instances(request, obj)
if self.account:
account = self.account
else:
account = Account.objects.get(pk=request.GET['account'])
[setattr(inline, 'account', account) for inline in inlines]
return inlines
def get_urls(self):
""" Hooks select account url """
urls = super(AccountAdminMixin, self).get_urls()
admin_site = self.admin_site
opts = self.model._meta
info = opts.app_label, opts.model_name
account_list = AccountListAdmin(Account, admin_site).changelist_view
select_urls = [
url("add/select-account/$",
wrap_admin_view(self, account_list),
name='%s_%s_select_account' % info),
]
return select_urls + urls
def add_view(self, request, form_url='', extra_context=None):
""" Redirects to select account view if required """
if request.user.is_superuser:
from_account_id = self.get_account_from_preserve_filters(request)
if from_account_id:
set_url_query(request, 'account', from_account_id)
account_id = request.GET.get('account')
if account_id or Account.objects.count() == 1:
kwargs = {}
if account_id:
kwargs = dict(pk=account_id)
self.account = Account.objects.get(**kwargs)
opts = self.model._meta
context = {
'title': _("Add %s for %s") % (opts.verbose_name, self.account.username),
'from_account': bool(from_account_id),
'from_select': True,
'account': self.account,
'account_opts': Account._meta,
}
context.update(extra_context or {})
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.
"""
if not change:
obj.account_id = self.account.pk
obj.save()

View File

@ -0,0 +1,32 @@
from django.utils.translation import gettext_lazy as _
from rest_framework import viewsets, exceptions
from orchestra.api import router, SetPasswordApiMixin, LogApiMixin
from .models import Account
from .serializers import AccountSerializer
class AccountApiMixin(object):
def get_queryset(self):
qs = super(AccountApiMixin, self).get_queryset()
return qs.filter(account=self.request.user.pk)
class AccountViewSet(LogApiMixin, SetPasswordApiMixin, viewsets.ModelViewSet):
queryset = Account.objects.all()
serializer_class = AccountSerializer
singleton_pk = lambda _,request: request.user.pk
def get_queryset(self):
qs = super(AccountViewSet, self).get_queryset()
return qs.filter(id=self.request.user.pk)
def destroy(self, request, pk=None):
# TODO reimplement in permissions
if not request.user.is_superuser:
raise exceptions.PermissionDenied(_("Accounts can not be deleted."))
return super(AccountViewSet, self).destroy(request, pk=pk)
router.register(r'accounts', AccountViewSet)

View File

@ -0,0 +1,18 @@
from django.apps import AppConfig
from django.db.models.signals import post_migrate
from django.utils.translation import gettext_lazy as _
from orchestra.core import services, accounts
class AccountConfig(AppConfig):
name = 'orchestra.contrib.accounts'
verbose_name = _("Accounts")
def ready(self):
from .management import create_initial_superuser
from .models import Account
services.register(Account, menu=False, dashboard=False)
accounts.register(Account, icon='Face-monkey.png')
post_migrate.connect(create_initial_superuser,
dispatch_uid="orchestra.contrib.accounts.management.createsuperuser")

View File

@ -0,0 +1,27 @@
from django.contrib.admin import SimpleListFilter
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
class IsActiveListFilter(SimpleListFilter):
title = _("is active")
parameter_name = 'active'
def lookups(self, request, model_admin):
return (
('True', _("Yes")),
('False', _("No")),
('account', _("Account disabled")),
('object', _("Object disabled")),
)
def queryset(self, request, queryset):
if self.value() == 'True':
return queryset.filter(is_active=True, account__is_active=True)
elif self.value() == 'False':
return queryset.filter(Q(is_active=False) | Q(account__is_active=False))
elif self.value() == 'account':
return queryset.filter(account__is_active=False)
elif self.value() == 'object':
return queryset.filter(is_active=False)
return queryset

View File

@ -0,0 +1,90 @@
import logging
from collections import OrderedDict
from django import forms
from django.core.exceptions import ValidationError
from django.apps import apps
from django.utils.translation import gettext_lazy as _
from orchestra.forms import UserCreationForm
from . import settings
from .models import Account
logger = logging.getLogger(__name__)
def create_account_creation_form():
fields = OrderedDict(**{
'enable_systemuser': forms.BooleanField(initial=True, required=False,
label=_("Enable systemuser"),
help_text=_("Designates whether to creates an enabled or disabled related system user. "
"Notice that a related system user will be always created."))
})
create_related = []
for model, key, kwargs, help_text in settings.ACCOUNTS_CREATE_RELATED:
try:
model = apps.get_model(model)
except LookupError:
logger.error("%s not installed." % model)
else:
field_name = 'create_%s' % model._meta.model_name
label = _("Create %s") % model._meta.verbose_name
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)
try:
account = Account(
username=cleaned_data['username'],
password=cleaned_data['password1']
)
except KeyError:
# Previous validation error
return
errors = {}
systemuser_model = Account.main_systemuser.field.related_model
if systemuser_model.objects.filter(username=account.username).exists():
errors['username'] = _("A system user with this name already exists.")
for model, key, related_kwargs, __ in create_related:
kwargs = {
key: eval(related_kwargs[key], {'account': account})
}
if model.objects.filter(**kwargs).exists():
verbose_name = model._meta.verbose_name
field_name = 'create_%s' % model._meta.model_name
errors[field_name] = ValidationError(
_("A %(type)s with this name already exists."),
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)
field_name = 'create_%s' % model._meta.model_name
if self.cleaned_data[field_name]:
kwargs = {
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)
AccountCreationForm = create_account_creation_form()

View File

@ -0,0 +1,32 @@
import sys
import textwrap
from django.contrib.auth import get_user_model, base_user
from django.core.exceptions import FieldError
from django.core.management import execute_from_command_line
from django.db import models
def create_initial_superuser(**kwargs):
if '--noinput' not in sys.argv and '--fake' not in sys.argv and '--fake-initial' not in sys.argv:
model = get_user_model()
if not model.objects.filter(is_superuser=True).exists():
sys.stdout.write(textwrap.dedent("""
It appears that you just installed Accounts application.
You can now create a superuser:
""")
)
from ..models import Account
try:
Account.systemusers.field.model.objects.filter(account_id=1).exists()
except FieldError:
# avoid creating a systemuser when systemuser table is not ready
Account.save = models.Model.save
old_init = base_user.AbstractBaseUser.__init__
def remove_is_staff(*args, **kwargs):
kwargs.pop('is_staff', None)
old_init(*args, **kwargs)
base_user.AbstractBaseUser.__init__ = remove_is_staff
manager = sys.argv[0]
execute_from_command_line(argv=[manager, 'createsuperuser'])

View File

@ -0,0 +1,207 @@
from django.contrib.auth import models as auth
from django.conf import settings as djsettings
from django.core import validators
from django.db import models
from django.db.models import signals
from django.apps import apps
from django.utils import timezone, translation
from django.utils.translation import gettext_lazy as _
#from orchestra.contrib.orchestration.middlewares import OperationsMiddleware
#from orchestra.contrib.orchestration import Operation
from orchestra import core
from orchestra.models.utils import has_db_field
from orchestra.utils.mail import send_email_template
from . import settings
class AccountManager(auth.UserManager):
def get_main(self):
return self.get(pk=settings.ACCOUNTS_MAIN_PK)
class Account(auth.AbstractBaseUser):
# Username max_length determined by LINUX system user/group lentgh: 32
username = models.CharField(_("username"), max_length=32, unique=True,
help_text=_("Required. 32 characters or fewer. Letters, digits and ./-/_ only."),
validators=[
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)
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"))
type = models.CharField(_("type"), choices=settings.ACCOUNTS_TYPES,
max_length=32, default=settings.ACCOUNTS_DEFAULT_TYPE)
language = models.CharField(_("language"), max_length=2,
choices=settings.ACCOUNTS_LANGUAGES,
default=settings.ACCOUNTS_DEFAULT_LANGUAGE)
comments = models.TextField(_("comments"), max_length=256, blank=True)
is_superuser = models.BooleanField(_("superuser status"), default=False,
help_text=_("Designates that this user has all permissions without "
"explicitly assigning them."))
is_active = models.BooleanField(_("active"), default=True,
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:
was_active = Account.objects.filter(pk=self.pk).values_list('is_active', flat=True)[0]
super(Account, self).save(*args, **kwargs)
if created:
self.main_systemuser = self.systemusers.create(
account=self, username=self.username, password=self.password,
is_active=active_systemuser)
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()
if (f.one_to_many or f.one_to_one)
and f.auto_created and not f.concrete
]
for rel in related_fields:
source = getattr(rel, 'related_model', rel.model)
if source in core.services and hasattr(source, 'active'):
for obj in getattr(self, rel.get_accessor_name()).all():
yield obj
def notify_related(self):
""" Trigger save() on related objects that depend on this account """
for obj in self.get_services_to_disable():
signals.pre_save.send(sender=type(obj), instance=obj)
signals.post_save.send(sender=type(obj), instance=obj)
# OperationsMiddleware.collect(Operation.SAVE, instance=obj, update_fields=())
def get_contacts_emails(self, usages=None):
contacts = self.contacts.all()
if usages is not None:
contactes = contacts.filter(email_usages=usages)
return contacts.values_list('email', flat=True)
def send_email(self, template, context, email_from=None, usages=None, attachments=[], html=None):
contacts = self.contacts.filter(email_usages=usages)
email_to = self.get_contacts_emails(usages)
extra_context = {
'account': self,
'email_from': email_from or djsettings.SERVER_EMAIL,
}
extra_context.update(context)
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
queries all available auth backends, but returns immediately if any
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:
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
def has_perms(self, perm_list, obj=None):
"""
Returns True if the user has each of the specified permissions. If
object is passed, it checks if the user has all required perms for this
object.
"""
for perm in perm_list:
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.
Uses pretty much the same logic as has_perm, above.
"""
# Active superusers have all permissions.
if self.is_active and self.is_superuser:
return True
def get_related_passwords(self, db_field=False):
related = [
self.main_systemuser,
]
for model, key, related_kwargs, __ in settings.ACCOUNTS_CREATE_RELATED:
if 'password' not in related_kwargs:
continue
model = apps.get_model(model)
kwargs = {
key: eval(related_kwargs[key], {'account': self})
}
try:
rel = model.objects.get(account=self, **kwargs)
except model.DoesNotExist:
continue
if db_field:
if not has_db_field(rel, 'password'):
continue
related.append(rel)
return related

View File

@ -0,0 +1,27 @@
from rest_framework import serializers
from .models import Account
class AccountSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Account
fields = (
'url', 'id', 'username', 'type', 'language', 'short_name', 'full_name', 'date_joined', 'last_login',
'is_active'
)
class AccountSerializerMixin(object):
def __init__(self, *args, **kwargs):
super(AccountSerializerMixin, self).__init__(*args, **kwargs)
self.account = self.get_account()
def get_account(self):
request = self.context.get('request')
if request:
return request.user
def create(self, validated_data):
validated_data['account'] = self.get_account()
return super(AccountSerializerMixin, self).create(validated_data)

View File

@ -0,0 +1,74 @@
from django.utils.translation import gettext_lazy as _
from orchestra.contrib.settings import Setting
from orchestra.settings import ORCHESTRA_BASE_DOMAIN
ACCOUNTS_TYPES = Setting('ACCOUNTS_TYPES',
(
('INDIVIDUAL', _("Individual")),
('ASSOCIATION', _("Association")),
('CUSTOMER', _("Customer")),
('COMPANY', _("Company")),
('PUBLICBODY', _("Public body")),
('STAFF', _("Staff")),
('FRIEND', _("Friend")),
),
validators=[Setting.validate_choices]
)
ACCOUNTS_DEFAULT_TYPE = Setting('ACCOUNTS_DEFAULT_TYPE',
'INDIVIDUAL', choices=ACCOUNTS_TYPES)
ACCOUNTS_LANGUAGES = Setting('ACCOUNTS_LANGUAGES',
(
('EN', _('English')),
),
validators=[Setting.validate_choices]
)
ACCOUNTS_DEFAULT_LANGUAGE = Setting('ACCOUNTS_DEFAULT_LANGUAGE',
'EN',
choices=ACCOUNTS_LANGUAGES
)
ACCOUNTS_SYSTEMUSER_MODEL = Setting('ACCOUNTS_SYSTEMUSER_MODEL',
'systemusers.SystemUser',
validators=[Setting.validate_model_label],
)
ACCOUNTS_MAIN_PK = Setting('ACCOUNTS_MAIN_PK',
1
)
ACCOUNTS_CREATE_RELATED = Setting('ACCOUNTS_CREATE_RELATED',
(
# <model>, <key field>, <kwargs>, <help_text>
('mailboxes.Mailbox',
'name',
{
'name': 'account.username',
'password': 'account.password',
},
_("Designates whether to creates a related mailbox with the same name and password or not."),
),
('domains.Domain',
'name',
{
'name': '"%s.{}" % account.username.replace("_", "-")'.format(ORCHESTRA_BASE_DOMAIN),
},
_("Designates whether to creates a related subdomain &lt;username&gt;.{} or not.".format(ORCHESTRA_BASE_DOMAIN)),
),
),
)
ACCOUNTS_SERVICE_REPORT_TEMPLATE = Setting('ACCOUNTS_SERVICE_REPORT_TEMPLATE',
'admin/accounts/account/service_report.html'
)

View File

@ -0,0 +1,42 @@
{% extends "orchestra/admin/change_form.html" %}
{% load i18n admin_urls static admin_modify %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
{% if from_account %}
&rsaquo; <a href="{% url 'admin:app_list' app_label=account_opts.app_label %}">{{ account_opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url account_opts|admin_urlname:'changelist' %}">{{ account_opts.verbose_name_plural|capfirst }}</a>
&rsaquo; <a href="{% url account_opts|admin_urlname:'change' account.pk|admin_urlquote %}">{{ account|truncatewords:"18" }}</a>
{% else %}
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; {% if has_change_permission %}<a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>{% else %}{{ opts.verbose_name_plural|capfirst }}{% endif %}
{% endif %}
{% if from_select %}
&rsaquo; <a href="{% url opts|admin_urlname:'select_account' %}">{% blocktrans with name=original_opts.verbose_name %}Select {{ name }} account{% endblocktrans %}</a>
{% endif %}
&rsaquo; {% if add %}{% trans 'Add' %} {{ opts.verbose_name }}{% else %}{{ original|truncatewords:"18" }}{% endif %}
</div>
{% endblock %}
{% block object-tools-items %}
{% if services %}
<li><select name="forma" onchange="location = this.options[this.selectedIndex].value;" style="margin: -3px 0 0 0;">
<option selected disabled>{% trans "Services" %}</option>
{% for service in services %}
<option value="{% url service|admin_urlname:'changelist' %}?account={{ original.pk }}">{{ service.verbose_name_plural|capfirst }}</option>
{% endfor %}
</select></li>
{% endif %}
{% if accounts %}
<li><select name="forma" onchange="location = this.options[this.selectedIndex].value;" style="margin: -3px 4px 0px 4px;">
<option selected disabled>{% trans "Accounts" %}</option>
{% for account in accounts %}
<option value="{% url account|admin_urlname:'changelist' %}?account={{ original.pk }}">{{ account.verbose_name_plural|capfirst }}</option>
{% endfor %}
</select></li>
{% endif %}
{{ block.super }}
{% endblock %}

View File

@ -0,0 +1,49 @@
{% extends "admin/change_list.html" %}
{% load i18n admin_urls admin_list %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
{% if account %}
&rsaquo; <a href="{% url 'admin:app_list' app_label=account_opts.app_label %}">{{ account_opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url account_opts|admin_urlname:'changelist' %}">{{ account_opts.verbose_name_plural|capfirst }}</a>
&rsaquo; <a href="{% url account_opts|admin_urlname:'change' account.pk|admin_urlquote %}">{{ account|truncatewords:"18" }}</a>
{% else %}
&rsaquo; <a href="{% url 'admin:app_list' app_label=cl.opts.app_label %}">{{ cl.opts.app_config.verbose_name }}</a>
{% endif %}
&rsaquo; {{ cl.opts.verbose_name_plural|capfirst }}
</div>
{% endblock %}
{% block object-tools-items %}
<li>
{% url cl.opts|admin_urlname:'add' as add_url %}
<a href="{% add_preserved_filters add_url is_popup to_field %}" class="addlink">
{% if all_selected %}
{% blocktrans with cl.opts.verbose_name as name %}Add {{ name }}{% endblocktrans %}
{% else %}
{% blocktrans with cl.opts.verbose_name as name and account|truncatewords:"18" as account %}Add {{ account }} {{ name }}{% endblocktrans %}
{% endif %}
</a>
</li>
{% endblock %}
{% block filters %}
{% if cl.has_filters %}
<div id="changelist-filter">
<h2>{% trans 'Filter' %}</h2>
{% if account %}
<h3>{% trans 'By account' %}</h3>
<ul>
<li {% if not all_selected %}class="selected"{% endif %}><a href="?account={{ account.pk }}">{{ account|truncatewords:"18" }}</a></li>
<li {% if all_selected %}class="selected"{% endif %}><a href="?account={{ account.pk }}&all=True">All</a></li>
</ul>
{% endif %}
{% for spec in cl.filter_specs %}{% admin_list_filter cl spec %}{% endfor %}
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,39 @@
{% extends "admin/delete_selected_confirmation.html" %}
{% load i18n l10n admin_urls %}
{% block content %}
{% if perms_lacking %}
<p>{% blocktrans %}Deleting the selected {{ objects_name }} would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktrans %}</p>
<ul>
{% for obj in perms_lacking %}
<li>{{ obj }}</li>
{% endfor %}
</ul>
{% elif protected %}
<p>{% blocktrans %}Deleting the selected {{ objects_name }} would require deleting the following protected related objects:{% endblocktrans %}</p>
<ul>
{% for obj in protected %}
<li>{{ obj }}</li>
{% endfor %}
</ul>
{% else %}
<p>{% blocktrans %}Are you sure you want to delete the selected {{ objects_name }}? All of the following objects and their related items will be deleted:{% endblocktrans %}</p>
{% include "admin/includes/object_delete_summary.html" %}
<h2>{% trans "Objects" %}</h2>
{% for deletable_object in deletable_objects %}
<ul>{{ deletable_object|unordered_list }}</ul>
{% endfor %}
<form action="" method="post">{% csrf_token %}
<div>
{% for obj in queryset %}
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk|unlocalize }}" />
{% endfor %}
<input type="hidden" name="action" value="delete_related_services" />
<input type="hidden" name="post" value="yes" />
<input type="submit" value="{% trans "Yes, I'm sure" %}" />
<a href="#" onclick="window.history.back(); return false;" class="button cancel-link">{% trans "No, take me back" %}</a>
</div>
</form>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,35 @@
{% extends "admin/base_site.html" %}
{% load i18n l10n admin_urls %}
{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} delete-confirmation delete-selected-confirmation{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; {% if disable%}{% blocktrans %}Disable {{ objects_name }}{% endblocktrans %}{% else %}{% blocktrans %}Enable {{ objects_name }}{% endblocktrans %}{% endif %}
</div>
{% endblock %}
{% block content %}
{% if disable%}<p>{% blocktrans %}Are you sure you want to disable selected {{ objects_name }}?{% endblocktrans %}</p>
{% else %}<p>{% blocktrans %}Are you sure you want to enable selected {{ objects_name }}?{% endblocktrans %}</p>
{% endif %}
<h2>{% trans "Objects" %}</h2>
{% for deletable_object in deletable_objects %}
<ul>{{ deletable_object|unordered_list }}</ul>
{% endfor %}
<form action="" method="post">{% csrf_token %}
<div>
{% for obj in queryset %}
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk|unlocalize }}" />
{% endfor %}
<input type="hidden" name="action" value="{{ action_name }}" />
<input type="hidden" name="post" value="yes" />
<input type="submit" value="{% trans "Yes, I'm sure" %}" />
<a href="#" onclick="window.history.back(); return false;" class="button cancel-link">{% trans "No, take me back" %}</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends 'admin/change_list.html' %}
{% load i18n admin_urls %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=cl.opts.app_label %}">{{ original_opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ original_opts.verbose_name_plural|capfirst }}</a>
&rsaquo; {% blocktrans with name=original_opts.verbose_name %}Select {{ name }} account{% endblocktrans %}
</div>
{% endblock %}

View File

@ -0,0 +1,84 @@
{% load i18n admin_urls utils %}
<html>
<head>
<title>{% block title %}Account service report{% endblock %}</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
{% block head %}{% endblock %}
<style type="text/css">
body {
max-width: 670px;
margin: 20 auto !important;
float: none !important;
font-family: Arial, 'Liberation Sans', 'DejaVu Sans', sans-serif;
font-size: 12px;
color: #444;
}
#date {
float: right;
color: rgb(102, 102, 102);
}
.account-content {
margin: 0px 0px 40px 20px;
}
.item-title {
list-style-type: none;
font-weight: bold;
color: #666;
}
.items-ul {
padding: 0px;
margin: 5px 0px 10px 20px;
}
.related {
list-style: disc;
}
hr {
margin-top: -9px;
}
a {
text-decoration: none;
color: rgb(91, 128, 178);
}
</style>
</head>
<body>
<div id="date">{% trans "Service report generated on" %} {{ date | date }}</div>
{% for account, items in accounts %}
<h3>{{ account.get_full_name }} - <a href="{{ account|admin_url }}">{{ account.username }}</a></h3>
<hr>
<div class="account-content">
{{ account.get_type_display }} {% trans "account registered on" %} {{ account.date_joined | date }}<br>
<ul class="items-ul">
<li class="item-title">{% trans 'Resources' %}</li>
{% if account.resources %}
<ul>
{% for resource in account.resources %}
<li><a href="{{ resource|admin_url }}">{{ resource.verbose_name }} {% if resource.used != None %}<span title="{% trans 'Used' %}">{{ resource.used }}</span>{% endif %}{% if resource.allocated != None %}{% if resource.used != None %} / {% endif %}<span title="{% trans 'Allocated' %}">{{ resource.allocated }}</span>{% endif %}</a> {{ resource.unit }}</li>
{% endfor %}
</ul>
{% endif %}
{% for opts, related in items %}
<li class="item-title"><a href="{% url opts|admin_urlname:'changelist' %}?account_id={{ account.pk }}">{{ opts.verbose_name_plural|capfirst }}</a></li>
<ul>
{% for obj in related %}
<li class="related"><a href="{{ obj|admin_url }}">{{ obj }}</a>
{% if not obj|isactive %} ({% trans "disabled" %}){% endif %}
{{ obj.get_description|capfirst }}
{% if obj.resources %}
<ul>
{% for resource in obj.resources %}
<li><a href="{{ resource|admin_url }}">{{ resource.verbose_name }} {% if resource.used != None %}<span title="{% trans 'Used' %}">{{ resource.used }}</span>{% endif %}{% if resource.allocated != None %}{% if resource.used != None %} / {% endif %}<span title="{% trans 'Allocated' %}">{{ resource.allocated }}</span>{% endif %}</a> {{ resource.unit }}</li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
</ul>
{% endfor %}
</ul>
</div>
{% endfor %}
</body>
</html>

View File

@ -0,0 +1 @@
default_app_config = 'orchestra.contrib.bills.apps.BillsConfig'

View File

@ -0,0 +1,377 @@
import io
import zipfile
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.db import transaction
from django.forms.models import modelformset_factory
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import render, redirect
from django.utils import translation, timezone
from django.utils.safestring import mark_safe
from django.utils.translation import ngettext, gettext_lazy as _
from orchestra.admin.decorators import action_with_confirmation
from orchestra.admin.forms import AdminFormSet
from orchestra.admin.utils import get_object_from_url, change_url
from . import settings
from .forms import SelectSourceForm
from .helpers import validate_contact, set_context_emails
from .models import Bill, BillLine
def view_bill(modeladmin, request, queryset):
bill = queryset.get()
if not validate_contact(request, bill):
return
html = bill.html or bill.render()
return HttpResponse(html)
view_bill.tool_description = _("View")
view_bill.url_name = 'view'
view_bill.hidden = True
@transaction.atomic
def close_bills(modeladmin, request, queryset, action='close_bills'):
# Validate bills
for bill in queryset:
if not validate_contact(request, bill):
return False
if not bill.is_open:
messages.warning(request, _("Selected bills should be in open state"))
return False
SelectSourceFormSet = modelformset_factory(modeladmin.model, form=SelectSourceForm, formset=AdminFormSet, extra=0)
formset = SelectSourceFormSet(queryset=queryset)
if request.POST.get('post') == 'generic_confirmation':
formset = SelectSourceFormSet(request.POST, request.FILES, queryset=queryset)
if formset.is_valid():
transactions = []
for form in formset.forms:
source = form.cleaned_data['source']
transaction = form.instance.close(payment=source)
if transaction:
transactions.append(transaction)
for bill in queryset:
modeladmin.log_change(request, bill, 'Closed')
messages.success(request, _("Selected bills have been closed"))
if transactions:
num = len(transactions)
if num == 1:
url = change_url(transactions[0])
else:
url = reverse('admin:payments_transaction_changelist')
url += 'id__in=%s' % ','.join([str(t.id) for t in transactions])
context = {
'url': url,
'num': num,
}
message = ngettext(
_('<a href="%(url)s">One related transaction</a> has been created') % context,
_('<a href="%(url)s">%(num)i related transactions</a> have been created') % context,
num)
messages.success(request, mark_safe(message))
return
opts = modeladmin.model._meta
context = {
'title': _("Are you sure about closing the following bills?"),
'content_message': _("Once a bill is closed it can not be further modified.</p>"
"<p>Please select a payment source for the selected bills"),
'action_name': 'Close bills',
'action_value': action,
'display_objects': [],
'queryset': queryset,
'opts': opts,
'app_label': opts.app_label,
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
'formset': formset,
'obj': get_object_from_url(modeladmin, request),
}
template = 'admin/orchestra/generic_confirmation.html'
if action == 'close_send_download_bills':
template = 'admin/bills/bill/close_send_download_bills.html'
return render(request, template, context)
close_bills.tool_description = _("Close")
close_bills.url_name = 'close'
def send_bills_action(modeladmin, request, queryset):
"""
raw function without confirmation
enables reuse on close_send_download_bills because of generic_confirmation.action_view
"""
for bill in queryset:
if not validate_contact(request, bill):
return False
num = 0
for bill in queryset:
bill.send()
modeladmin.log_change(request, bill, 'Sent')
num += 1
messages.success(request, ngettext(
_("One bill has been sent."),
_("%i bills have been sent.") % num,
num))
@action_with_confirmation(extra_context=set_context_emails)
def send_bills(modeladmin, request, queryset):
return send_bills_action(modeladmin, request, queryset)
send_bills.verbose_name = lambda bill: _("Resend" if getattr(bill, 'is_sent', False) else "Send")
send_bills.url_name = 'send'
def download_bills(modeladmin, request, queryset):
for bill in queryset:
if not validate_contact(request, bill):
return False
if len(queryset) > 1:
bytesio = io.BytesIO()
archive = zipfile.ZipFile(bytesio, 'w')
for bill in queryset:
pdf = bill.as_pdf()
archive.writestr('%s.pdf' % bill.number, pdf)
archive.close()
response = HttpResponse(bytesio.getvalue(), content_type='application/zip')
response['Content-Disposition'] = 'attachment; filename="orchestra-bills.zip"'
return response
bill = queryset[0]
pdf = bill.as_pdf()
response = HttpResponse(pdf, content_type='application/pdf')
response['Content-Disposition'] = 'attachment; filename="%s.pdf"' % bill.number
return response
download_bills.tool_description = _("Download")
download_bills.url_name = 'download'
def close_send_download_bills(modeladmin, request, queryset):
response = close_bills(modeladmin, request, queryset, action='close_send_download_bills')
if response is False:
# Not a valid contact or closed bill
return
if request.POST.get('post') == 'generic_confirmation':
response = send_bills_action(modeladmin, request, queryset)
if response is False:
# Not a valid contact
return
return download_bills(modeladmin, request, queryset)
return response
close_send_download_bills.tool_description = _("C.S.D.")
close_send_download_bills.url_name = 'close-send-download'
close_send_download_bills.help_text = _("Close, send and download bills in one shot.")
def manage_lines(modeladmin, request, queryset):
url = reverse('admin:bills_bill_manage_lines')
url += '?ids=%s' % ','.join(map(str, queryset.values_list('id', flat=True)))
return redirect(url)
@action_with_confirmation()
def undo_billing(modeladmin, request, queryset):
group = {}
for line in queryset.select_related('order'):
if line.order_id:
try:
group[line.order].append(line)
except KeyError:
group[line.order] = [line]
# Validate
for order, lines in group.items():
prev = None
billed_on = date.max
billed_until = date.max
for line in sorted(lines, key=lambda l: l.start_on):
if billed_on is not None:
if line.order_billed_on is None:
billed_on = line.order_billed_on
else:
billed_on = min(billed_on, line.order_billed_on)
if billed_until is not None:
if line.order_billed_until is None:
billed_until = line.order_billed_until
else:
billed_until = min(billed_until, line.order_billed_until)
if prev:
if line.start_on != prev:
messages.error(request, "Line dates doesn't match.")
return
else:
# First iteration
if order.billed_on < line.start_on:
messages.error(request, "Billed on is smaller than first line start_on.")
return
prev = line.end_on
nlines += 1
if not prev:
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():
for line in lines:
nlines += 1
line.delete()
# 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
})
def move_lines(modeladmin, request, queryset, action=None):
# Validate
target = request.GET.get('target')
if not target:
# select target
context = {}
return render(request, 'admin/orchestra/generic_confirmation.html', context)
target = Bill.objects.get(pk=int(pk))
if request.POST.get('post') == 'generic_confirmation':
for line in queryset:
line.bill = target
line.save(update_fields=['bill'])
# TODO bill history update
messages.success(request, _("Lines moved"))
# Final confirmation
return render(request, 'admin/orchestra/generic_confirmation.html', context)
def copy_lines(modeladmin, request, queryset):
# same as move, but changing action behaviour
return move_lines(modeladmin, request, queryset)
def validate_amend_bills(bills):
for bill in bills:
if bill.is_open:
raise ValidationError(_("Selected bills should be in closed state"))
if bill.type not in bill.AMEND_MAP:
raise ValidationError(_("%s can not be amended.") % bill.get_type_display())
@action_with_confirmation(validator=validate_amend_bills)
def amend_bills(modeladmin, request, queryset):
amend_ids = []
for bill in queryset:
with translation.override(bill.account.language):
amend_type = bill.get_amend_type()
context = {
'related_type': _(bill.get_type_display()),
'number': bill.number,
'date': bill.created_on,
}
amend = Bill.objects.create(
account=bill.account,
type=amend_type,
amend_of=bill,
)
context['type'] = _(amend.get_type_display())
amend.comments = _("%(type)s of %(related_type)s %(number)s and creation date %(date)s") % context
amend.save(update_fields=('comments',))
for tax, subtotals in bill.compute_subtotals().items():
context['tax'] = tax
line = BillLine.objects.create(
bill=amend,
start_on=bill.created_on,
description=_("%(related_type)s %(number)s subtotal for tax %(tax)s%%") % context,
subtotal=subtotals[0],
tax=tax
)
amend_ids.append(amend.pk)
modeladmin.log_change(request, bill, 'Amended, amend id is %i' % amend.id)
num = len(amend_ids)
if num == 1:
amend_url = reverse('admin:bills_bill_change', args=amend_ids)
else:
amend_url = reverse('admin:bills_bill_changelist')
amend_url += '?id=%s' % ','.join(map(str, amend_ids))
context = {
'url': amend_url,
'num': num,
}
messages.success(request, mark_safe(ngettext(
_('<a href="%(url)s">One amendment bill</a> have been generated.') % context,
_('<a href="%(url)s">%(num)i amendment bills</a> have been generated.') % context,
num
)))
amend_bills.tool_description = _("Amend")
amend_bills.url_name = 'amend'
def bill_report(modeladmin, request, queryset):
subtotals = {}
total = 0
for bill in queryset:
for tax, subtotal in bill.compute_subtotals().items():
try:
subtotals[tax][0] += subtotal[0]
except KeyError:
subtotals[tax] = subtotal
else:
subtotals[tax][1] += subtotal[1]
total += bill.compute_total()
context = {
'subtotals': subtotals,
'total': total,
'bills': queryset,
'currency': settings.BILLS_CURRENCY,
}
return render(request, 'admin/bills/bill/report.html', context)
def service_report(modeladmin, request, queryset):
services = {}
totals = [0, 0, 0, 0, 0]
now = timezone.now().date()
if queryset.model == Bill:
queryset = BillLine.objects.filter(bill_id__in=queryset.values_list('id', flat=True))
# Filter amends
queryset = queryset.filter(bill__amend_of__isnull=True)
for line in queryset.select_related('order__service').prefetch_related('sublines'):
order, service = None, None
if line.order_id:
order = line.order
service = order.service
name = service.description
active, cancelled = (1, 0) if not order.cancelled_on or order.cancelled_on > now else (0, 1)
nominal_price = order.service.nominal_price
else:
name = '*%s' % line.description
active = 1
cancelled = 0
nominal_price = 0
try:
info = services[name]
except KeyError:
info = [active, cancelled, nominal_price, line.quantity or 1, line.compute_total()]
services[name] = info
else:
info[0] += active
info[1] += cancelled
info[3] += line.quantity or 1
info[4] += line.compute_total()
totals[0] += active
totals[1] += cancelled
totals[2] += nominal_price
totals[3] += line.quantity or 1
totals[4] += line.compute_total()
context = {
'services': sorted(services.items(), key=lambda n: -n[1][4]),
'totals': totals,
}
return render(request, 'admin/bills/billline/report.html', context)
def list_bills(modeladmin, request, queryset):
ids = ','.join(map(str, queryset.values_list('bill_id', flat=True).distinct()))
return HttpResponseRedirect('../bill/?id__in=%s' % ids)

View File

@ -0,0 +1,493 @@
from django import forms
from django.urls import re_path as url
from django.contrib import admin, messages
from django.contrib.admin.utils import unquote
from django.urls import reverse
from django.db import models
from django.db.models import F, Sum, Prefetch
from django.db.models.functions import Coalesce
from django.templatetags.static import static
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django.shortcuts import redirect
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 . import settings, actions
from .filters import (BillTypeListFilter, HasBillContactListFilter, TotalListFilter,
PaymentStateListFilter, AmendedListFilter)
from .models import (Bill, Invoice, AmendmentInvoice, AbonoInvoice, Fee, AmendmentFee, ProForma, BillLine,
BillSubline, BillContact)
PAYMENT_STATE_COLORS = {
Bill.OPEN: 'grey',
Bill.CREATED: 'magenta',
Bill.PROCESSED: 'darkorange',
Bill.AMENDED: 'blue',
Bill.PAID: 'green',
Bill.EXECUTED: 'olive',
Bill.BAD_DEBT: 'red',
Bill.INCOMPLETE: 'red',
}
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
return super().has_delete_permission(request, obj)
class BillLineInline(admin.TabularInline):
model = BillLine
fields = (
'description', 'order_link', 'start_on', 'end_on', 'rate', 'quantity', 'tax',
'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()
sublines = line.sublines.all()
url = change_url(line)
if sublines:
content = '\n'.join(['%s: %s' % (sub.description, sub.total) for sub in sublines])
img = static('admin/img/icon-alert.svg')
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")
def formfield_for_dbfield(self, db_field, **kwargs):
""" Make value input widget bigger """
if db_field.name == 'description':
kwargs['widget'] = forms.TextInput(attrs={'size':'50'})
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')
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)
return '<br>'.join(descriptions)
display_description.short_description = _("Description")
@mark_safe
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")
def display_total(self, line):
if line.pk:
return line.compute_total()
display_total.short_description = _("Total")
def has_add_permission(self, request, obj):
return False
class BillLineAdmin(admin.ModelAdmin):
list_display = (
'description', 'bill_link', 'display_is_open', 'account_link', 'rate', 'quantity',
'tax', 'subtotal', 'display_sublinetotal', 'display_total'
)
actions = (
actions.undo_billing, actions.move_lines, actions.copy_lines, actions.service_report,
actions.list_bills,
)
fieldsets = (
(None, {
'fields': ('bill_link', 'description', 'tax', 'start_on', 'end_on', 'amended_line_link')
}),
(_("Totals"), {
'fields': ('rate', ('quantity', 'verbose_quantity'), 'subtotal', 'display_sublinetotal',
'display_total'),
}),
(_("Order"), {
'fields': ('order_link', 'order_billed_on', 'order_billed_until',)
}),
)
readonly_fields = (
'bill_link', 'order_link', 'amended_line_link', 'display_sublinetotal', 'display_total'
)
list_filter = ('tax', 'bill__is_open', 'order__service')
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:
return list(fields) + [
'description', 'tax', 'start_on', 'end_on', 'rate', 'quantity', 'verbose_quantity',
'subtotal', 'order_billed_on', 'order_billed_until'
]
return fields
def get_queryset(self, request):
qs = super().get_queryset(request)
qs = qs.annotate(
subline_total=Sum('sublines__total'),
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
return super().has_delete_permission(request, obj)
class BillLineManagerAdmin(BillLineAdmin):
def get_queryset(self, request):
qset = super().get_queryset(request)
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)
if bill_ids:
bill_ids = bill_ids[0]
request.GET = GET_copy
bill_ids = list(map(int, bill_ids.split(',')))
else:
messages.error(request, _("No bills selected."))
return redirect('..')
self.bill_ids = bill_ids
bill = None
if len(bill_ids) == 1:
bill_url = reverse('admin:bills_bill_change', args=(bill_ids[0],))
bill = Bill.objects.get(pk=bill_ids[0])
bill_link = '<a href="%s">%s</a>' % (bill_url, bill.number)
title = mark_safe(_("Manage %s bill lines") % bill_link)
if not bill.is_open:
messages.warning(request, _("Bill not in open state."))
else:
if Bill.objects.filter(id__in=bill_ids, is_open=False).exists():
messages.warning(request, _("Not all bills are in open state."))
title = _("Manage bill lines of multiple bills")
context = {
'title': title,
'bill': bill,
}
context.update(extra_context or {})
return super().changelist_view(request, context)
class BillAdminMixin(AccountAdminMixin):
@mark_safe
def display_total_with_subtotals(self, bill):
if bill.pk:
currency = settings.BILLS_CURRENCY.lower()
subtotals = []
for tax, subtotal in bill.compute_subtotals().items():
subtotals.append(_("Subtotal %s%% VAT %s &%s;") % (tax, subtotal[0], currency))
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.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
if bill.get_type() == bill.PROFORMA:
return '<span title="Pro forma">---</span>'
transactions = bill.transactions.all()
if len(transactions) == 1:
args = (transactions[0].pk,)
view = 'admin:%s_%s_change' % (t_opts.app_label, t_opts.model_name)
url = reverse(view, args=args)
else:
url = reverse('admin:%s_%s_changelist' % (t_opts.app_label, t_opts.model_name))
url += '?bill=%i' % bill.pk
state = bill.get_payment_state_display().upper()
title = ''
if bill.closed_amends:
state = '<strike>%s*</strike>' % state
title = _("This bill has been amended, this value may not be valid.")
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.short_description = _("Payment")
def get_queryset(self, request):
qs = super().get_queryset(request)
qs = qs.annotate(
models.Count('lines'),
# FIXME https://code.djangoproject.com/ticket/10060
approx_total=Coalesce(Sum(
(F('lines__subtotal') + Coalesce('lines__sublines__total', 0)) * (1+F('lines__tax')/100),
), 0),
)
qs = qs.prefetch_related(
Prefetch('amends', queryset=Bill.objects.filter(is_open=False), to_attr='closed_amends')
)
return qs.defer('html')
class AmendInline(BillAdminMixin, admin.TabularInline):
model = Bill
fields = (
'self_link', 'type', 'display_total_with_subtotals', 'display_payment_state', 'is_open',
'is_sent'
)
readonly_fields = fields
verbose_name_plural = _("Amends")
can_delete = False
extra = 0
self_link = admin_link('__str__')
def has_add_permission(self, *args, **kwargs):
return False
class BillAdmin(BillAdminMixin, ExtendedModelAdmin):
list_display = (
'number', 'type_link', 'account_link', 'closed_on_display', 'updated_on_display',
'num_lines', 'display_total', 'display_payment_state', 'is_sent'
)
list_filter = (
BillTypeListFilter, 'is_open', 'is_sent', TotalListFilter, PaymentStateListFilter,
AmendedListFilter, 'account__is_active',
)
add_fields = ('account', 'type', 'amend_of', 'is_open', 'due_on', 'comments')
change_list_template = 'admin/bills/bill/change_list.html'
fieldsets = (
(None, {
'fields': ['number', 'type', (), 'account_link', 'display_total_with_subtotals',
'display_payment_state', 'is_sent', 'comments'],
}),
(_("Dates"), {
'classes': ('collapse',),
'fields': ('created_on_display', 'closed_on_display', 'updated_on_display',
'due_on'),
}),
(_("Raw"), {
'classes': ('collapse',),
'fields': ('html',),
}),
)
list_prefetch_related = ('transactions', 'lines__sublines')
search_fields = ('number', 'account__username', 'comments')
change_view_actions = [
actions.manage_lines, actions.view_bill, actions.download_bills, actions.send_bills,
actions.close_bills, actions.amend_bills, actions.close_send_download_bills,
]
actions = [
actions.manage_lines, actions.download_bills, actions.close_bills, actions.send_bills,
actions.amend_bills, actions.bill_report, actions.service_report,
actions.close_send_download_bills, list_accounts,
]
change_readonly_fields = ('account_link', 'type', 'is_open', 'amend_of_link')
readonly_fields = (
'number', 'display_total', 'is_sent', 'display_payment_state', 'created_on_display',
'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():
# url = reverse('admin:bills_bill_change', args=(amend.id,))
# links.append('<a href="{url}">{num}</a>'.format(url=url, num=amend.number))
# 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)
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())
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()
admin_site = self.admin_site
extra_urls = [
url("^manage-lines/$",
admin_site.admin_view(BillLineManagerAdmin(BillLine, admin_site).changelist_view),
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:
# Switches between amend_of_link and amend_links fields
fields = fieldsets[0][1]['fields']
if obj.amend_of_id:
fields[2] = 'amend_of_link'
else:
fields[2] = ()
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 = []
if obj:
if not obj.is_open:
exclude += ['close_bills', 'close_send_download_bills']
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:
if obj.amends.all():
cls.inlines = [AmendInline, ClosedBillLineInline]
else:
cls.inlines = [ClosedBillLineInline]
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':
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 4})
elif db_field.name == 'html':
kwargs['widget'] = forms.Textarea(attrs={'cols': 150, 'rows': 20})
formfield = super().formfield_for_dbfield(db_field, **kwargs)
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))
actions.validate_contact(request, bill, error=False)
return super().change_view(request, object_id, **kwargs)
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)
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':
kwargs['widget'] = forms.TextInput(attrs={'size':'90'})
if db_field.name == 'address':
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 2})
if db_field.name == 'email_usage':
kwargs['widget'] = PaddingCheckboxSelectMultiple(45)
return super().formfield_for_dbfield(db_field, **kwargs)
def has_bill_contact(account):
return hasattr(account, 'billcontact')
has_bill_contact.boolean = True
has_bill_contact.admin_order_field = 'billcontact'
insertattr(AccountAdmin, 'inlines', BillContactInline)
insertattr(AccountAdmin, 'list_display', has_bill_contact)
insertattr(AccountAdmin, 'list_filter', HasBillContactListFilter)
insertattr(AccountAdmin, 'list_select_related', 'billcontact')

View File

@ -0,0 +1,29 @@
from django.http import HttpResponse
from rest_framework import viewsets
from rest_framework.decorators import action
from orchestra.api import router, LogApiMixin
from orchestra.contrib.accounts.api import AccountApiMixin
from orchestra.utils.html import html_to_pdf
from .models import Bill
from .serializers import BillSerializer
class BillViewSet(LogApiMixin, AccountApiMixin, viewsets.ModelViewSet):
queryset = Bill.objects.all()
serializer_class = BillSerializer
@action(detail=True, methods=['get'])
def document(self, request, pk):
bill = self.get_object()
content_type = request.META.get('HTTP_ACCEPT')
if content_type == 'application/pdf':
pdf = html_to_pdf(bill.html or bill.render())
return HttpResponse(pdf, content_type='application/pdf')
else:
return HttpResponse(bill.html or bill.render())
router.register('bills', BillViewSet)

View File

@ -0,0 +1,12 @@
from django.apps import AppConfig
from orchestra.core import accounts
class BillsConfig(AppConfig):
name = 'orchestra.contrib.bills'
verbose_name = 'Bills'
def ready(self):
from .models import Bill
accounts.register(Bill, icon='invoice.png')

View File

@ -0,0 +1,160 @@
from django.contrib.admin import SimpleListFilter
from django.urls import reverse
from django.db.models import Q
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from . models import Bill
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")),
('invoice', _("Invoice")),
('fee', _("Fee")),
('proforma', _("Pro-forma")),
('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:
yield {
'selected': self.value() == lookup,
'query_string': reverse('admin:bills_%s_changelist' % lookup) + '?%s' % query,
'display': title,
}
class TotalListFilter(SimpleListFilter):
title = _("total")
parameter_name = 'total'
def lookups(self, request, model_admin):
return (
('gt', mark_safe("total &gt; 0")),
('lt', mark_safe("total &lt; 0")),
('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)
elif self.value() == 'eq':
return queryset.filter(approx_total=0)
elif self.value() == 'lt':
return queryset.filter(approx_total__lt=0)
elif self.value() == 'ne':
return queryset.exclude(approx_total=0)
return queryset
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)
elif self.value() == 'False':
return queryset.filter(billcontact__isnull=True)
class PaymentStateListFilter(SimpleListFilter):
title = _("payment state")
parameter_name = 'payment_state'
def lookups(self, request, model_admin):
return (
('OPEN', _("Open")),
('PAID', _("Paid")),
('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
if self.value() == 'OPEN':
return queryset.filter(Q(is_open=True)|Q(type=queryset.model.PROFORMA))
elif self.value() == 'PAID':
zeros = queryset.filter(approx_total=0, approx_total__isnull=True)
zeros = zeros.values_list('id', flat=True)
amounts = Transaction.objects.exclude(bill_id__in=zeros).secured().group_by('bill_id')
paid = []
relevant = queryset.exclude(approx_total=0, approx_total__isnull=True, is_open=True)
for bill_id, total in relevant.values_list('id', 'approx_total'):
try:
amount = sum([t.amount for t in amounts[bill_id]])
except KeyError:
pass
else:
if abs(total) <= abs(amount):
paid.append(bill_id)
return queryset.filter(
Q(approx_total=0) |
Q(approx_total__isnull=True) |
Q(id__in=paid)
).exclude(is_open=True)
elif self.value() == 'PENDING':
has_transaction = queryset.exclude(transactions__isnull=True)
non_rejected = has_transaction.exclude(transactions__state=Transaction.REJECTED)
paid = non_rejected.exclude(transactions__state=Transaction.SECURED)
paid = paid.values_list('id', flat=True).distinct()
return queryset.filter(pk__in=paid)
elif self.value() == 'BAD_DEBT':
closed = queryset.filter(is_open=False).exclude(approx_total=0)
return closed.filter(
Q(transactions__state=Transaction.REJECTED) |
Q(transactions__isnull=True)
)
class AmendedListFilter(SimpleListFilter):
title = _("amended")
parameter_name = 'amended'
def lookups(self, request, model_admin):
return (
('3', _("Closed amends")),
('2', _("Open amends")),
('1', _("Any amends")),
('0', _("No amends")),
)
def queryset(self, request, queryset):
if self.value() is None:
return queryset
amended = queryset.filter(amends__isnull=False)
if self.value() == '1':
return amended.distinct()
elif self.value() == '2':
return amended.filter(amends__is_open=True).distinct()
elif self.value() == '3':
return amended.filter(amends__is_open=False).distinct()
elif self.value() == '0':
return queryset.filter(amends__isnull=True).distinct()

View File

@ -0,0 +1,49 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from orchestra.admin.utils import admin_link
from orchestra.forms import SpanWidget
class SelectSourceForm(forms.ModelForm):
bill_link = forms.CharField(label=_("Number"), required=False, widget=SpanWidget)
account_link = forms.CharField(label=_("Account"), required=False, widget=SpanWidget)
show_total = forms.CharField(label=_("Total"), required=False, widget=SpanWidget)
display_type = forms.CharField(label=_("Type"), required=False, widget=SpanWidget)
source = forms.ChoiceField(label=_("Source"), required=False)
class Meta:
fields = (
'bill_link', 'display_type', 'account_link', 'show_total', 'source'
)
def __init__(self, *args, **kwargs):
super(SelectSourceForm, self).__init__(*args, **kwargs)
bill = kwargs.get('instance')
if bill:
total = bill.compute_total()
sources = bill.account.paymentsources.filter(is_active=True)
recharge = bool(total < 0)
choices = [(None, '-----------')]
for source in sources:
if not recharge or source.method_class().allow_recharge:
choices.append((source.pk, str(source)))
self.fields['source'].choices = choices
self.fields['source'].initial = choices[-1][0]
self.fields['show_total'].widget.display = total
self.fields['bill_link'].widget.display = admin_link('__str__')(bill)
self.fields['display_type'].widget.display = bill.get_type_display()
self.fields['account_link'].widget.display = admin_link('account')(bill)
def clean_source(self):
source_id = self.cleaned_data['source']
if not source_id:
return None
source_model = self.instance.account.paymentsources.model
return source_model.objects.get(id=source_id)
def has_changed(self):
return False
def save(self, commit=True):
pass

View File

@ -0,0 +1,44 @@
from django.contrib import messages
from django.urls import reverse
from django.utils.encoding import force_str
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.text import capfirst
from django.utils.translation import gettext_lazy as _
from orchestra.admin.utils import change_url
def validate_contact(request, bill, error=True):
""" checks if all the preconditions for bill generation are met """
msg = _('{relation} account "{account}" does not have a declared invoice contact. '
'You should <a href="{url}#invoicecontact-group">provide one</a>')
valid = True
send = messages.error if error else messages.warning
if not hasattr(bill.account, 'billcontact'):
account = force_str(bill.account)
url = reverse('admin:accounts_account_change', args=(bill.account_id,))
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()
if not hasattr(main, 'billcontact'):
account = force_str(main)
url = reverse('admin:accounts_account_change', args=(main.id,))
message = msg.format(relation=_("Main"), account=account, url=url)
send(request, mark_safe(message))
valid = False
return valid
def set_context_emails(modeladmin, request, queryset):
opts = modeladmin.model._meta
bills = []
for bill in queryset:
emails = ', '.join(bill.get_billing_contact_emails())
bills.append(format_html('{0}: <a href="{1}">{2}</a> <i>{3}</i>',
capfirst(opts.verbose_name), change_url(bill), bill, emails)
)
return {
'display_objects': bills
}

View File

@ -0,0 +1,749 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2019-12-20 11:56+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: actions.py:33
msgid "View"
msgstr "Vista"
#: actions.py:45
msgid "Selected bills should be in open state"
msgstr "Les factures seleccionades han d'estar en estat obert"
#: actions.py:60
msgid "Selected bills have been closed"
msgstr "Les factures seleccionades han estat tancades"
#: actions.py:73
#, python-format
msgid "<a href=\"%(url)s\">One related transaction</a> has been created"
msgstr "S'ha creat una <a href=\"%(url)s\">transacció</a>"
#: actions.py:74
#, python-format
msgid "<a href=\"%(url)s\">%(num)i related transactions</a> have been created"
msgstr "S'han creat les <a href=\"%(url)s\">%(num)i següents transaccions</a>"
#: actions.py:80
msgid "Are you sure about closing the following bills?"
msgstr "Estàs a punt de tancar les següents factures, estàs segur?"
#: actions.py:81
msgid ""
"Once a bill is closed it can not be further modified.</p><p>Please select a "
"payment source for the selected bills"
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
msgid "Close"
msgstr "Tanca"
#: actions.py:115
msgid "One bill has been sent."
msgstr "S'ha creat una factura"
#: actions.py:116
#, python-format
msgid "%i bills have been sent."
msgstr "S'han enviat %i factures."
#: actions.py:123
msgid "Resend"
msgstr "Reenviat"
#: actions.py:146
msgid "Download"
msgstr "Descarrega"
#: actions.py:162
msgid "C.S.D."
msgstr ""
#: actions.py:164
msgid "Close, send and download bills in one shot."
msgstr ""
#: actions.py:225
#, python-format
msgid "%(norders)s orders and %(nlines)s lines undoed."
msgstr "%(norders)s ordres i %(nlines)s línies desfetes."
#: actions.py:244
msgid "Lines moved"
msgstr "Línies mogudes"
#: actions.py:257
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
#, 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
#, 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
#, 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
#, 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
msgid "Amend"
msgstr ""
#: admin.py:80 admin.py:126 admin.py:180 forms.py:11
#: templates/admin/bills/bill/report.html:43
#: templates/admin/bills/bill/report.html:70
msgid "Total"
msgstr "Total"
#: admin.py:112
msgid "Description"
msgstr "Descripció"
#: admin.py:120
msgid "Subtotal"
msgstr "Subtotal"
#: admin.py:146
#, fuzzy
#| msgid "Total"
msgid "Totals"
msgstr "Total"
#: admin.py:150
msgid "Order"
msgstr ""
#: admin.py:169
msgid "Is open"
msgstr "És oberta"
#: admin.py:175
#, fuzzy
#| msgid "Subline"
msgid "Sublines"
msgstr "Sublínia"
#: admin.py:221
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"
msgstr "Gestiona %s línies de factura."
#: admin.py:231
msgid "Bill not in open state."
msgstr "La factura no està en estat obert"
#: admin.py:234
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"
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
msgid "Dates"
msgstr ""
#: admin.py:335
msgid "Raw"
msgstr "Raw"
#: admin.py:358 models.py:75
msgid "Created"
msgstr "Creada"
#: admin.py:359
#, fuzzy
#| msgid "Close"
msgid "Closed"
msgstr "Tanca"
#: admin.py:360
#, fuzzy
#| msgid "updated on"
msgid "Updated"
msgstr "actualitzada el"
#: admin.py:375
msgid "lines"
msgstr "línies"
#: admin.py:389 models.py:108 models.py:501
msgid "type"
msgstr "tipus"
#: filters.py:21
msgid "All"
msgstr "Tot"
#: filters.py:22 models.py:91
msgid "Invoice"
msgstr "Factura"
#: filters.py:23 models.py:93
msgid "Fee"
msgstr "Quota de soci"
#: filters.py:24
msgid "Pro-forma"
msgstr "Pro-forma"
#: filters.py:25
msgid "Amendment fee"
msgstr "Rectificació de quota de soci"
#: filters.py:26 models.py:92
msgid "Amendment invoice"
msgstr "Factura rectificativa"
#: filters.py:71
msgid "has bill contact"
msgstr "té contacte de facturació"
#: filters.py:76
msgid "Yes"
msgstr "Si"
#: filters.py:77
msgid "No"
msgstr "No"
#: filters.py:88
msgid "payment state"
msgstr "Pagament"
#: filters.py:93 models.py:74
msgid "Open"
msgstr ""
#: filters.py:94 models.py:78
msgid "Paid"
msgstr "Pagat"
#: filters.py:95
msgid "Pending"
msgstr "Pendent"
#: filters.py:96 models.py:81
msgid "Bad debt"
msgstr "Incobrable"
#: filters.py:138
#, fuzzy
#| msgid "amended line"
msgid "amended"
msgstr "línia rectificada"
#: filters.py:143
#, 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:145
#, fuzzy
#| msgid "amended line"
msgid "Any amends"
msgstr "línia rectificada"
#: filters.py:146
msgid "No amends"
msgstr ""
#: forms.py:9 templates/admin/bills/bill/report.html:64
msgid "Number"
msgstr "Número"
#: forms.py:10
msgid "Account"
msgstr "Compte"
#: forms.py:12
msgid "Type"
msgstr "Tipus"
#: forms.py:13
msgid "Source"
msgstr "Font"
#: helpers.py:14
msgid ""
"{relation} account \"{account}\" does not have a declared invoice contact. "
"You should <a href=\"{url}#invoicecontact-group\">provide one</a>"
msgstr ""
"{relation} compte \"{account}\" no te un contacte de facturació. Hauries de "
"<a href=\"{url}#invoicecontact-group\">proporcionar un</a>"
#: helpers.py:21
msgid "Related"
msgstr "Relacionat"
#: helpers.py:28
msgid "Main"
msgstr "Principal"
#: models.py:26 models.py:104
msgid "account"
msgstr "compte"
#: models.py:28
msgid "name"
msgstr "nom"
#: models.py:29
msgid "Account full name will be used when left blank."
msgstr "S'emprarà el nom complet del compte quan es deixi en blanc."
#: models.py:30
msgid "address"
msgstr "adreça"
#: models.py:31
msgid "city"
msgstr "ciutat"
#: models.py:33
msgid "zip code"
msgstr "codi postal"
#: models.py:34
msgid "Enter a valid zipcode."
msgstr "Introdueix un codi postal vàlid."
#: models.py:35
msgid "country"
msgstr "país"
#: models.py:38 templates/admin/bills/bill/report.html:65
msgid "VAT number"
msgstr "NIF"
#: models.py:76
msgid "Processed"
msgstr ""
#: models.py:77
#, fuzzy
#| msgid "amended line"
msgid "Amended"
msgstr "línia rectificada"
#: models.py:79
msgid "Incomplete"
msgstr ""
#: models.py:80
msgid "Executed"
msgstr ""
#: models.py:94
msgid "Amendment Fee"
msgstr "Rectificació de quota de soci"
#: models.py:95
#, fuzzy
#| msgid "Invoice"
msgid "Abono Invoice"
msgstr "Abonament"
#: models.py:96
msgid "Pro forma"
msgstr "Pro forma"
#: models.py:103
msgid "number"
msgstr "número"
#: models.py:106
#, fuzzy
#| msgid "amended line"
msgid "amend of"
msgstr "línia rectificada"
#: models.py:109
msgid "created on"
msgstr "creat el"
#: models.py:110
msgid "closed on"
msgstr "tancat el"
#: models.py:111
msgid "open"
msgstr "obert"
#: models.py:112
msgid "sent"
msgstr "enviat"
#: models.py:113
msgid "due on"
msgstr "es deu"
#: models.py:114
msgid "updated on"
msgstr "actualitzada el"
#: models.py:116
msgid "comments"
msgstr "comentaris"
#: models.py:117
msgid "HTML"
msgstr "HTML"
#: models.py:200
#, python-format
msgid "Type %s is not an amendment."
msgstr ""
#: models.py:202
msgid "Amend of related account doesn't match bill account."
msgstr ""
#: models.py:204
#, fuzzy
#| msgid "Bill not in open state."
msgid "Related invoice is in open state."
msgstr "La factura no està en estat obert"
#: models.py:206
msgid "Related invoice is an amendment."
msgstr ""
#: models.py:419
msgid "bill"
msgstr "factura"
#: models.py:420 models.py:499 templates/bills/microspective.html:75
msgid "description"
msgstr "descripció"
#: models.py:421
msgid "rate"
msgstr "tarifa"
#: models.py:422
msgid "quantity"
msgstr "quantitat"
#: models.py:424
#, 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
msgid "subtotal"
msgstr "subtotal"
#: models.py:426
msgid "tax"
msgstr "impostos"
#: models.py:427
msgid "start"
msgstr "iniciar"
#: models.py:428
msgid "end"
msgstr "finalitzar"
#: models.py:431
msgid "Informative link back to the order"
msgstr "Enllaç informatiu de l'ordre"
#: models.py:432
msgid "order billed"
msgstr "ordre facturada"
#: models.py:433
msgid "order billed until"
msgstr "ordre facturada fins a"
#: models.py:434
msgid "created"
msgstr "creada"
#: models.py:436
msgid "amended line"
msgstr "línia rectificada"
#: models.py:492
msgid "Volume"
msgstr "Volum"
#: models.py:493
msgid "Compensation"
msgstr "Compensació"
#: models.py:494
msgid "Other"
msgstr "Altre"
#: models.py:498
msgid "bill line"
msgstr "línia de factura"
#: templates/admin/bills/bill/change_list.html:9
#, fuzzy
#| msgid "lines"
msgid "Lines"
msgstr "línies"
#: templates/admin/bills/bill/change_list.html:15
#, fuzzy
#| msgid "bill"
msgid "Add bill"
msgstr "factura"
#: templates/admin/bills/bill/close_send_download_bills.html:57
msgid "Yes, I'm sure"
msgstr ""
#: templates/admin/bills/bill/report.html:42
msgid "Summary"
msgstr ""
#: 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
msgid "VAT"
msgstr "IVA"
#: templates/admin/bills/bill/report.html:51
#: templates/bills/microspective.html:119
msgid "taxes"
msgstr "impostos"
#: templates/admin/bills/bill/report.html:56
#: templates/admin/bills/billline/report.html:60
#: templates/bills/microspective.html:54
msgid "TOTAL"
msgstr "TOTAL"
#: templates/admin/bills/bill/report.html:66
msgid "Contact"
msgstr ""
#: templates/admin/bills/bill/report.html:67
#, fuzzy
#| msgid "Due date"
msgid "Close date"
msgstr "Data de pagament"
#: templates/admin/bills/bill/report.html:68
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"
msgstr ""
#: templates/admin/bills/billline/report.html:43
msgid "Active"
msgstr ""
#: templates/admin/bills/billline/report.html:44
msgid "Cancelled"
msgstr ""
#: templates/admin/bills/billline/report.html:45
msgid "Nominal price"
msgstr ""
#: templates/admin/bills/billline/report.html:46
#, fuzzy
#| msgid "quantity"
msgid "Quantity"
msgstr "quantitat"
#: templates/admin/bills/billline/report.html:47
msgid "Profit"
msgstr ""
#: templates/bills/microspective-fee.html:115
msgid "Due date"
msgstr "Data de pagament"
#: templates/bills/microspective-fee.html:116
#, python-format
msgid "On %(bank_account)s"
msgstr "Al %(bank_account)s"
#: templates/bills/microspective-fee.html:122
#, python-format
msgid "From %(ini)s to %(end)s"
msgstr "De %(ini)s a %(end)s"
#: templates/bills/microspective-fee.html:144
msgid ""
"\n"
"<strong>With your membership</strong> you are supporting ...\n"
msgstr ""
"\n"
"<strong>Amb la teva quota de soci</strong> estàs donant suport ...\n"
#: templates/bills/microspective.html:50
msgid "DUE DATE"
msgstr "VENCIMENT"
#: templates/bills/microspective.html:58
#, python-format
msgid "%(bill_type)s DATE"
msgstr "DATA %(bill_type)s"
#: templates/bills/microspective.html:76
msgid "period"
msgstr "període"
#: templates/bills/microspective.html:77
msgid "hrs/qty"
msgstr "hrs/qnt"
#: templates/bills/microspective.html:78
msgid "rate/price"
msgstr "tarifa/preu"
#: templates/bills/microspective.html:137
msgid "COMMENTS"
msgstr "COMENTARIS"
#: templates/bills/microspective.html:145
msgid "PAYMENT"
msgstr "PAGAMENT"
#: templates/bills/microspective.html:149
#, python-format
msgid ""
"\n"
" You can pay our <i>%(type)s</i> by bank transfer.<br>\n"
" Please make sure to state your name and the <i>%(type)s</"
"i> number.\n"
" Our bank account number is <br>\n"
" "
msgstr ""
"\n"
"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
msgid "QUESTIONS"
msgstr "PREGUNTES"
#: templates/bills/microspective.html:161
#, python-format
msgid ""
"\n"
" If you have any question about your <i>%(type)s</i>, please\n"
" feel free to write us at %(email)s. We will reply as soon as we "
"get\n"
" your message.\n"
" "
msgstr ""
"\n"
" Si tens algun dubte o pregunta sobre la teva <i>%(type)s</i>, si "
"us plau\n"
" contacta amb nosaltres a %(email)s. Et respondrem el més "
"ràpidament possible.\n"
" "
#, fuzzy
#~| msgid "closed on"
#~ msgid "No closed amends"
#~ msgstr "tancat el"
#~ msgid "positive price"
#~ msgstr "preu positiu"

View File

@ -0,0 +1,728 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2019-12-20 11:56+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: actions.py:33
msgid "View"
msgstr "Vista"
#: actions.py:45
msgid "Selected bills should be in open state"
msgstr "Las facturas seleccionadas están en estado abierto"
#: actions.py:60
msgid "Selected bills have been closed"
msgstr "Las facturas seleccionadas han sido cerradas"
#: actions.py:73
#, python-format
msgid "<a href=\"%(url)s\">One related transaction</a> has been created"
msgstr "Se ha creado una <a href=\"%(url)s\">transacción</a>"
#: actions.py:74
#, python-format
msgid "<a href=\"%(url)s\">%(num)i related transactions</a> have been created"
msgstr "Se han creado <a href=\"%(url)s\">%(num)i transacciones</a>"
#: actions.py:80
msgid "Are you sure about closing the following bills?"
msgstr "Estás a punto de cerrar las sigüientes facturas. ¿Estás seguro?"
#: actions.py:81
msgid ""
"Once a bill is closed it can not be further modified.</p><p>Please select a "
"payment source for the selected bills"
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
msgid "Close"
msgstr "Cerrar"
#: actions.py:115
msgid "One bill has been sent."
msgstr "Se ha enviado una factura"
#: actions.py:116
#, python-format
msgid "%i bills have been sent."
msgstr ""
#: actions.py:123
msgid "Resend"
msgstr ""
#: actions.py:146
msgid "Download"
msgstr "Descarga"
#: actions.py:162
msgid "C.S.D."
msgstr ""
#: actions.py:164
msgid "Close, send and download bills in one shot."
msgstr ""
#: actions.py:225
#, python-format
msgid "%(norders)s orders and %(nlines)s lines undoed."
msgstr ""
#: actions.py:244
msgid "Lines moved"
msgstr ""
#: actions.py:257
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
#, 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
#, 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
#, 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
#, 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
msgid "Amend"
msgstr ""
#: admin.py:80 admin.py:126 admin.py:180 forms.py:11
#: templates/admin/bills/bill/report.html:43
#: templates/admin/bills/bill/report.html:70
msgid "Total"
msgstr ""
#: admin.py:112
msgid "Description"
msgstr ""
#: admin.py:120
msgid "Subtotal"
msgstr ""
#: admin.py:146
msgid "Totals"
msgstr ""
#: admin.py:150
msgid "Order"
msgstr ""
#: admin.py:169
msgid "Is open"
msgstr ""
#: admin.py:175
msgid "Sublines"
msgstr ""
#: admin.py:221
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:231
msgid "Bill not in open state."
msgstr ""
#: admin.py:234
msgid "Not all bills are in open state."
msgstr ""
#: admin.py:235
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
msgid "Dates"
msgstr ""
#: admin.py:335
msgid "Raw"
msgstr ""
#: admin.py:358 models.py:75
msgid "Created"
msgstr ""
#: admin.py:359
#, fuzzy
#| msgid "Close"
msgid "Closed"
msgstr "Cerrar"
#: admin.py:360
#, fuzzy
#| msgid "updated on"
msgid "Updated"
msgstr "actualizada en"
#: admin.py:375
msgid "lines"
msgstr ""
#: admin.py:389 models.py:108 models.py:501
msgid "type"
msgstr ""
#: filters.py:21
msgid "All"
msgstr ""
#: filters.py:22 models.py:91
msgid "Invoice"
msgstr "Factura"
#: filters.py:23 models.py:93
msgid "Fee"
msgstr "Cuota de socio"
#: filters.py:24
msgid "Pro-forma"
msgstr ""
#: filters.py:25
msgid "Amendment fee"
msgstr "Cuota rectificativa"
#: filters.py:26 models.py:92
msgid "Amendment invoice"
msgstr "Factura rectificativa"
#: filters.py:71
msgid "has bill contact"
msgstr ""
#: filters.py:76
msgid "Yes"
msgstr ""
#: filters.py:77
msgid "No"
msgstr ""
#: filters.py:88
msgid "payment state"
msgstr "Pago"
#: filters.py:93 models.py:74
msgid "Open"
msgstr ""
#: filters.py:94 models.py:78
msgid "Paid"
msgstr ""
#: filters.py:95
msgid "Pending"
msgstr ""
#: filters.py:96 models.py:81
msgid "Bad debt"
msgstr ""
#: filters.py:138
#, fuzzy
#| msgid "Amended"
msgid "amended"
msgstr "Quota rectificativa"
#: filters.py:143
#, 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:145
#, fuzzy
#| msgid "Amended"
msgid "Any amends"
msgstr "Quota rectificativa"
#: filters.py:146
msgid "No amends"
msgstr ""
#: forms.py:9 templates/admin/bills/bill/report.html:64
msgid "Number"
msgstr ""
#: forms.py:10
msgid "Account"
msgstr ""
#: forms.py:12
msgid "Type"
msgstr ""
#: forms.py:13
msgid "Source"
msgstr ""
#: helpers.py:14
msgid ""
"{relation} account \"{account}\" does not have a declared invoice contact. "
"You should <a href=\"{url}#invoicecontact-group\">provide one</a>"
msgstr ""
#: helpers.py:21
msgid "Related"
msgstr ""
#: helpers.py:28
msgid "Main"
msgstr ""
#: models.py:26 models.py:104
msgid "account"
msgstr ""
#: models.py:28
msgid "name"
msgstr ""
#: models.py:29
msgid "Account full name will be used when left blank."
msgstr ""
#: models.py:30
msgid "address"
msgstr ""
#: models.py:31
msgid "city"
msgstr ""
#: models.py:33
msgid "zip code"
msgstr ""
#: models.py:34
msgid "Enter a valid zipcode."
msgstr ""
#: models.py:35
msgid "country"
msgstr ""
#: models.py:38 templates/admin/bills/bill/report.html:65
msgid "VAT number"
msgstr ""
#: models.py:76
msgid "Processed"
msgstr ""
#: models.py:77
msgid "Amended"
msgstr "Quota rectificativa"
#: models.py:79
msgid "Incomplete"
msgstr ""
#: models.py:80
msgid "Executed"
msgstr ""
#: models.py:94
msgid "Amendment Fee"
msgstr ""
#: models.py:95
#, fuzzy
#| msgid "Invoice"
msgid "Abono Invoice"
msgstr "Abono"
#: models.py:96
msgid "Pro forma"
msgstr ""
#: models.py:103
msgid "number"
msgstr "número"
#: models.py:106
msgid "amend of"
msgstr "rectificación de"
#: models.py:109
msgid "created on"
msgstr "creado en"
#: models.py:110
msgid "closed on"
msgstr "cerrada en"
#: models.py:111
msgid "open"
msgstr "abierta"
#: models.py:112
msgid "sent"
msgstr "enviada"
#: models.py:113
msgid "due on"
msgstr "vencimiento"
#: models.py:114
msgid "updated on"
msgstr "actualizada en"
#: models.py:116
msgid "comments"
msgstr "comentarios"
#: models.py:117
msgid "HTML"
msgstr "HTML"
#: models.py:200
#, python-format
msgid "Type %s is not an amendment."
msgstr ""
#: models.py:202
msgid "Amend of related account doesn't match bill account."
msgstr ""
#: models.py:204
#, fuzzy
#| msgid "Selected bills should be in open state"
msgid "Related invoice is in open state."
msgstr "Las facturas seleccionadas están en estado abierto"
#: models.py:206
msgid "Related invoice is an amendment."
msgstr ""
#: models.py:419
msgid "bill"
msgstr "factura"
#: models.py:420 models.py:499 templates/bills/microspective.html:75
msgid "description"
msgstr "descripción"
#: models.py:421
msgid "rate"
msgstr "tarifa"
#: models.py:422
msgid "quantity"
msgstr "cantidad"
#: models.py:424
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
msgid "subtotal"
msgstr "subtotal"
#: models.py:426
msgid "tax"
msgstr "impuesto"
#: models.py:427
msgid "start"
msgstr "inicio"
#: models.py:428
msgid "end"
msgstr "fín"
#: models.py:431
msgid "Informative link back to the order"
msgstr ""
#: models.py:432
msgid "order billed"
msgstr ""
#: models.py:433
msgid "order billed until"
msgstr ""
#: models.py:434
msgid "created"
msgstr "creado"
#: models.py:436
msgid "amended line"
msgstr "linea rectificativa"
#: models.py:492
msgid "Volume"
msgstr "Volumen"
#: models.py:493
msgid "Compensation"
msgstr "Compensación"
#: models.py:494
msgid "Other"
msgstr "Otro"
#: models.py:498
msgid "bill line"
msgstr "linea de factura"
#: templates/admin/bills/bill/change_list.html:9
msgid "Lines"
msgstr ""
#: templates/admin/bills/bill/change_list.html:15
#, fuzzy
#| msgid "bill"
msgid "Add bill"
msgstr "factura"
#: templates/admin/bills/bill/close_send_download_bills.html:57
msgid "Yes, I'm sure"
msgstr ""
#: templates/admin/bills/bill/report.html:42
msgid "Summary"
msgstr ""
#: 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
msgid "VAT"
msgstr "IVA"
#: templates/admin/bills/bill/report.html:51
#: templates/bills/microspective.html:119
msgid "taxes"
msgstr "impuestos"
#: templates/admin/bills/bill/report.html:56
#: templates/admin/bills/billline/report.html:60
#: templates/bills/microspective.html:54
msgid "TOTAL"
msgstr "TOTAL"
#: templates/admin/bills/bill/report.html:66
msgid "Contact"
msgstr "Contacto"
#: templates/admin/bills/bill/report.html:67
#, fuzzy
#| msgid "Due date"
msgid "Close date"
msgstr "Fecha de pago"
#: templates/admin/bills/bill/report.html:68
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"
msgstr ""
#: templates/admin/bills/billline/report.html:43
msgid "Active"
msgstr ""
#: templates/admin/bills/billline/report.html:44
msgid "Cancelled"
msgstr ""
#: templates/admin/bills/billline/report.html:45
msgid "Nominal price"
msgstr ""
#: templates/admin/bills/billline/report.html:46
#, fuzzy
#| msgid "quantity"
msgid "Quantity"
msgstr "cantidad"
#: templates/admin/bills/billline/report.html:47
msgid "Profit"
msgstr ""
#: templates/bills/microspective-fee.html:115
msgid "Due date"
msgstr "Fecha de pago"
#: templates/bills/microspective-fee.html:116
#, python-format
msgid "On %(bank_account)s"
msgstr "En %(bank_account)s"
#: templates/bills/microspective-fee.html:122
#, python-format
msgid "From %(ini)s to %(end)s"
msgstr "Desde %(ini)s hasta %(end)s"
#: templates/bills/microspective-fee.html:144
msgid ""
"\n"
"<strong>With your membership</strong> you are supporting ...\n"
msgstr ""
#: templates/bills/microspective.html:50
msgid "DUE DATE"
msgstr "VENCIMIENTO"
#: templates/bills/microspective.html:58
#, python-format
msgid "%(bill_type)s DATE"
msgstr "FECHA %(bill_type)s"
#: templates/bills/microspective.html:76
msgid "period"
msgstr "periodo"
#: templates/bills/microspective.html:77
msgid "hrs/qty"
msgstr "hrs/cant"
#: templates/bills/microspective.html:78
msgid "rate/price"
msgstr "tarifa/precio"
#: templates/bills/microspective.html:137
msgid "COMMENTS"
msgstr "COMENTARIOS"
#: templates/bills/microspective.html:145
msgid "PAYMENT"
msgstr "PAGO"
#: templates/bills/microspective.html:149
#, python-format
msgid ""
"\n"
" You can pay our <i>%(type)s</i> by bank transfer.<br>\n"
" Please make sure to state your name and the <i>%(type)s</"
"i> number.\n"
" Our bank account number is <br>\n"
" "
msgstr ""
"\n"
"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
msgid "QUESTIONS"
msgstr "PREGUNTAS"
#: templates/bills/microspective.html:161
#, python-format
msgid ""
"\n"
" If you have any question about your <i>%(type)s</i>, please\n"
" feel free to write us at %(email)s. We will reply as soon as we "
"get\n"
" your message.\n"
" "
msgstr ""
"\n"
" Si tienes alguna duda o pregunta sobre tu <i>%(type)s</i>, por "
"favor\n"
" 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"

View File

@ -0,0 +1,504 @@
import datetime
from dateutil.relativedelta import relativedelta
from django.urls import reverse
from django.core.validators import ValidationError, RegexValidator
from django.db import models
from django.db.models import F, Sum
from django.db.models.functions import Coalesce
from django.template import loader
from django.utils import timezone, translation
from django.utils.encoding import force_str
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from orchestra.admin.utils import change_url
from orchestra.contrib.accounts.models import Account
from orchestra.contrib.contacts.models import Contact
from orchestra.core import validators
from orchestra.utils.functional import cached
from orchestra.utils.html import html_to_pdf
from . import settings
class BillContact(models.Model):
account = models.OneToOneField('accounts.Account', verbose_name=_("account"),
related_name='billcontact', on_delete=models.CASCADE)
name = models.CharField(_("name"), max_length=256, blank=True,
help_text=_("Account full name will be used when left blank."))
address = models.TextField(_("address"))
city = models.CharField(_("city"), max_length=128,
default=settings.BILLS_CONTACT_DEFAULT_CITY)
zipcode = models.CharField(_("zip code"), max_length=10,
validators=[RegexValidator(r'^[0-9A-Z]{3,10}$', _("Enter a valid zipcode."))])
country = models.CharField(_("country"), max_length=20,
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()
validators.all_valid({
'vat': (validators.validate_vat, self.vat, self.country),
'zipcode': (validators.validate_zipcode, self.zipcode, self.country)
})
class BillManager(models.Manager):
def get_queryset(self):
queryset = super(BillManager, self).get_queryset()
if self.model != Bill:
bill_type = self.model.get_class_type()
queryset = queryset.filter(type=bill_type)
return queryset
class Bill(models.Model):
OPEN = ''
CREATED = 'CREATED'
PROCESSED = 'PROCESSED'
AMENDED = 'AMENDED'
PAID = 'PAID'
EXECUTED = 'EXECUTED'
BAD_DEBT = 'BAD_DEBT'
INCOMPLETE = 'INCOMPLETE'
PAYMENT_STATES = (
(OPEN, _("Open")),
(CREATED, _("Created")),
(PROCESSED, _("Processed")),
(AMENDED, _("Amended")),
(PAID, _("Paid")),
(INCOMPLETE, _('Incomplete')),
(EXECUTED, _("Executed")),
(BAD_DEBT, _("Bad debt")),
)
BILL = 'BILL'
INVOICE = 'INVOICE'
AMENDMENTINVOICE = 'AMENDMENTINVOICE'
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)
amend_of = models.ForeignKey('self', null=True, blank=True, verbose_name=_("amend of"),
related_name='amends', on_delete=models.SET_NULL)
type = models.CharField(_("type"), max_length=16, choices=TYPES)
created_on = models.DateField(_("created on"), auto_now_add=True)
closed_on = models.DateField(_("closed on"), blank=True, null=True, db_index=True)
is_open = models.BooleanField(_("open"), default=True)
is_sent = models.BooleanField(_("sent"), default=False)
due_on = models.DateField(_("due on"), null=True, blank=True)
updated_on = models.DateField(_("updated on"), auto_now=True)
# 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:
return self.OPEN
secured = 0
pending = 0
created = False
processed = False
executed = False
rejected = False
for transaction in self.transactions.all():
if transaction.state == transaction.SECURED:
secured += transaction.amount
pending += transaction.amount
elif transaction.state == transaction.WAITTING_PROCESSING:
pending += transaction.amount
created = True
elif transaction.state == transaction.WAITTING_EXECUTION:
pending += transaction.amount
processed = True
elif transaction.state == transaction.EXECUTED:
pending += transaction.amount
executed = True
elif transaction.state == transaction.REJECTED:
rejected = True
else:
raise TypeError("Unknown state")
ongoing = bool(secured != 0 or created or processed or executed)
total = self.compute_total()
if total >= 0:
if secured >= total:
return self.PAID
elif ongoing and pending < total:
return self.INCOMPLETE
else:
if secured <= total:
return self.PAID
elif ongoing and pending > total:
return self.INCOMPLETE
if created:
return self.CREATED
elif processed:
return self.PROCESSED
elif executed:
return self.EXECUTED
return self.BAD_DEBT
def clean(self):
if self.amend_of_id:
errors = {}
if self.type not in self.AMEND_MAP.values():
errors['amend_of'] = _("Type %s is not an amendment.") % self.get_type_display()
if self.amend_of.account_id != self.account_id:
errors['account'] = _("Amend of related account doesn't match bill account.")
if self.amend_of.is_open:
errors['amend_of'] = _("Related invoice is in open state.")
if self.amend_of.type in self.AMEND_MAP.values():
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_str(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:
cls = cls.__base__
bill_type = self.get_type()
if bill_type == self.BILL:
raise TypeError('This method can not be used on BILL instances')
bill_type = bill_type.replace('AMENDMENT', 'AMENDMENT_')
prefix = getattr(settings, 'BILLS_%s_NUMBER_PREFIX' % bill_type)
if self.is_open:
prefix = 'O{}'.format(prefix)
year = timezone.now().strftime("%Y")
bills = cls.objects.filter(number__regex=r'^%s%s[0-9]+' % (prefix, year))
last_number = bills.order_by('-number').values_list('number', flat=True).first()
if last_number is None:
last_number = 0
else:
last_number = int(last_number[len(prefix)+4:])
number = last_number + 1
number_length = settings.BILLS_NUMBER_LENGTH
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.")
if payment is False:
payment = self.account.paymentsources.get_default()
if not self.due_on:
self.due_on = self.get_due_date(payment=payment)
total = self.compute_total()
transaction = None
if self.get_type() != self.PROFORMA:
transaction = self.transactions.create(bill=self, source=payment, amount=total)
self.closed_on = timezone.now()
self.is_open = False
self.is_sent = False
self.number = self.get_number()
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(
template=settings.BILLS_EMAIL_NOTIFICATION_TEMPLATE,
context={
'bill': self,
'settings': settings,
},
email_from=settings.BILLS_SELLER_EMAIL,
usages=(Contact.BILLING,),
attachments=[
('%s.pdf' % self.number, pdf, 'application/pdf')
]
)
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 = {
'bill': self,
'lines': self.lines.all().prefetch_related('sublines'),
'seller': self.seller,
'buyer': self.buyer,
'seller_info': {
'phone': settings.BILLS_SELLER_PHONE,
'website': settings.BILLS_SELLER_WEBSITE,
'email': settings.BILLS_SELLER_EMAIL,
'bank_account': settings.BILLS_SELLER_BANK_ACCOUNT,
},
'currency': settings.BILLS_CURRENCY,
'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 = {}
lines = self.lines.annotate(totals=F('subtotal') + Sum(Coalesce('sublines__total', 0)))
for tax, total in lines.values_list('tax', 'totals'):
try:
subtotals[tax] += total
except KeyError:
subtotals[tax] = total
result = {}
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', ()):
total = 0
for line in self.lines.all():
line_total = line.compute_total()
total += line_total * (1+line.tax/100)
return round(total, 2)
else:
totals = self.lines.annotate(
totals=(F('subtotal') + Sum(Coalesce('sublines__total', 0))) * (1+F('tax')/100)
)
return round(totals.aggregate(Sum('totals'))['totals__sum'] or 0, 2)
class Invoice(Bill):
class Meta:
proxy = True
class AmendmentInvoice(Bill):
class Meta:
proxy = True
class AbonoInvoice(Bill):
class Meta:
proxy = True
class Fee(Bill):
class Meta:
proxy = True
class AmendmentFee(Bill):
class Meta:
proxy = True
class ProForma(Bill):
class Meta:
proxy = True
class BillLine(models.Model):
""" Base model for bill item representation """
bill = models.ForeignKey(Bill, verbose_name=_("bill"), related_name='lines', on_delete=models.CASCADE)
description = models.CharField(_("description"), max_length=256)
rate = models.DecimalField(_("rate"), blank=True, null=True, max_digits=12, decimal_places=2)
quantity = models.DecimalField(_("quantity"), blank=True, null=True, max_digits=12,
decimal_places=2)
verbose_quantity = models.CharField(_("Verbose quantity"), max_length=16, blank=True)
subtotal = models.DecimalField(_("subtotal"), max_digits=12, decimal_places=2)
tax = models.DecimalField(_("tax"), max_digits=4, decimal_places=2)
start_on = models.DateField(_("start"))
end_on = models.DateField(_("end"), null=True, blank=True)
order = models.ForeignKey(settings.BILLS_ORDER_MODEL, null=True, blank=True,
related_name='lines', on_delete=models.SET_NULL,
help_text=_("Informative link back to the order"))
order_billed_on = models.DateField(_("order billed"), null=True, blank=True)
order_billed_until = models.DateField(_("order billed until"), null=True, blank=True)
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)
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"
if self.start_on.day != 1 or (self.end_on and self.end_on.day != 1):
date_format = "N j, 'y"
end = date(self.end_on, date_format)
elif self.end_on:
end = date((self.end_on - datetime.timedelta(days=1)), date_format)
ini = date(self.start_on, date_format).capitalize()
if not self.end_on:
return ini
end = end.capitalize()
if ini == end:
return ini
return "{ini} / {end}".format(ini=ini, end=end)
@cached
def compute_total(self):
total = self.subtotal or 0
if hasattr(self, 'subline_total'):
total += self.subline_total or 0
elif 'sublines' in getattr(self, '_prefetched_objects_cache', ()):
total += sum(subline.total for subline in self.sublines.all())
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)
class BillSubline(models.Model):
""" Subline used for describing an item discount """
VOLUME = 'VOLUME'
COMPENSATION = 'COMPENSATION'
OTHER = 'OTHER'
TYPES = (
(VOLUME, _("Volume")),
(COMPENSATION, _("Compensation")),
(OTHER, _("Other")),
)
# TODO: order info for undoing
line = models.ForeignKey(BillLine, verbose_name=_("bill line"), related_name='sublines', on_delete=models.CASCADE)
description = models.CharField(_("description"), max_length=256)
total = models.DecimalField(max_digits=12, decimal_places=2)
type = models.CharField(_("type"), max_length=16, choices=TYPES, default=OTHER)
def __str__(self):
return "%s %i" % (self.description, self.total)

View File

@ -0,0 +1,34 @@
from rest_framework import serializers
from orchestra.api import router
from orchestra.contrib.accounts.models import Account
from orchestra.contrib.accounts.serializers import AccountSerializerMixin
from .models import Bill, BillLine, BillContact
class BillLineSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = BillLine
class BillSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer):
# lines = BillLineSerializer(source='lines')
class Meta:
model = Bill
fields = (
'url', 'id', 'number', 'type', 'total', 'is_sent', 'created_on', 'due_on',
'comments',
# 'lines'
)
class BillContactSerializer(AccountSerializerMixin, serializers.ModelSerializer):
class Meta:
model = BillContact
fields = ('name', 'address', 'city', 'zipcode', 'country', 'vat')
router.insert(Account, 'billcontact', BillContactSerializer, required=False)

View File

@ -0,0 +1,106 @@
from django_countries import data
from orchestra.contrib.settings import Setting
from orchestra.settings import ORCHESTRA_BASE_DOMAIN
BILLS_NUMBER_LENGTH = Setting('BILLS_NUMBER_LENGTH',
4
)
BILLS_INVOICE_NUMBER_PREFIX = Setting('BILLS_INVOICE_NUMBER_PREFIX',
'I'
)
BILLS_AMENDMENT_INVOICE_NUMBER_PREFIX = Setting('BILLS_AMENDMENT_INVOICE_NUMBER_PREFIX',
'A'
)
BILLS_ABONOINVOICE_NUMBER_PREFIX = Setting('BILLS_ABONOINVOICE_NUMBER_PREFIX',
'AB'
)
BILLS_FEE_NUMBER_PREFIX = Setting('BILLS_FEE_NUMBER_PREFIX',
'F'
)
BILLS_AMENDMENT_FEE_NUMBER_PREFIX = Setting('BILLS_AMENDMENT_FEE_NUMBER_PREFIX',
'B'
)
BILLS_PROFORMA_NUMBER_PREFIX = Setting('BILLS_PROFORMA_NUMBER_PREFIX',
'P'
)
BILLS_DEFAULT_TEMPLATE = Setting('BILLS_DEFAULT_TEMPLATE',
'bills/microspective.html'
)
BILLS_FEE_TEMPLATE = Setting('BILLS_FEE_TEMPLATE',
'bills/microspective-fee.html'
)
BILLS_PROFORMA_TEMPLATE = Setting('BILLS_PROFORMA_TEMPLATE',
'bills/microspective-proforma.html'
)
BILLS_CURRENCY = Setting('BILLS_CURRENCY',
'euro'
)
BILLS_SELLER_PHONE = Setting('BILLS_SELLER_PHONE',
'111-112-11-222'
)
BILLS_SELLER_EMAIL = Setting('BILLS_SELLER_EMAIL',
'sales@{}'.format(ORCHESTRA_BASE_DOMAIN),
help_text="Uses ORCHESTRA_BASE_DOMAIN by default.",
)
BILLS_SELLER_WEBSITE = Setting('BILLS_SELLER_WEBSITE',
'www.{}'.format(ORCHESTRA_BASE_DOMAIN),
help_text="Uses ORCHESTRA_BASE_DOMAIN by default.",
)
BILLS_SELLER_BANK_ACCOUNT = Setting('BILLS_SELLER_BANK_ACCOUNT',
'0000 0000 00 00000000 (Orchestra Bank)'
)
BILLS_EMAIL_NOTIFICATION_TEMPLATE = Setting('BILLS_EMAIL_NOTIFICATION_TEMPLATE',
'bills/bill-notification.email'
)
BILLS_ORDER_MODEL = Setting('BILLS_ORDER_MODEL',
'orders.Order',
validators=[Setting.validate_model_label]
)
BILLS_CONTACT_DEFAULT_CITY = Setting('BILLS_CONTACT_DEFAULT_CITY',
'Barcelona'
)
BILLS_CONTACT_COUNTRIES = Setting('BILLS_CONTACT_COUNTRIES',
tuple((k,v) for k,v in data.COUNTRIES.items()),
serializable=False
)
BILLS_CONTACT_DEFAULT_COUNTRY = Setting('BILLS_CONTACT_DEFAULT_COUNTRY',
'ES',
choices=BILLS_CONTACT_COUNTRIES
)

View File

@ -0,0 +1,18 @@
{% extends "admin/change_list.html" %}
{% load i18n admin_urls %}
{% block object-tools-items %}
<li>
{% url 'admin:bills_billline_changelist' as list_url %}
<a href="{% add_preserved_filters list_url is_popup to_field %}" class="historylink">
{% trans "Lines" %}
</a>
</li>
<li>
{% url 'admin:bills_bill_add' as add_url %}
<a href="{% add_preserved_filters add_url is_popup to_field %}" class="addlink">
{% trans "Add bill" %}
</a>
</li>
{% endblock %}

View File

@ -0,0 +1,60 @@
{% extends "admin/orchestra/generic_confirmation.html" %}
{% load i18n l10n %}
{% load admin_urls static utils %}
{% block extrastyle %}
{{ block.super }}
<script type="text/javascript">
function DoSubmit() {
// document.form.button.type = 'hidden';
document.getElementsByName("message")[0].innerHTML = "Bills are being generated and download will start shortly...";
document.getElementsByTagName("fieldset")[0].style.display = 'none';
document.form.button.value = 'Go back';
return true;
}
</script>
{% endblock %}
{% block content %}
<div name="content">
<div style="margin:20px;">
<div name="message">
<p>{{ content_message | safe }}</p>
<ul>{{ display_objects | unordered_list }}</ul>
</div>
<form name="form" action="" method="post" onsubmit="DoSubmit();">{% csrf_token %}
{% block form %}
{% if form %}
<fieldset class="module aligned">
{{ form.non_field_errors }}
{% for field in form %}
<div class="form-row ">
<div >
{{ field.errors }}
{% if field|is_checkbox %}
{{ field }} <label for="{{ field.id_for_label }}" class="vCheckboxLabel">{{ field.label }}</label>
{% else %}
{{ field.label_tag }} {{ field }}
{% endif %}
<p class="help">{{ field.help_text|safe }}</p>
</div>
</div>
{% endfor %}
</fieldset>
{% endif %}
{% endblock %}
{% block formset %}
{% if formset %}
{{ formset.as_admin }}
{% endif %}
{% endblock %}
<div>
{% for obj in queryset %}
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk|unlocalize }}" />
{% endfor %}
<input type="hidden" name="action" value="{{ action_value }}" />
<input type="hidden" name="post" value="{{ post_value|default:'generic_confirmation' }}" />
<input name="button" type="submit" value="{{ submit_value|default:_("Yes, I'm sure") }}" />
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,87 @@
{% load i18n utils %}
<html>
<head>
<title>Bill Report</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<style type="text/css">
@page {
size: 11.69in 8.27in;
}
table {
font-family: sans;
font-size: 10px;
max-width: 10in;
margin: 4px;
}
.item.column-name {
text-align: right;
}
table tr:nth-child(even) {
background-color: #eee;
}
table tr:nth-child(odd) {
background-color: #fff;
}
table th {
color: white;
background-color: grey;
}
.item.column-base, .item.column-vat, .item.column-total, .item.column-number {
text-align: right;
}
.column-vat-number {
text-align: center;
}
</style>
</head>
<body>
<table id="summary">
<tr class="header">
<th class="title column-name">{% trans "Summary" %}</th>
<th class="title column-total">{% trans "Total" %}</th>
</tr>
{% for tax, subtotal in subtotals.items %}
<tr>
<td class="item column-name">{% trans "subtotal" %} {{ tax }}% {% trans "VAT" %}</td>
<td class="item column-total">{{ subtotal|first}}</td>
</tr>
<tr>
<td class="item column-name">{% trans "taxes" %} {{ tax }}% {% trans "VAT" %}</td>
<td class="item column-total">{{ subtotal|last}}</td>
</tr>
{% endfor %}
<tr>
<td class="item column-name"><b>{% trans "TOTAL" %}</b></td>
<td class="item column-total"><b>{{ total }}</b></td>
</tr>
</table>
<table id="main">
<tr class="header">
<th class="title column-number">{% trans "Number" %}</th>
<th class="title column-vat-number">{% trans "VAT number" %}</th>
<th class="title column-billcontant">{% trans "Contact" %}</th>
<th class="title column-date">{% trans "Close date" %}</th>
<th class="title column-base">{% trans "Base" %}</th>
<th class="title column-vat">{% trans "VAT" %}</th>
<th class="title column-total">{% trans "Total" %}</th>
</tr>
{% for bill in bills %}
<tr>
<td class="item column-number">{{ bill.number }}</td>
<td class="item column-vat-number">{{ bill.buyer.vat }}</td>
<td class="item column-billcontant">{{ bill.buyer.get_name }}</td>
<td class="item column-date">{{ bill.closed_on|date }}</td>
{% with base=bill.compute_base total=bill.compute_total %}
<td class="item column-base">{{ base }}</td>
<td class="item column-vat">{{ total|sub:base }}</td>
<td class="item column-total">{{ total }}</td>
{% endwith %}
</tr>
{% endfor %}
</table>
</body>
</html>

View File

@ -0,0 +1,12 @@
{% extends "admin/change_list.html" %}
{% load i18n admin_urls %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=cl.opts.app_label %}">{{ cl.opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url 'admin:bills_bill_changelist' %}">{% trans "Bills" %}</a>
&rsaquo; {% if bill %}<a href="{% url 'admin:bills_bill_change' bill.pk %}">{{ bill }}</a>{% else %}{% trans 'Multiple bills' %}{% endif %}
&rsaquo; {{ cl.opts.verbose_name_plural|capfirst }}
</div>
{% endblock %}

View File

@ -0,0 +1,72 @@
{% load i18n utils %}
<html>
<head>
<title>Transaction Report</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<style type="text/css">
@page {
size: 11.69in 8.27in;
}
table {
max-width: 10in;
font-family: sans;
font-size: 10px;
}
table tr:nth-child(even) {
background-color: #eee;
}
table tr:nth-child(odd) {
background-color: #fff;
}
table th {
color: white;
background-color: grey;
}
.item.column-created, .item.column-updated {
text-align: center;
}
.item.column-amount {
text-align: right;
}
.footnote {
font-family: sans;
font-size: 10px;
}
</style>
</head>
<body>
<table id="summary">
<tr class="header">
<th class="title column-name">{% trans "Service" %}</th>
<th class="title column-active">{% trans "Active" %}</th>
<th class="title column-cancelled">{% trans "Cancelled" %}</th>
<th class="title column-nominal-price">{% trans "Nominal price" %}</th>
<th class="title column-number">{% trans "Quantity" %}</th>
<th class="title column-number">{% trans "Profit" %}</th>
</tr>
{% for service, info in services %}
<tr>
<td class="item column-name">{{ service }}</td>
<td class="item column-amount">{{ info.0 }}</td>
<td class="item column-amount">{{ info.1 }}</td>
<td class="item column-amount">{{ info.2 }}</td>
<td class="item column-amount">{{ info.3 }}</td>
<td class="item column-amount">{{ info.4 }}</td>
</tr>
{% endfor %}
<tr>
<td class="item column-name"><b>{% trans "TOTAL" %}</b></td>
<td class="item column-amount"><b>{{ totals.0 }}</b></td>
<td class="item column-amount"><b>{{ totals.1 }}<b></td>
<td class="item column-amount"><b>{{ totals.2 }}<b></td>
<td class="item column-amount"><b>{{ totals.3 }}<b></td>
<td class="item column-amount"><b>{{ totals.4 }}<b></td>
</tr>
</table>
<div class="footnote">
* Custom lines
</div>
</body>
</html>

View File

@ -0,0 +1,10 @@
<html>
<head>
<title>{% block title %}{{ bill.get_type_display }} - {{ bill.number }}{% endblock %}</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
{% block head %}{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,6 @@
{% if email_part == 'subject' %}Bill {{ bill.number }}{% endif %}
{% if email_part == 'message' %}Dear {{ bill.account.username }},
Find your {{ bill.get_type_display.lower }} attached.
If you have any question, please write us at support@orchestra.lan
{% endif %}

View File

@ -0,0 +1,217 @@
<html>
<style>
@page {
margin: 1cm;
margin-bottom: 0cm;
margin-top: 3cm;
size: a4 portrait;
background-image: url('img/letter_head.png');
@frame footer {
-pdf-frame-content: footerContent;
bottom: 0cm;
margin-left: 1cm;
margin-right: 1cm;
height: 2cm;
}
@frame simple {
-pdf-frame-content: simple;
bottom: 2.0cm;
height: 2.5cm;
margin-left: 1cm;
margin-right: 1cm;
}
}
div#buyer-details{
font-size: 120%;
}
div#specification{
margin-top: 20px;
}
div#specification td{
vertical-align: middle;
padding-top: 5px;
padding-bottom: 3px;
}
table td {
vertical-align: top;
padding: 2px 0;
height: 16px;
}
table td.amount{
text-align: right;
padding-right: 2px;
}
table td.total{
padding-top: 5px;
}
table th {
text-align: left;
border-bottom: 1px solid #000;
}
tr.uneven {
background-color: #efefef;
}
div#footerContent {
color: #777777;
}
div#footerContent a {
color: #790000;
text-decoration: none;
}
.date {
font-size: 90%;
color: #777;
}
div#totals {
margin: 20px 0;
}
div#simple td {
margin-left: 10px;
background-color: #efefef;
}
div#simple tr {
border-right: 1px solid #333;
}
div#simple table{
text-align: center;
border-left: 1px solid #999;
}
</style>
<body>
<h1>{{ bill_type }}</h1>
<div id="buyer-details">
<table>
<tr>
<td width="60%">
<strong>{{ buyer.name }}</strong><br>
{{ buyer.address }}<br>
{{ buyer.zipcode }} {{ buyer.city }}<br>
{{ buyer.country }}<br>
{{ buyer.vat_number }}<br>
</td>
<td width="20%">
<strong>Invoice number</strong><br />
<strong>Date</strong><br />
<strong>Due date</strong>
</td>
<td width="20%">
: {{ bill.ident }}<br />
: {{ bill.date|date:"d F, Y" }}<br />
: {{ bill.due_on|date:"d F, Y" }}<br />
</td>
</tr>
</table>
</div>
<div id="specification">
<table width="100%">
<tr>
<th width="5%">ID</th>
<th width="65%">{% trans Description %}</th>
<th width="20%">Amount</th>
<th width="10%">Price</th>
</tr>
{% for line in lines %}
<tr class="{% cycle 'even' 'uneven' %}"{% if forloop.last %} style="border-bottom: 1px solid #000;"{% endif %}>
<td class="ID">{{ line.order_id }}</td>
<td style="padding-left: 2px;">{{ line.description }}
<span class="date">({{ line.initial_date|date:"d-m-Y" }}{% if line.initial_date != line.final_date %} - {{ line.final_date|date:"d-m-Y" }}{% endif %})</span></td>
<td class="quantity">{{ line.amount }}</td>
<td class="amount total">&{{ currency }}; {{ line.price }}</td>
</tr>
{% endfor %}
</table>
</div>
<div id="totals">
<table width="100%">
<tr>
{% for tax, base in bases.items %}
<td width="60%">&nbsp;</td>
<td width="20%">Subtotal{% if bases.items|length > 1 %} (for {{ tax }}% taxes){% endif %}</td>
<td width="20%" class="amount">&{{ currency }}; {{ base }}</td>
{% endfor %}
</tr>
<tr>
{% for tax, value in taxes.items %}
<td width="60%">&nbsp;</td>
<td width="20%">Total {{ tax }}%</td>
<td width="20%" class="amount" style="border-bottom: 1px solid #333;">&{{ currency }}; {{ value }}</td>
{% endfor %}
</tr>
<tr>
<td width="60%">&nbsp;</td>
<td width="20%" class="total"><strong>Total</strong></td>
<td width="20%" class="amount total">&{{ currency }}; {{ total }}</td>
</tr>
</table>
</div>
<div id="simple">
<table>
<tr>
<td width="33%" style="padding-top: 5px;">IBAN</th>
<td width="34%" style="padding-top: 5px;">Invoice ID</th>
<td width="33%" style="padding-top: 5px;">Amount {{ currency.upper }}</th>
</tr>
<tr>
<td><strong>NL28INGB0004954664</strong></td>
<td><strong>{{ bill.ident }}</strong></td>
<td><strong>{{ total }}</strong></td>
</tr>
</table>
<p style="text-align:center;">The invoice is to be paid before <strong>{{ invoice.exp_date|date:"F jS, Y" }}</strong> with the mention of the invoice id.</p>
</div>
<div id="footerContent">
<table>
<tr>
<td width="33%">
{{ seller.name }}<br />
{{ seller.address }}<br />
{{ seller.city }}<br />
{{ seller.country }}<br />
</td>
<td width="5%">
Tel<br />
Web<br />
Email<br />
</td>
<td width="29%">
{{ seller_info.phone }}<br />
<a href="http://{{ seller_info.website }}">{{ seller_info.website }}</a><br />
{{ seller_info.email }}
</td>
<td width="8%">
Bank ING<br />
IBAN<br />
BTW<br />
KvK<br />
</td>
<td width="25%">
4954664<br />
NL28INGB0004954664<br />
NL 8207.29.449.B01<br />
27343027
</td>
</tr>
</table>
</div>
Payment info
</body>
</html>

View File

@ -0,0 +1,155 @@
{% extends 'bills/microspective.html' %}
{% load i18n %}
{% block head %}
<style type="text/css">
{% with color="#809708" %}
{% include 'bills/microspective.css' %}
{% endwith %}
#buyer-details {
clear: left;
margin-top: 40px;
margin-left: 54%;
margin-bottom: 40px;
}
.column-1 {
float: left;
font-size: 30;
font-weight: bold;
text-align: right;
color: #666;
width: 40%;
margin: 10px;
}
#extralines {
clear: left;
clear: right;
text-align: right;
color: #A40000;
font-weight: bold;
text-align: center;
}
#number-date {
font-size: large;
}
#number-value {
font-size: 30;
color: #809708;
}
.column-2 {
float: right;
padding: 15px;
margin: 10px;
margin-top: 0px;
width: 44%;
font-size: large;
}
#amount {
color: white;
background-color: #809708;
}
#amount-value {
font-size: 30;
font-weight: bold;
}
#date {
clear: left;
clear: right;
margin-top: 0px;
padding-top: 0px;
font-weight: bold;
color: #666;
}
#text {
clear: left;
clear: right;
text-align: right;
margin: 40px 10px 50px 10px;
font-weight: bold;
color: #666;
}
#text strong {
color: #809708;
}
hr {
margin-top: 20px;
border: 2px solid #809708;
clear: left;
}
</style>
{% endblock %}
{% block summary %}
<div style="position: relative; margin-top: 140px;">
<hr>
</div>
<div id="buyer-details">
<span class="name">{{ buyer.get_name }}</span><br>
{{ buyer.vat }}<br>
{{ buyer.address }}<br>
{{ buyer.zipcode }} - {{ buyer.city }}<br>
{% trans buyer.get_country_display %}<br>
</div>
<div id="number" class="column-1">
<span id="number-title">{% filter title %}{% trans bill.get_type_display %}{% endfilter %}</span><br>
<span id="number-value">{{ bill.number }}</span><br>
<span id="number-date">{{ bill.closed_on | default:now | date:"F j, Y" | capfirst }}</span><br>
</div>
<div id="amount" class="column-2">
<span id="amount-value">{{ bill.compute_total }} &{{ currency.lower }};</span><br>
<span id="amount-note">{% trans "Due date" %} {{ payment.due_date| default:default_due_date | date:"F j, Y" }}<br>
{% if not payment.message %}{% blocktrans with bank_account=seller_info.bank_account %}On {{ bank_account }}{% endblocktrans %}{% endif %}<br>
</span>
</div>
<div id="date" class="column-2">
{% with line=bill.lines.first %}
{% blocktrans with ini=line.start_on|date:"F j, Y" end=line.end_on|date:"F j, Y" %}From {{ ini }} to {{ end }}{% endblocktrans %}
{% endwith %}
</div>
{% endblock %}
{% block content %}
{% block lines %}
<div id="extralines">
{% for line in bill.lines.all %}
<ul>
{% if not forloop.first %}
<li>{{ line.description }}</li>
{% endif %}
</ul>
{% endfor %}
</div>
{% endblock %}
{% block text %}
<div id="text">
{% blocktrans %}
<strong>With your membership</strong> you are supporting ...
{% endblocktrans %}
</div>
{% endblock %}
{% endblock %}
{% block footer %}
<hr>
{{ block.super }}
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends 'bills/microspective.html' %}
{% block head %}
<style type="text/css">
{% with color="#2C5899" %}
{% include 'bills/microspective.css' %}
{% endwith %}
</style>
{% endblock %}
{% block payment %}
{% endblock %}

View File

@ -0,0 +1,298 @@
body {
/* max-width: 650px;*/
max-width: 820px;
margin: 40 auto !important;
/* margin-bottom: 30 !important;*/
float: none !important;
font-family: sans;
}
a {
font-size: 100%;
text-decoration: none;
vertical-align: baseline;
margin: 0;
padding: 0;
color: #666;
}
a:hover {
color: {{ color }};
text-decoration: underline;
}
#logo {
float: left;
font-weight: bold;
color: {{ color }};
margin: 1px 10px 15px 60px;
padding: 1px;
}
#bill-number {
float: right;
text-align: right;
font-size: 20;
font-weight: bold;
color: grey;
margin-top: 30px;
margin-bottom: 10px;
}
#bill-number .value {
font-size: 30;
color: {{ color }};
font-weight: normal;
}
/* SUMMARY */
#bill-summary {
clear: right;
}
#bill-summary > * {
float: right;
border: 1px solid grey;
padding: 7px 12px 7px 12px;
text-align: center;
font-size: large;
width: 100px;
overflow: hidden;
white-space: nowrap;
}
#bill-summary hr {
padding: 0;
margin-top: 20px;
color: #ccc;
margin-bottom: -1px;
float: none;
width: 100%;
border-left: none;
border-right: none;
border-bottom: 1px solid grey;
}
#bill-summary .title {
color: {{ color }};
font-size: x-small;
font-weight: bold;
position: relative;
top: -6px;
}
#bill-summary #total, #total .title {
background-color: {{ color }};
color: white;
font-weight: bold;
}
#bill-summary #due-date, #bill-date, #total {
border-bottom: 2px solid grey;
height: 32px;
}
#bill-summary #due-date {
border-right: 2px solid grey;
font-size: medium;
}
#bill-summary #bill-date {
border-left: 2px solid grey;
font-size: medium;
}
/* DETAILS */
#seller-details, #buyer-details {
margin: 40px;
}
#seller-details {
margin-top: 0px;
}
#seller-details p {
margin-top: 5px;
}
#seller-details .name {
font-weight: bold;
color: {{ color }};
}
#seller-details .contact {
float: left;
font-style: italic;
font-size: small;
color: #666;
}
#buyer-details {
margin: 30px 40px 30px 60px;
font-size: 15;
}
#buyer-details .name {
font-weight: bold;
}
/* LINES */
#lines > * {
-webkit-box-sizing: border-box; /* Safari/Chrome, other WebKit */
-moz-box-sizing: border-box; /* Firefox, other Gecko */
box-sizing: border-box; /* Opera/IE 8+ */
padding-left: 10px;
float: left;
padding: 5px;
text-align: center;
font-size: small;
}
#lines .title {
font-weight: bold;
border-bottom: 2px solid #CCC;
color: {{ color }};
}
#lines .last {
border-bottom: 1px solid #CCC;
}
#lines .subline {
padding-top: 0px;
}
#lines .column-id {
width: 8%;
text-align: right;
}
#lines .column-description {
width: 39%;
text-align: left;
}
#lines .column-period {
width: 23%;
}
#lines .column-quantity {
width: 10%;
}
#lines .column-rate {
width: 10%;
}
#lines .column-subtotal {
width: 10%;
text-align: right;
}
/* TOTALS */
#totals {
padding-top: 100px;
}
#totals > * {
-webkit-box-sizing: border-box; /* Safari/Chrome, other WebKit */
-moz-box-sizing: border-box; /* Firefox, other Gecko */
box-sizing: border-box; /* Opera/IE 8+ */
padding: 5px;
padding-left: 10px;
text-align: right;
font-size: small;
}
#totals .column-title {
font-weight: bold;
color: {{ color }};
width: 86%;
float: left;
}
#totals .column-value {
width: 14%;
float: left;
}
#totals .subtotal {
border-bottom: 1px solid #CCC;
font-weight: normal;
}
#totals .tax {
border-bottom: 2px solid #CCC;
font-weight: normal;
}
#totals .total {
font-weight: bold;
}
/* FOOTER */
.content {
display: table-row; /* height is dynamic, and will expand... */
height: 100%; /* ...as content is added (won't scroll) */
}
.wrapper {
display: table;
height: 100%;
width: 100%;
}
.footer {
display: table-row;
}
.footer .title {
color: {{ color }};
font-weight: bold;
}
.footer > * > * {
margin: 5px;
margin-bottom: 8px;
color: #666;
font-size: small;
text-align: justify;
}
#footer-column-1 {
float: left;
width: 48%;
}
#footer-column-2 {
float: right;
width: 48%;
}
#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

@ -0,0 +1,178 @@
{% extends 'bills/base.html' %}
{% load i18n %}
{% block head %}
<style type="text/css">
{% with color="#B23" %}
{% include 'bills/microspective.css' %}
{% endwith %}
</style>
{% endblock %}
{% 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 %}
<div style="border-bottom:5px solid {{ color }}; color:{{ color }}; font-size:30; margin-right: 20px;">
YOUR<br>
LOGO<br>
HERE<br>
</div>
{% endblock %}
</div>
<div id="seller-details">
<div claas="address">
<span class="name">{{ seller.get_name }}</span>
</div>
<div class="contact">
<p>{{ seller.vat }}<br>
{{ seller.address }}<br>
{{ seller.zipcode }} - {% trans seller.city %}<br>
{% trans seller.get_country_display %}<br>
</p>
<p><a href="tel:93-803-21-32">{{ seller_info.phone }}</a><br>
<a href="mailto:sales@pangea.org">{{ seller_info.email }}</a><br>
<a href="http://www.pangea.org">{{ seller_info.website }}</a></p>
</div>
</div>
{% endblock %}
{% block summary %}
<div id="bill-number">
{% filter title %}{% trans bill.get_type_display %}{% endfilter %}<br>
<span class="value">{{ bill.number }}</span><br>
</div>
<div id="bill-summary">
<hr>
<div id="due-date">
<span class="title">{% trans "DUE DATE" %}</span><br>
<psan class="value">{{ bill.due_on | default:default_due_date | date | capfirst }}</span>
</div>
<div id="total">
<span class="title">{% trans "TOTAL" %}</span><br>
<psan class="value">{{ bill.compute_total }} &{{ currency.lower }};</span>
</div>
<div id="bill-date">
<span class="title">{% blocktrans with bill_type=bill.get_type_display.upper %}{{ bill_type }} DATE{% endblocktrans %}</span><br>
<psan class="value">{{ bill.closed_on | default:now | date | capfirst }}</span>
</div>
</div>
<div id="buyer-details">
<span class="name">{{ buyer.get_name }}</span><br>
{{ buyer.vat }}<br>
{{ buyer.address }}<br>
{{ buyer.zipcode }} - {% trans buyer.city %}<br>
{% trans buyer.get_country_display %}<br>
</div>
{% endblock %}
{% block content %}
{% block lines %}
<div id="lines">
<span class="title column-id">id</span>
<span class="title column-description">{% trans "description" %}</span>
<span class="title column-period">{% trans "period" %}</span>
<span class="title column-quantity">{% trans "hrs/qty" %}</span>
<span class="title column-rate">{% trans "rate/price" %}</span>
<span class="title column-subtotal">{% trans "subtotal" %}</span>
<br>
{% for line in lines %}
{% with sublines=line.sublines.all description=line.description|slice:"38:" %}
<span class="{% if not sublines and not description %}last {% endif %}column-id">{% if not line.order_id %}L{% endif %}{{ line.order_id|default:line.pk }}</span>
<span class="{% if not sublines and not description %}last {% endif %}column-description">{{ line.description|safe|slice:":38" }}</span>
<span class="{% if not sublines and not description %}last {% endif %}column-period">{{ line.get_verbose_period }}</span>
<span class="{% if not sublines and not description %}last {% endif %}column-quantity">{{ line.get_verbose_quantity|default:"&nbsp;"|safe }}</span>
<span class="{% if not sublines and not description %}last {% endif %}column-rate">{% if line.rate %}{{ line.rate }} &{{ currency.lower }};{% else %}&nbsp;{% endif %}</span>
<span class="{% if not sublines and not description %}last {% endif %}column-subtotal">{{ line.subtotal }} &{{ currency.lower }};</span>
<br>
{% if description %}
<span class="{% if not sublines %}last {% endif %}subline column-id">&nbsp;</span>
<span class="{% if not sublines %}last {% endif %}subline column-description">{{ description|safe|truncatechars:39 }}</span>
<span class="{% if not sublines %}last {% endif %}subline column-period">&nbsp;</span>
<span class="{% if not sublines %}last {% endif %}subline column-quantity">&nbsp;</span>
<span class="{% if not sublines %}last {% endif %}subline column-rate">&nbsp;</span>
<span class="{% if not sublines %}last {% endif %}subline column-subtotal">&nbsp;</span>
{% endif %}
{% for subline in sublines %}
<span class="{% if forloop.last %}last {% endif %}subline column-id">&nbsp;</span>
<span class="{% if forloop.last %}last {% endif %}subline column-description">{{ subline.description|safe|truncatechars:39 }}</span>
<span class="{% if forloop.last %}last {% endif %}subline column-period">&nbsp;</span>
<span class="{% if forloop.last %}last {% endif %}subline column-quantity">&nbsp;</span>
<span class="{% if forloop.last %}last {% endif %}subline column-rate">&nbsp;</span>
<span class="{% if forloop.last %}last {% endif %}subline column-subtotal">{{ subline.total }} &{{ currency.lower }};</span>
<br>
{% endfor %}
{% endwith %}
{% endfor %}
</div>
{% endblock %}
{% block totals %}
<div id="totals">
<br>&nbsp;<br>
{% for tax, subtotal in bill.compute_subtotals.items %}
<span class="subtotal column-title">{% trans "subtotal" %} {{ tax }}% {% trans "VAT" %}</span>
<span class="subtotal column-value">{{ subtotal | first }} &{{ currency.lower }};</span>
<br>
<span class="tax column-title">{% trans "taxes" %} {{ tax }}% {% trans "VAT" %}</span>
<span class="tax column-value">{{ subtotal | last }} &{{ currency.lower }};</span>
<br>
{% endfor %}
<span class="total column-title">{% trans "total" %}</span>
<span class="total column-value">{{ bill.compute_total }} &{{ currency.lower }};</span>
<br>
</div>
{% endblock %}
{% endblock %}
{% block footer %}
</div>
<div class="footer">
<div id="footer-column-1">
<div id="comments">
{% block comments %}
{% if bill.comments %}
<span class="title">{% trans "COMMENTS" %}</span> {{ bill.comments|linebreaksbr }}
{% endif %}
{% endblock %}
</div>
</div>
<div id="footer-column-2">
{% block payment %}
<div id="payment">
<span class="title">{% trans "PAYMENT" %}</span>
{% if payment.message %}
{{ payment.message|safe }}
{% else %}
{% blocktrans with type=bill.get_type_display.lower %}
You can pay our <i>{{ type }}</i> by bank transfer.<br>
Please make sure to state your name and the <i>{{ type }}</i> number.
Our bank account number is <br>
{% endblocktrans %}
<strong>{{ seller_info.bank_account }}</strong>
{% endif %}
</div>
{% endblock %}
{% block questions %}
<div id="questions">
<span class="title">{% trans "QUESTIONS" %}</span>
{% blocktrans with type=bill.get_type_display.lower email=seller_info.email %}
If you have any question about your <i>{{ type }}</i>, please
feel free to write us at {{ email }}. We will reply as soon as we get
your message.
{% endblocktrans %}
</div>
{% endblock %}
</div>
</div>
</div>
{% endblock %}
{% endblock %}

View File

@ -0,0 +1 @@
default_app_config = 'orchestra.contrib.contacts.apps.ContactsConfig'

View File

@ -0,0 +1,113 @@
from django import forms
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from orchestra.admin import AtLeastOneRequiredInlineFormSet, ExtendedModelAdmin
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 .filters import EmailUsageListFilter
from .models import Contact
class ContactAdmin(AccountAdminMixin, ExtendedModelAdmin):
list_display = (
'dispaly_name', 'email', 'phone', 'phone2', 'country', 'account_link'
)
# TODO email usage custom filter contains
list_filter = (EmailUsageListFilter,)
search_fields = (
'account__username', 'account__full_name', 'short_name', 'full_name', 'phone', 'phone2',
'email'
)
fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('account_link', 'short_name', 'full_name')
}),
(_("Email"), {
'classes': ('wide',),
'fields': ('email', 'email_usage',)
}),
(_("Phone"), {
'classes': ('wide',),
'fields': ('phone', 'phone2'),
}),
(_("Postal address"), {
'classes': ('wide',),
'fields': ('address', ('zipcode', 'city'), 'country')
}),
)
# TODO don't repeat all only for account_link do it on accountadmin
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('account', 'short_name', 'full_name')
}),
(_("Email"), {
'classes': ('wide',),
'fields': ('email', 'email_usage',)
}),
(_("Phone"), {
'classes': ('wide',),
'fields': ('phone', 'phone2'),
}),
(_("Postal address"), {
'classes': ('wide',),
'fields': ('address', ('zipcode', 'city'), 'country')
}),
)
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)
return super(ContactAdmin, self).formfield_for_dbfield(db_field, **kwargs)
admin.site.register(Contact, ContactAdmin)
class ContactInline(admin.StackedInline):
model = Contact
formset = AtLeastOneRequiredInlineFormSet
extra = 0
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':
kwargs['widget'] = forms.TextInput(attrs={'size':'15'})
if db_field.name == 'address':
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 2})
if db_field.name == 'email_usage':
kwargs['widget'] = PaddingCheckboxSelectMultiple(45)
return super(ContactInline, self).formfield_for_dbfield(db_field, **kwargs)
insertattr(AccountAdmin, 'inlines', ContactInline)
search_fields = (
'contacts__short_name', 'contacts__full_name',
)
for field in search_fields:
insertattr(AccountAdmin, 'search_fields', field)

View File

@ -0,0 +1,15 @@
from rest_framework import viewsets
from orchestra.api import router, LogApiMixin
from orchestra.contrib.accounts.api import AccountApiMixin
from .models import Contact
from .serializers import ContactSerializer
class ContactViewSet(LogApiMixin, AccountApiMixin, viewsets.ModelViewSet):
queryset = Contact.objects.all()
serializer_class = ContactSerializer
router.register(r'contacts', ContactViewSet)

View File

@ -0,0 +1,12 @@
from django.apps import AppConfig
from orchestra.core import accounts
class ContactsConfig(AppConfig):
name = 'orchestra.contrib.contacts'
verbose_name = 'Contacts'
def ready(self):
from .models import Contact
accounts.register(Contact, icon='contact_book.png')

View File

@ -0,0 +1,18 @@
from django.contrib.admin import SimpleListFilter
from django.utils.translation import gettext_lazy as _
from .models import Contact
class EmailUsageListFilter(SimpleListFilter):
title = _("email usages")
parameter_name = 'email_usages'
def lookups(self, request, model_admin):
return Contact.EMAIL_USAGES
def queryset(self, request, queryset):
value = self.value()
if value is None:
return queryset
return queryset.filter(email_usages=value.split(','))

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