diff --git a/TODO.md b/TODO.md index 19ad5cae..5edb02a1 100644 --- a/TODO.md +++ b/TODO.md @@ -276,8 +276,14 @@ https://code.djangoproject.com/ticket/24576 * force ignore slack billing period overridig when billing * fpm reload starts new pools? * rename resource.monitors to resource.backends ? -* abstract model classes enabling overriding? +* 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?) # 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 - + + + +# Link related orders on bill line +# Customize those service.descriptions that are +# replace multichoicefield and jsonfield by ArrayField, HStoreField diff --git a/docs/images/orchestration.svg b/docs/images/orchestration.svg new file mode 100644 index 00000000..65ea3de2 --- /dev/null +++ b/docs/images/orchestration.svg @@ -0,0 +1,3628 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + HttpRequest + HttpResponse + collect(save/delete signals) + + + Transaction + + + Admin + + + + REST API + + + + Models + + + + URLDispatcher + + + + + + + + + + + OrchestrationMiddleware + + + + BackendOperation + + diff --git a/orchestra/admin/utils.py b/orchestra/admin/utils.py index fede4fe3..37e7eff9 100644 --- a/orchestra/admin/utils.py +++ b/orchestra/admin/utils.py @@ -107,10 +107,15 @@ def admin_link(*args, **kwargs): if not getattr(obj, 'pk', None): return '---' url = change_url(obj) + display = kwargs.get('display') + if display: + display = getattr(obj, display, 'merda') + else: + display = obj extra = '' if kwargs['popup']: extra = 'onclick="return showAddAnotherPopup(this);"' - return '%s' % (url, extra, obj) + return '%s' % (url, extra, display) @admin_field diff --git a/orchestra/conf/base_settings.py b/orchestra/conf/base_settings.py index fee78fd1..f965df1d 100644 --- a/orchestra/conf/base_settings.py +++ b/orchestra/conf/base_settings.py @@ -35,6 +35,7 @@ MEDIA_URL = '/media/' ALLOWED_HOSTS = '*' + # Set this to True to wrap each HTTP request in a transaction on this database. # ATOMIC REQUESTS do not wrap middlewares (orchestra.contrib.orchestration.middlewares.OperationsMiddleware) ATOMIC_REQUESTS = False @@ -101,6 +102,7 @@ INSTALLED_APPS = ( 'rest_framework', 'rest_framework.authtoken', 'passlib.ext.django', + 'django_countries', # Django.contrib 'django.contrib.auth', diff --git a/orchestra/conf/project_template/project_name/settings.py b/orchestra/conf/project_template/project_name/settings.py index 20dee7c2..3accc5c5 100644 --- a/orchestra/conf/project_template/project_name/settings.py +++ b/orchestra/conf/project_template/project_name/settings.py @@ -40,6 +40,7 @@ DATABASES = { 'PASSWORD': 'orchestra', # Not used with sqlite3. 'HOST': 'localhost', # Set to empty string for localhost. Not used with sqlite3. 'PORT': '', # Set to empty string for default. Not used with sqlite3. + 'CONN_MAX_AGE': 300, } } diff --git a/orchestra/contrib/bills/admin.py b/orchestra/contrib/bills/admin.py index c8b0d887..ad4e51ef 100644 --- a/orchestra/contrib/bills/admin.py +++ b/orchestra/contrib/bills/admin.py @@ -11,7 +11,7 @@ from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ from orchestra.admin import ExtendedModelAdmin -from orchestra.admin.utils import admin_date, insertattr +from orchestra.admin.utils import admin_date, insertattr, admin_link from orchestra.contrib.accounts.admin import AccountAdminMixin, AccountAdmin from orchestra.forms.widgets import paddingCheckboxSelectMultiple @@ -29,8 +29,13 @@ PAYMENT_STATE_COLORS = { class BillLineInline(admin.TabularInline): model = BillLine - fields = ('description', 'rate', 'quantity', 'tax', 'subtotal', 'display_total') - readonly_fields = ('display_total',) + 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') def display_total(self, line): total = line.get_total() @@ -46,9 +51,9 @@ class BillLineInline(admin.TabularInline): def formfield_for_dbfield(self, db_field, **kwargs): """ Make value input widget bigger """ if db_field.name == 'description': - kwargs['widget'] = forms.TextInput(attrs={'size':'110'}) - else: - kwargs['widget'] = forms.TextInput(attrs={'size':'13'}) + 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(BillLineInline, self).formfield_for_dbfield(db_field, **kwargs) def get_queryset(self, request): @@ -61,7 +66,8 @@ class ClosedBillLineInline(BillLineInline): # https://code.djangoproject.com/ticket/9025 fields = ( - 'display_description', 'rate', 'quantity', 'tax', 'display_subtotal', 'display_total' + 'display_description', 'order_link', 'start_on', 'end_on', 'rate', 'quantity', 'tax', + 'display_subtotal', 'display_total' ) readonly_fields = fields @@ -157,10 +163,10 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin): num_lines.short_description = _("lines") def display_total(self, bill): - return "%s &%s;" % (round(bill.totals, 2), settings.BILLS_CURRENCY.lower()) + return "%s &%s;" % (round(bill.computed_total or 0, 2), settings.BILLS_CURRENCY.lower()) display_total.allow_tags = True display_total.short_description = _("total") - display_total.admin_order_field = 'totals' + display_total.admin_order_field = 'computed_total' def type_link(self, bill): bill_type = bill.type.lower() @@ -235,7 +241,7 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin): qs = super(BillAdmin, self).get_queryset(request) qs = qs.annotate( models.Count('lines'), - totals=Sum( + computed_total=Sum( (F('lines__subtotal') + Coalesce(F('lines__sublines__total'), 0)) * (1+F('lines__tax')/100) ), ) diff --git a/orchestra/contrib/bills/locale/ca/LC_MESSAGES/django.mo b/orchestra/contrib/bills/locale/ca/LC_MESSAGES/django.mo index a3fd9f38..e2aeb0f7 100644 Binary files a/orchestra/contrib/bills/locale/ca/LC_MESSAGES/django.mo and b/orchestra/contrib/bills/locale/ca/LC_MESSAGES/django.mo differ diff --git a/orchestra/contrib/bills/locale/ca/LC_MESSAGES/django.po b/orchestra/contrib/bills/locale/ca/LC_MESSAGES/django.po index 57872c77..3c731913 100644 --- a/orchestra/contrib/bills/locale/ca/LC_MESSAGES/django.po +++ b/orchestra/contrib/bills/locale/ca/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2015-03-29 20:21+0000\n" +"POT-Creation-Date: 2015-04-20 11:02+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,112 +18,112 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: actions.py:35 +#: actions.py:36 msgid "Download" msgstr "Descarrega" -#: actions.py:45 +#: actions.py:46 msgid "View" msgstr "Vista" -#: actions.py:53 +#: actions.py:54 msgid "Selected bills should be in open state" msgstr "" -#: actions.py:71 +#: actions.py:72 msgid "Selected bills have been closed" msgstr "" -#: actions.py:80 -#, python-format -msgid "One related transaction has been created" -msgstr "" - #: actions.py:81 #, python-format +msgid "One related transaction has been created" +msgstr "" + +#: actions.py:82 +#, python-format msgid "%i related transactions have been created" msgstr "" -#: actions.py:87 +#: actions.py:88 msgid "Are you sure about closing the following bills?" msgstr "" -#: actions.py:88 +#: actions.py:89 msgid "" "Once a bill is closed it can not be further modified.

Please select a " "payment source for the selected bills" msgstr "" -#: actions.py:101 +#: actions.py:102 msgid "Close" msgstr "" -#: actions.py:112 +#: actions.py:113 msgid "Resend" msgstr "" -#: actions.py:129 models.py:309 +#: actions.py:130 models.py:312 msgid "Not enough information stored for undoing" msgstr "" -#: actions.py:132 models.py:311 +#: actions.py:133 models.py:314 msgid "Dates don't match" msgstr "" -#: actions.py:147 +#: actions.py:148 msgid "Can not move lines which are not in open state." msgstr "" -#: actions.py:152 +#: actions.py:153 msgid "Can not move lines from different accounts" msgstr "" -#: actions.py:160 +#: actions.py:161 msgid "Target account different than lines account." msgstr "" -#: actions.py:167 +#: actions.py:168 msgid "Lines moved" msgstr "" -#: admin.py:41 forms.py:12 +#: admin.py:43 admin.py:86 forms.py:11 msgid "Total" msgstr "" -#: admin.py:69 +#: admin.py:73 msgid "Description" msgstr "Descripció" -#: admin.py:77 +#: admin.py:81 msgid "Subtotal" msgstr "" -#: admin.py:104 +#: admin.py:113 msgid "Manage bill lines of multiple bills." msgstr "" -#: admin.py:109 +#: admin.py:118 #, python-format msgid "Manage %s bill lines." msgstr "" -#: admin.py:129 +#: admin.py:138 msgid "Raw" msgstr "" -#: admin.py:147 +#: admin.py:157 msgid "lines" msgstr "" -#: admin.py:152 templates/bills/microspective.html:107 +#: admin.py:162 templates/bills/microspective.html:107 msgid "total" msgstr "" -#: admin.py:160 models.py:85 models.py:340 +#: admin.py:170 models.py:87 models.py:342 msgid "type" msgstr "tipus" -#: admin.py:177 +#: admin.py:187 msgid "Payment" msgstr "Pagament" @@ -131,15 +131,15 @@ msgstr "Pagament" msgid "All" msgstr "Tot" -#: filters.py:18 models.py:75 +#: filters.py:18 models.py:77 msgid "Invoice" msgstr "Factura" -#: filters.py:19 models.py:76 +#: filters.py:19 models.py:78 msgid "Amendment invoice" msgstr "Factura rectificativa" -#: filters.py:20 models.py:77 +#: filters.py:20 models.py:79 msgid "Fee" msgstr "Quota de soci" @@ -167,15 +167,15 @@ msgstr "No" msgid "Number" msgstr "" -#: forms.py:11 +#: forms.py:10 msgid "Account" msgstr "" -#: forms.py:13 +#: forms.py:12 msgid "Type" msgstr "" -#: forms.py:15 +#: forms.py:13 msgid "Source" msgstr "" @@ -193,158 +193,178 @@ msgstr "" msgid "Main" msgstr "" -#: models.py:20 models.py:83 +#: models.py:22 models.py:85 msgid "account" msgstr "" -#: models.py:22 +#: models.py:24 msgid "name" msgstr "" -#: models.py:23 +#: models.py:25 msgid "Account full name will be used when left blank." msgstr "" -#: models.py:24 +#: models.py:26 msgid "address" msgstr "" -#: models.py:25 +#: models.py:27 msgid "city" msgstr "" -#: models.py:27 +#: models.py:29 msgid "zip code" msgstr "" -#: models.py:28 +#: models.py:30 msgid "Enter a valid zipcode." msgstr "" -#: models.py:29 +#: models.py:31 msgid "country" msgstr "" -#: models.py:32 +#: models.py:34 msgid "VAT number" msgstr "NIF" -#: models.py:64 +#: models.py:66 msgid "Paid" msgstr "" -#: models.py:65 +#: models.py:67 msgid "Pending" msgstr "" -#: models.py:66 +#: models.py:68 msgid "Bad debt" msgstr "" -#: models.py:78 +#: models.py:80 msgid "Amendment Fee" msgstr "" -#: models.py:79 +#: models.py:81 msgid "Pro forma" msgstr "" -#: models.py:82 +#: models.py:84 msgid "number" msgstr "" -#: models.py:86 +#: models.py:88 msgid "created on" msgstr "" -#: models.py:87 +#: models.py:89 msgid "closed on" msgstr "" -#: models.py:88 +#: models.py:90 msgid "open" msgstr "" -#: models.py:89 +#: models.py:91 msgid "sent" msgstr "" -#: models.py:90 +#: models.py:92 msgid "due on" msgstr "" -#: models.py:91 +#: models.py:93 msgid "updated on" msgstr "" -#: models.py:93 +#: models.py:96 msgid "comments" -msgstr "" +msgstr "comentaris" -#: models.py:94 +#: models.py:97 msgid "HTML" msgstr "" -#: models.py:271 +#: models.py:273 msgid "bill" msgstr "" -#: models.py:272 models.py:337 templates/bills/microspective.html:73 +#: models.py:274 models.py:339 templates/bills/microspective.html:73 msgid "description" msgstr "descripció" -#: models.py:273 +#: models.py:275 msgid "rate" msgstr "tarifa" -#: models.py:274 +#: models.py:276 msgid "quantity" msgstr "quantitat" -#: models.py:275 templates/bills/microspective.html:76 +#: models.py:277 +#, fuzzy +#| msgid "quantity" +msgid "Verbose quantity" +msgstr "quantitat" + +#: models.py:278 templates/bills/microspective.html:76 +#: templates/bills/microspective.html:100 msgid "subtotal" msgstr "" -#: models.py:276 +#: models.py:279 msgid "tax" msgstr "impostos" -#: models.py:282 +#: models.py:284 msgid "Informative link back to the order" msgstr "" -#: models.py:283 +#: models.py:285 msgid "order billed" msgstr "" -#: models.py:284 +#: models.py:286 msgid "order billed until" msgstr "" -#: models.py:285 +#: models.py:287 msgid "created" msgstr "" -#: models.py:287 +#: models.py:289 msgid "amended line" msgstr "" -#: models.py:330 +#: models.py:332 msgid "Volume" msgstr "" -#: models.py:331 +#: models.py:333 msgid "Compensation" msgstr "" -#: models.py:332 +#: models.py:334 msgid "Other" msgstr "" -#: models.py:336 +#: models.py:338 msgid "bill line" msgstr "" +#: templates/bills/microspective.html:49 +msgid "DUE DATE" +msgstr "VENCIMENT" + +#: templates/bills/microspective.html:53 +msgid "TOTAL" +msgstr "TOTAL" + +#: templates/bills/microspective.html:57 +#, python-format +msgid "%(bill_type|upper)s DATE " +msgstr "DATA %(bill_type|upper)s" + #: templates/bills/microspective.html:74 msgid "hrs/qty" msgstr "hrs/quant" @@ -360,7 +380,7 @@ msgstr "IVA" #: templates/bills/microspective.html:103 msgid "taxes" -msgstr "" +msgstr "impostos" #: templates/bills/microspective.html:119 msgid "COMMENTS" @@ -371,15 +391,17 @@ msgid "PAYMENT" msgstr "PAGAMENT" #: templates/bills/microspective.html:129 -#, python-format msgid "" "\n" " You can pay our %(type)s by bank transfer.
\n" -" Please make sure to state your name and the " -"%(bill.get_type_display.lower)s number.\n" +" Please make sure to state your name and the %(type)s " +"number.\n" " Our bank account number is
\n" " " msgstr "" +"\n" +"Pots pagar aquesta %(type)s per transferencia banacaria.
Inclou el teu " +"nom i el numero de %(type)s. El nostre compte bancari és" #: templates/bills/microspective.html:138 msgid "QUESTIONS" diff --git a/orchestra/contrib/bills/migrations/0003_auto_20150420_1223.py b/orchestra/contrib/bills/migrations/0003_auto_20150420_1223.py new file mode 100644 index 00000000..802b87aa --- /dev/null +++ b/orchestra/contrib/bills/migrations/0003_auto_20150420_1223.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.utils.timezone import utc +import datetime + + +class Migration(migrations.Migration): + + dependencies = [ + ('bills', '0002_auto_20150413_1937'), + ] + + operations = [ + migrations.AddField( + model_name='billline', + name='end_on', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='billline', + name='start_on', + field=models.DateTimeField(default=datetime.datetime(2015, 4, 20, 12, 23, 38, 471684, tzinfo=utc)), + preserve_default=False, + ), + migrations.AlterField( + model_name='billcontact', + name='country', + field=models.CharField(max_length=20, verbose_name='country', default='ES', choices=[('IQ', 'Iraq'), ('MN', 'Mongolia'), ('CX', 'Christmas Island'), ('RO', 'Romania'), ('KR', 'Korea (the Republic of)'), ('TH', 'Thailand'), ('FO', 'Faroe Islands'), ('CZ', 'Czech Republic'), ('ER', 'Eritrea'), ('MA', 'Morocco'), ('SA', 'Saudi Arabia'), ('SB', 'Solomon Islands'), ('GA', 'Gabon'), ('RS', 'Serbia'), ('HM', 'Heard Island and McDonald Islands'), ('GW', 'Guinea-Bissau'), ('KE', 'Kenya'), ('BE', 'Belgium'), ('MV', 'Maldives'), ('SR', 'Suriname'), ('AZ', 'Azerbaijan'), ('KG', 'Kyrgyzstan'), ('UA', 'Ukraine'), ('CF', 'Central African Republic'), ('PM', 'Saint Pierre and Miquelon'), ('GU', 'Guam'), ('ZM', 'Zambia'), ('AI', 'Anguilla'), ('VU', 'Vanuatu'), ('MZ', 'Mozambique'), ('TF', 'French Southern Territories'), ('PG', 'Papua New Guinea'), ('TT', 'Trinidad and Tobago'), ('NF', 'Norfolk Island'), ('KM', 'Comoros'), ('JM', 'Jamaica'), ('NU', 'Niue'), ('MH', 'Marshall Islands'), ('AL', 'Albania'), ('KY', 'Cayman Islands'), ('FR', 'France'), ('BA', 'Bosnia and Herzegovina'), ('GD', 'Grenada'), ('KP', "Korea (the Democratic People's Republic of)"), ('SZ', 'Swaziland'), ('TN', 'Tunisia'), ('CR', 'Costa Rica'), ('IO', 'British Indian Ocean Territory'), ('BY', 'Belarus'), ('ST', 'Sao Tome and Principe'), ('VC', 'Saint Vincent and the Grenadines'), ('CH', 'Switzerland'), ('AG', 'Antigua and Barbuda'), ('TO', 'Tonga'), ('CG', 'Congo'), ('MC', 'Monaco'), ('PS', 'Palestine, State of'), ('YE', 'Yemen'), ('PW', 'Palau'), ('VG', 'Virgin Islands (British)'), ('MQ', 'Martinique'), ('NZ', 'New Zealand'), ('TZ', 'Tanzania, United Republic of'), ('KZ', 'Kazakhstan'), ('NC', 'New Caledonia'), ('IT', 'Italy'), ('BQ', 'Bonaire, Sint Eustatius and Saba'), ('GI', 'Gibraltar'), ('EE', 'Estonia'), ('PN', 'Pitcairn'), ('TV', 'Tuvalu'), ('TJ', 'Tajikistan'), ('FJ', 'Fiji'), ('OM', 'Oman'), ('MY', 'Malaysia'), ('GL', 'Greenland'), ('PE', 'Peru'), ('SX', 'Sint Maarten (Dutch part)'), ('CY', 'Cyprus'), ('GG', 'Guernsey'), ('GS', 'South Georgia and the South Sandwich Islands'), ('SK', 'Slovakia'), ('BO', 'Bolivia (Plurinational State of)'), ('CI', "Côte d'Ivoire"), ('MG', 'Madagascar'), ('UZ', 'Uzbekistan'), ('IR', 'Iran (Islamic Republic of)'), ('CV', 'Cabo Verde'), ('MX', 'Mexico'), ('GM', 'Gambia'), ('TC', 'Turks and Caicos Islands'), ('TK', 'Tokelau'), ('BZ', 'Belize'), ('SE', 'Sweden'), ('WF', 'Wallis and Futuna'), ('HT', 'Haiti'), ('MR', 'Mauritania'), ('GN', 'Guinea'), ('MU', 'Mauritius'), ('GB', 'United Kingdom of Great Britain and Northern Ireland'), ('LS', 'Lesotho'), ('LU', 'Luxembourg'), ('JE', 'Jersey'), ('MF', 'Saint Martin (French part)'), ('PF', 'French Polynesia'), ('IS', 'Iceland'), ('LA', "Lao People's Democratic Republic"), ('IN', 'India'), ('AX', 'Åland Islands'), ('VN', 'Viet Nam'), ('MM', 'Myanmar'), ('RW', 'Rwanda'), ('WS', 'Samoa'), ('MW', 'Malawi'), ('EH', 'Western Sahara'), ('GH', 'Ghana'), ('DO', 'Dominican Republic'), ('HN', 'Honduras'), ('AS', 'American Samoa'), ('TD', 'Chad'), ('NG', 'Nigeria'), ('DJ', 'Djibouti'), ('ZA', 'South Africa'), ('BI', 'Burundi'), ('TM', 'Turkmenistan'), ('EC', 'Ecuador'), ('GE', 'Georgia'), ('NP', 'Nepal'), ('AT', 'Austria'), ('PA', 'Panama'), ('BR', 'Brazil'), ('MD', 'Moldova (the Republic of)'), ('GY', 'Guyana'), ('KH', 'Cambodia'), ('CL', 'Chile'), ('NO', 'Norway'), ('SJ', 'Svalbard and Jan Mayen'), ('BJ', 'Benin'), ('CO', 'Colombia'), ('CC', 'Cocos (Keeling) Islands'), ('SH', 'Saint Helena, Ascension and Tristan da Cunha'), ('UM', 'United States Minor Outlying Islands'), ('BF', 'Burkina Faso'), ('UG', 'Uganda'), ('GR', 'Greece'), ('TW', 'Taiwan (Province of China)'), ('SO', 'Somalia'), ('DE', 'Germany'), ('PL', 'Poland'), ('TL', 'Timor-Leste'), ('BT', 'Bhutan'), ('CA', 'Canada'), ('HR', 'Croatia'), ('BB', 'Barbados'), ('LR', 'Liberia'), ('PR', 'Puerto Rico'), ('GF', 'French Guiana'), ('IM', 'Isle of Man'), ('VI', 'Virgin Islands (U.S.)'), ('HU', 'Hungary'), ('ES', 'Spain'), ('AR', 'Argentina'), ('CU', 'Cuba'), ('AU', 'Australia'), ('NI', 'Nicaragua'), ('SS', 'South Sudan'), ('IE', 'Ireland'), ('BH', 'Bahrain'), ('GQ', 'Equatorial Guinea'), ('SC', 'Seychelles'), ('PH', 'Philippines'), ('SM', 'San Marino'), ('ID', 'Indonesia'), ('HK', 'Hong Kong'), ('VE', 'Venezuela (Bolivarian Republic of)'), ('PY', 'Paraguay'), ('ZW', 'Zimbabwe'), ('GT', 'Guatemala'), ('CD', 'Congo (the Democratic Republic of the)'), ('ME', 'Montenegro'), ('RE', 'Réunion'), ('LK', 'Sri Lanka'), ('FK', 'Falkland Islands [Malvinas]'), ('BL', 'Saint Barthélemy'), ('NR', 'Nauru'), ('LV', 'Latvia'), ('KW', 'Kuwait'), ('IL', 'Israel'), ('BV', 'Bouvet Island'), ('SY', 'Syrian Arab Republic'), ('BS', 'Bahamas'), ('CW', 'Curaçao'), ('CM', 'Cameroon'), ('SV', 'El Salvador'), ('SL', 'Sierra Leone'), ('DM', 'Dominica'), ('US', 'United States of America'), ('LB', 'Lebanon'), ('AD', 'Andorra'), ('CN', 'China'), ('SN', 'Senegal'), ('LI', 'Liechtenstein'), ('JP', 'Japan'), ('KI', 'Kiribati'), ('BM', 'Bermuda'), ('EG', 'Egypt'), ('UY', 'Uruguay'), ('BD', 'Bangladesh'), ('PK', 'Pakistan'), ('MT', 'Malta'), ('CK', 'Cook Islands'), ('MK', 'Macedonia (the former Yugoslav Republic of)'), ('SI', 'Slovenia'), ('ET', 'Ethiopia'), ('BG', 'Bulgaria'), ('GP', 'Guadeloupe'), ('BW', 'Botswana'), ('VA', 'Holy See'), ('AO', 'Angola'), ('AQ', 'Antarctica'), ('LY', 'Libya'), ('TR', 'Turkey'), ('TG', 'Togo'), ('LT', 'Lithuania'), ('QA', 'Qatar'), ('AM', 'Armenia'), ('DZ', 'Algeria'), ('SD', 'Sudan'), ('ML', 'Mali'), ('MP', 'Northern Mariana Islands'), ('LC', 'Saint Lucia'), ('NA', 'Namibia'), ('MO', 'Macao'), ('KN', 'Saint Kitts and Nevis'), ('JO', 'Jordan'), ('RU', 'Russian Federation'), ('AW', 'Aruba'), ('AF', 'Afghanistan'), ('SG', 'Singapore'), ('DK', 'Denmark'), ('MS', 'Montserrat'), ('YT', 'Mayotte'), ('NL', 'Netherlands'), ('FM', 'Micronesia (Federated States of)'), ('BN', 'Brunei Darussalam'), ('AE', 'United Arab Emirates'), ('PT', 'Portugal'), ('NE', 'Niger'), ('FI', 'Finland')]), + ), + ] diff --git a/orchestra/contrib/bills/models.py b/orchestra/contrib/bills/models.py index 6a25a270..83cd7fa6 100644 --- a/orchestra/contrib/bills/models.py +++ b/orchestra/contrib/bills/models.py @@ -1,3 +1,4 @@ +import datetime from dateutil.relativedelta import relativedelta from django.core.validators import ValidationError, RegexValidator @@ -277,9 +278,8 @@ class BillLine(models.Model): verbose_quantity = models.CharField(_("Verbose quantity"), max_length=16) subtotal = models.DecimalField(_("subtotal"), max_digits=12, decimal_places=2) tax = models.DecimalField(_("tax"), max_digits=4, decimal_places=2) - # Undo -# initial = models.DateTimeField(null=True) -# end = models.DateTimeField(null=True) + start_on = models.DateField(_("start")) + end_on = models.DateField(_("end"), null=True) order = models.ForeignKey(settings.BILLS_ORDER_MODEL, null=True, blank=True, help_text=_("Informative link back to the order"), on_delete=models.SET_NULL) order_billed_on = models.DateField(_("order billed"), null=True, blank=True) @@ -305,6 +305,15 @@ class BillLine(models.Model): def get_verbose_quantity(self): return self.verbose_quantity or self.quantity + def get_verbose_period(self): + ini = self.start_on.strftime("%b, %Y") + if not self.end_on: + return ini + end = (self.end_on - datetime.timedelta(seconds=1)).strftime("%b, %Y") + if ini == end: + return ini + return _("{ini} to {end}").format(ini=ini, end=end) + def undo(self): # TODO warn user that undoing bills with compensations lead to compensation lost for attr in ['order_id', 'order_billed_on', 'order_billed_until']: diff --git a/orchestra/contrib/bills/templates/bills/microspective-fee.html b/orchestra/contrib/bills/templates/bills/microspective-fee.html index 7c4d4da0..7ba9d6b4 100644 --- a/orchestra/contrib/bills/templates/bills/microspective-fee.html +++ b/orchestra/contrib/bills/templates/bills/microspective-fee.html @@ -108,7 +108,7 @@ hr {

-From {{ bill.lines.get.description }} +From {{ bill.lines.get.get_verbose_period }}
{% endblock %} {% block content %} diff --git a/orchestra/contrib/bills/templates/bills/microspective.css b/orchestra/contrib/bills/templates/bills/microspective.css index eb984dcd..628ac261 100644 --- a/orchestra/contrib/bills/templates/bills/microspective.css +++ b/orchestra/contrib/bills/templates/bills/microspective.css @@ -175,10 +175,14 @@ a:hover { } #lines .column-description { - width: 65%; + width: 45%; text-align: left; } +#lines .column-period { + width: 20%; +} + #lines .column-quantity { width: 10%; } diff --git a/orchestra/contrib/bills/templates/bills/microspective.html b/orchestra/contrib/bills/templates/bills/microspective.html index be997133..c51fd7c8 100644 --- a/orchestra/contrib/bills/templates/bills/microspective.html +++ b/orchestra/contrib/bills/templates/bills/microspective.html @@ -28,8 +28,8 @@

{{ seller.address }}
- {{ seller.zipcode }} - {{ seller.city }}
- {{ seller.country }}
+ {{ seller.zipcode }} - {% trans seller.city %}
+ {% trans seller.get_country_display %}

{{ seller_info.phone }}
{{ seller_info.email }}
@@ -40,21 +40,21 @@ {% block summary %}

- {{ bill.get_type_display.capitalize }}
+ {% trans bill.get_type_display.capitalize %}
{{ bill.number }}

- DUE DATE
+ {% trans "DUE DATE" %}
{{ bill.due_on | default:default_due_date | date }}
- TOTAL
+ {% trans "TOTAL" %}
{{ bill.get_total }} &{{ currency.lower }};
- {{ bill.get_type_display.upper }} DATE
+ {% blocktrans with bill_type=bill.get_type_display %}{{ bill_type|upper }} DATE {% endblocktrans %}
{{ bill.closed_on | default:now | date }}
@@ -62,8 +62,8 @@ {{ buyer.get_name }}
{{ buyer.vat }}
{{ buyer.address }}
- {{ buyer.zipcode }} - {{ buyer.city }}
- {{ buyer.country }}
+ {{ buyer.zipcode }} - {% trans buyer.city %}
+ {% trans buyer.get_country_display %}
{% endblock %} @@ -71,6 +71,7 @@
id {% trans "description" %} + {% trans "period" %} {% trans "hrs/qty" %} {% trans "rate/price" %} {% trans "subtotal" %} @@ -79,6 +80,7 @@ {% with sublines=line.sublines.all %} {{ line.id }} {{ line.description }} + {{ line.get_verbose_period }} {{ line.get_verbose_quantity|default:" "|safe }} {% if line.rate %}{{ line.rate }} &{{ currency.lower }};{% else %} {% endif %} {{ line.subtotal }} &{{ currency.lower }}; @@ -86,6 +88,7 @@ {% for subline in sublines %}   {{ subline.description }} +       {{ subline.total }} &{{ currency.lower }}; @@ -97,7 +100,7 @@

 
{% for tax, subtotal in bill.get_subtotals.items %} - subtotal {{ tax }}% {% trans "VAT" %} + {% trans "subtotal" %} {{ tax }}% {% trans "VAT" %} {{ subtotal | first }} &{{ currency.lower }};
{% trans "taxes" %} {{ tax }}% {% trans "VAT" %} @@ -126,9 +129,9 @@ {% if payment.message %} {{ payment.message | safe }} {% else %} - {% blocktrans with type=bill.get_type_display.lower %} + {% blocktrans with type=bill.get_type_display %} You can pay our {{ type }} by bank transfer.
- Please make sure to state your name and the {{ bill.get_type_display.lower}} number. + Please make sure to state your name and the {{ type }} number. Our bank account number is
{% endblocktrans %} {{ seller_info.bank_account }} diff --git a/orchestra/contrib/domains/backends.py b/orchestra/contrib/domains/backends.py index 5fb7fb06..13762b4e 100644 --- a/orchestra/contrib/domains/backends.py +++ b/orchestra/contrib/domains/backends.py @@ -5,6 +5,7 @@ from django.utils.translation import ugettext_lazy as _ from orchestra.contrib.orchestration import ServiceController, replace from orchestra.contrib.orchestration import Operation +from orchestra.utils.python import OrderedSet from . import settings @@ -92,9 +93,9 @@ class Bind9MasterDomainBackend(ServiceController): return servers def get_slaves(self, domain): - return set(settings.DOMAINS_SLAVES).union( - set(self.get_servers(domain, Bind9SlaveDomainBackend)) - ) + ips = list(settings.DOMAINS_SLAVES) + ips += self.get_servers(domain, Bind9SlaveDomainBackend) + return OrderedSet(ips) def get_context(self, domain): slaves = self.get_slaves(domain) @@ -139,9 +140,9 @@ class Bind9SlaveDomainBackend(Bind9MasterDomainBackend): self.append('if [[ $UPDATED == 1 ]]; then { sleep 1 && service bind9 reload; } & fi') def get_masters(self, domain): - return set(settings.DOMAINS_MASTERS).union( - set(self.get_servers(domain, Bind9MasterDomainBackend)) - ) + ips = list(settings.DOMAINS_MASTERS) + ips += self.get_servers(domain, Bind9MasterDomainBackend) + return OrderedSet(ips) def get_context(self, domain): context = { diff --git a/orchestra/contrib/domains/models.py b/orchestra/contrib/domains/models.py index 8984b252..213aa9f2 100644 --- a/orchestra/contrib/domains/models.py +++ b/orchestra/contrib/domains/models.py @@ -164,17 +164,25 @@ class Domain(models.Model): value=' '.join(soa) )) is_host = self.is_top or not types or Record.A in types or Record.AAAA in types - if Record.MX not in types and is_host: - for mx in settings.DOMAINS_DEFAULT_MX: + if is_host: + if Record.MX not in types: + for mx in settings.DOMAINS_DEFAULT_MX: + records.append(AttrDict( + type=Record.MX, + value=mx + )) + default_a = settings.DOMAINS_DEFAULT_A + if default_a and Record.A not in types: records.append(AttrDict( - type=Record.MX, - value=mx + type=Record.A, + value=default_a + )) + default_aaaa = settings.DOMAINS_DEFAULT_AAAA + if default_aaaa and Record.AAAA not in types: + records.append(AttrDict( + type=Record.AAAA, + value=default_aaaa )) - if (Record.A not in types and Record.AAAA not in types) and is_host: - records.append(AttrDict( - type=Record.A, - value=settings.DOMAINS_DEFAULT_A - )) result = '' for record in records: name = '{name}.{spaces}'.format( diff --git a/orchestra/contrib/domains/settings.py b/orchestra/contrib/domains/settings.py index 5f6960fa..058d042e 100644 --- a/orchestra/contrib/domains/settings.py +++ b/orchestra/contrib/domains/settings.py @@ -69,6 +69,11 @@ DOMAINS_DEFAULT_A = getattr(settings, 'DOMAINS_DEFAULT_A', ) +DOMAINS_DEFAULT_AAAA = getattr(settings, 'DOMAINS_DEFAULT_AAAA', + '' +) + + DOMAINS_DEFAULT_MX = getattr(settings, 'DOMAINS_DEFAULT_MX', ( '10 mail.{}.'.format(ORCHESTRA_BASE_DOMAIN), '10 mail2.{}.'.format(ORCHESTRA_BASE_DOMAIN), diff --git a/orchestra/contrib/orchestration/management/commands/orchestrate.py b/orchestra/contrib/orchestration/management/commands/orchestrate.py index 9ee38daf..b76dddee 100644 --- a/orchestra/contrib/orchestration/management/commands/orchestrate.py +++ b/orchestra/contrib/orchestration/management/commands/orchestrate.py @@ -6,7 +6,7 @@ from django.db.models.loading import get_model from orchestra.contrib.orchestration import manager, Operation from orchestra.contrib.orchestration.models import Server from orchestra.contrib.orchestration.backends import ServiceBackend -from orchestra.utils.python import import_class +from orchestra.utils.python import import_class, OrderedSet class Command(BaseCommand): @@ -34,7 +34,7 @@ class Command(BaseCommand): list_backends = options.get('list_backends') if list_backends: for backend in ServiceBackend.get_backends(): - print(str(backend).split("'")[1]) + self.stdout.write(str(backend).split("'")[1]) return model = get_model(*options['model'].split('.')) action = options.get('action') @@ -49,9 +49,9 @@ class Command(BaseCommand): comps = iter(comp.split('=')) for arg in comps: kwargs[arg] = next(comps).strip().rstrip(',') - operations = [] - operations = set() + operations = OrderedSet() route_cache = {} + queryset = model.objects.filter(**kwargs).order_by('id') if servers: server_objects = [] # Get and create missing Servers @@ -62,12 +62,12 @@ class Command(BaseCommand): server = Server.objects.create(name=server, address=server) server_objects.append(server) # Generate operations for the given backend - for instance in model.objects.filter(**kwargs): + for instance in queryset: for backend in backends: backend = import_class(backend) operations.add(Operation(backend, instance, action, servers=server_objects)) else: - for instance in model.objects.filter(**kwargs): + for instance in queryset: manager.collect(instance, action, operations=operations, route_cache=route_cache) scripts, block = manager.generate(operations) servers = [] @@ -76,11 +76,10 @@ class Command(BaseCommand): server, __ = key backend, operations = value servers.append(server.name) - sys.stdout.write('# Execute on %s\n' % server.name) + self.stdout.write('# Execute on %s' % server.name) for method, commands in backend.scripts: - script = '\n'.join(commands) + '\n' - script = script.encode('ascii', errors='replace') - sys.stdout.write(script.decode('ascii')) + script = '\n'.join(commands) + self.stdout.write(script) if interactive: context = { 'servers': ', '.join(servers), @@ -97,7 +96,7 @@ class Command(BaseCommand): if not dry: logs = manager.execute(scripts, block=block) for log in logs: - print(log.stdout.encode('utf8', errors='replace')) - sys.stderr.write(log.stderr.encode('utf8', errors='replace')) + self.stdout.write(log.stdout) + self.stderr.write(log.stderr) for log in logs: - print(log.backend, log.state) + self.stdout.write(' '.join((log.backend, log.state))) diff --git a/orchestra/contrib/orchestration/manager.py b/orchestra/contrib/orchestration/manager.py index a7cca3bf..e30a8b34 100644 --- a/orchestra/contrib/orchestration/manager.py +++ b/orchestra/contrib/orchestration/manager.py @@ -6,7 +6,7 @@ from collections import OrderedDict from django import db from django.core.mail import mail_admins -from orchestra.utils.python import import_class +from orchestra.utils.python import import_class, OrderedSet from . import settings, Operation from .backends import ServiceBackend @@ -138,7 +138,7 @@ def execute(scripts, block=False, async=False): def collect(instance, action, **kwargs): """ collect operations """ - operations = kwargs.get('operations', set()) + operations = kwargs.get('operations', OrderedSet()) route_cache = kwargs.get('route_cache', {}) for backend_cls in ServiceBackend.get_backends(): # Check if there exists a related instance to be executed for this backend and action diff --git a/orchestra/contrib/orchestration/settings.py b/orchestra/contrib/orchestration/settings.py index 49188751..a6d3e845 100644 --- a/orchestra/contrib/orchestration/settings.py +++ b/orchestra/contrib/orchestration/settings.py @@ -1,3 +1,4 @@ +from datetime import timedelta from os import path from django.conf import settings @@ -28,3 +29,8 @@ ORCHESTRATION_TEMP_SCRIPT_PATH = getattr(settings, 'ORCHESTRATION_TEMP_SCRIPT_PA ORCHESTRATION_DISABLE_EXECUTION = getattr(settings, 'ORCHESTRATION_DISABLE_EXECUTION', False ) + + +ORCHESTRATION_BACKEND_CLEANUP_DELTA = getattr(settings, 'ORCHESTRATION_BACKEND_CLEANUP_DELTA', + timedelta(days=40) +) diff --git a/orchestra/contrib/orchestration/tasks.py b/orchestra/contrib/orchestration/tasks.py new file mode 100644 index 00000000..e3bd6599 --- /dev/null +++ b/orchestra/contrib/orchestration/tasks.py @@ -0,0 +1,11 @@ +from celery.task.schedules import crontab +from celery.decorators import periodic_task +from django.utils import timezone + +from .models import BackendLog + + +@periodic_task(run_every=crontab(hour=7, minute=30, day_of_week=1)) +def backend_logs_cleanup(run_every=run_every): + epoch = timezone.now()-settings.ORCHESTRATION_BACKEND_CLEANUP_DELTA + BackendLog.objects.filter(created_at__lt=epoch).delete() diff --git a/orchestra/contrib/orders/billing.py b/orchestra/contrib/orders/billing.py index d6606444..56ab1fcb 100644 --- a/orchestra/contrib/orders/billing.py +++ b/orchestra/contrib/orders/billing.py @@ -12,6 +12,9 @@ class BillsBackend(object): create_new = options.get('new_open', False) proforma = options.get('proforma', False) for line in lines: + quantity = line.metric*line.size + if quantity == 0: + continue service = line.order.service # Create bill if needed if bill is None or service.is_fee: @@ -33,36 +36,32 @@ class BillsBackend(object): bill = Invoice.objects.create(account=account, is_open=True) bills.append(bill) # Create bill line - quantity = line.metric*line.size - if quantity != 0: - billine = bill.lines.create( - rate=service.nominal_price, - quantity=line.metric*line.size, - verbose_quantity=self.get_verbose_quantity(line), - subtotal=line.subtotal, - tax=service.tax, - description=self.get_line_description(line), - order=line.order, - order_billed_on=line.order.old_billed_on, - order_billed_until=line.order.old_billed_until - ) - self.create_sublines(billine, line.discounts) + billine = bill.lines.create( + rate=service.nominal_price, + quantity=line.metric*line.size, + verbose_quantity=self.get_verbose_quantity(line), + subtotal=line.subtotal, + tax=service.tax, + description=self.get_line_description(line), + start_on=line.ini, + end_on=line.end if service.billing_period != service.NEVER else None, + order=line.order, + order_billed_on=line.order.old_billed_on, + order_billed_until=line.order.old_billed_until + ) + self.create_sublines(billine, line.discounts) return bills - def format_period(self, ini, end): - ini = ini.strftime("%b, %Y") - end = (end-datetime.timedelta(seconds=1)).strftime("%b, %Y") - if ini == end: - return ini - return _("{ini} to {end}").format(ini=ini, end=end) +# def format_period(self, ini, end): +# ini = ini.strftime("%b, %Y") +# end = (end-datetime.timedelta(seconds=1)).strftime("%b, %Y") +# if ini == end: +# return ini +# return _("{ini} to {end}").format(ini=ini, end=end) def get_line_description(self, line): service = line.order.service - if service.is_fee: - return self.format_period(line.ini, line.end) description = line.order.description - if service.billing_period != service.NEVER: - description += " %s" % self.format_period(line.ini, line.end) return description def get_verbose_quantity(self, line): diff --git a/orchestra/contrib/orders/filters.py b/orchestra/contrib/orders/filters.py index 9198a3c7..de81f5db 100644 --- a/orchestra/contrib/orders/filters.py +++ b/orchestra/contrib/orders/filters.py @@ -31,18 +31,15 @@ class BilledOrderListFilter(SimpleListFilter): def lookups(self, request, model_admin): return ( - ('to_date', _("To date")), - ('full', _("Full period")), - ('not', _("Not billed")), + ('yes', _("Billed")), + ('no', _("Not billed")), ) def queryset(self, request, queryset): - if self.value() == 'to_date': + if self.value() == 'yes': return queryset.filter(billed_until__isnull=False, billed_until__gte=timezone.now()) - elif self.value() == 'full': - raise NotImplementedError - elif self.value() == 'not': + elif self.value() == 'no': return queryset.filter( Q(billed_until__isnull=True) | Q(billed_until__lt=timezone.now()) diff --git a/orchestra/contrib/orders/models.py b/orchestra/contrib/orders/models.py index 7b97c69c..cbd15ddc 100644 --- a/orchestra/contrib/orders/models.py +++ b/orchestra/contrib/orders/models.py @@ -44,6 +44,9 @@ class OrderQuerySet(models.QuerySet): bills += bill_backend.create_bills(account, bill_lines, **options) else: bills += [(account, bill_lines)] + # TODO remove if commit and always return unique elemenets (set()) when the other todo is fixed + if commit: + return list(set(bills)) return bills def givers(self, ini, end): @@ -173,6 +176,8 @@ class Order(models.Model): def update(self): instance = self.content_object + if instance is None: + return handler = self.service.handler metric = '' if handler.metric: diff --git a/orchestra/contrib/saas/backends/gitlab.py b/orchestra/contrib/saas/backends/gitlab.py index 9f2d51a6..3f39d287 100644 --- a/orchestra/contrib/saas/backends/gitlab.py +++ b/orchestra/contrib/saas/backends/gitlab.py @@ -25,7 +25,7 @@ class GitLabSaaSBackend(ServiceController): def validate_response(self, response, *status_codes): if response.status_code not in status_codes: raise RuntimeError("[%i] %s" % (response.status_code, response.content)) - return json.loads(response.content) + return json.loads(response.content.decode('utf8')) def authenticate(self): login_url = self.get_base_url() + '/session' @@ -61,7 +61,7 @@ class GitLabSaaSBackend(ServiceController): user_url = self.get_user_url(saas) response = requests.get(user_url, headers=self.headers) user = self.validate_response(response, 200) - user = json.loads(response.content) + user = json.loads(response.content.decode('utf8')) user['password'] = saas.password response = requests.put(user_url, data=user, headers=self.headers) user = self.validate_response(response, 200) @@ -92,7 +92,8 @@ class GitLabSaaSBackend(ServiceController): username = saas.name email = saas.data['email'] users_url = self.get_base_url() + '/users/' - users = json.loads(requests.get(users_url, headers=self.headers).content) + response = requests.get(users_url, headers=self.headers) + users = json.loads(response.content.decode('utf8')) for user in users: if user['username'] == username: print('ValidationError: user-exists') diff --git a/orchestra/contrib/saas/backends/phplist.py b/orchestra/contrib/saas/backends/phplist.py index c257809b..1b028f5e 100644 --- a/orchestra/contrib/saas/backends/phplist.py +++ b/orchestra/contrib/saas/backends/phplist.py @@ -14,7 +14,7 @@ class PhpListSaaSBackend(ServiceController): def _save(self, saas, server): admin_link = 'http://%s/admin/' % saas.get_site_domain() - admin_content = requests.get(admin_link).content + admin_content = requests.get(admin_link).content.decode('utf8') if admin_content.startswith('Cannot connect to Database'): raise RuntimeError("Database is not yet configured") install = re.search(r'([^"]+firstinstall[^"]+)', admin_content) @@ -30,7 +30,7 @@ class PhpListSaaSBackend(ServiceController): 'adminpassword': saas.password, } response = requests.post(install_link, data=post) - print(response.content) + print(response.content.decode('utf8')) if response.status_code != 200: raise RuntimeError("Bad status code %i" % response.status_code) else: diff --git a/orchestra/contrib/saas/backends/wordpressmu.py b/orchestra/contrib/saas/backends/wordpressmu.py index 6fa8f94e..e8501a21 100644 --- a/orchestra/contrib/saas/backends/wordpressmu.py +++ b/orchestra/contrib/saas/backends/wordpressmu.py @@ -31,7 +31,7 @@ class WordpressMuBackend(ServiceController): def validate_response(self, response): if response.status_code != 200: - errors = re.findall(r'\n\t

(.*)

', response.content) + errors = re.findall(r'\n\t

(.*)

', response.content.decode('utf8')) raise RuntimeError(errors[0] if errors else 'Unknown %i error' % response.status_code) def get_id(self, session, webapp): @@ -41,7 +41,7 @@ class WordpressMuBackend(ServiceController): '%s' % webapp.name ) - content = session.get(search).content + content = session.get(search).content.decode('utf8') # Get id ids = regex.search(content) if not ids: @@ -64,7 +64,7 @@ class WordpressMuBackend(ServiceController): except RuntimeError: url = self.get_base_url() url += '/wp-admin/network/site-new.php' - content = session.get(url).content + content = session.get(url).content.decode('utf8') wpnonce = re.compile('name="_wpnonce_add-blog"\s+value="([^"]*)"') wpnonce = wpnonce.search(content).groups()[0] @@ -94,7 +94,7 @@ class WordpressMuBackend(ServiceController): delete += '/wp-admin/network/sites.php?action=confirm&action2=deleteblog' delete += '&id=%d&_wpnonce=%s' % (id, wpnonce) - content = session.get(delete).content + content = session.get(delete).content.decode('utf8') wpnonce = re.compile('name="_wpnonce"\s+value="([^"]*)"') wpnonce = wpnonce.search(content).groups()[0] data = { diff --git a/orchestra/contrib/saas/services/gitlab.py b/orchestra/contrib/saas/services/gitlab.py index 38343389..33334e51 100644 --- a/orchestra/contrib/saas/services/gitlab.py +++ b/orchestra/contrib/saas/services/gitlab.py @@ -11,12 +11,12 @@ from .. import settings class GitLabForm(SoftwareServiceForm): email = forms.EmailField(label=_("Email"), - help_text=_("Initial email address, changes on the GitLab server are not reflected here.")) + help_text=_("Initial email address, changes on the GitLab server are not reflected here.")) class GitLaChangebForm(GitLabForm): user_id = forms.IntegerField(label=("User ID"), widget=widgets.ShowTextWidget, - help_text=_("ID of this user on the GitLab server, the only attribute that not changes.")) + help_text=_("ID of this user on the GitLab server, the only attribute that not changes.")) class GitLabSerializer(serializers.Serializer): diff --git a/orchestra/contrib/saas/services/options.py b/orchestra/contrib/saas/services/options.py index 3b3523d3..e3ab9283 100644 --- a/orchestra/contrib/saas/services/options.py +++ b/orchestra/contrib/saas/services/options.py @@ -106,6 +106,8 @@ class SoftwareService(plugins.Plugin): except IndexError: pass else: + if log.state != log.SUCCESS: + raise ValidationError(_("Validate creation execution has failed.")) errors = {} if 'user-exists' in log.stdout: errors['name'] = _("User with this username already exists.") diff --git a/orchestra/contrib/services/handlers.py b/orchestra/contrib/services/handlers.py index 6ada4d4e..293977fb 100644 --- a/orchestra/contrib/services/handlers.py +++ b/orchestra/contrib/services/handlers.py @@ -23,8 +23,8 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount): Relax and enjoy the journey. """ - _VOLUME = 'VOLUME' - _COMPENSATION = 'COMPENSATION' + _VOLUME = 'volume' + _COMPENSATION = 'compensation' model = None @@ -42,29 +42,27 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount): def validate_content_type(self, service): pass - def validate_match(self, service): - if not service.match: - service.match = 'True' + def validate_expression(self, service, method): try: obj = service.content_type.model_class().objects.all()[0] except IndexError: return try: - bool(self.matches(obj)) + bool(getattr(self, method)(obj)) except Exception as exception: name = type(exception).__name__ raise ValidationError(': '.join((name, str(exception)))) + def validate_match(self, service): + if not service.match: + service.match = 'True' + self.validate_expression(service, 'matches') + def validate_metric(self, service): - try: - obj = service.content_type.model_class().objects.all()[0] - except IndexError: - return - try: - bool(self.get_metric(obj)) - except Exception as exception: - name = type(exception).__name__ - raise ValidationError(': '.join((name, str(exception)))) + self.validate_expression(service, 'get_metric') + + def validate_order_description(self, service): + self.validate_expression(service, 'get_order_description') def get_content_type(self): if not self.model: @@ -72,15 +70,26 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount): app_label, model = self.model.split('.') return ContentType.objects.get_by_natural_key(app_label, model.lower()) + def get_expression_context(self, instance): + return { + 'instance': instance, + 'obj': instance, + 'ugettext': ugettext, + 'handler': self, + 'service': self.service, + instance._meta.model_name: instance, + 'math': math, + 'logsteps': lambda n, size=1: \ + round(n/(decimal.Decimal(size*10**int(math.log10(max(n, 1))))))*size*10**int(math.log10(max(n, 1))), + 'log10': math.log10, + 'Decimal': decimal.Decimal, + } + def matches(self, instance): if not self.match: # Blank expressions always evaluate True return True - safe_locals = { - 'instance': instance, - 'obj': instance, - instance._meta.model_name: instance, - } + safe_locals = self.get_expression_context(instance) return eval(self.match, safe_locals) def get_ignore_delta(self): @@ -113,27 +122,14 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount): def get_metric(self, instance): if self.metric: - safe_locals = { - instance._meta.model_name: instance, - 'instance': instance, - 'math': math, - 'logsteps': lambda n, size=1: \ - round(n/(decimal.Decimal(size*10**int(math.log10(max(n, 1))))))*size*10**int(math.log10(max(n, 1))), - 'log10': math.log10, - 'Decimal': decimal.Decimal, - } + safe_locals = self.get_expression_context(instance) try: return eval(self.metric, safe_locals) except Exception as error: raise type(error)("%s on '%s'" %(error, self.service)) def get_order_description(self, instance): - safe_locals = { - 'instance': instance, - 'obj': instance, - 'ugettext': ugettext, - instance._meta.model_name: instance, - } + safe_locals = self.get_expression_context(instance) account = getattr(instance, 'account', instance) with translation.override(account.language): if not self.order_description: diff --git a/orchestra/contrib/services/models.py b/orchestra/contrib/services/models.py index aaa9a0c5..9b832a01 100644 --- a/orchestra/contrib/services/models.py +++ b/orchestra/contrib/services/models.py @@ -91,7 +91,7 @@ class Service(models.Model): help_text=_( "Python expression " "used for generating the description for the bill lines of this services.
" - "Defaults to '%s: %s' % (handler.description, instance)" + "Defaults to '%s: %s' % (ugettext(handler.description), instance)" )) ignore_period = models.CharField(_("ignore period"), max_length=16, blank=True, help_text=_("Period in which orders will be ignored if cancelled. " @@ -180,6 +180,7 @@ class Service(models.Model): 'content_type': (self.handler.validate_content_type, self), 'match': (self.handler.validate_match, self), 'metric': (self.handler.validate_metric, self), + 'order_description': (self.handler.validate_order_description, self), }) def get_pricing_period(self): @@ -238,7 +239,10 @@ class Service(models.Model): order_model = get_model(settings.SERVICES_ORDER_MODEL) related_model = self.content_type.model_class() updates = [] - for instance in related_model.objects.select_related('account').all(): + queryset = related_model.objects.all() + if related_model._meta.model_name != 'account': + queryset = queryset.select_related('account').all() + for instance in queryset: updates += order_model.update_orders(instance, service=self, commit=commit) return updates diff --git a/orchestra/contrib/webapps/backends/php.py b/orchestra/contrib/webapps/backends/php.py index 72fdf5c6..b60b14f1 100644 --- a/orchestra/contrib/webapps/backends/php.py +++ b/orchestra/contrib/webapps/backends/php.py @@ -118,8 +118,18 @@ class PHPBackend(WebAppServiceMixin, ServiceController): ) super(PHPBackend, self).commit() + def get_options(self, webapp): + kwargs = {} + if self.MERGE: + kwargs = { + 'webapp__account': webapp.account, + 'webapp__type': webapp.type, + 'webapp__data__contains': '"php_version":"%s"' % webapp.data['php_version'], + } + return webapp.get_options(**kwargs) + def get_fpm_config(self, webapp, context): - options = webapp.get_options(merge=self.MERGE) + options = self.get_options(webapp) context.update({ 'init_vars': webapp.type_instance.get_php_init_vars(merge=self.MERGE), 'max_children': options.get('processes', settings.WEBAPPS_FPM_DEFAULT_MAX_CHILDREN), @@ -168,7 +178,7 @@ class PHPBackend(WebAppServiceMixin, ServiceController): exec %(php_binary_path)s %(php_init_vars)s""") % context def get_fcgid_cmd_options(self, webapp, context): - options = webapp.get_options(merge=self.MERGE) + options = self.get_options(webapp) maps = { 'MaxProcesses': options.get('processes', None), 'IOTimeout': options.get('timeout', None), diff --git a/orchestra/contrib/webapps/backends/python.py b/orchestra/contrib/webapps/backends/python.py index b69271b7..e665dd5e 100644 --- a/orchestra/contrib/webapps/backends/python.py +++ b/orchestra/contrib/webapps/backends/python.py @@ -10,7 +10,12 @@ from . import WebAppServiceMixin from .. import settings -class PythonBackend(WebAppServiceMixin, ServiceController): +class uWSGIPythonBackend(WebAppServiceMixin, ServiceController): + """ + Emperor mode + + http://uwsgi-docs.readthedocs.org/en/latest/Emperor.html + """ verbose_name = _("Python uWSGI") default_route_match = "webapp.type.endswith('python')" @@ -26,26 +31,14 @@ class PythonBackend(WebAppServiceMixin, ServiceController): self.delete_webapp_dir(context) def save_uwsgi(self, webapp, context): - self.append(textwrap.dedent("""\ - uwsgi_config='%(uwsgi_config)s' - { - echo -e "${uwsgi_config}" | diff -N -I'^\s*;;' %(uwsgi_path)s - - } || { - echo -e "${uwsgi_config}" > %(uwsgi_path)s - UPDATED_UWSGI=1 - } - ln -s %(uwsgi_path)s %(uwsgi_enabled)s - """) % context - ) + self.append("echo '%(uwsgi_config)s' > %(vassal_path)s" % context) def delete_uwsgi(self, webapp, context): - self.append("rm -f %(uwsgi_path)s" % context) - self.append("rm -f %(uwsgi_enabled)s" % context) + self.append("rm -f %(vassal_path)s" % context) def get_uwsgi_ini(self, context): - # TODO switch to this http://uwsgi-docs.readthedocs.org/en/latest/Emperor.html - # TODO http://uwsgi-docs.readthedocs.org/en/latest/Changelog-1.9.1.html#on-demand-vassals return textwrap.dedent("""\ + # %(banner)s [uwsgi] plugins = python{python_version_number} chdir = {app_path} @@ -70,10 +63,8 @@ class PythonBackend(WebAppServiceMixin, ServiceController): context.update({ 'uwsgi_ini': self.get_uwsgi_ini(context), 'uwsgi_dir': settings.WEBAPPS_UWSGI_BASE_DIR, - 'uwsgi_path': os.path.join(settings.WEBAPPS_UWSGI_BASE_DIR, - 'apps-available/%s.ini'% context['app_name']), - 'uwsgi_enabled': os.path.join(settings.WEBAPPS_UWSGI_BASE_DIR, - 'apps-enabled/%s.ini'% context['app_name']), + 'vassal_path': os.path.join(settings.WEBAPPS_UWSGI_BASE_DIR, + 'vassals/%s' % context['app_name']), }) return context diff --git a/orchestra/contrib/webapps/backends/wordpressmu.py b/orchestra/contrib/webapps/backends/wordpressmu.py deleted file mode 100644 index ac304f4f..00000000 --- a/orchestra/contrib/webapps/backends/wordpressmu.py +++ /dev/null @@ -1,123 +0,0 @@ -import re - -import requests -from django.utils.translation import ugettext_lazy as _ - -from orchestra.contrib.orchestration import ServiceController - -from .. import settings - - -class WordpressMuBackend(ServiceController): - verbose_name = _("Wordpress multisite") - model = 'saas.SaaS' - default_route_match = "saas.service == 'wordpress-mu'" - - @property - def script(self): - return self.cmds - - def login(self, session): - base_url = self.get_base_url() - login_url = base_url + '/wp-login.php' - login_data = { - 'log': 'admin', - 'pwd': settings.WEBSITES_WORDPRESSMU_ADMIN_PASSWORD, - 'redirect_to': '/wp-admin/' - } - response = session.post(login_url, data=login_data) - if response.url != base_url + '/wp-admin/': - raise IOError("Failure login to remote application") - - def get_base_url(self): - base_url = settings.WEBSITES_WORDPRESSMU_BASE_URL - return base_url.rstrip('/') - - def validate_response(self, response): - if response.status_code != 200: - errors = re.findall(r'\n\t

(.*)

', response.content) - raise RuntimeError(errors[0] if errors else 'Unknown %i error' % response.status_code) - - def get_id(self, session, webapp): - search = self.get_base_url() - search += '/wp-admin/network/sites.php?s=%s&action=blogs' % webapp.name - regex = re.compile( - '%s' % webapp.name - ) - content = session.get(search).content - # Get id - ids = regex.search(content) - if not ids: - raise RuntimeError("Blog '%s' not found" % webapp.name) - ids = ids.groups() - if len(ids) > 1: - raise ValueError("Multiple matches") - # Get wpnonce - wpnonce = re.search(r'(.*)', content).groups()[0] - wpnonce = re.search(r'_wpnonce=([^"]*)"', wpnonce).groups()[0] - return int(ids[0]), wpnonce - - def create_blog(self, webapp, server): - session = requests.Session() - self.login(session) - - # Check if blog already exists - try: - self.get_id(session, webapp) - except RuntimeError: - url = self.get_base_url() - url += '/wp-admin/network/site-new.php' - content = session.get(url).content - - wpnonce = re.compile('name="_wpnonce_add-blog"\s+value="([^"]*)"') - wpnonce = wpnonce.search(content).groups()[0] - - url += '?action=add-site' - data = { - 'blog[domain]': webapp.name, - 'blog[title]': webapp.name, - 'blog[email]': webapp.account.email, - '_wpnonce_add-blog': wpnonce, - } - - # Validate response - response = session.post(url, data=data) - self.validate_response(response) - - def delete_blog(self, webapp, server): - session = requests.Session() - self.login(session) - - try: - id, wpnonce = self.get_id(session, webapp) - except RuntimeError: - pass - else: - delete = self.get_base_url() - delete += '/wp-admin/network/sites.php?action=confirm&action2=deleteblog' - delete += '&id=%d&_wpnonce=%s' % (id, wpnonce) - - content = session.get(delete).content - wpnonce = re.compile('name="_wpnonce"\s+value="([^"]*)"') - wpnonce = wpnonce.search(content).groups()[0] - data = { - 'action': 'deleteblog', - 'id': id, - '_wpnonce': wpnonce, - '_wp_http_referer': '/wp-admin/network/sites.php', - } - delete = self.get_base_url() - delete += '/wp-admin/network/sites.php?action=deleteblog' - response = session.post(delete, data=data) - self.validate_response(response) - - def save(self, webapp): - if webapp.type != 'wordpress-mu': - return - self.append(self.create_blog, webapp) - - def delete(self, webapp): - if webapp.type != 'wordpress-mu': - return - self.append(self.delete_blog, webapp) diff --git a/orchestra/contrib/webapps/models.py b/orchestra/contrib/webapps/models.py index 1249c5aa..6d78e504 100644 --- a/orchestra/contrib/webapps/models.py +++ b/orchestra/contrib/webapps/models.py @@ -57,17 +57,19 @@ class WebApp(models.Model): self.data = apptype.clean_data() @cached - def get_options(self, merge=False): - if merge: - options = OrderedDict() - qs = WebAppOption.objects.filter(webapp__account=self.account, webapp__type=self.type) - for name, value in qs.values_list('name', 'value').order_by('name'): - if name in options: - options[name] = max(options[name], value) - else: - options[name] = value - return options - return OrderedDict(self.options.values_list('name', 'value').order_by('name')) + def get_options(self, **kwargs): + options = OrderedDict() + if not kwargs: + kwargs = { + 'webapp_id': self.pk, + } + qs = WebAppOption.objects.filter(**kwargs) + for name, value in qs.values_list('name', 'value').order_by('name'): + if name in options: + options[name] = max(options[name], value) + else: + options[name] = value + return options def get_directive(self): return self.type_instance.get_directive() diff --git a/orchestra/management/commands/staticcheck.py b/orchestra/management/commands/staticcheck.py index 208e3c5b..e471a215 100644 --- a/orchestra/management/commands/staticcheck.py +++ b/orchestra/management/commands/staticcheck.py @@ -9,4 +9,4 @@ class Command(BaseCommand): def handle(self, *filenames, **options): flake = run('flake8 {%s,%s} | grep -v "W293\|E501"' % (get_orchestra_dir(), get_site_dir())) - print(flake.stdout) + self.stdout.write(flake.stdout) diff --git a/orchestra/utils/html.py b/orchestra/utils/html.py index 356397c0..57aca198 100644 --- a/orchestra/utils/html.py +++ b/orchestra/utils/html.py @@ -7,5 +7,5 @@ def html_to_pdf(html): 'PATH=$PATH:/usr/local/bin/\n' 'xvfb-run -a -s "-screen 0 640x4800x16" ' 'wkhtmltopdf -q --footer-center "Page [page] of [topage]" --footer-font-size 9 - -', - stdin=html.encode('utf-8'), force_unicode=False + stdin=html.encode('utf-8') ).stdout diff --git a/orchestra/utils/sys.py b/orchestra/utils/sys.py index 430862b1..4277cc46 100644 --- a/orchestra/utils/sys.py +++ b/orchestra/utils/sys.py @@ -45,15 +45,15 @@ def read_async(fd): return '' -def runiterator(command, display=False, error_codes=[0], silent=False, stdin='', force_unicode=True): +def runiterator(command, display=False, error_codes=[0], silent=False, stdin=''): """ Subprocess wrapper for running commands concurrently """ if display: sys.stderr.write("\n\033[1m $ %s\033[0m\n" % command) p = subprocess.Popen(command, shell=True, executable='/bin/bash', - stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE) + stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE) - p.stdin.write(bytes(stdin, 'utf-8')) + p.stdin.write(stdin) p.stdin.close() yield @@ -62,16 +62,18 @@ def runiterator(command, display=False, error_codes=[0], silent=False, stdin='', # Async reading of stdout and sterr while True: - stdout = '' - stderr = '' + stdout = b'' + stderr = b'' # Get complete unicode chunks select.select([p.stdout, p.stderr], [], []) stdoutPiece = read_async(p.stdout) stderrPiece = read_async(p.stderr) - stdout += (stdoutPiece or b'').decode('utf8', errors='replace') - stderr += (stderrPiece or b'').decode('utf8', errors='replace') + stdout += (stdoutPiece or b'') + #.decode('ascii'), errors='replace') + stderr += (stderrPiece or b'') + #.decode('ascii'), errors='replace') if display and stdout: sys.stdout.write(stdout) @@ -89,14 +91,14 @@ def runiterator(command, display=False, error_codes=[0], silent=False, stdin='', raise StopIteration -def run(command, display=False, error_codes=[0], silent=False, stdin='', async=False, force_unicode=True): - iterator = runiterator(command, display, error_codes, silent, stdin, force_unicode) +def run(command, display=False, error_codes=[0], silent=False, stdin='', async=False): + iterator = runiterator(command, display, error_codes, silent, stdin) next(iterator) if async: return iterator - stdout = '' - stderr = '' + stdout = b'' + stderr = b'' for state in iterator: stdout += state.stdout stderr += state.stderr