From 3458c091a42cb32c3e77be3997fa4e59aa02d871 Mon Sep 17 00:00:00 2001 From: swasp Date: Tue, 29 Aug 2023 10:50:23 +0200 Subject: [PATCH] adding repo --- INSTALL.md | 106 + INSTALLDEV.md | 39 + LICENSE | 35 + MANIFEST.in | 8 + README.md | 121 + ROADMAP.md | 69 + TODO.md | 472 ++ docs/API.rst | 222 + docs/Makefile | 153 + docs/README.md | 10 + docs/conf.py | 244 + docs/create-services.md | 94 + docs/images/index-screenshot.png | Bin 0 -> 198046 bytes docs/images/orchestration.svg | 3628 +++++++++++ docs/images/services.svg | 482 ++ docs/index.rst | 21 + docs/make.bat | 190 + install_manually.md | 132 + orchestra/__init__.py | 25 + orchestra/admin/__init__.py | 121 + orchestra/admin/actions.py | 145 + orchestra/admin/dashboard.py | 74 + orchestra/admin/decorators.py | 101 + orchestra/admin/forms.py | 228 + orchestra/admin/html.py | 20 + orchestra/admin/menu.py | 100 + orchestra/admin/options.py | 339 + orchestra/admin/utils.py | 185 + orchestra/api/__init__.py | 2 + orchestra/api/actions.py | 30 + orchestra/api/helpers.py | 45 + orchestra/api/options.py | 94 + orchestra/api/root.py | 70 + orchestra/api/serializers.py | 114 + orchestra/apps.py | 6 + orchestra/bin/celerybeat | 285 + orchestra/bin/celeryd | 387 ++ orchestra/bin/celeryevcam | 226 + orchestra/bin/django_bash_completion.sh | 72 + orchestra/bin/orchestra-admin | 246 + orchestra/bin/orchestra-beat | 226 + orchestra/bin/sieve-test | Bin 0 -> 1295912 bytes orchestra/conf/__init__.py | 0 .../conf/project_template/locale/.gitignore | 0 orchestra/conf/project_template/manage.py | 13 + .../conf/project_template/media/.gitignore | 0 .../project_template/project_name/__init__.py | 0 .../project_template/project_name/settings.py | 275 + .../project_template/project_name/urls.py | 6 + .../project_template/project_name/wsgi.py | 14 + orchestra/contrib/__init__.py | 0 orchestra/contrib/accounts/__init__.py | 1 + orchestra/contrib/accounts/actions.py | 287 + orchestra/contrib/accounts/admin.py | 415 ++ orchestra/contrib/accounts/api.py | 32 + orchestra/contrib/accounts/apps.py | 18 + orchestra/contrib/accounts/filters.py | 27 + orchestra/contrib/accounts/forms.py | 90 + .../contrib/accounts/management/__init__.py | 32 + orchestra/contrib/accounts/models.py | 207 + orchestra/contrib/accounts/serializers.py | 27 + orchestra/contrib/accounts/settings.py | 74 + .../admin/accounts/account/change_form.html | 42 + .../admin/accounts/account/change_list.html | 49 + .../delete_related_services_confirmation.html | 39 + .../disable_selected_confirmation.html | 35 + .../accounts/account/select_account_list.html | 13 + .../accounts/account/service_report.html | 84 + orchestra/contrib/bills/__init__.py | 1 + orchestra/contrib/bills/actions.py | 377 ++ orchestra/contrib/bills/admin.py | 493 ++ orchestra/contrib/bills/api.py | 29 + orchestra/contrib/bills/apps.py | 12 + orchestra/contrib/bills/filters.py | 160 + orchestra/contrib/bills/forms.py | 49 + orchestra/contrib/bills/helpers.py | 44 + .../bills/locale/ca/LC_MESSAGES/django.mo | Bin 0 -> 7410 bytes .../bills/locale/ca/LC_MESSAGES/django.po | 749 +++ .../bills/locale/es/LC_MESSAGES/django.mo | Bin 0 -> 4980 bytes .../bills/locale/es/LC_MESSAGES/django.po | 728 +++ orchestra/contrib/bills/models.py | 504 ++ orchestra/contrib/bills/serializers.py | 34 + orchestra/contrib/bills/settings.py | 106 + .../admin/bills/bill/change_list.html | 18 + .../bills/bill/close_send_download_bills.html | 60 + .../templates/admin/bills/bill/report.html | 87 + .../admin/bills/billline/change_list.html | 12 + .../admin/bills/billline/report.html | 72 + .../contrib/bills/templates/bills/base.html | 10 + .../templates/bills/bill-notification.email | 6 + .../bills/templates/bills/invoice.html | 217 + .../templates/bills/microspective-fee.html | 155 + .../bills/microspective-proforma.html | 13 + .../bills/templates/bills/microspective.css | 298 + .../bills/templates/bills/microspective.html | 178 + orchestra/contrib/contacts/__init__.py | 1 + orchestra/contrib/contacts/admin.py | 113 + orchestra/contrib/contacts/api.py | 15 + orchestra/contrib/contacts/apps.py | 12 + orchestra/contrib/contacts/filters.py | 18 + orchestra/contrib/contacts/models.py | 80 + orchestra/contrib/contacts/serializers.py | 17 + orchestra/contrib/contacts/settings.py | 32 + orchestra/contrib/contacts/validators.py | 7 + orchestra/contrib/databases/__init__.py | 1 + orchestra/contrib/databases/admin.py | 129 + orchestra/contrib/databases/api.py | 23 + orchestra/contrib/databases/apps.py | 14 + orchestra/contrib/databases/backends.py | 193 + orchestra/contrib/databases/filters.py | 34 + orchestra/contrib/databases/forms.py | 153 + .../databases/migrations/0001_initial.py | 46 + .../0002_databaseuser_target_server.py | 20 + .../migrations/0003_auto_20230629_1838.py | 29 + .../migrations/0004_database_target_server.py | 20 + .../migrations/0005_auto_20230705_1208.py | 18 + .../migrations/0006_auto_20230705_1237.py | 19 + .../contrib/databases/migrations/__init__.py | 0 orchestra/contrib/databases/models.py | 94 + orchestra/contrib/databases/serializers.py | 51 + orchestra/contrib/databases/settings.py | 29 + orchestra/contrib/databases/tests/__init__.py | 0 .../tests/functional_tests/__init__.py | 0 .../databases/tests/functional_tests/tests.py | 348 + orchestra/contrib/domains/__init__.py | 1 + orchestra/contrib/domains/actions.py | 152 + orchestra/contrib/domains/admin.py | 227 + orchestra/contrib/domains/api.py | 38 + orchestra/contrib/domains/apps.py | 12 + orchestra/contrib/domains/backends.py | 224 + orchestra/contrib/domains/filters.py | 49 + orchestra/contrib/domains/forms.py | 164 + orchestra/contrib/domains/helpers.py | 32 + orchestra/contrib/domains/models.py | 352 ++ orchestra/contrib/domains/serializers.py | 82 + orchestra/contrib/domains/settings.py | 127 + .../admin/domains/domain/change_form.html | 15 + .../admin/domains/domain/edit_records.html | 20 + .../admin/domains/domain/view_zone.html | 22 + orchestra/contrib/domains/tests/__init__.py | 0 .../tests/functional_tests/__init__.py | 0 .../domains/tests/functional_tests/tests.py | 325 + .../contrib/domains/tests/test_domains.py | 18 + orchestra/contrib/domains/utils.py | 52 + orchestra/contrib/domains/validators.py | 137 + orchestra/contrib/history/__init__.py | 1 + orchestra/contrib/history/admin.py | 130 + orchestra/contrib/history/apps.py | 14 + .../admin/admin/logentry/change_form.html | 22 + .../templates/admin/object_history.html | 43 + orchestra/contrib/issues/__init__.py | 1 + orchestra/contrib/issues/actions.py | 137 + orchestra/contrib/issues/admin.py | 323 + orchestra/contrib/issues/api.py | 44 + orchestra/contrib/issues/apps.py | 15 + orchestra/contrib/issues/filters.py | 49 + orchestra/contrib/issues/forms.py | 112 + orchestra/contrib/issues/helpers.py | 40 + orchestra/contrib/issues/models.py | 202 + orchestra/contrib/issues/serializers.py | 45 + orchestra/contrib/issues/settings.py | 16 + .../issues/static/issues/css/ticket-admin.css | 67 + .../issues/static/issues/images/btn_edit.gif | Bin 0 -> 204 bytes .../static/issues/images/unread_ticket.gif | Bin 0 -> 260 bytes .../issues/static/issues/js/admin-ticket.js | 16 + .../issues/static/issues/js/ticket-admin.js | 30 + .../issues/static/issues/markdown_syntax.html | 55 + .../templates/issues/ticket_notification.mail | 36 + .../issues/ticket_notification_html.mail | 60 + orchestra/contrib/issues/tests.py | 16 + orchestra/contrib/letsencrypt/actions.py | 115 + orchestra/contrib/letsencrypt/admin.py | 8 + orchestra/contrib/letsencrypt/backends.py | 54 + orchestra/contrib/letsencrypt/forms.py | 32 + orchestra/contrib/letsencrypt/helpers.py | 48 + orchestra/contrib/letsencrypt/settings.py | 17 + orchestra/contrib/lists/__init__.py | 1 + orchestra/contrib/lists/admin.py | 79 + orchestra/contrib/lists/api.py | 16 + orchestra/contrib/lists/apps.py | 13 + orchestra/contrib/lists/backends.py | 328 + orchestra/contrib/lists/filters.py | 21 + orchestra/contrib/lists/models.py | 85 + orchestra/contrib/lists/serializers.py | 44 + orchestra/contrib/lists/settings.py | 40 + orchestra/contrib/lists/signals.py | 19 + orchestra/contrib/lists/tests/__init__.py | 0 .../lists/tests/functional_tests/__init__.py | 0 .../lists/tests/functional_tests/tests.py | 278 + orchestra/contrib/mailboxes/__init__.py | 1 + orchestra/contrib/mailboxes/actions.py | 13 + orchestra/contrib/mailboxes/admin.py | 327 + orchestra/contrib/mailboxes/api.py | 28 + orchestra/contrib/mailboxes/apps.py | 14 + orchestra/contrib/mailboxes/backends.py | 620 ++ orchestra/contrib/mailboxes/filters.py | 47 + orchestra/contrib/mailboxes/forms.py | 79 + orchestra/contrib/mailboxes/models.py | 178 + orchestra/contrib/mailboxes/serializers.py | 107 + orchestra/contrib/mailboxes/settings.py | 205 + orchestra/contrib/mailboxes/signals.py | 51 + orchestra/contrib/mailboxes/tests/__init__.py | 0 .../tests/functional_tests/__init__.py | 0 .../mailboxes/tests/functional_tests/tests.py | 380 ++ orchestra/contrib/mailboxes/validators.py | 70 + orchestra/contrib/mailboxes/widgets.py | 33 + orchestra/contrib/mailer/README.md | 5 + orchestra/contrib/mailer/__init__.py | 1 + orchestra/contrib/mailer/actions.py | 8 + orchestra/contrib/mailer/admin.py | 157 + orchestra/contrib/mailer/apps.py | 12 + orchestra/contrib/mailer/backends.py | 53 + orchestra/contrib/mailer/engine.py | 76 + .../commands/sendpendingmessages.py | 12 + orchestra/contrib/mailer/models.py | 73 + orchestra/contrib/mailer/settings.py | 24 + orchestra/contrib/mailer/tasks.py | 23 + .../admin/mailer/message/change_list.html | 14 + orchestra/contrib/miscellaneous/__init__.py | 1 + orchestra/contrib/miscellaneous/admin.py | 150 + orchestra/contrib/miscellaneous/apps.py | 15 + orchestra/contrib/miscellaneous/models.py | 85 + orchestra/contrib/miscellaneous/settings.py | 8 + orchestra/contrib/orchestration/README.md | 80 + orchestra/contrib/orchestration/__init__.py | 91 + orchestra/contrib/orchestration/actions.py | 134 + orchestra/contrib/orchestration/admin.py | 196 + orchestra/contrib/orchestration/apps.py | 14 + orchestra/contrib/orchestration/backends.py | 249 + orchestra/contrib/orchestration/forms.py | 20 + orchestra/contrib/orchestration/helpers.py | 173 + .../orchestration/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/orchestrate.py | 137 + orchestra/contrib/orchestration/manager.py | 209 + orchestra/contrib/orchestration/managers.py | 81 + orchestra/contrib/orchestration/methods.py | 186 + .../contrib/orchestration/middlewares.py | 116 + orchestra/contrib/orchestration/models.py | 266 + orchestra/contrib/orchestration/settings.py | 51 + orchestra/contrib/orchestration/signals.py | 14 + orchestra/contrib/orchestration/tasks.py | 16 + .../admin/orchestration/backends/retry.html | 9 + .../admin/orchestration/orchestrate.html | 16 + .../contrib/orchestration/tests/__init__.py | 0 .../contrib/orchestration/tests/test_route.py | 41 + orchestra/contrib/orchestration/utils.py | 37 + orchestra/contrib/orchestration/widgets.py | 24 + orchestra/contrib/orders/__init__.py | 1 + orchestra/contrib/orders/actions.py | 174 + orchestra/contrib/orders/admin.py | 207 + orchestra/contrib/orders/api.py | 15 + orchestra/contrib/orders/apps.py | 13 + orchestra/contrib/orders/billing.py | 96 + orchestra/contrib/orders/filters.py | 131 + orchestra/contrib/orders/forms.py | 70 + orchestra/contrib/orders/helpers.py | 39 + orchestra/contrib/orders/models.py | 310 + orchestra/contrib/orders/serializers.py | 14 + orchestra/contrib/orders/settings.py | 46 + orchestra/contrib/orders/signals.py | 39 + orchestra/contrib/orders/tasks.py | 50 + .../orders/order/bill_selected_options.html | 98 + .../templates/admin/orders/order/report.html | 62 + .../contrib/orders/templatetags/orders.py | 19 + orchestra/contrib/orders/tests/__init__.py | 1 + orchestra/contrib/payments/__init__.py | 1 + orchestra/contrib/payments/actions.py | 220 + orchestra/contrib/payments/admin.py | 245 + orchestra/contrib/payments/api.py | 21 + orchestra/contrib/payments/apps.py | 14 + orchestra/contrib/payments/helpers.py | 36 + .../payments/locale/ca/LC_MESSAGES/django.mo | Bin 0 -> 740 bytes .../payments/locale/ca/LC_MESSAGES/django.po | 342 + .../payments/locale/es/LC_MESSAGES/django.mo | Bin 0 -> 735 bytes .../payments/locale/es/LC_MESSAGES/django.po | 342 + .../contrib/payments/methods/__init__.py | 1 + .../contrib/payments/methods/creditcard.py | 32 + orchestra/contrib/payments/methods/options.py | 42 + .../payments/methods/pain.001.001.03.xsd | 921 +++ .../payments/methods/pain.008.001.02.xsd | 879 +++ .../payments/methods/sepadirectdebit.py | 319 + orchestra/contrib/payments/models.py | 210 + orchestra/contrib/payments/serializers.py | 46 + orchestra/contrib/payments/settings.py | 46 + .../payments/transaction/get_processes.html | 19 + .../admin/payments/transaction/report.html | 81 + orchestra/contrib/plans/__init__.py | 1 + orchestra/contrib/plans/admin.py | 59 + orchestra/contrib/plans/apps.py | 16 + orchestra/contrib/plans/models.py | 104 + orchestra/contrib/plans/ratings.py | 212 + orchestra/contrib/plans/settings.py | 15 + orchestra/contrib/resources/__init__.py | 4 + orchestra/contrib/resources/actions.py | 47 + orchestra/contrib/resources/admin.py | 356 ++ orchestra/contrib/resources/aggregations.py | 169 + orchestra/contrib/resources/api.py | 15 + orchestra/contrib/resources/apps.py | 32 + orchestra/contrib/resources/backends.py | 100 + orchestra/contrib/resources/filters.py | 17 + orchestra/contrib/resources/forms.py | 44 + orchestra/contrib/resources/helpers.py | 134 + orchestra/contrib/resources/models.py | 353 ++ orchestra/contrib/resources/serializers.py | 93 + orchestra/contrib/resources/settings.py | 6 + orchestra/contrib/resources/signals.py | 18 + orchestra/contrib/resources/tasks.py | 77 + .../admin/resources/resourcedata/history.html | 251 + orchestra/contrib/resources/validators.py | 11 + orchestra/contrib/saas/README.md | 90 + orchestra/contrib/saas/__init__.py | 1 + orchestra/contrib/saas/admin.py | 60 + orchestra/contrib/saas/api.py | 16 + orchestra/contrib/saas/apps.py | 13 + orchestra/contrib/saas/backends/__init__.py | 132 + orchestra/contrib/saas/backends/bscw.py | 59 + orchestra/contrib/saas/backends/dokuwikimu.py | 115 + orchestra/contrib/saas/backends/drupalmu.py | 46 + orchestra/contrib/saas/backends/gitlab.py | 119 + orchestra/contrib/saas/backends/moodle.py | 170 + orchestra/contrib/saas/backends/nextcloud.py | 175 + orchestra/contrib/saas/backends/owncloud.py | 175 + orchestra/contrib/saas/backends/phplist.py | 239 + .../contrib/saas/backends/wordpressmu.py | 279 + orchestra/contrib/saas/fields.py | 14 + orchestra/contrib/saas/filters.py | 20 + orchestra/contrib/saas/forms.py | 95 + orchestra/contrib/saas/models.py | 87 + orchestra/contrib/saas/serializers.py | 28 + orchestra/contrib/saas/services/__init__.py | 1 + orchestra/contrib/saas/services/bscw.py | 25 + orchestra/contrib/saas/services/dokuwiki.py | 24 + orchestra/contrib/saas/services/drupal.py | 10 + orchestra/contrib/saas/services/gitlab.py | 35 + orchestra/contrib/saas/services/helpers.py | 134 + orchestra/contrib/saas/services/moodle.py | 25 + orchestra/contrib/saas/services/nextcloud.py | 13 + orchestra/contrib/saas/services/options.py | 205 + orchestra/contrib/saas/services/owncloud.py | 13 + orchestra/contrib/saas/services/phplist.py | 122 + orchestra/contrib/saas/services/seafile.py | 31 + orchestra/contrib/saas/services/wordpress.py | 45 + orchestra/contrib/saas/settings.py | 341 + orchestra/contrib/saas/signals.py | 22 + orchestra/contrib/saas/validators.py | 14 + orchestra/contrib/services/__init__.py | 1 + orchestra/contrib/services/actions.py | 84 + orchestra/contrib/services/admin.py | 107 + orchestra/contrib/services/apps.py | 14 + orchestra/contrib/services/handlers.py | 662 ++ orchestra/contrib/services/helpers.py | 149 + orchestra/contrib/services/models.py | 266 + orchestra/contrib/services/settings.py | 51 + .../services/static/services/img/services.png | Bin 0 -> 93392 bytes .../services/static/services/img/services.svg | 485 ++ orchestra/contrib/services/tasks.py | 13 + .../admin/services/service/change_form.html | 180 + .../admin/services/service/help.html | 12 + .../admin/services/service/update_orders.html | 52 + orchestra/contrib/services/tests/__init__.py | 0 .../tests/functional_tests/__init__.py | 0 .../tests/functional_tests/test_domain.py | 138 + .../tests/functional_tests/test_ftp.py | 102 + .../tests/functional_tests/test_job.py | 49 + .../tests/functional_tests/test_mailbox.py | 176 + .../tests/functional_tests/test_plan.py | 52 + .../tests/functional_tests/test_traffic.py | 169 + .../contrib/services/tests/test_handler.py | 537 ++ orchestra/contrib/settings/README.md | 18 + orchestra/contrib/settings/__init__.py | 113 + orchestra/contrib/settings/admin.py | 110 + orchestra/contrib/settings/apps.py | 30 + orchestra/contrib/settings/forms.py | 122 + orchestra/contrib/settings/parser.py | 170 + .../templates/admin/settings/change_form.html | 90 + .../templates/admin/settings/reload.html | 54 + .../templates/admin/settings/view.html | 28 + orchestra/contrib/systemusers/__init__.py | 1 + orchestra/contrib/systemusers/actions.py | 130 + orchestra/contrib/systemusers/admin.py | 111 + orchestra/contrib/systemusers/api.py | 23 + orchestra/contrib/systemusers/apps.py | 30 + orchestra/contrib/systemusers/backends.py | 833 +++ orchestra/contrib/systemusers/filters.py | 20 + orchestra/contrib/systemusers/forms.py | 189 + .../systemusers/migrations/0001_initial.py | 32 + .../migrations/0002_webappusers.py | 33 + .../migrations/0003_auto_20230724_1813.py | 23 + .../migrations/0004_auto_20230813_0920.py | 19 + .../systemusers/migrations/__init__.py | 0 orchestra/contrib/systemusers/models.py | 178 + orchestra/contrib/systemusers/serializers.py | 43 + orchestra/contrib/systemusers/settings.py | 73 + .../systemusers/systemuser/create_link.html | 73 + .../systemuser/set_permission.html | 26 + .../contrib/systemusers/tests/__init__.py | 0 .../tests/functional_tests/__init__.py | 0 .../tests/functional_tests/tests.py | 378 ++ orchestra/contrib/systemusers/validators.py | 48 + orchestra/contrib/tasks/README.md | 6 + orchestra/contrib/tasks/__init__.py | 5 + orchestra/contrib/tasks/admin.py | 10 + orchestra/contrib/tasks/apps.py | 16 + orchestra/contrib/tasks/beat.py | 43 + orchestra/contrib/tasks/decorators.py | 117 + .../contrib/tasks/management/commands/beat.py | 10 + .../tasks/management/commands/runfunction.py | 32 + .../tasks/management/commands/runtask.py | 48 + .../management/commands/syncperiodictasks.py | 15 + orchestra/contrib/tasks/parser.py | 61 + orchestra/contrib/tasks/schedules.py | 118 + orchestra/contrib/tasks/settings.py | 23 + orchestra/contrib/tasks/tasks.py | 14 + orchestra/contrib/tasks/utils.py | 19 + orchestra/contrib/vps/__init__.py | 1 + orchestra/contrib/vps/admin.py | 47 + orchestra/contrib/vps/apps.py | 12 + orchestra/contrib/vps/backends.py | 154 + orchestra/contrib/vps/backends.py.new | 135 + orchestra/contrib/vps/models.py | 46 + orchestra/contrib/vps/settings.py | 36 + orchestra/contrib/webapps/__init__.py | 1 + orchestra/contrib/webapps/admin.py | 125 + orchestra/contrib/webapps/api.py | 50 + orchestra/contrib/webapps/apps.py | 13 + .../contrib/webapps/backends/__init__.py | 98 + orchestra/contrib/webapps/backends/moodle.py | 108 + orchestra/contrib/webapps/backends/php.py | 334 + orchestra/contrib/webapps/backends/python.py | 86 + orchestra/contrib/webapps/backends/static.py | 30 + .../contrib/webapps/backends/symboliclink.py | 35 + .../contrib/webapps/backends/webalizer.py | 22 + .../contrib/webapps/backends/wordpress.py | 160 + orchestra/contrib/webapps/fields.py | 28 + orchestra/contrib/webapps/filters.py | 51 + .../webapps/migrations/0001_initial.py | 51 + .../migrations/0002_webapp_sftpuser.py | 20 + .../migrations/0003_auto_20230728_1639.py | 20 + .../migrations/0004_auto_20230817_1108.py | 18 + .../contrib/webapps/migrations/__init__.py | 0 orchestra/contrib/webapps/models.py | 127 + orchestra/contrib/webapps/options.py | 383 ++ orchestra/contrib/webapps/serializers.py | 69 + orchestra/contrib/webapps/settings.py | 298 + orchestra/contrib/webapps/signals.py | 25 + orchestra/contrib/webapps/tests/__init__.py | 0 .../tests/functional_tests/__init__.py | 0 .../webapps/tests/functional_tests/tests.py | 130 + orchestra/contrib/webapps/types/__init__.py | 97 + orchestra/contrib/webapps/types/cms.py | 114 + orchestra/contrib/webapps/types/misc.py | 57 + orchestra/contrib/webapps/types/moodle.py | 19 + orchestra/contrib/webapps/types/php.py | 176 + orchestra/contrib/webapps/types/python.py | 64 + orchestra/contrib/webapps/types/wordpress.py | 19 + orchestra/contrib/webapps/utils.py | 10 + orchestra/contrib/websites/__init__.py | 1 + orchestra/contrib/websites/admin.py | 139 + orchestra/contrib/websites/api.py | 25 + orchestra/contrib/websites/apps.py | 19 + .../contrib/websites/backends/__init__.py | 5 + orchestra/contrib/websites/backends/apache.py | 503 ++ orchestra/contrib/websites/backends/moodle.py | 25 + .../contrib/websites/backends/webalizer.py | 130 + .../contrib/websites/backends/wordpress.py | 66 + orchestra/contrib/websites/directives.py | 207 + orchestra/contrib/websites/filters.py | 34 + orchestra/contrib/websites/forms.py | 71 + .../websites/migrations/0001_initial.py | 65 + .../migrations/0002_auto_20230817_1149.py | 20 + .../contrib/websites/migrations/__init__.py | 0 orchestra/contrib/websites/models.py | 177 + orchestra/contrib/websites/serializers.py | 130 + orchestra/contrib/websites/settings.py | 129 + orchestra/contrib/websites/tests/__init__.py | 0 .../tests/functional_tests/__init__.py | 0 .../websites/tests/functional_tests/tests.py | 140 + orchestra/contrib/websites/utils.py | 5 + orchestra/contrib/websites/validators.py | 38 + orchestra/core/__init__.py | 52 + orchestra/core/caches.py | 45 + orchestra/core/context_processors.py | 9 + orchestra/core/translations.py | 13 + orchestra/core/validators.py | 186 + orchestra/forms/__init__.py | 1 + orchestra/forms/fields.py | 29 + orchestra/forms/options.py | 98 + orchestra/forms/widgets.py | 73 + orchestra/management/__init__.py | 0 orchestra/management/commands/__init__.py | 0 orchestra/management/commands/makemessages.py | 59 + .../management/commands/orchestrastatus.py | 79 + .../management/commands/orchestraversion.py | 10 + .../commands/postupgradeorchestra.py | 108 + .../management/commands/restartservices.py | 10 + orchestra/management/commands/setupcelery.py | 146 + .../management/commands/setupcronbeat.py | 40 + orchestra/management/commands/setuplog.py | 84 + orchestra/management/commands/setupnginx.py | 340 + orchestra/management/commands/setuppostfix.py | 365 ++ .../management/commands/setuppostgres.py | 145 + .../management/commands/startservices.py | 62 + orchestra/management/commands/staticcheck.py | 12 + orchestra/management/commands/stopservices.py | 10 + .../management/commands/upgradeorchestra.py | 105 + orchestra/models/__init__.py | 0 orchestra/models/fields.py | 99 + orchestra/models/queryset.py | 31 + orchestra/models/utils.py | 59 + orchestra/permissions/__init__.py | 1 + orchestra/permissions/api.py | 28 + orchestra/permissions/auth.py | 46 + orchestra/permissions/options.py | 106 + orchestra/plugins/__init__.py | 1 + orchestra/plugins/admin.py | 126 + orchestra/plugins/forms.py | 127 + orchestra/plugins/options.py | 142 + orchestra/settings.py | 99 + orchestra/static/admin/css/login.css | 209 + orchestra/static/admin_tools/css/theming.css | 21 + .../orchestra/css/adminextraprettystyle.css | 209 + .../static/orchestra/css/dancing-dots.css | 40 + .../static/orchestra/css/hide-inline-id.css | 7 + .../static/orchestra/css/pygments/default.css | 62 + .../static/orchestra/css/pygments/github.css | 61 + .../orchestra/icons/Applications-internet.png | Bin 0 -> 4646 bytes .../orchestra/icons/Applications-internet.svg | 520 ++ .../orchestra/icons/Applications-office.svg | 614 ++ .../orchestra/icons/Applications-other.png | Bin 0 -> 2863 bytes .../orchestra/icons/Applications-other.svg | 380 ++ .../static/orchestra/icons/Appointment.png | Bin 0 -> 4203 bytes .../static/orchestra/icons/Appointment.svg | 413 ++ .../static/orchestra/icons/ContractedPack.png | Bin 0 -> 2533 bytes .../static/orchestra/icons/ContractedPack.svg | 4337 +++++++++++++ .../static/orchestra/icons/Dialog-accept.png | Bin 0 -> 2797 bytes .../static/orchestra/icons/Dialog-accept.svg | 183 + .../orchestra/icons/Edit-check-sheet.png | Bin 0 -> 19927 bytes .../orchestra/icons/Edit-check-sheet.svg | 390 ++ .../orchestra/icons/Emblem-important.png | Bin 0 -> 906 bytes .../orchestra/icons/Emblem-important.svg | 108 + .../static/orchestra/icons/Face-monkey.png | Bin 0 -> 3908 bytes .../static/orchestra/icons/Face-monkey.svg | 414 ++ orchestra/static/orchestra/icons/History.png | Bin 0 -> 2675 bytes orchestra/static/orchestra/icons/History.svg | 554 ++ orchestra/static/orchestra/icons/Koala.png | Bin 0 -> 4728 bytes orchestra/static/orchestra/icons/Koala.svg | 5595 +++++++++++++++++ .../static/orchestra/icons/Mail-send.png | Bin 0 -> 3237 bytes .../static/orchestra/icons/Mail-send.svg | 999 +++ .../orchestra/icons/Misc-Misc-Box-icon.png | Bin 0 -> 4933 bytes .../orchestra/icons/Misc-Misc-Box-icon.svg | 470 ++ .../static/orchestra/icons/Mr-potato.png | Bin 0 -> 4811 bytes .../static/orchestra/icons/Mr-potato.svg | 4273 +++++++++++++ .../icons/Multimedia-volume-control.png | Bin 0 -> 2732 bytes .../icons/Multimedia-volume-control.svg | 242 + orchestra/static/orchestra/icons/Pack.png | Bin 0 -> 3427 bytes orchestra/static/orchestra/icons/Pack.svg | 5095 +++++++++++++++ .../orchestra/icons/Package-x-generic.png | Bin 0 -> 2221 bytes .../orchestra/icons/Package-x-generic.svg | 418 ++ .../orchestra/icons/Preferences-system.svg | 396 ++ .../static/orchestra/icons/Preferences.png | Bin 0 -> 2016 bytes .../static/orchestra/icons/Preferences.svg | 783 +++ .../static/orchestra/icons/Taskstate.svg | 512 ++ .../static/orchestra/icons/Text-x-boo.svg | 997 +++ .../static/orchestra/icons/Text-x-script.svg | 419 ++ .../static/orchestra/icons/Ticket_star.png | Bin 0 -> 4230 bytes orchestra/static/orchestra/icons/Tux.png | Bin 0 -> 3957 bytes orchestra/static/orchestra/icons/Tux.svg | 648 ++ orchestra/static/orchestra/icons/TuxBox.png | Bin 0 -> 3490 bytes orchestra/static/orchestra/icons/TuxBox.svg | 4661 ++++++++++++++ .../icons/Utilities-system-monitor.png | Bin 0 -> 3389 bytes .../icons/Utilities-system-monitor.svg | 378 ++ .../orchestra/icons/X-office-address-book.png | Bin 0 -> 3193 bytes .../orchestra/icons/X-office-address-book.svg | 301 + .../orchestra/icons/applications-other.png | Bin 0 -> 2625 bytes .../orchestra/icons/applications-other.svg | 807 +++ orchestra/static/orchestra/icons/apps.png | Bin 0 -> 2071 bytes orchestra/static/orchestra/icons/apps.svg | 531 ++ .../static/orchestra/icons/apps/BSCW.png | Bin 0 -> 3963 bytes .../static/orchestra/icons/apps/Dokuwiki.png | Bin 0 -> 5013 bytes .../static/orchestra/icons/apps/Dokuwiki.svg | 581 ++ .../static/orchestra/icons/apps/Drupal.png | Bin 0 -> 2422 bytes .../static/orchestra/icons/apps/Drupal.svg | 103 + .../static/orchestra/icons/apps/Moodle.png | Bin 0 -> 3754 bytes .../static/orchestra/icons/apps/Moodle.svg | 258 + orchestra/static/orchestra/icons/apps/PHP.png | Bin 0 -> 3243 bytes orchestra/static/orchestra/icons/apps/PHP.svg | 203 + .../static/orchestra/icons/apps/Phplist.png | Bin 0 -> 2105 bytes .../static/orchestra/icons/apps/Phplist.svg | 69 + .../static/orchestra/icons/apps/Python.png | Bin 0 -> 3272 bytes .../static/orchestra/icons/apps/Python.svg | 36 + .../static/orchestra/icons/apps/Static.png | Bin 0 -> 3252 bytes .../static/orchestra/icons/apps/Static.svg | 1365 ++++ .../static/orchestra/icons/apps/Stats.png | Bin 0 -> 2436 bytes .../static/orchestra/icons/apps/Stats.svg | 394 ++ .../orchestra/icons/apps/SymbolicLink.png | Bin 0 -> 1928 bytes .../orchestra/icons/apps/SymbolicLink.svg | 177 + .../static/orchestra/icons/apps/WordPress.png | Bin 0 -> 3050 bytes .../static/orchestra/icons/apps/WordPress.svg | 94 + .../static/orchestra/icons/apps/gitlab.png | Bin 0 -> 2540 bytes .../static/orchestra/icons/apps/ownCloud.png | Bin 0 -> 2166 bytes .../static/orchestra/icons/apps/ownCloud.svg | 151 + .../static/orchestra/icons/apps/seafile.png | Bin 0 -> 2604 bytes .../static/orchestra/icons/apps/seafile.svg | 68 + orchestra/static/orchestra/icons/auth.svg | 2449 ++++++++ orchestra/static/orchestra/icons/basket.png | Bin 0 -> 4316 bytes orchestra/static/orchestra/icons/basket.svg | 1543 +++++ orchestra/static/orchestra/icons/bill.png | Bin 0 -> 2812 bytes orchestra/static/orchestra/icons/bill.svg | 1717 +++++ .../static/orchestra/icons/card_in_use.png | Bin 0 -> 1108 bytes .../static/orchestra/icons/card_in_use.svg | 53 + orchestra/static/orchestra/icons/contact.png | Bin 0 -> 2729 bytes orchestra/static/orchestra/icons/contact.svg | 2810 +++++++++ .../static/orchestra/icons/contact_alt.png | Bin 0 -> 4286 bytes .../static/orchestra/icons/contact_book.png | Bin 0 -> 5771 bytes orchestra/static/orchestra/icons/daemon.png | Bin 0 -> 3252 bytes orchestra/static/orchestra/icons/daemon.svg | 562 ++ orchestra/static/orchestra/icons/database.png | Bin 0 -> 3039 bytes orchestra/static/orchestra/icons/database.svg | 401 ++ orchestra/static/orchestra/icons/domain.png | Bin 0 -> 4559 bytes orchestra/static/orchestra/icons/domain.svg | 3045 +++++++++ .../static/orchestra/icons/email-alter.png | Bin 0 -> 2892 bytes .../static/orchestra/icons/email-alter.svg | 3072 +++++++++ orchestra/static/orchestra/icons/email.png | Bin 0 -> 2646 bytes orchestra/static/orchestra/icons/email.svg | 1643 +++++ .../static/orchestra/icons/extrafield.png | Bin 0 -> 2512 bytes .../static/orchestra/icons/extrafield.svg | 1170 ++++ orchestra/static/orchestra/icons/gauge.png | Bin 0 -> 3887 bytes orchestra/static/orchestra/icons/gauge.svg | 1116 ++++ .../static/orchestra/icons/gnome-terminal.png | Bin 0 -> 1690 bytes .../static/orchestra/icons/gnome-terminal.svg | 354 ++ orchestra/static/orchestra/icons/hal.png | Bin 0 -> 4052 bytes orchestra/static/orchestra/icons/hal.svg | 1002 +++ orchestra/static/orchestra/icons/invoice.png | Bin 0 -> 2164 bytes orchestra/static/orchestra/icons/invoice.svg | 679 ++ orchestra/static/orchestra/icons/job.svg | 3386 ++++++++++ orchestra/static/orchestra/icons/monitor.png | Bin 0 -> 2591 bytes orchestra/static/orchestra/icons/monitor.svg | 3134 +++++++++ orchestra/static/orchestra/icons/mysql.png | Bin 0 -> 3221 bytes orchestra/static/orchestra/icons/mysql.svg | 1686 +++++ orchestra/static/orchestra/icons/order.png | Bin 0 -> 3506 bytes orchestra/static/orchestra/icons/order.svg | 1838 ++++++ .../static/orchestra/icons/periodictask.svg | 3801 +++++++++++ .../static/orchestra/icons/postgresql.png | Bin 0 -> 3868 bytes .../static/orchestra/icons/postgresql.svg | 894 +++ .../static/orchestra/icons/preferences.png | Bin 0 -> 1685 bytes orchestra/static/orchestra/icons/price.png | Bin 0 -> 2295 bytes orchestra/static/orchestra/icons/price.svg | 94 + .../static/orchestra/icons/roleplaying.png | Bin 0 -> 3475 bytes .../static/orchestra/icons/roleplaying.svg | 444 ++ orchestra/static/orchestra/icons/saas.png | Bin 0 -> 2214 bytes orchestra/static/orchestra/icons/saas.svg | 109 + .../static/orchestra/icons/scriptlog.png | Bin 0 -> 2709 bytes .../static/orchestra/icons/scriptlog.svg | 756 +++ orchestra/static/orchestra/icons/ssh.svg | 890 +++ .../static/orchestra/icons/taskstate.png | Bin 0 -> 3242 bytes .../static/orchestra/icons/transaction.png | Bin 0 -> 3320 bytes .../static/orchestra/icons/transaction.svg | 1112 ++++ .../orchestra/icons/transactionprocess.png | Bin 0 -> 3741 bytes .../orchestra/icons/transactionprocess.svg | 1533 +++++ orchestra/static/orchestra/icons/users.png | Bin 0 -> 3081 bytes orchestra/static/orchestra/icons/users.svg | 3802 +++++++++++ orchestra/static/orchestra/icons/vps.png | Bin 0 -> 2710 bytes orchestra/static/orchestra/icons/vps.svg | 3625 +++++++++++ orchestra/static/orchestra/icons/web.png | Bin 0 -> 3984 bytes orchestra/static/orchestra/icons/web.svg | 295 + orchestra/static/orchestra/icons/zone.png | Bin 0 -> 3119 bytes orchestra/static/orchestra/icons/zone.svg | 2875 +++++++++ orchestra/static/orchestra/images/add.png | Bin 0 -> 356 bytes orchestra/static/orchestra/images/add.svg | 89 + orchestra/static/orchestra/images/favicon.png | Bin 0 -> 1150 bytes orchestra/static/orchestra/images/history.png | Bin 0 -> 1057 bytes orchestra/static/orchestra/images/history.svg | 358 ++ .../orchestra/images/icon_changelink.gif | Bin 0 -> 119 bytes .../orchestra/images/orchestra-logo.png | Bin 0 -> 1818 bytes .../orchestra/images/orchestra-logo.svg | 206 + .../static/orchestra/images/page-gradient.png | Bin 0 -> 280 bytes orchestra/static/orchestra/images/reload.png | Bin 0 -> 828 bytes orchestra/static/orchestra/images/reload.svg | 207 + .../static/orchestra/images/view-on-site.png | Bin 0 -> 344 bytes .../static/orchestra/images/view-on-site.svg | 116 + .../static/orchestra/js/collapse-open.js | 5 + .../orchestra/js/highcharts/highcharts.js | 325 + .../js/highcharts/modules/exporting.js | 23 + .../js/highcharts/stock/highstock.js | 414 ++ orchestra/templates/admin/base.html | 76 + orchestra/templates/admin/base_site.html | 32 + orchestra/templates/admin/index.html | 15 + orchestra/templates/admin/login.html | 65 + .../admin/orchestra/change_password.html | 41 + .../admin/orchestra/generic_confirmation.html | 71 + orchestra/templates/admin/orchestra/menu.html | 71 + .../templates/admin/orchestra/search.html | 18 + .../admin/plugins/select_plugin.html | 37 + .../orchestra/admin/change_form.html | 10 + orchestra/templates/rest_framework/api.html | 26 + orchestra/templatetags/__init__.py | 0 orchestra/templatetags/markdown.py | 10 + orchestra/templatetags/utils.py | 130 + orchestra/urls.py | 32 + orchestra/utils/__init__.py | 0 orchestra/utils/apps.py | 23 + orchestra/utils/db.py | 62 + orchestra/utils/functional.py | 19 + orchestra/utils/html.py | 38 + orchestra/utils/html.py.bk | 37 + orchestra/utils/humanize.py | 151 + orchestra/utils/mail.py | 38 + orchestra/utils/paths.py | 24 + orchestra/utils/python.py | 107 + orchestra/utils/sys.py | 249 + orchestra/utils/tests.py | 180 + orchestra/views.py | 20 + requirements.txt | 23 + scripts/containers/Dockerfile | 144 + scripts/containers/create.sh | 60 + scripts/containers/deploy-dev.sh | 152 + scripts/containers/deploy.sh | 306 + scripts/migration/README.md | 6 + scripts/migration/accounts.sh | 17 + scripts/migration/apache2.py | 192 + scripts/migration/bind9.sh | 56 + scripts/migration/mailbox.sh | 36 + scripts/migration/mysql.sh | 11 + scripts/migration/virtusertable.sh | 64 + scripts/services/README.md | 6 + scripts/services/apache_full_stack.md | 106 + scripts/services/bind9.md | 14 + scripts/services/mailman.md | 54 + scripts/services/mailman.sh | 3 + scripts/services/mysql.md | 6 + scripts/services/php4_on_debian.md | 94 + scripts/services/postfix.md | 47 + scripts/services/rssh.md | 17 + scripts/services/vsftpd.md | 30 + scripts/services/webalizer.md | 21 + scripts/tests/setup.sh | 9 + setup.py | 53 + 738 files changed, 148221 insertions(+) create mode 100644 INSTALL.md create mode 100644 INSTALLDEV.md create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 ROADMAP.md create mode 100644 TODO.md create mode 100644 docs/API.rst create mode 100644 docs/Makefile create mode 100644 docs/README.md create mode 100644 docs/conf.py create mode 100644 docs/create-services.md create mode 100644 docs/images/index-screenshot.png create mode 100644 docs/images/orchestration.svg create mode 100644 docs/images/services.svg create mode 100644 docs/index.rst create mode 100644 docs/make.bat create mode 100644 install_manually.md create mode 100644 orchestra/__init__.py create mode 100644 orchestra/admin/__init__.py create mode 100644 orchestra/admin/actions.py create mode 100644 orchestra/admin/dashboard.py create mode 100644 orchestra/admin/decorators.py create mode 100644 orchestra/admin/forms.py create mode 100644 orchestra/admin/html.py create mode 100644 orchestra/admin/menu.py create mode 100644 orchestra/admin/options.py create mode 100644 orchestra/admin/utils.py create mode 100644 orchestra/api/__init__.py create mode 100644 orchestra/api/actions.py create mode 100644 orchestra/api/helpers.py create mode 100644 orchestra/api/options.py create mode 100644 orchestra/api/root.py create mode 100644 orchestra/api/serializers.py create mode 100644 orchestra/apps.py create mode 100755 orchestra/bin/celerybeat create mode 100755 orchestra/bin/celeryd create mode 100755 orchestra/bin/celeryevcam create mode 100755 orchestra/bin/django_bash_completion.sh create mode 100755 orchestra/bin/orchestra-admin create mode 100755 orchestra/bin/orchestra-beat create mode 100755 orchestra/bin/sieve-test create mode 100644 orchestra/conf/__init__.py create mode 100644 orchestra/conf/project_template/locale/.gitignore create mode 100755 orchestra/conf/project_template/manage.py create mode 100644 orchestra/conf/project_template/media/.gitignore create mode 100644 orchestra/conf/project_template/project_name/__init__.py create mode 100644 orchestra/conf/project_template/project_name/settings.py create mode 100644 orchestra/conf/project_template/project_name/urls.py create mode 100644 orchestra/conf/project_template/project_name/wsgi.py create mode 100644 orchestra/contrib/__init__.py create mode 100644 orchestra/contrib/accounts/__init__.py create mode 100644 orchestra/contrib/accounts/actions.py create mode 100644 orchestra/contrib/accounts/admin.py create mode 100644 orchestra/contrib/accounts/api.py create mode 100644 orchestra/contrib/accounts/apps.py create mode 100644 orchestra/contrib/accounts/filters.py create mode 100644 orchestra/contrib/accounts/forms.py create mode 100644 orchestra/contrib/accounts/management/__init__.py create mode 100644 orchestra/contrib/accounts/models.py create mode 100644 orchestra/contrib/accounts/serializers.py create mode 100644 orchestra/contrib/accounts/settings.py create mode 100644 orchestra/contrib/accounts/templates/admin/accounts/account/change_form.html create mode 100644 orchestra/contrib/accounts/templates/admin/accounts/account/change_list.html create mode 100644 orchestra/contrib/accounts/templates/admin/accounts/account/delete_related_services_confirmation.html create mode 100644 orchestra/contrib/accounts/templates/admin/accounts/account/disable_selected_confirmation.html create mode 100644 orchestra/contrib/accounts/templates/admin/accounts/account/select_account_list.html create mode 100644 orchestra/contrib/accounts/templates/admin/accounts/account/service_report.html create mode 100644 orchestra/contrib/bills/__init__.py create mode 100644 orchestra/contrib/bills/actions.py create mode 100644 orchestra/contrib/bills/admin.py create mode 100644 orchestra/contrib/bills/api.py create mode 100644 orchestra/contrib/bills/apps.py create mode 100644 orchestra/contrib/bills/filters.py create mode 100644 orchestra/contrib/bills/forms.py create mode 100644 orchestra/contrib/bills/helpers.py create mode 100644 orchestra/contrib/bills/locale/ca/LC_MESSAGES/django.mo create mode 100644 orchestra/contrib/bills/locale/ca/LC_MESSAGES/django.po create mode 100644 orchestra/contrib/bills/locale/es/LC_MESSAGES/django.mo create mode 100644 orchestra/contrib/bills/locale/es/LC_MESSAGES/django.po create mode 100644 orchestra/contrib/bills/models.py create mode 100644 orchestra/contrib/bills/serializers.py create mode 100644 orchestra/contrib/bills/settings.py create mode 100644 orchestra/contrib/bills/templates/admin/bills/bill/change_list.html create mode 100644 orchestra/contrib/bills/templates/admin/bills/bill/close_send_download_bills.html create mode 100644 orchestra/contrib/bills/templates/admin/bills/bill/report.html create mode 100644 orchestra/contrib/bills/templates/admin/bills/billline/change_list.html create mode 100644 orchestra/contrib/bills/templates/admin/bills/billline/report.html create mode 100644 orchestra/contrib/bills/templates/bills/base.html create mode 100644 orchestra/contrib/bills/templates/bills/bill-notification.email create mode 100644 orchestra/contrib/bills/templates/bills/invoice.html create mode 100644 orchestra/contrib/bills/templates/bills/microspective-fee.html create mode 100644 orchestra/contrib/bills/templates/bills/microspective-proforma.html create mode 100644 orchestra/contrib/bills/templates/bills/microspective.css create mode 100644 orchestra/contrib/bills/templates/bills/microspective.html create mode 100644 orchestra/contrib/contacts/__init__.py create mode 100644 orchestra/contrib/contacts/admin.py create mode 100644 orchestra/contrib/contacts/api.py create mode 100644 orchestra/contrib/contacts/apps.py create mode 100644 orchestra/contrib/contacts/filters.py create mode 100644 orchestra/contrib/contacts/models.py create mode 100644 orchestra/contrib/contacts/serializers.py create mode 100644 orchestra/contrib/contacts/settings.py create mode 100644 orchestra/contrib/contacts/validators.py create mode 100644 orchestra/contrib/databases/__init__.py create mode 100644 orchestra/contrib/databases/admin.py create mode 100644 orchestra/contrib/databases/api.py create mode 100644 orchestra/contrib/databases/apps.py create mode 100644 orchestra/contrib/databases/backends.py create mode 100644 orchestra/contrib/databases/filters.py create mode 100644 orchestra/contrib/databases/forms.py create mode 100644 orchestra/contrib/databases/migrations/0001_initial.py create mode 100644 orchestra/contrib/databases/migrations/0002_databaseuser_target_server.py create mode 100644 orchestra/contrib/databases/migrations/0003_auto_20230629_1838.py create mode 100644 orchestra/contrib/databases/migrations/0004_database_target_server.py create mode 100644 orchestra/contrib/databases/migrations/0005_auto_20230705_1208.py create mode 100644 orchestra/contrib/databases/migrations/0006_auto_20230705_1237.py create mode 100644 orchestra/contrib/databases/migrations/__init__.py create mode 100644 orchestra/contrib/databases/models.py create mode 100644 orchestra/contrib/databases/serializers.py create mode 100644 orchestra/contrib/databases/settings.py create mode 100644 orchestra/contrib/databases/tests/__init__.py create mode 100644 orchestra/contrib/databases/tests/functional_tests/__init__.py create mode 100644 orchestra/contrib/databases/tests/functional_tests/tests.py create mode 100644 orchestra/contrib/domains/__init__.py create mode 100644 orchestra/contrib/domains/actions.py create mode 100644 orchestra/contrib/domains/admin.py create mode 100644 orchestra/contrib/domains/api.py create mode 100644 orchestra/contrib/domains/apps.py create mode 100644 orchestra/contrib/domains/backends.py create mode 100644 orchestra/contrib/domains/filters.py create mode 100644 orchestra/contrib/domains/forms.py create mode 100644 orchestra/contrib/domains/helpers.py create mode 100644 orchestra/contrib/domains/models.py create mode 100644 orchestra/contrib/domains/serializers.py create mode 100644 orchestra/contrib/domains/settings.py create mode 100644 orchestra/contrib/domains/templates/admin/domains/domain/change_form.html create mode 100644 orchestra/contrib/domains/templates/admin/domains/domain/edit_records.html create mode 100644 orchestra/contrib/domains/templates/admin/domains/domain/view_zone.html create mode 100644 orchestra/contrib/domains/tests/__init__.py create mode 100644 orchestra/contrib/domains/tests/functional_tests/__init__.py create mode 100644 orchestra/contrib/domains/tests/functional_tests/tests.py create mode 100644 orchestra/contrib/domains/tests/test_domains.py create mode 100644 orchestra/contrib/domains/utils.py create mode 100644 orchestra/contrib/domains/validators.py create mode 100644 orchestra/contrib/history/__init__.py create mode 100644 orchestra/contrib/history/admin.py create mode 100644 orchestra/contrib/history/apps.py create mode 100644 orchestra/contrib/history/templates/admin/admin/logentry/change_form.html create mode 100644 orchestra/contrib/history/templates/admin/object_history.html create mode 100644 orchestra/contrib/issues/__init__.py create mode 100644 orchestra/contrib/issues/actions.py create mode 100644 orchestra/contrib/issues/admin.py create mode 100644 orchestra/contrib/issues/api.py create mode 100644 orchestra/contrib/issues/apps.py create mode 100644 orchestra/contrib/issues/filters.py create mode 100644 orchestra/contrib/issues/forms.py create mode 100644 orchestra/contrib/issues/helpers.py create mode 100644 orchestra/contrib/issues/models.py create mode 100644 orchestra/contrib/issues/serializers.py create mode 100644 orchestra/contrib/issues/settings.py create mode 100644 orchestra/contrib/issues/static/issues/css/ticket-admin.css create mode 100644 orchestra/contrib/issues/static/issues/images/btn_edit.gif create mode 100644 orchestra/contrib/issues/static/issues/images/unread_ticket.gif create mode 100644 orchestra/contrib/issues/static/issues/js/admin-ticket.js create mode 100644 orchestra/contrib/issues/static/issues/js/ticket-admin.js create mode 100644 orchestra/contrib/issues/static/issues/markdown_syntax.html create mode 100644 orchestra/contrib/issues/templates/issues/ticket_notification.mail create mode 100644 orchestra/contrib/issues/templates/issues/ticket_notification_html.mail create mode 100644 orchestra/contrib/issues/tests.py create mode 100644 orchestra/contrib/letsencrypt/actions.py create mode 100644 orchestra/contrib/letsencrypt/admin.py create mode 100644 orchestra/contrib/letsencrypt/backends.py create mode 100644 orchestra/contrib/letsencrypt/forms.py create mode 100644 orchestra/contrib/letsencrypt/helpers.py create mode 100644 orchestra/contrib/letsencrypt/settings.py create mode 100644 orchestra/contrib/lists/__init__.py create mode 100644 orchestra/contrib/lists/admin.py create mode 100644 orchestra/contrib/lists/api.py create mode 100644 orchestra/contrib/lists/apps.py create mode 100644 orchestra/contrib/lists/backends.py create mode 100644 orchestra/contrib/lists/filters.py create mode 100644 orchestra/contrib/lists/models.py create mode 100644 orchestra/contrib/lists/serializers.py create mode 100644 orchestra/contrib/lists/settings.py create mode 100644 orchestra/contrib/lists/signals.py create mode 100644 orchestra/contrib/lists/tests/__init__.py create mode 100644 orchestra/contrib/lists/tests/functional_tests/__init__.py create mode 100644 orchestra/contrib/lists/tests/functional_tests/tests.py create mode 100644 orchestra/contrib/mailboxes/__init__.py create mode 100644 orchestra/contrib/mailboxes/actions.py create mode 100644 orchestra/contrib/mailboxes/admin.py create mode 100644 orchestra/contrib/mailboxes/api.py create mode 100644 orchestra/contrib/mailboxes/apps.py create mode 100644 orchestra/contrib/mailboxes/backends.py create mode 100644 orchestra/contrib/mailboxes/filters.py create mode 100644 orchestra/contrib/mailboxes/forms.py create mode 100644 orchestra/contrib/mailboxes/models.py create mode 100644 orchestra/contrib/mailboxes/serializers.py create mode 100644 orchestra/contrib/mailboxes/settings.py create mode 100644 orchestra/contrib/mailboxes/signals.py create mode 100644 orchestra/contrib/mailboxes/tests/__init__.py create mode 100644 orchestra/contrib/mailboxes/tests/functional_tests/__init__.py create mode 100644 orchestra/contrib/mailboxes/tests/functional_tests/tests.py create mode 100644 orchestra/contrib/mailboxes/validators.py create mode 100644 orchestra/contrib/mailboxes/widgets.py create mode 100644 orchestra/contrib/mailer/README.md create mode 100644 orchestra/contrib/mailer/__init__.py create mode 100644 orchestra/contrib/mailer/actions.py create mode 100644 orchestra/contrib/mailer/admin.py create mode 100644 orchestra/contrib/mailer/apps.py create mode 100644 orchestra/contrib/mailer/backends.py create mode 100644 orchestra/contrib/mailer/engine.py create mode 100644 orchestra/contrib/mailer/management/commands/sendpendingmessages.py create mode 100644 orchestra/contrib/mailer/models.py create mode 100644 orchestra/contrib/mailer/settings.py create mode 100644 orchestra/contrib/mailer/tasks.py create mode 100644 orchestra/contrib/mailer/templates/admin/mailer/message/change_list.html create mode 100644 orchestra/contrib/miscellaneous/__init__.py create mode 100644 orchestra/contrib/miscellaneous/admin.py create mode 100644 orchestra/contrib/miscellaneous/apps.py create mode 100644 orchestra/contrib/miscellaneous/models.py create mode 100644 orchestra/contrib/miscellaneous/settings.py create mode 100644 orchestra/contrib/orchestration/README.md create mode 100644 orchestra/contrib/orchestration/__init__.py create mode 100644 orchestra/contrib/orchestration/actions.py create mode 100644 orchestra/contrib/orchestration/admin.py create mode 100644 orchestra/contrib/orchestration/apps.py create mode 100644 orchestra/contrib/orchestration/backends.py create mode 100644 orchestra/contrib/orchestration/forms.py create mode 100644 orchestra/contrib/orchestration/helpers.py create mode 100644 orchestra/contrib/orchestration/management/__init__.py create mode 100644 orchestra/contrib/orchestration/management/commands/__init__.py create mode 100644 orchestra/contrib/orchestration/management/commands/orchestrate.py create mode 100644 orchestra/contrib/orchestration/manager.py create mode 100644 orchestra/contrib/orchestration/managers.py create mode 100644 orchestra/contrib/orchestration/methods.py create mode 100644 orchestra/contrib/orchestration/middlewares.py create mode 100644 orchestra/contrib/orchestration/models.py create mode 100644 orchestra/contrib/orchestration/settings.py create mode 100644 orchestra/contrib/orchestration/signals.py create mode 100644 orchestra/contrib/orchestration/tasks.py create mode 100644 orchestra/contrib/orchestration/templates/admin/orchestration/backends/retry.html create mode 100644 orchestra/contrib/orchestration/templates/admin/orchestration/orchestrate.html create mode 100644 orchestra/contrib/orchestration/tests/__init__.py create mode 100644 orchestra/contrib/orchestration/tests/test_route.py create mode 100644 orchestra/contrib/orchestration/utils.py create mode 100644 orchestra/contrib/orchestration/widgets.py create mode 100644 orchestra/contrib/orders/__init__.py create mode 100644 orchestra/contrib/orders/actions.py create mode 100644 orchestra/contrib/orders/admin.py create mode 100644 orchestra/contrib/orders/api.py create mode 100644 orchestra/contrib/orders/apps.py create mode 100644 orchestra/contrib/orders/billing.py create mode 100644 orchestra/contrib/orders/filters.py create mode 100644 orchestra/contrib/orders/forms.py create mode 100644 orchestra/contrib/orders/helpers.py create mode 100644 orchestra/contrib/orders/models.py create mode 100644 orchestra/contrib/orders/serializers.py create mode 100644 orchestra/contrib/orders/settings.py create mode 100644 orchestra/contrib/orders/signals.py create mode 100644 orchestra/contrib/orders/tasks.py create mode 100644 orchestra/contrib/orders/templates/admin/orders/order/bill_selected_options.html create mode 100644 orchestra/contrib/orders/templates/admin/orders/order/report.html create mode 100644 orchestra/contrib/orders/templatetags/orders.py create mode 100644 orchestra/contrib/orders/tests/__init__.py create mode 100644 orchestra/contrib/payments/__init__.py create mode 100644 orchestra/contrib/payments/actions.py create mode 100644 orchestra/contrib/payments/admin.py create mode 100644 orchestra/contrib/payments/api.py create mode 100644 orchestra/contrib/payments/apps.py create mode 100644 orchestra/contrib/payments/helpers.py create mode 100644 orchestra/contrib/payments/locale/ca/LC_MESSAGES/django.mo create mode 100644 orchestra/contrib/payments/locale/ca/LC_MESSAGES/django.po create mode 100644 orchestra/contrib/payments/locale/es/LC_MESSAGES/django.mo create mode 100644 orchestra/contrib/payments/locale/es/LC_MESSAGES/django.po create mode 100644 orchestra/contrib/payments/methods/__init__.py create mode 100644 orchestra/contrib/payments/methods/creditcard.py create mode 100644 orchestra/contrib/payments/methods/options.py create mode 100644 orchestra/contrib/payments/methods/pain.001.001.03.xsd create mode 100644 orchestra/contrib/payments/methods/pain.008.001.02.xsd create mode 100644 orchestra/contrib/payments/methods/sepadirectdebit.py create mode 100644 orchestra/contrib/payments/models.py create mode 100644 orchestra/contrib/payments/serializers.py create mode 100644 orchestra/contrib/payments/settings.py create mode 100644 orchestra/contrib/payments/templates/admin/payments/transaction/get_processes.html create mode 100644 orchestra/contrib/payments/templates/admin/payments/transaction/report.html create mode 100644 orchestra/contrib/plans/__init__.py create mode 100644 orchestra/contrib/plans/admin.py create mode 100644 orchestra/contrib/plans/apps.py create mode 100644 orchestra/contrib/plans/models.py create mode 100644 orchestra/contrib/plans/ratings.py create mode 100644 orchestra/contrib/plans/settings.py create mode 100644 orchestra/contrib/resources/__init__.py create mode 100644 orchestra/contrib/resources/actions.py create mode 100644 orchestra/contrib/resources/admin.py create mode 100644 orchestra/contrib/resources/aggregations.py create mode 100644 orchestra/contrib/resources/api.py create mode 100644 orchestra/contrib/resources/apps.py create mode 100644 orchestra/contrib/resources/backends.py create mode 100644 orchestra/contrib/resources/filters.py create mode 100644 orchestra/contrib/resources/forms.py create mode 100644 orchestra/contrib/resources/helpers.py create mode 100644 orchestra/contrib/resources/models.py create mode 100644 orchestra/contrib/resources/serializers.py create mode 100644 orchestra/contrib/resources/settings.py create mode 100644 orchestra/contrib/resources/signals.py create mode 100644 orchestra/contrib/resources/tasks.py create mode 100644 orchestra/contrib/resources/templates/admin/resources/resourcedata/history.html create mode 100644 orchestra/contrib/resources/validators.py create mode 100644 orchestra/contrib/saas/README.md create mode 100644 orchestra/contrib/saas/__init__.py create mode 100644 orchestra/contrib/saas/admin.py create mode 100644 orchestra/contrib/saas/api.py create mode 100644 orchestra/contrib/saas/apps.py create mode 100644 orchestra/contrib/saas/backends/__init__.py create mode 100644 orchestra/contrib/saas/backends/bscw.py create mode 100644 orchestra/contrib/saas/backends/dokuwikimu.py create mode 100644 orchestra/contrib/saas/backends/drupalmu.py create mode 100644 orchestra/contrib/saas/backends/gitlab.py create mode 100644 orchestra/contrib/saas/backends/moodle.py create mode 100644 orchestra/contrib/saas/backends/nextcloud.py create mode 100644 orchestra/contrib/saas/backends/owncloud.py create mode 100644 orchestra/contrib/saas/backends/phplist.py create mode 100644 orchestra/contrib/saas/backends/wordpressmu.py create mode 100644 orchestra/contrib/saas/fields.py create mode 100644 orchestra/contrib/saas/filters.py create mode 100644 orchestra/contrib/saas/forms.py create mode 100644 orchestra/contrib/saas/models.py create mode 100644 orchestra/contrib/saas/serializers.py create mode 100644 orchestra/contrib/saas/services/__init__.py create mode 100644 orchestra/contrib/saas/services/bscw.py create mode 100644 orchestra/contrib/saas/services/dokuwiki.py create mode 100644 orchestra/contrib/saas/services/drupal.py create mode 100644 orchestra/contrib/saas/services/gitlab.py create mode 100644 orchestra/contrib/saas/services/helpers.py create mode 100644 orchestra/contrib/saas/services/moodle.py create mode 100644 orchestra/contrib/saas/services/nextcloud.py create mode 100644 orchestra/contrib/saas/services/options.py create mode 100644 orchestra/contrib/saas/services/owncloud.py create mode 100644 orchestra/contrib/saas/services/phplist.py create mode 100644 orchestra/contrib/saas/services/seafile.py create mode 100644 orchestra/contrib/saas/services/wordpress.py create mode 100644 orchestra/contrib/saas/settings.py create mode 100644 orchestra/contrib/saas/signals.py create mode 100644 orchestra/contrib/saas/validators.py create mode 100644 orchestra/contrib/services/__init__.py create mode 100644 orchestra/contrib/services/actions.py create mode 100644 orchestra/contrib/services/admin.py create mode 100644 orchestra/contrib/services/apps.py create mode 100644 orchestra/contrib/services/handlers.py create mode 100644 orchestra/contrib/services/helpers.py create mode 100644 orchestra/contrib/services/models.py create mode 100644 orchestra/contrib/services/settings.py create mode 100644 orchestra/contrib/services/static/services/img/services.png create mode 100644 orchestra/contrib/services/static/services/img/services.svg create mode 100644 orchestra/contrib/services/tasks.py create mode 100644 orchestra/contrib/services/templates/admin/services/service/change_form.html create mode 100644 orchestra/contrib/services/templates/admin/services/service/help.html create mode 100644 orchestra/contrib/services/templates/admin/services/service/update_orders.html create mode 100644 orchestra/contrib/services/tests/__init__.py create mode 100644 orchestra/contrib/services/tests/functional_tests/__init__.py create mode 100644 orchestra/contrib/services/tests/functional_tests/test_domain.py create mode 100644 orchestra/contrib/services/tests/functional_tests/test_ftp.py create mode 100644 orchestra/contrib/services/tests/functional_tests/test_job.py create mode 100644 orchestra/contrib/services/tests/functional_tests/test_mailbox.py create mode 100644 orchestra/contrib/services/tests/functional_tests/test_plan.py create mode 100644 orchestra/contrib/services/tests/functional_tests/test_traffic.py create mode 100644 orchestra/contrib/services/tests/test_handler.py create mode 100644 orchestra/contrib/settings/README.md create mode 100644 orchestra/contrib/settings/__init__.py create mode 100644 orchestra/contrib/settings/admin.py create mode 100644 orchestra/contrib/settings/apps.py create mode 100644 orchestra/contrib/settings/forms.py create mode 100644 orchestra/contrib/settings/parser.py create mode 100644 orchestra/contrib/settings/templates/admin/settings/change_form.html create mode 100644 orchestra/contrib/settings/templates/admin/settings/reload.html create mode 100644 orchestra/contrib/settings/templates/admin/settings/view.html create mode 100644 orchestra/contrib/systemusers/__init__.py create mode 100644 orchestra/contrib/systemusers/actions.py create mode 100644 orchestra/contrib/systemusers/admin.py create mode 100644 orchestra/contrib/systemusers/api.py create mode 100644 orchestra/contrib/systemusers/apps.py create mode 100644 orchestra/contrib/systemusers/backends.py create mode 100644 orchestra/contrib/systemusers/filters.py create mode 100644 orchestra/contrib/systemusers/forms.py create mode 100644 orchestra/contrib/systemusers/migrations/0001_initial.py create mode 100644 orchestra/contrib/systemusers/migrations/0002_webappusers.py create mode 100644 orchestra/contrib/systemusers/migrations/0003_auto_20230724_1813.py create mode 100644 orchestra/contrib/systemusers/migrations/0004_auto_20230813_0920.py create mode 100644 orchestra/contrib/systemusers/migrations/__init__.py create mode 100644 orchestra/contrib/systemusers/models.py create mode 100644 orchestra/contrib/systemusers/serializers.py create mode 100644 orchestra/contrib/systemusers/settings.py create mode 100644 orchestra/contrib/systemusers/templates/admin/systemusers/systemuser/create_link.html create mode 100644 orchestra/contrib/systemusers/templates/admin/systemusers/systemuser/set_permission.html create mode 100644 orchestra/contrib/systemusers/tests/__init__.py create mode 100644 orchestra/contrib/systemusers/tests/functional_tests/__init__.py create mode 100644 orchestra/contrib/systemusers/tests/functional_tests/tests.py create mode 100644 orchestra/contrib/systemusers/validators.py create mode 100644 orchestra/contrib/tasks/README.md create mode 100644 orchestra/contrib/tasks/__init__.py create mode 100644 orchestra/contrib/tasks/admin.py create mode 100644 orchestra/contrib/tasks/apps.py create mode 100644 orchestra/contrib/tasks/beat.py create mode 100644 orchestra/contrib/tasks/decorators.py create mode 100644 orchestra/contrib/tasks/management/commands/beat.py create mode 100644 orchestra/contrib/tasks/management/commands/runfunction.py create mode 100644 orchestra/contrib/tasks/management/commands/runtask.py create mode 100644 orchestra/contrib/tasks/management/commands/syncperiodictasks.py create mode 100644 orchestra/contrib/tasks/parser.py create mode 100644 orchestra/contrib/tasks/schedules.py create mode 100644 orchestra/contrib/tasks/settings.py create mode 100644 orchestra/contrib/tasks/tasks.py create mode 100644 orchestra/contrib/tasks/utils.py create mode 100644 orchestra/contrib/vps/__init__.py create mode 100644 orchestra/contrib/vps/admin.py create mode 100644 orchestra/contrib/vps/apps.py create mode 100644 orchestra/contrib/vps/backends.py create mode 100644 orchestra/contrib/vps/backends.py.new create mode 100644 orchestra/contrib/vps/models.py create mode 100644 orchestra/contrib/vps/settings.py create mode 100644 orchestra/contrib/webapps/__init__.py create mode 100644 orchestra/contrib/webapps/admin.py create mode 100644 orchestra/contrib/webapps/api.py create mode 100644 orchestra/contrib/webapps/apps.py create mode 100644 orchestra/contrib/webapps/backends/__init__.py create mode 100644 orchestra/contrib/webapps/backends/moodle.py create mode 100644 orchestra/contrib/webapps/backends/php.py create mode 100644 orchestra/contrib/webapps/backends/python.py create mode 100644 orchestra/contrib/webapps/backends/static.py create mode 100644 orchestra/contrib/webapps/backends/symboliclink.py create mode 100644 orchestra/contrib/webapps/backends/webalizer.py create mode 100644 orchestra/contrib/webapps/backends/wordpress.py create mode 100644 orchestra/contrib/webapps/fields.py create mode 100644 orchestra/contrib/webapps/filters.py create mode 100644 orchestra/contrib/webapps/migrations/0001_initial.py create mode 100644 orchestra/contrib/webapps/migrations/0002_webapp_sftpuser.py create mode 100644 orchestra/contrib/webapps/migrations/0003_auto_20230728_1639.py create mode 100644 orchestra/contrib/webapps/migrations/0004_auto_20230817_1108.py create mode 100644 orchestra/contrib/webapps/migrations/__init__.py create mode 100644 orchestra/contrib/webapps/models.py create mode 100644 orchestra/contrib/webapps/options.py create mode 100644 orchestra/contrib/webapps/serializers.py create mode 100644 orchestra/contrib/webapps/settings.py create mode 100644 orchestra/contrib/webapps/signals.py create mode 100644 orchestra/contrib/webapps/tests/__init__.py create mode 100644 orchestra/contrib/webapps/tests/functional_tests/__init__.py create mode 100644 orchestra/contrib/webapps/tests/functional_tests/tests.py create mode 100644 orchestra/contrib/webapps/types/__init__.py create mode 100644 orchestra/contrib/webapps/types/cms.py create mode 100644 orchestra/contrib/webapps/types/misc.py create mode 100644 orchestra/contrib/webapps/types/moodle.py create mode 100644 orchestra/contrib/webapps/types/php.py create mode 100644 orchestra/contrib/webapps/types/python.py create mode 100644 orchestra/contrib/webapps/types/wordpress.py create mode 100644 orchestra/contrib/webapps/utils.py create mode 100644 orchestra/contrib/websites/__init__.py create mode 100644 orchestra/contrib/websites/admin.py create mode 100644 orchestra/contrib/websites/api.py create mode 100644 orchestra/contrib/websites/apps.py create mode 100644 orchestra/contrib/websites/backends/__init__.py create mode 100644 orchestra/contrib/websites/backends/apache.py create mode 100644 orchestra/contrib/websites/backends/moodle.py create mode 100644 orchestra/contrib/websites/backends/webalizer.py create mode 100644 orchestra/contrib/websites/backends/wordpress.py create mode 100644 orchestra/contrib/websites/directives.py create mode 100644 orchestra/contrib/websites/filters.py create mode 100644 orchestra/contrib/websites/forms.py create mode 100644 orchestra/contrib/websites/migrations/0001_initial.py create mode 100644 orchestra/contrib/websites/migrations/0002_auto_20230817_1149.py create mode 100644 orchestra/contrib/websites/migrations/__init__.py create mode 100644 orchestra/contrib/websites/models.py create mode 100644 orchestra/contrib/websites/serializers.py create mode 100644 orchestra/contrib/websites/settings.py create mode 100644 orchestra/contrib/websites/tests/__init__.py create mode 100644 orchestra/contrib/websites/tests/functional_tests/__init__.py create mode 100644 orchestra/contrib/websites/tests/functional_tests/tests.py create mode 100644 orchestra/contrib/websites/utils.py create mode 100644 orchestra/contrib/websites/validators.py create mode 100644 orchestra/core/__init__.py create mode 100644 orchestra/core/caches.py create mode 100644 orchestra/core/context_processors.py create mode 100644 orchestra/core/translations.py create mode 100644 orchestra/core/validators.py create mode 100644 orchestra/forms/__init__.py create mode 100644 orchestra/forms/fields.py create mode 100644 orchestra/forms/options.py create mode 100644 orchestra/forms/widgets.py create mode 100644 orchestra/management/__init__.py create mode 100644 orchestra/management/commands/__init__.py create mode 100644 orchestra/management/commands/makemessages.py create mode 100644 orchestra/management/commands/orchestrastatus.py create mode 100644 orchestra/management/commands/orchestraversion.py create mode 100644 orchestra/management/commands/postupgradeorchestra.py create mode 100644 orchestra/management/commands/restartservices.py create mode 100644 orchestra/management/commands/setupcelery.py create mode 100644 orchestra/management/commands/setupcronbeat.py create mode 100644 orchestra/management/commands/setuplog.py create mode 100644 orchestra/management/commands/setupnginx.py create mode 100644 orchestra/management/commands/setuppostfix.py create mode 100644 orchestra/management/commands/setuppostgres.py create mode 100644 orchestra/management/commands/startservices.py create mode 100644 orchestra/management/commands/staticcheck.py create mode 100644 orchestra/management/commands/stopservices.py create mode 100644 orchestra/management/commands/upgradeorchestra.py create mode 100644 orchestra/models/__init__.py create mode 100644 orchestra/models/fields.py create mode 100644 orchestra/models/queryset.py create mode 100644 orchestra/models/utils.py create mode 100644 orchestra/permissions/__init__.py create mode 100644 orchestra/permissions/api.py create mode 100644 orchestra/permissions/auth.py create mode 100644 orchestra/permissions/options.py create mode 100644 orchestra/plugins/__init__.py create mode 100644 orchestra/plugins/admin.py create mode 100644 orchestra/plugins/forms.py create mode 100644 orchestra/plugins/options.py create mode 100644 orchestra/settings.py create mode 100644 orchestra/static/admin/css/login.css create mode 100644 orchestra/static/admin_tools/css/theming.css create mode 100644 orchestra/static/orchestra/css/adminextraprettystyle.css create mode 100644 orchestra/static/orchestra/css/dancing-dots.css create mode 100644 orchestra/static/orchestra/css/hide-inline-id.css create mode 100644 orchestra/static/orchestra/css/pygments/default.css create mode 100644 orchestra/static/orchestra/css/pygments/github.css create mode 100644 orchestra/static/orchestra/icons/Applications-internet.png create mode 100644 orchestra/static/orchestra/icons/Applications-internet.svg create mode 100644 orchestra/static/orchestra/icons/Applications-office.svg create mode 100644 orchestra/static/orchestra/icons/Applications-other.png create mode 100644 orchestra/static/orchestra/icons/Applications-other.svg create mode 100644 orchestra/static/orchestra/icons/Appointment.png create mode 100644 orchestra/static/orchestra/icons/Appointment.svg create mode 100644 orchestra/static/orchestra/icons/ContractedPack.png create mode 100644 orchestra/static/orchestra/icons/ContractedPack.svg create mode 100644 orchestra/static/orchestra/icons/Dialog-accept.png create mode 100644 orchestra/static/orchestra/icons/Dialog-accept.svg create mode 100644 orchestra/static/orchestra/icons/Edit-check-sheet.png create mode 100644 orchestra/static/orchestra/icons/Edit-check-sheet.svg create mode 100644 orchestra/static/orchestra/icons/Emblem-important.png create mode 100644 orchestra/static/orchestra/icons/Emblem-important.svg create mode 100644 orchestra/static/orchestra/icons/Face-monkey.png create mode 100644 orchestra/static/orchestra/icons/Face-monkey.svg create mode 100644 orchestra/static/orchestra/icons/History.png create mode 100644 orchestra/static/orchestra/icons/History.svg create mode 100644 orchestra/static/orchestra/icons/Koala.png create mode 100644 orchestra/static/orchestra/icons/Koala.svg create mode 100644 orchestra/static/orchestra/icons/Mail-send.png create mode 100644 orchestra/static/orchestra/icons/Mail-send.svg create mode 100644 orchestra/static/orchestra/icons/Misc-Misc-Box-icon.png create mode 100644 orchestra/static/orchestra/icons/Misc-Misc-Box-icon.svg create mode 100644 orchestra/static/orchestra/icons/Mr-potato.png create mode 100644 orchestra/static/orchestra/icons/Mr-potato.svg create mode 100644 orchestra/static/orchestra/icons/Multimedia-volume-control.png create mode 100644 orchestra/static/orchestra/icons/Multimedia-volume-control.svg create mode 100644 orchestra/static/orchestra/icons/Pack.png create mode 100644 orchestra/static/orchestra/icons/Pack.svg create mode 100644 orchestra/static/orchestra/icons/Package-x-generic.png create mode 100644 orchestra/static/orchestra/icons/Package-x-generic.svg create mode 100644 orchestra/static/orchestra/icons/Preferences-system.svg create mode 100644 orchestra/static/orchestra/icons/Preferences.png create mode 100644 orchestra/static/orchestra/icons/Preferences.svg create mode 100644 orchestra/static/orchestra/icons/Taskstate.svg create mode 100644 orchestra/static/orchestra/icons/Text-x-boo.svg create mode 100644 orchestra/static/orchestra/icons/Text-x-script.svg create mode 100644 orchestra/static/orchestra/icons/Ticket_star.png create mode 100644 orchestra/static/orchestra/icons/Tux.png create mode 100644 orchestra/static/orchestra/icons/Tux.svg create mode 100644 orchestra/static/orchestra/icons/TuxBox.png create mode 100644 orchestra/static/orchestra/icons/TuxBox.svg create mode 100644 orchestra/static/orchestra/icons/Utilities-system-monitor.png create mode 100644 orchestra/static/orchestra/icons/Utilities-system-monitor.svg create mode 100644 orchestra/static/orchestra/icons/X-office-address-book.png create mode 100644 orchestra/static/orchestra/icons/X-office-address-book.svg create mode 100644 orchestra/static/orchestra/icons/applications-other.png create mode 100644 orchestra/static/orchestra/icons/applications-other.svg create mode 100644 orchestra/static/orchestra/icons/apps.png create mode 100644 orchestra/static/orchestra/icons/apps.svg create mode 100644 orchestra/static/orchestra/icons/apps/BSCW.png create mode 100644 orchestra/static/orchestra/icons/apps/Dokuwiki.png create mode 100644 orchestra/static/orchestra/icons/apps/Dokuwiki.svg create mode 100644 orchestra/static/orchestra/icons/apps/Drupal.png create mode 100644 orchestra/static/orchestra/icons/apps/Drupal.svg create mode 100644 orchestra/static/orchestra/icons/apps/Moodle.png create mode 100644 orchestra/static/orchestra/icons/apps/Moodle.svg create mode 100644 orchestra/static/orchestra/icons/apps/PHP.png create mode 100644 orchestra/static/orchestra/icons/apps/PHP.svg create mode 100644 orchestra/static/orchestra/icons/apps/Phplist.png create mode 100644 orchestra/static/orchestra/icons/apps/Phplist.svg create mode 100644 orchestra/static/orchestra/icons/apps/Python.png create mode 100644 orchestra/static/orchestra/icons/apps/Python.svg create mode 100644 orchestra/static/orchestra/icons/apps/Static.png create mode 100644 orchestra/static/orchestra/icons/apps/Static.svg create mode 100644 orchestra/static/orchestra/icons/apps/Stats.png create mode 100644 orchestra/static/orchestra/icons/apps/Stats.svg create mode 100644 orchestra/static/orchestra/icons/apps/SymbolicLink.png create mode 100644 orchestra/static/orchestra/icons/apps/SymbolicLink.svg create mode 100644 orchestra/static/orchestra/icons/apps/WordPress.png create mode 100644 orchestra/static/orchestra/icons/apps/WordPress.svg create mode 100644 orchestra/static/orchestra/icons/apps/gitlab.png create mode 100644 orchestra/static/orchestra/icons/apps/ownCloud.png create mode 100644 orchestra/static/orchestra/icons/apps/ownCloud.svg create mode 100644 orchestra/static/orchestra/icons/apps/seafile.png create mode 100644 orchestra/static/orchestra/icons/apps/seafile.svg create mode 100644 orchestra/static/orchestra/icons/auth.svg create mode 100644 orchestra/static/orchestra/icons/basket.png create mode 100644 orchestra/static/orchestra/icons/basket.svg create mode 100644 orchestra/static/orchestra/icons/bill.png create mode 100644 orchestra/static/orchestra/icons/bill.svg create mode 100644 orchestra/static/orchestra/icons/card_in_use.png create mode 100644 orchestra/static/orchestra/icons/card_in_use.svg create mode 100644 orchestra/static/orchestra/icons/contact.png create mode 100644 orchestra/static/orchestra/icons/contact.svg create mode 100644 orchestra/static/orchestra/icons/contact_alt.png create mode 100644 orchestra/static/orchestra/icons/contact_book.png create mode 100644 orchestra/static/orchestra/icons/daemon.png create mode 100644 orchestra/static/orchestra/icons/daemon.svg create mode 100644 orchestra/static/orchestra/icons/database.png create mode 100644 orchestra/static/orchestra/icons/database.svg create mode 100644 orchestra/static/orchestra/icons/domain.png create mode 100644 orchestra/static/orchestra/icons/domain.svg create mode 100644 orchestra/static/orchestra/icons/email-alter.png create mode 100644 orchestra/static/orchestra/icons/email-alter.svg create mode 100644 orchestra/static/orchestra/icons/email.png create mode 100644 orchestra/static/orchestra/icons/email.svg create mode 100644 orchestra/static/orchestra/icons/extrafield.png create mode 100644 orchestra/static/orchestra/icons/extrafield.svg create mode 100644 orchestra/static/orchestra/icons/gauge.png create mode 100644 orchestra/static/orchestra/icons/gauge.svg create mode 100644 orchestra/static/orchestra/icons/gnome-terminal.png create mode 100644 orchestra/static/orchestra/icons/gnome-terminal.svg create mode 100644 orchestra/static/orchestra/icons/hal.png create mode 100644 orchestra/static/orchestra/icons/hal.svg create mode 100644 orchestra/static/orchestra/icons/invoice.png create mode 100644 orchestra/static/orchestra/icons/invoice.svg create mode 100644 orchestra/static/orchestra/icons/job.svg create mode 100644 orchestra/static/orchestra/icons/monitor.png create mode 100644 orchestra/static/orchestra/icons/monitor.svg create mode 100644 orchestra/static/orchestra/icons/mysql.png create mode 100644 orchestra/static/orchestra/icons/mysql.svg create mode 100644 orchestra/static/orchestra/icons/order.png create mode 100644 orchestra/static/orchestra/icons/order.svg create mode 100644 orchestra/static/orchestra/icons/periodictask.svg create mode 100644 orchestra/static/orchestra/icons/postgresql.png create mode 100644 orchestra/static/orchestra/icons/postgresql.svg create mode 100644 orchestra/static/orchestra/icons/preferences.png create mode 100644 orchestra/static/orchestra/icons/price.png create mode 100644 orchestra/static/orchestra/icons/price.svg create mode 100644 orchestra/static/orchestra/icons/roleplaying.png create mode 100644 orchestra/static/orchestra/icons/roleplaying.svg create mode 100644 orchestra/static/orchestra/icons/saas.png create mode 100644 orchestra/static/orchestra/icons/saas.svg create mode 100644 orchestra/static/orchestra/icons/scriptlog.png create mode 100644 orchestra/static/orchestra/icons/scriptlog.svg create mode 100644 orchestra/static/orchestra/icons/ssh.svg create mode 100755 orchestra/static/orchestra/icons/taskstate.png create mode 100644 orchestra/static/orchestra/icons/transaction.png create mode 100644 orchestra/static/orchestra/icons/transaction.svg create mode 100644 orchestra/static/orchestra/icons/transactionprocess.png create mode 100644 orchestra/static/orchestra/icons/transactionprocess.svg create mode 100644 orchestra/static/orchestra/icons/users.png create mode 100644 orchestra/static/orchestra/icons/users.svg create mode 100644 orchestra/static/orchestra/icons/vps.png create mode 100644 orchestra/static/orchestra/icons/vps.svg create mode 100644 orchestra/static/orchestra/icons/web.png create mode 100644 orchestra/static/orchestra/icons/web.svg create mode 100644 orchestra/static/orchestra/icons/zone.png create mode 100644 orchestra/static/orchestra/icons/zone.svg create mode 100644 orchestra/static/orchestra/images/add.png create mode 100644 orchestra/static/orchestra/images/add.svg create mode 100644 orchestra/static/orchestra/images/favicon.png create mode 100644 orchestra/static/orchestra/images/history.png create mode 100644 orchestra/static/orchestra/images/history.svg create mode 100644 orchestra/static/orchestra/images/icon_changelink.gif create mode 100644 orchestra/static/orchestra/images/orchestra-logo.png create mode 100644 orchestra/static/orchestra/images/orchestra-logo.svg create mode 100644 orchestra/static/orchestra/images/page-gradient.png create mode 100644 orchestra/static/orchestra/images/reload.png create mode 100644 orchestra/static/orchestra/images/reload.svg create mode 100644 orchestra/static/orchestra/images/view-on-site.png create mode 100644 orchestra/static/orchestra/images/view-on-site.svg create mode 100644 orchestra/static/orchestra/js/collapse-open.js create mode 100644 orchestra/static/orchestra/js/highcharts/highcharts.js create mode 100644 orchestra/static/orchestra/js/highcharts/modules/exporting.js create mode 100644 orchestra/static/orchestra/js/highcharts/stock/highstock.js create mode 100644 orchestra/templates/admin/base.html create mode 100644 orchestra/templates/admin/base_site.html create mode 100644 orchestra/templates/admin/index.html create mode 100644 orchestra/templates/admin/login.html create mode 100644 orchestra/templates/admin/orchestra/change_password.html create mode 100644 orchestra/templates/admin/orchestra/generic_confirmation.html create mode 100644 orchestra/templates/admin/orchestra/menu.html create mode 100644 orchestra/templates/admin/orchestra/search.html create mode 100644 orchestra/templates/admin/plugins/select_plugin.html create mode 100644 orchestra/templates/orchestra/admin/change_form.html create mode 100644 orchestra/templates/rest_framework/api.html create mode 100644 orchestra/templatetags/__init__.py create mode 100644 orchestra/templatetags/markdown.py create mode 100644 orchestra/templatetags/utils.py create mode 100644 orchestra/urls.py create mode 100644 orchestra/utils/__init__.py create mode 100644 orchestra/utils/apps.py create mode 100644 orchestra/utils/db.py create mode 100644 orchestra/utils/functional.py create mode 100644 orchestra/utils/html.py create mode 100644 orchestra/utils/html.py.bk create mode 100644 orchestra/utils/humanize.py create mode 100644 orchestra/utils/mail.py create mode 100644 orchestra/utils/paths.py create mode 100644 orchestra/utils/python.py create mode 100644 orchestra/utils/sys.py create mode 100644 orchestra/utils/tests.py create mode 100644 orchestra/views.py create mode 100644 requirements.txt create mode 100644 scripts/containers/Dockerfile create mode 100755 scripts/containers/create.sh create mode 100755 scripts/containers/deploy-dev.sh create mode 100644 scripts/containers/deploy.sh create mode 100644 scripts/migration/README.md create mode 100644 scripts/migration/accounts.sh create mode 100644 scripts/migration/apache2.py create mode 100644 scripts/migration/bind9.sh create mode 100644 scripts/migration/mailbox.sh create mode 100644 scripts/migration/mysql.sh create mode 100644 scripts/migration/virtusertable.sh create mode 100644 scripts/services/README.md create mode 100644 scripts/services/apache_full_stack.md create mode 100644 scripts/services/bind9.md create mode 100644 scripts/services/mailman.md create mode 100644 scripts/services/mailman.sh create mode 100644 scripts/services/mysql.md create mode 100644 scripts/services/php4_on_debian.md create mode 100644 scripts/services/postfix.md create mode 100644 scripts/services/rssh.md create mode 100644 scripts/services/vsftpd.md create mode 100644 scripts/services/webalizer.md create mode 100644 scripts/tests/setup.sh create mode 100644 setup.py diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..0390229 --- /dev/null +++ b/INSTALL.md @@ -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 # e.g. panel + cd + ``` + +5. Create and configure a Postgres database + ```bash + sudo apt-get install python3-psycopg2 postgresql + sudo python3 manage.py setuppostgres --db_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@ + diff --git a/INSTALLDEV.md b/INSTALLDEV.md new file mode 100644 index 0000000..e8a26c7 --- /dev/null +++ b/INSTALLDEV.md @@ -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 + ``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e8b2442 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..b6c1788 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,8 @@ +recursive-include orchestra * + +recursive-exclude * __pycache__ +recursive-exclude * *.py[co] +recursive-exclude * *~ +recursive-exclude * *.save +recursive-exclude * *.svg + diff --git a/README.md b/README.md new file mode 100644 index 0000000..2cc054a --- /dev/null +++ b/README.md @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..9ae4e66 --- /dev/null +++ b/ROADMAP.md @@ -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. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..c3cbaaf --- /dev/null +++ b/TODO.md @@ -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@ + +Php binaries should have this format: /usr/bin/php5.2-cgi + + +* logs on panel/logs/ ? mkdir ~webapps, backend post save signal? +* 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 + +* ``` 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 .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 diff --git a/docs/API.rst b/docs/API.rst new file mode 100644 index 0000000..228cf72 --- /dev/null +++ b/docs/API.rst @@ -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 +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 +virtual_domain_type String +zone Zone +========================== ============ ========== =========================== + +VirtualHost [application/vnd.orchestra.VirtualHost+json] +======================================================== + +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 +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 diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..2626a9c --- /dev/null +++ b/docs/Makefile @@ -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 ' where 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." diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b055f52 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,10 @@ +# Documentation + +### Architecture +* [Orchestration](../orchestra/contrib/orchestration) +* [Orders](../orchestra/contrib/orders) +* [Resources](../orchestra/contrib/resources) + + + + diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..1bd8e07 --- /dev/null +++ b/docs/conf.py @@ -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 +# " v 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 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' diff --git a/docs/create-services.md b/docs/create-services.md new file mode 100644 index 0000000..b3abef4 --- /dev/null +++ b/docs/create-services.md @@ -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 + + + + diff --git a/docs/images/index-screenshot.png b/docs/images/index-screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..80dd22b46a28dc908ea81d64f7a53cacda2f9b4c GIT binary patch literal 198046 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%0Aouu&8CJg4<;YSyFsB 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 prH$`&EQ=g_+rN(z!@7J>@Xj}d>9Tge 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 literal 0 HcmV?d00001 diff --git a/docs/images/orchestration.svg b/docs/images/orchestration.svg new file mode 100644 index 0000000..65ea3de --- /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/docs/images/services.svg b/docs/images/services.svg new file mode 100644 index 0000000..e06e642 --- /dev/null +++ b/docs/images/services.svg @@ -0,0 +1,482 @@ + + + + + + + + + + image/svg+xml + + + + + + + Orders + Metric + Periodicbilling + One-timeservice + Pricingperiod + No pricingperiod + Periodicbilling + One-timeservice + Pricingperiod + No pricingperiod + Pricingperiod + No pricingperiod + Pricingperiod + No pricingperiod + Mail accountsConcurrent (changes)Compensate on prepay + DomainsRegister or renew eventsCompensate on prepay + PlansAlways one order + CMS installationRegister or renew events + Traffic consumptionMetric period lookupPrepay and != billing_period NotImplemented + Mailbox sizeConcurrent (changes) + JobsLast known metric + NotImplement + + + + + + + + + + + + + + diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..4a15192 --- /dev/null +++ b/docs/index.rst @@ -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` + diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..83d5e83 --- /dev/null +++ b/docs/make.bat @@ -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 ^` where ^ 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 diff --git a/install_manually.md b/install_manually.md new file mode 100644 index 0000000..a274704 --- /dev/null +++ b/install_manually.md @@ -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 # e.g. panel +cd +``` + +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 +``` diff --git a/orchestra/__init__.py b/orchestra/__init__.py new file mode 100644 index 0000000..ea43b53 --- /dev/null +++ b/orchestra/__init__.py @@ -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) diff --git a/orchestra/admin/__init__.py b/orchestra/admin/__init__.py new file mode 100644 index 0000000..393f475 --- /dev/null +++ b/orchestra/admin/__init__.py @@ -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 '{query}'").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') diff --git a/orchestra/admin/actions.py b/orchestra/admin/actions.py new file mode 100644 index 0000000..9bec048 --- /dev/null +++ b/orchestra/admin/actions.py @@ -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") diff --git a/orchestra/admin/dashboard.py b/orchestra/admin/dashboard.py new file mode 100644 index 0000000..7e73628 --- /dev/null +++ b/orchestra/admin/dashboard.py @@ -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 diff --git a/orchestra/admin/decorators.py b/orchestra/admin/decorators.py new file mode 100644 index 0000000..87f0375 --- /dev/null +++ b/orchestra/admin/decorators.py @@ -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}: {2}', + 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: + + """ + + 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, '
'.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 diff --git a/orchestra/admin/forms.py b/orchestra/admin/forms.py new file mode 100644 index 0000000..f74df5e --- /dev/null +++ b/orchestra/admin/forms.py @@ -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("""\ +

+ +
""") + ) + 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) diff --git a/orchestra/admin/html.py b/orchestra/admin/html.py new file mode 100644 index 0000000..208e0b3 --- /dev/null +++ b/orchestra/admin/html.py @@ -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('
%s
' % (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('
%s
' % code) diff --git a/orchestra/admin/menu.py b/orchestra/admin/menu.py new file mode 100644 index 0000000..f849829 --- /dev/null +++ b/orchestra/admin/menu.py @@ -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} v{version}'.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)), + ] diff --git a/orchestra/admin/options.py b/orchestra/admin/options.py new file mode 100644 index 0000000..3849bc2 --- /dev/null +++ b/orchestra/admin/options.py @@ -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 : """ + 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) diff --git a/orchestra/admin/utils.py b/orchestra/admin/utils.py new file mode 100644 index 0000000..e38ceb7 --- /dev/null +++ b/orchestra/admin/utils.py @@ -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('{}', 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 = '%s' % (color, value) + if kwargs.get('bold', True): + colored_value = '%s' % 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('{1}', 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 diff --git a/orchestra/api/__init__.py b/orchestra/api/__init__.py new file mode 100644 index 0000000..9f59e74 --- /dev/null +++ b/orchestra/api/__init__.py @@ -0,0 +1,2 @@ +from .options import * +from .actions import * diff --git a/orchestra/api/actions.py b/orchestra/api/actions.py new file mode 100644 index 0000000..d1b9026 --- /dev/null +++ b/orchestra/api/actions.py @@ -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) diff --git a/orchestra/api/helpers.py b/orchestra/api/helpers.py new file mode 100644 index 0000000..53803d9 --- /dev/null +++ b/orchestra/api/helpers.py @@ -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) diff --git a/orchestra/api/options.py b/orchestra/api/options.py new file mode 100644 index 0000000..9e2a913 --- /dev/null +++ b/orchestra/api/options.py @@ -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')) diff --git a/orchestra/api/root.py b/orchestra/api/root.py new file mode 100644 index 0000000..51fa23c --- /dev/null +++ b/orchestra/api/root.py @@ -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 diff --git a/orchestra/api/serializers.py b/orchestra/api/serializers.py new file mode 100644 index 0000000..4b6f466 --- /dev/null +++ b/orchestra/api/serializers.py @@ -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 diff --git a/orchestra/apps.py b/orchestra/apps.py new file mode 100644 index 0000000..dcf13f6 --- /dev/null +++ b/orchestra/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OrchestraConfig(AppConfig): + name = 'orchestra' + verbose_name = 'Orchestra' diff --git a/orchestra/bin/celerybeat b/orchestra/bin/celerybeat new file mode 100755 index 0000000..00e8b35 --- /dev/null +++ b/orchestra/bin/celerybeat @@ -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 diff --git a/orchestra/bin/celeryd b/orchestra/bin/celeryd new file mode 100755 index 0000000..df918bc --- /dev/null +++ b/orchestra/bin/celeryd @@ -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 diff --git a/orchestra/bin/celeryevcam b/orchestra/bin/celeryevcam new file mode 100755 index 0000000..623e1ad --- /dev/null +++ b/orchestra/bin/celeryevcam @@ -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 + diff --git a/orchestra/bin/django_bash_completion.sh b/orchestra/bin/django_bash_completion.sh new file mode 100755 index 0000000..8f85211 --- /dev/null +++ b/orchestra/bin/django_bash_completion.sh @@ -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 + diff --git a/orchestra/bin/orchestra-admin b/orchestra/bin/orchestra-admin new file mode 100755 index 0000000..a5b6e1d --- /dev/null +++ b/orchestra/bin/orchestra-admin @@ -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 "${@}" diff --git a/orchestra/bin/orchestra-beat b/orchestra/bin/orchestra-beat new file mode 100755 index 0000000..b11eda0 --- /dev/null +++ b/orchestra/bin/orchestra-beat @@ -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) diff --git a/orchestra/bin/sieve-test b/orchestra/bin/sieve-test new file mode 100755 index 0000000000000000000000000000000000000000..da75303891277cc4f823ecc2d0aa8c9124bb6bd9 GIT binary patch literal 1295912 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

Vp&;Lrb#hZRe|Fw4~mq?Rg<@iNUC%uat^Pp>@ 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èJusya!>ekR-&rPS2sV03xQT=zz=RMR2%1^ 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

    $@f0s2+=KUApiRl@_XN9*o7ue^KAx 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

    Bqavk@I3i`Yznlv@dRH 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

    ^5d8g1M#Qmr70u}*u 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

    Zs{6_xh(aRmzN4u>*X6&(x z<+vK74G%TAzMHF}>09RUlRmdJ-g2Del_+=|S7N_OIne*B7F06TNFJ1_ z-ioL3|8|QC;`NCnDZtcUJ`jmRzw~is!T2JRI$ZG4wvJeBy?B~JdN84~{uakG%+3=y z29E_#=}ifU-k8ydyW(fvJ}ds7eLwsFeNSokMw)gRmucP)Xjp|v%g`@mwEC1yqmq}9 zSKo(o_g7iJ*uHbUeB$nM+;W#*$EKgE<%fFJ4T(Sg%Z&v5j?WuQTUP+x9F-J+>ZSmz zSmV!xqT_Tx7o@r^v4%!71?>WazaQ3Zv^gMLI+nmoyHMTP4V5jaTI$ii?8$r?8&ub5 z@TnfWNM0vKbEnOWFul?o6ShIJF;dC*LOPV)deN6&bU7bjKP;Ju?!|}#^r=A!?kzZN zTpTYH1BEZ8!3o95*7f+-XurnvcM`o33-Ic$dQftYQ&>?J%BM+UV$Sl<(WW-cAyQf@ zaV778ZRvGVM28FhWPP6V-spbVD$aYO8}kkZm?XaQ0W302=IvM9;BuWKz}scYcM$nZ z4ztnSM?Lzq(5jH8uRoRjh#yp<)qgQ$>2^w`-koROh^<$IX(A)r*pceCL zP8TyvKslCZ1L?3f+?FnP6+ELM#VP-N24`z0C`5ZdT6a1VK_uwv)Ag~$RNCMZi;RPE z^+&PF4gHH+QmvFEv(Y6<1_UOz^8>vi?I|sgqQo&q1&|MD7P>G#3Lky1#`*$d_RV;Q z#+S2ziPz$eJeDvYw*`w^DJP_GzsFM?g4J>X{4(4Yg)LOyjhOHr)l`?S$&RaZ*w^&w zF*>HvNe{4wsQ^rlad*wA=L(i^E=wBXH~~YraTVzY)nq)7vmTU1XYx{>AaVo#a@fGa>IwTJs-|nLEzsHv~^S$2&?N3GQ}Q z^B76=i`UzeyKt1|X1&{gB=sg2N&C#F>dBQyed^r%bXQ?(StlVe2x)hQzb`^~aA(+J z^(^80{+|E9`0KIX9oABOW@!o{CD({}XVzDF_XI4s(|lga&_yhT3|zDsR`K3!zfYRuFX`9{{X&8VfXY!L zU~g;TW2h0f(Va*)=$546zhNP`zkC9e13c)|U+;y+^*5UpY|chm=1nc`W-eai!SyCi z4!njl#~&7=8O-+nqZdy9orZ=sTbt4k(K>5hsFOoxDJkwowi5G;Up>{#p`vxtTho|d zJ<`UUlCF@fVSWpS>-(d_&AI$G7D4CvbnTncp-Eu8dKB6H_l0X`q5mxWQL(d!<}UG? zDJeKGgk9He)&5@V1-Fz~KYCn(f@VtpesN^Mo+_Mf z;hvP(h~wfdu3qJurXqfUU&Vi zNt|j$GFt&Y9$ZYXTJ5&d08K6!M$>B7v&V29th~7rdBTyG#^^R18Isw0w2NtG(&}zKkmASUZ*kCF{ zKxB7j0eSlP1WB@e+eK>V<#dmQ+%~^Ki?LGxzYmLJ=(E`$S*cpfv?f=TRK3q`$hJYs zcc7n6^mCKZPw8v=dv7od8&5H!p|gWSU$-$`CC2h6kxh}vs7N`x!7&*_e8BiF5N8T! zYG1*{DRKvr_vgfzep6QnYc#+gB2lx1w*G#7$QSBK(l_{j>&B4T@3-Vntj^atgjMRE zMbHE+o-sJ(lO`+a?>_0?&i!2FWn}96)Sygh%Vf+|xBGwHmZrZ9j`;KEgF%n&@%pBL zLirQx#8&Y|J5)zQE29)GKn-xW&I@HWL4~I)CmZrQI6tBcx3x0QTF-#mUzz**hjy?3 zlVrF0#XJ6&`W0)qYyG+TGqcdLw9^KL1`;#&<6l&U@B)P^g z?n*R}XzFc>`0#U3gN^Q;HGWOV>Ov>i=j}c5NB6;NUI-0e@sgk}kCdzIVPLdQ{9SS@ zE(*(G?GB}=s8k_xG_f5LLFUglF2wU;ED+qgmE)g2*KlGI zjW#4sO>P~S_=Os{w?Yk6-)~o2fLz}$V06}4n_RX)=VZp#63&Q=Lj5`A+IVpU1JyBi z6&~-266Ez6F4NVMClX6l=d;uqs`k>Kw?H8ohXV+?o-#m46^CwNqGt~%HHl52Ec^d< zN&|+}S9}ri+wWZ8(`AI_(*7@Z7`DznYbxh{E<4R=A@-EFiO6fs#yV~p0t`i}p0r9n zcKg3%)0BRVcUPI~5pgxq675A#u%(5xhvWGh3|ZIld_~Kic{rEi8sTYSAEgB(Xzs;^ zsPFrReLRlup}wM$b3=&aEBX-`JiLY9UHIG1{U2n{?f+10p-wzFHaOyg4Z`Oi;=9`uI?3S`ZrVRnUwTrn;)tQhDGc83$?KRkdSVk2#u?M#Qwu5Cl|v;Go)ugyWMqVeI3LLwJqWzr)`?t1}0z6+kz}(!J3_adThr zon}u8HZ|F4B5gLSPe z@$=36vd{#7qxXbaudy@eVTdSq>=YO?U| ze*Q`CWgBMJe2_Ho?wws^(LiTEH&PVBW4+5DB`~>qi(BR?PfGH!Cc&$7?r|Y)J=uSw zuUSleZhuvKXY)C`g)BOcwMDz}3)w~8-@P0SQ(x)%aAf(T>r2C0(3dAhOOA6)Jg65N z80v;6xBehzRvwLw#$mr%6|HE_IG#b6-y8%f?jju4I`+Ego%eI@r$6bQDb{#6%WFa8 zHqP8qz0Qo`HK(4{A8FIK_6v@R=AXg#N2mRQ9T{b|gz2#j!|3BF}Z+|&|!^lzx)E*SCKi8AK|216O#U=eCj#@>xiB{2qX2< z>NBg-o5V5{Ym@Z`E#K1jM}N)Z)B7W}O(8FDaW&7gDAkdr+G4i-OW2ioG*)}Vzkzds zs!lwZo6pMnTXnp*+a?Kz|1(CE@2b-)lbL3|v%l!TtsAq}XHJMT6(0Z*aEFMjUn^Na z=o{EUbZAOwWc&{+-jS+&xdM3ms20k9*?C!rw^nLx9 zG-o1aMH;293G))M-RWh~x-B*{AG?RYk!;9DP#bL0Kw$l=()UZEfp|!$dWOg~G+>T3 z{Yd^WFi%ym+4{BrWLdM)^K0Mpr=2ss`5}1qVDwZZFt`C)YIRNX0toeYH;qDv20EHo zeSN{t<>_t(B+YikE_)ESX8AqnCm(d*gLe0jK3+7z=!mwP(gUkn%5dezSOs)0R}U<_PjC@nCa;Ruu^ z=)>9Ma(@H;lf(Km|B2tmf-&M95(nVdGFq2pF!C^SuNw8CDmAJiKY&3L77sT3pAv(k zjzG;D-6DE}V(;_Xh>LIW`}FGrt+lP7v>W_4PPlmy2_@*=IjjWKG)l6X+wDtHs=9nFCsT&e|>u=^^A$A(?W1qO5EDf5ehKP+(j z9%3q;H|Wkww+bkmgmkbQ z|BUF&_~r$D38zV&`&cQwOg{sSvGZgxZT#m$wu_ zOW6dG9^U5b;TM13iFRJM+|z1mM-{z0n1U@cYAMvP^aMRN@HnqYk0%V9oUz~Xh<*tv zwTU#TX7pOoIykbj*FX7N5EQ3(&+w4s@rb8*h$B!}#@uP?t3P2mVA|g9V6!a0c&awJ z#zk5T(+&F_KIE|ha_$>MB|h!9NYE49XJ$S zk{n20B%4FJ^AU+sVKvdI*~2}5q#2*X7~6Kut;5htAnnjW0|n}Sz#a(7N9(@FLpM&y z9o1qRPTili0WVx3Zg**jwvu8?SdbD2mtP~S23rPdF+_ag7Y&&pw3mh;5hSTf7uaC_ z%T@(9*@voOAfr-Eaz)4VJjc>8gIp*`NA>~HXQLzjfG@9!x$%6~G>~bbNabuO&L~n$ z?h%Y`gK6)i^%+Ewt`4c?8(c@Kl;slDhAK1T|V?lsBY z`&j=xMG>S@TcMW1E>Jz*42oyl_z?CdtQHDt`UEzSK{?i?lL>KrEe#G`qm@0%_xY69 z^86z%uU5F9fOMLa2gztC`E|a;WM5*x9ZGCwS~zxGYPo+1GUYZPaRESO^Q*#hG$1@a zWM2U}>^x`N6dq2L&pl5rbBeMefw1|rra$_|+HYm&ST1-9&8I?*Jp2a$5kFoECGJ;= zI3?Ul<-gg(U+k##ZP*)WKYJk{QYE{Q&sXxIK{1^65@ba5_EmVR&S`&CIjDQBCxcU{ zJ-d+!MjRv|@93$?nj(1mE9p%ZWztS0y7x@0ZAQ1+e$&0S?^D|juxfe5V3pN1nkH!m z>)PG)ij-W&gq{9=*G_NfVC%dlOqbB>KU#S?Ot#V|cS&7GYR)MMv~bBM(7cvj^Z6O~ zs_0~|i?V~E1%AKOIez-6+rvZ+x7Rq5$(2?)PUqZmmvGo#(5e&nRPZFz%v0{feFq!M z|AC*mx+FNF=tC-;-qK7J?u+vUm4Q+=-yKbhq9$D;Entu0>kv-N2hfjIj{M+UFNjUY z>K#tb#=St#f>D3_XmBJm+e*s4C>tPmYUNB@QSGSNAFzK^O2>zH6uLMKp$l2m`kMBQ z(Z%EVwf*AvjdA@oOGzzc_^l_ADoQ!{Lo{?pYiITGV+(KKa;<#E2t14%!j& zji)d`6D&3kDfs=6ir`Ugy{2x^&V&17IUH1U z{!=&ef@h87M+MsH|D`;-TQsGnjqW^#lvfs5i``6OFGXt97zz@*5P1|RVuavcK8!)X zrQm;|oxe_LuPpOCd4qE|n!)~};>?AL+xE4Q}w8&6-#_3;et?)t~0 z_WSTrEJFA=H+-!9tIoFTkzIOtaMCD_&ac(SX4eZ&*W9ag^55OkxrhC(^xBy!Uf}P) zsIPX8-v8s@%^r}bRmsK%K6WI&A0IqVPrgubJUwp?Zy}u-##}6D{R8Guh9{z3k%8P#}dpwz!oLW$OqqVP&=~|09L&9PKp=qSo~0Rq|+Y7^cj<%)?j?1;mNUK9}YS3 z0N9LpT2)4`dA$fe|Aa}Ul`Spc6}o-8d&gmZ>ZiypQ7|eeS5-Y`^X{p4wcJxf)RRFe zVN<-t-aPuGa+n0YU2LVWWFNX-WfEoMWJ{F%rU2hgNH=LH{|Eq$rL@Y~x+IARtly>Ro zW6{hhk>N`@*H9NPyN*r2gZGFYlvCKd`0z<(16V zIPIv|r(+~B;1!!7&MoRCeMk0(zpMKT@0EC08^?ue{oy-Tj=&6*Pt|{p*H`RSuvop5 ze=&_HI=?XNLv?Mqe^laK*Wa(DQ0t{)`jOn;`;vaK+AH??3T_sU0&GR2x7oElJU&2G4`r-NBbuecvAhyV{oof zHd$8k=WC;zpjg|GaD>%WgT8U?ANGW3RWT~=hWSRQ0!?iWNICqP@k*Xw9-H1KM<)Y` z#VAz^u%Z$*OLQZJ8K!#~t!uEu(=FD`^b%&*MltB6c#pB*z#GvS3k`p6(WF0E=r_)Xtv>U3Ugma@NB zpY!qt@_$2YEyKDi$3+^VslGhJVM-NVdda^rCfzR)`Y_{sEv59!AqWhd@j=Vz9{4RR zu8NM?9v?ntTy6mAr!*YI@I5vEsd>EAj-6Dx-IjY*ym^3N7El=aYWMxXrl7; z8a_ZVWf`_3AFEKrqQ!$Ng0GNnd_Nbmmnyqq(~O z!VpiapL9^9YS1ScuamRhP5g%)cN2fN8;!Oqyf}d;w6?Nx(7L9(MLy|LL>G7cC@+BG zW3UQ$3ZP~AiS<_u?j=->$CID+sv7i}oA|y^GdgpVbF~&B({&4RBHUx zLLsouet)Jm&=&N^8j|CN;mz1Vkh-3GK{22^t2?rjNN71>!X7l2y<2CJ6sOzI6T?_X zM|Si*yP^8vIQA`|lGw&^iBi7r(a|4AZ+s1%=kq=L&s_azD`j|Aw{G~IdXj7|1nP4c zNVb%BaItn+|1Jm^i{89KL^1mhQazT~9^ogXvi_2S%KD1lp#PY0?#4HT+H4>>Y)9yy z5_xf{wI1nhuM#Nd%G~HN4JZiLvTb+Kx@ja>9rXix$4A_vjzv@F@-bY%+1aeVPI@7< zVS+}Ly*U_ji)|se!)iqs*1MKPiQCvtw>(j%#jH%)>`LHkmS#M;?HgNJ(bSzl?!@}T2aX|1)?YLc zp6p10&PYvmnjI(oQknRMEtMKIl1@#wwk$YROG@ALFSk_mo?R^%dJ2z=rrcD@(`~y| zUd+0iO2)?1UuS9mmh~O4AFx}~HQnm`ndSQSHJr0*su90&-r~YR6R2#G}9;G`q?Y@vB%~ijRF%>L%(LLuY$(D zc0Sd`e`)6-6?SD1m3ZiS$esBI0cFW|d)9tc5Z|jAW}}dO zNkQ)ym8<&T@G{r%ia_Y!VMee8!~|@?K6Krsm@d=hs7!v1t9hI`(fZd6;y?arUw@7e zK_1d{mliN=BYf63;&U{h%4AFL%H)RL+WCeEGy~gq(fSM(MC_wyqGYhX$KQp9|c39uUUqZeU zo3!eMS{B1}QY~-G`wtRl243Gm%n|vD@*W2^y6sF7$ISKCEoFF<46=MTAYy|}u1+DtvN2Xk1G5O*R1U-bCtjQG)YNgz%uU@Hd}Qi&V(ND4 zoVx5dohdw>@#Eq!m|m)4$@gBLIZK^{c4|;?S{;c2Y)-}T9=0pRhaN0K9izWchW2)nqEz5p4eK5{!CGxx>ZUVgL|yJUDBO zkV3;GV5Q0&?BZ$b6F-8TJMhI$n#M`xhk9j0xn)CsYfQEljU)!lmeo2{FUxPmT&HO= z1FI&vis(EahohjH*IwvWYl~iS3iD{&{qYt&C$O&E6D+Zz%?)jTi#0wV1S}Slv1msG z9Cd|9!2J04?C)TtXa z*ln6JJZYd*hNpMT@cHH1kIgZfKfjj~R@uExy=bJFa3k@9cs!;P&{k-nxTg+AR3UgMG7Gy03rKtIru`+cL>v z`x5_6h)h#TvQ~4+_xWU-<1XkPCWpKD#R$s{4OG-MX#n+>G%@=Eqx7GDqjo z2*EBkm?hb%cF$-$y`?h!oK(x-b!Q}c$lXfR-`Z7kmBrQwcZ~)QM6joKDfnGCSGn^! z9&o+bb5RZTqe3-=$#cge{au-q47|WAE%rorpwaW|o%Ro=F9b0T`A@9Cw9msBeu+A( z)AB^~^LSW<1}d`sFt&0G!!F^t$JO-6+C^$1QaxVlg$$FvveN7&O6xmwWKVeOYU(n5`E!}iyrG4*yS}wcQ#D`t`;cnqL2bivnHEw3S3nt$U z#_HxM^B-PwBLDIlrYCq<@Np+U%9UwgKteenGm~sm`pdc>6nq<0*&&SG1FkSEv~UU~ zOvBK@)61Qau}l46ZejIg_-aAZTgB?h{nT@X zrccVX-tI|@f({k7v z03fUIOi}pdNBXiiUt*1W@oB+SO-daVbeoiiFVTw4uV=x_NruGbCWDoa_}$!5)?m$U zou+^~b=6W;By&XA+7mCK|F_Q88%G17u(w0QUYdH~+qXwk6^ix&||9Mo=;z*a1b zWAcOUTKhn8E(zgzDx5MdIRqZ=X4u2pvEclu(B1x!>87Gi`X|84wZE|6^|;@FH1kPb z#Otz6QZQ7VE!OhrSr)xHxt5O*___Ka8;dPDkLT?9op5_da4VsjZg9J}+U~F1TS({7 zW=-sqS%3zLX5w$Fb8eUiQAr3V_E|%@g6h}9DJJUD(Ds@3l@r=44~Bz?ret*6_IQ38 zPp3NA-Y4!P1F8bA8lO2z6KWkBuw%#GOfaEc<}+z_=k#oQqA%el^5;_@wgU{W3NhRV zwaFv&U#VEk2+(k+Zbf;*BCf%JB(+QuPfC;hh4;1TQ)j-t+-mt%?IM8|JNcmImNqrO zisJi!4!yame~iG<$UJ*eUG)pO%ZyNN5$u#Sr$`0WDeVWDIW%t(5X18ZOKGhgv8Ab+ zJ7L4qc3$;k4$qD@Y-oGaCl8IoE6M9_2Dp!qLJDoC0`5vymZmXt2!j;({(6@kH<7+3 ze$2a6?Z0?fDlAOUGPmG%zj7*7wEouxV{*HK`sd!7=1tM}A~lxmVEOLFdTWSY^A9TL zL!s|CXD;5c_gZuhjz}_~G{^ZBvEYn|`xP~S(7(LHH}g}9^ZGKa#)yhi;cY6oRP7eV zL-+q^0&s5530XY;WO{~U+K+I|NWs%m+f|$#4Y(3@xf%`p9WaB4QPvH&U|T_?ZFcT{ z4qdNivPiQTcexwg&iD30K@Bjjyz1gu?e%4wNUU{VLILT282zxqSFLhxM45BVTm~&V90sXJ6$Hqwcp12{OZyxz#wGUX;SG&K21*Bw*{Qa2BW;zR>>wfzj+H+Tg zsY^)}{PRQF=N=Pee)<)<42vzXmTDvJBno)#`s$|`Qq$YS#&&!BLAN~L3SRP<3QtkH z`SqLWL%nMKeLSYwe&XjdvkK~_@Bh;GV+_b4aG&zP<(Tjr)i;9rFzw2arNe!F+qRCZ z6!$w@e>1bH>ue0B3}Amn86#xeVgHT?GXy^IU}d#UnvBS0yP1n6gx~8qq2}aX+@H-$ z_lj)ZLEj;a?`STA*xNQ|jxK~sbHsJOg9E{S)C5W>`k|dtx0>4H$x%&nkqA_C?nDiC zUDKf+(C>4zhK4i5Amo>hxSecx+x&sObEBdA;hyD4mR1z8?m5;Z77L;N$;i;$UYFj$ zh4BRuk|QL8<;t8KX_+I5oAfAp5lLJkl$ZKiDvPWfUFMCR+7=mn@t_Rh zPD~Yzp143XIw+7F_GT`afNsC6Tw?$y@MKa<5pBpk^%A#l_76a*0dvY6J-%J*ZGq@<+pIjjXVz{J$w`hZfLGp)B5KX-A}Zis z@8V(!ioX}n>}kSQ(c2liocdVE-N3l8P|SYKOQY(c`)yBj;lD)IyYWx8SMN<7+vC9@ z)rV>D2XnJay&ctKBn+$P#;DV8{D76C_onu?ex2+2zwuREt<`FMq5pC3SW)(xE4e|N zr%y1YMYMvX6ca#WkYuSv=U$92`J^?z1$WUPWpt zyjDj?pRH(MoU5+h$E`uw_h_A1SKnFl2Hn|T7qAcDCjoxy{lAKz+US0TvWw^Mhf*rP z-Z#4P4LQcYgoNCFNgr2D|ENQtU-C&mI?`|YyLub4W=M0N&-<)G;~*vOE_)Gw^7=PK z7%~w=JpD4%p~xkepgdrsGj%29t!H-@51V=`n)rZm0LJqZNegjc(@2?1<-j>liApIq!Q<-9x<>`oeFlNXH ze)!g&DQIj4d~Gl^Dt8$hJ>$K4Ut`R}z=Uk~`0)3rQc`ESJnCOR^V(dwj zgO$VP*=KmJ=eOqecb*@*H9x=26k)vSzu0(fexFw@|A+alrj{M%_s6ID`DI=`bln_3 zyVujk81jGD>_Wdz@nkl+(8xq$9fOEHQ$#a%3OkmVCS<%nDSgG^J-ZJ{8V`6; zJ%J+CD7-E&8q#&DjSf@%S@M4i4|4R?54QXC%~k{AQ7C-93Jb~35t3cCjG5$6b0n?% z^&8IL$=%oEFo&PLuJb2_Rlgp$3&NCnb<~?Juymi|JH7wAmf>)Wc$Y+PF#C2f&l1su>lY21 z?^?g#xLcSyh3I6<%P-3b~jNh+d$_$|D?rk7cWZu){6hVGM`E2 z{5lWmR(2FH5z)y^g;60onC)J9&(iOFmfI4fy2d<)_m%iv;C{nvM(fg20p;?iDI^tJ z89uu!ZpY%mzR|kgpoRB(R-PE;!5-XcmSM8c5t}FrowfG+NLj1>dh0G(=xpL0>4oZC zR#pFq32j!MpnX5QCESSF8LXkK&E}G6+;$0>L#H3y=(CXo4caWEEz-zu`lThG?7Sq~ zH`)51a_= zWfZEtU#xkPfhxP&Kwn2y@Np;X@6EowL-&KQ5?wiF|47>l;e5I~{v4iHS`wq}=mQ0D zXE(uD1$!!}HW7Y_sD1O2KC5JB_!d)MKE#v#+mmKZkGlxE z7fT-7zxv)PFMG5j(PEvIrOUN_A|eMJjF}Lwxm<7q*b5iA3#VeS?{D3ygYk-Za9CH< z@Mvl|MRIMJ6}EZG2!&58>y76xmf$Jb`n}{HcZm@X8-+mzGu&)0a+ZG$oJBX*7VFjPZ(dt+`^uZ3~un)JhS1j=VoaVi!lK6cM=k>>rRFC~l3*S~o{d${Z;X8jqP z$&EpB`H)Ue@EmE%;bpw{#i$uG=Y?#VDc^|7SOxOE=-Dd@Mw%`aJw6_!TsVg(Ff*es zanyMh>J)^`#+9(Bnr0~9KFT*c%%?AwPe&N#+XO!z?0)%EgIV2@Y#kN!AInST0n&-~ zRJ}*r5e@M(R}pQdXV9-+wJQeHkA}Y#y?JSL{+fc-!5)%ey&a#O9GOxwnz(BjeA7c} z`#x}CXN9#YKhIw&8iu|31EGGO6C8o`%20;qQR(w?x0{_xCe(bTR#GOU3nu0KqOAn8w)_ zGdh?30kvxe>slxx>y!#6GIgx2p&e;rq7@7-BPn&>wAE8!P1^OnYIPJo_`{jPo2%@qd$=@`MA&oPc$&pmUG9-kKK?q z1w2%e{$*=em<`=BM9CmIcd~XBnF?EKl~>tK)i}HsSbZN-(--)fd4(y;-PPRuYwWHMQ9st+m z0HD=o`enfk>rQiGzp$rzt=^?`5T}a}Hv9as#%+LTL9-w>9_6J@h`Fjsz%T$ZasV6*Z%E{5Zb`rTu=-3?xaL4a*c?X_w8QIr^L_pFH{ zQR$#8&DQect|we&_b=D+C21Wk%$@+4JUz(eABl(C?)lFpf7=VO#@Pa70}{1`{dWZ9 z)9m@lCgB3eYrd2kC+Bm^KA>}A0ehOs0Hf1?f#3V$788jJfJId%Lp~+V=di za!KCflDv;&!WE3OR=sOaiM=YUUKL!rxA1Kp-;gEc7IW0idRCqxV&>ntx(8ye%6fl& z${^`YSTWgQ;UF&}iX zGw46vy^qg9O0%zf0uO}5S+t?W=|;c;AhM|l%|MYWF&a!6>BfRu&3PO!ber?9Cg1}R zL2>wXLZNYy@AFka+J3C>%uscvaV$0XYL3kpm?Fm=Pm7*{!WSnGT3yAv)4z$- zGN)lcNRK5$`N%$$sDxcKPg+8l?-Ju?&sxRWg70zX*`(E6ROyk!jd)o%K^FgUGW#77 zeF})5M{a;{VkN>xylx~)7W|0rHf;h3C{*6IBzhAbI)}YfeK4^ZHUY>|jr7l~pG+zF z#HPP8(SV&k6X>bkJUV|@@4DPq+R#nqLJrdf41)qh75!gZ!np;_En83%cO!nG5C~(7 zhprhBFOALsL^+xoty{*65aQzl)fr=LYkpT5q&z`rwE2Gc^d96v+}E?rZ5*e%s)=Jg zvP>I8KOI1>qX7;uQ^ERvvEitACPqjGn_P+l0FJTNyFu@O?y#b<(|UMcn2JaDzvy}( z5Ax?pb9J9=Etee#ac+TOe6KYyB83X&m>v^pd$g$pTyb}vDq?5YH&57SN*rA&&NMRY zAg^94Ahw3|23uq{C-x%m7tA7THMN52=+Ar^Pd|^+x}AQ)qs6C7MK`+bE1{ZeF3^2d z@U-CPe%y7(toc_MsC6W0&cU#*Rt*Fhqd%zJ7P(n3i6}^^h1<=C=IRa^L3R>FjK22S z9g2X{XYeX)?$0e!EbaBDhX+T3{@33=7}@-@3+Fs~an4yJa|X}netJ(&c5$m1H3T~L zx5u$1kWp8>rDqvG9odTw3DqU0`rH1}Z1Xn%kJ`K^K#6UD_cn7D0MFSiI7m(6rW5iD z4Ms~=Ab|#B+85GbMq`abS9Mq=STWkmRb>OkKA|4I-qqnwEze)n%+vLoVB`7;EHIzC z`yD5R=ThZvXDQV+hq`-b2iRnE-MU^2(qfjysVs1xO2g42B+= z-+?tO6orf#_fCPXbF(sn&hd|e2ds-%aVmkja3|+Zg9#2 zz=83>l|6}$nLg+ruUSO=`TJ7Sm-*{6xwzA{WRZqk_wL1ili4@JUJ8&?{} znL!U?{E^-3@7f7gYbPjuh$SumDVZOVlw%_1{;~^yuf7CV$1@)f85eQu9+ebgHBA>9 zL7lh^v2c^R!t88UoB?}rr>Z~6X%l=9s0r>!&d+i54&B$>^`pi4+5?)^brVr#B_MGr z0g1cfL#q*CD(tcWc|U^fGt_p^ssKxui{e;eFbly`f=^D7iYr-Hay6k`W2~{5oI}^7Q%(s1iutTGrMlM;sbpWK+xSzRX2VY( zMm?EPU=|A<-0Fv^n(@Wi(zCky)c)ff#Z)$O+qVSZ;YCy+ZwqVqNKu2rvg841MgtlRN(-`R5zQA?Q>0EZIh= zX{@o5fiIY>QJN`D*9CXf&$ZxcX@~}M{8xAUXW#V5FOTuLf!2M~9Vh#{gBXH%O#ECS zgEHJtuIf3raHc&*>lUjC;70}0rRjet3y3C_pcGX7{MaV3fpf#?gY7a|BW>?I$qmoYfvlO7OJsa zna|LwcH zQ(NIyUW#nXHz727nz{w`s|}h@)as|l6Mn5*KV-;!sAa0bf3E+O2JJp`&kVJ0)j;R% zvpdgcJ1U>;Be#`2RBwjjOkO;5JAHD$oXbh2#K~n8>n{5xi;!Pik9NS0rseWCq}JKK zwmi!%eIV)Cbz%P8zTgU}FZ(lthR*9uPsHk}c_#b3Ez@&^z_mhPd2Z~sOfRWonc6wO z_C}#pVfJvVzC1#FdVNrQ`s!Y8T{!W3$Ot#Reqwep54s`G?~h&h0d0bcLf<506l+rC z?#3Blc>G(znuIfo8qRp^k^CV9n?U>9As)1FDU>Ba^&pr%qGP57TXf}jTcCl(IXpJ1iEhO9=@)yt=IFD}C|H&?s5&#%%$srZ3m9{T(I#@UT}u zumhW{vvT-P-%^(e4SiiX2#Wv$blCy!XaP%+?kdp6Nu;nA_R`z)P3H{?~j(N!z3=Xb<_VK4! zCyF6Mn?L`%Lb7amDW1%7$g9E<{K215By5<;n`|LlK64k^XV^pw4IOd{oTq|@O-zfs zu|+=9*1Hz)1Qg<=wcgzUz2I4G;iwE~;X2e~uR_A_sPGRTEgC~WjV4&t)jCiQy(4gf| zl{R)JZOl$kUgF7F0XoN2Ra-9>sKCVus4OzT=G#pcsdb&xACO5=sQW)qn?>^KQcUX) ziPmlBia);xod;*HPU_#vy0U%ANzLQ&{qwcoK|OEBk$Ev<8ia)tioo*rF~fTLD}-p> zBd}=-$;+nn1dr(W?`fAppK`Zd`;4y<=@@Tet~042IFGL5=dL~Ze5vbu0padKuC?wX z>%i!Ed##B%qNxj1GUWb~^0qjF51t3KDw;E|X><7fg%X2`6XYxBI*Z`xI+helI2wLU zXOr))H`PhE@+}Z0IH`nioQVOQIeg+}Pb$$LIH)X_TnquOKane*HPERg@{w2!xP*hq zwDc$n7c_|JbiG%oifKhj^B#&j3Ba?wHyyXmxTCOyuL1y>_vw2q{aGy8&NkfPu4WX` z`A4j+AB;9(dtyXv@!ZERjx^5UPure;zhRA5!_7I=sf(0n&x+L4o{4NZwoj_zN@{3( zOMI#M6XMHxd`W(|CwJY5ZPi601D9~fGMs#2lKN?rk@=Lq#Df!I=6-5A9aS7+-3BHH z?;(jZW5I<*(0NWUVC>K}m0PyNh9BQg0)c4qG)6XNXh+-bv>OYKFP0jnl;@(?BAgZ+ zYD`RJ;e6kwLh@kaKCPxQNUVss>-bVJ{Oe0s#G6XPfARW9%I0X+GWjoQR*|2_n)t%p z*UCltr_OH+^4}VBPx1V>c>Y`E;(TiQN$=~G6OdCI*r_2+Lzyj?qY787^Y~Iljz;@M zY1)44yi!E^`m$Dg_s-UZ5u5DYducoyMTf1tmzJ`~w&}s0%r$*ga9)jMcU%8}GfVGJ zCRuC6Ho7NCH^}L`d&u5K<8(|?VorCPSjBl3?qY-_=auU2xy875sC`GcrzlXdn>kc& zaNFrr31}Lvy9-QUZ<9Oy4YLvEpMIdCS zi8o@6;|`2RGTH*F>L0L+N!Pz#^^K1U0=n&s2RDgu1UG3Bwl78F=q_=Fvh>5E?jZ-gWi_N+ zWUq_rPHw+Wt&t^zCq}8U$kc+AV2WHYQKtR9jcSN`D|&0Uq&g_g@PhUm)~N}vej2Si zN?ohJ*+^VI=UpWxhBd1j=wNk`s+4=;u%8WC!eDv6wZ%>T8(XZ`WQb(mW%+3ZPa{kv zG~kQ0rvXPTUL_LHEE2FA>r2aDp^d`jUt-Julk#x+Uq#`zx3v#+u75sdd$I}U?u~j~ zc87!!CzNKtg#TN(6}gDFBl|Nq`XT!)owNno=#V$9ckVN|0N^fca62E82RfHhewWDB zDX;Ov?gylYn5JuvVt4LWvT32HCj0ABWcTa<@KhW@TQYN&T^w<<_YXgUk(pty^5t5- z=tVhQ_5do-lPah>0Y|67B?Lh}2IX*9znN>=TID+PJQmSbfaq&>hq0&>7NVF$!FBxJ z)Z&Ta7I&&8ZgB?#9M$?zgWB83>35atI^bYlgNOAj>|b@R#hsNP_3KAKPKGy!BzeL$ z9QT?`A`tqg85FjGDv4X~mb!}rCgle!_iJIdkUR=8W5Zp@kX{+e2Y@~jM z_1O`W6kP;?7Rm5T^bTYBEW{nI>_?Ev?Z{2;reoEIbO*(tx9sISlLbc*XRk8g<`W>v zg0_$niOl@s?AM_q#V~AjCrt6=e=pjQF!vJS+1bI=pqr<-Y018?D!BW{2FYdQ$InnU zdpA#4{Q+=nKZ_gMLA1dvUSNv904=OTTA2Lh{j{)cM~hQTv~;=m5QW7^>V($pk4)U{ z)sDxagU{e&0y4b)iZkQ?Gf^X-UdB@qz>u%f{qvBgtAgcbUc(zv(oUB#;J)MTyTI%B z&!{v4bIc4(mO2XCT4wf)rbzb9$;6)K^;`C%7+;95w}hI&_(k?>JES)Fc}4Q# zM6jgEYU@bi7W$kCy9HxvW+^c{n^Z`9p~Zrd+zCc*0Xj79^CrP7W0$ zXMB8@H8t9_({y~acH*(+7u^lLaX_ouJ3i_@nqf0>IUifEWxyA{jw|Y3k3M?}YE1C6 zav+qQ;Ud97O0n{tU?KIrM4=o1ba#p2B|%7zD+J}KiTL$E$J>Reto(45=foE5huEIP z6h42JyF%>^zM!r)b51N#xGs^oz~YHRI<5aOn(XM_0D3*$CU3#QP8;`=SA_i-_t*31 zZ?U#r`N_Etete`EgM9x_{xA3}SoGiUDN(;&qW=F+eE#t)!>0t8FTiKczEc_;C)uP? z8f7B5jKtM&^dq6VkcQtj8lDUd@7QHdY4^ps@%41+wn zVec3$_Seuw&FT3G;%>AuRxZDX$|DVUiV7||yK?yo?q;7}f6f7)WiBZ0sqf;pFnOBF zCr{G&qgLVX!um7*ZBbxBqCfo)7yS=`{!`E8Xg^WeIrrt3?s`9N^~WCo39K#k79_?! zk&!{I`3E4!%wyir4a>~j*$PbV8de*_SNCGI-;-X;QzGA|Fuk%r5!?%zi*GK!XVMaOtkf}EII@MMN9*?_REHP34s-Ftv1CBd6qr_(I9*~c%r z(^*mp0*PF808mTzaJ=Y=)fJ^?CxQv_*66_ra&Yi$6`D1Y+TW1Bjy>D2VGYl!5_Xg7 zo3_=Ypicxx`tbSp&(fM`#>o^>(v#~@{TC&OA7=@>9$ca)mQHN2zWMz|9)FS}4YKsQ zd2Z`^bpSJs=p3 zzYNixG-PYu8N^V<13Emuqw>hqm%@FdxqY4=CWpaSD%a0_r*b_xw&^)WkL*y*FI$gl zn`ahwzs321MRbk4V{&xWeVHt8&-nK>vQx16Z4fTwBQqT=u8kC=2i5%Fx(wLdFGhmW z&gxRtfTx?SGHa;bqP__s=yZjpLivJOZu;rssGL{VxXg-fb>}1TuBMKH>{q4v^qKFn z2X2xdx66F{mLHDbBFu5N`9e9hEa)M9Ih^hw2o^hDx$Jmkc>iA`&Y)M8mG-qX81LyU zN^M94d-p67&n!+o0K$qHVER>G`CX#OF zEworO82d6vn8bXzlt%zRZ_@CDvYEc0`f~S$UcQ)INXJI;ZV!nIlb8TL|MTB3dWFFz z`oqOgDWy$9GZ3=QK?x?f<2mexQ)Z7a0Bomi=B4h15M2W;sjW!k*P)5*3eQiJKtSzv zZ}N0$1c-8h9!GVeMa>hp?({(g-G9vZ)aOOtj(zLQn??G*v5{b;nGUBh87D|uKe6Fz!E{*Sc#$TLi>;M;LE36qz z=s_a0sGe?S|HXTc2k>;=8V2BwhDVaOiL%~BVWI!szw$jClzo-5tWFZTmWdDln;QG^ z;dd(#$}Rq63E%oNrjdz{KcL7E;pIs2=bFrC@o6GdZHfGV2o8DKjzvknY*uY>^!j6? zQKjNz{vT^<0>3k9iz$CB*_9Tza4EjPrKm5wT-JLj$u)esqW7{Xyd>7S{M4ZZZpslx zo3=0V%ucmLtnt&I6ME+-zKJ(KIpiZ|PdX#9-!`2CT%!H>d+Wvnt%5 zHO6u^WY3|CqR-6Mopr@bfP=$vQgJ!Trl`8~0vrL27g4k}^V1o&qdcd-^r;%`&x*R= ziT&y+M+Vg?iT^}CTaJa$a-dPGS!>*#Kw!Ipa`T6ttEk(Gr`kUG zRU!@lVjwhKN{!y#e8eI9KGB?-=k}wH^a2T~0mbHgC*HF}Y_(7djBRm}jms|L!EcPW zy_h<%CRj3qLbzwxdH=IuNO==>Q69)?UG-2rvqS}Jf}t!b)3x6BS?yaq)(W#fF?7ZN zg^tQ^<{pAdvPJgT=B2gDs5M;7v+flk9zy_&lQ(#Lbk~QnN{8mikb$zw$K|H*GWgEuOkiv5cSm=o z9|)gzB3SwQI~hd(QIo-xxu%u-e(`&K;uCy6t6*-_B%3W}9ZO_haW_tvCNS?j#(9>K zCR8L3t#1~r(M0otm_HgX^OBv6_r)2mZ2SXT+tD18k0@GgdTMoon0WQ4GFdi*REvKz zx?{fhm@7AVG1KBt=HqN`H>{T&0@l-pbsx)P_n*76fU!TIqFB_D9m1BGFcrsK=3GH@a6&{RqFa--KI~92REJ%h513 zSgr0)Uz6{+4jCblxyIt!G8Wxz`ZMG%o1iNRHwAZhv}ioLe2BN=4HbvVABQ$7Em*-P zK!MdHp7}r)yC6=U zr(#FloX3$~ zP@w{3)+eT1{`!32Z=@*&^Mo*`SEhxWWQ7R4AbS>1J^u`|Y;1P#6Ij*C<1nE@j{iU< z8W&Eys7O6Lz9UQw7-`s#lJWEhh4^FFWlOlp@fA|*uy*ulxFhZ=$xB!vWL)z6445ZMdF11_01oq>D7E%%qEvRW}3~cU9nr0oRK|%gqJPw2MalLQ-HVo^_FSK? zA6xj=CTf$qR>6i%(|20yCMS4OOB^*qM@+%)7#yCYm@@k(c6LR^T=H&Y%tbFp#{A@| z$e6R&M8=%{*T|Uf{x&k^JHL#KnY@s^bDGNO4fV^TO?H{wj|HPG_6OMl2JAjRYXuXq z*;^wl4tpyR;i72Ftwj|Eo3yfKrDg`4689N?m_)FrKVvvpC0C($TZ~do3)iYY*ns-! zSO06gARbPlpudqiB^S^PO?^s`m!{)a=mS6$Q(qt6Y5Prk8Kcnb#$L~mF*Pqq8^bK@ z{zb@lE-!Hry$kL(EE8=WQAm%)ReqZ|VcA%&{2%J~=kF+QHtX7)OjYxFe6-qs1P@T2 zk~vXkE#MHbT9vs?j2Uwje=s7BrbVJ9tV5{F=c&RCXScRMoh(i~TN?`6Zzt z{SZ;u8%zkkTbOm(ia+tJ{czG{yxV* zuB&1CiVw(U(}G^ywn2oD!BDW7IZp;4?RSy;Q0Da-tTrm#)?$Sy;c9u^eP!SIn~#UL z_&2(7IhNxO+!PP&9^xctLNxRaIe@7o5rDj+#eX!efSRNhsQEpV0N$G*^>Kb{dR&Diq9a z0-kYD&Cm5mklbACKuYd#je(WDDpd34y_{TIc; z=rUf}GrKE0&UjYA=gI~B%NbCS#$Ql6dp3WlbeZ8#;!ZykWrE~&y_8F%86)FNbpjr$ zRTXh~mV1m2tLNO5yREn8Zzd>$@x=GS`(RpmqH^y@!>4-ydCk>AT5>n2>im{$5nVJr z`Ce&QhQygd?f7bZ(UKm}I8-I38X(uLBaw$mFO=hr~=F%s^po={?uh7$lz zFB%tr391)3;OhqU9j=RX!uj%LFt!{Zg~?(9LiF64BctOM2KIjLbC0PkRV*cd?fwNE zD;cs0!B&*og#bAoXQqw7m*{J0_!AJ-A|urV$FP}VhB(>qAXRMY$IY6OQJb|!A9x$l0+8864L+b;3gV9?$tHrQ6kgmEso^BV5!V_6dABPV?Kqy?Yktc@N zQN4{T&>`)m%S4c>u)(<*CS?~-|5D!7Yb0@E#H`j1=woPB0vnI8PsjPm*VU*Ke6;G% zC+!1CeWoXZsTJJI!aVkFec}Fkh<7&2P+(S&&F(M6b1-`|i}^VF7|&ZiN8fj%b@Z7B z>D7{t@qNtRmDbJ9G$=@$?W(%ih*;Ozg;1BR1B^D71dI2`k9sLDv%6af#<8}nLT4~k zJ=(;TWdqbY5L`2ldK5!>8(=XbW^ju;pS`1YOt4a{%M%W5v?s*c@_g-*1EgdO_G(qk zvj$SBusVv7_0cU2K2NRM?)Hv*HYzgo%1V~SQMpA<;+qh#s);iz zE<&d5oiK;lOaH+pJ|NGt_28t6i6@PYEW!oGb}NnPWUd5mtFf?1!zlluxUJh-%CdKj zXo;y*QGnTvdD`$i5iwZV80-Ca5V!6g#f3BuKswRwSkIy9q*;>xkvUh{4f(VbAM!)gkJ~0KRxuN`%y`^m=S(jL|^xl1$O#XuXY?`PEhUFtIp9YW~ zTk1+u!(lbpF*XPx!EZEWt4WD*l6|ZXeLT5W?;x*bkPpiarkv4r|8ai(j(+3(pWHb601Zzu4^bI8 z$e_jix59`ZELDdD(>8(eV?&XT(~$q@TA7FH%!%~=?^BC8+G}d&ljN}K;E<{es{=H^ z=x0A=)LDmWB(A_G#2ejRSNfGIk*cRw|YQYBDR)x?lU zAH%7jnbTyUuesUVuvv`76Q0^_^zBv!=~nfka^37Ld=yeIX&2j;=LDm^)cfrF5b9BU z${O=%y&vX5k?M!+v>2TO%^DG|3^mQyi-4M{%F10EW)r1$DG$ffY%5_o$kko8g5g9G zJe3HsKD5+uk7_jRh983M@UsMl`#Sg9jcmBOic(tJ_J)r9nzdAn9cRX6k$A8~WCt^E zI26BR4&ufn59@CeVn53+0Ac+J&ERwHE-GKo)HFGvy6=!%uxqR6AXuSZN^UC;t~NUO z;V4a&2aGyon3PW(_ZX8WM+|t?v?>K9{$sOkwTi&4Q(-m3)iuR4dA&jYMRMk3wObo_ z@Uy?gFv1ET4la9nxPj@@%` zfh~T*QFelsV9ERVp!003VLrp+7tCmGK;EJ+k+zNf$7K7{_tyh9>Gs`;$d=h}GU1_> zuuyI~hWx6RQ~U!E*d8>4{RRAOt$aFe~8KUKk0B9?y&e|jX+_+wiz@Z=%& zTqw5*fy%*y%T>enRB5Io=LK|gcECi_3rgX}Ki4-fu#Gm1DFoBbydzM2RwxIL1@66% zWjGEC%3a8{F_cU-N0!>DpEM>jkw|8idq|9$312hVk^})$O~$SwO?daQwN|74F-j36 zHUylQWY(n@gVcC%uU4Y=Lus}7gu?jPX3d1iT~EfzJeqkbF=kCo^N2{IazkYC*$iG{ z%%e4#>ojpJ1P!;(j1Z2gL1T-+K_iJG3>MdHCwc8d&9ipaMDE(={w5qq&}KqLQRk$+ zz{0URpLIUiqLPX#)_}0RzKIPbJG=K8(rN^0j<%1%asUIxGbyZ}`S#jNCMYy*8FUNW7?BGm<0N15;L1fYJ|k@S9GJ#?vqFU)6M_pD^Z=KeGzytlT~EM4y%Q zhdm%9tWYCv1R~Nb z)}Zj8{ALsz#I540BRzDHhVM``ltU#yCZ?!ox74rEjX+Ub=-z;=Rfmn?J0v|u7WH9YC3CZ=C)+WtJh0=XIRJ(x^hWXk+(a2pi$}X_%?tEDj7z|9L$Ws1{ra&!1XJ-JPcS=Z%?Jis*~Ya7S--t}P<+DuV)s6xnBKdkyLfm` zGjvv$gbmhDjn=D|4OhY^WSQu6>)z93^}7hLMV6AgmKHIF!-nn&18N>2bivhbFDl3m zv-%b=n=dV!mv?FHmtfk$)e%)l0AseiLG1AlpAcgV8*85h>eM7`Kx^X+cf7Tnf=$TN z%lt}=*=DPzJL!BD6fqCKSh7c=Nu6geM7{k!+`XrL;BHVs`qOkIBk${AL`rDMJ$R1l zxYqBVseIw0T@zHJH=O&4#>Wrt_xlBuyU6&J|IXJt>pVUl>&vH*+Hwt({3Zv>qrNBG zUl4bj;hvZvq`@Jzoc;DIOsgHf3y~{tCC4ACm>8-=+R8PNrRUfUAuV)rhKIXw0UGiu z!9ze=$T6peTywmV0EjJ`)MZn+Q(>Bt$g}IEjZfrKvsF;A9w2nOG zt6Z{)n)MI)UgaWJ{h4Cw9f3Gr=)Du`!yhQYnGd1$x2v#btZDq1229*v!E3R`4t{Zm zZd5n8-C(!Z>lU$BKl)dbxn7NGF-&aV#F5tvPL8MVQsad+!SyYA1wFK=WawC13@gr# z0s+WW`Axg=cBD&C?vteu+B*Db9;w! zUn^iEHA4gOlYt}k(eAk5`ovdyZ?^I!hxXkae4(jH0s*aag{ggGJ?l%7L$pP0ul9YR z0oOCpf+>=3wZy|9|E~0gV()A#u8LH3RcBs{r(Z)noPgag-m~_rsiQ3o>E<=>3{LkH zlI+iY>2=A>nRW9H$4Hkv2}9kt2;3pg@>z30BtsJgZuj{`t0Jr zo4~Hn`zoFLxH+hBzr9Q20Nm_i<(~{9NB@e#m=iDJiMQ+G2~ekOqf_-h9dRzIG95U9 z0kWhjQr*EXgz{&~1nF`7tqp<-{V>Be2{Cx3ZNgn!wo$juSC(zyw<=SFDAyCubht-f z9fSgs{s_*1CW8??6c+x-4V@$%*#lOSdv}u$>^mHhNIR_S;2%c;IN~4jo{CuuI2`NT zkYQT;j(XxOLiZ(3iCL`R*WGf2nC4OU+KUqZqC+pzdO<0zlC(n2`F#`wCU_*h4JX1ytOAWcb-OJ-(O6IaE+|hn}tZnk=G69h3vb)xA|jhEv2J z1bwxIY}mzbj6N#2_H=<(pdIwhtdLcAiXc??ShV zth`f$Z$t?>n!hvs8i9N7xa42F$7`wlJ&}fk`O7pQUa;Y7CVW7~TA?<(<0)too63d6 zyP?pZ`;jdsu+s3R#6-nCalZ0()|I9oVk(wD3VG;3 zxyya(I^?QL%Vs9fxr8#wPUejwq7nsfd=C$1xueg7?1X<(W-<4?7b%R8SWkomlQ)_G zS|#H#VJfIg=(^sSJ+ z58AAS!>yo=%ykwW(aa8BwdfQ7)HXlZC7dlD40{4l6)Z{ZBjQmhi^d?h*bO~Gj`-wP zay36-%*7`ZBHto%_qCT{k!&d20DWnEOU=e|t7&%o2G^jYd^7r*I8nOmKjGab5HU+A7Hw8VT-c$x|T zm&!~3PzeEF=RO?-8-lLR(XpXW)KZSG|%0GawDsbAd_qoeS|$rW?HnuAN+O%L<^ zst}6ay<`ylX}cTqoDZ?lW;8a`Q&*?0bd-Tdr^R~xrC$Pa*IF+0Pbu*B4Ocap_ww+; zGf(DQMc-=n?~X;O;Si&{etBD?y(a^XGS)L@yodwBtB5oufhnAj?uRe>iI{x@k)DTI zKXO30DqgBHXCfg%Fqt)!5s#VlwxK?IH}_sYuA5$Iq)RL_1RhVnibHxY`a*b=Xg5(g zt0K}c#2(NUbM%E8clKorWFfj4dB&>5#VO&1g!-6B1;SBn8ZQRMH;(?XUtjy8;o=3M&k-9$?(8aT>@- z(>Qeu7{rpUB;6w%8v~noy`HFrfFW;uHP2dtMooz9 zmF7#J3*Pp-ewy_n(ol(&n8XfvD0fDhW`P``U}kN=Ve*}kMdv|awV55YV>;NPI!J3+ zEV5*^pR>l(;cSu;(ohBK?^v^3URhQyGK6w6?DQ5}7{Ptpp@5BpZw!$k8zOf-mwDO} zZJt6bI3=_oXyXdqvX8evpQwB^vh)yDSh!KrPLX=A2}4Q!%N0#;Vb>!w6<%N~X=YDg z7QMix6Jyr9ODFgf9twExspX0nJbkMnur1)wz^foNb*YyAC;U2@*#3Nm0J4w1WGuXC zKNfyb+oR9udt_cZKE4N#^qKy-ikD@SdBCJc+lyM!yVzZ|CnFHzX|-nxZpCw|>Dsam zKGwh+7C4Tn;mcG_ZV?g*Z6Lvr-Zp<{klnmtMYZF&c?3}nk46?vr8464YR0Us9ZP1+ zn&wl=2r>KUE0M(q8R201tJUL?Bg57I3WQ%#^;8iey^RT5HO+?=xh|%F-fX84^F?Y~ zh#{>n=3UM6sDxFxW)s16UGzOsSyvHhoXvEre^4?Av6jRX*NZRBf@>KE%APNN22Tv7 z!0OvgbXxjX%zoKIOmy%%le^sU4syL;EC)HqFhV+4LbYO4TwojO@R9XC_zDCZX`#F&q=Y$q zQ$--8!~`X0M49sIbX72;ROt&FQj}uwj+C{jw?9))gZX=`B<>=(bHMs@ zN$Nany2nKi)<9m|)MpUCc{-mbp^%dB%{6v_! zB9EEZ1{U~>6^Jqyd=2c|>_1-3R-hN|FAXekAq6~KO7zKo8Q^+_32)P1n-H1*dC-lH zu27NT_6$*_EitgW2SN1NH_bg#1+!JY1jTFbND#b8sP;B(U&7i2iyWbuRTV5<2G5ZH z(+;t!Uuucz6RZm(+)HxgCCf(C-=>G3X4VnjuoqMHV#LUz-E~DB+j+^C?w-v1$0Zuv+)STYY1V9bWN0!AYR~`z_-97cYB| zNx8H8Mm=ifQTNh#>pUGP(4l#9{=j)M3b&4}5533U6!$EV(o~o{(;us_7rDBPEtsCb_=* zp)vATyKnNlW@J-z*OQ_MHd``PY_?ApU$YBX{ObD}8g1+>)#eE8;o3MWx2+nI zA$&*Ms<)PTvm^Uiu^SIaAP(lzUu=|mI`&nr}tTnY;9+08^02x?5 zUu-jY8wl?g*0-`>!G86TucGOI-h34dL+1Fg1t+)3>E;uFBr@!B=yZQmj&rtF_suTd zV)zS|bWj-Do|p)(zd_&W^q;Ajjzkb4P@a(9!wyYk{#p-p)!Qc9f{RDsE}>v%;iWt@ zY{y&6{|33%uY_D*?;4k>uxps<(O~( ztO+KSnY+R5b299RUGS8*iRnWIb9q_9@8xeJFnxi2J?|lcK)mKq`(*y(+qB!zD}>>sN+|YkLXa3$W?d3>bgnnS^z4H zZ$=spgx}&%9Iu}~<8^AHm9qMGd7^TCd>W?`g!C6#ayEm|bKC(%IeWrgnk+T&k6B{d z@qHPI%x0w~I98YJc36;Nycxx8%&$ps}JXP zrMi5XG!}Kif+)&4sRzny$Gllvn00S`*IR#vQFTwF+%JX61M63>Rc-TFkOl|!V$m@7 zh+@D8&#n`x4n<7&4)QA>?XyRQm6Tm1+9QKNb0K^aW>T)BeNf!lEu((bFs8O-C7&Sa z#LPtVv2XSve_Ej`R0$`kw@1^mQY57Uq?)0h_nPQNj)A%X&8ZV<>& zKD_m1I)}AE`uq|d<{M(VUGIZtGsJP|+?7{xe?s%PF<`$o^JhKIUSq_@7Dy8%kC_^7 zdC7jofH*%7ge(gEfLQyk2(MmiltC$-zh8mdtiVb68i*3^Utp0HSdp(mQ@>vgKO9g4 z2LAp9Vif4ZeMH&4=`xJ05nTGG$si7cl|?Kh^Ef*?s1ZX`>L7d@Rf<9TKXYfaBr4xa zKBYZHZVQp=!Tc!9G(7L*-M1yx%|~K`l(ieJeu^Gk3Wcx|&VJS#l5V#_na|GpcWBjJ zcy?IErco8?THgEzPns_+BN5qp_v>0xZC1XMJdAvs^Nxo+r)J)riXiO|yxaluAkTO^ zqXjY5*E2~IXxcD_!#41QJybpJg-))huf!D)_njR{QTULXy`Sur($8_#U&%04XSN65 zEzXp<%H!evY>S#`dD4C5u8fDimYe8^C30q?xJLJ1B2re7{$q1H%9H3nep>Gc)nwkE zn(0*jgy$^HHmBE|T$TAdLdtXof?Q+rcwr$NVRk@F3yEM*o&f-pTjw+ASERHNh znvuSk=!xyaejfQ7U@o!~)zLMoT0j12>yPqtlR8m#HC(Na3LmOsK-P2(W#JvFuTw9| ztyY&v&zn#X$$Z(2zjTH#bSClDD}apm7e*Ez&F7d$$3NXXdY{C0ws_uCR!>{>WKtDO zd4fPIr4MK38t|zv@C0U+LEhHos>~~iHLna!3_ep`ov+NTW{kb+W=b62Jo1oQc7iM! zZoLD!?3*VO{3wDvpK@zp*D}{tifRU5FrjCPdY;x@;RO|BM~gJc1va(vgUFIM%@+7( z`ogkFg^9r@N2|xbST*t7vI$c~>nBGei>vr7o_?#)o?;g_f+W63>t!Z8c=Ck8HN=3O znXNVQixpU7jTppsPF0qe!x$BcPKr;Ql87`8;jTuj1^(Zr;{}imO*O&n={C${qbzY> ziw%KoGPX6r$uR}=UZo7}Rh0mh;#31Hb*wc_>UVnngo5N4YCqG85*C|QtPneYYFW2L zH(GYmm_@8UYby~Ap4tg03OKwpj~#)2)4`aDFb$f6$^(w_`l#6;$H zS4ElxW-)^(gNviOpXWx_YF)E>=dnNosL9**t^4Q0Vh?7kqg|irhc*3XVFA3%s~J3H zLe1DIG(DxFhHQs5V{p{*sCnEW+5d#*wOBsZXrJSd0Q2ilaB_4tqsR%>r!bEif1%MD zjHxoxw8RDuNMJ(KfPj_=qE42@GK)0_P$4w!`z5d9JnR>{@6|j5+B2;ii80SYVa$bh zSd1&&2n-z%ZRUG`)x8J|^p4?k?&rBN%pPIu8q>M&B+mXAim&OM5@UP{vdgKV#uB-K zS&ygo5-r6xzH1D_Afpb%Ot*c`BT=!RLa0FbxXzG5*EG{B+&9{VUwURA5$16GJ`4)! z0aYRxO1LmQV}<9=+O;1{p+E1nwcZW;CV(wdR8~d$hz@u8Lm>^?@ed(=ZFWtHnboM* z|5gIaX9i++vUQP7*NHW+4<#j7aLV{-b`B5Ytq+JVN5z*vqkO+bbp)ogc{p;Ws{#f20*QeI9;JYvzM?M|Veg^NAcKJ%hK(=BKr%JpHTye=HQw5-o?E5Y z4y-h8mCmM8$jl99hKZYBU_vvx>nrNNeH4wpSRrE&HfSIQZ4ZP{=6oJ>QSN);V%4kDl_y`_+lrU?(F<{DEs z;4%hMvSPs=iRK5&>eXcWiJk5qg(&78WL|RrA?~iW`4%gXeFT>a4(lqQDa<|Oyn)aW zBSd&d%;oH*$i2^A%WEPHMawfpCKvK`KRWvB>)zxXt8X&BB9WdFEwod*S?r3G?-ox> zXT5lz{)IBsjQr#nJdiMFP+`3Fc~OA1c*ejMkD)-{WISBw>?bn+U_>OFrR*oeq+8_^ zfYk;+h6fEN)F+r#%!xe-^7GUNm5J?hiV>hz?aE3*z}OmpJcj~;w$*I^AU<&zqR5EI z;v0c|YV*%AVvH!m^wNE_$syH^Jt>mkb5^yz_$kM>@N(U5db!zs(+U-pb>AM^yF%rBvwMXS z-B)3c>u2S_d@s(mt(5`F0BGA7u5wYmCD`kIbSL()L}oh%re}pFci(VpO=+thu*)@x zF)neG!8G1t7_?=DZ6Let9PSm-n!?ACUb}a1!N^ekSq-^iN%%I@a>Tw}6Ci>6lxwXM z7*p~blO7BVADq}ew^-?q)+cA;gzP>HIXvH=J?T$PkW#PX$R|3F!L=g^S6BXLHYqYU zyRYUT%jVGaa2{R1qM*urwu^q(b+62?&v`Qh;M#_Ji~;=1+uLLhUkx zDIq*}Vj|pafR35i(e$<`4_oxGX-YKGP{DH^Ecc~;$4_smW%*Jc`BF{3lq9*@;2eXo zKrmLiNJCxX67pry8>yC=(l1ZTwgE%faRO8743ie-NU zRfhDqoU8))2w9)!&$68RZa6P<5dExJdPcx#QOj>qM_X}=eDa?qr8jA8JXi+6}XsGBS;FQsGW>p0% zw2JaJPS{da=5F~3+~IsX)~cxwM7Z@;nfEBtrue2zx`QXw(xK7C{Kd@2Z0Qa_YUu6H z!@^k;!hpwbev;m)=eK|U`87P(bhCh0A`$O{jYjW>jds#lKQ{Wv=Dzl)UhA(sOKfvl z@;o1~i7-Ws;&B+f4jZ2!x9pehlx^a$rk#h zn)HV|lOs-O9x?ocjDK8{?kKKF?a95E@OHB3}NPLyzhsZyI#V8T=+|78Ut?-0_g%?qneB|C> z?tL9NY~*yC8;vF^=gyeJ5}TUALQy}F6zT8N(goNU7P3W5X?Y0Ei}DsLY+aKxH7M91b%fR=;z%NP}#4oXN`#tJ<5~ zupUKL?%D8kvLe#jC6Qn%Ec^GMB@$2s!a93AhtR-3r;)xhB1ar-=s z>1gGyB=S5tvT^~PKDoG;KMJbG*+t3WP3)ZKB~?dOl8Y70JtRmrr?*FAO?r`&3%p-5 zPF7-{bCR#RUl{*L_F@JI{GS>o(e}asXh$HJgT0?kUFI8br)yR>kHcWMwR?{il1mmU zC+qECgtNy6c|mdIF6M|;a@R~}>P3Zly@JhnYBTYh3eUg}jPO@?*;vI6P9XMbaH8_5 zx%&}?j@_Kw-7($H-rf8LaxTQEoONTXJ9rQ*ggv|F7lN@oR68c?GLrD+?0wS!%&qVt zLDRF=2W5L}P$dP*S$hgS&8GMx{{!p&wqED5KYW(zK2A<+FMvYa1$3A7ll+J*_3TA3 zuE`8>zeBkOCg0)nmZO!F+|c6=e3<9e2e5yn!)m-k)4HPC&|A8(gB<9HJ7Nx>`}o81F4F~Epz%{XhYVL{y9 zsM)Z9XgwOvbS{=vVM5_aoCP61P#~ruPe?zeaS2|hUi$vr_-NU*@tKOTbK(*yX35Fz zF@Bu*k*%#9I?!oLD_y*^&|uPMUpO+m+M34T^-F@4mlu(Tb!=pbyA6wIaD1_3ev(07 z#%e{H!!X}bs;K^)D4o4q?AlIR-ffjm8W4iU+A8D^?^I~?ZmV>rgWl|+H~e0#>CUf` z`|5WWC{w?`0JVM;@L}qF(QWHdsM1QZ>7|T$6eJKrsEi4fmk&hcA{Jdh6RGX3)2=`C)Tr#M_9>}#$^ z$Cw0B&1ta61rJd)Cy=B{^h9I85?^AmkZhE}te>K5r0JToqNqSQQ&v)f5mcU=O5UR; zWyIk(yJu~VZ+35oIGXk!aAZh%ab1WM4irRQWn9uPm#4S<)5F(0L_^arZx+N~I7o}p zWrYSdvzKNzj^9AiKReh(^^r?UXE(@#i{b8;l{5!HYh{u+$#_UTKwDVn8}2f_4ai+k zIfX66zvH=mPx6m^&I31mwXb23YKT}3RoD(Xa>)~nvU~>hL)e6jYp=9O6|t|L1#n79 z>IC-3gOuJh3hq9#vGqN&U&B*?6-7K**s{Adk2~*|Bm-dN-&M|W#VRbTGw_#7 zu~UaS>m671_Y$Hs8q_3$!^#kfH(IN#Vt39H_726k!c((~vN#qSbZH?rd3quk^0h-0 z9F@L-dwyE<(`nIVP|(itkK|h4>@Mk0iA`c?SUN707xr9vVJ}n)d^TRl9~%SxiBR6O zDpd*fsajpu2P?Q5)O`!|KdDT|7Eg)+cI5il<+g$-^xo;-jce;5*P>+@P_es<_`qyEM61sh zovzLM$zgn2A?SjU`0?7-&%j2^+f%-8mF~rxe)OnO?-EfsIn>V;IS@ETKsR2sQVHEdEZsl^)k&%X9@!J6N8cIuOa0s1OTBS(yQ}Ig6IkeU71D}Q+Hfh;SH2ag4*so!0 zN0|LpCdUob#cMlrliQ8lx+gyU(5(HEKiwM6QzkKN@=|RP(%0?RUR)&G^vJcp{JjJqK{J29{wF_f`pS2bd*1~*na=y69g<=D||sD^^_`TU&dsh z?o0nsRDd}pKmYS>m;T7!|7ld;wr^@_|I~k1#dq1PNkLX$B4D2y^(LBU<1^ag&Ryh@ z@*6!1X3tAB+rh?u?_PR+DHE*zs5kc;tGDy#DO)rJD{dh9YUdL5y@bo;cY?#$QY6vr zQ*|X4c9sjrq&(=Nh>P9H624mF?^08>ofB1&$}(%DEk1m)5!7o2VvLtVenD01hs@z` zVNj7d{3l$b3E%szvTwFoG5EIew+{N~`P*(k9l+m=3mKC#{&a3xSLld;zn57-nl{+M zF~Q+Qv{IevgkYy;9uGUfRfI9l(P zwqehlpmDku*A2OZ5maqc3|z?l@?#NC>iaO4(gO>&6%-s*5QBmn9-+c`XSFu*hS7wO z#tqzvG?wu@Ftui|0!48$WSlKl{+a$jg)C(mQr*Nq#-OV`TCqF^XE{ zt9hYKM5+LltG|~^db*T#KmagS9)Rp6n$||QdXD!qe69?cz0YA$Vk`t{=wSnQz9TAs zek&*3eJkV+eb%e&M7`{#-z6${vilC{EE`*pi(8UbzwoFV z!$MUx@D9MaEk_+ZfaQJvGnBI=z zvnXe=>0)=AI;gXX-f*RCa?Fll?4+N8@8IyyzG9frC+^MP3lj?GE6#of&GeT?`sfST z`>`Gbhp*}V7z4NaWB7drHI2ixl=jwSe<>&LP+xxs!otrA>J1CYQCt=bDoE~^bw^3O z@$^QN$#QihGsvG;*HdpR=lxnDlEcC*lZgzQ_?RBxzYlymCL`V6C90Q_!4eb!a ziRsHnvt54wQdAc)6Qf%#qzD9REl-%JT&g7v< zAptvuF&*-PiOpyzk+l2|Tx1U?6A)y||JO*Mu}Pocz+*VmO^5@2WqLH!zds*;Pnu~Z zLRQ@h0!&5`l4b(5ILN_YMB54yD0Y&jkvWiZiZc#7*R#1wy4&;PrTO-`c>l0H>sGG4 z>Hl|ozhM>lTzjhI|7Cj~w_*PzGyWg_JLHSnQzhN)IjR+XeU6_b!qEQA2a$$XAh*a$ zlkdhqjUzsJprjJ~M-Pi?&XmuQP#ejNF~(9tBJ{6ULw*Z~(PWP_zF>7&;tHMY5`~l` z%qE&^nM`b4z&7-H-wYb@bjL34Y!Y0syY;e^{Igc9{Bygse|EICR)??0dD6V8dQk49 z;LWaior&MBk<=ONv#9ohD(PBnAA%3YwRz^!#7AcL^h<;IUN~Qq&(T>EHV-AAzU1s~ zSleb-#+|3id*iLmV&A{#&)@cFySWeV-u%b_ZHV~yV99c!XJM!P1b`na+1Zs}kI;`~ z#DvEzS)p>;*1<%mb7?Fl`8@jbc9%1h7qPlThR6pnZ%1jwj9+|iJXs%nZD={FuqN}E z)p-bY5}UrSb>xetiS$`>JeX+kj(zkI0x+ojyN6(6Q5l&k5gyrf{NSn;blwCTp=kWM zxruvw@A^gmWV!NPoR^T5b%Z71$G7s^$7KmFI@|EUGfRS{b8@(4O;O z+P}gknV}FCGWv7v&r7)DE|9P~Z&%;8=j(mjeS<1`+wG^HgzeLWh z?n@ba3jn(9-zMDg=ULmsXgkVY4388@x~R;}QnN*6{!KVu>YrrEX6`<`68#?ZZm0 zb(a)`QFc6db3!jY0Vr57+_Z0k`beZnb}Fr(VW6aKgrMMA7-wEg?UlU_Y6!<+ zU!Fu78hDVNUtE}aCzvvl)IG`9mybwpVM#&>J_qdAMQbXbx#%gv1Hr9Medhe+cT7Hh zP+|YyuVFYaY;JWof2RFA#e))=>L`J9!8Fo$Pi8%;rji9v@Ot;-w@Bv6iNg)D+$eEn zb9Gc_Mb-lX3w5>R>iiO^6L=61_gF2X9e-A>~Eer+aNm6=J#S+BcxPAO-R0PoEBqyESTW|y15TwJ9G}EqreLfOnJN1!z>9oc|obE9XLIZMj3N zaH=Rriohe_T`rwbgsYkb#k_Y@*cOaHa!3NDmC^;2NS&>d+51bjvTI=6NV0Az#Jq)g z?y*=-YBS@?l8?zBQ0G}L5yyL=7oh*F6m>&7vcbG*wB33>{D~-m-91h#O=gj|mJ}twX`nmaJ%L%l1y)7r~0+QrI zoKRhAG>INQ9z1t=hS;~Ff6suVa-5)wu}5Rv+{|GmDS{_pjjjlTH*xxW4W_xc8XVSU&m!A$$1 zV9#xvi%{(CkrO1|czfiGmDnS{VbjUmBX7{kpW7Cf^PfEfp?uJW*zP$X)}-`Xb?#P3 z-HhYUT77uOwz?NSqLpBNom}I1`^J7O8zk9c(-J`4EyvUA#tv?0a2sj(lm4!Z6}KHo z)lC@315QW}=9RiBzG?_I((ohIPL<+LFRJ=XTGAy7BfiY<=x9#`D#4;+N{v1Zm)yES z-i)pP=)jbn7P}Y=(xjgyizcN~N9Rk#O&-ILw266A-O!UdUIwMu1380IdTm|K_tv{U zOgcAt%cKL7H>hYaMH#_5TU+}U6@7X;(E4hS|G}jo|KwSPeU_x()gSft?x>x~Uj&E0 z%!TYWqGn_P0=;u~lCy3sG7GwR8B=gn*0=p!2=zQ)j^hF8)3d?LurTY->?+=HM_ zAm;Ys#eaY~ddQDxas7}B`D>P-8C;;rX64dWzcxWEG75df`m6i;@Ls8{26t#cRUx~L7>MOU@K4Azo^A(Ua-EGy*n2V5__E(tQSU*mZ6?!H5v5pAun=UlQ|CQamXfrS<2l1Q!8%KQXaE~2_1 zIODhpZXc%+HeRW@d#R+mS*32vYdj9_7DDYI`?TC;%tO0zuf7iP2ya5j-dG}xDG<=@ zGsc+Jpwqp?i<(R|-|(g;Gt@MR>A9&!uEdu51%(CLXgp=sk#!>?4HrU(=;&&+hIFtP z*yo9AkoMoN&Pn?ZgnZ(-!W!H+lYRz&L7JG9^5?iP!6&W~2W#nTxV7AsoUtiT;ZR`LWoN-O?=_Q5O)7!S;-~z-*+bnd5t?ozJ zkZrD~VNc@vq}bj1fpAtU44%R&m?1FW*=&-Q^7@ysSMGjwk|z3Uanw~~9z>^^3Bal% zravWaFZK~MbeMlg)s-aos%f4J`+U;e4n{Rd|6*W7ruh zrgr+rT%b-ElPdnE1{Gh&|Lp1kEfKr1z=A|34%D?2WUK%q9t(~gneHmtZ5Z-mCO$H< z^4Jkg@e!_@8hq!#k-`B!vUcxQUmy`xK7Hg+(iC{UCQzz8zpF9`?tGOTr8Ggym$D@W zWh*+qr;5$CC}x`(V|gM1>Jq-K2&A+yF|r`aq+0q(A99j{lE6F%S-IbzU~F=FFPqHe z;k4a;Hn*>{N)_HAsC9A#(|PLf$8tk#+(@59Qf|^YK?+8)TPAX||hjY!Q8+ ztkL)Ei+1l%b`hk+DQZ5D+4GJq!Y)s2!F8ML_HJW$dg_+s-yh82yLq`=rJlRMZWu6i zI)=^QZ=%h-ys~P9$OSgVFm85F92rguix|_q(3~ta7UtHx-j{!Ujm5!YzZ(%J!9t{A z51wRq7~_utuBbLM#`sv{Ye;$z%4C@BG8ek&hXT>IV`!a*(O0Aeoi$lx%rLrpndr8R zX7`n6+{O+yx^0=Cy!9D90*Lu5=I~|i9)w!&Ii;(qM zj5AMyYyF2w%w$x33{y}9S`l2KT55wu;)Y^KrS9M(2Wx^|MK8j5F%4e@^A<(Ntr+j$ z?g-152a}8BR4hq8&*t)ol*&lM%{(}&AWEvzwH*82`uJo1pR}-OV*8fFnm3Dx;JbQi z^SFkom2ag=x_=$=-L)g9R=$>88V^RI=xohAC#L28ym1JOPW&YKZ=XMO{?nZ2`&1u( z3EYXw7WE;rF5Pz4N0gILJUDo9~Vjo5zg<2K?#JLO7mr+A31ce5kkv z*~8uZjCOFomU=z=E|s^~!nN5oKF4yd3v;SK-sBSn|UuzAiDeV3UWH0Qa0M}f1dQz5JJ+=E1LbBGI>@_Bu;1c zv9-atGBuVI+n=Is7*d-gvkW$3=>5T2S|HP%(_Q(xLZ-q0=)Q(t z#Y_!%i`Eyy@Hu%l5j4(J(aa0=&EMmqIG8%J?O7^J1YsZ>PL3|x6=9W_2veFJiS(%P zY3x~j_cmNBOO=+PF)4K60H(11t^NbGWPjm_qro2Wc3JqA%jLO(Z7P`XU(e(rSE^`6 zc)9u(fzw-Yc0b;Ge)z@p-J|-%+p!6#-+|6ho)^ph8iV%d?0-Ai_|$CDID3-52AGM= zKP{v4>KVd|WQvM}568uyrfzgj8L6L83*<*I^E2K5NPNqrXW8F>@wfh*(j7X9S$b6; z{@o|OFx{S@se;T0IGU3q63uq&wzl9}ORt`wSINDI2eB(P+?kH)p)Lcn`v|l4dUlq5Fr%G#3vW+rtp|oiOR}G0|M&Hxq{b!B2oKF8<|Klv1!r)| zqs1xCOZG!y=XC+ubRa9v?t7OAsi0MO_4OoMxi>N^ez>JI%Jc49^Zc+iru(aU?=?}f z^C1V%f4;bpQ~!crGQ|H5Mwi`<$nDuf?j7vimA(8kr*W0{@*COelq+VBL=lU}W#8B4 z-O_^8YdvYrmZH?S&=@=kp8A4YJ&w|pC@x4nTpw78?p3|@!)iQH!SZGEa(CV{xS;#S ze1ANX>DAXY9-K;`dX|?YcTab1ZL%BT4!h;o+h9R6I$%KuN_Dg0n&~4B9zp}l+xMaV zCH2-1#B|YnNHukn5C*Th6`ZEuL@zL3V?m~Fl4#Iw+yz$|4a$q=51H1Lo93S`&p*|b zJH|h4%|F$Z8)Hv}ktF#?_;qu?DY+?ah-eP+5gt)(Yj5*vzXaQ}v|KOo)=hNs{$&u} zDpC&g@9Czy#B4+U!u;wA?Xxz&e83TCWD#n&YOw>A(;1Ak_-Wi3OX*){x)?Gl<|&g{ z6qRV6500uU>@=T5<^oQ!z0>i!HgnrHl?qlkmC6j3@*lKHaB+L+Fuqu6TR{wl{0S2$ zV|jGb)sqU{;pd4l`yNv*ChbJVa*b0jnUM8PGbf2Ca+}5pxnA-ZB!WL&QyJl!&@oX* zcFZ^<=CHs2OR2n{5PWP3?Z1W(zkf=$>txxEHuYJ!Y5&73C$Jhk=dL_gYJizLHX0l< zcd&`PCbPCSvt0Ot$0)eF)BO_mTAS(W&v67Mu;w-_waADf;_CuK!SrIB1H1LQ(~KXA zAQr!fsE#C7oGEP^21&zngVRRBBjlj|@Y^}fw-bLE{b9X)UtI$HsDY2*{;AY~CwD*a7>(9s*t|WLNBo6OcLyam1?WOMZ-^;JM z+FFQ3C7XgHUV^vMiA&Vv?4=>UqO!i{WrIq6MPOs|VX)Wwn1D=fE6z@_6%j&Wyv@#^ zPz(chf5kU#BY|Gd-p@R+Du`DHe%keQw3KicEWd#fX3NhCev)VO7^@kSJhUf0opxgJ z2PspKI`O{_`4{uyI(POmDIAyIJE7}XUnAm z@*g8B@>qFHM)AgPWO%3z-y3M$Z1hd@hHe}+CIYz;*A^BWwTKdt#vbS&h==-k)qa3# z9R|*0LO35YL8K3yzoNMiPHc;EaIAM%{{cZ8Ag@3mH=9ZtgcZ2Iqakq1{ByH-Q-g+s zRb{gn1Kc4O$(q@L1=byp{8_*b_3ali#?e5Q8#gn#+4em2#o$42c%Y;=jBvDX-vTlR#H^ZX^l{&opF9YJk;6E@Qv9Y%% zr0oPL0QxuyzdBQTDS^FVjpA?8$lES}ySl>HSY>wvw#|_@^`1 zNMb}1^@3xP8-vEo{36H#fFTUv29wihrbOhj?>}Ij5XES6KE}TLaW9Vn^$4HLpGgnqH!Csk2saXg2cACcP5Jks z(FfQ^xDPPPbT48&Lw;RS6&yE`;{{VcLWn6>P03N-gWqho?wQFF-sa8VwWEt(l+l%Y zfs^8R+GS5idyx&Fu|04P^3rqulGL{uRYVcbzuXk&mil6u=&sup#3|1P zmn=r6$uz1w%AM9HB-2D^V;xsOP!QbPL2qGE3HR8tK?U7^>N8)Nj~hiF2n|LbwtgTq z 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�$-pg~X}fN*JO34{yGx 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

    7FqwV`k5b&93jv<5~RgvO`p(=_n&^ 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

    oB+2X*={ICU0$0E~3j>p4~I5X*6Lw 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 {}', *context) + return format_html('{}: {}', *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('{}: {}', *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") diff --git a/orchestra/contrib/accounts/admin.py b/orchestra/contrib/accounts/admin.py new file mode 100644 index 0000000..b2d6442 --- /dev/null +++ b/orchestra/contrib/accounts/admin.py @@ -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 %(from)s to %(to)s. " + "You may want to mark existing ignored orders 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 %(from)s to %(to)s. " + "You may want to ignore existing not ignored orders.") + 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': '+', + } + return _('%(plus)s Add to %(name)s') % 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 'False' % static('admin/img/icon-no.svg') + elif not instance.account.is_active: + msg = _("Account disabled") + return 'False' % (static('admin/img/inline-delete.svg'), msg) + return '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 += "
    This user's account is dissabled" + 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() diff --git a/orchestra/contrib/accounts/api.py b/orchestra/contrib/accounts/api.py new file mode 100644 index 0000000..e602f90 --- /dev/null +++ b/orchestra/contrib/accounts/api.py @@ -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) diff --git a/orchestra/contrib/accounts/apps.py b/orchestra/contrib/accounts/apps.py new file mode 100644 index 0000000..4501614 --- /dev/null +++ b/orchestra/contrib/accounts/apps.py @@ -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") diff --git a/orchestra/contrib/accounts/filters.py b/orchestra/contrib/accounts/filters.py new file mode 100644 index 0000000..ff4be0a --- /dev/null +++ b/orchestra/contrib/accounts/filters.py @@ -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 diff --git a/orchestra/contrib/accounts/forms.py b/orchestra/contrib/accounts/forms.py new file mode 100644 index 0000000..4368996 --- /dev/null +++ b/orchestra/contrib/accounts/forms.py @@ -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() diff --git a/orchestra/contrib/accounts/management/__init__.py b/orchestra/contrib/accounts/management/__init__.py new file mode 100644 index 0000000..c163afa --- /dev/null +++ b/orchestra/contrib/accounts/management/__init__.py @@ -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']) diff --git a/orchestra/contrib/accounts/models.py b/orchestra/contrib/accounts/models.py new file mode 100644 index 0000000..b8c6a39 --- /dev/null +++ b/orchestra/contrib/accounts/models.py @@ -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 diff --git a/orchestra/contrib/accounts/serializers.py b/orchestra/contrib/accounts/serializers.py new file mode 100644 index 0000000..9f05b16 --- /dev/null +++ b/orchestra/contrib/accounts/serializers.py @@ -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) diff --git a/orchestra/contrib/accounts/settings.py b/orchestra/contrib/accounts/settings.py new file mode 100644 index 0000000..b8a2594 --- /dev/null +++ b/orchestra/contrib/accounts/settings.py @@ -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', + ( + # , , , + ('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 <username>.{} or not.".format(ORCHESTRA_BASE_DOMAIN)), + ), + ), +) + + +ACCOUNTS_SERVICE_REPORT_TEMPLATE = Setting('ACCOUNTS_SERVICE_REPORT_TEMPLATE', + 'admin/accounts/account/service_report.html' +) diff --git a/orchestra/contrib/accounts/templates/admin/accounts/account/change_form.html b/orchestra/contrib/accounts/templates/admin/accounts/account/change_form.html new file mode 100644 index 0000000..b1a4138 --- /dev/null +++ b/orchestra/contrib/accounts/templates/admin/accounts/account/change_form.html @@ -0,0 +1,42 @@ +{% extends "orchestra/admin/change_form.html" %} +{% load i18n admin_urls static admin_modify %} + + +{% block breadcrumbs %} +

    +{% endblock %} + + +{% block object-tools-items %} +{% if services %} +
  1. +{% endif %} +{% if accounts %} +
  2. +{% endif %} +{{ block.super }} +{% endblock %} diff --git a/orchestra/contrib/accounts/templates/admin/accounts/account/change_list.html b/orchestra/contrib/accounts/templates/admin/accounts/account/change_list.html new file mode 100644 index 0000000..08a7b56 --- /dev/null +++ b/orchestra/contrib/accounts/templates/admin/accounts/account/change_list.html @@ -0,0 +1,49 @@ +{% extends "admin/change_list.html" %} +{% load i18n admin_urls admin_list %} + + +{% block breadcrumbs %} + +{% endblock %} + + +{% block object-tools-items %} +
  3. + {% url cl.opts|admin_urlname:'add' as add_url %} + + {% 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 %} + +
  4. +{% endblock %} + + +{% block filters %} + {% if cl.has_filters %} +
    +

    {% trans 'Filter' %}

    + {% if account %} +

    {% trans 'By account' %}

    + + {% endif %} + {% for spec in cl.filter_specs %}{% admin_list_filter cl spec %}{% endfor %} +
    + {% endif %} +{% endblock %} + diff --git a/orchestra/contrib/accounts/templates/admin/accounts/account/delete_related_services_confirmation.html b/orchestra/contrib/accounts/templates/admin/accounts/account/delete_related_services_confirmation.html new file mode 100644 index 0000000..b25aee0 --- /dev/null +++ b/orchestra/contrib/accounts/templates/admin/accounts/account/delete_related_services_confirmation.html @@ -0,0 +1,39 @@ +{% extends "admin/delete_selected_confirmation.html" %} +{% load i18n l10n admin_urls %} + +{% block content %} +{% if perms_lacking %} +

    {% 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 %}

    +
      + {% for obj in perms_lacking %} +
    • {{ obj }}
    • + {% endfor %} +
    +{% elif protected %} +

    {% blocktrans %}Deleting the selected {{ objects_name }} would require deleting the following protected related objects:{% endblocktrans %}

    +
      + {% for obj in protected %} +
    • {{ obj }}
    • + {% endfor %} +
    +{% else %} +

    {% 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 %}

    + {% include "admin/includes/object_delete_summary.html" %} +

    {% trans "Objects" %}

    + {% for deletable_object in deletable_objects %} +
      {{ deletable_object|unordered_list }}
    + {% endfor %} +
    {% csrf_token %} +
    + {% for obj in queryset %} + + {% endfor %} + + + + {% trans "No, take me back" %} +
    +
    +{% endif %} +{% endblock %} + diff --git a/orchestra/contrib/accounts/templates/admin/accounts/account/disable_selected_confirmation.html b/orchestra/contrib/accounts/templates/admin/accounts/account/disable_selected_confirmation.html new file mode 100644 index 0000000..7ada724 --- /dev/null +++ b/orchestra/contrib/accounts/templates/admin/accounts/account/disable_selected_confirmation.html @@ -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 %} + +{% endblock %} + +{% block content %} +{% if disable%}

    {% blocktrans %}Are you sure you want to disable selected {{ objects_name }}?{% endblocktrans %}

    +{% else %}

    {% blocktrans %}Are you sure you want to enable selected {{ objects_name }}?{% endblocktrans %}

    +{% endif %} +

    {% trans "Objects" %}

    +{% for deletable_object in deletable_objects %} +
      {{ deletable_object|unordered_list }}
    +{% endfor %} +
    {% csrf_token %} +
    +{% for obj in queryset %} + +{% endfor %} + + + +{% trans "No, take me back" %} +
    +
    +{% endblock %} + diff --git a/orchestra/contrib/accounts/templates/admin/accounts/account/select_account_list.html b/orchestra/contrib/accounts/templates/admin/accounts/account/select_account_list.html new file mode 100644 index 0000000..bb60f3a --- /dev/null +++ b/orchestra/contrib/accounts/templates/admin/accounts/account/select_account_list.html @@ -0,0 +1,13 @@ +{% extends 'admin/change_list.html' %} +{% load i18n admin_urls %} + + +{% block breadcrumbs %} + +{% endblock %} + diff --git a/orchestra/contrib/accounts/templates/admin/accounts/account/service_report.html b/orchestra/contrib/accounts/templates/admin/accounts/account/service_report.html new file mode 100644 index 0000000..f10d116 --- /dev/null +++ b/orchestra/contrib/accounts/templates/admin/accounts/account/service_report.html @@ -0,0 +1,84 @@ +{% load i18n admin_urls utils %} + + + {% block title %}Account service report{% endblock %} + + {% block head %}{% endblock %} + + + + +
    {% trans "Service report generated on" %} {{ date | date }}
    +{% for account, items in accounts %} +

    {{ account.get_full_name }} - {{ account.username }}

    +
    + +{% endfor %} + + diff --git a/orchestra/contrib/bills/__init__.py b/orchestra/contrib/bills/__init__.py new file mode 100644 index 0000000..c568ce6 --- /dev/null +++ b/orchestra/contrib/bills/__init__.py @@ -0,0 +1 @@ +default_app_config = 'orchestra.contrib.bills.apps.BillsConfig' diff --git a/orchestra/contrib/bills/actions.py b/orchestra/contrib/bills/actions.py new file mode 100644 index 0000000..64c9ed5 --- /dev/null +++ b/orchestra/contrib/bills/actions.py @@ -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( + _('One related transaction has been created') % context, + _('%(num)i related transactions 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.

    " + "

    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( + _('One amendment bill have been generated.') % context, + _('%(num)i amendment bills 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) diff --git a/orchestra/contrib/bills/admin.py b/orchestra/contrib/bills/admin.py new file mode 100644 index 0000000..ac453db --- /dev/null +++ b/orchestra/contrib/bills/admin.py @@ -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 '%s ' % (url, content, total, img) + return '%s' % (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(' ' * 4 + subline.description) + return '
    '.join(descriptions) + display_description.short_description = _("Description") + + @mark_safe + def display_subtotal(self, line): + subtotals = [' ' + str(line.subtotal)] + for subline in line.sublines.all(): + subtotals.append(str(subline.total)) + return '
    '.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 = '%s' % (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 '%s &%s;' % (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 '---' + 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 = '%s*' % state + title = _("This bill has been amended, this value may not be valid.") + color = PAYMENT_STATE_COLORS.get(bill.payment_state, 'grey') + return '{name}'.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('{num}'.format(url=url, num=amend.number)) +# return '
    '.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('{}', 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') diff --git a/orchestra/contrib/bills/api.py b/orchestra/contrib/bills/api.py new file mode 100644 index 0000000..7d050b4 --- /dev/null +++ b/orchestra/contrib/bills/api.py @@ -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) diff --git a/orchestra/contrib/bills/apps.py b/orchestra/contrib/bills/apps.py new file mode 100644 index 0000000..ecc7458 --- /dev/null +++ b/orchestra/contrib/bills/apps.py @@ -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') diff --git a/orchestra/contrib/bills/filters.py b/orchestra/contrib/bills/filters.py new file mode 100644 index 0000000..adcf575 --- /dev/null +++ b/orchestra/contrib/bills/filters.py @@ -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 > 0")), + ('lt', mark_safe("total < 0")), + ('eq', "total = 0"), + ('ne', mark_safe("total ≠ 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() diff --git a/orchestra/contrib/bills/forms.py b/orchestra/contrib/bills/forms.py new file mode 100644 index 0000000..b475992 --- /dev/null +++ b/orchestra/contrib/bills/forms.py @@ -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 diff --git a/orchestra/contrib/bills/helpers.py b/orchestra/contrib/bills/helpers.py new file mode 100644 index 0000000..d255e93 --- /dev/null +++ b/orchestra/contrib/bills/helpers.py @@ -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 provide one') + 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}: {2} {3}', + capfirst(opts.verbose_name), change_url(bill), bill, emails) + ) + return { + 'display_objects': bills + } diff --git a/orchestra/contrib/bills/locale/ca/LC_MESSAGES/django.mo b/orchestra/contrib/bills/locale/ca/LC_MESSAGES/django.mo new file mode 100644 index 0000000000000000000000000000000000000000..19d238d4991694405e519bda04e0880288cbde21 GIT binary patch literal 7410 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, 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 \n" +"Language-Team: LANGUAGE \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 "One related transaction has been created" +msgstr "S'ha creat una transacció" + +#: actions.py:74 +#, python-format +msgid "%(num)i related transactions have been created" +msgstr "S'han creat les %(num)i següents transaccions" + +#: 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.

    Please select a " +"payment source for the selected bills" +msgstr "" +"Una vegada la factura estigui tancada no podrà ser modificada.

    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 "One amendment bill have been generated." +msgstr "S'ha creat una transacció" + +#: actions.py:304 +#, python-format +msgid "%(num)i amendment bills have been generated." +msgstr "S'han creat les %(num)i següents transaccions" + +#: 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 provide one" +msgstr "" +"{relation} compte \"{account}\" no te un contacte de facturació. Hauries de " +"proporcionar un" + +#: 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" +"With your membership you are supporting ...\n" +msgstr "" +"\n" +"Amb la teva quota de soci 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 %(type)s by bank transfer.
    \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 transferència bancaria.
    Inclou el " +"teu nom i el número de %(type)s. 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 %(type)s, 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 %(type)s, 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" diff --git a/orchestra/contrib/bills/locale/es/LC_MESSAGES/django.mo b/orchestra/contrib/bills/locale/es/LC_MESSAGES/django.mo new file mode 100644 index 0000000000000000000000000000000000000000..135b964303db3ec6e4c1be5253930c588194e5bb GIT binary patch literal 4980 zcmbuCTWnlM8OMiG8Zgj63HP*2L$}yX*0<6&*{t2Hy=!MxuXpQRyG{Gh&Ym;7p62Y# zX678bE`oA<;H4meP+mYZ1qmdIL_tWXsZ^=FpF$pSp>$3t;(XJkXwh2V;K)uYj+D&)ve=V>4hEoCTi;F~t4=z8Cxx_AE_2W-^Do+c(9pj$?KMXz#(tejgTK6sR1K{_-OW@Bzns?7gCT9+$dFMS_ zAf~WK!B2o)a1{I^csKY%ko5ZvNOE5X-v|B}zF+t6 z-v-H!7eJc#1CaKA$$$SDNb|3P7vIL%FTk(ieHubu0bc{jug~7f*f{tNaFM=)XTXu8 z8LQyEc>kQ|Pe5AtN6&wQAH@5uaN<087fAN3gAmCEAlbhKl6{{CN#C#f@yq`GyMFvd z5Gt^j!Hf)q-vf~3DiBN9C6MHQ$$!5LLKXHMko^5)knFw+lHI=qN&c@vilf*4 z_!}VU`*)D!{SzcP|M5HmAvAszr0>T-ns*$eeG4G@^OPSi`R{We&8vH^fF%EMko+n^ zghSfy!IN?ajih(Kg6X#TGY-&1va?F({)GY6A@vhJpd2EGtJ!!|wT|0Z|POV4^ zC;32Jl-wm&^4M?}i&*k4+E|N$p%x_|P_<=x72fMe3N4j;%0C z7;~&F__~$7nG=Q4#KOuG2oZ=LQygciL-d%Fx`-Gto4L6wrG~v)TGDv@23vTIvH%kS ziQm6Zs+J57*`%p)qI-0BD=N_HL&WjFMP`%UQja~z=5T4)e6!K0EqB^%9#ipZ21BV`dKP%mG5o@+tFAr9|+r?JH9Ub&{mce+eZ_{)R|O&!3A zsXBD0NLd+j6{l%I8yv0Vy~N_$T0StL>M0y(d15$Q9?tqZ?_?x{7@HuAq^@ze@z^zu zMqKx79J{7AL!!*Yt>a##PId^Pan>XmtW~){zF@6vFZS?it=*|Nm)oqJipjURCeSjS z_m8`ElSGgyxzf2=X+pZ;L<7%fd5d+Loyrnht900!v|YFbnM3GU#anDm$xXIqqGTXh z8i3sNvL8?{Qd%-HHGO2S-mONnzzoPoj^&Z%$uSm67g#l$E`U>H>()(Ng4FE3 zGg6W5xSCQd8%nE8$aeIjYscs@F|-twK+Bk@j6*)9EE;d|Wg~hp~?}{B$;v=nQr#SC3NU@3yOMH5A z>P&HJx;Q<_r>08x-G6d&YH|`2iz{+N(dK^Mg<55$*k~536@I>1YSd3Y)?BWQED0AE zJ5+d(_lzy^h1I1czFcY4c)8Z7)R)dxs@0WRyFH7kx}PA87CTZ5N_?rZytqm#mzS2# zMQYsG{_Y&8+Nd{byY@|uPma(ngO7?Gl)e&=<8ow4qS=rVfyN3rq80F(8$cF=6BBUsj;&oY^0S)oH%Yx zUs#TsBn}itIC`h_N*yTNQ=tQ4l}HP5T?bLJg?f~5Z3bORJsdSkZ@)Z{*6{E^r5)$X zRQ#;qK>~Akw}be-MYFAVtdPJ_;G%w_1rL)@aKndI;uR~MA$gE|uyjJ{p4c#UPqhoO zJO;@qh}t+4TjMw`+cnj*LkZap4Kl5j0}Mxo54Nv3ZpBcAbkR_khRCMfXn)0AsLh|R zP2J|H2f*ee@H&EL@B|Ch*Y)j-Oq#TTdhF`yWrhii* z5#t`nwB{i({a-6{k#t-K#qNlMvQ*W1O!wLS*z#*Yf_f&uiwISI2A>S5IvJmh4 zX4YIxr-?#=5K7}Skk%sjBUA#X`rEG{0#tm)3CML@_fBf$LuDnBE1UR7buwi^lfLqC5xsS5h6h$Hw&Y!B(8;YWjLcvc&eYFPVb+L)s3bR`)wZ&C9vCWWZ^GP@c zseNE27YJ-ySfP&Pp-u91l zTAK0yr, 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 \n" +"Language-Team: LANGUAGE \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 "One related transaction has been created" +msgstr "Se ha creado una transacción" + +#: actions.py:74 +#, python-format +msgid "%(num)i related transactions have been created" +msgstr "Se han creado %(num)i transacciones" + +#: 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.

    Please select a " +"payment source for the selected bills" +msgstr "" +"Una vez cerrada la factura ya no se podrá modificar.

    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 "One amendment bill have been generated." +msgstr "Se ha creado una transacción" + +#: actions.py:304 +#, python-format +msgid "%(num)i amendment bills have been generated." +msgstr "Se han creado %(num)i transacciones" + +#: 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 provide one" +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" +"With your membership 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 %(type)s by bank transfer.
    \n" +" Please make sure to state your name and the %(type)s number.\n" +" Our bank account number is
    \n" +" " +msgstr "" +"\n" +"Puedes pagar esta %(type)s por transferencia bancaria.
    Incluye tu " +"nombre y el número de %(type)s. 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 %(type)s, 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 %(type)s, 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" diff --git a/orchestra/contrib/bills/models.py b/orchestra/contrib/bills/models.py new file mode 100644 index 0000000..9510f6b --- /dev/null +++ b/orchestra/contrib/bills/models.py @@ -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-', '') + 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) diff --git a/orchestra/contrib/bills/serializers.py b/orchestra/contrib/bills/serializers.py new file mode 100644 index 0000000..f73380b --- /dev/null +++ b/orchestra/contrib/bills/serializers.py @@ -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) diff --git a/orchestra/contrib/bills/settings.py b/orchestra/contrib/bills/settings.py new file mode 100644 index 0000000..93e15da --- /dev/null +++ b/orchestra/contrib/bills/settings.py @@ -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 +) diff --git a/orchestra/contrib/bills/templates/admin/bills/bill/change_list.html b/orchestra/contrib/bills/templates/admin/bills/bill/change_list.html new file mode 100644 index 0000000..220dbfe --- /dev/null +++ b/orchestra/contrib/bills/templates/admin/bills/bill/change_list.html @@ -0,0 +1,18 @@ +{% extends "admin/change_list.html" %} +{% load i18n admin_urls %} + + +{% block object-tools-items %} +

  5. + {% url 'admin:bills_billline_changelist' as list_url %} + + {% trans "Lines" %} + +
  6. +
  7. + {% url 'admin:bills_bill_add' as add_url %} + + {% trans "Add bill" %} + +
  8. +{% endblock %} diff --git a/orchestra/contrib/bills/templates/admin/bills/bill/close_send_download_bills.html b/orchestra/contrib/bills/templates/admin/bills/bill/close_send_download_bills.html new file mode 100644 index 0000000..d56dbe0 --- /dev/null +++ b/orchestra/contrib/bills/templates/admin/bills/bill/close_send_download_bills.html @@ -0,0 +1,60 @@ +{% extends "admin/orchestra/generic_confirmation.html" %} +{% load i18n l10n %} +{% load admin_urls static utils %} + +{% block extrastyle %} +{{ block.super }} + +{% endblock %} + +{% block content %} +
    +
    +
    +

    {{ content_message | safe }}

    +
      {{ display_objects | unordered_list }}
    +
    +
    {% csrf_token %} + {% block form %} + {% if form %} +
    + {{ form.non_field_errors }} + {% for field in form %} +
    +
    + {{ field.errors }} + {% if field|is_checkbox %} + {{ field }} + {% else %} + {{ field.label_tag }} {{ field }} + {% endif %} +

    {{ field.help_text|safe }}

    +
    +
    + {% endfor %} +
    + {% endif %} + {% endblock %} + {% block formset %} + {% if formset %} + {{ formset.as_admin }} + {% endif %} + {% endblock %} +
    + {% for obj in queryset %} + + {% endfor %} + + + +
    +
    +{% endblock %} diff --git a/orchestra/contrib/bills/templates/admin/bills/bill/report.html b/orchestra/contrib/bills/templates/admin/bills/bill/report.html new file mode 100644 index 0000000..4a26ec7 --- /dev/null +++ b/orchestra/contrib/bills/templates/admin/bills/bill/report.html @@ -0,0 +1,87 @@ +{% load i18n utils %} + + + + Bill Report + + + + + + + + + +{% for tax, subtotal in subtotals.items %} + + + + + + + + +{% endfor %} + + + + +
    {% trans "Summary" %}{% trans "Total" %}
    {% trans "subtotal" %} {{ tax }}% {% trans "VAT" %}{{ subtotal|first}}
    {% trans "taxes" %} {{ tax }}% {% trans "VAT" %}{{ subtotal|last}}
    {% trans "TOTAL" %}{{ total }}
    + + + + + + + + + + + + +{% for bill in bills %} + + + + + + {% with base=bill.compute_base total=bill.compute_total %} + + + + {% endwith %} + +{% endfor %} +
    {% trans "Number" %}{% trans "VAT number" %}{% trans "Contact" %}{% trans "Close date" %}{% trans "Base" %}{% trans "VAT" %}{% trans "Total" %}
    {{ bill.number }}{{ bill.buyer.vat }}{{ bill.buyer.get_name }}{{ bill.closed_on|date }}{{ base }}{{ total|sub:base }}{{ total }}
    + + diff --git a/orchestra/contrib/bills/templates/admin/bills/billline/change_list.html b/orchestra/contrib/bills/templates/admin/bills/billline/change_list.html new file mode 100644 index 0000000..ea8dc4c --- /dev/null +++ b/orchestra/contrib/bills/templates/admin/bills/billline/change_list.html @@ -0,0 +1,12 @@ +{% extends "admin/change_list.html" %} +{% load i18n admin_urls %} + +{% block breadcrumbs %} + +{% endblock %} diff --git a/orchestra/contrib/bills/templates/admin/bills/billline/report.html b/orchestra/contrib/bills/templates/admin/bills/billline/report.html new file mode 100644 index 0000000..c43c940 --- /dev/null +++ b/orchestra/contrib/bills/templates/admin/bills/billline/report.html @@ -0,0 +1,72 @@ +{% load i18n utils %} + + + + Transaction Report + + + + + + + + + + + + + + +{% for service, info in services %} + + + + + + + + +{% endfor %} + + + + + + + + +
    {% trans "Service" %}{% trans "Active" %}{% trans "Cancelled" %}{% trans "Nominal price" %}{% trans "Quantity" %}{% trans "Profit" %}
    {{ service }}{{ info.0 }}{{ info.1 }}{{ info.2 }}{{ info.3 }}{{ info.4 }}
    {% trans "TOTAL" %}{{ totals.0 }}{{ totals.1 }}{{ totals.2 }}{{ totals.3 }}{{ totals.4 }}
    +
    +* Custom lines +
    + + diff --git a/orchestra/contrib/bills/templates/bills/base.html b/orchestra/contrib/bills/templates/bills/base.html new file mode 100644 index 0000000..ce8a782 --- /dev/null +++ b/orchestra/contrib/bills/templates/bills/base.html @@ -0,0 +1,10 @@ + + + {% block title %}{{ bill.get_type_display }} - {{ bill.number }}{% endblock %} + + {% block head %}{% endblock %} + + + {% block body %}{% endblock %} + + diff --git a/orchestra/contrib/bills/templates/bills/bill-notification.email b/orchestra/contrib/bills/templates/bills/bill-notification.email new file mode 100644 index 0000000..7a00022 --- /dev/null +++ b/orchestra/contrib/bills/templates/bills/bill-notification.email @@ -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 %} diff --git a/orchestra/contrib/bills/templates/bills/invoice.html b/orchestra/contrib/bills/templates/bills/invoice.html new file mode 100644 index 0000000..1b75168 --- /dev/null +++ b/orchestra/contrib/bills/templates/bills/invoice.html @@ -0,0 +1,217 @@ + + + + +

    {{ bill_type }}

    +
    + + + + + + +
    + {{ buyer.name }}
    + {{ buyer.address }}
    + {{ buyer.zipcode }} {{ buyer.city }}
    + {{ buyer.country }}
    + {{ buyer.vat_number }}
    +
    + Invoice number
    + Date
    + Due date +
    + : {{ bill.ident }}
    + : {{ bill.date|date:"d F, Y" }}
    + : {{ bill.due_on|date:"d F, Y" }}
    +
    +
    +
    + + + + + + + + {% for line in lines %} + + + + + + + {% endfor %} +
    ID{% trans Description %}AmountPrice
    {{ line.order_id }}{{ line.description }} + ({{ line.initial_date|date:"d-m-Y" }}{% if line.initial_date != line.final_date %} - {{ line.final_date|date:"d-m-Y" }}{% endif %}){{ line.amount }}&{{ currency }}; {{ line.price }}
    +
    +
    + + + {% for tax, base in bases.items %} + + + + {% endfor %} + + + {% for tax, value in taxes.items %} + + + + {% endfor %} + + + + + + +
     Subtotal{% if bases.items|length > 1 %} (for {{ tax }}% taxes){% endif %}&{{ currency }}; {{ base }}
     Total {{ tax }}%&{{ currency }}; {{ value }}
     Total&{{ currency }}; {{ total }}
    +
    +
    + + + + + + + + +
    IBAN + Invoice ID + Amount {{ currency.upper }} +
    NL28INGB0004954664{{ bill.ident }}{{ total }}
    +

    The invoice is to be paid before {{ invoice.exp_date|date:"F jS, Y" }} with the mention of the invoice id.

    +
    +
    + + + + + + + + +
    + {{ seller.name }}
    + {{ seller.address }}
    + {{ seller.city }}
    + {{ seller.country }}
    +
    + Tel
    + Web
    + Email
    +
    + {{ seller_info.phone }}
    + {{ seller_info.website }}
    + {{ seller_info.email }} +
    + Bank ING
    + IBAN
    + BTW
    + KvK
    +
    + 4954664
    + NL28INGB0004954664
    + NL 8207.29.449.B01
    + 27343027 +
    +
    + Payment info + + + + diff --git a/orchestra/contrib/bills/templates/bills/microspective-fee.html b/orchestra/contrib/bills/templates/bills/microspective-fee.html new file mode 100644 index 0000000..21f4817 --- /dev/null +++ b/orchestra/contrib/bills/templates/bills/microspective-fee.html @@ -0,0 +1,155 @@ +{% extends 'bills/microspective.html' %} +{% load i18n %} + +{% block head %} + +{% endblock %} + +{% block summary %} +
    +
    +
    + +
    + {{ buyer.get_name }}
    + {{ buyer.vat }}
    + {{ buyer.address }}
    + {{ buyer.zipcode }} - {{ buyer.city }}
    + {% trans buyer.get_country_display %}
    +
    + +
    + {% filter title %}{% trans bill.get_type_display %}{% endfilter %}
    + {{ bill.number }}
    + {{ bill.closed_on | default:now | date:"F j, Y" | capfirst }}
    +
    + +
    + {{ bill.compute_total }} &{{ currency.lower }};
    + {% trans "Due date" %} {{ payment.due_date| default:default_due_date | date:"F j, Y" }}
    + {% if not payment.message %}{% blocktrans with bank_account=seller_info.bank_account %}On {{ bank_account }}{% endblocktrans %}{% endif %}
    +
    +
    + +
    +{% 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 %} +
    +{% endblock %} + + + +{% block content %} +{% block lines %} +
    +{% for line in bill.lines.all %} +
      + {% if not forloop.first %} +
    • {{ line.description }}
    • + {% endif %} +
    +{% endfor %} +
    +{% endblock %} + +{% block text %} +
    +{% blocktrans %} +With your membership you are supporting ... +{% endblocktrans %} +
    +{% endblock %} + +{% endblock %} + +{% block footer %} +
    +{{ block.super }} +{% endblock %} diff --git a/orchestra/contrib/bills/templates/bills/microspective-proforma.html b/orchestra/contrib/bills/templates/bills/microspective-proforma.html new file mode 100644 index 0000000..9240024 --- /dev/null +++ b/orchestra/contrib/bills/templates/bills/microspective-proforma.html @@ -0,0 +1,13 @@ +{% extends 'bills/microspective.html' %} + +{% block head %} + +{% endblock %} + + +{% block payment %} +{% endblock %} diff --git a/orchestra/contrib/bills/templates/bills/microspective.css b/orchestra/contrib/bills/templates/bills/microspective.css new file mode 100644 index 0000000..6513d65 --- /dev/null +++ b/orchestra/contrib/bills/templates/bills/microspective.css @@ -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; +} \ No newline at end of file diff --git a/orchestra/contrib/bills/templates/bills/microspective.html b/orchestra/contrib/bills/templates/bills/microspective.html new file mode 100644 index 0000000..e4422bf --- /dev/null +++ b/orchestra/contrib/bills/templates/bills/microspective.html @@ -0,0 +1,178 @@ +{% extends 'bills/base.html' %} +{% load i18n %} + +{% block head %} + +{% endblock %} + +{% block body %} +
    +
    +{% if bill.is_open %} + +
    +

    ESBORRANY - DRAFT - BORRADOR

    +
    +{% endif %} +{% block header %} + +
    +
    + {{ seller.get_name }} +
    +
    +

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

    +

    {{ seller_info.phone }}
    + {{ seller_info.email }}
    + {{ seller_info.website }}

    +
    +
    +{% endblock %} + +{% block summary %} +
    + {% filter title %}{% trans bill.get_type_display %}{% endfilter %}
    + {{ bill.number }}
    +
    +
    +
    +
    + {% trans "DUE DATE" %}
    + {{ bill.due_on | default:default_due_date | date | capfirst }} +
    +
    + {% trans "TOTAL" %}
    + {{ bill.compute_total }} &{{ currency.lower }}; +
    +
    + {% blocktrans with bill_type=bill.get_type_display.upper %}{{ bill_type }} DATE{% endblocktrans %}
    + {{ bill.closed_on | default:now | date | capfirst }} +
    +
    +
    + {{ buyer.get_name }}
    + {{ buyer.vat }}
    + {{ buyer.address }}
    + {{ buyer.zipcode }} - {% trans buyer.city %}
    + {% trans buyer.get_country_display %}
    +
    +{% endblock %} + +{% block content %} +{% block lines %} +
    + id + {% trans "description" %} + {% trans "period" %} + {% trans "hrs/qty" %} + {% trans "rate/price" %} + {% trans "subtotal" %} +
    + {% for line in lines %} + {% with sublines=line.sublines.all description=line.description|slice:"38:" %} + {% if not line.order_id %}L{% endif %}{{ line.order_id|default:line.pk }} + {{ line.description|safe|slice:":38" }} + {{ line.get_verbose_period }} + {{ line.get_verbose_quantity|default:" "|safe }} + {% if line.rate %}{{ line.rate }} &{{ currency.lower }};{% else %} {% endif %} + {{ line.subtotal }} &{{ currency.lower }}; +
    + {% if description %} +   + {{ description|safe|truncatechars:39 }} +   +   +   +   + {% endif %} + {% for subline in sublines %} +   + {{ subline.description|safe|truncatechars:39 }} +   +   +   + {{ subline.total }} &{{ currency.lower }}; +
    + {% endfor %} + {% endwith %} + {% endfor %} +
    +{% endblock %} + +{% block totals %} +
    +
     
    + {% for tax, subtotal in bill.compute_subtotals.items %} + {% trans "subtotal" %} {{ tax }}% {% trans "VAT" %} + {{ subtotal | first }} &{{ currency.lower }}; +
    + {% trans "taxes" %} {{ tax }}% {% trans "VAT" %} + {{ subtotal | last }} &{{ currency.lower }}; +
    + {% endfor %} + {% trans "total" %} + {{ bill.compute_total }} &{{ currency.lower }}; +
    +
    +{% endblock %} +{% endblock %} + +{% block footer %} +
    + +
    +{% endblock %} +{% endblock %} diff --git a/orchestra/contrib/contacts/__init__.py b/orchestra/contrib/contacts/__init__.py new file mode 100644 index 0000000..3af1574 --- /dev/null +++ b/orchestra/contrib/contacts/__init__.py @@ -0,0 +1 @@ +default_app_config = 'orchestra.contrib.contacts.apps.ContactsConfig' diff --git a/orchestra/contrib/contacts/admin.py b/orchestra/contrib/contacts/admin.py new file mode 100644 index 0000000..c5c7647 --- /dev/null +++ b/orchestra/contrib/contacts/admin.py @@ -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) diff --git a/orchestra/contrib/contacts/api.py b/orchestra/contrib/contacts/api.py new file mode 100644 index 0000000..6a2c5ee --- /dev/null +++ b/orchestra/contrib/contacts/api.py @@ -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) diff --git a/orchestra/contrib/contacts/apps.py b/orchestra/contrib/contacts/apps.py new file mode 100644 index 0000000..4ed7fe7 --- /dev/null +++ b/orchestra/contrib/contacts/apps.py @@ -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') diff --git a/orchestra/contrib/contacts/filters.py b/orchestra/contrib/contacts/filters.py new file mode 100644 index 0000000..0f36a54 --- /dev/null +++ b/orchestra/contrib/contacts/filters.py @@ -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(',')) diff --git a/orchestra/contrib/contacts/models.py b/orchestra/contrib/contacts/models.py new file mode 100644 index 0000000..2f069af --- /dev/null +++ b/orchestra/contrib/contacts/models.py @@ -0,0 +1,80 @@ +from django.core.exceptions import ValidationError +from django.core.validators import RegexValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from orchestra.core import validators +from orchestra.models.fields import MultiSelectField + +from . import settings +from .validators import validate_phone + + +class ContactQuerySet(models.QuerySet): + def filter(self, *args, **kwargs): + usages = kwargs.pop('email_usages', []) + qs = models.Q() + for usage in usages: + qs = qs | models.Q(email_usage__regex=r'.*(^|,)+%s($|,)+.*' % usage) + return super(ContactQuerySet, self).filter(qs, *args, **kwargs) + + +class Contact(models.Model): + BILLING = 'BILLING' + EMAIL_USAGES = ( + ('SUPPORT', _("Support tickets")), + ('ADMIN', _("Administrative")), + (BILLING, _("Billing")), + ('TECH', _("Technical")), + ('ADDS', _("Announcements")), + ('EMERGENCY', _("Emergency contact")), + ) + + objects = ContactQuerySet.as_manager() + + account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), + related_name='contacts', null=True, on_delete=models.SET_NULL) + short_name = models.CharField(_("short name"), max_length=128) + full_name = models.CharField(_("full name"), max_length=256, blank=True) + email = models.EmailField() + email_usage = MultiSelectField(_("email usage"), max_length=256, blank=True, + choices=EMAIL_USAGES, + default=settings.CONTACTS_DEFAULT_EMAIL_USAGES) + phone = models.CharField(_("phone"), max_length=32, blank=True, + validators=[validate_phone]) + phone2 = models.CharField(_("alternative phone"), max_length=32, blank=True, + validators=[validate_phone]) + address = models.TextField(_("address"), blank=True) + city = models.CharField(_("city"), max_length=128, blank=True) + zipcode = models.CharField(_("zip code"), max_length=10, blank=True, + validators=[ + RegexValidator(r'^[0-9,A-Z]{3,10}$', + _("Enter a valid zipcode."), 'invalid') + ]) + country = models.CharField(_("country"), max_length=20, blank=True, + choices=settings.CONTACTS_COUNTRIES, + default=settings.CONTACTS_DEFAULT_COUNTRY) + + def __str__(self): + return self.full_name or self.short_name + + def clean(self): + self.short_name = self.short_name.strip() + self.full_name = self.full_name.strip() + self.phone = self.phone.strip() + self.phone2 = self.phone2.strip() + self.address = self.address.strip() + self.city = self.city.strip() + self.country = self.country.strip() + errors = {} + if self.address and not (self.city and self.zipcode and self.country): + errors['__all__'] = _("City, zipcode and country must be provided when address is provided.") + if self.zipcode and not self.country: + errors['country'] = _("Country must be provided when zipcode is provided.") + elif self.zipcode and self.country: + try: + validators.validate_zipcode(self.zipcode, self.country) + except ValidationError as error: + errors['zipcode'] = error + if errors: + raise ValidationError(errors) diff --git a/orchestra/contrib/contacts/serializers.py b/orchestra/contrib/contacts/serializers.py new file mode 100644 index 0000000..54538cc --- /dev/null +++ b/orchestra/contrib/contacts/serializers.py @@ -0,0 +1,17 @@ +from rest_framework import serializers + +#from orchestra.api.serializers import MultiSelectField +from orchestra.contrib.accounts.serializers import AccountSerializerMixin + +from .models import Contact + + +class ContactSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer): + email_usage = serializers.MultipleChoiceField(choices=Contact.EMAIL_USAGES) + + class Meta: + model = Contact + fields = ( + 'url', 'id', 'short_name', 'full_name', 'email', 'email_usage', 'phone', + 'phone2', 'address', 'city', 'zipcode', 'country' + ) diff --git a/orchestra/contrib/contacts/settings.py b/orchestra/contrib/contacts/settings.py new file mode 100644 index 0000000..111231f --- /dev/null +++ b/orchestra/contrib/contacts/settings.py @@ -0,0 +1,32 @@ +from django_countries import data + +from orchestra.contrib.settings import Setting + + +CONTACTS_DEFAULT_EMAIL_USAGES = Setting('CONTACTS_DEFAULT_EMAIL_USAGES', + default=( + 'SUPPORT', + 'ADMIN', + 'BILLING', + 'TECH', + 'ADDS', + 'EMERGENCY' + ), +) + + +CONTACTS_DEFAULT_CITY = Setting('CONTACTS_DEFAULT_CITY', + default='Barcelona' +) + + +CONTACTS_COUNTRIES = Setting('CONTACTS_COUNTRIES', + default=tuple((k,v) for k,v in data.COUNTRIES.items()), + serializable=False +) + + +CONTACTS_DEFAULT_COUNTRY = Setting('CONTACTS_DEFAULT_COUNTRY', + default='ES', + choices=CONTACTS_COUNTRIES +) diff --git a/orchestra/contrib/contacts/validators.py b/orchestra/contrib/contacts/validators.py new file mode 100644 index 0000000..c97e4ca --- /dev/null +++ b/orchestra/contrib/contacts/validators.py @@ -0,0 +1,7 @@ +from orchestra.core import validators + +from . import settings + + +def validate_phone(phone): + validators.validate_phone(phone, settings.CONTACTS_DEFAULT_COUNTRY) diff --git a/orchestra/contrib/databases/__init__.py b/orchestra/contrib/databases/__init__.py new file mode 100644 index 0000000..f21f8dd --- /dev/null +++ b/orchestra/contrib/databases/__init__.py @@ -0,0 +1 @@ +default_app_config = 'orchestra.contrib.databases.apps.DatabasesConfig' diff --git a/orchestra/contrib/databases/admin.py b/orchestra/contrib/databases/admin.py new file mode 100644 index 0000000..4a18d6d --- /dev/null +++ b/orchestra/contrib/databases/admin.py @@ -0,0 +1,129 @@ +from django.urls import re_path as url +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.utils.html import format_html +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ + +from orchestra.admin import ExtendedModelAdmin, ChangePasswordAdminMixin +from orchestra.admin.utils import change_url +from orchestra.contrib.accounts.actions import list_accounts +from orchestra.contrib.accounts.admin import SelectAccountAdminMixin + +from .filters import HasUserListFilter, HasDatabaseListFilter +from .forms import DatabaseCreationForm, DatabaseUserChangeForm, DatabaseUserCreationForm, DatabaseForm +from .models import Database, DatabaseUser + +def save_selected(modeladmin, request, queryset): + for selected in queryset: + selected.save() +save_selected.short_description = "Re-save selected objects" + +class DatabaseAdmin(SelectAccountAdminMixin, ExtendedModelAdmin): + list_display = ('name', 'type', 'target_server', 'display_users', 'account_link') + list_filter = ('type', HasUserListFilter) + search_fields = ('name', 'account__username') + change_readonly_fields = ('name', 'type', 'target_server') + extra = 1 + fieldsets = ( + (None, { + 'classes': ('extrapretty',), + 'fields': ('account_link', 'name', 'type', 'users', 'display_users', 'comments', 'target_server'), + }), + ) + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('account_link', 'name', 'type', 'target_server') + }), + (_("Create new user"), { + 'classes': ('wide',), + 'fields': ('username', 'password1', 'password2'), + }), + (_("Use existing user"), { + 'classes': ('wide',), + 'fields': ('user',) + }), + ) + form = DatabaseForm + add_form = DatabaseCreationForm + readonly_fields = ('account_link', 'display_users',) + filter_horizontal = ['users'] + actions = (list_accounts, save_selected) + + @mark_safe + def display_users(self, db): + links = [] + for user in db.users.all(): + link = format_html('{}', change_url(user), user.username) + links.append(link) + return '
    '.join(links) + display_users.short_description = _("Users") + display_users.admin_order_field = 'users__username' + + def save_model(self, request, obj, form, change): + super(DatabaseAdmin, self).save_model(request, obj, form, change) + if not change: + user = form.cleaned_data['user'] + if not user: + user = DatabaseUser( + username=form.cleaned_data['username'], + type=obj.type, + account_id=obj.account.pk, + target_server=form.cleaned_data['target_server'], + ) + user.set_password(form.cleaned_data["password1"]) + user.save() + obj.users.add(user) + + +class DatabaseUserAdmin(SelectAccountAdminMixin, ChangePasswordAdminMixin, ExtendedModelAdmin): + list_display = ('username', 'target_server', 'type', 'display_databases', 'account_link') + list_filter = ('type', HasDatabaseListFilter) + search_fields = ('username', 'account__username') + form = DatabaseUserChangeForm + add_form = DatabaseUserCreationForm + change_readonly_fields = ('username', 'type', 'target_server') + fieldsets = ( + (None, { + 'classes': ('extrapretty',), + 'fields': ('account_link', 'username', 'password', 'type', 'display_databases', 'target_server', 'permision') + }), + ) + add_fieldsets = ( + (None, { + 'classes': ('extrapretty',), + 'fields': ('account_link', 'username', 'password1', 'password2', 'type', 'target_server', 'permision') + }), + ) + readonly_fields = ('account_link', 'display_databases',) + filter_by_account_fields = ('databases',) + list_prefetch_related = ('databases',) + actions = (list_accounts, save_selected) + + @mark_safe + def display_databases(self, user): + links = [] + for db in user.databases.all(): + link = format_html('{}', change_url(db), db.name) + links.append(link) + return '
    '.join(links) + display_databases.short_description = _("Databases") + display_databases.admin_order_field = 'databases__name' + + def get_urls(self): + useradmin = UserAdmin(DatabaseUser, self.admin_site) + return [ + url(r'^(\d+)/password/$', + self.admin_site.admin_view(useradmin.user_change_password)) + ] + super(DatabaseUserAdmin, self).get_urls() + + def save_model(self, request, obj, form, change): + """ set password """ + if not change: + obj.set_password(form.cleaned_data["password1"]) + super(DatabaseUserAdmin, self).save_model(request, obj, form, change) + + +admin.site.register(Database, DatabaseAdmin) +admin.site.register(DatabaseUser, DatabaseUserAdmin) diff --git a/orchestra/contrib/databases/api.py b/orchestra/contrib/databases/api.py new file mode 100644 index 0000000..808d02c --- /dev/null +++ b/orchestra/contrib/databases/api.py @@ -0,0 +1,23 @@ +from rest_framework import viewsets + +from orchestra.api import router, SetPasswordApiMixin, LogApiMixin +from orchestra.contrib.accounts.api import AccountApiMixin + +from .models import Database, DatabaseUser +from .serializers import DatabaseSerializer, DatabaseUserSerializer + + +class DatabaseViewSet(LogApiMixin, AccountApiMixin, viewsets.ModelViewSet): + queryset = Database.objects.prefetch_related('users').all() + serializer_class = DatabaseSerializer + filter_fields = ('name',) + + +class DatabaseUserViewSet(LogApiMixin, AccountApiMixin, SetPasswordApiMixin, viewsets.ModelViewSet): + queryset = DatabaseUser.objects.prefetch_related('databases').all() + serializer_class = DatabaseUserSerializer + filter_fields = ('username',) + + +router.register(r'databases', DatabaseViewSet) +router.register(r'databaseusers', DatabaseUserViewSet) diff --git a/orchestra/contrib/databases/apps.py b/orchestra/contrib/databases/apps.py new file mode 100644 index 0000000..87e8938 --- /dev/null +++ b/orchestra/contrib/databases/apps.py @@ -0,0 +1,14 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + +from orchestra.core import services + + +class DatabasesConfig(AppConfig): + name = 'orchestra.contrib.databases' + verbose_name = 'Databases' + + def ready(self): + from .models import Database, DatabaseUser + services.register(Database, icon='database.png') + services.register(DatabaseUser, icon='postgresql.png', verbose_name_plural=_("Database users")) diff --git a/orchestra/contrib/databases/backends.py b/orchestra/contrib/databases/backends.py new file mode 100644 index 0000000..ca48ff0 --- /dev/null +++ b/orchestra/contrib/databases/backends.py @@ -0,0 +1,193 @@ +import textwrap + +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.orchestration import ServiceController, replace +from orchestra.contrib.resources import ServiceMonitor + +from . import settings + + +class MySQLController(ServiceController): + """ + Simple backend for creating MySQL databases using CREATE DATABASE statement. + """ + verbose_name = "MySQL database" + model = 'databases.Database' + default_route_match = "database.type == 'mysql'" + doc_settings = (settings, + ('DATABASES_DEFAULT_HOST',) + ) + + def save(self, database): + if database.type != database.MYSQL: + return + context = self.get_context(database) + # Not available on delete() + context['owner'] = database.owner + self.append(textwrap.dedent(""" + # Create database and re-set permissions + mysql -e 'CREATE DATABASE `%(database)s`;' || true + mysql mysql -e 'DELETE FROM db WHERE db = "%(database)s";'\ + """) % context + ) + for user in database.users.all(): + context.update({ + 'username': user.username, + 'grant': 'WITH GRANT OPTION' if user == context['owner'] else '' + }) + if user.permision == "ro": + self.append(textwrap.dedent("""\ + mysql -e 'GRANT SELECT ON `%(database)s`.* TO "%(username)s"@"%(host)s" %(grant)s;'\ + """) % context + ) + else: + self.append(textwrap.dedent("""\ + mysql -e 'GRANT ALL PRIVILEGES ON `%(database)s`.* TO "%(username)s"@"%(host)s" %(grant)s;'\ + """) % context + ) + + def delete(self, database): + if database.type != database.MYSQL: + return + context = self.get_context(database) + self.append(textwrap.dedent(""" + # Remove database %(database)s + mysql -e 'DROP DATABASE `%(database)s`;' || exit_code=$? + mysql mysql -e 'DELETE FROM db WHERE db = "%(database)s";'\ + """) % context + ) + + def commit(self): + self.append(textwrap.dedent(""" + # Apply permissions + mysql -e 'FLUSH PRIVILEGES;'\ + """) + ) + super(MySQLController, self).commit() + + def get_context(self, database): + context = { + 'database': database.name, + 'host': settings.DATABASES_DEFAULT_HOST, + } + return replace(replace(context, "'", '"'), ';', '') + + +class MySQLUserController(ServiceController): + """ + Simple backend for creating MySQL users using CREATE USER statement. + """ + verbose_name = "MySQL user" + model = 'databases.DatabaseUser' + default_route_match = "databaseuser.type == 'mysql'" + doc_settings = (settings, + ('DATABASES_DEFAULT_HOST',) + ) + + def save(self, user): + if user.type != user.MYSQL: + return + context = self.get_context(user) + if user.target_server.name != "mysql.pangea.lan": + self.append(textwrap.dedent("""\ + # Create user %(username)s + mysql -e 'CREATE USER IF NOT EXISTS "%(username)s"@"%(host)s";' + mysql -e 'ALTER USER IF EXISTS "%(username)s"@"%(host)s" IDENTIFIED BY PASSWORD "%(password)s";'\ + """) % context + ) + else: + self.append(textwrap.dedent("""\ + # Create user %(username)s + mysql -e 'CREATE USER "%(username)s"@"%(host)s";' || true # User already exists + mysql -e 'UPDATE mysql.user SET Password="%(password)s" WHERE User="%(username)s";'\ + """) % context + ) + + def delete(self, user): + if user.type != user.MYSQL: + return + context = self.get_context(user) + self.append(textwrap.dedent(""" + # Delete user %(username)s + mysql -e 'DROP USER "%(username)s"@"%(host)s";' || exit_code=$? \ + """) % context + ) + + def commit(self): + self.append("# Apply permissions") + self.append("mysql -e 'FLUSH PRIVILEGES;'") + + def get_context(self, user): + context = { + 'username': user.username, + 'password': user.password, + 'host': settings.DATABASES_DEFAULT_HOST, + } + return replace(replace(context, "'", '"'), ';', '') + + +class MysqlDisk(ServiceMonitor): + """ + du -bs <database_path> + Implements triggers for resource limit exceeded and recovery, disabling insert and create privileges. + """ + model = 'databases.Database' + verbose_name = _("MySQL disk") + delete_old_equal_values = True + doc_settings = (settings, + ('DATABASES_MYSQL_DB_DIR',) + ) + mysql_db_dir = settings.DATABASES_MYSQL_DB_DIR + + def exceeded(self, db): + if db.type != db.MYSQL: + return + context = self.get_context(db) + self.append(textwrap.dedent("""\ + mysql -e 'UPDATE db SET Insert_priv="N", Create_priv="N" WHERE Db="%(db_name)s";'\ + """) % context + ) + + def recovery(self, db): + if db.type != db.MYSQL: + return + context = self.get_context(db) + self.append(textwrap.dedent("""\ + mysql -e 'UPDATE db SET Insert_priv="Y", Create_priv="Y" WHERE Db="%(db_name)s";'\ + """) % context + ) + + def prepare(self): + super().prepare() + context = { + 'mysql_db_dir': self.mysql_db_dir, + } + self.append(textwrap.dedent("""\ + function monitor_mysql () { + { SIZE=$(du -bs "%(mysql_db_dir)s/$1") && echo $SIZE || echo 0; } | awk {'print $1'} + }""") % context) + # Slower way + #self.append(textwrap.dedent("""\ + # function monitor () { + # mysql -B -e " + # SELECT IFNULL(sum(data_length + index_length), 0) 'Size' + # FROM information_schema.TABLES + # WHERE table_schema = '$1'; + # " | tail -n 1 + # }""")) + + def monitor(self, db): + if db.type != db.MYSQL: + return + context = self.get_context(db) + self.append('echo %(db_id)s $(monitor_%(db_type)s "%(db_dirname)s")' % context) + + def get_context(self, db): + context = { + 'db_name': db.name, + 'db_dirname': db.name.replace('-', '@002d'), + 'db_id': db.pk, + 'db_type': db.type, + } + return replace(replace(context, "'", '"'), ';', '') diff --git a/orchestra/contrib/databases/filters.py b/orchestra/contrib/databases/filters.py new file mode 100644 index 0000000..50136c7 --- /dev/null +++ b/orchestra/contrib/databases/filters.py @@ -0,0 +1,34 @@ +from django.contrib.admin import SimpleListFilter +from django.utils.translation import gettext_lazy as _ + + +class HasUserListFilter(SimpleListFilter): + """ Filter addresses whether they have any db user or not """ + title = _("has user") + parameter_name = 'has_user' + + def lookups(self, request, model_admin): + return ( + ('True', _("True")), + ('False', _("False")), + ) + + def queryset(self, request, queryset): + if self.value() == 'True': + return queryset.filter(users__isnull=False) + elif self.value() == 'False': + return queryset.filter(users__isnull=True) + return queryset + + +class HasDatabaseListFilter(HasUserListFilter): + """ Filter addresses whether they have any db or not """ + title = _("has database") + parameter_name = 'has_database' + + def queryset(self, request, queryset): + if self.value() == 'True': + return queryset.filter(databases__isnull=False) + elif self.value() == 'False': + return queryset.filter(databases__isnull=True) + return queryset diff --git a/orchestra/contrib/databases/forms.py b/orchestra/contrib/databases/forms.py new file mode 100644 index 0000000..f35f00c --- /dev/null +++ b/orchestra/contrib/databases/forms.py @@ -0,0 +1,153 @@ +from django import forms +from django.contrib.auth.forms import ReadOnlyPasswordHashField +from django.core.exceptions import ValidationError +from django.utils.html import format_html +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ + +from orchestra.core import validators + +from .models import DatabaseUser, Database + + +class DatabaseUserCreationForm(forms.ModelForm): + password1 = forms.CharField(label=_("Password"), required=False, + widget=forms.PasswordInput(attrs={'autocomplete': 'off'}), + validators=[validators.validate_password]) + password2 = forms.CharField(label=_("Password confirmation"), required=False, + widget=forms.PasswordInput, + help_text=_("Enter the same password as above, for verification.")) + + class Meta: + model = DatabaseUser + fields = ('username', 'account', 'type') + + def clean_password2(self): + password1 = self.cleaned_data.get("password1") + password2 = self.cleaned_data.get("password2") + if password1 and password2 and password1 != password2: + msg = _("The two password fields didn't match.") + raise ValidationError(msg) + return password2 + + +class DatabaseForm(forms.ModelForm): + + class Meta: + model = Database + fields = ('name', 'users', 'type', 'account', 'target_server') + + def __init__(self, *args, **kwargs): + super(DatabaseForm, self).__init__(*args, **kwargs) + # muestra solo los usuarios del mismo server + account_id = self.instance.account_id + database_server_id = self.instance.target_server_id + if account_id: + self.fields['users'].queryset = DatabaseUser.objects.filter(account=account_id, target_server=database_server_id) + + def clean(self): + # verifica que los usuarios petenecen al servidor de la bbdd + database_server_id = self.instance.target_server_id + users = self.cleaned_data.get('users') + if users and database_server_id: + for user in users: + if user.target_server_id != database_server_id: + self.add_error("users", _(f"{user.username} does not belong to the database server")) + + return self.cleaned_data + + +class DatabaseCreationForm(DatabaseUserCreationForm): + username = forms.CharField(label=_("Username"), max_length=16, + required=False, validators=[validators.validate_name], + help_text=_("Required. 16 characters or fewer. Letters, digits and " + "@/./+/-/_ only."), + error_messages={ + 'invalid': _("This value may contain 16 characters or fewer, only letters, numbers and " + "@/./+/-/_ characters.")}) + user = forms.ModelChoiceField(required=False, queryset=DatabaseUser.objects) + + class Meta: + model = Database + fields = ('username', 'account', 'type') + + def __init__(self, *args, **kwargs): + super(DatabaseCreationForm, self).__init__(*args, **kwargs) + account_id = self.initial.get('account', self.initial_account) + if account_id: + qs = self.fields['user'].queryset.filter(account=account_id).order_by('username') + choices = [ (u.pk, "%s (%s) (%s)" % (u, u.get_type_display(), str(u.target_server.name) )) for u in qs ] + self.fields['user'].queryset = qs + self.fields['user'].choices = [(None, '--------'),] + choices + + def clean_username(self): + username = self.cleaned_data.get('username') + server = self.cleaned_data.get('target_server') + if DatabaseUser.objects.filter(username=username, target_server=server).exists(): + raise ValidationError("Provided username already exists.") + return username + + def clean_password2(self): + username = self.cleaned_data.get('username') + password1 = self.cleaned_data.get('password1') + password2 = self.cleaned_data.get('password2') + if username and not (password1 and password2): + raise ValidationError(_("Missing password")) + if password1 and password2 and password1 != password2: + msg = _("The two password fields didn't match.") + raise ValidationError(msg) + return password2 + + def clean_user(self): + user = self.cleaned_data.get('user') + if user and user.type != self.cleaned_data.get('type'): + msg = _("Database type and user type doesn't match") + raise ValidationError(msg) + if user and user.target_server != self.cleaned_data.get('target_server'): + msg = _("Database server and user server doesn't match") + raise ValidationError(msg) + return user + + def clean(self): + cleaned_data = super(DatabaseCreationForm, self).clean() + if 'user' in cleaned_data and 'username' in cleaned_data: + msg = _("Use existing user or create a new one? you have provided both.") + if cleaned_data['user'] and self.cleaned_data['username']: + raise ValidationError(msg) + elif not (cleaned_data['username'] or cleaned_data['user']): + raise ValidationError(msg) + return cleaned_data + + +class ReadOnlySQLPasswordHashField(ReadOnlyPasswordHashField): + class ReadOnlyPasswordHashWidget(forms.Widget): + def render(self, name, value, attrs, renderer=None): + original = ReadOnlyPasswordHashField.widget().render(name, value, attrs) + if 'Invalid' not in original: + return original + encoded = value + if not encoded: + summary = mark_safe("%s" % _("No password set.")) + else: + size = len(value) + summary = value[:int(size/2)] + '*'*int(size-size/2) + summary = "hash: %s" % summary + if value.startswith('*'): + summary = "algorithm: sha1_bin_hex %s" % summary + return format_html("
    %s
    " % summary) + widget = ReadOnlyPasswordHashWidget + + +class DatabaseUserChangeForm(forms.ModelForm): + password = ReadOnlySQLPasswordHashField(label=_("Password"), + help_text=_("Raw passwords are not stored, so there is no way to see " + "this user's password, but you can change the password " + "using this form. " + "Show hash.")) + + class Meta: + model = DatabaseUser + fields = ('username', 'password', 'type', 'account') + + def clean_password(self): + return self.initial["password"] diff --git a/orchestra/contrib/databases/migrations/0001_initial.py b/orchestra/contrib/databases/migrations/0001_initial.py new file mode 100644 index 0000000..cf3f8f8 --- /dev/null +++ b/orchestra/contrib/databases/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# Generated by Django 2.2.28 on 2023-06-28 17:06 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import orchestra.core.validators + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='DatabaseUser', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('username', models.CharField(max_length=16, validators=[orchestra.core.validators.validate_name], verbose_name='username')), + ('password', models.CharField(max_length=256, verbose_name='password')), + ('type', models.CharField(choices=[('mysql', 'MySQL')], default='mysql', max_length=32, verbose_name='type')), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='databaseusers', to=settings.AUTH_USER_MODEL, verbose_name='Account')), + ], + options={ + 'verbose_name_plural': 'DB users', + 'unique_together': {('username', 'type')}, + }, + ), + migrations.CreateModel( + name='Database', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=64, validators=[orchestra.core.validators.validate_name], verbose_name='name')), + ('type', models.CharField(choices=[('mysql', 'MySQL')], default='mysql', max_length=32, verbose_name='type')), + ('comments', models.TextField(blank=True, default='')), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='databases', to=settings.AUTH_USER_MODEL, verbose_name='Account')), + ('users', models.ManyToManyField(blank=True, related_name='databases', to='databases.DatabaseUser', verbose_name='users')), + ], + options={ + 'unique_together': {('name', 'type')}, + }, + ), + ] diff --git a/orchestra/contrib/databases/migrations/0002_databaseuser_target_server.py b/orchestra/contrib/databases/migrations/0002_databaseuser_target_server.py new file mode 100644 index 0000000..3db3d0c --- /dev/null +++ b/orchestra/contrib/databases/migrations/0002_databaseuser_target_server.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.28 on 2023-06-28 17:11 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('orchestration', '__first__'), + ('databases', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='databaseuser', + name='target_server', + field=models.ForeignKey(default=3, on_delete=django.db.models.deletion.CASCADE, to='orchestration.Server', verbose_name='Target Server'), + ), + ] diff --git a/orchestra/contrib/databases/migrations/0003_auto_20230629_1838.py b/orchestra/contrib/databases/migrations/0003_auto_20230629_1838.py new file mode 100644 index 0000000..cd71691 --- /dev/null +++ b/orchestra/contrib/databases/migrations/0003_auto_20230629_1838.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.28 on 2023-06-29 16:38 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('orchestration', '__first__'), + ('databases', '0002_databaseuser_target_server'), + ] + + operations = [ + migrations.AddField( + model_name='databaseuser', + name='permision', + field=models.CharField(choices=[('all', 'all'), ('ro', 'read only')], default='all', max_length=20, verbose_name='Permisson'), + ), + migrations.AlterField( + model_name='databaseuser', + name='target_server', + field=models.ForeignKey(default=3, on_delete=django.db.models.deletion.CASCADE, to='orchestration.Server', verbose_name='Server'), + ), + migrations.AlterUniqueTogether( + name='databaseuser', + unique_together={('username', 'type', 'target_server')}, + ), + ] diff --git a/orchestra/contrib/databases/migrations/0004_database_target_server.py b/orchestra/contrib/databases/migrations/0004_database_target_server.py new file mode 100644 index 0000000..2868cbf --- /dev/null +++ b/orchestra/contrib/databases/migrations/0004_database_target_server.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.28 on 2023-06-29 16:57 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('orchestration', '__first__'), + ('databases', '0003_auto_20230629_1838'), + ] + + operations = [ + migrations.AddField( + model_name='database', + name='target_server', + field=models.ForeignKey(default=3, on_delete=django.db.models.deletion.CASCADE, to='orchestration.Server', verbose_name='Server'), + ), + ] diff --git a/orchestra/contrib/databases/migrations/0005_auto_20230705_1208.py b/orchestra/contrib/databases/migrations/0005_auto_20230705_1208.py new file mode 100644 index 0000000..5528568 --- /dev/null +++ b/orchestra/contrib/databases/migrations/0005_auto_20230705_1208.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2023-07-05 10:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('orchestration', '__first__'), + ('databases', '0004_database_target_server'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='database', + unique_together={('name', 'type', 'target_server')}, + ), + ] diff --git a/orchestra/contrib/databases/migrations/0006_auto_20230705_1237.py b/orchestra/contrib/databases/migrations/0006_auto_20230705_1237.py new file mode 100644 index 0000000..a086489 --- /dev/null +++ b/orchestra/contrib/databases/migrations/0006_auto_20230705_1237.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.28 on 2023-07-05 10:37 + +from django.db import migrations, models +import orchestra.core.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ('databases', '0005_auto_20230705_1208'), + ] + + operations = [ + migrations.AlterField( + model_name='databaseuser', + name='username', + field=models.CharField(max_length=32, validators=[orchestra.core.validators.validate_name], verbose_name='username'), + ), + ] diff --git a/orchestra/contrib/databases/migrations/__init__.py b/orchestra/contrib/databases/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestra/contrib/databases/models.py b/orchestra/contrib/databases/models.py new file mode 100644 index 0000000..528657f --- /dev/null +++ b/orchestra/contrib/databases/models.py @@ -0,0 +1,94 @@ +import hashlib + +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from orchestra.core import validators + +from . import settings + + +class Database(models.Model): + """ Represents a basic database for a web application """ + MYSQL = 'mysql' + POSTGRESQL = 'postgresql' + + name = models.CharField(_("name"), max_length=64, # MySQL limit + validators=[validators.validate_name]) + users = models.ManyToManyField('databases.DatabaseUser', blank=True, + verbose_name=_("users"),related_name='databases') + type = models.CharField(_("type"), max_length=32, + choices=settings.DATABASES_TYPE_CHOICES, + default=settings.DATABASES_DEFAULT_TYPE) + account = models.ForeignKey('accounts.Account', on_delete=models.CASCADE, + verbose_name=_("Account"), related_name='databases') + comments = models.TextField(default="", blank=True) + target_server = models.ForeignKey('orchestration.Server', on_delete=models.CASCADE, + verbose_name=_("Server"), default=3 ) + + class Meta: + unique_together = ('name', 'type', 'target_server') + + def __str__(self): + return "%s" % self.name + + @property + def owner(self): + """ database owner is the first user related to it """ + # Accessing intermediary model to get which is the first user + users = Database.users.through.objects.filter(database_id=self.id) + user = users.order_by('id').first() + if user is not None: + return user.databaseuser + return None + + @property + def active(self): + return self.account.is_active + + +Database.users.through._meta.unique_together = ( + ('database', 'databaseuser'), +) + + +class DatabaseUser(models.Model): + MYSQL = Database.MYSQL + POSTGRESQL = Database.POSTGRESQL + + typeOfPermision = [ + ('all','all'), + ('ro', 'read only'), + ] + + username = models.CharField(_("username"), max_length=32, # MySQL usernames 16 char long + validators=[validators.validate_name]) + password = models.CharField(_("password"), max_length=256) + type = models.CharField(_("type"), max_length=32, + choices=settings.DATABASES_TYPE_CHOICES, + default=settings.DATABASES_DEFAULT_TYPE) + account = models.ForeignKey('accounts.Account', on_delete=models.CASCADE, + verbose_name=_("Account"), related_name='databaseusers') + target_server = models.ForeignKey('orchestration.Server', on_delete=models.CASCADE, + verbose_name=_("Server"), default=3 ) + permision = models.CharField(verbose_name=_("Permisson"), max_length=20, choices=typeOfPermision, default='all') + + + class Meta: + verbose_name_plural = _("DB users") + unique_together = ('username', 'type', 'target_server') + + def __str__(self): + return self.username + + def get_username(self): + return self.username + + def set_password(self, password): + if self.type == self.MYSQL: + # MySQL stores sha1(sha1(password).binary).hex + binary = hashlib.sha1(password.encode('utf-8')).digest() + hexdigest = hashlib.sha1(binary).hexdigest() + self.password = '*%s' % hexdigest.upper() + else: + raise TypeError("Database type '%s' not supported" % self.type) diff --git a/orchestra/contrib/databases/serializers.py b/orchestra/contrib/databases/serializers.py new file mode 100644 index 0000000..7f55619 --- /dev/null +++ b/orchestra/contrib/databases/serializers.py @@ -0,0 +1,51 @@ +from rest_framework import serializers + +from orchestra.api.serializers import (HyperlinkedModelSerializer, + SetPasswordHyperlinkedSerializer, RelatedHyperlinkedModelSerializer) +from orchestra.contrib.accounts.serializers import AccountSerializerMixin + +from .models import Database, DatabaseUser + + +class RelatedDatabaseUserSerializer(AccountSerializerMixin, RelatedHyperlinkedModelSerializer): + class Meta: + model = DatabaseUser + fields = ('url', 'id', 'username') + + +class DatabaseSerializer(AccountSerializerMixin, HyperlinkedModelSerializer): + users = RelatedDatabaseUserSerializer(many=True) #allow_add_remove=True + + class Meta: + model = Database + fields = ('url', 'id', 'name', 'type', 'users') + postonly_fields = ('name', 'type') + + def validate(self, attrs): + attrs = super(DatabaseSerializer, self).validate(attrs) + for user in attrs['users']: + if user.type != attrs['type']: + raise serializers.ValidationError("User type must be" % attrs['type']) + return attrs + + +class RelatedDatabaseSerializer(AccountSerializerMixin, RelatedHyperlinkedModelSerializer): + class Meta: + model = Database + fields = ('url', 'id', 'name',) + + +class DatabaseUserSerializer(AccountSerializerMixin, SetPasswordHyperlinkedSerializer): + databases = RelatedDatabaseSerializer(many=True, required=False) # allow_add_remove=True + + class Meta: + model = DatabaseUser + fields = ('url', 'id', 'username', 'password', 'type', 'databases') + postonly_fields = ('username', 'type', 'password') + + def validate(self, attrs): + attrs = super(DatabaseUserSerializer, self).validate(attrs) + for database in attrs.get('databases', []): + if database.type != attrs['type']: + raise serializers.ValidationError("Database type must be" % attrs['type']) + return attrs diff --git a/orchestra/contrib/databases/settings.py b/orchestra/contrib/databases/settings.py new file mode 100644 index 0000000..473c48a --- /dev/null +++ b/orchestra/contrib/databases/settings.py @@ -0,0 +1,29 @@ +from orchestra.core.validators import validate_hostname + +from orchestra.contrib.settings import Setting + + +DATABASES_TYPE_CHOICES = Setting('DATABASES_TYPE_CHOICES', + ( + ('mysql', 'MySQL'), + ('postgres', 'PostgreSQL'), + ), + validators=[Setting.validate_choices] +) + + +DATABASES_DEFAULT_TYPE = Setting('DATABASES_DEFAULT_TYPE', + 'mysql', + choices=DATABASES_TYPE_CHOICES, +) + + +DATABASES_DEFAULT_HOST = Setting('DATABASES_DEFAULT_HOST', + 'localhost', +# validators=[validate_hostname], +) + + +DATABASES_MYSQL_DB_DIR = Setting('DATABASES_MYSQL_DB_DIR', + '/var/lib/mysql', +) diff --git a/orchestra/contrib/databases/tests/__init__.py b/orchestra/contrib/databases/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestra/contrib/databases/tests/functional_tests/__init__.py b/orchestra/contrib/databases/tests/functional_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestra/contrib/databases/tests/functional_tests/tests.py b/orchestra/contrib/databases/tests/functional_tests/tests.py new file mode 100644 index 0000000..3d89bff --- /dev/null +++ b/orchestra/contrib/databases/tests/functional_tests/tests.py @@ -0,0 +1,348 @@ +import os +import socket +import time +import unittest + +import MySQLdb +from django.conf import settings as djsettings +from django.core.management.base import CommandError +from django.urls import reverse +from orchestra.admin.utils import change_url +from orchestra.contrib.orchestration.models import Route, Server +from orchestra.utils.sys import sshrun +from orchestra.utils.tests import (BaseLiveServerTestCase, random_ascii, + save_response_on_error, snapshot_on_error) +from selenium.webdriver.support.select import Select + +from ... import backends, settings +from ...models import Database, DatabaseUser + +TEST_REST_API = int(os.getenv('TEST_REST_API', '0')) + + +class DatabaseTestMixin(object): + MASTER_SERVER = os.environ.get('ORCHESTRA_SECOND_SERVER', 'localhost') + DEPENDENCIES = ( + 'orchestra.contrib.orchestration', + 'orcgestra.apps.databases', + ) + + def setUp(self): + super(DatabaseTestMixin, self).setUp() + self.add_route() + djsettings.DEBUG = True + + def add_route(self): + raise NotImplementedError + + def save(self): + raise NotImplementedError + + def add(self): + raise NotImplementedError + + def delete(self): + raise NotImplementedError + + def update(self): + raise NotImplementedError + + def disable(self): + raise NotImplementedError + + def add_group(self, username, groupname): + raise NotImplementedError + + def test_add(self): + dbname = '%s_database' % random_ascii(5) + username = '%s_dbuser' % random_ascii(5) + password = '@!?%spppP001' % random_ascii(5) + self.add(dbname, username, password) + self.validate_create_table(dbname, username, password) + + def test_delete(self): + dbname = '%s_database' % random_ascii(5) + username = '%s_dbuser' % random_ascii(5) + password = '@!?%spppP001' % random_ascii(5) + self.add(dbname, username, password) + self.validate_create_table(dbname, username, password) + self.delete(dbname) + self.delete_user(username) + self.validate_delete(dbname, username, password) + self.validate_delete_user(dbname, username) + + def test_change_password(self): + dbname = '%s_database' % random_ascii(5) + username = '%s_dbuser' % random_ascii(5) + password = '@!?%spppP001' % random_ascii(5) + self.add(dbname, username, password) + self.addCleanup(self.delete, dbname) + self.addCleanup(self.delete_user, username) + self.validate_create_table(dbname, username, password) + new_password = '@!?%spppP001' % random_ascii(5) + self.change_password(username, new_password) + self.validate_login_error(dbname, username, password) + self.validate_create_table(dbname, username, new_password) + + def test_add_user(self): + dbname = '%s_database' % random_ascii(5) + username = '%s_dbuser' % random_ascii(5) + password = '@!?%spppP001' % random_ascii(5) + self.add(dbname, username, password) + self.addCleanup(self.delete, dbname) + self.addCleanup(self.delete_user, username) + self.validate_create_table(dbname, username, password) + username2 = '%s_dbuser' % random_ascii(5) + password2 = '@!?%spppP001' % random_ascii(5) + self.add_user(username2, password2) + self.addCleanup(self.delete_user, username2) + self.validate_login_error(dbname, username2, password2) + self.add_user_to_db(username2, dbname) + self.validate_create_table(dbname, username, password) + self.validate_create_table(dbname, username2, password2) + + def test_delete_user(self): + dbname = '%s_database' % random_ascii(5) + username = '%s_dbuser' % random_ascii(5) + password = '@!?%spppP001' % random_ascii(5) + self.add(dbname, username, password) + self.addCleanup(self.delete, dbname) + self.validate_create_table(dbname, username, password) + username2 = '%s_dbuser' % random_ascii(5) + password2 = '@!?%spppP001' % random_ascii(5) + self.add_user(username2, password2) + self.add_user_to_db(username2, dbname) + self.delete_user(username) + self.validate_delete_user(username, password) + self.validate_login_error(dbname, username, password) + self.validate_create_table(dbname, username2, password2) + self.delete_user(username2) + self.validate_login_error(dbname, username2, password2) + self.validate_delete_user(username2, password2) + + def test_swap_user(self): + dbname = '%s_database' % random_ascii(5) + username = '%s_dbuser' % random_ascii(5) + password = '@!?%spppP001' % random_ascii(5) + self.add(dbname, username, password) + self.addCleanup(self.delete, dbname) + self.addCleanup(self.delete_user, username) + self.validate_create_table(dbname, username, password) + username2 = '%s_dbuser' % random_ascii(5) + password2 = '@!?%spppP001' % random_ascii(5) + self.add_user(username2, password2) + self.addCleanup(self.delete_user, username2) + self.swap_user(username, username2, dbname) + self.validate_login_error(dbname, username, password) + self.validate_create_table(dbname, username2, password2) + + +class MySQLControllerMixin(object): + db_type = 'mysql' + + def setUp(self): + super(MySQLControllerMixin, self).setUp() + # Get local ip address used to reach self.MASTER_SERVER + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect((self.MASTER_SERVER, 22)) + settings.DATABASES_DEFAULT_HOST = s.getsockname()[0] + s.close() + + def add_route(self): + server = Server.objects.create(name=self.MASTER_SERVER) + backend = backends.MySQLController.get_name() + match = "database.type == '%s'" % self.db_type + Route.objects.create(backend=backend, match=match, host=server) + match = "databaseuser.type == '%s'" % self.db_type + backend = backends.MySQLUserController.get_name() + Route.objects.create(backend=backend, match=match, host=server) + + def validate_create_table(self, name, username, password): + db = MySQLdb.connect(host=self.MASTER_SERVER, port=3306, user=username, passwd=password, db=name) + cur = db.cursor() + cur.execute('CREATE TABLE table_%s ( id INT ) ;' % random_ascii(10)) + + def validate_login_error(self, dbname, username, password): + self.assertRaises(MySQLdb.OperationalError, + self.validate_create_table, dbname, username, password + ) + + def validate_delete(self, dbname, username, password): + self.validate_login_error(dbname, username, password) + self.assertRaises(CommandError, + sshrun, self.MASTER_SERVER, 'mysql %s' % dbname, display=False) + + def validate_delete_user(self, name, username): + context = { + 'name': name, + 'username': username, + } + self.assertEqual('', sshrun(self.MASTER_SERVER, + """mysql mysql -e 'SELECT * FROM db WHERE db="%(name)s";'""" % context, display=False).stdout) + self.assertEqual('', sshrun(self.MASTER_SERVER, + """mysql mysql -e 'SELECT * FROM user WHERE user="%(username)s";'""" % context, display=False).stdout) + + +@unittest.skipUnless(TEST_REST_API, "REST API tests") +class RESTDatabaseMixin(DatabaseTestMixin): + def setUp(self): + super(RESTDatabaseMixin, self).setUp() + self.rest_login() + + @save_response_on_error + def add(self, dbname, username, password): + user = self.rest.databaseusers.create(username=username, password=password, type=self.db_type) + users = [{ + 'username': user.username + }] + self.rest.databases.create(name=dbname, users=users, type=self.db_type) + + @save_response_on_error + def delete(self, dbname): + self.rest.databases.retrieve(name=dbname).delete() + + @save_response_on_error + def change_password(self, username, password): + user = self.rest.databaseusers.retrieve(username=username).get() + user.set_password(password) + + @save_response_on_error + def add_user(self, username, password): + self.rest.databaseusers.create(username=username, password=password, type=self.db_type) + + @save_response_on_error + def add_user_to_db(self, username, dbname): + user = self.rest.databaseusers.retrieve(username=username).get() + db = self.rest.databases.retrieve(name=dbname).get() + db.users.append(user) + db.save() + + @save_response_on_error + def delete_user(self, username): + self.rest.databaseusers.retrieve(username=username).delete() + + @save_response_on_error + def swap_user(self, username, username2, dbname): + user = self.rest.databaseusers.retrieve(username=username2).get() + db = self.rest.databases.retrieve(name=dbname).get() + db.users = db.users.exclude(username=username) + db.users.append(user) + db.save() + + +class AdminDatabaseMixin(DatabaseTestMixin): + def setUp(self): + super(AdminDatabaseMixin, self).setUp() + self.admin_login() + + @snapshot_on_error + def add(self, dbname, username, password): + url = self.live_server_url + reverse('admin:databases_database_add') + self.selenium.get(url) + + type_input = self.selenium.find_element_by_id('id_type') + type_select = Select(type_input) + type_select.select_by_value(self.db_type) + + name_field = self.selenium.find_element_by_id('id_name') + name_field.send_keys(dbname) + + username_field = self.selenium.find_element_by_id('id_username') + username_field.send_keys(username) + + password_field = self.selenium.find_element_by_id('id_password1') + password_field.send_keys(password) + password_field = self.selenium.find_element_by_id('id_password2') + password_field.send_keys(password) + + name_field.submit() + self.assertNotEqual(url, self.selenium.current_url) + + @snapshot_on_error + def delete(self, dbname): + db = Database.objects.get(name=dbname) + self.admin_delete(db) + + @snapshot_on_error + def change_password(self, username, password): + user = DatabaseUser.objects.get(username=username) + self.admin_change_password(user, password) + + @snapshot_on_error + def add_user(self, username, password): + url = self.live_server_url + reverse('admin:databases_databaseuser_add') + self.selenium.get(url) + + type_input = self.selenium.find_element_by_id('id_type') + type_select = Select(type_input) + type_select.select_by_value(self.db_type) + + username_field = self.selenium.find_element_by_id('id_username') + username_field.send_keys(username) + + password_field = self.selenium.find_element_by_id('id_password1') + password_field.send_keys(password) + password_field = self.selenium.find_element_by_id('id_password2') + password_field.send_keys(password) + + username_field.submit() + self.assertNotEqual(url, self.selenium.current_url) + + @snapshot_on_error + def add_user_to_db(self, username, dbname): + database = Database.objects.get(name=dbname, type=self.db_type) + url = self.live_server_url + change_url(database) + self.selenium.get(url) + + user = DatabaseUser.objects.get(username=username, type=self.db_type) + users_from = self.selenium.find_element_by_id('id_users_from') + users_select = Select(users_from) + users_select.select_by_value(str(user.pk)) + + add_user = self.selenium.find_element_by_id('id_users_add_link') + add_user.click() + + save = self.selenium.find_element_by_name('_save') + save.submit() + self.assertNotEqual(url, self.selenium.current_url) + + @snapshot_on_error + def swap_user(self, username, username2, dbname): + database = Database.objects.get(name=dbname, type=self.db_type) + url = self.live_server_url + change_url(database) + self.selenium.get(url) + + # remove user "username" + user = DatabaseUser.objects.get(username=username, type=self.db_type) + users_to = self.selenium.find_element_by_id('id_users_to') + users_select = Select(users_to) + users_select.select_by_value(str(user.pk)) + remove_user = self.selenium.find_element_by_id('id_users_remove_link') + remove_user.click() + time.sleep(0.2) + + # add user "username2" + user = DatabaseUser.objects.get(username=username2, type=self.db_type) + users_from = self.selenium.find_element_by_id('id_users_from') + users_select = Select(users_from) + users_select.select_by_value(str(user.pk)) + add_user = self.selenium.find_element_by_id('id_users_add_link') + add_user.click() + time.sleep(0.2) + + save = self.selenium.find_element_by_name('_save') + save.submit() + self.assertNotEqual(url, self.selenium.current_url) + + @snapshot_on_error + def delete_user(self, username): + user = DatabaseUser.objects.get(username=username) + self.admin_delete(user) + + +class RESTMysqlDatabaseTest(MySQLControllerMixin, RESTDatabaseMixin, BaseLiveServerTestCase): + pass + + +class AdminMysqlDatabaseTest(MySQLControllerMixin, AdminDatabaseMixin, BaseLiveServerTestCase): + pass diff --git a/orchestra/contrib/domains/__init__.py b/orchestra/contrib/domains/__init__.py new file mode 100644 index 0000000..5c85353 --- /dev/null +++ b/orchestra/contrib/domains/__init__.py @@ -0,0 +1 @@ +default_app_config = 'orchestra.contrib.domains.apps.DomainsConfig' diff --git a/orchestra/contrib/domains/actions.py b/orchestra/contrib/domains/actions.py new file mode 100644 index 0000000..0e1b222 --- /dev/null +++ b/orchestra/contrib/domains/actions.py @@ -0,0 +1,152 @@ +import copy + +from django.contrib import messages +from django.contrib.admin import helpers +from django.db.models import Q +from django.db.models.functions import Concat, Coalesce +from django.forms.models import modelformset_factory +from django.shortcuts import render +from django.utils.safestring import mark_safe +from django.utils.translation import ngettext, gettext_lazy as _ +from django.template.response import TemplateResponse + +from orchestra.admin.utils import get_object_from_url, change_url, admin_link +from orchestra.utils.python import AttrDict + +from .forms import RecordForm, RecordEditFormSet, SOAForm +from .models import Record + + +def view_zone(modeladmin, request, queryset): + zone = queryset.get() + context = { + 'opts': modeladmin.model._meta, + 'object': zone, + 'title': _("%s zone content") % zone.origin.name + } + return TemplateResponse(request, 'admin/domains/domain/view_zone.html', context) +view_zone.url_name = 'view-zone' +view_zone.short_description = _("View zone") + + +def edit_records(modeladmin, request, queryset): + selected_ids = queryset.values_list('id', flat=True) + # Include subodmains + queryset = queryset.model.objects.filter( + Q(top__id__in=selected_ids) | Q(id__in=selected_ids) + ).annotate( + structured_id=Coalesce('top__id', 'id'), + structured_name=Concat('top__name', 'name') + ).order_by('-structured_id', 'structured_name') + formsets = [] + for domain in queryset.prefetch_related('records'): + modeladmin_copy = copy.copy(modeladmin) + modeladmin_copy.model = Record + prefix = '' if domain.is_top else ' '*8 + context = { + 'url': change_url(domain), + 'name': prefix+domain.name, + 'title': '', + } + if domain.id not in selected_ids: + context['name'] += '*' + context['title'] = _("This subdomain was not explicitly selected " + "but has been automatically added to this list.") + link = '%(name)s' % context + modeladmin_copy.verbose_name_plural = mark_safe(link) + RecordFormSet = modelformset_factory( + Record, form=RecordForm, formset=RecordEditFormSet, extra=1, can_delete=True) + formset = RecordFormSet(queryset=domain.records.all(), prefix=domain.id) + formset.instance = domain + formset.cls = RecordFormSet + formsets.append(formset) + + if request.POST.get('post') == 'generic_confirmation': + posted_formsets = [] + all_valid = True + for formset in formsets: + instance = formset.instance + formset = formset.cls( + request.POST, request.FILES, queryset=formset.queryset, prefix=instance.id) + formset.instance = instance + if not formset.is_valid(): + all_valid = False + posted_formsets.append(formset) + formsets = posted_formsets + if all_valid: + for formset in formsets: + for form in formset.forms: + form.instance.domain_id = formset.instance.id + formset.save() + fake_form = AttrDict({ + 'changed_data': False + }) + change_message = modeladmin.construct_change_message(request, fake_form, [formset]) + modeladmin.log_change(request, formset.instance, change_message) + num = len(formsets) + message = ngettext( + _("Records for one selected domain have been updated."), + _("Records for %i selected domains have been updated.") % num, + num) + modeladmin.message_user(request, message) + return + + opts = modeladmin.model._meta + context = { + 'title': _("Edit records"), + 'action_name': _("Edit records"), + 'action_value': 'edit_records', + 'display_objects': [], + 'queryset': queryset, + 'opts': opts, + 'app_label': opts.app_label, + 'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME, + 'formsets': formsets, + 'obj': get_object_from_url(modeladmin, request), + } + return render(request, 'admin/domains/domain/edit_records.html', context) + + +def set_soa(modeladmin, request, queryset): + if queryset.filter(top__isnull=False).exists(): + msg = _("Set SOA on subdomains is not possible.") + modeladmin.message_user(request, msg, messages.ERROR) + return + form = SOAForm() + if request.POST.get('post') == 'generic_confirmation': + form = SOAForm(request.POST) + if form.is_valid(): + updates = {name: value for name, value in form.cleaned_data.items() if value} + change_message = _("SOA set %s") % str(updates)[1:-1] + for domain in queryset: + for name, value in updates.items(): + if name.startswith('clear_'): + name = name.replace('clear_', '') + value = '' + setattr(domain, name, value) + modeladmin.log_change(request, domain, change_message) + domain.save() + num = len(queryset) + msg = ngettext( + _("SOA record for one domain has been updated."), + _("SOA record for %s domains has been updated.") % num, + num + ) + modeladmin.message_user(request, msg) + return + opts = modeladmin.model._meta + context = { + 'title': _("Set SOA for selected domains"), + 'content_message': '', + 'action_name': _("Set SOA"), + 'action_value': 'set_soa', + 'display_objects': [admin_link('__str__')(domain) for domain in queryset], + 'queryset': queryset, + 'opts': opts, + 'app_label': opts.app_label, + 'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME, + 'form': form, + 'obj': get_object_from_url(modeladmin, request), + } + return render(request, 'admin/orchestra/generic_confirmation.html', context) +set_soa.short_description = _("Set SOA for selected domains") diff --git a/orchestra/contrib/domains/admin.py b/orchestra/contrib/domains/admin.py new file mode 100644 index 0000000..10994cf --- /dev/null +++ b/orchestra/contrib/domains/admin.py @@ -0,0 +1,227 @@ +from django.contrib import admin +from django.urls import reverse +from django.db import models +from django.db.models.functions import Concat, Coalesce +from django.templatetags.static import static +from django.utils.html import format_html +from django.utils.safestring import mark_safe +from django.utils.translation import gettext, gettext_lazy as _ + +from orchestra.admin import ExtendedModelAdmin +from orchestra.admin.utils import admin_link, change_url +from orchestra.contrib.accounts.actions import list_accounts +from orchestra.contrib.accounts.admin import AccountAdminMixin +from orchestra.utils import apps +from orchestra.utils.html import get_on_site_link + +from . import settings +from .actions import view_zone, edit_records, set_soa +from .filters import TopDomainListFilter, HasWebsiteFilter, HasAddressFilter +from .forms import RecordForm, RecordInlineFormSet, BatchDomainCreationAdminForm +from .models import Domain, Record + + +class RecordInline(admin.TabularInline): + model = Record + form = RecordForm + formset = RecordInlineFormSet + verbose_name_plural = _("Extra records") + + +class DomainInline(admin.TabularInline): + model = Domain + fields = ('domain_link', 'display_records', 'account_link') + readonly_fields = ('domain_link', 'display_records', 'account_link') + extra = 0 + verbose_name_plural = _("Subdomains") + + domain_link = admin_link('__str__') + domain_link.short_description = _("Name") + account_link = admin_link('account') + + def display_records(self, domain): + return ', '.join([record.type for record in domain.records.all()]) + display_records.short_description = _("Declared records") + + def has_add_permission(self, *args, **kwargs): + return False + + def get_queryset(self, request): + """ Order by structured name and imporve performance """ + qs = super(DomainInline, self).get_queryset(request) + return qs.select_related('account').prefetch_related('records') + + +class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin): + list_display = ( + 'structured_name', 'display_is_top', 'display_websites', 'display_addresses', 'account_link' + ) + add_fields = ('name', 'account') + fields = ('name', 'account_link', 'display_websites', 'display_addresses', 'dns2136_address_match_list') + readonly_fields = ( + 'account_link', 'top_link', 'display_websites', 'display_addresses', 'implicit_records' + ) + inlines = (RecordInline, DomainInline) + list_filter = (TopDomainListFilter, HasWebsiteFilter, HasAddressFilter) + change_readonly_fields = ('name', 'serial') + search_fields = ('name', 'account__username', 'records__value') + add_form = BatchDomainCreationAdminForm + actions = (edit_records, set_soa, list_accounts) + change_view_actions = (view_zone, edit_records) + + top_link = admin_link('top') + + def structured_name(self, domain): + if domain.is_top: + return domain.name + return mark_safe(' '*4 + domain.name) + structured_name.short_description = _("name") + structured_name.admin_order_field = 'structured_name' + + def display_is_top(self, domain): + return domain.is_top + display_is_top.short_description = _("Is top") + display_is_top.boolean = True + display_is_top.admin_order_field = 'top' + + @mark_safe + def display_websites(self, domain): + if apps.isinstalled('orchestra.contrib.websites'): + websites = domain.websites.all() + if websites: + links = [] + for website in websites: + site_link = get_on_site_link(website.get_absolute_url()) + admin_url = change_url(website) + title = _("Edit website") + link = format_html('{} {}', + admin_url, title, website.name, site_link) + links.append(link) + return '
    '.join(links) + add_url = reverse('admin:websites_website_add') + add_url += '?account=%i&domains=%i' % (domain.account_id, domain.pk) + add_link = format_html( + '', add_url, + _("Add website"), static('orchestra/images/add.png'), + ) + return _("No website %s") % (add_link) + return '---' + display_websites.admin_order_field = 'websites__name' + display_websites.short_description = _("Websites") + + @mark_safe + def display_addresses(self, domain): + if apps.isinstalled('orchestra.contrib.mailboxes'): + add_url = reverse('admin:mailboxes_address_add') + add_url += '?account=%i&domain=%i' % (domain.account_id, domain.pk) + image = '' % static('orchestra/images/add.png') + add_link = '%s' % ( + add_url, _("Add address"), image + ) + addresses = domain.addresses.all() + if addresses: + url = reverse('admin:mailboxes_address_changelist') + url += '?domain=%i' % addresses[0].domain_id + title = '\n'.join([address.email for address in addresses]) + return '%s %s' % (url, title, len(addresses), add_link) + return _("No address %s") % (add_link) + return '---' + display_addresses.short_description = _("Addresses") + display_addresses.admin_order_field = 'addresses__count' + + @mark_safe + def implicit_records(self, domain): + types = set(domain.records.values_list('type', flat=True)) + ttl = settings.DOMAINS_DEFAULT_TTL + lines = [] + for record in domain.get_default_records(): + line = '{name} {ttl} IN {type} {value}'.format( + name=domain.name, + ttl=ttl, + type=record.type, + value=record.value + ) + if not domain.record_is_implicit(record, types): + line = format_html('{}', line) + if record.type is Record.SOA: + lines.insert(0, line) + else: + lines.append(line) + return '
    '.join(lines) + implicit_records.short_description = _("Implicit records") + + def get_fieldsets(self, request, obj=None): + """ Add SOA fields when domain is top """ + fieldsets = super(DomainAdmin, self).get_fieldsets(request, obj) + if obj: + fieldsets += ( + (_("Implicit records"), { + 'classes': ('collapse',), + 'fields': ('implicit_records',), + }), + ) + if obj.is_top: + fieldsets += ( + (_("SOA"), { + 'classes': ('collapse',), + 'description': _( + "SOA (Start of Authority) records are used to determine how the " + "zone propagates to the secondary nameservers."), + 'fields': ('serial', 'refresh', 'retry', 'expire', 'min_ttl'), + }), + ) + else: + existing = fieldsets[0][1]['fields'] + if 'top_link' not in existing: + fieldsets[0][1]['fields'].insert(2, 'top_link') + return fieldsets + + def get_inline_instances(self, request, obj=None): + inlines = super(DomainAdmin, self).get_inline_instances(request, obj) + if not obj or not obj.is_top: + return [inline for inline in inlines if type(inline) != DomainInline] + return inlines + + def get_queryset(self, request): + """ Order by structured name and imporve performance """ + qs = super(DomainAdmin, self).get_queryset(request) + qs = qs.select_related('top', 'account') + if request.method == 'GET': + qs = qs.annotate( + structured_id=Coalesce('top__id', 'id'), + structured_name=Concat('top__name', 'name') + ).order_by('-structured_id', 'structured_name') + if apps.isinstalled('orchestra.contrib.websites'): + qs = qs.prefetch_related('websites__domains') + if apps.isinstalled('orchestra.contrib.mailboxes'): + qs = qs.annotate(models.Count('addresses')) + return qs + + def save_model(self, request, obj, form, change): + """ batch domain creation support """ + super(DomainAdmin, self).save_model(request, obj, form, change) + self.extra_domains = [] + if not change: + for name in form.extra_names: + domain = Domain.objects.create(name=name, account_id=obj.account_id) + self.extra_domains.append(domain) + + def save_related(self, request, form, formsets, change): + """ batch domain creation support """ + super(DomainAdmin, self).save_related(request, form, formsets, change) + if not change: + # Clone records to extra_domains, if any + for formset in formsets: + if formset.model is Record: + for domain in self.extra_domains: + # Reset pk value of the record instances to force creation of new ones + for record_form in formset.forms: + record = record_form.instance + if record.pk: + record.pk = None + formset.instance = domain + form.instance = domain + self.save_formset(request, form, formset, change) + + +admin.site.register(Domain, DomainAdmin) diff --git a/orchestra/contrib/domains/api.py b/orchestra/contrib/domains/api.py new file mode 100644 index 0000000..27a2545 --- /dev/null +++ b/orchestra/contrib/domains/api.py @@ -0,0 +1,38 @@ +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.response import Response + +from orchestra.api import router +from orchestra.contrib.accounts.api import AccountApiMixin + +from . import settings +from .models import Domain +from .serializers import DomainSerializer + + +class DomainViewSet(AccountApiMixin, viewsets.ModelViewSet): + serializer_class = DomainSerializer + filter_fields = ('name',) + queryset = Domain.objects.all() + + def get_queryset(self): + qs = super(DomainViewSet, self).get_queryset() + return qs.prefetch_related('records') + + @action(detail=True) + def view_zone(self, request, pk=None): + domain = self.get_object() + return Response({ + 'zone': domain.render_zone() + }) + + def options(self, request): + metadata = super(DomainViewSet, self).options(request) + names = ['DOMAINS_DEFAULT_A', 'DOMAINS_DEFAULT_MX', 'DOMAINS_DEFAULT_NS'] + metadata.data['settings'] = { + name.lower(): getattr(settings, name, None) for name in names + } + return metadata + + +router.register(r'domains', DomainViewSet) diff --git a/orchestra/contrib/domains/apps.py b/orchestra/contrib/domains/apps.py new file mode 100644 index 0000000..559166c --- /dev/null +++ b/orchestra/contrib/domains/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + +from orchestra.core import services + + +class DomainsConfig(AppConfig): + name = 'orchestra.contrib.domains' + verbose_name = 'Domains' + + def ready(self): + from .models import Domain + services.register(Domain, icon='domain.png') diff --git a/orchestra/contrib/domains/backends.py b/orchestra/contrib/domains/backends.py new file mode 100644 index 0000000..e160e61 --- /dev/null +++ b/orchestra/contrib/domains/backends.py @@ -0,0 +1,224 @@ +import re +import socket +import textwrap + +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.orchestration import ServiceController +from orchestra.contrib.orchestration import Operation +from orchestra.utils.python import OrderedSet + +from . import settings +from .models import Record, Domain + + +class Bind9MasterDomainController(ServiceController): + """ + Bind9 zone and config generation. + It auto-discovers slave Bind9 servers based on your routing configuration and NS servers. + """ + CONF_PATH = settings.DOMAINS_MASTERS_PATH + + verbose_name = _("Bind9 master domain") + model = 'domains.Domain' + related_models = ( + ('domains.Record', 'domain__origin'), + ('domains.Domain', 'origin'), + ) + ignore_fields = ('serial',) + doc_settings = (settings, + ('DOMAINS_MASTERS_PATH',) + ) + + @classmethod + def is_main(cls, obj): + """ work around Domain.top self relationship """ + if super(Bind9MasterDomainController, cls).is_main(obj): + return not obj.top + + def save(self, domain): + context = self.get_context(domain) + domain.refresh_serial() + self.update_zone(domain, context) + self.update_conf(context) + + def update_zone(self, domain, context): + context['zone'] = ';; %(banner)s\n' % context + context['zone'] += domain.render_zone() + self.append(textwrap.dedent("""\ + # Generate %(name)s zone file + cat << 'EOF' > %(zone_path)s.tmp + %(zone)s + EOF + diff -N -I'^\s*;;' %(zone_path)s %(zone_path)s.tmp || UPDATED=1 + # Because bind reload will not display any fucking error + named-checkzone -k fail -n fail %(name)s %(zone_path)s.tmp + mv %(zone_path)s.tmp %(zone_path)s\ + """) % context + ) + + def update_conf(self, context): + self.append(textwrap.dedent(""" + # Update bind config file for %(name)s + read -r -d '' conf << 'EOF' || true + %(conf)s + EOF + sed '/zone "%(name)s".*/,/^\s*};\s*$/!d' %(conf_path)s | diff -B -I"^\s*//" - <(echo "${conf}") || { + sed -i -e '/zone\s\s*"%(name)s".*/,/^\s*};/d' \\ + -e 'N; /^\s*\\n\s*$/d; P; D' %(conf_path)s + echo "${conf}" >> %(conf_path)s + UPDATED=1 + }""") % context + ) + self.append(textwrap.dedent("""\ + # Delete ex-top-domains that are now subdomains + sed -i -e '/zone\s\s*".*\.%(name)s".*/,/^\s*};\s*$/d' \\ + -e 'N; /^\s*\\n\s*$/d; P; D' %(conf_path)s""") % context + ) + if 'zone_path' in context: + context['zone_subdomains_path'] = re.sub(r'^(.*/)', r'\1*.', context['zone_path']) + self.append('rm -f -- %(zone_subdomains_path)s' % context) + + def delete(self, domain): + context = self.get_context(domain) + self.append('# Delete zone file for %(name)s' % context) + self.append('rm -f -- %(zone_path)s;' % context) + self.delete_conf(context) + + def delete_conf(self, context): + if context['name'][0] in ('*', '_'): + # These can never be top level domains + return + self.append(textwrap.dedent(""" + # Delete config for %(name)s + sed -e '/zone\s\s*"%(name)s".*/,/^\s*};\s*$/d' \\ + -e 'N; /^\s*\\n\s*$/d; P; D' %(conf_path)s > %(conf_path)s.tmp""") % context + ) + self.append('diff -B -I"^\s*//" %(conf_path)s.tmp %(conf_path)s || UPDATED=1' % context) + self.append('mv %(conf_path)s.tmp %(conf_path)s' % context) + + def commit(self): + """ reload bind if needed """ + self.append(textwrap.dedent(""" + # Apply changes + if [[ $UPDATED == 1 ]]; then + rm /etc/bind/master/*jnl || true; service bind9 restart + fi""") + ) + + def get_servers(self, domain, backend): + """ Get related server IPs from registered backend routes """ + from orchestra.contrib.orchestration.manager import router + operation = Operation(backend, domain, Operation.SAVE) + servers = [] + for route in router.objects.get_for_operation(operation): + servers.append(route.host.get_ip()) + return servers + + def get_masters_ips(self, domain): + ips = list(settings.DOMAINS_MASTERS) + if not ips: + ips += self.get_servers(domain, Bind9MasterDomainController) + return OrderedSet(sorted(ips)) + + def get_slaves(self, domain): + ips = [] + masters_ips = self.get_masters_ips(domain) + records = domain.get_records() + # Slaves from NS + for record in records.by_type(Record.NS): + hostname = record.value.rstrip('.') + # First try with a DNS query, a more reliable source + try: + addr = socket.gethostbyname(hostname) + except socket.gaierror: + # check if hostname is declared + try: + domain = Domain.objects.get(name=hostname) + except Domain.DoesNotExist: + continue + else: + # default to domain A record address + addr = records.by_type(Record.A)[0].value + if addr not in masters_ips: + ips.append(addr) + # Slaves from internal networks + if not settings.DOMAINS_MASTERS: + for server in self.get_servers(domain, Bind9SlaveDomainController): + ips.append(server) + return OrderedSet(sorted(ips)) + + def get_context(self, domain): + slaves = self.get_slaves(domain) + context = { + 'name': domain.name, + 'zone_path': settings.DOMAINS_ZONE_PATH % {'name': domain.name}, + 'subdomains': domain.subdomains.all(), + 'banner': self.get_banner(), + 'slaves': '; '.join(slaves) or 'none', + 'also_notify': '; '.join(slaves) + ';' if slaves else '', + 'conf_path': self.CONF_PATH, + 'dns2136_address_match_list': domain.dns2136_address_match_list + } + context['conf'] = textwrap.dedent("""\ + zone "%(name)s" { + // %(banner)s + type master; + file "%(zone_path)s"; + allow-transfer { %(slaves)s; }; + also-notify { %(also_notify)s }; + allow-update { %(dns2136_address_match_list)s }; + notify yes; + };""") % context + return context + + +class Bind9SlaveDomainController(Bind9MasterDomainController): + """ + Generate the configuartion for slave servers + It auto-discover the master server based on your routing configuration or you can use + DOMAINS_MASTERS to explicitly configure the master. + """ + CONF_PATH = settings.DOMAINS_SLAVES_PATH + + verbose_name = _("Bind9 slave domain") + related_models = ( + ('domains.Domain', 'origin'), + ) + doc_settings = (settings, + ('DOMAINS_MASTERS', 'DOMAINS_SLAVES_PATH') + ) + def save(self, domain): + context = self.get_context(domain) + self.update_conf(context) + + def delete(self, domain): + context = self.get_context(domain) + self.delete_conf(context) + + def commit(self): + self.append(textwrap.dedent(""" + # Apply changes + if [[ $UPDATED == 1 ]]; then + # Async restart, ideally after master + nohup bash -c 'sleep 1 && service bind9 reload' &> /dev/null & + fi""") + ) + + def get_context(self, domain): + context = { + 'name': domain.name, + 'banner': self.get_banner(), + 'subdomains': domain.subdomains.all(), + 'masters': '; '.join(self.get_masters_ips(domain)) or 'none', + 'conf_path': self.CONF_PATH, + } + context['conf'] = textwrap.dedent("""\ + zone "%(name)s" { + // %(banner)s + type slave; + file "%(name)s"; + masters { %(masters)s; }; + allow-notify { %(masters)s; }; + };""") % context + return context diff --git a/orchestra/contrib/domains/filters.py b/orchestra/contrib/domains/filters.py new file mode 100644 index 0000000..c12f48e --- /dev/null +++ b/orchestra/contrib/domains/filters.py @@ -0,0 +1,49 @@ +from django.contrib.admin import SimpleListFilter +from django.utils.translation import gettext_lazy as _ + + +class TopDomainListFilter(SimpleListFilter): + """ Filter Nodes by group according to request.user """ + title = _("top domains") + parameter_name = 'top_domain' + + def lookups(self, request, model_admin): + return ( + ('True', _("Top domains")), + ) + + def queryset(self, request, queryset): + if self.value() == 'True': + return queryset.filter(top__isnull=True) + + +class HasWebsiteFilter(SimpleListFilter): + """ Filter addresses whether they have any websites or not """ + title = _("has websites") + parameter_name = 'has_websites' + + def lookups(self, request, model_admin): + return ( + ('True', _("True")), + ('False', _("False")), + ) + + def queryset(self, request, queryset): + if self.value() == 'True': + return queryset.filter(websites__isnull=False) + elif self.value() == 'False': + return queryset.filter(websites__isnull=True) + return queryset + + +class HasAddressFilter(HasWebsiteFilter): + """ Filter addresses whether they have any addresses or not """ + title = _("has addresses") + parameter_name = 'has_addresses' + + def queryset(self, request, queryset): + if self.value() == 'True': + return queryset.filter(addresses__isnull=False) + elif self.value() == 'False': + return queryset.filter(addresses__isnull=True) + return queryset diff --git a/orchestra/contrib/domains/forms.py b/orchestra/contrib/domains/forms.py new file mode 100644 index 0000000..15db21c --- /dev/null +++ b/orchestra/contrib/domains/forms.py @@ -0,0 +1,164 @@ +from django import forms +from django.core.exceptions import ValidationError +from django.utils.text import capfirst +from django.utils.translation import gettext_lazy as _ + +from orchestra.admin.forms import AdminFormSet, AdminFormMixin + +from . import validators +from .helpers import domain_for_validation +from .models import Domain, Record + + +class BatchDomainCreationAdminForm(forms.ModelForm): + name = forms.CharField(label=_("Names"), widget=forms.Textarea(attrs={'rows': 5, 'cols': 50}), + help_text=_("Fully qualified domain name per line. " + "All domains will have the provided account and records.")) + + def clean_name(self): + self.extra_names = [] + target = None + existing = set() + errors = [] + domain_names = self.cleaned_data['name'].strip().splitlines() + for name in domain_names: + name = name.strip() + if not name: + continue + if name in existing: + errors.append(ValidationError(_("%s domain name provided multiple times.") % name)) + existing.add(name) + if target is None: + target = name + else: + domain = Domain(name=name) + try: + domain.full_clean(exclude=['top']) + except ValidationError as e: + for error in e.error_dict['name']: + for msg in error.messages: + errors.append( + ValidationError("%s: %s" % (name, msg)) + ) + self.extra_names.append(name) + if errors: + raise ValidationError(errors) + return target + + def clean(self): + """ inherit related parent domain account, when exists """ + cleaned_data = super().clean() + if not cleaned_data['account']: + account = None + domain_names = [] + if 'name' in cleaned_data: + first = cleaned_data['name'] + domain_names.append(first) + domain_names.extend(self.extra_names) + for name in domain_names: + parent = Domain.objects.get_parent(name) + if not parent: + # Fake an account to make django validation happy + account_model = self.fields['account']._queryset.model + cleaned_data['account'] = account_model() + raise ValidationError({ + 'account': _("An account should be provided for top domain names."), + }) + elif account and parent.account != account: + # Fake an account to make django validation happy + account_model = self.fields['account']._queryset.model + cleaned_data['account'] = account_model() + raise ValidationError({ + 'account': _("Provided domain names belong to different accounts."), + }) + account = parent.account + cleaned_data['account'] = account + return cleaned_data + + def full_clean(self): + # set extra_names on instance to use it on inline formsets validation + super().full_clean() + self.instance.extra_names = extra_names + + +class RecordForm(forms.ModelForm): + class Meta: + fields = ('ttl', 'type', 'value') + + def __init__(self, *args, **kwargs): + super(RecordForm, self).__init__(*args, **kwargs) + self.fields['ttl'].widget = forms.TextInput(attrs={'size': '10'}) + self.fields['value'].widget = forms.TextInput(attrs={'size': '100'}) + + +class ValidateZoneMixin(object): + def clean(self): + """ Checks if everything is consistent """ + super(ValidateZoneMixin, self).clean() + if any(self.errors): + return + is_host = True + for form in self.forms: + if form.cleaned_data.get('type') in (Record.TXT, Record.SRV, Record.CNAME): + is_host = False + break + domain_names = [] + if self.instance.name: + domain_names.append(self.instance.name) + domain_names.extend(getattr(self.instance, 'extra_names', [])) + errors = [] + for name in domain_names: + records = [] + for form in self.forms: + data = form.cleaned_data + if data and not data['DELETE']: + records.append(data) + if '_' in name and is_host: + errors.append(ValidationError( + _("%s: Hosts can not have underscore character '_', consider providing a SRV, CNAME or TXT record.") % name + )) + domain = domain_for_validation(self.instance, records) + try: + validators.validate_zone(domain.render_zone()) + except ValidationError as error: + for msg in error: + errors.append( + ValidationError("%s: %s" % (name, msg)) + ) + if errors: + raise ValidationError(errors) + + +class RecordEditFormSet(ValidateZoneMixin, AdminFormSet): + pass + + +class RecordInlineFormSet(ValidateZoneMixin, forms.models.BaseInlineFormSet): + pass + + +class SOAForm(AdminFormMixin, forms.Form): + refresh = forms.CharField() + clear_refresh = forms.BooleanField(label=_("Clear refresh"), required=False, + help_text=_("Remove custom refresh value for all selected domains.")) + retry = forms.CharField() + clear_retry = forms.BooleanField(label=_("Clear retry"), required=False, + help_text=_("Remove custom retry value for all selected domains.")) + expire = forms.CharField() + clear_expire = forms.BooleanField(label=_("Clear expire"), required=False, + help_text=_("Remove custom expire value for all selected domains.")) + min_ttl = forms.CharField() + clear_min_ttl = forms.BooleanField(label=_("Clear min TTL"), required=False, + help_text=_("Remove custom min TTL value for all selected domains.")) + + def __init__(self, *args, **kwargs): + super(SOAForm, self).__init__(*args, **kwargs) + for name in self.fields: + if not name.startswith('clear_'): + field = Domain._meta.get_field(name) + self.fields[name] = forms.CharField( + label=capfirst(field.verbose_name), + help_text=field.help_text, + validators=field.validators, + required=False, + ) diff --git a/orchestra/contrib/domains/helpers.py b/orchestra/contrib/domains/helpers.py new file mode 100644 index 0000000..a161fb8 --- /dev/null +++ b/orchestra/contrib/domains/helpers.py @@ -0,0 +1,32 @@ +import copy + +from .models import Domain, Record + + +def domain_for_validation(instance, records): + """ + Since the new data is not yet on the database, we update it on the fly, + so when validation calls render_zone() it will use the new provided data + """ + domain = copy.copy(instance) + def get_declared_records(records=records): + for data in records: + yield Record(type=data['type'], value=data['value']) + domain.get_declared_records = get_declared_records + + if not domain.pk: + # top domain lookup for new domains + domain.top = domain.get_parent(top=True) + if domain.top: + # is a subdomain + subdomains = domain.top.subdomains.select_related('top').prefetch_related('records').all() + subdomains = [sub for sub in subdomains if sub.pk != domain.pk] + domain.top.get_subdomains = lambda: subdomains + [domain] + elif not domain.pk: + # is a new top domain + subdomains = [] + for subdomain in Domain.objects.filter(name__endswith='.%s' % domain.name): + subdomain.top = domain + subdomains.append(subdomain) + domain.get_subdomains = lambda: subdomains + return domain diff --git a/orchestra/contrib/domains/models.py b/orchestra/contrib/domains/models.py new file mode 100644 index 0000000..9d099aa --- /dev/null +++ b/orchestra/contrib/domains/models.py @@ -0,0 +1,352 @@ +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import ngettext, gettext_lazy as _ + +from orchestra.core.validators import validate_ipv4_address, validate_ipv6_address, validate_ascii +from orchestra.utils.python import AttrDict + +from . import settings, validators, utils + + +class DomainQuerySet(models.QuerySet): + def get_parent(self, name, top=False): + """ get the next domain on the chain """ + split = name.split('.') + parent = None + for i in range(1, len(split)-1): + name = '.'.join(split[i:]) + domain = Domain.objects.filter(name=name) + if domain: + parent = domain.get() + if not top: + return parent + return parent + + +class Domain(models.Model): + name = models.CharField(_("name"), max_length=256, unique=True, db_index=True, + help_text=_("Domain or subdomain name."), + validators=[ + validators.validate_domain_name, + validators.validate_allowed_domain + ]) + account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), blank=True, + related_name='domains', on_delete=models.CASCADE, help_text=_("Automatically selected for subdomains.")) + top = models.ForeignKey('domains.Domain', null=True, related_name='subdomain_set', + editable=False, verbose_name=_("top domain"), on_delete=models.CASCADE) + serial = models.IntegerField(_("serial"), default=utils.generate_zone_serial, editable=False, + help_text=_("A revision number that changes whenever this domain is updated.")) + refresh = models.CharField(_("refresh"), max_length=16, blank=True, + validators=[validators.validate_zone_interval], + help_text=_("The time a secondary DNS server waits before querying the primary DNS " + "server's SOA record to check for changes. When the refresh time expires, " + "the secondary DNS server requests a copy of the current SOA record from " + "the primary. The primary DNS server complies with this request. " + "The secondary DNS server compares the serial number of the primary DNS " + "server's current SOA record and the serial number in it's own SOA record. " + "If they are different, the secondary DNS server will request a zone " + "transfer from the primary DNS server. " + "The default value is %s.") % settings.DOMAINS_DEFAULT_REFRESH) + retry = models.CharField(_("retry"), max_length=16, blank=True, + validators=[validators.validate_zone_interval], + help_text=_("The time a secondary server waits before retrying a failed zone transfer. " + "Normally, the retry time is less than the refresh time. " + "The default value is %s.") % settings.DOMAINS_DEFAULT_RETRY) + expire = models.CharField(_("expire"), max_length=16, blank=True, + validators=[validators.validate_zone_interval], + help_text=_("The time that a secondary server will keep trying to complete a zone " + "transfer. If this time expires prior to a successful zone transfer, " + "the secondary server will expire its zone file. This means the secondary " + "will stop answering queries. " + "The default value is %s.") % settings.DOMAINS_DEFAULT_EXPIRE) + min_ttl = models.CharField(_("min TTL"), max_length=16, blank=True, + validators=[validators.validate_zone_interval], + help_text=_("The minimum time-to-live value applies to all resource records in the " + "zone file. This value is supplied in query responses to inform other " + "servers how long they should keep the data in cache. " + "The default value is %s.") % settings.DOMAINS_DEFAULT_MIN_TTL) + dns2136_address_match_list = models.CharField(max_length=80, default=settings.DOMAINS_DEFAULT_DNS2136, + blank=True, + help_text="A bind-9 'address_match_list' that will be granted permission to perform " + "dns2136 updates. Chiefly used to enable Let's Encrypt self-service validation.") + + objects = DomainQuerySet.as_manager() + + def __str__(self): + return self.name + + @property + def origin(self): + return self.top or self + + @property + def is_top(self): + # don't cache, don't replace by top_id + try: + return not bool(self.top) + except Domain.DoesNotExist: + return False + + @property + def subdomains(self): + return Domain.objects.filter(name__regex='\.%s$' % self.name) + + def clean(self): + self.name = self.name.lower() + + def save(self, *args, **kwargs): + """ create top relation """ + update = False + if not self.pk: + top = self.get_parent(top=True) + if top: + self.top = top + self.account_id = self.account_id or top.account_id + else: + update = True + super(Domain, self).save(*args, **kwargs) + if update: + for domain in self.subdomains.exclude(pk=self.pk): + # queryset.update() is not used because we want to trigger backend to delete ex-topdomains + domain.top = self + domain.save(update_fields=('top',)) + + def get_description(self): + if self.is_top: + num = self.subdomains.count() + return ngettext( + _("top domain with one subdomain"), + _("top domain with %d subdomains") % num, + num) + return _("subdomain") + + def get_absolute_url(self): + return 'http://%s' % self.name + + def get_declared_records(self): + """ proxy method, needed for input validation, see helpers.domain_for_validation """ + return self.records.all() + + def get_subdomains(self): + """ proxy method, needed for input validation, see helpers.domain_for_validation """ + return self.origin.subdomain_set.all().prefetch_related('records') + + def get_parent(self, top=False): + return type(self).objects.get_parent(self.name, top=top) + + def render_zone(self): + origin = self.origin + zone = origin.render_records() + tail = [] + for subdomain in origin.get_subdomains(): + if subdomain.name.startswith('*'): + # This subdomains needs to be rendered last in order to avoid undesired matches + tail.append(subdomain) + else: + zone += subdomain.render_records() + ###darmengo 2021-03-25 add autoconfig + if self.has_default_mx(): + zone += 'autoconfig.{}. 30m IN A 109.69.8.133\n'.format(self.name) + ###END darmengo 2021-03-25 add autoconfig + for subdomain in sorted(tail, key=lambda x: len(x.name), reverse=True): + zone += subdomain.render_records() + return zone.strip() + + def refresh_serial(self): + """ Increases the domain serial number by one """ + serial = utils.generate_zone_serial() + if serial <= self.serial: + num = int(str(self.serial)[8:]) + 1 + if num >= 99: + raise ValueError('No more serial numbers for today') + serial = str(self.serial)[:8] + '%.2d' % num + serial = int(serial) + self.serial = serial + self.save(update_fields=('serial',)) + + def get_default_soa(self): + return ' '.join([ + "%s." % settings.DOMAINS_DEFAULT_NAME_SERVER, + utils.format_hostmaster(settings.DOMAINS_DEFAULT_HOSTMASTER), + str(self.serial), + self.refresh or settings.DOMAINS_DEFAULT_REFRESH, + self.retry or settings.DOMAINS_DEFAULT_RETRY, + self.expire or settings.DOMAINS_DEFAULT_EXPIRE, + self.min_ttl or settings.DOMAINS_DEFAULT_MIN_TTL, + ]) + + def get_default_records(self): + defaults = [] + if self.is_top: + for ns in settings.DOMAINS_DEFAULT_NS: + defaults.append(AttrDict( + type=Record.NS, + value=ns + )) + soa = self.get_default_soa() + defaults.insert(0, AttrDict( + type=Record.SOA, + value=soa + )) + for mx in settings.DOMAINS_DEFAULT_MX: + defaults.append(AttrDict( + type=Record.MX, + value=mx + )) + default_a = settings.DOMAINS_DEFAULT_A + if default_a: + defaults.append(AttrDict( + type=Record.A, + value=default_a + )) + default_aaaa = settings.DOMAINS_DEFAULT_AAAA + if default_aaaa: + defaults.append(AttrDict( + type=Record.AAAA, + value=default_aaaa + )) + return defaults + + def record_is_implicit(self, record, types): + if record.type not in types: + if record.type is Record.NS: + if self.is_top: + return True + elif record.type is Record.SOA: + if self.is_top: + return True + else: + has_a = Record.A in types + has_aaaa = Record.AAAA in types + is_host = self.is_top or not types or has_a or has_aaaa + if is_host: + if record.type is Record.MX: + return True + elif not has_a and not has_aaaa: + return True + return False + + def get_records(self): + types = set() + records = utils.RecordStorage() + for record in self.get_declared_records(): + types.add(record.type) + if record.type == record.SOA: + # Update serial and insert at 0 + value = record.value.split() + value[2] = str(self.serial) + records.insert(0, AttrDict( + type=record.SOA, + ttl=record.get_ttl(), + value=' '.join(value) + )) + else: + records.append(AttrDict( + type=record.type, + ttl=record.get_ttl(), + value=record.value + )) + for record in self.get_default_records(): + if self.record_is_implicit(record, types): + if record.type is Record.SOA: + records.insert(0, record) + else: + records.append(record) + return records + + def render_records(self): + result = '' + for record in self.get_records(): + name = '{name}.{spaces}'.format( + name=self.name, + spaces=' ' * (37-len(self.name)) + ) + ttl = record.get('ttl', settings.DOMAINS_DEFAULT_TTL) + ttl = '{spaces}{ttl}'.format( + spaces=' ' * (7-len(ttl)), + ttl=ttl + ) + type = '{type} {spaces}'.format( + type=record.type, + spaces=' ' * (7-len(record.type)) + ) + result += '{name} {ttl} IN {type} {value}\n'.format( + name=name, + ttl=ttl, + type=type, + value=record.value + ) + return result + + def has_default_mx(self): + records = self.get_records() + for record in records.by_type('MX'): + for default in settings.DOMAINS_DEFAULT_MX: + if record.value.endswith(' %s' % default.split()[-1]): + return True + return False + + +class Record(models.Model): + """ Represents a domain resource record """ + MX = 'MX' + NS = 'NS' + CNAME = 'CNAME' + A = 'A' + AAAA = 'AAAA' + SRV = 'SRV' + TXT = 'TXT' + SPF = 'SPF' + SOA = 'SOA' + + TYPE_CHOICES = ( + (MX, "MX"), + (NS, "NS"), + (CNAME, "CNAME"), + (A, _("A (IPv4 address)")), + (AAAA, _("AAAA (IPv6 address)")), + (SRV, "SRV"), + (TXT, "TXT"), + (SPF, "SPF"), + ) + + VALIDATORS = { + MX: (validators.validate_mx_record,), + NS: (validators.validate_zone_label,), + A: (validate_ipv4_address,), + AAAA: (validate_ipv6_address,), + CNAME: (validators.validate_zone_label,), + TXT: (validate_ascii, validators.validate_quoted_record), + SPF: (validate_ascii, validators.validate_quoted_record), + SRV: (validators.validate_srv_record,), + SOA: (validators.validate_soa_record,), + } + + domain = models.ForeignKey(Domain, verbose_name=_("domain"), related_name='records', on_delete=models.CASCADE) + ttl = models.CharField(_("TTL"), max_length=8, blank=True, + help_text=_("Record TTL, defaults to %s") % settings.DOMAINS_DEFAULT_TTL, + validators=[validators.validate_zone_interval]) + type = models.CharField(_("type"), max_length=32, choices=TYPE_CHOICES) + # max_length bumped from 256 to 1024 (arbitrary) on August 2019. + value = models.CharField(_("value"), max_length=1024, + help_text=_("MX, NS and CNAME records sould end with a dot.")) + + def __str__(self): + return "%s %s IN %s %s" % (self.domain, self.get_ttl(), self.type, self.value) + + def clean(self): + """ validates record value based on its type """ + # validate value + if self.type != self.TXT: + self.value = self.value.lower().strip() + if self.type: + for validator in self.VALIDATORS.get(self.type, []): + try: + validator(self.value) + except ValidationError as error: + raise ValidationError({ + 'value': error, + }) + + def get_ttl(self): + return self.ttl or settings.DOMAINS_DEFAULT_TTL diff --git a/orchestra/contrib/domains/serializers.py b/orchestra/contrib/domains/serializers.py new file mode 100644 index 0000000..7c6bebd --- /dev/null +++ b/orchestra/contrib/domains/serializers.py @@ -0,0 +1,82 @@ +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from orchestra.api.serializers import HyperlinkedModelSerializer +from orchestra.contrib.accounts.serializers import AccountSerializerMixin + +from .helpers import domain_for_validation +from .models import Domain, Record +from . import validators + + +class RecordSerializer(serializers.ModelSerializer): + class Meta: + model = Record + fields = ('type', 'value') + + def get_identity(self, data): + return data.get('value') + + +class DomainSerializer(AccountSerializerMixin, HyperlinkedModelSerializer): + """ Validates if this zone generates a correct zone file """ + records = RecordSerializer(required=False, many=True) + + class Meta: + model = Domain + fields = ('url', 'id', 'name', 'records') + postonly_fields = ('name',) + + def clean_name(self, attrs, source): + """ prevent users creating subdomains of other users domains """ + name = attrs[source] + parent = Domain.objects.get_parent(name) + if parent and parent.account != self.account: + raise ValidationError(_("Can not create subdomains of other users domains")) + return attrs + + def validate(self, data): + """ Checks if everything is consistent """ + data = super(DomainSerializer, self).validate(data) + name = data.get('name') + if name: + instance = self.instance + if instance is None: + instance = Domain(name=name, account=self.account) + records = data['records'] + domain = domain_for_validation(instance, records) + validators.validate_zone(domain.render_zone()) + return data + + def create(self, validated_data): + records = validated_data.pop('records') + domain = super(DomainSerializer, self).create(validated_data) + for record in records: + domain.records.create(type=record['type'], value=record['value']) + return domain + + def update(self, instance, validated_data): + precords = validated_data.pop('records') + domain = super(DomainSerializer, self).update(instance, validated_data) + to_delete = [] + for erecord in domain.records.all(): + match = False + for ix, precord in enumerate(precords): + if erecord.type == precord['type'] and erecord.value == precord['value']: + match = True + break + if match: + precords.pop(ix) + else: + to_delete.append(erecord) + for precord in precords: + try: + recycled = to_delete.pop() + except IndexError: + domain.records.create(type=precord['type'], value=precord['value']) + else: + recycled.type = precord['type'] + recycled.value = precord['value'] + recycled.save() + return domain diff --git a/orchestra/contrib/domains/settings.py b/orchestra/contrib/domains/settings.py new file mode 100644 index 0000000..78b5fe9 --- /dev/null +++ b/orchestra/contrib/domains/settings.py @@ -0,0 +1,127 @@ +from orchestra.contrib.settings import Setting +from orchestra.core.validators import validate_ipv4_address, validate_ipv6_address, validate_ip_address +from orchestra.settings import ORCHESTRA_BASE_DOMAIN + +from .validators import validate_zone_interval, validate_mx_record, validate_domain_name + + +DOMAINS_DEFAULT_NAME_SERVER = Setting('DOMAINS_DEFAULT_NAME_SERVER', + 'ns.{}'.format(ORCHESTRA_BASE_DOMAIN), + validators=[validate_domain_name], + help_text="Uses ORCHESTRA_BASE_DOMAIN by default." +) + + +DOMAINS_DEFAULT_HOSTMASTER = Setting('DOMAINS_DEFAULT_HOSTMASTER', + 'hostmaster@{}'.format(ORCHESTRA_BASE_DOMAIN), + help_text="Uses ORCHESTRA_BASE_DOMAIN by default." +) + + +DOMAINS_DEFAULT_TTL = Setting('DOMAINS_DEFAULT_TTL', + '1h', + validators=[validate_zone_interval], +) + + +DOMAINS_DEFAULT_REFRESH = Setting('DOMAINS_DEFAULT_REFRESH', + '1d', + validators=[validate_zone_interval], +) + + +DOMAINS_DEFAULT_RETRY = Setting('DOMAINS_DEFAULT_RETRY', + '2h', + validators=[validate_zone_interval], +) + + +DOMAINS_DEFAULT_EXPIRE = Setting('DOMAINS_DEFAULT_EXPIRE', + '4w', + validators=[validate_zone_interval], +) + + +DOMAINS_DEFAULT_MIN_TTL = Setting('DOMAINS_DEFAULT_MIN_TTL', + '1h', + validators=[validate_zone_interval], +) + + +DOMAINS_ZONE_PATH = Setting('DOMAINS_ZONE_PATH', + '/etc/bind/master/%(name)s' +) + + +DOMAINS_MASTERS_PATH = Setting('DOMAINS_MASTERS_PATH', + '/etc/bind/named.conf.local', +) + + +DOMAINS_SLAVES_PATH = Setting('DOMAINS_SLAVES_PATH', + '/etc/bind/named.conf.local', +) + + +DOMAINS_CHECKZONE_BIN_PATH = Setting('DOMAINS_CHECKZONE_BIN_PATH', + 'named-checkzone -i local -k fail -n fail', +) + + +DOMAINS_ZONE_VALIDATION_TMP_DIR = Setting('DOMAINS_ZONE_VALIDATION_TMP_DIR', + '/dev/shm', + help_text="Used for creating temporary zone files used for validation." +) + + +DOMAINS_DEFAULT_A = Setting('DOMAINS_DEFAULT_A', + '10.0.3.13', + validators=[validate_ipv4_address] +) + + +DOMAINS_DEFAULT_AAAA = Setting('DOMAINS_DEFAULT_AAAA', '', + validators=[validate_ipv6_address] +) + + +DOMAINS_DEFAULT_MX = Setting('DOMAINS_DEFAULT_MX', + default=( + '10 mail.{}.'.format(ORCHESTRA_BASE_DOMAIN), + '10 mail2.{}.'.format(ORCHESTRA_BASE_DOMAIN), + ), + validators=[lambda mxs: list(map(validate_mx_record, mxs))], + help_text="Uses ORCHESTRA_BASE_DOMAIN by default." +) + + +DOMAINS_DEFAULT_NS = Setting('DOMAINS_DEFAULT_NS', + default=( + 'ns1.{}.'.format(ORCHESTRA_BASE_DOMAIN), + 'ns2.{}.'.format(ORCHESTRA_BASE_DOMAIN), + ), + validators=[lambda nss: list(map(validate_domain_name, nss))], + help_text="Uses ORCHESTRA_BASE_DOMAIN by default." +) + + +DOMAINS_FORBIDDEN = Setting('DOMAINS_FORBIDDEN', + '', + help_text=( + "This setting prevents users from providing random domain names, i.e. google.com
    " + "You can generate a 5K forbidden domains list from Alexa's top 1M:
    " + " wget http://s3.amazonaws.com/alexa-static/top-1m.csv.zip -O /tmp/top-1m.csv.zip && " + "unzip -p /tmp/top-1m.csv.zip | head -n 5000 | sed 's/^.*,//' > forbidden_domains.list
    " + "'%(site_dir)s/forbidden_domains.list')" + ) +) + + +DOMAINS_MASTERS = Setting('DOMAINS_MASTERS', + (), + validators=[lambda masters: list(map(validate_ip_address, masters))], + help_text="Additional master server ip addresses other than autodiscovered by router.get_servers()." +) + +#TODO remove pangea-specific default +DOMAINS_DEFAULT_DNS2136 = "key pangea.key;" diff --git a/orchestra/contrib/domains/templates/admin/domains/domain/change_form.html b/orchestra/contrib/domains/templates/admin/domains/domain/change_form.html new file mode 100644 index 0000000..5bf4ffb --- /dev/null +++ b/orchestra/contrib/domains/templates/admin/domains/domain/change_form.html @@ -0,0 +1,15 @@ +{% extends "admin/change_form.html" %} +{% load i18n admin_urls static admin_modify %} + + +{% block object-tools-items %} +
  9. + {% trans "View zone" %} +
  10. +
  11. + {% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %} + {% trans "History" %} +
  12. +{% if has_absolute_url %}
  13. {% trans "View on site" %}
  14. {% endif%} +{% endblock %} + diff --git a/orchestra/contrib/domains/templates/admin/domains/domain/edit_records.html b/orchestra/contrib/domains/templates/admin/domains/domain/edit_records.html new file mode 100644 index 0000000..48d3a0b --- /dev/null +++ b/orchestra/contrib/domains/templates/admin/domains/domain/edit_records.html @@ -0,0 +1,20 @@ +{% extends "admin/orchestra/generic_confirmation.html" %} +{% load static %} + + +{% block extrahead %} +{{ block.super }} + + + + + + + +{% endblock %} + +{% block formset %} + {% for formset in formsets %} + {{ formset.as_admin }} + {% endfor %} +{% endblock %} diff --git a/orchestra/contrib/domains/templates/admin/domains/domain/view_zone.html b/orchestra/contrib/domains/templates/admin/domains/domain/view_zone.html new file mode 100644 index 0000000..838e107 --- /dev/null +++ b/orchestra/contrib/domains/templates/admin/domains/domain/view_zone.html @@ -0,0 +1,22 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls %} + +{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} delete-confirmation{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} + +
    +{{ object.render_zone }}
    +
    +{% endblock %} + diff --git a/orchestra/contrib/domains/tests/__init__.py b/orchestra/contrib/domains/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestra/contrib/domains/tests/functional_tests/__init__.py b/orchestra/contrib/domains/tests/functional_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestra/contrib/domains/tests/functional_tests/tests.py b/orchestra/contrib/domains/tests/functional_tests/tests.py new file mode 100644 index 0000000..f13342e --- /dev/null +++ b/orchestra/contrib/domains/tests/functional_tests/tests.py @@ -0,0 +1,325 @@ +import os +import time +import socket +from functools import partial + +from django.conf import settings as djsettings +from django.urls import reverse +from selenium.webdriver.support.select import Select + +from orchestra.contrib.orchestration.models import Server, Route +from orchestra.utils.tests import BaseLiveServerTestCase, random_ascii, snapshot_on_error, save_response_on_error +from orchestra.utils.sys import run + +from ... import settings, utils, backends +from ...models import Domain, Record + + +run = partial(run, display=False) + + +class DomainTestMixin(object): + MASTER_SERVER = os.environ.get('ORCHESTRA_MASTER_SERVER', 'localhost') + SLAVE_SERVER = os.environ.get('ORCHESTRA_SLAVE_SERVER', 'localhost') + MASTER_SERVER_ADDR = socket.gethostbyname(MASTER_SERVER) + SLAVE_SERVER_ADDR = socket.gethostbyname(SLAVE_SERVER) + + def setUp(self): + djsettings.DEBUG = True + super(DomainTestMixin, self).setUp() + self.domain_name = 'orchestra%s.lan' % random_ascii(10) + self.domain_records = ( + (Record.MX, '10 mail.orchestra.lan.'), + (Record.MX, '20 mail2.orchestra.lan.'), + (Record.NS, 'ns1.%s.' % self.domain_name), + (Record.NS, 'ns2.%s.' % self.domain_name), + ) + self.domain_update_records = ( + (Record.MX, '30 mail3.orchestra.lan.'), + (Record.MX, '40 mail4.orchestra.lan.'), + (Record.NS, 'ns1.%s.' % self.domain_name), + (Record.NS, 'ns2.%s.' % self.domain_name), + ) + self.ns1_name = 'ns1.%s' % self.domain_name + self.ns1_records = ( + (Record.A, self.SLAVE_SERVER_ADDR), + ) + self.ns2_name = 'ns2.%s' % self.domain_name + self.ns2_records = ( + (Record.A, self.MASTER_SERVER_ADDR), + ) + self.www_name = 'www.%s' % self.domain_name + self.www_records = ( + (Record.CNAME, 'external.server.org.'), + ) + self.django_domain_name = 'django%s.lan' % random_ascii(10) + + def add_route(self): + raise NotImplementedError + + def add(self, domain_name, records): + raise NotImplementedError + + def delete(self, domain_name, records): + raise NotImplementedError + + def update(self, domain_name, records): + raise NotImplementedError + + def validate_add(self, server_addr, domain_name): + context = { + 'domain_name': domain_name, + 'server_addr': server_addr + } + dig_soa = 'dig @%(server_addr)s %(domain_name)s SOA|grep "\sSOA\s"' + soa = run(dig_soa % context).stdout.split() + # testdomain.org. 3600 IN SOA ns.example.com. hostmaster.example.com. 2014021100 86400 7200 2419200 3600 + self.assertEqual('%(domain_name)s.' % context, soa[0]) + self.assertEqual('3600', soa[1]) + self.assertEqual('IN', soa[2]) + self.assertEqual('SOA', soa[3]) + self.assertEqual('%s.' % settings.DOMAINS_DEFAULT_NAME_SERVER, soa[4]) + hostmaster = utils.format_hostmaster(settings.DOMAINS_DEFAULT_HOSTMASTER) + self.assertEqual(hostmaster, soa[5]) + + dig_ns = 'dig @%(server_addr)s %(domain_name)s NS|grep "\sNS\s"' + name_servers = run(dig_ns % context).stdout + # testdomain.org. 3600 IN NS ns1.orchestra.lan. + ns_records = ['ns1.%s.' % self.domain_name, 'ns2.%s.' % self.domain_name] + self.assertEqual(2, len(name_servers.splitlines())) + for ns in name_servers.splitlines(): + ns = ns.split() + # testdomain.org. 3600 IN NS ns1.orchestra.lan. + self.assertEqual('%(domain_name)s.' % context, ns[0]) + self.assertEqual('3600', ns[1]) + self.assertEqual('IN', ns[2]) + self.assertEqual('NS', ns[3]) + self.assertIn(ns[4], ns_records) + + dig_mx = 'dig @%(server_addr)s %(domain_name)s MX|grep "\sMX\s"' + mail_servers = run(dig_mx % context).stdout + for mx in mail_servers.splitlines(): + mx = mx.split() + # testdomain.org. 3600 IN NS ns1.orchestra.lan. + self.assertEqual('%(domain_name)s.' % context, mx[0]) + self.assertEqual('3600', mx[1]) + self.assertEqual('IN', mx[2]) + self.assertEqual('MX', mx[3]) + self.assertIn(mx[4], ['10', '20']) + self.assertIn(mx[5], ['mail2.orchestra.lan.', 'mail.orchestra.lan.']) + + def validate_delete(self, server_addr, domain_name): + context = { + 'domain_name': domain_name, + 'server_addr': server_addr + } + dig_soa = 'dig @%(server_addr)s %(domain_name)s|grep "\sSOA\s"' + soa = run(dig_soa % context, valid_codes=(0, 1)).stdout + if soa: + soa = soa.split() + self.assertEqual('IN', soa[2]) + self.assertEqual('SOA', soa[3]) + self.assertNotEqual('%s.' % settings.DOMAINS_DEFAULT_NAME_SERVER, soa[4]) + hostmaster = utils.format_hostmaster(settings.DOMAINS_DEFAULT_HOSTMASTER) + self.assertNotEqual(hostmaster, soa[5]) + + def validate_update(self, server_addr, domain_name): + context = { + 'domain_name': domain_name, + 'server_addr': server_addr + } + dig_soa = 'dig @%(server_addr)s %(domain_name)s SOA | grep "\sSOA\s"' + soa = run(dig_soa % context).stdout.split() + # testdomain.org. 3600 IN SOA ns.example.com. hostmaster.example.com. 2014021100 86400 7200 2419200 3600 + self.assertEqual('%(domain_name)s.' % context, soa[0]) + self.assertEqual('3600', soa[1]) + self.assertEqual('IN', soa[2]) + self.assertEqual('SOA', soa[3]) + self.assertEqual('%s.' % settings.DOMAINS_DEFAULT_NAME_SERVER, soa[4]) + hostmaster = utils.format_hostmaster(settings.DOMAINS_DEFAULT_HOSTMASTER) + self.assertEqual(hostmaster, soa[5]) + + dig_ns = 'dig @%(server_addr)s %(domain_name)s NS |grep "\sNS\s"' + name_servers = run(dig_ns % context).stdout + ns_records = ['ns1.%s.' % self.domain_name, 'ns2.%s.' % self.domain_name] + self.assertEqual(2, len(name_servers.splitlines())) + for ns in name_servers.splitlines(): + ns = ns.split() + # testdomain.org. 3600 IN NS ns1.orchestra.lan. + self.assertEqual('%(domain_name)s.' % context, ns[0]) + self.assertEqual('3600', ns[1]) + self.assertEqual('IN', ns[2]) + self.assertEqual('NS', ns[3]) + self.assertIn(ns[4], ns_records) + + dig_mx = 'dig @%(server_addr)s %(domain_name)s MX | grep "\sMX\s"' + mx = run(dig_mx % context).stdout.split() + # testdomain.org. 3600 IN MX 10 orchestra.lan. + self.assertEqual('%(domain_name)s.' % context, mx[0]) + self.assertEqual('3600', mx[1]) + self.assertEqual('IN', mx[2]) + self.assertEqual('MX', mx[3]) + self.assertIn(mx[4], ['30', '40']) + self.assertIn(mx[5], ['mail3.orchestra.lan.', 'mail4.orchestra.lan.']) + + def validate_www_update(self, server_addr, domain_name): + context = { + 'domain_name': domain_name, + 'server_addr': server_addr + } + dig_cname = 'dig @%(server_addr)s www.%(domain_name)s CNAME | grep "\sCNAME\s"' + cname = run(dig_cname % context).stdout.split() + # testdomain.org. 3600 IN MX 10 orchestra.lan. + self.assertEqual('www.%(domain_name)s.' % context, cname[0]) + self.assertEqual('3600', cname[1]) + self.assertEqual('IN', cname[2]) + self.assertEqual('CNAME', cname[3]) + self.assertEqual('external.server.org.', cname[4]) + + def test_add(self): + self.add(self.ns1_name, self.ns1_records) + self.add(self.ns2_name, self.ns2_records) + self.add(self.domain_name, self.domain_records) +# self.addCleanup(self.delete, self.domain_name) + self.validate_add(self.MASTER_SERVER_ADDR, self.domain_name) + time.sleep(1) + self.validate_add(self.SLAVE_SERVER_ADDR, self.domain_name) + + def test_delete(self): + self.add(self.ns1_name, self.ns1_records) + self.add(self.ns2_name, self.ns2_records) + self.add(self.domain_name, self.domain_records) + self.delete(self.domain_name) + for name in [self.domain_name, self.ns1_name, self.ns2_name]: + self.validate_delete(self.MASTER_SERVER_ADDR, name) + self.validate_delete(self.SLAVE_SERVER_ADDR, name) + + def test_update(self): + self.add(self.ns1_name, self.ns1_records) + self.add(self.ns2_name, self.ns2_records) + self.add(self.domain_name, self.domain_records) + self.addCleanup(self.delete, self.domain_name) + self.update(self.domain_name, self.domain_update_records) + time.sleep(0.5) + self.validate_update(self.MASTER_SERVER_ADDR, self.domain_name) + time.sleep(5) + self.validate_update(self.SLAVE_SERVER_ADDR, self.domain_name) + self.add(self.www_name, self.www_records) + time.sleep(0.5) + self.validate_www_update(self.MASTER_SERVER_ADDR, self.domain_name) + time.sleep(5) + self.validate_www_update(self.SLAVE_SERVER_ADDR, self.domain_name) + + def test_add_add_delete_delete(self): + self.add(self.ns1_name, self.ns1_records) + self.add(self.ns2_name, self.ns2_records) + self.add(self.domain_name, self.domain_records) + self.add(self.django_domain_name, self.domain_records) + self.delete(self.domain_name) + self.validate_add(self.MASTER_SERVER_ADDR, self.django_domain_name) + self.validate_add(self.SLAVE_SERVER_ADDR, self.django_domain_name) + self.delete(self.django_domain_name) + self.validate_delete(self.MASTER_SERVER_ADDR, self.django_domain_name) + self.validate_delete(self.SLAVE_SERVER_ADDR, self.django_domain_name) + + def test_bad_creation(self): + self.assertRaises((self.rest.ResponseStatusError, AssertionError), + self.add, self.domain_name, self.domain_records) + + +class AdminDomainMixin(DomainTestMixin): + def setUp(self): + super(AdminDomainMixin, self).setUp() + self.add_route() + self.admin_login() + + def _add_records(self, records): + self.selenium.find_element_by_link_text('Add another Record').click() + for i, record in zip(range(0, len(records)), records): + type, value = record + type_input = self.selenium.find_element_by_id('id_records-%d-type' % i) + type_select = Select(type_input) + type_select.select_by_value(type) + value_input = self.selenium.find_element_by_id('id_records-%d-value' % i) + value_input.clear() + value_input.send_keys(value) + return value_input + + @snapshot_on_error + def add(self, domain_name, records): + add = reverse('admin:domains_domain_add') + url = self.live_server_url + add + self.selenium.get(url) + + name = self.selenium.find_element_by_id('id_name') + name.send_keys(domain_name) + + account_input = self.selenium.find_element_by_id('id_account') + account_select = Select(account_input) + account_select.select_by_value(str(self.account.pk)) + + value_input = self._add_records(records) + value_input.submit() + self.assertNotEqual(url, self.selenium.current_url) + + @snapshot_on_error + def delete(self, domain_name): + domain = Domain.objects.get(name=domain_name) + self.admin_delete(domain) + + @snapshot_on_error + def update(self, domain_name, records): + domain = Domain.objects.get(name=domain_name) + change = reverse('admin:domains_domain_change', args=(domain.pk,)) + url = self.live_server_url + change + self.selenium.get(url) + value_input = self._add_records(records) + value_input.submit() + self.assertNotEqual(url, self.selenium.current_url) + + +class RESTDomainMixin(DomainTestMixin): + def setUp(self): + super(RESTDomainMixin, self).setUp() + self.rest_login() + self.add_route() + + @save_response_on_error + def add(self, domain_name, records): + records = [ dict(type=type, value=value) for type,value in records ] + self.rest.domains.create(name=domain_name, records=records) + + @save_response_on_error + def delete(self, domain_name): + domain = Domain.objects.get(name=domain_name) + domain = self.rest.domains.retrieve(id=domain.pk) + domain.delete() + + @save_response_on_error + def update(self, domain_name, records): + records = [ dict(type=type, value=value) for type,value in records ] + domains = self.rest.domains.retrieve(name=domain_name) + domain = domains.get() + domain.update(records=records) + + +class Bind9BackendMixin(object): + DEPENDENCIES = ( + 'orchestra.contrib.orchestration', + ) + + def add_route(self): + master = Server.objects.create(name=self.MASTER_SERVER, address=self.MASTER_SERVER_ADDR) + backend = backends.Bind9MasterDomainController.get_name() + Route.objects.create(backend=backend, match=True, host=master) + slave = Server.objects.create(name=self.SLAVE_SERVER, address=self.SLAVE_SERVER_ADDR) + backend = backends.Bind9SlaveDomainController.get_name() + Route.objects.create(backend=backend, match=True, host=slave) + + +class RESTBind9BackendDomainTest(Bind9BackendMixin, RESTDomainMixin, BaseLiveServerTestCase): + pass + + +class AdminBind9BackendDomainTest(Bind9BackendMixin, AdminDomainMixin, BaseLiveServerTestCase): + pass diff --git a/orchestra/contrib/domains/tests/test_domains.py b/orchestra/contrib/domains/tests/test_domains.py new file mode 100644 index 0000000..f15ac2d --- /dev/null +++ b/orchestra/contrib/domains/tests/test_domains.py @@ -0,0 +1,18 @@ +from orchestra.utils.tests import BaseTestCase + +from ..models import Domain + + +class DomainTest(BaseTestCase): + def test_top_relation(self): + account = self.create_account() + domain = Domain.objects.create(name='rostrepalid.org', account=account) + Domain.objects.create(name='www.rostrepalid.org') + Domain.objects.create(name='mail.rostrepalid.org') + self.assertEqual(2, len(domain.subdomains.all())) + + def test_render_zone(self): + account = self.create_account() + domain = Domain.objects.create(name='rostrepalid.org', account=account) + domain.render_zone() + diff --git a/orchestra/contrib/domains/utils.py b/orchestra/contrib/domains/utils.py new file mode 100644 index 0000000..e644729 --- /dev/null +++ b/orchestra/contrib/domains/utils.py @@ -0,0 +1,52 @@ +from collections import defaultdict + +from django.utils import timezone + + +class RecordStorage(object): + """ + list-dict implementation for fast lookups of record types + """ + + def __init__(self, *args): + self.records = list(*args) + self.type = defaultdict(list) + + def __iter__(self): + return iter(self.records) + + def append(self, record): + self.records.append(record) + self.type[record['type']].append(record) + + def insert(self, ix, record): + self.records.insert(ix, record) + self.type[record['type']].insert(ix, record) + + def by_type(self, type): + return self.type[type] + + +def generate_zone_serial(): + today = timezone.now() + return int("%.4d%.2d%.2d%.2d" % (today.year, today.month, today.day, 0)) + + +def format_hostmaster(hostmaster): + """ + The DNS encodes the as a single label, and encodes the + as a domain name. The single label from the + is prefaced to the domain name from to form the domain + name corresponding to the mailbox. Thus the mailbox HOSTMASTER@SRI- + NIC.ARPA is mapped into the domain name HOSTMASTER.SRI-NIC.ARPA. If the + contains dots or other special characters, its + representation in a master file will require the use of backslash + quoting to ensure that the domain name is properly encoded. For + example, the mailbox Action.domains@ISI.EDU would be represented as + Action\.domains.ISI.EDU. + http://www.ietf.org/rfc/rfc1035.txt + """ + name, domain = hostmaster.split('@') + if '.' in name: + name = name.replace('.', '\.') + return "%s.%s." % (name, domain) diff --git a/orchestra/contrib/domains/validators.py b/orchestra/contrib/domains/validators.py new file mode 100644 index 0000000..4722493 --- /dev/null +++ b/orchestra/contrib/domains/validators.py @@ -0,0 +1,137 @@ +import logging +import os +import re + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +from orchestra.core.validators import validate_hostname +from orchestra.utils import paths +from orchestra.utils.sys import run + +from .. import domains + + +logger = logging.getLogger(__name__) + + +def validate_allowed_domain(value): + context = { + 'site_dir': paths.get_site_dir() + } + fname = domains.settings.DOMAINS_FORBIDDEN + if fname: + fname = fname % context + with open(fname, 'r') as forbidden: + for domain in forbidden.readlines(): + if re.match(r'^(.*\.)*%s$' % domain.strip(), value): + raise ValidationError(_("This domain name is not allowed")) + + +def validate_domain_name(value): + # SRV, CNAME and TXT records may use '_' in the domain name + value = value.lstrip('*.').replace('_', '') + try: + validate_hostname(value) + except ValidationError: + raise ValidationError(_("Not a valid domain name.")) + + +def validate_zone_interval(value): + try: + int(value) + except ValueError: + value, magnitude = value[:-1], value[-1] + if magnitude not in ('s', 'm', 'h', 'd', 'w') or not value.isdigit(): + msg = _("%s is not an appropiate zone interval value") % value + raise ValidationError(msg) + + +def validate_zone_label(value): + """ + Allowable characters in a label for a host name are only ASCII letters, digits, and the `-' character. + Labels may not be all numbers, but may have a leading digit (e.g., 3com.com). + Labels must end and begin only with a letter or digit. See [RFC 1035] and [RFC 1123]. + """ + if not re.match(r'^[a-z0-9][\.\-0-9a-z]*[\.0-9a-z]$', value): + msg = _("Labels must start and end with a letter or digit, " + "and have as interior characters only letters, digits, and hyphen.") + raise ValidationError(msg) + if not value.endswith('.'): + msg = _("Use a fully expanded domain name ending with a dot.") + raise ValidationError(msg) + if len(value) > 254: + raise ValidationError(_("Labels must be 63 characters or less.")) + + +def validate_mx_record(value): + msg = _("MX record format is 'priority domain.' tuple, with priority being a number.") + value = value.split() + if len(value) != 2: + raise ValidationError(msg) + else: + try: + int(value[0]) + except ValueError: + raise ValidationError(msg) + value = value[1] + validate_zone_label(value) + + +def validate_srv_record(value): + # 1 0 9 server.example.com. + msg = _("%s is not an appropiate SRV record value") % value + value = value.split() + for i in [0,1,2]: + try: + int(value[i]) + except ValueError: + raise ValidationError(msg) + validate_zone_label(value[-1]) + + +def validate_soa_record(value): + # ns1.pangea.ORG. hostmaster.pangea.ORG. 2012010401 28800 7200 604800 86400 + msg = _("%s is not an appropiate SRV record value") % value + values = value.split() + if len(values) != 7: + raise ValidationError(msg) + validate_zone_label(values[0]) + validate_zone_label(values[1]) + for value in values[2:]: + try: + int(value) + except ValueError: + raise ValidationError(msg) + + +def validate_quoted_record(value): + value = value.strip() + if ' ' in value and (value[0] != '"' or value[-1] != '"'): + raise ValidationError( + _("This record value contains spaces, you must enclose the string in double quotes; " + "otherwise, individual words will be separately quoted and break up the record " + "into multiple parts.") + ) + + +def validate_zone(zone): + """ Ultimate zone file validation using named-checkzone """ + zone_name = zone.split()[0][:-1] + zone_path = os.path.join(domains.settings.DOMAINS_ZONE_VALIDATION_TMP_DIR, zone_name) + checkzone = domains.settings.DOMAINS_CHECKZONE_BIN_PATH + try: + with open(zone_path, 'wb') as f: + f.write(zone.encode('ascii')) + # Don't use /dev/stdin becuase the 'argument list is too long' error + check = run(' '.join([checkzone, zone_name, zone_path]), valid_codes=(0,1,127), display=False) + finally: + try: + os.unlink(zone_path) + except FileNotFoundError: + pass + if check.exit_code == 127: + logger.error("Cannot validate domain zone: %s not installed." % checkzone) + elif check.exit_code == 1: + errors = re.compile(r'zone.*: (.*)').findall(check.stdout.decode('utf8'))[:-1] + raise ValidationError(', '.join(errors)) diff --git a/orchestra/contrib/history/__init__.py b/orchestra/contrib/history/__init__.py new file mode 100644 index 0000000..d39042d --- /dev/null +++ b/orchestra/contrib/history/__init__.py @@ -0,0 +1 @@ +default_app_config = 'orchestra.contrib.history.apps.HistoryConfig' diff --git a/orchestra/contrib/history/admin.py b/orchestra/contrib/history/admin.py new file mode 100644 index 0000000..903eeda --- /dev/null +++ b/orchestra/contrib/history/admin.py @@ -0,0 +1,130 @@ +from django.contrib import admin +from django.templatetags.static import static +from django.contrib.admin.templatetags.admin_urls import add_preserved_filters +from django.contrib.admin.utils import unquote +from django.http import HttpResponseRedirect +from django.urls import NoReverseMatch, reverse +from django.utils.html import format_html +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ + +from orchestra.admin.utils import admin_date, admin_link + + +class LogEntryAdmin(admin.ModelAdmin): + list_display = ( + 'display_action_time', 'user_link', 'display_message', + ) + list_filter = ( + 'action_flag', + ('user', admin.RelatedOnlyFieldListFilter), + ('content_type', admin.RelatedOnlyFieldListFilter), + ) + date_hierarchy = 'action_time' + search_fields = ('object_repr', 'change_message', 'user__username') + fields = ( + 'user_link', 'content_object_link', 'display_action_time', 'display_action', + 'change_message' + ) + readonly_fields = ( + 'user_link', 'content_object_link', 'display_action_time', 'display_action', + ) + actions = None + list_select_related = ('user', 'content_type') + list_display_links = None + + user_link = admin_link('user') + display_action_time = admin_date('action_time', short_description=_("Time")) + + @mark_safe + def display_message(self, log): + edit = format_html('', **{ + 'url': reverse('admin:admin_logentry_change', args=(log.pk,)), + 'img': static('admin/img/icon-changelink.svg'), + }) + if log.is_addition(): + return _('Added "%(link)s". %(edit)s') % { + 'link': self.content_object_link(log), + 'edit': edit + } + elif log.is_change(): + return _('Changed "%(link)s" - %(changes)s %(edit)s') % { + 'link': self.content_object_link(log), + 'changes': log.get_change_message(), + 'edit': edit, + } + elif log.is_deletion(): + return _('Deleted "%(object)s." %(edit)s') % { + 'object': log.object_repr, + 'edit': edit, + } + display_message.short_description = _("Message") + display_message.admin_order_field = 'action_flag' + + def display_action(self, log): + if log.is_addition(): + return _("Added") + elif log.is_change(): + return _("Changed") + return _("Deleted") + display_action.short_description = _("Action") + display_action.admin_order_field = 'action_flag' + + def content_object_link(self, log): + ct = log.content_type + view = 'admin:%s_%s_change' % (ct.app_label, ct.model) + try: + url = reverse(view, args=(log.object_id,)) + except NoReverseMatch: + return log.object_repr + return format_html('{}', url, log.object_repr) + content_object_link.short_description = _("Content object") + content_object_link.admin_order_field = 'object_repr' + + def render_change_form(self, request, context, add=False, change=False, form_url='', obj=None): + """ Add rel_opts and object to context """ + if not add and 'edit' in request.GET.urlencode(): + context.update({ + 'rel_opts': obj.content_type.model_class()._meta, + 'object': obj, + }) + return super(LogEntryAdmin, self).render_change_form( + request, context, add, change, form_url, obj) + + def response_change(self, request, obj): + """ save and continue preserve edit query string """ + response = super(LogEntryAdmin, self).response_change(request, obj) + if 'edit' in request.GET.urlencode() and 'edit' not in response.url: + return HttpResponseRedirect(response.url + '?edit=True') + return response + + def response_post_save_change(self, request, obj): + """ save redirect to object history """ + if 'edit' in request.GET.urlencode(): + opts = obj.content_type.model_class()._meta + view = 'admin:%s_%s_history' % (opts.app_label, opts.model_name) + post_url = reverse(view, args=(obj.object_id,)) + preserved_filters = self.get_preserved_filters(request) + post_url = add_preserved_filters({ + 'preserved_filters': preserved_filters, 'opts': opts + }, post_url) + return HttpResponseRedirect(post_url) + return super(LogEntryAdmin, self).response_post_save_change(request, obj) + + def has_add_permission(self, *args, **kwargs): + return False + + def has_delete_permission(self, *args, **kwargs): + return False + + def log_addition(self, *args, **kwargs): + pass + + def log_change(self, *args, **kwargs): + pass + + def log_deletion(self, *args, **kwargs): + pass + + +admin.site.register(admin.models.LogEntry, LogEntryAdmin) diff --git a/orchestra/contrib/history/apps.py b/orchestra/contrib/history/apps.py new file mode 100644 index 0000000..8d168e5 --- /dev/null +++ b/orchestra/contrib/history/apps.py @@ -0,0 +1,14 @@ +from django.apps import AppConfig + +from orchestra.core import administration + + +class HistoryConfig(AppConfig): + name = 'orchestra.contrib.history' + verbose_name = 'History' + + def ready(self): + from django.contrib.admin.models import LogEntry + administration.register( + LogEntry, verbose_name='History', verbose_name_plural='History', icon='History.png' + ) diff --git a/orchestra/contrib/history/templates/admin/admin/logentry/change_form.html b/orchestra/contrib/history/templates/admin/admin/logentry/change_form.html new file mode 100644 index 0000000..95c693c --- /dev/null +++ b/orchestra/contrib/history/templates/admin/admin/logentry/change_form.html @@ -0,0 +1,22 @@ +{% extends "admin/change_form.html" %} +{% load i18n admin_urls %} + +{% block object-tools-items %} +{% endblock %} + + +{% block breadcrumbs %} +{% if rel_opts %} + +{% else %} +{{ block.super }} +{% endif %} +{% endblock %} + diff --git a/orchestra/contrib/history/templates/admin/object_history.html b/orchestra/contrib/history/templates/admin/object_history.html new file mode 100644 index 0000000..9441b52 --- /dev/null +++ b/orchestra/contrib/history/templates/admin/object_history.html @@ -0,0 +1,43 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
    +
    + +{% if action_list %} + + + + + + + + + + {% for action in action_list %} + + + + + + {% endfor %} + +
    {% trans 'Date/time' %}{% trans 'User' %}{% trans 'Action' %}
    {{ action.action_time|date:"DATETIME_FORMAT" }}{{ action.user.get_username }}{% if action.user.get_full_name %} ({{ action.user.get_full_name }}){% endif %}{% if action.is_addition and not action.change_message %}{% trans 'Added' %}{% else %}{{ action.change_message }}{% endif %}
    +{% else %} +

    {% trans "This object doesn't have a change history. It probably wasn't added via this admin site." %}

    +{% endif %} +
    +
    +{% endblock %} + diff --git a/orchestra/contrib/issues/__init__.py b/orchestra/contrib/issues/__init__.py new file mode 100644 index 0000000..650ba7f --- /dev/null +++ b/orchestra/contrib/issues/__init__.py @@ -0,0 +1 @@ +default_app_config = 'orchestra.contrib.issues.apps.IssuesConfig' diff --git a/orchestra/contrib/issues/actions.py b/orchestra/contrib/issues/actions.py new file mode 100644 index 0000000..dac2976 --- /dev/null +++ b/orchestra/contrib/issues/actions.py @@ -0,0 +1,137 @@ +import sys + +from django.contrib import messages +from django.db import transaction +from django.utils.translation import ngettext, gettext_lazy as _ + +from orchestra.admin.decorators import action_with_confirmation + +from .forms import ChangeReasonForm +from .helpers import markdown_formated_changes +from .models import Queue, Ticket + + +def change_ticket_state_factory(action, verbose_name, final_state): + context = { + 'action': action, + 'form': ChangeReasonForm() + } + @transaction.atomic + @action_with_confirmation(action_name=action, extra_context=context) + def change_ticket_state(modeladmin, request, queryset, action=action, final_state=final_state): + form = ChangeReasonForm(request.POST) + if form.is_valid(): + reason = form.cleaned_data['reason'] + for ticket in queryset: + if ticket.state != final_state: + changes = { + 'state': (ticket.state, final_state) + } + is_read = ticket.is_read_by(request.user) + getattr(ticket, action)() + msg = _("Marked as %s") % final_state.lower() + modeladmin.log_change(request, ticket, msg) + content = markdown_formated_changes(changes) + content += reason + ticket.messages.create(content=content, author=request.user) + if is_read and not ticket.is_read_by(request.user): + ticket.mark_as_read_by(request.user) + context = { + 'count': queryset.count(), + 'state': final_state.lower() + } + msg = _("%(count)s selected tickets are now %(state)s.") % context + modeladmin.message_user(request, msg) + else: + context['form'] = form + # action_with_confirmation must display form validation errors + return True + change_ticket_state.url_name = action + change_ticket_state.tool_description = verbose_name + change_ticket_state.short_description = _('%s selected tickets') % verbose_name + change_ticket_state.help_text = _('Mark ticket as %s.') % final_state.lower() + change_ticket_state.__name__ = action + return change_ticket_state + + +action_map = { + Ticket.RESOLVED: ('resolve', _("Resolve")), + Ticket.REJECTED: ('reject', _("Reject")), + Ticket.CLOSED: ('close', _("Close")), +} + + +thismodule = sys.modules[__name__] +for state, names in action_map.items(): + name, verbose_name = names + action = change_ticket_state_factory(name, verbose_name, state) + setattr(thismodule, '%s_tickets' % name, action) + + +@transaction.atomic +def take_tickets(modeladmin, request, queryset): + for ticket in queryset: + if ticket.owner != request.user: + changes = { + 'owner': (ticket.owner, request.user) + } + is_read = ticket.is_read_by(request.user) + ticket.take(request.user) + modeladmin.log_change(request, ticket, _("Taken")) + content = markdown_formated_changes(changes) + ticket.messages.create(content=content, author=request.user) + if is_read and not ticket.is_read_by(request.user): + ticket.mark_as_read_by(request.user) + modeladmin.log_change(request, ticket, 'Taken') + context = { + 'count': queryset.count(), + 'user': request.user + } + msg = _("%(count)s selected tickets are now owned by %(user)s.") % context + modeladmin.message_user(request, msg) +take_tickets.url_name = 'take' +take_tickets.tool_description = _("Take") +take_tickets.short_description = _("Take selected tickets") +take_tickets.help_text = _("Make yourself owner of the ticket.") + + +@transaction.atomic +def mark_as_unread(modeladmin, request, queryset): + """ Mark a tickets as unread """ + for ticket in queryset: + ticket.mark_as_unread_by(request.user) + modeladmin.log_change(request, ticket, 'Marked as unread') + num = len(queryset) + msg = ngettext( + _("Selected ticket has been marked as unread."), + _("%i selected tickets have been marked as unread.") % num, + num) + modeladmin.message_user(request, msg) + + +@transaction.atomic +def mark_as_read(modeladmin, request, queryset): + """ Mark a tickets as unread """ + for ticket in queryset: + ticket.mark_as_read_by(request.user) + modeladmin.log_change(request, ticket, 'Marked as read') + num = len(queryset) + msg = ngettext( + _("Selected ticket has been marked as read."), + _("%i selected tickets have been marked as read.") % num, + num) + modeladmin.message_user(request, msg) + + +@transaction.atomic +def set_default_queue(modeladmin, request, queryset): + """ Set a queue as default issues queue """ + if queryset.count() != 1: + messages.warning(request, _("Please, select only one queue.")) + return + Queue.objects.filter(default=True).update(default=False) + queue = queryset.get() + queue.default = True + queue.save(update_fields=['default']) + modeladmin.log_change(request, queue, _("Chosen as default.")) + messages.info(request, _("Chosen '%s' as default queue.") % queue) diff --git a/orchestra/contrib/issues/admin.py b/orchestra/contrib/issues/admin.py new file mode 100644 index 0000000..25ae3f2 --- /dev/null +++ b/orchestra/contrib/issues/admin.py @@ -0,0 +1,323 @@ +from django import forms +from django.urls import re_path as url +from django.contrib import admin +from django.urls import reverse +from django.db import models +from django.http import HttpResponse +from django.shortcuts import get_object_or_404 +from django.utils.html import format_html, strip_tags +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ +from markdown import markdown + +from orchestra.admin import ExtendedModelAdmin +from orchestra.admin.utils import admin_link, admin_colored, wrap_admin_view, admin_date +from orchestra.contrib.contacts.models import Contact + +from .actions import (reject_tickets, resolve_tickets, take_tickets, close_tickets, + mark_as_unread, mark_as_read, set_default_queue) +from .filters import MyTicketsListFilter, TicketStateListFilter +from .forms import MessageInlineForm, TicketForm +from .helpers import get_ticket_changes, markdown_formated_changes, filter_actions +from .models import Ticket, Queue, Message + + +PRIORITY_COLORS = { + Ticket.HIGH: 'red', + Ticket.MEDIUM: 'darkorange', + Ticket.LOW: 'green', +} + + +STATE_COLORS = { + Ticket.NEW: 'grey', + Ticket.IN_PROGRESS: 'darkorange', + Ticket.FEEDBACK: 'purple', + Ticket.RESOLVED: 'green', + Ticket.REJECTED: 'firebrick', + Ticket.CLOSED: 'grey', +} + + +class MessageReadOnlyInline(admin.TabularInline): + model = Message + extra = 0 + can_delete = False + fields = ('content_html',) + readonly_fields = ('content_html',) + + class Media: + css = { + 'all': ('orchestra/css/hide-inline-id.css',) + } + + @mark_safe + def content_html(self, msg): + context = { + 'number': msg.number, + 'time': admin_date('created_at')(msg), + 'author': admin_link('author')(msg) if msg.author else msg.author_name, + } + summary = _("#%(number)i Updated by %(author)s about %(time)s") % context + header = '%s
    ' % summary + + content = markdown(msg.content) + content = content.replace('>\n', '>') + content = '
    %s
    ' % content + + return header + content + content_html.short_description = _("Content") + + def has_add_permission(self, request, obj): + return False + + def has_delete_permission(self, request, obj=None): + return False + + +class MessageInline(admin.TabularInline): + model = Message + extra = 1 + max_num = 1 + form = MessageInlineForm + can_delete = False + fields = ('content',) + + def get_formset(self, request, obj=None, **kwargs): + """ hook request.user on the inline form """ + self.form.user = request.user + return super(MessageInline, self).get_formset(request, obj, **kwargs) + + def get_queryset(self, request): + """ Don't show any message """ + qs = super(MessageInline, self).get_queryset(request) + return qs.none() + + +class TicketInline(admin.TabularInline): + fields = ( + 'ticket_id', 'subject', 'creator_link', 'owner_link', 'colored_state', + 'colored_priority', 'created', 'updated' + ) + readonly_fields = ( + 'ticket_id', 'subject', 'creator_link', 'owner_link', 'colored_state', + 'colored_priority', 'created', 'updated' + ) + model = Ticket + extra = 0 + max_num = 0 + + creator_link = admin_link('creator') + owner_link = admin_link('owner') + created = admin_link('created_at') + updated = admin_link('updated_at') + colored_state = admin_colored('state', colors=STATE_COLORS, bold=False) + colored_priority = admin_colored('priority', colors=PRIORITY_COLORS, bold=False) + + @mark_safe + def ticket_id(self, instance): + return '%s' % admin_link()(instance) + ticket_id.short_description = '#' + + +class TicketAdmin(ExtendedModelAdmin): + list_display = ( + 'unbold_id', 'bold_subject', 'display_creator', 'display_owner', + 'display_queue', 'display_priority', 'display_state', 'updated' + ) + list_display_links = ('unbold_id', 'bold_subject') + list_filter = ( + MyTicketsListFilter, 'queue', 'priority', TicketStateListFilter, + ) + default_changelist_filters = ( + ('state', 'OPEN'), + ) + date_hierarchy = 'created_at' + search_fields = ( + 'id', 'subject', 'creator__username', 'creator__email', 'queue__name', + 'owner__username' + ) + actions = ( + mark_as_unread, mark_as_read, reject_tickets, + resolve_tickets, close_tickets, take_tickets + ) + sudo_actions = ('delete_selected',) + change_view_actions = ( + resolve_tickets, close_tickets, reject_tickets, take_tickets + ) +# change_form_template = "admin/orchestra/change_form.html" + form = TicketForm + add_inlines = () + inlines = (MessageReadOnlyInline, MessageInline) + readonly_fields = ( + 'display_summary', 'display_queue', 'display_owner', 'display_state', + 'display_priority' + ) + readonly_fieldsets = ( + (None, { + 'fields': ('display_summary', + ('display_queue', 'display_owner'), + ('display_state', 'display_priority'), + 'display_description') + }), + ) + fieldsets = readonly_fieldsets + ( + ('Update', { + 'classes': ('collapse',), + 'fields': ('subject', + ('queue', 'owner',), + ('state', 'priority'), + 'description') + }), + ) + add_fieldsets = ( + (None, { + 'fields': ('subject', + ('queue', 'owner',), + ('state', 'priority'), + 'description') + }), + ) + list_select_related = ('queue', 'owner', 'creator') + + class Media: + css = { + 'all': ('issues/css/ticket-admin.css',) + } + js = ( + 'issues/js/ticket-admin.js', + ) + + display_creator = admin_link('creator') + display_queue = admin_link('queue') + display_owner = admin_link('owner') + updated = admin_date('updated_at') + display_state = admin_colored('state', colors=STATE_COLORS, bold=False) + display_priority = admin_colored('priority', colors=PRIORITY_COLORS, bold=False) + + @mark_safe + def display_summary(self, ticket): + context = { + 'creator': admin_link('creator')(self, ticket) if ticket.creator else ticket.creator_name, + 'created': admin_date('created_at')(ticket), + 'updated': '', + } + msg = ticket.messages.last() + if msg: + context.update({ + 'updated': admin_date('created_at')(msg), + 'updater': admin_link('author')(self, msg) if msg.author else msg.author_name, + }) + context['updated'] = '. Updated by %(updater)s about %(updated)s' % context + return '

    Added by %(creator)s about %(created)s%(updated)s

    ' % context + display_summary.short_description = 'Summary' + + def unbold_id(self, ticket): + """ Unbold id if ticket is read """ + if ticket.is_read_by(self.user): + return format_html('{}', ticket.pk) + return ticket.pk + unbold_id.short_description = "#" + unbold_id.admin_order_field = 'id' + + def bold_subject(self, ticket): + """ Bold subject when tickets are unread for request.user """ + if ticket.is_read_by(self.user): + return ticket.subject + return format_html("{}", ticket.subject) + bold_subject.short_description = _("Subject") + bold_subject.admin_order_field = 'subject' + + def formfield_for_dbfield(self, db_field, **kwargs): + """ Make value input widget bigger """ + if db_field.name == 'subject': + kwargs['widget'] = forms.TextInput(attrs={'size':'120'}) + return super(TicketAdmin, self).formfield_for_dbfield(db_field, **kwargs) + + def save_model(self, request, obj, *args, **kwargs): + """ Define creator for new tickets """ + if not obj.pk: + obj.creator = request.user + super(TicketAdmin, self).save_model(request, obj, *args, **kwargs) + obj.mark_as_read_by(request.user) + + def get_urls(self): + """ add markdown preview url """ + return [ + url(r'^preview/$', + wrap_admin_view(self, self.message_preview_view)) + ] + super(TicketAdmin, self).get_urls() + + def add_view(self, request, form_url='', extra_context=None): + """ Do not sow message inlines """ + return super(TicketAdmin, self).add_view(request, form_url, extra_context) + + def change_view(self, request, object_id, form_url='', extra_context=None): + """ Change view actions based on ticket state """ + ticket = get_object_or_404(Ticket, pk=object_id) + # Change view actions based on ticket state + self.change_view_actions = filter_actions(self, ticket, request) + if request.method == 'POST': + # Hack: Include the ticket changes on the request.POST + # other approaches get really messy + changes = get_ticket_changes(self, request, ticket) + if changes: + content = markdown_formated_changes(changes) + content += request.POST['messages-2-0-content'] + request.POST['messages-2-0-content'] = content + ticket.mark_as_read_by(request.user) + context = {'title': "Issue #%i - %s" % (ticket.id, ticket.subject)} + context.update(extra_context or {}) + return super(TicketAdmin, self).change_view(request, object_id, form_url=form_url, + extra_context=context) + + def changelist_view(self, request, extra_context=None): + # Hook user for bold_subject + self.user = request.user + return super(TicketAdmin,self).changelist_view(request, extra_context=extra_context) + + def message_preview_view(self, request): + """ markdown preview render via ajax """ + data = request.POST.get("data") + data_formated = markdown(strip_tags(data)) + return HttpResponse(data_formated) + + +class QueueAdmin(admin.ModelAdmin): + list_display = ('name', 'default', 'num_tickets') + actions = (set_default_queue,) + inlines = (TicketInline,) + ordering = ('name',) + + class Media: + css = { + 'all': ('orchestra/css/hide-inline-id.css',) + } + + def num_tickets(self, queue): + num = queue.tickets__count + url = reverse('admin:issues_ticket_changelist') + url += '?queue=%i' % queue.pk + return format_html('{}', url, num) + num_tickets.short_description = _("Tickets") + num_tickets.admin_order_field = 'tickets__count' + + def get_list_display(self, request): + """ show notifications """ + list_display = list(self.list_display) + for value, verbose in Contact.EMAIL_USAGES: + def display_notify(queue, notify=value): + return notify in queue.notify + display_notify.short_description = verbose + display_notify.boolean = True + list_display.append(display_notify) + return list_display + + def get_queryset(self, request): + qs = super(QueueAdmin, self).get_queryset(request) + qs = qs.annotate(models.Count('tickets')) + return qs + + +admin.site.register(Ticket, TicketAdmin) +admin.site.register(Queue, QueueAdmin) diff --git a/orchestra/contrib/issues/api.py b/orchestra/contrib/issues/api.py new file mode 100644 index 0000000..bbc5d5e --- /dev/null +++ b/orchestra/contrib/issues/api.py @@ -0,0 +1,44 @@ +from rest_framework import viewsets, mixins +from rest_framework.decorators import action +from rest_framework.response import Response + +from orchestra.api import router, LogApiMixin + +from .models import Ticket, Queue +from .serializers import TicketSerializer, QueueSerializer + + + +class TicketViewSet(LogApiMixin, viewsets.ModelViewSet): + queryset = Ticket.objects.all() + serializer_class = TicketSerializer + + @action(detail=True) + def mark_as_read(self, request, pk=None): + ticket = self.get_object() + ticket.mark_as_read_by(request.user) + return Response({'status': 'Ticket marked as read'}) + + @action(detail=True) + def mark_as_unread(self, request, pk=None): + ticket = self.get_object() + ticket.mark_as_unread_by(request.user) + return Response({'status': 'Ticket marked as unread'}) + + def get_queryset(self): + qs = super(TicketViewSet, self).get_queryset() + qs = qs.select_related('creator', 'queue') + qs = qs.prefetch_related('messages__author') + return qs.filter(creator=self.request.user) + + +class QueueViewSet(LogApiMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet): + queryset = Queue.objects.all() + serializer_class = QueueSerializer + + +router.register(r'tickets', TicketViewSet) +router.register(r'ticket-queues', QueueViewSet) diff --git a/orchestra/contrib/issues/apps.py b/orchestra/contrib/issues/apps.py new file mode 100644 index 0000000..c2c32de --- /dev/null +++ b/orchestra/contrib/issues/apps.py @@ -0,0 +1,15 @@ +from django.apps import AppConfig + +from orchestra.core import accounts, administration +from orchestra.core.translations import ModelTranslation + + +class IssuesConfig(AppConfig): + name = 'orchestra.contrib.issues' + verbose_name = "Issues" + + def ready(self): + from .models import Queue, Ticket + accounts.register(Ticket, icon='Ticket_star.png') + administration.register(Queue, dashboard=False) + ModelTranslation.register(Queue, ('verbose_name',)) diff --git a/orchestra/contrib/issues/filters.py b/orchestra/contrib/issues/filters.py new file mode 100644 index 0000000..d0431fe --- /dev/null +++ b/orchestra/contrib/issues/filters.py @@ -0,0 +1,49 @@ +from django.contrib.admin import SimpleListFilter +from django.utils.translation import gettext_lazy as _ + +from .models import Ticket + + +class MyTicketsListFilter(SimpleListFilter): + """ Filter tickets by created_by according to request.user """ + title = 'Tickets' + parameter_name = 'my_tickets' + + def lookups(self, request, model_admin): + return ( + ('True', _("My Tickets")), + ) + + def queryset(self, request, queryset): + if self.value() == 'True': + return queryset.involved_by(request.user) + + +class TicketStateListFilter(SimpleListFilter): + title = 'State' + parameter_name = 'state' + + def lookups(self, request, model_admin): + return ( + ('OPEN', _("Open")), + (Ticket.NEW, _("New")), + (Ticket.IN_PROGRESS, _("In Progress")), + (Ticket.RESOLVED, _("Resolved")), + (Ticket.FEEDBACK, _("Feedback")), + (Ticket.REJECTED, _("Rejected")), + (Ticket.CLOSED, _("Closed")), + ('False', _("All")), + ) + + def queryset(self, request, queryset): + if self.value() == 'OPEN': + return queryset.exclude(state__in=[Ticket.CLOSED, Ticket.REJECTED]) + elif self.value() == 'False': + return queryset + return queryset.filter(state=self.value()) + + def choices(self, cl): + """ Remove default All """ + choices = iter(super(TicketStateListFilter, self).choices(cl)) + next(choices) + return choices diff --git a/orchestra/contrib/issues/forms.py b/orchestra/contrib/issues/forms.py new file mode 100644 index 0000000..137e709 --- /dev/null +++ b/orchestra/contrib/issues/forms.py @@ -0,0 +1,112 @@ +from django import forms +from django.contrib.auth import get_user_model +from django.utils.html import strip_tags +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ +from django.templatetags.static import static +from markdown import markdown + +from orchestra.forms.widgets import SpanWidget + +from .models import Queue, Ticket + + +class MarkDownWidget(forms.Textarea): + """ MarkDown textarea widget with syntax preview """ + + markdown_url = static('issues/markdown_syntax.html') + markdown_help_text = ( + 'markdown format' % (markdown_url, markdown_url) + ) + markdown_help_text = 'HTML not allowed, you can use %s' % markdown_help_text + + def render(self, name, value, attrs, renderer=None): + widget_id = attrs['id'] if attrs and 'id' in attrs else 'id_%s' % name + textarea = super(MarkDownWidget, self).render(name, value, attrs) + preview = ('preview'\ + '
    '.format(widget_id)) + return mark_safe('

    %s
    %s
    %s

    ' % ( + self.markdown_help_text, textarea, preview)) + + +class MessageInlineForm(forms.ModelForm): + """ Add message form """ + created_on = forms.CharField(label="Created On", required=False) + content = forms.CharField(widget=MarkDownWidget(), required=False) + + class Meta: + fields = ('author', 'author_name', 'created_on', 'content') + + def __init__(self, *args, **kwargs): + super(MessageInlineForm, self).__init__(*args, **kwargs) + self.fields['created_on'].widget = SpanWidget(display='') + + def clean_content(self): + """ clean HTML tags """ + return strip_tags(self.cleaned_data['content']) + + def save(self, *args, **kwargs): + if self.instance.pk is None: + self.instance.author = self.user + return super(MessageInlineForm, self).save(*args, **kwargs) + + +class UsersIterator(forms.models.ModelChoiceIterator): + """ Group ticket owner by superusers, ticket.group and regular users """ + def __init__(self, *args, **kwargs): + self.ticket = kwargs.pop('ticket', False) + super(forms.models.ModelChoiceIterator, self).__init__(*args, **kwargs) + + def __iter__(self): + yield ('', '---------') + users = get_user_model().objects.exclude(is_active=False).order_by('name') + superusers = users.filter(is_superuser=True) + if superusers: + yield ('Operators', list(superusers.values_list('pk', 'name'))) + users = users.exclude(is_superuser=True) + if users: + yield ('Other', list(users.values_list('pk', 'name'))) + + +class TicketForm(forms.ModelForm): + display_description = forms.CharField(label=_("Description"), required=False) + description = forms.CharField(widget=MarkDownWidget(attrs={'class':'vLargeTextField'})) + + class Meta: + model = Ticket + fields = ( + 'creator', 'creator_name', 'owner', 'queue', 'subject', 'description', + 'priority', 'state', 'cc', 'display_description' + ) + + def __init__(self, *args, **kwargs): + super(TicketForm, self).__init__(*args, **kwargs) + ticket = kwargs.get('instance', False) + users = self.fields['owner'].queryset + self.fields['owner'].queryset = users.filter(is_superuser=True) + if not ticket: + # Provide default ticket queue for new ticket + try: + self.initial['queue'] = Queue.objects.get(default=True).id + except Queue.DoesNotExist: + pass + else: + description = markdown(ticket.description) + # some hacks for better line breaking + description = description.replace('>\n', '#Ha9G9-?8') + description = description.replace('\n', '
    ') + description = description.replace('#Ha9G9-?8', '>\n') + description = '
    %s
    ' % description + widget = SpanWidget(display=description) + self.fields['display_description'].widget = widget + + def clean_description(self): + """ clean HTML tags """ + return strip_tags(self.cleaned_data['description']) + + +class ChangeReasonForm(forms.Form): + reason = forms.CharField(widget=forms.Textarea(attrs={'cols': '100', 'rows': '10'}), + required=False) diff --git a/orchestra/contrib/issues/helpers.py b/orchestra/contrib/issues/helpers.py new file mode 100644 index 0000000..a7cceb3 --- /dev/null +++ b/orchestra/contrib/issues/helpers.py @@ -0,0 +1,40 @@ +def filter_actions(modeladmin, ticket, request): + if not hasattr(modeladmin, 'change_view_actions_backup'): + modeladmin.change_view_actions_backup = list(modeladmin.change_view_actions) + actions = modeladmin.change_view_actions_backup + if ticket.state == modeladmin.model.CLOSED: + del_actions = actions + else: + from .actions import action_map + del_actions = [action_map.get(ticket.state, None)] + if ticket.owner == request.user: + del_actions.append('take') + exclude = lambda a: not (a == action or a.url_name == action) + for action in del_actions: + actions = list(filter(exclude, actions)) + return actions + + +def markdown_formated_changes(changes): + markdown = '' + for name, values in changes.items(): + context = (name.capitalize(), values[0], values[1]) + markdown += '* **%s** changed from _%s_ to _%s_\n' % context + return markdown + '\n' + + +def get_ticket_changes(modeladmin, request, ticket): + ModelForm = modeladmin.get_form(request, ticket) + form = ModelForm(request.POST, request.FILES) + changes = {} + if form.is_valid(): + for attr in ['state', 'priority', 'owner', 'queue']: + old_value = getattr(ticket, attr) + new_value = form.cleaned_data[attr] + if old_value != new_value: + choices = dict(form.fields[attr].choices) + if old_value in choices: + old_value = choices[old_value] + new_value = choices[new_value] + changes[attr] = (old_value, new_value) + return changes diff --git a/orchestra/contrib/issues/models.py b/orchestra/contrib/issues/models.py new file mode 100644 index 0000000..717f3ac --- /dev/null +++ b/orchestra/contrib/issues/models.py @@ -0,0 +1,202 @@ +from django.conf import settings as djsettings +from django.db import models +from django.db.models import query, Q +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.contacts import settings as contacts_settings +from orchestra.contrib.contacts.models import Contact +from orchestra.models.fields import MultiSelectField +from orchestra.utils.mail import send_email_template + +from . import settings + + +class Queue(models.Model): + name = models.CharField(_("name"), max_length=128, unique=True) + verbose_name = models.CharField(_("verbose_name"), max_length=128, blank=True) + default = models.BooleanField(_("default"), default=False) + notify = MultiSelectField(_("notify"), max_length=256, blank=True, + choices=Contact.EMAIL_USAGES, + default=contacts_settings.CONTACTS_DEFAULT_EMAIL_USAGES, + help_text=_("Contacts to notify by email")) + + def __str__(self): + return self.verbose_name or self.name + + def save(self, *args, **kwargs): + """ mark as default queue if needed """ + existing_default = Queue.objects.filter(default=True) + if self.default: + existing_default.update(default=False) + elif not existing_default: + self.default = True + super(Queue, self).save(*args, **kwargs) + + +class TicketQuerySet(query.QuerySet): + def involved_by(self, user, *args, **kwargs): + qset = Q(creator=user) | Q(owner=user) | Q(messages__author=user) + return self.filter(qset, *args, **kwargs).distinct() + + +class Ticket(models.Model): + HIGH = 'HIGH' + MEDIUM = 'MEDIUM' + LOW = 'LOW' + PRIORITIES = ( + (HIGH, 'High'), + (MEDIUM, 'Medium'), + (LOW, 'Low'), + ) + + NEW = 'NEW' + IN_PROGRESS = 'IN_PROGRESS' + RESOLVED = 'RESOLVED' + FEEDBACK = 'FEEDBACK' + REJECTED = 'REJECTED' + CLOSED = 'CLOSED' + STATES = ( + (NEW, 'New'), + (IN_PROGRESS, 'In Progress'), + (RESOLVED, 'Resolved'), + (FEEDBACK, 'Feedback'), + (REJECTED, 'Rejected'), + (CLOSED, 'Closed'), + ) + + creator = models.ForeignKey(djsettings.AUTH_USER_MODEL, verbose_name=_("created by"), + related_name='tickets_created', null=True, on_delete=models.SET_NULL) + creator_name = models.CharField(_("creator name"), max_length=256, blank=True) + owner = models.ForeignKey(djsettings.AUTH_USER_MODEL, null=True, blank=True, + on_delete=models.SET_NULL, + related_name='tickets_owned', verbose_name=_("assigned to")) + queue = models.ForeignKey(Queue, related_name='tickets', null=True, blank=True, + on_delete=models.SET_NULL) + subject = models.CharField(_("subject"), max_length=256) + description = models.TextField(_("description")) + priority = models.CharField(_("priority"), max_length=32, choices=PRIORITIES, default=MEDIUM) + state = models.CharField(_("state"), max_length=32, choices=STATES, default=NEW) + created_at = models.DateTimeField(_("created"), auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(_("modified"), auto_now=True) + cc = models.TextField("CC", help_text=_("emails to send a carbon copy to"), blank=True) + + objects = TicketQuerySet.as_manager() + + class Meta: + ordering = ['-updated_at'] + + def __str__(self): + return str(self.pk) + + def get_notification_emails(self): + """ Get emails of the users related to the ticket """ + emails = list(settings.ISSUES_SUPPORT_EMAILS) + emails.append(self.creator.email) + if self.owner: + emails.append(self.owner.email) + for contact in self.creator.contacts.all(): + if self.queue and set(contact.email_usage).union(set(self.queue.notify)): + emails.append(contact.email) + for message in self.messages.distinct('author'): + emails.append(message.author.email) + return set(emails + self.get_cc_emails()) + + def notify(self, message=None, content=None): + """ Send an email to ticket stakeholders notifying an state update """ + emails = self.get_notification_emails() + template = 'issues/ticket_notification.mail' + html_template = 'issues/ticket_notification_html.mail' + context = { + 'ticket': self, + 'ticket_message': message + } + send_email_template(template, context, emails, html=html_template) + + def save(self, *args, **kwargs): + """ notify stakeholders of new ticket """ + new_issue = not self.pk + if not self.creator_name and self.creator: + self.creator_name = self.creator.get_full_name() + super(Ticket, self).save(*args, **kwargs) + if new_issue: + # PK should be available for rendering the template + self.notify() + + def is_involved_by(self, user): + """ returns whether user has participated or is referenced on the ticket + as owner or member of the group + """ + return Ticket.objects.filter(pk=self.pk).involved_by(user).exists() + + def get_cc_emails(self): + return self.cc.split(',') if self.cc else [] + + def mark_as_read_by(self, user): + self.trackers.get_or_create(user=user) + + def mark_as_unread_by(self, user): + self.trackers.filter(user=user).delete() + + def mark_as_unread(self): + self.trackers.all().delete() + + def is_read_by(self, user): + return self.trackers.filter(user=user).exists() + + def reject(self): + self.state = Ticket.REJECTED + self.save(update_fields=('state', 'updated_at')) + + def resolve(self): + self.state = Ticket.RESOLVED + self.save(update_fields=('state', 'updated_at')) + + def close(self): + self.state = Ticket.CLOSED + self.save(update_fields=('state', 'updated_at')) + + def take(self, user): + self.owner = user + self.save(update_fields=('state', 'updated_at')) + + +class Message(models.Model): + ticket = models.ForeignKey('issues.Ticket', on_delete=models.CASCADE, + verbose_name=_("ticket"), related_name='messages') + author = models.ForeignKey(djsettings.AUTH_USER_MODEL, on_delete=models.CASCADE, + verbose_name=_("author"), related_name='ticket_messages') + author_name = models.CharField(_("author name"), max_length=256, blank=True) + content = models.TextField(_("content")) + created_at = models.DateTimeField(_("created at"), auto_now_add=True) + + class Meta: + get_latest_by = 'id' + + def __str__(self): + return "#%i" % self.id + + def save(self, *args, **kwargs): + """ notify stakeholders of ticket update """ + if not self.pk: + self.ticket.mark_as_unread() + self.ticket.mark_as_read_by(self.author) + self.ticket.notify(message=self) + self.author_name = self.author.get_full_name() + super(Message, self).save(*args, **kwargs) + + @property + def number(self): + return self.ticket.messages.filter(id__lte=self.id).count() + + +class TicketTracker(models.Model): + """ Keeps track of user read tickets """ + ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, + verbose_name=_("ticket"), related_name='trackers') + user = models.ForeignKey(djsettings.AUTH_USER_MODEL, on_delete=models.CASCADE, + verbose_name=_("user"), related_name='ticket_trackers') + + class Meta: + unique_together = ( + ('ticket', 'user'), + ) diff --git a/orchestra/contrib/issues/serializers.py b/orchestra/contrib/issues/serializers.py new file mode 100644 index 0000000..eb636c4 --- /dev/null +++ b/orchestra/contrib/issues/serializers.py @@ -0,0 +1,45 @@ +from rest_framework import serializers + +from .models import Ticket, Message, Queue + + +class QueueSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = Queue + fields = ('url', 'id', 'name', 'default', 'notify') + read_only_fields = ('name', 'default', 'notify') + + +class MessageSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = Message + fields = ('id', 'author', 'author_name', 'content', 'created_at') + read_only_fields = ('author', 'author_name', 'created_at') + + def get_identity(self, data): + return data.get('id') + + def create(self, validated_data): + validated_data['author'] = self.context['request'].user + return super(MessageSerializer, self).create(validated_data) + + +class TicketSerializer(serializers.HyperlinkedModelSerializer): + """ Validates if this zone generates a correct zone file """ + messages = MessageSerializer(required=False, many=True, read_only=True) + is_read = serializers.SerializerMethodField() + + class Meta: + model = Ticket + fields = ( + 'url', 'id', 'creator', 'creator_name', 'owner', 'queue', 'subject', + 'description', 'state', 'messages', 'is_read' + ) + read_only_fields = ('creator', 'creator_name', 'owner') + + def get_is_read(self, obj): + return obj.is_read_by(self.context['request'].user) + + def create(self, validated_data): + validated_data['creator'] = self.context['request'].user + return super(TicketSerializer, self).create(validated_data) diff --git a/orchestra/contrib/issues/settings.py b/orchestra/contrib/issues/settings.py new file mode 100644 index 0000000..902ba33 --- /dev/null +++ b/orchestra/contrib/issues/settings.py @@ -0,0 +1,16 @@ +from django.core.validators import validate_email + +from orchestra.contrib.settings import Setting +from orchestra.settings import ORCHESTRA_DEFAULT_SUPPORT_FROM_EMAIL + + +ISSUES_SUPPORT_EMAILS = Setting('ISSUES_SUPPORT_EMAILS', + (ORCHESTRA_DEFAULT_SUPPORT_FROM_EMAIL,), + validators=[lambda emails: [validate_email(e) for e in emails]], + help_text="Includes ORCHESTRA_DEFAULT_SUPPORT_FROM_EMAIL by default", +) + + +ISSUES_NOTIFY_SUPERUSERS = Setting('ISSUES_NOTIFY_SUPERUSERS', + True +) diff --git a/orchestra/contrib/issues/static/issues/css/ticket-admin.css b/orchestra/contrib/issues/static/issues/css/ticket-admin.css new file mode 100644 index 0000000..b52da12 --- /dev/null +++ b/orchestra/contrib/issues/static/issues/css/ticket-admin.css @@ -0,0 +1,67 @@ +fieldset .field-box { + float: left; + margin-right: 20px; + width: 300px; +} + +hr { + background-color: #B6B6B6; +} + +h4 { + color: #666; +} + +form .field-display_description p, form .field-display_description ul { + margin-left: 0; + padding-left: 12px; +} + +form .field-display_description ul { + margin-left: 24px; +} + +ul li { + list-style-type: disc; + padding: 0; +} + +/*** messages format ***/ +#messages-group { + margin-bottom: 0; +} + +#messages-2-group { + margin-top: 0; +} + +#messages-2-group h2, #messages-2-group thead { + display: none; +} + +#id_messages-2-0-content { + width: 99%; +} + +/** ticket.description preview CSS overrides **/ +.content-preview { + border: 1px solid #ccc; + padding: 2px 5px; +} + +.aligned .content-preview p { + margin-left: 5px; + padding-left: 0; +} +.module .content-preview ol, +.module .content-preview ul { + margin-left: 5px; +} + +/** unread messages admin changelist **/ +strong.unread { + display: inline-block; + padding-left: 21px; + background: url(../images/unread_ticket.gif) no-repeat left; +} + diff --git a/orchestra/contrib/issues/static/issues/images/btn_edit.gif b/orchestra/contrib/issues/static/issues/images/btn_edit.gif new file mode 100644 index 0000000000000000000000000000000000000000..1a6f83c58c140f9b0c58c7077ae9fe65b39fee85 GIT binary patch literal 204 zcmZ?wbhEHb6l4%!I3muFmzVeJ*RSQvmp^&(||i|e4yHwk~!~6;@q@X4BFa3mz)?k_^=D6M2cSPGnm29I*H+;gp2BQ_vM=; nOl?_u+?q6nMHqzjxF3rCnYE_tD38Gj7yH)VzUwbLGgt!vkDXIH literal 0 HcmV?d00001 diff --git a/orchestra/contrib/issues/static/issues/images/unread_ticket.gif b/orchestra/contrib/issues/static/issues/images/unread_ticket.gif new file mode 100644 index 0000000000000000000000000000000000000000..62bc6ffd4b5c9bb3606a102ec7ad2460a96ca7df GIT binary patch literal 260 zcmV+f0sH<(Nk%w1VGsZi0K^{vb&{>L*!!u}{Exfi`uzRc?Dfmj z+J&jaZ=1rS(EFav`|I`oQeb(e%IulJ=0i(dk+jd5z}Aeg(J3)QK1Ef&-uLpu7M7|NsC0A^8LW0018VEC2ui01yBW000Gn;3tk`X`ZB~u57DrRYfgy2i$-G zsTJY>K!RfDp%5q + + + + +Markdown formatting + + + + +

    Markdown Syntax Quick Reference

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Font Styles
    **Strong**Strong
    _Italic_Italic
    > QuoteQuote
         4 or more spacesCode block
    Break Lines
    end a line with 2 or more spaces  first line
    new line
    type an empty line
     
    (or containing only spaces)
    first line
    new line
    Lists
    * Item 1
    * Item 2
    • Item 1
    • Item 2
    1. Item 1
    2. Item 2
    1. Item 1
    2. Item 2
    Headings
    # Title 1 #

    Title 1

    ## Title ##

    Title 2

    Links
    <http://foo.bar>http://foo.bar
    [link](http://foo.bar/)link
    [relative link](/about/)relative link
    + +

    + Full reference of markdown syntax. +

    + + + diff --git a/orchestra/contrib/issues/templates/issues/ticket_notification.mail b/orchestra/contrib/issues/templates/issues/ticket_notification.mail new file mode 100644 index 0000000..c9b39e1 --- /dev/null +++ b/orchestra/contrib/issues/templates/issues/ticket_notification.mail @@ -0,0 +1,36 @@ +{% if subject %} +{% if not ticket_message %} +[{{ site.name }} - Issue #{{ ticket.pk }}] ({{ ticket.get_state_display }}) {{ ticket.subject }} +{% else %} +[{{ site.name }} - Issue #{{ ticket.pk }}] {% if '**State** changed' in ticket_message.content %}({{ ticket.get_state_display }}) {% endif %}{{ ticket.subject }} +{% endif %} +{% endif %} + +{% if message %} +{% if not ticket_message %} +Issue #{{ ticket.id }} has been reported by {{ ticket.created_by }}. +{% else %} +Issue #{{ ticket.id }} has been updated by {{ ticket_message.author }}. +{% autoescape off %} +{{ ticket_message.content }} +{% endautoescape %} +{% endif %} +----------------------------------------------------------------- +Issue #{{ ticket.pk }}: {{ ticket.subject }} + + * Author: {{ ticket.creator_name }} + * Status: {{ ticket.get_state_display }} + * Priority: {{ ticket.get_priority_display }} + * Visibility: {{ ticket.get_visibility_display }} + * Group: {% if ticket.group %}{{ ticket.group }}{% endif %} + * Assigned to: {% if ticket.owner %}{{ ticket.owner }}{% endif %} + * Queue: {{ ticket.queue }} + +{% autoescape off %} +{{ ticket.description }} +{% endautoescape %} +----------------------------------------------------------------- +You have received this notification because you have either subscribed to it, or are involved in it. +To change your notification preferences, please visit: {{ site.scheme }}://{{ site.domain }}{% url 'admin:issues_ticket_change' ticket.id %} +{% endif %} + diff --git a/orchestra/contrib/issues/templates/issues/ticket_notification_html.mail b/orchestra/contrib/issues/templates/issues/ticket_notification_html.mail new file mode 100644 index 0000000..a1bf322 --- /dev/null +++ b/orchestra/contrib/issues/templates/issues/ticket_notification_html.mail @@ -0,0 +1,60 @@ +{% load markdown %} + +{% if message %} + + + + + +{% if not ticket_message %} +Issue #{{ ticket.id }} has been reported by {{ ticket.created_by }}. +{% else %} +Issue #{{ ticket.id }} has been updated by {{ ticket_message.author }}. +{% autoescape off %} +{{ ticket_message.content|markdown }} +{% endautoescape %} +{% endif %} +
    +

    Issue #{{ ticket.pk }}: {{ ticket.subject }}

    + +
      +
    • Author: {{ ticket.creator_name }}
    • +
    • Status: {{ ticket.get_state_display }}
    • +
    • Priority: {{ ticket.get_priority_display }}
    • +
    • Visibility: {{ ticket.get_visibility_display }}
    • +
    • Group: {% if ticket.group %}{{ ticket.group }}{% endif %}
    • +
    • Assigned to: {% if ticket.owner %}{{ ticket.owner }}{% endif %}
    • +
    • Queue: {{ ticket.queue }}
    • +
    +{% autoescape off %} +{{ ticket.description|markdown }} +{% endautoescape %} +
    +

    You have received this notification because you have either subscribed to it, or are involved in it.
    +To change your notification preferences, please click here: {{ site.scheme }}://{{ site.domain }}{% url 'admin:issues_ticket_change' ticket.id %}

    + + +{% endif %} diff --git a/orchestra/contrib/issues/tests.py b/orchestra/contrib/issues/tests.py new file mode 100644 index 0000000..501deb7 --- /dev/null +++ b/orchestra/contrib/issues/tests.py @@ -0,0 +1,16 @@ +""" +This file demonstrates writing tests using the unittest module. These will pass +when you run "manage.py test". + +Replace this with more appropriate tests for your application. +""" + +from django.test import TestCase + + +class SimpleTest(TestCase): + def test_basic_addition(self): + """ + Tests that 1 + 1 always equals 2. + """ + self.assertEqual(1 + 1, 2) diff --git a/orchestra/contrib/letsencrypt/actions.py b/orchestra/contrib/letsencrypt/actions.py new file mode 100644 index 0000000..375933a --- /dev/null +++ b/orchestra/contrib/letsencrypt/actions.py @@ -0,0 +1,115 @@ +from django.contrib import messages, admin +from django.template.response import TemplateResponse +from django.utils.safestring import mark_safe +from django.utils.translation import ngettext, gettext, gettext_lazy as _ + +from orchestra.admin.utils import admin_link +from orchestra.contrib.orchestration import Operation, helpers + +from .helpers import is_valid_domain, read_live_lineages, configure_cert +from .forms import LetsEncryptForm + + +def letsencrypt(modeladmin, request, queryset): + wildcards = set() + domains = set() + content_error = '' + contentless = queryset.exclude(content__path='/').distinct() + if contentless: + content_error = ngettext( + gettext("Selected website %s doesn't have a webapp mounted on /."), + gettext("Selected websites %s don't have a webapp mounted on /."), + len(contentless), + ) + content_error += gettext("
    Websites need a webapp (e.g. static) mounted on / " + "for let's encrypt HTTP-01 challenge to work.") + content_error = content_error % ', '.join((admin_link()(website) for website in contentless)) + content_error = '
    • %s
    ' % content_error + queryset = queryset.prefetch_related('domains') + for website in queryset: + for domain in website.domains.all(): + if domain.name.startswith('*.'): + wildcards.add(domain.name) + else: + domains.add(domain.name) + form = LetsEncryptForm(domains, wildcards, initial={'domains': '\n'.join(domains)}) + action_value = 'letsencrypt' + if request.POST.get('post') == 'generic_confirmation': + form = LetsEncryptForm(domains, wildcards, request.POST) + if not content_error and form.is_valid(): + cleaned_data = form.cleaned_data + domains = set(cleaned_data['domains']) + operations = [] + for website in queryset: + website_domains = [d.name for d in website.domains.all()] + encrypt_domains = set() + for domain in domains: + if is_valid_domain(domain, website_domains, wildcards): + encrypt_domains.add(domain) + website.encrypt_domains = encrypt_domains + operations.extend(Operation.create_for_action(website, 'encrypt')) + modeladmin.log_change(request, website, _("Encrypted!")) + if not operations: + messages.error(request, _("No backend operation has been executed.")) + else: + logs = Operation.execute(operations) + helpers.message_user(request, logs) + live_lineages = read_live_lineages(logs) + errors = 0 + successes = 0 + no_https = 0 + for website in queryset: + try: + configure_cert(website, live_lineages) + except LookupError: + errors += 1 + messages.error(request, _("No lineage found for website %s") % website.name) + else: + if website.protocol == website.HTTP: + no_https += 1 + website.save(update_fields=('name',)) + successes += 1 + context = { + 'name': website.name, + 'errors': errors, + 'successes': successes, + 'no_https': no_https + } + if errors: + msg = ngettext( + _("No lineages found for websites {name}."), + _("No lineages found for {errors} websites."), + errors) + messages.error(request, msg % context) + if successes: + msg = ngettext( + _("{name} website has successfully been encrypted."), + _("{successes} websites have been successfully encrypted."), + successes) + messages.success(request, msg.format(**context)) + if no_https: + msg = ngettext( + _("{name} website does not have HTTPS protocol enabled."), + _("{no_https} websites do not have HTTPS protocol enabled."), + no_https) + messages.warning(request, mark_safe(msg.format(**context))) + return + opts = modeladmin.model._meta + app_label = opts.app_label + context = { + 'title': _("Let's encrypt!"), + 'action_name': _("Encrypt"), + 'content_message': gettext("You are going to request certificates for the following domains.
    " + "This operation is safe to run multiple times, " + "existing certificates will not be regenerated. " + "Also notice that let's encrypt does not currently support wildcard certificates.") + content_error, + 'action_value': action_value, + 'queryset': queryset, + 'opts': opts, + 'obj': website if len(queryset) == 1 else None, + 'app_label': app_label, + 'action_checkbox_name': admin.helpers.ACTION_CHECKBOX_NAME, + 'form': form, + } + return TemplateResponse(request, 'admin/orchestra/generic_confirmation.html', context) +letsencrypt.short_description = "Let's encrypt!" diff --git a/orchestra/contrib/letsencrypt/admin.py b/orchestra/contrib/letsencrypt/admin.py new file mode 100644 index 0000000..1f2ae62 --- /dev/null +++ b/orchestra/contrib/letsencrypt/admin.py @@ -0,0 +1,8 @@ +from orchestra.admin.utils import insertattr +from orchestra.contrib.websites.admin import WebsiteAdmin + +from .import actions + + +insertattr(WebsiteAdmin, 'change_view_actions', actions.letsencrypt) +insertattr(WebsiteAdmin, 'actions', actions.letsencrypt) diff --git a/orchestra/contrib/letsencrypt/backends.py b/orchestra/contrib/letsencrypt/backends.py new file mode 100644 index 0000000..218c093 --- /dev/null +++ b/orchestra/contrib/letsencrypt/backends.py @@ -0,0 +1,54 @@ +import os +import textwrap + +from orchestra.contrib.orchestration import ServiceController + +from . import settings + + +class LetsEncryptController(ServiceController): + model = 'websites.Website' + verbose_name = "Let's encrypt!" + actions = ('encrypt',) + + def prepare(self): + super().prepare() + self.cleanup = [] + context = { + 'letsencrypt_auto': settings.LETSENCRYPT_AUTO_PATH, + } + self.append(textwrap.dedent(""" + %(letsencrypt_auto)s --non-interactive --no-self-upgrade \\ + --keep --expand --agree-tos certonly --webroot \\""") % context + ) + + def encrypt(self, website): + context = self.get_context(website) + self.append(" --webroot-path %(webroot)s \\" % context) + self.append(" --email %(email)s \\" % context) + self.append(" -d %(domains)s \\" % context) + self.cleanup.append("rm -rf -- %(webroot)s/.well-known" % context) + + def commit(self): + self.append(" || exit_code=$?") + for cleanup in self.cleanup: + self.append(cleanup) + context = { + 'letsencrypt_live': os.path.normpath(settings.LETSENCRYPT_LIVE_PATH), + } + self.append(textwrap.dedent(""" + # Report back the lineages in order to infere each certificate path + echo '' + find %(letsencrypt_live)s/* -maxdepth 0 + echo ''""") % context + ) + super().commit() + + def get_context(self, website): + content = website.content_set.get(path='/') + return { + 'letsencrypt_auto': settings.LETSENCRYPT_AUTO_PATH, + 'webroot': content.webapp.get_path(), + 'email': settings.LETSENCRYPT_EMAIL or website.account.email, + 'domains': ' \\\n -d '.join(website.encrypt_domains), + } diff --git a/orchestra/contrib/letsencrypt/forms.py b/orchestra/contrib/letsencrypt/forms.py new file mode 100644 index 0000000..9d1db1d --- /dev/null +++ b/orchestra/contrib/letsencrypt/forms.py @@ -0,0 +1,32 @@ +from django import forms +from django.core.exceptions import ValidationError +from django.utils.translation import ngettext, gettext_lazy as _ + +from .helpers import is_valid_domain + + +class LetsEncryptForm(forms.Form): + domains = forms.CharField(widget=forms.Textarea) + + def __init__(self, domains, wildcards, *args, **kwargs): + self.domains = domains + self.wildcards = wildcards + super().__init__(*args, **kwargs) + if wildcards: + help_text = _("You can add domains maching the following wildcards: %s") + self.fields['domains'].help_text += help_text % ', '.join(wildcards) + + def clean_domains(self): + domains = self.cleaned_data['domains'].split() + cleaned_domains = set() + for domain in domains: + domain = domain.strip() + if domain not in self.domains: + domain = domain.strip() + if not is_valid_domain(domain, self.domains, self.wildcards): + raise ValidationError(_( + "%s domain is not included on selected websites, " + "nor matches with any wildcard domain.") % domain + ) + cleaned_domains.add(domain) + return cleaned_domains diff --git a/orchestra/contrib/letsencrypt/helpers.py b/orchestra/contrib/letsencrypt/helpers.py new file mode 100644 index 0000000..9577d57 --- /dev/null +++ b/orchestra/contrib/letsencrypt/helpers.py @@ -0,0 +1,48 @@ +import os + + +def is_valid_domain(domain, existing, wildcards): + if domain in existing: + return True + for wildcard in wildcards: + if domain.startswith(wildcard.lstrip('*')) and domain.count('.') == wildcard.count('.'): + return True + return False + + +def read_live_lineages(logs): + live_lineages = {} + for log in logs: + reading = False + for line in log.stdout.splitlines(): + line = line.strip() + if line == '': + break + if reading: + live_lineages[line.split('/')[-1]] = line + elif line == '': + reading = True + return live_lineages + + +def configure_cert(website, live_lineages): + for domain in website.domains.all(): + try: + path = live_lineages[domain.name] + except KeyError: + pass + else: + maps = ( + ('ssl-ca', os.path.join(path, 'chain.pem')), + ('ssl-cert', os.path.join(path, 'cert.pem')), + ('ssl-key', os.path.join(path, 'privkey.pem')), + ) + for directive, path in maps: + try: + directive = website.directives.get(name=directive) + except website.directives.model.DoesNotExist: + directive = website.directives.model(name=directive, website=website) + directive.value = path + directive.save() + return + raise LookupError("Lineage not found") diff --git a/orchestra/contrib/letsencrypt/settings.py b/orchestra/contrib/letsencrypt/settings.py new file mode 100644 index 0000000..d4ca304 --- /dev/null +++ b/orchestra/contrib/letsencrypt/settings.py @@ -0,0 +1,17 @@ +from orchestra.contrib.settings import Setting + + +LETSENCRYPT_AUTO_PATH = Setting('LETSENCRYPT_AUTO_PATH', + '/home/httpd/letsencrypt/letsencrypt-auto' +) + + +LETSENCRYPT_LIVE_PATH = Setting('LETSENCRYPT_LIVE_PATH', + '/etc/letsencrypt/live' +) + + +LETSENCRYPT_EMAIL = Setting('LETSENCRYPT_EMAIL', + '', + help_text="Uses account.email by default", +) diff --git a/orchestra/contrib/lists/__init__.py b/orchestra/contrib/lists/__init__.py new file mode 100644 index 0000000..413f2e0 --- /dev/null +++ b/orchestra/contrib/lists/__init__.py @@ -0,0 +1 @@ +default_app_config = 'orchestra.contrib.lists.apps.ListsConfig' diff --git a/orchestra/contrib/lists/admin.py b/orchestra/contrib/lists/admin.py new file mode 100644 index 0000000..d356d72 --- /dev/null +++ b/orchestra/contrib/lists/admin.py @@ -0,0 +1,79 @@ +from django.contrib import admin +from django.urls import re_path as url +from django.contrib.auth.admin import UserAdmin +from django.utils.translation import gettext_lazy as _ + +from orchestra.admin import ExtendedModelAdmin, ChangePasswordAdminMixin +from orchestra.admin.actions import disable, enable +from orchestra.admin.utils import admin_link +from orchestra.contrib.accounts.actions import list_accounts +from orchestra.contrib.accounts.admin import SelectAccountAdminMixin +from orchestra.contrib.accounts.filters import IsActiveListFilter +from orchestra.forms import UserCreationForm, NonStoredUserChangeForm + +from . import settings +from .filters import HasCustomAddressListFilter +from .models import List + + +class ListAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedModelAdmin): + list_display = ( + 'name', 'address_name', 'address_domain_link', 'account_link', 'display_active' + ) + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('account_link', 'name', 'is_active') + }), + (_("Address"), { + 'classes': ('wide',), + 'fields': (('address_name', 'address_domain'),) + }), + (_("Admin"), { + 'classes': ('wide',), + 'fields': ('admin_email', 'password1', 'password2'), + }), + ) + fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('account_link', 'name', 'is_active') + }), + (_("Address"), { + 'classes': ('wide',), + 'description': _("Additional address besides the default <name>@%s" + ) % settings.LISTS_DEFAULT_DOMAIN, + 'fields': (('address_name', 'address_domain'),) + }), + (_("Admin"), { + 'classes': ('wide',), + 'fields': ('password',), + }), + ) + search_fields = ('name', 'address_name', 'address_domain__name', 'account__username') + list_filter = (IsActiveListFilter, HasCustomAddressListFilter) + readonly_fields = ('account_link',) + change_readonly_fields = ('name',) + form = NonStoredUserChangeForm + add_form = UserCreationForm + list_select_related = ('account', 'address_domain',) + filter_by_account_fields = ['address_domain'] + actions = (disable, enable, list_accounts) + + address_domain_link = admin_link('address_domain', order='address_domain__name') + + def get_urls(self): + useradmin = UserAdmin(List, self.admin_site) + return [ + url(r'^(\d+)/password/$', + self.admin_site.admin_view(useradmin.user_change_password)) + ] + super(ListAdmin, self).get_urls() + + def save_model(self, request, obj, form, change): + """ set password """ + if not change: + obj.set_password(form.cleaned_data["password1"]) + super(ListAdmin, self).save_model(request, obj, form, change) + + +admin.site.register(List, ListAdmin) diff --git a/orchestra/contrib/lists/api.py b/orchestra/contrib/lists/api.py new file mode 100644 index 0000000..7dc8513 --- /dev/null +++ b/orchestra/contrib/lists/api.py @@ -0,0 +1,16 @@ +from rest_framework import viewsets + +from orchestra.api import router, SetPasswordApiMixin, LogApiMixin +from orchestra.contrib.accounts.api import AccountApiMixin + +from .models import List +from .serializers import ListSerializer + + +class ListViewSet(LogApiMixin, AccountApiMixin, SetPasswordApiMixin, viewsets.ModelViewSet): + queryset = List.objects.all() + serializer_class = ListSerializer + filter_fields = ('name', 'address_domain') + + +router.register(r'lists', ListViewSet) diff --git a/orchestra/contrib/lists/apps.py b/orchestra/contrib/lists/apps.py new file mode 100644 index 0000000..f4d2b06 --- /dev/null +++ b/orchestra/contrib/lists/apps.py @@ -0,0 +1,13 @@ +from django.apps import AppConfig + +from orchestra.core import services + + +class ListsConfig(AppConfig): + name = 'orchestra.contrib.lists' + verbose_name = 'Lists' + + def ready(self): + from .models import List + services.register(List, icon='email-alter.png') + from . import signals diff --git a/orchestra/contrib/lists/backends.py b/orchestra/contrib/lists/backends.py new file mode 100644 index 0000000..b6d4dc9 --- /dev/null +++ b/orchestra/contrib/lists/backends.py @@ -0,0 +1,328 @@ +import textwrap + +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.orchestration import ServiceController, replace +from orchestra.contrib.resources import ServiceMonitor + +from . import settings +from .models import List + + +class MailmanVirtualDomainController(ServiceController): + """ + Only syncs virtualdomains used on mailman addresses + """ + verbose_name = _("Mailman virtdomain-only") + model = 'lists.List' + doc_settings = (settings, + ('LISTS_VIRTUAL_ALIAS_DOMAINS_PATH',) + ) + + def is_hosted_domain(self, domain): + """ whether or not domain MX points to this server """ + return domain.has_default_mx() + + def include_virtual_alias_domain(self, context): + domain = context['address_domain'] + if domain and self.is_hosted_domain(domain): + self.append(textwrap.dedent(""" + # Add virtual domain %(address_domain)s + [[ $(grep '^\s*%(address_domain)s\s*$' %(virtual_alias_domains)s) ]] || { + echo '%(address_domain)s' >> %(virtual_alias_domains)s + UPDATED_VIRTUAL_ALIAS_DOMAINS=1 + }""") % context + ) + + def is_last_domain(self, domain): + return not List.objects.filter(address_domain=domain).exists() + + def exclude_virtual_alias_domain(self, context): + domain = context['address_domain'] + if domain and self.is_last_domain(domain): + self.append(textwrap.dedent(""" + # Remove %(address_domain)s from virtual domains + sed -i '/^%(address_domain)s\s*$/d' %(virtual_alias_domains)s\ + """) % context + ) + + def save(self, mail_list): + context = self.get_context(mail_list) + self.include_virtual_alias_domain(context) + + def delete(self, mail_list): + context = self.get_context(mail_list) + self.exclude_virtual_alias_domain(context) + + def commit(self): + context = self.get_context_files() + super(MailmanVirtualDomainController, self).commit() + + def get_context_files(self): + return { + 'virtual_alias_domains': settings.LISTS_VIRTUAL_ALIAS_DOMAINS_PATH, + } + + def get_context(self, mail_list): + context = self.get_context_files() + context.update({ + 'address_domain': mail_list.address_domain, + }) + return replace(context, "'", '"') + + +class MailmanController(MailmanVirtualDomainController): + """ + Mailman 2 backend based on newlist, it handles custom domains. + Includes MailmanVirtualDomainController + """ + verbose_name = "Mailman" + address_suffixes = [ + '', + '-admin', + '-bounces', + '-confirm', + '-join', + '-leave', + '-owner', + '-request', + '-subscribe', + '-unsubscribe' + ] + doc_settings = (settings, ( + 'LISTS_VIRTUAL_ALIAS_PATH', + 'LISTS_VIRTUAL_ALIAS_DOMAINS_PATH', + 'LISTS_DEFAULT_DOMAIN', + 'LISTS_MAILMAN_ROOT_DIR' + )) + + def get_virtual_aliases(self, context): + aliases = ['# %(banner)s' % context] + for suffix in self.address_suffixes: + context['suffix'] = suffix + # Because mailman doesn't properly handle lists aliases we need virtual aliases + if context['address_name'] != context['name']: + aliases.append("%(address_name)s%(suffix)s@%(domain)s\t%(name)s%(suffix)s@grups.pangea.org" % context) + return '\n'.join(aliases) + + + def save(self, mail_list): + context = self.get_context(mail_list) + + # Create list + cmd = "/opt/mailman/venv/bin/python /usr/local/admin/orchestra_mailman3/save.py %(name)s %(admin)s %(address_name)s@%(domain)s" % context + if not mail_list.active: + cmd += ' --inactive' + self.append(cmd) + + # Custom domain + if mail_list.address: + context.update({ + 'aliases': self.get_virtual_aliases(context), + 'num_entries': 2 if context['address_name'] != context['name'] else 1, + }) + self.append(textwrap.dedent("""\ + # Create list alias for custom domain + aliases='%(aliases)s' + if ! grep '\s\s*%(name)s\s*$' %(virtual_alias)s > /dev/null; then + echo "${aliases}" >> %(virtual_alias)s + UPDATED_VIRTUAL_ALIAS=1 + else + if grep -E '(%(address_name)s|%(name)s)@(%(address_domain)s|grups.pangea.org)' %(virtual_alias)s > /dev/null ; then + sed -i -e '/^.*%(name)s\(-admin\|-bounces\|-confirm\|-join\|-leave\|-owner\|-request\|-subscribe\|-unsubscribe\|@\).*$/d' \\ + -e '/# .*%(name)s$/d' %(virtual_alias)s + echo "${aliases}" >> %(virtual_alias)s + UPDATED_VIRTUAL_ALIAS=1 + fi + fi """) % context + ) + else: + self.append(textwrap.dedent("""\ + # Cleanup possible ex-custom domain + if grep '\s\s*%(name)s\s*$' %(virtual_alias)s > /dev/null; then + #sed -i "/^.*\s%(name)s\s*$/d" %(virtual_alias)s + sed -i -e '/^.*%(name)s\(-admin\|-bounces\|-confirm\|-join\|-leave\|-owner\|-request\|-subscribe\|-unsubscribe\|@\).*$/d' \\ + -e '/# .*%(name)s$/d' %(virtual_alias)s + fi""") % context + ) + + + def delete(self, mail_list): + context = self.get_context(mail_list) + + # Custom domain delete + self.append(textwrap.dedent("""\ + # Cleanup possible ex-custom domain + if grep '\s\s*%(name)s\s*$' %(virtual_alias)s > /dev/null; then + sed -i -e '/^.*%(name)s\(-admin\|-bounces\|-confirm\|-join\|-leave\|-owner\|-request\|-subscribe\|-unsubscribe\|@\).*$/d' \\ + -e '/# .*%(name)s$/d' %(virtual_alias)s + fi""") % context + ) + + # Delete list + cmd = "/opt/mailman/venv/bin/python /usr/local/admin/orchestra_mailman3/delete.py %(name)s" % context + self.append(cmd) + + + def commit(self): + pass + + def get_context_files(self): + return { + 'virtual_alias': settings.LISTS_VIRTUAL_ALIAS_PATH, + 'virtual_alias_domains': settings.LISTS_VIRTUAL_ALIAS_DOMAINS_PATH, + } + + def get_banner(self, mail_list): + banner = super(MailmanController, self).get_banner() + return '%s %s' % (banner, mail_list.name) + + def get_context(self, mail_list): + context = self.get_context_files() + context.update({ + 'banner': self.get_banner(mail_list), + 'name': mail_list.name, + 'password': mail_list.password, + 'domain': mail_list.address_domain or settings.LISTS_DEFAULT_DOMAIN, + 'address_name': mail_list.get_address_name(), + 'address_domain': mail_list.address_domain, + 'suffixes_regex': '\|'.join(self.address_suffixes), + 'admin': mail_list.admin_email, + 'mailman_root': settings.LISTS_MAILMAN_ROOT_DIR, + }) + return replace(context, "'", '"') + + +class MailmanTraffic(ServiceMonitor): + """ + Parses mailman log file looking for email size and multiples it by list_members count. + """ + model = 'lists.List' + resource = ServiceMonitor.TRAFFIC + verbose_name = _("Mailman traffic") + script_executable = '/usr/bin/python' + monthly_sum_old_values = True + doc_settings = (settings, + ('LISTS_MAILMAN_POST_LOG_PATH',) + ) + + def prepare(self): + postlog = settings.LISTS_MAILMAN_POST_LOG_PATH + context = { + 'postlogs': str((postlog, postlog+'.1')), + 'current_date': self.current_date.strftime("%Y-%m-%d %H:%M:%S %Z"), + } + self.append(textwrap.dedent("""\ + import re + import subprocess + import sys + from datetime import datetime + from dateutil import tz + + def to_local_timezone(date, tzlocal=tz.tzlocal()): + date = datetime.strptime(date, '%Y-%m-%d %H:%M:%S %Z') + date = date.replace(tzinfo=tz.tzutc()) + date = date.astimezone(tzlocal) + return date + + postlogs = {postlogs} + # Use local timezone + end_date = to_local_timezone('{current_date}') + end_date = int(end_date.strftime('%Y%m%d%H%M%S')) + lists = {{}} + months = {{ + 'Jan': '01', + 'Feb': '02', + 'Mar': '03', + 'Apr': '04', + 'May': '05', + 'Jun': '06', + 'Jul': '07', + 'Aug': '08', + 'Sep': '09', + 'Oct': '10', + 'Nov': '11', + 'Dec': '12', + }} + mailman_addr = re.compile(r'.*-(admin|bounces|confirm|join|leave|owner|request|subscribe|unsubscribe)@.*|mailman@.*') + + def prepare(object_id, list_name, ini_date): + global lists + ini_date = to_local_timezone(ini_date) + ini_date = int(ini_date.strftime('%Y%m%d%H%M%S')) + lists[list_name] = [ini_date, object_id, 0] + + def monitor(lists, end_date, months, postlogs): + for postlog in postlogs: + try: + with open(postlog, 'r') as postlog: + for line in postlog.readlines(): + line = line.split() + if len(line) < 11: + continue + month, day, time, year, __, __, __, list_name, __, addr, size = line[:11] + try: + list = lists[list_name] + except KeyError: + continue + else: + # discard mailman messages because of inconsistent POST logging + if mailman_addr.match(addr): + continue + date = year + months[month] + day + time.replace(':', '') + if list[0] < int(date) < end_date: + size = size[5:-1] + try: + list[2] += int(size) + except ValueError: + # anonymized post + pass + except IOError as e: + sys.stderr.write(str(e)+'\\n') + + for list_name, opts in lists.items(): + __, object_id, size = opts + if size: + cmd = ' '.join(('list_members', list_name, '| wc -l')) + ps = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + subscribers = ps.communicate()[0].strip() + size *= int(subscribers) + sys.stderr.write("%s %s*%s traffic*subscribers\\n" % (object_id, size, subscribers)) + print object_id, size + """).format(**context) + ) + + def monitor(self, user): + context = self.get_context(user) + self.append("prepare(%(object_id)s, '%(list_name)s', '%(last_date)s')" % context) + + def commit(self): + self.append('monitor(lists, end_date, months, postlogs)') + + def get_context(self, mail_list): + context = { + 'list_name': mail_list.name, + 'object_id': mail_list.pk, + 'last_date': self.get_last_date(mail_list.pk).strftime("%Y-%m-%d %H:%M:%S %Z"), + } + return replace(context, "'", '"') + + +class MailmanSubscribers(ServiceMonitor): + """ + Monitors number of list subscribers via list_members + """ + model = 'lists.List' + verbose_name = _("Mailman subscribers") + delete_old_equal_values = True + + def monitor(self, mail_list): + context = self.get_context(mail_list) + self.append('echo %(object_id)i $(list_members %(list_name)s | wc -l)' % context) + + def get_context(self, mail_list): + context = { + 'list_name': mail_list.name, + 'object_id': mail_list.pk, + } + return replace(context, "'", '"') diff --git a/orchestra/contrib/lists/filters.py b/orchestra/contrib/lists/filters.py new file mode 100644 index 0000000..8ba06eb --- /dev/null +++ b/orchestra/contrib/lists/filters.py @@ -0,0 +1,21 @@ +from django.contrib.admin import SimpleListFilter +from django.utils.translation import gettext_lazy as _ + + +class HasCustomAddressListFilter(SimpleListFilter): + """ Filter addresses whether they have any webapp or not """ + title = _("has custom address") + parameter_name = 'has_custom_address' + + def lookups(self, request, model_admin): + return ( + ('True', _("True")), + ('False', _("False")), + ) + + def queryset(self, request, queryset): + if self.value() == 'True': + return queryset.exclude(address_name='') + elif self.value() == 'False': + return queryset.filter(address_name='') + return queryset diff --git a/orchestra/contrib/lists/models.py b/orchestra/contrib/lists/models.py new file mode 100644 index 0000000..8ac3372 --- /dev/null +++ b/orchestra/contrib/lists/models.py @@ -0,0 +1,85 @@ +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ + +from orchestra.core.validators import validate_name + +from . import settings + + +class ListQuerySet(models.QuerySet): + def create(self, **kwargs): + """ Sets password if provided, all within a single DB operation """ + password = kwargs.pop('password') + instance = self.model(**kwargs) + if password: + instance.set_password(password) + instance.save() + return instance + + +# TODO address and domain, perhaps allow only domain? +class List(models.Model): + name = models.CharField(_("name"), max_length=64, unique=True, validators=[validate_name], + help_text=_("Default list address <name>@%s") % settings.LISTS_DEFAULT_DOMAIN) + address_name = models.CharField(_("address name"), max_length=64, + validators=[validate_name], blank=True) + address_domain = models.ForeignKey(settings.LISTS_DOMAIN_MODEL, on_delete=models.SET_NULL, + verbose_name=_("address domain"), blank=True, null=True) + admin_email = models.EmailField(_("admin email"), + help_text=_("Administration email address")) + account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), + related_name='lists', on_delete=models.CASCADE) + # TODO also admin + is_active = models.BooleanField(_("active"), default=True, + help_text=_("Designates whether this account should be treated as active. " + "Unselect this instead of deleting accounts.")) + password = None + + objects = ListQuerySet.as_manager() + + class Meta: + unique_together = ('address_name', 'address_domain') + + def __str__(self): + return self.name + + @property + def address(self): + if self.address_name and self.address_domain: + return "%s@%s" % (self.address_name, self.address_domain) + return '' + + @cached_property + def active(self): + return self.is_active and self.account.is_active + + def clean(self): + if self.address_name and not self.address_domain_id: + raise ValidationError({ + 'address_domain': _("Domain should be selected for provided address name."), + }) + + def disable(self): + self.is_active = False + self.save(update_fields=('is_active',)) + + def enable(self): + self.is_active = False + self.save(update_fields=('is_active',)) + + def get_address_name(self): + return self.address_name or self.name + + def get_username(self): + return self.name + + def set_password(self, password): + self.password = password + + def get_absolute_url(self): + context = { + 'name': self.name + } + return settings.LISTS_LIST_URL % context diff --git a/orchestra/contrib/lists/serializers.py b/orchestra/contrib/lists/serializers.py new file mode 100644 index 0000000..593612a --- /dev/null +++ b/orchestra/contrib/lists/serializers.py @@ -0,0 +1,44 @@ +from django.core.validators import RegexValidator +from django.forms import widgets +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from orchestra.api.serializers import SetPasswordHyperlinkedSerializer, RelatedHyperlinkedModelSerializer +from orchestra.contrib.accounts.serializers import AccountSerializerMixin +from orchestra.core.validators import validate_password + +from .models import List + + +class RelatedDomainSerializer(AccountSerializerMixin, RelatedHyperlinkedModelSerializer): + class Meta: + model = List.address_domain.field.related_model + fields = ('url', 'id', 'name') + + +class ListSerializer(AccountSerializerMixin, SetPasswordHyperlinkedSerializer): + password = serializers.CharField(max_length=128, label=_('Password'), + write_only=True, style={'widget': widgets.PasswordInput}, + validators=[ + validate_password, + RegexValidator(r'^[^"\'\\]+$', + _('Enter a valid password. ' + 'This value may contain any ascii character except for ' + ' \'/"/\\/ characters.'), 'invalid'), + ]) + + address_domain = RelatedDomainSerializer(required=False) + + class Meta: + model = List + fields = ('url', 'id', 'name', 'password', 'address_name', 'address_domain', 'admin_email', 'is_active',) + postonly_fields = ('name', 'password') + + def validate_address_domain(self, address_name): + if self.instance: + address_domain = address_domain or self.instance.address_domain + address_name = address_name or self.instance.address_name + if address_name and not address_domain: + raise serializers.ValidationError( + _("address_domains should should be provided when providing an addres_name")) + return address_name diff --git a/orchestra/contrib/lists/settings.py b/orchestra/contrib/lists/settings.py new file mode 100644 index 0000000..9d5e25a --- /dev/null +++ b/orchestra/contrib/lists/settings.py @@ -0,0 +1,40 @@ +from orchestra.contrib.settings import Setting +from orchestra.settings import ORCHESTRA_BASE_DOMAIN + + +LISTS_DOMAIN_MODEL = Setting('LISTS_DOMAIN_MODEL', + 'domains.Domain', + validators=[Setting.validate_model_label] +) + + +LISTS_DEFAULT_DOMAIN = Setting('LISTS_DEFAULT_DOMAIN', + 'lists.{}'.format(ORCHESTRA_BASE_DOMAIN), + help_text="Uses ORCHESTRA_BASE_DOMAIN by default." +) + + +LISTS_LIST_URL = Setting('LISTS_LIST_URL', + 'https://lists.{}/mailman/listinfo/%(name)s'.format(ORCHESTRA_BASE_DOMAIN), + help_text="Uses ORCHESTRA_BASE_DOMAIN by default." +) + + +LISTS_MAILMAN_POST_LOG_PATH = Setting('LISTS_MAILMAN_POST_LOG_PATH', + '/var/log/mailman3/smtp' +) + + +LISTS_MAILMAN_ROOT_DIR = Setting('LISTS_MAILMAN_ROOT_DIR', + '/var/lib/mailman3' +) + + +LISTS_VIRTUAL_ALIAS_PATH = Setting('LISTS_VIRTUAL_ALIAS_PATH', + '/etc/postfix/mailman3_virtusertable' +) + + +LISTS_VIRTUAL_ALIAS_DOMAINS_PATH = Setting('LISTS_VIRTUAL_ALIAS_DOMAINS_PATH', + '/etc/postfix/mailman3_virtdomains' +) diff --git a/orchestra/contrib/lists/signals.py b/orchestra/contrib/lists/signals.py new file mode 100644 index 0000000..1b2d54f --- /dev/null +++ b/orchestra/contrib/lists/signals.py @@ -0,0 +1,19 @@ +from django.apps import apps +from django.db.models.signals import pre_delete +from django.dispatch import receiver + +from . import settings +from .models import List + + +DOMAIN_MODEL = apps.get_model(settings.LISTS_DOMAIN_MODEL) + + +@receiver(pre_delete, sender=DOMAIN_MODEL, dispatch_uid="lists.clean_address_name") +def clean_address_name(sender, **kwargs): + domain = kwargs['instance'] + for list in List.objects.filter(address_domain_id=domain.pk): + list.address_name = '' + list.address_domain_id = None + list.save(update_fields=('address_name', 'address_domain_id')) + diff --git a/orchestra/contrib/lists/tests/__init__.py b/orchestra/contrib/lists/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestra/contrib/lists/tests/functional_tests/__init__.py b/orchestra/contrib/lists/tests/functional_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestra/contrib/lists/tests/functional_tests/tests.py b/orchestra/contrib/lists/tests/functional_tests/tests.py new file mode 100644 index 0000000..447a824 --- /dev/null +++ b/orchestra/contrib/lists/tests/functional_tests/tests.py @@ -0,0 +1,278 @@ +import os +import smtplib +import time +import unittest +from email.mime.text import MIMEText + +import requests +from django.conf import settings as djsettings +from django.core.management.base import CommandError +from django.urls import reverse +from orchestra.admin.utils import change_url +from orchestra.contrib.domains.models import Domain +from orchestra.contrib.orchestration.models import Route, Server +from orchestra.utils.sys import sshrun +from orchestra.utils.tests import (BaseLiveServerTestCase, random_ascii, + save_response_on_error, snapshot_on_error) +from selenium.webdriver.support.select import Select + +from ... import backends, settings +from ...models import List + +TEST_REST_API = int(os.getenv('TEST_REST_API', '0')) + + +class ListMixin(object): + MASTER_SERVER = os.environ.get('ORCHESTRA_SLAVE_SERVER', 'localhost') + DEPENDENCIES = ( + 'orchestra.contrib.orchestration', + 'orchestra.contrib.domains', + 'orchestra.contrib.lists', + ) + + def setUp(self): + super(ListMixin, self).setUp() + self.add_route() + djsettings.DEBUG = True + + def validate_add(self, name, address=None): + sshrun(self.MASTER_SERVER, 'list_members %s' % name, display=False) + if not address: + address = "%s@%s" % (name, settings.LISTS_DEFAULT_DOMAIN) + subscribe_address = "{}-subscribe@{}".format(*address.split('@')) + request_address = "{}-request@{}".format(name, address.split('@')[1]) + self.subscribe(subscribe_address) + time.sleep(3) + sshrun(self.MASTER_SERVER, + 'grep -v ":\|^\s\|^$\|-\|\.\|\s" /var/spool/mail/nobody | base64 -d | grep "%s"' + % request_address, display=False) + + def validate_login(self, name, password): + url = 'http://%s/cgi-bin/mailman/admin/%s' % (settings.LISTS_DEFAULT_DOMAIN, name) + self.assertEqual(200, requests.post(url, data={'adminpw': password}).status_code) + + def validate_delete(self, name): + context = { + 'name': name, + 'domain': Domain.objects.get().name, + 'virtual_domain': settings.LISTS_VIRTUAL_ALIAS_DOMAINS_PATH, + 'virtual_alias': settings.LISTS_VIRTUAL_ALIAS_PATH, + } + self.assertRaises(CommandError, sshrun, self.MASTER_SERVER, + 'grep "\s%(name)s\s*" %(virtual_alias)s' % context, display=False) + self.assertRaises(CommandError, sshrun, self.MASTER_SERVER, + 'grep "^\s*$(domain)s\s*$" %(virtual_domain)s' % context, display=False) + self.assertRaises(CommandError, sshrun, self.MASTER_SERVER, + 'list_lists | grep -i "^\s*%(name)s\s"' % context, display=False) + + def subscribe(self, subscribe_address): + msg = MIMEText('') + msg['To'] = subscribe_address + msg['From'] = 'root@%s' % self.MASTER_SERVER + msg['Subject'] = 'subscribe' + server = smtplib.SMTP(self.MASTER_SERVER, 25) + try: + server.ehlo() + server.starttls() + server.ehlo() + server.sendmail(msg['From'], msg['To'], msg.as_string()) + finally: + server.quit() + + def add_route(self): + server = Server.objects.create(name=self.MASTER_SERVER) + backend = backends.MailmanController.get_name() + Route.objects.create(backend=backend, match=True, host=server) + + def test_add(self): + name = '%s_list' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + admin_email = 'root@test3.orchestra.lan' + self.add(name, password, admin_email) + self.validate_add(name) + self.validate_login(name, password) + self.addCleanup(self.delete, name) + + def test_add_with_address(self): + name = '%s_list' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + admin_email = 'root@test3.orchestra.lan' + address_name = '%s_name' % random_ascii(10) + domain_name = '%sdomain.lan' % random_ascii(10) + address_domain = Domain.objects.create(name=domain_name, account=self.account) + self.add(name, password, admin_email, address_name=address_name, address_domain=address_domain) + self.addCleanup(self.delete, name) + # Mailman doesn't support changing the address, only the domain + self.validate_add(name, address="%s@%s" % (address_name, address_domain)) + + def test_change_password(self): + name = '%s_list' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + admin_email = 'root@test3.orchestra.lan' + self.add(name, password, admin_email) + self.addCleanup(self.delete, name) + self.validate_login(name, password) + new_password = '@!?%spppP001' % random_ascii(5) + self.change_password(name, new_password) + self.validate_login(name, new_password) + + def test_change_domain(self): + name = '%s_list' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + admin_email = 'root@test3.orchestra.lan' + address_name = '%s_name' % random_ascii(10) + domain_name = '%sdomain.lan' % random_ascii(10) + address_domain = Domain.objects.create(name=domain_name, account=self.account) + self.add(name, password, admin_email, address_name=address_name, address_domain=address_domain) + self.addCleanup(self.delete, name) + # Mailman doesn't support changing the address, only the domain + domain_name = '%sdomain.lan' % random_ascii(10) + address_domain = Domain.objects.create(name=domain_name, account=self.account) + self.update_domain(name, domain_name) + self.validate_add(name, address="%s@%s" % (address_name, address_domain)) + + def test_change_address_name(self): + name = '%s_list' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + admin_email = 'root@test3.orchestra.lan' + address_name = '%s_name' % random_ascii(10) + domain_name = '%sdomain.lan' % random_ascii(10) + address_domain = Domain.objects.create(name=domain_name, account=self.account) + self.add(name, password, admin_email, address_name=address_name, address_domain=address_domain) + self.addCleanup(self.delete, name) + # Mailman doesn't support changing the address, only the domain + address_name = '%s_name' % random_ascii(10) + self.update_address_name(name, address_name) + self.validate_add(name, address="%s@%s" % (address_name, address_domain)) + + def test_delete(self): + name = '%s_list' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + admin_email = 'root@test3.orchestra.lan' + address_name = '%s_name' % random_ascii(10) + domain_name = '%sdomain.lan' % random_ascii(10) + address_domain = Domain.objects.create(name=domain_name, account=self.account) + self.add(name, password, admin_email, address_name=address_name, address_domain=address_domain) + # Mailman doesn't support changing the address, only the domain + self.validate_add(name, address="%s@%s" % (address_name, address_domain)) + self.delete(name) + self.assertRaises(AssertionError, self.validate_login, name, password) + self.validate_delete(name) + + +@unittest.skipUnless(TEST_REST_API, "REST API tests") +class RESTListMixin(ListMixin): + def setUp(self): + super(RESTListMixin, self).setUp() + self.rest_login() + + @save_response_on_error + def add(self, name, password, admin_email, address_name=None, address_domain=None): + extra = {} + if address_name: + extra.update({ + 'address_name': address_name, + 'address_domain': self.rest.domains.retrieve(name=address_domain.name).get(), + }) + self.rest.lists.create(name=name, password=password, admin_email=admin_email, **extra) + + @save_response_on_error + def delete(self, name): + self.rest.lists.retrieve(name=name).delete() + + @save_response_on_error + def change_password(self, name, password): + mail_list = self.rest.lists.retrieve(name=name).get() + mail_list.set_password(password) + + @save_response_on_error + def update_domain(self, name, domain_name): + mail_list = self.rest.lists.retrieve(name=name).get() + domain = self.rest.domains.retrieve(name=domain_name).get() + mail_list.update(address_domain=domain) + + @save_response_on_error + def update_address_name(self, name, address_name): + mail_list = self.rest.lists.retrieve(name=name).get() + mail_list.update(address_name=address_name) + + +class AdminListMixin(ListMixin): + def setUp(self): + super(AdminListMixin, self).setUp() + self.admin_login() + + @snapshot_on_error + def add(self, name, password, admin_email, address_name=None, address_domain=None): + url = self.live_server_url + reverse('admin:lists_list_add') + self.selenium.get(url) + + name_field = self.selenium.find_element_by_id('id_name') + name_field.send_keys(name) + + password_field = self.selenium.find_element_by_id('id_password1') + password_field.send_keys(password) + password_field = self.selenium.find_element_by_id('id_password2') + password_field.send_keys(password) + + admin_email_field = self.selenium.find_element_by_id('id_admin_email') + admin_email_field.send_keys(admin_email) + + if address_name: + address_name_field = self.selenium.find_element_by_id('id_address_name') + address_name_field.send_keys(address_name) + + domain = Domain.objects.get(name=address_domain) + domain_input = self.selenium.find_element_by_id('id_address_domain') + domain_select = Select(domain_input) + domain_select.select_by_value(str(domain.pk)) + + name_field.submit() + self.assertNotEqual(url, self.selenium.current_url) + + @snapshot_on_error + def delete(self, name): + mail_list = List.objects.get(name=name) + self.admin_delete(mail_list) + + @snapshot_on_error + def change_password(self, name, password): + mail_list = List.objects.get(name=name) + self.admin_change_password(mail_list, password) + + @snapshot_on_error + def update_domain(self, name, domain_name): + mail_list = List.objects.get(name=name) + url = self.live_server_url + change_url(mail_list) + self.selenium.get(url) + + domain = Domain.objects.get(name=domain_name) + domain_input = self.selenium.find_element_by_id('id_address_domain') + domain_select = Select(domain_input) + domain_select.select_by_value(str(domain.pk)) + + save = self.selenium.find_element_by_name('_save') + save.submit() + self.assertNotEqual(url, self.selenium.current_url) + + @snapshot_on_error + def update_address_name(self, name, address_name): + mail_list = List.objects.get(name=name) + url = self.live_server_url + change_url(mail_list) + self.selenium.get(url) + + address_name_field = self.selenium.find_element_by_id('id_address_name') + address_name_field.clear() + address_name_field.send_keys(address_name) + + save = self.selenium.find_element_by_name('_save') + save.submit() + self.assertNotEqual(url, self.selenium.current_url) + + +class RESTListTest(RESTListMixin, BaseLiveServerTestCase): + pass + + +class AdminListTest(AdminListMixin, BaseLiveServerTestCase): + pass diff --git a/orchestra/contrib/mailboxes/__init__.py b/orchestra/contrib/mailboxes/__init__.py new file mode 100644 index 0000000..dbf8974 --- /dev/null +++ b/orchestra/contrib/mailboxes/__init__.py @@ -0,0 +1 @@ +default_app_config = 'orchestra.contrib.mailboxes.apps.MailboxesConfig' diff --git a/orchestra/contrib/mailboxes/actions.py b/orchestra/contrib/mailboxes/actions.py new file mode 100644 index 0000000..bef631d --- /dev/null +++ b/orchestra/contrib/mailboxes/actions.py @@ -0,0 +1,13 @@ +from orchestra.admin.actions import SendEmail + + +class SendMailboxEmail(SendEmail): + def get_email_addresses(self): + for mailbox in self.queryset.all(): + yield mailbox.get_local_address() + + +class SendAddressEmail(SendEmail): + def get_email_addresses(self): + for address in self.queryset.all(): + yield address.email diff --git a/orchestra/contrib/mailboxes/admin.py b/orchestra/contrib/mailboxes/admin.py new file mode 100644 index 0000000..d1094d3 --- /dev/null +++ b/orchestra/contrib/mailboxes/admin.py @@ -0,0 +1,327 @@ +import copy +from urllib.parse import parse_qs + +from django import forms +from django.contrib import admin, messages +from django.urls import reverse +from django.db.models import F, Count, Value as V +from django.db.models.functions import Concat +from django.utils.html import format_html, format_html_join +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ + +from orchestra.admin import ExtendedModelAdmin, ChangePasswordAdminMixin +from orchestra.admin.actions import disable, enable +from orchestra.admin.utils import admin_link, change_url +from orchestra.contrib.accounts.actions import list_accounts +from orchestra.contrib.accounts.admin import SelectAccountAdminMixin +from orchestra.contrib.accounts.filters import IsActiveListFilter +from orchestra.core import caches + +from . import settings +from .actions import SendMailboxEmail, SendAddressEmail +from .filters import HasMailboxListFilter, HasForwardListFilter, HasAddressListFilter +from .forms import MailboxCreationForm, MailboxChangeForm, AddressForm +from .models import Mailbox, Address, Autoresponse +from .widgets import OpenCustomFilteringOnSelect + + +class AutoresponseInline(admin.StackedInline): + model = Autoresponse + verbose_name_plural = _("autoresponse") + + def formfield_for_dbfield(self, db_field, **kwargs): + if db_field.name == 'subject': + kwargs['widget'] = forms.TextInput(attrs={'size':'118'}) + return super(AutoresponseInline, self).formfield_for_dbfield(db_field, **kwargs) + + +class MailboxAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedModelAdmin): + list_display = ( + 'name', 'account_link', 'display_filtering', 'display_addresses', 'display_active', + ) + list_filter = (IsActiveListFilter, HasAddressListFilter, 'filtering') + search_fields = ( + 'account__username', 'account__short_name', 'account__full_name', 'name', + 'addresses__name', 'addresses__domain__name', + ) + add_fieldsets = ( + (None, { + 'fields': ('account_link', 'name', 'password1', 'password2', 'filtering'), + }), + (_("Custom filtering"), { + 'classes': ('collapse',), + 'description': _("Please remember to select custom filtering " + "if you want this filter to be applied."), + 'fields': ('custom_filtering',), + }), + (_("Addresses"), { + 'fields': ('addresses',) + }), + ) + fieldsets = ( + (None, { + 'fields': ('name', 'password', 'is_active', 'account_link', 'filtering'), + }), + (_("Custom filtering"), { + 'classes': ('collapse',), + 'fields': ('custom_filtering',), + }), + (_("Addresses"), { + 'fields': ('addresses', 'display_forwards') + }), + ) + readonly_fields = ('account_link', 'display_addresses', 'display_forwards') + change_readonly_fields = ('name',) + add_form = MailboxCreationForm + form = MailboxChangeForm + list_prefetch_related = ('addresses__domain',) + actions = (disable, enable, list_accounts) + + def __init__(self, *args, **kwargs): + super(MailboxAdmin, self).__init__(*args, **kwargs) + if settings.MAILBOXES_LOCAL_DOMAIN: + type(self).actions = self.actions + (SendMailboxEmail(),) + + @mark_safe + def display_addresses(self, mailbox): + # Get from forwards + cache = caches.get_request_cache() + cached_forwards = cache.get('forwards') + if cached_forwards is None: + cached_forwards = {} + qs = Address.objects.filter(forward__regex=r'(^|.*\s)[^@]+(\s.*|$)') + qs = qs.annotate(email=Concat('name', V('@'), 'domain__name')) + qs = qs.values_list('id', 'email', 'forward') + for addr_id, email, mbox in qs: + url = reverse('admin:mailboxes_address_change', args=(addr_id,)) + link = format_html('{}', url, email) + try: + cached_forwards[mbox].append(link) + except KeyError: + cached_forwards[mbox] = [link] + cache.set('forwards', cached_forwards) + try: + forwards = cached_forwards[mailbox.name] + except KeyError: + forwards = [] + # Get from mailboxes + addresses = [] + for addr in mailbox.addresses.all(): + url = change_url(addr) + addresses.append(format_html('{}', url, addr.email)) + return '
    '.join(addresses+forwards) + display_addresses.short_description = _("Addresses") + + def display_forwards(self, mailbox): + forwards = mailbox.get_forwards() + return format_html_join( + '
    ', '{}', + [(change_url(addr), addr.email) for addr in forwards] + ) + display_forwards.short_description = _("Forward from") + + @mark_safe + def display_filtering(self, mailbox): + return mailbox.get_filtering_display() + display_filtering.short_description = _("Filtering") + display_filtering.admin_order_field = 'filtering' + + def formfield_for_dbfield(self, db_field, **kwargs): + if db_field.name == 'filtering': + kwargs['widget'] = OpenCustomFilteringOnSelect() + return super(MailboxAdmin, self).formfield_for_dbfield(db_field, **kwargs) + + def get_fieldsets(self, request, obj=None): + fieldsets = super(MailboxAdmin, self).get_fieldsets(request, obj) + if obj and obj.filtering == obj.CUSTOM: + # not collapsed filtering when exists + fieldsets = copy.deepcopy(fieldsets) + fieldsets[1][1]['classes'] = fieldsets[0][1]['fields'] + ('collapse', 'open',) + elif '_to_field' in parse_qs(request.META['QUERY_STRING']): + # remove address from popup + fieldsets = list(copy.deepcopy(fieldsets)) + fieldsets.pop(-1) + return fieldsets + + def get_form(self, *args, **kwargs): + form = super(MailboxAdmin, self).get_form(*args, **kwargs) + form.modeladmin = self + return form + + def get_search_results(self, request, queryset, search_term): + # Remove local domain from the search term if present (implicit local addreç) + search_term = search_term.replace('@'+settings.MAILBOXES_LOCAL_DOMAIN, '') + # Split address name from domain in order to support address searching + search_term = search_term.replace('@', ' ') + return super(MailboxAdmin, self).get_search_results(request, queryset, search_term) + + def render_change_form(self, request, context, add=False, change=False, form_url='', obj=None): + if not add: + self.check_unrelated_address(request, obj) + self.check_matching_address(request, obj) + return super(MailboxAdmin, self).render_change_form( + request, context, add, change, form_url, obj) + + def log_addition(self, request, object, *args, **kwargs): + self.check_unrelated_address(request, object) + self.check_matching_address(request, object) + return super(MailboxAdmin, self).log_addition(request, object, *args, **kwargs) + + def check_matching_address(self, request, obj): + local_domain = settings.MAILBOXES_LOCAL_DOMAIN + if obj.name and local_domain: + try: + addr = Address.objects.get( + name=obj.name, domain__name=local_domain, account_id=self.account.pk) + except Address.DoesNotExist: + pass + else: + if addr not in obj.addresses.all(): + msg = _("Mailbox '%s' local address matches '%s', please consider if " + "selecting it makes sense.") % (obj, addr) + if msg not in (m.message for m in messages.get_messages(request)): + self.message_user(request, msg, level=messages.WARNING) + + def check_unrelated_address(self, request, obj): + # Check if there exists an unrelated local Address for this mbox + local_domain = settings.MAILBOXES_LOCAL_DOMAIN + if local_domain and obj.name: + non_mbox_addresses = Address.objects.exclude(mailboxes__name=obj.name).exclude( + forward__regex=r'.*(^|\s)+%s($|\s)+.*' % obj.name) + try: + addr = non_mbox_addresses.get(name=obj.name, domain__name=local_domain) + except Address.DoesNotExist: + pass + else: + url = reverse('admin:mailboxes_address_change', args=(addr.pk,)) + msg = mark_safe( + _("Address {addr} clashes with '{mailbox}' mailbox " + "local address. Consider adding this mailbox to the address.").format( + mailbox=obj.name, url=url, addr=addr) + ) + # Prevent duplication (add_view+continue) + if msg not in (m.message for m in messages.get_messages(request)): + self.message_user(request, msg, level=messages.WARNING) + + def save_model(self, request, obj, form, change): + """ save hacky mailbox.addresses and local domain clashing """ + if obj.filtering != obj.CUSTOM: + msg = _("You have provided a custom filtering but filtering " + "selected option is %s") % obj.get_filtering_display() + if change: + old = Mailbox.objects.get(pk=obj.pk) + if old.custom_filtering != obj.custom_filtering: + messages.warning(request, msg) + elif obj.custom_filtering: + messages.warning(request, msg) + super(MailboxAdmin, self).save_model(request, obj, form, change) + obj.addresses.set(form.cleaned_data['addresses']) + + +class AddressAdmin(SelectAccountAdminMixin, ExtendedModelAdmin): + list_display = ( + 'display_email', 'account_link', 'domain_link', 'display_mailboxes', 'display_forward', + ) + list_filter = (HasMailboxListFilter, HasForwardListFilter) + fields = ('account_link', 'email_link', 'mailboxes', 'forward', 'display_all_mailboxes') + add_fields = ('account_link', ('name', 'domain'), 'mailboxes', 'forward') +# inlines = [AutoresponseInline] + search_fields = ( + 'forward', 'mailboxes__name', 'account__username', 'computed_email', 'domain__name' + ) + readonly_fields = ('account_link', 'domain_link', 'email_link', 'display_all_mailboxes') + actions = (SendAddressEmail(),) + filter_by_account_fields = ('domain', 'mailboxes') + filter_horizontal = ['mailboxes'] + form = AddressForm + list_prefetch_related = ('mailboxes', 'domain') + + domain_link = admin_link('domain', order='domain__name') + + def display_email(self, address): + return address.computed_email + display_email.short_description = _("Email") + display_email.admin_order_field = 'computed_email' + + def email_link(self, address): + link = self.domain_link(address) + return format_html("{}@{}", address.name, link) + email_link.short_description = _("Email") + + def display_mailboxes(self, address): + boxes = address.mailboxes.all() + return format_html_join( + mark_safe('
    '), '{}', + [(change_url(mailbox), mailbox.name) for mailbox in boxes] + ) + display_mailboxes.short_description = _("Mailboxes") + display_mailboxes.admin_order_field = 'mailboxes__count' + + def display_all_mailboxes(self, address): + boxes = address.get_mailboxes() + return format_html_join( + mark_safe('
    '), '{}', + [(change_url(mailbox), mailbox.name) for mailbox in boxes] + ) + display_all_mailboxes.short_description = _("Mailboxes links") + + @mark_safe + def display_forward(self, address): + forward_mailboxes = {m.name: m for m in address.get_forward_mailboxes()} + values = [] + for forward in address.forward.split(): + mbox = forward_mailboxes.get(forward) + if mbox: + values.append(admin_link()(mbox)) + else: + values.append(forward) + return '
    '.join(values) + display_forward.short_description = _("Forward") + display_forward.admin_order_field = 'forward' + + def formfield_for_dbfield(self, db_field, **kwargs): + if db_field.name == 'forward': + kwargs['widget'] = forms.TextInput(attrs={'size':'118'}) + return super(AddressAdmin, self).formfield_for_dbfield(db_field, **kwargs) + + def get_fields(self, request, obj=None): + """ Remove mailboxes field when creating address from a popup i.e. from mailbox add form """ + fields = super(AddressAdmin, self).get_fields(request, obj) + if '_to_field' in parse_qs(request.META['QUERY_STRING']): + # Add address popup + fields = list(fields) + fields.remove('mailboxes') + return fields + + def get_queryset(self, request): + qs = super(AddressAdmin, self).get_queryset(request) + qs = qs.annotate(computed_email=Concat(F('name'), V('@'), F('domain__name'))) + return qs.annotate(Count('mailboxes')) + + def render_change_form(self, request, context, add=False, change=False, form_url='', obj=None): + if not add: + self.check_matching_mailbox(request, obj) + return super(AddressAdmin, self).render_change_form( + request, context, add, change, form_url, obj) + + def log_addition(self, request, object, *args, **kwargs): + self.check_matching_mailbox(request, object) + return super(AddressAdmin, self).log_addition(request, object, *args, **kwargs) + + def check_matching_mailbox(self, request, obj): + # Check if new addresse matches with a mbox because of having a local domain + if obj.name and obj.domain and obj.domain.name == settings.MAILBOXES_LOCAL_DOMAIN: + if obj.name not in obj.forward.split() and Mailbox.objects.filter(name=obj.name).exists(): + for mailbox in obj.mailboxes.all(): + if mailbox.name == obj.name: + return + msg = _("Address '%s' matches mailbox '%s' local address, please consider " + "if makes sense adding the mailbox on the mailboxes or forward field." + ) % (obj, obj.name) + if msg not in (m.message for m in messages.get_messages(request)): + self.message_user(request, msg, level=messages.WARNING) + + +admin.site.register(Mailbox, MailboxAdmin) +admin.site.register(Address, AddressAdmin) diff --git a/orchestra/contrib/mailboxes/api.py b/orchestra/contrib/mailboxes/api.py new file mode 100644 index 0000000..e17b68d --- /dev/null +++ b/orchestra/contrib/mailboxes/api.py @@ -0,0 +1,28 @@ +from rest_framework import viewsets + +from orchestra.api import router, SetPasswordApiMixin, LogApiMixin +from orchestra.contrib.accounts.api import AccountApiMixin + +from .models import Address, Mailbox +from .serializers import AddressSerializer, MailboxSerializer, MailboxWritableSerializer + + +class AddressViewSet(LogApiMixin, AccountApiMixin, viewsets.ModelViewSet): + queryset = Address.objects.select_related('domain').prefetch_related('mailboxes').all() + serializer_class = AddressSerializer + filter_fields = ('domain', 'mailboxes__name') + + +class MailboxViewSet(LogApiMixin, SetPasswordApiMixin, AccountApiMixin, viewsets.ModelViewSet): + queryset = Mailbox.objects.prefetch_related('addresses__domain').all() + serializer_class = MailboxSerializer + + def get_serializer_class(self): + if self.request.method == 'GET': + return self.serializer_class + + return MailboxWritableSerializer + + +router.register(r'mailboxes', MailboxViewSet) +router.register(r'addresses', AddressViewSet) diff --git a/orchestra/contrib/mailboxes/apps.py b/orchestra/contrib/mailboxes/apps.py new file mode 100644 index 0000000..395cf1e --- /dev/null +++ b/orchestra/contrib/mailboxes/apps.py @@ -0,0 +1,14 @@ +from django.apps import AppConfig + +from orchestra.core import services + + +class MailboxesConfig(AppConfig): + name = 'orchestra.contrib.mailboxes' + verbose_name = 'Mailboxes' + + def ready(self): + from .models import Mailbox, Address + services.register(Mailbox, icon='email.png') + services.register(Address, icon='X-office-address-book.png') + from . import signals diff --git a/orchestra/contrib/mailboxes/backends.py b/orchestra/contrib/mailboxes/backends.py new file mode 100644 index 0000000..c15c42f --- /dev/null +++ b/orchestra/contrib/mailboxes/backends.py @@ -0,0 +1,620 @@ +import logging +import os +import re +import textwrap + +from django.core.exceptions import ObjectDoesNotExist +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.orchestration import ServiceController +from orchestra.contrib.resources import ServiceMonitor + +from . import settings +from .models import Address, Mailbox + + +logger = logging.getLogger(__name__) + + +class SieveFilteringMixin: + def generate_filter(self, mailbox, context): + name, content = mailbox.get_filtering() + for box in re.findall(r'fileinto\s+"([^"]+)"', content): + # create mailboxes if fileinfo is provided witout ':create' option + context['box'] = box + self.append(textwrap.dedent(""" + # Create %(box)s mailbox + su - %(user)s --shell /bin/bash << 'EOF' + mkdir -p "%(maildir)s/.%(box)s" + EOF + if ! grep '%(box)s' %(maildir)s/subscriptions > /dev/null; then + echo '%(box)s' >> %(maildir)s/subscriptions + chown %(user)s:%(user)s %(maildir)s/subscriptions + fi + """) % context + ) + context['filtering_path'] = settings.MAILBOXES_SIEVE_PATH % context + context['filtering_cpath'] = re.sub(r'\.sieve$', '.svbin', context['filtering_path']) + if content: + context['filtering'] = ('# %(banner)s\n' + content) % context + self.append(textwrap.dedent("""\ + # Create and compile orchestra sieve filtering + su - %(user)s --shell /bin/bash << 'EOF' + mkdir -p $(dirname "%(filtering_path)s") + cat << ' EOF' > %(filtering_path)s + %(filtering)s + EOF + sievec %(filtering_path)s + EOF + """) % context + ) + else: + self.append("echo '' > %(filtering_path)s" % context) + self.append('chown %(user)s:%(group)s %(filtering_path)s' % context) + + +class UNIXUserMaildirController(SieveFilteringMixin, ServiceController): + """ + Assumes that all system users on this servers all mail accounts. + If you want to have system users AND mailboxes on the same server you should consider using virtual mailboxes. + Supports quota allocation via resources.disk.allocated. + """ + SHELL = '/dev/null' + + verbose_name = _("UNIX maildir user") + model = 'mailboxes.Mailbox' + + def save(self, mailbox): + context = self.get_context(mailbox) + self.append(textwrap.dedent(""" + # Update/create %(user)s user state + if id %(user)s ; then + old_password=$(getent shadow %(user)s | cut -d':' -f2) + usermod %(user)s \\ + --shell %(initial_shell)s \\ + --password '%(password)s' + if [[ "$old_password" != '%(password)s' ]]; then + # Postfix SASL caches passwords + RESTART_POSTFIX=1 + fi + else + useradd %(user)s \\ + --home %(home)s \\ + --password '%(password)s' + fi + mkdir -p %(home)s + chmod 751 %(home)s + chown %(user)s:%(group)s %(home)s""") % context + ) + if hasattr(mailbox, 'resources') and hasattr(mailbox.resources, 'disk'): + self.set_quota(mailbox, context) + self.generate_filter(mailbox, context) + + def set_quota(self, mailbox, context): + allocated = mailbox.resources.disk.allocated + scale = mailbox.resources.disk.resource.get_scale() + context['quota'] = allocated * scale + #unit_to_bytes(mailbox.resources.disk.unit) + self.append(textwrap.dedent(""" + # Set Maildir quota for %(user)s + su - %(user)s --shell /bin/bash << 'EOF' + mkdir -p %(maildir)s + EOF + if [ ! -f %(maildir)s/maildirsize ]; then + echo "%(quota)iS" > %(maildir)s/maildirsize + chown %(user)s:%(group)s %(maildir)s/maildirsize + else + sed -i '1s/.*/%(quota)iS/' %(maildir)s/maildirsize + fi""") % context + ) + + def delete(self, mailbox): + context = self.get_context(mailbox) + if context['deleted_home']: + self.append(textwrap.dedent("""\ + # Move home into MAILBOXES_MOVE_ON_DELETE_PATH, nesting if exists. + deleted_home="%(deleted_home)s" + while [[ -e $deleted_home ]]; do + deleted_home="${deleted_home}/$(basename ${deleted_home})" + done + mv %(home)s $deleted_home || exit_code=$? + """) % context + ) + else: + self.append("rm -fr -- %(base_home)s" % context) + self.append(textwrap.dedent(""" + nohup bash -c '{ sleep 2 && killall -u %(user)s -s KILL; }' &> /dev/null & + killall -u %(user)s || true + # Restart because of Postfix SASL caching credentials + userdel %(user)s && RESTART_POSTFIX=1 || true + groupdel %(user)s || true""") % context + ) + + def commit(self): + self.append('[[ $RESTART_POSTFIX -eq 1 ]] && service postfix restart') + super().commit() + + def get_context(self, mailbox): + context = { + 'user': mailbox.name, + 'group': mailbox.name, + 'name': mailbox.name, + 'password': mailbox.password if mailbox.active else '*%s' % mailbox.password, + 'home': mailbox.get_home(), + 'maildir': os.path.join(mailbox.get_home(), 'Maildir'), + 'initial_shell': self.SHELL, + 'banner': self.get_banner(), + } + context['deleted_home'] = settings.MAILBOXES_MOVE_ON_DELETE_PATH % context + return context + + +#class DovecotPostfixPasswdVirtualUserController(SieveFilteringMixin, ServiceController): +# """ +# WARNING: This backends is not fully implemented +# """ +# DEFAULT_GROUP = 'postfix' +# +# verbose_name = _("Dovecot-Postfix virtualuser") +# model = 'mailboxes.Mailbox' +# +# def set_user(self, context): +# self.append(textwrap.dedent(""" +# if grep '^%(user)s:' %(passwd_path)s > /dev/null ; then +# sed -i 's#^%(user)s:.*#%(passwd)s#' %(passwd_path)s +# else +# echo '%(passwd)s' >> %(passwd_path)s +# fi""") % context +# ) +# self.append("mkdir -p %(home)s" % context) +# self.append("chown %(uid)s:%(gid)s %(home)s" % context) +# +# def set_mailbox(self, context): +# self.append(textwrap.dedent(""" +# if ! grep '^%(user)s@%(mailbox_domain)s\s' %(virtual_mailbox_maps)s > /dev/null; then +# echo "%(user)s@%(mailbox_domain)s\tOK" >> %(virtual_mailbox_maps)s +# UPDATED_VIRTUAL_MAILBOX_MAPS=1 +# fi""") % context +# ) +# +# def save(self, mailbox): +# context = self.get_context(mailbox) +# self.set_user(context) +# self.set_mailbox(context) +# self.generate_filter(mailbox, context) +# +# def delete(self, mailbox): +# context = self.get_context(mailbox) +# self.append(textwrap.dedent(""" +# nohup bash -c 'sleep 2 && killall -u %(uid)s -s KILL' &> /dev/null & +# killall -u %(uid)s || true +# sed -i '/^%(user)s:.*/d' %(passwd_path)s +# sed -i '/^%(user)s@%(mailbox_domain)s\s.*/d' %(virtual_mailbox_maps)s +# UPDATED_VIRTUAL_MAILBOX_MAPS=1""") % context +# ) +# if context['deleted_home']: +# self.append("mv %(home)s %(deleted_home)s || exit_code=$?" % context) +# else: +# self.append("rm -fr -- %(home)s" % context) +# +# def get_extra_fields(self, mailbox, context): +# context['quota'] = self.get_quota(mailbox) +# return 'userdb_mail=maildir:~/Maildir {quota}'.format(**context) +# +# def get_quota(self, mailbox): +# try: +# quota = mailbox.resources.disk.allocated +# except (AttributeError, ObjectDoesNotExist): +# return '' +# unit = mailbox.resources.disk.unit[0].upper() +# return 'userdb_quota_rule=*:bytes=%i%s' % (quota, unit) +# +# def commit(self): +# context = { +# 'virtual_mailbox_maps': settings.MAILBOXES_VIRTUAL_MAILBOX_MAPS_PATH +# } +# self.append(textwrap.dedent(""" +# [[ $UPDATED_VIRTUAL_MAILBOX_MAPS == 1 ]] && { +# postmap %(virtual_mailbox_maps)s +# }""") % context +# ) +# +# def get_context(self, mailbox): +# context = { +# 'name': mailbox.name, +# 'user': mailbox.name, +# 'password': mailbox.password if mailbox.active else '*%s' % mailbox.password, +# 'uid': 10000 + mailbox.pk, +# 'gid': 10000 + mailbox.pk, +# 'group': self.DEFAULT_GROUP, +# 'quota': self.get_quota(mailbox), +# 'passwd_path': settings.MAILBOXES_PASSWD_PATH, +# 'home': mailbox.get_home(), +# 'banner': self.get_banner(), +# 'virtual_mailbox_maps': settings.MAILBOXES_VIRTUAL_MAILBOX_MAPS_PATH, +# 'mailbox_domain': settings.MAILBOXES_VIRTUAL_MAILBOX_DEFAULT_DOMAIN, +# } +# context['extra_fields'] = self.get_extra_fields(mailbox, context) +# context.update({ +# 'passwd': '{user}:{password}:{uid}:{gid}::{home}::{extra_fields}'.format(**context), +# 'deleted_home': settings.MAILBOXES_MOVE_ON_DELETE_PATH % context, +# }) +# return context + + +class PostfixAddressVirtualDomainController(ServiceController): + """ + Secondary SMTP server without mailboxes in it, only syncs virtual domains. + """ + verbose_name = _("Postfix address virtdomain-only") + model = 'mailboxes.Address' + related_models = ( + ('mailboxes.Mailbox', 'addresses'), + ) + doc_settings = (settings, + ('MAILBOXES_LOCAL_DOMAIN', 'MAILBOXES_VIRTUAL_ALIAS_DOMAINS_PATH') + ) + + def is_hosted_domain(self, domain): + """ whether or not domain MX points to this server """ + return domain.has_default_mx() + + def include_virtual_alias_domain(self, context): + domain = context['domain'] + if domain.name != context['local_domain'] and self.is_hosted_domain(domain): + self.append(textwrap.dedent(""" + # %(domain)s is a virtual domain belonging to this server + if ! grep '^\s*%(domain)s\s*$' %(virtual_alias_domains)s > /dev/null; then + echo '%(domain)s' >> %(virtual_alias_domains)s + UPDATED_VIRTUAL_ALIAS_DOMAINS=1 + fi""") % context + ) + + def is_last_domain(self, domain): + return not Address.objects.filter(domain=domain).exists() + + def exclude_virtual_alias_domain(self, context): + domain = context['domain'] + if self.is_last_domain(domain): + # Prevent deleting the same domain multiple times on bulk deletes + if not hasattr(self, '_excluded_domains'): + self._excluded_domains = set() + if domain.name not in self._excluded_domains: + self._excluded_domains.add(domain.name) + self.append(textwrap.dedent(""" + # Delete %(domain)s virtual domain + if grep '^%(domain)s\s*$' %(virtual_alias_domains)s > /dev/null; then + sed -i '/^%(domain)s\s*/d' %(virtual_alias_domains)s + UPDATED_VIRTUAL_ALIAS_DOMAINS=1 + fi""") % context + ) + + def save(self, address): + context = self.get_context(address) + self.include_virtual_alias_domain(context) + return context + + def delete(self, address): + context = self.get_context(address) + self.exclude_virtual_alias_domain(context) + return context + + def commit(self): + context = self.get_context_files() + self.append(textwrap.dedent(""" + [[ $UPDATED_VIRTUAL_ALIAS_DOMAINS == 1 ]] && { + service postfix reload + } + exit $exit_code + """) % context + ) + + def get_context_files(self): + return { + 'virtual_alias_domains': settings.MAILBOXES_VIRTUAL_ALIAS_DOMAINS_PATH, + 'virtual_alias_maps': settings.MAILBOXES_VIRTUAL_ALIAS_MAPS_PATH + } + + def get_context(self, address): + context = self.get_context_files() + context.update({ + 'name': address.name, + 'domain': address.domain, + 'email': address.email, + 'local_domain': settings.MAILBOXES_LOCAL_DOMAIN, + }) + return context + + +class PostfixAddressController(PostfixAddressVirtualDomainController): + """ + Addresses based on Postfix virtual alias domains, includes PostfixAddressVirtualDomainController. + """ + verbose_name = _("Postfix address") + doc_settings = (settings, ( + 'MAILBOXES_LOCAL_DOMAIN', + 'MAILBOXES_VIRTUAL_ALIAS_DOMAINS_PATH', + 'MAILBOXES_VIRTUAL_ALIAS_MAPS_PATH' + )) + + def is_implicit_entry(self, context): + """ + check if virtual_alias_map entry can be omitted because the address is + equivalent to its local mbox + """ + return bool( + context['domain'].name == context['local_domain'] and + context['destination'] == context['name'] and + Mailbox.objects.filter(name=context['name']).exists()) + + def update_virtual_alias_maps(self, address, context): + context['destination'] = address.destination + if not self.is_implicit_entry(context): + self.append(textwrap.dedent(""" + # Set virtual alias entry for %(email)s + LINE='%(email)s\t%(destination)s' + if ! grep '^%(email)s\s' %(virtual_alias_maps)s > /dev/null; then + # Add new line + echo "${LINE}" >> %(virtual_alias_maps)s + UPDATED_VIRTUAL_ALIAS_MAPS=1 + else + # Update existing line, if needed + if ! grep "^${LINE}$" %(virtual_alias_maps)s > /dev/null; then + sed -i "s/^%(email)s\s.*$/${LINE}/" %(virtual_alias_maps)s + UPDATED_VIRTUAL_ALIAS_MAPS=1 + fi + fi""") % context) + else: + if not context['destination']: + msg = "Address %i is empty" % address.pk + self.append("\necho 'msg' >&2" % msg) + logger.warning(msg) + else: + self.append("\n# %(email)s %(destination)s entry is redundant" % context) + self.exclude_virtual_alias_maps(context) + # Virtual mailbox stuff +# destination = [] +# for mailbox in address.get_mailboxes(): +# context['mailbox'] = mailbox +# destination.append("%(mailbox)s@%(local_domain)s" % context) +# for forward in address.forward: +# if '@' in forward: +# destination.append(forward) + + def exclude_virtual_alias_maps(self, context): + self.append(textwrap.dedent("""\ + # Remove %(email)s virtual alias entry + if grep '^%(email)s\s' %(virtual_alias_maps)s > /dev/null; then + sed -i '/^%(email)s\s/d' %(virtual_alias_maps)s + UPDATED_VIRTUAL_ALIAS_MAPS=1 + fi""") % context + ) + + def save(self, address): + context = super().save(address) + self.update_virtual_alias_maps(address, context) + + def delete(self, address): + context = super().delete(address) + self.exclude_virtual_alias_maps(context) + + def commit(self): + context = self.get_context_files() + self.append(textwrap.dedent(""" + # Apply changes if needed + [[ $UPDATED_VIRTUAL_ALIAS_DOMAINS == 1 ]] && { + service postfix reload + } + [[ $UPDATED_VIRTUAL_ALIAS_MAPS == 1 ]] && { + postmap %(virtual_alias_maps)s + } + exit $exit_code + """) % context + ) + + +class AutoresponseController(ServiceController): + """ + WARNING: not implemented + """ + verbose_name = _("Mail autoresponse") + model = 'mailboxes.Autoresponse' + + +class DovecotMaildirDisk(ServiceMonitor): + """ + Maildir disk usage based on Dovecot maildirsize file + http://wiki2.dovecot.org/Quota/Maildir + """ + model = 'mailboxes.Mailbox' + resource = ServiceMonitor.DISK + verbose_name = _("Dovecot Maildir size") + delete_old_equal_values = True + doc_settings = (settings, + ('MAILBOXES_MAILDIRSIZE_PATH',) + ) + + def prepare(self): + super().prepare() + current_date = self.current_date.strftime("%Y-%m-%d %H:%M:%S %Z") + # self.append(textwrap.dedent("""\ + # function monitor () { + # awk 'BEGIN { size = 0 } NR > 1 { size += $1 } END { print size }' $1 || echo 0 + # }""")) + self.append(textwrap.dedent("""\ + function monitor () { + SIZE=$(du -sb $1/Maildir/ 2> /dev/null || echo 0) && echo $SIZE | awk '{print $1}' + }""")) + + def monitor(self, mailbox): + context = self.get_context(mailbox) + # self.append("echo %(object_id)s $(monitor %(maildir_path)s)" % context) + self.append("echo %(object_id)s $(monitor %(home)s)" % context) + + def get_context(self, mailbox): + context = { + 'home': mailbox.get_home(), + 'object_id': mailbox.pk + } + context['maildir_path'] = settings.MAILBOXES_MAILDIRSIZE_PATH % context + return context + + +class PostfixMailscannerTraffic(ServiceMonitor): + """ + A high-performance log parser. + Reads the mail.log file only once, for all users. + """ + model = 'mailboxes.Mailbox' + resource = ServiceMonitor.TRAFFIC + verbose_name = _("Postfix-Mailscanner traffic") + script_executable = '/usr/bin/python' + monthly_sum_old_values = True + doc_settings = (settings, + ('MAILBOXES_MAIL_LOG_PATH',) + ) + + def prepare(self): + mail_log = settings.MAILBOXES_MAIL_LOG_PATH + context = { + 'current_date': self.current_date.strftime("%Y-%m-%d %H:%M:%S %Z"), + 'mail_logs': str((mail_log, mail_log+'.1')), + } + self.append(textwrap.dedent("""\ + import re + import sys + from datetime import datetime + from dateutil import tz + + def to_local_timezone(date, tzlocal=tz.tzlocal()): + # Converts orchestra's UTC dates to local timezone + date = datetime.strptime(date, '%Y-%m-%d %H:%M:%S %Z') + date = date.replace(tzinfo=tz.tzutc()) + date = date.astimezone(tzlocal) + return date + + maillogs = {mail_logs} + end_datetime = to_local_timezone('{current_date}') + end_date = int(end_datetime.strftime('%Y%m%d%H%M%S')) + months = ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec') + months = dict((m, '%02d' % n) for n, m in enumerate(months, 1)) + + def inside_period(month, day, time, ini_date): + global months + global end_datetime + # Mar 9 17:13:22 + month = months[month] + year = end_datetime.year + if month == '12' and end_datetime.month == 1: + year = year+1 + if len(day) == 1: + day = '0' + day + date = str(year) + month + day + date += time.replace(':', '') + return ini_date < int(date) < end_date + + users = {{}} + delivers = {{}} + reverse = {{}} + + def prepare(object_id, mailbox, ini_date): + global users + global delivers + global reverse + ini_date = to_local_timezone(ini_date) + ini_date = int(ini_date.strftime('%Y%m%d%H%M%S')) + users[mailbox] = (ini_date, object_id) + delivers[mailbox] = set() + reverse[mailbox] = set() + + def monitor(users, delivers, reverse, maillogs): + targets = {{}} + counter = {{}} + user_regex = re.compile(r'\(Authenticated sender: ([^ ]+)\)') + for maillog in maillogs: + try: + with open(maillog, 'r') as maillog: + for line in maillog.readlines(): + # Only search for Authenticated sendings + if '(Authenticated sender: ' in line: + username = user_regex.search(line).groups()[0] + try: + sender = users[username] + except KeyError: + continue + else: + month, day, time, __, proc, id = line.split()[:6] + if inside_period(month, day, time, sender[0]): + # Add new email + delivers[id[:-1]] = username + # Look for a MailScanner requeue ID + elif ' Requeue: ' in line: + id, __, req_id = line.split()[6:9] + id = id.split('.')[0] + try: + username = delivers[id] + except KeyError: + pass + else: + targets[req_id] = (username, 0) + reverse[username].add(req_id) + # Look for the mail size and count the number of recipients of each email + else: + try: + month, day, time, __, proc, req_id, __, msize = line.split()[:8] + except ValueError: + # not interested in this line + continue + if proc.startswith('postfix/'): + req_id = req_id[:-1] + if msize.startswith('size='): + try: + target = targets[req_id] + except KeyError: + pass + else: + targets[req_id] = (target[0], int(msize[5:-1])) + elif proc.startswith('postfix/smtp'): + try: + target = targets[req_id] + except KeyError: + pass + else: + if inside_period(month, day, time, users[target[0]][0]): + try: + counter[req_id] += 1 + except KeyError: + counter[req_id] = 1 + except IOError as e: + sys.stderr.write(str(e)+'\\n') + + for username, opts in users.iteritems(): + size = 0 + for req_id in reverse[username]: + size += targets[req_id][1] * counter.get(req_id, 0) + print opts[1], size + """).format(**context) + ) + + def commit(self): + self.append('monitor(users, delivers, reverse, maillogs)') + + def monitor(self, mailbox): + context = self.get_context(mailbox) + self.append("prepare(%(object_id)s, '%(mailbox)s', '%(last_date)s')" % context) + + def get_context(self, mailbox): + context = { + 'mailbox': mailbox.name, + 'object_id': mailbox.pk, + 'last_date': self.get_last_date(mailbox.pk).strftime("%Y-%m-%d %H:%M:%S %Z"), + } + return context + +class RoundcubeIdentityController(ServiceController): + """ + WARNING: not implemented + """ + verbose_name = _("Roundcube Identity Controller") + model = 'mailboxes.Mailbox' + diff --git a/orchestra/contrib/mailboxes/filters.py b/orchestra/contrib/mailboxes/filters.py new file mode 100644 index 0000000..2c1dd60 --- /dev/null +++ b/orchestra/contrib/mailboxes/filters.py @@ -0,0 +1,47 @@ +from django.contrib.admin import SimpleListFilter +from django.utils.translation import gettext_lazy as _ + + +class HasMailboxListFilter(SimpleListFilter): + """ Filter addresses whether they have any mailbox or not """ + title = _("has mailbox") + parameter_name = 'has_mailbox' + + def lookups(self, request, model_admin): + return ( + ('True', _("True")), + ('False', _("False")), + ) + + def queryset(self, request, queryset): + if self.value() == 'True': + return queryset.filter(mailboxes__isnull=False) + elif self.value() == 'False': + return queryset.filter(mailboxes__isnull=True) + return queryset + + +class HasForwardListFilter(HasMailboxListFilter): + """ Filter addresses whether they have any mailbox or not """ + title = _("has forward") + parameter_name = 'has_forward' + + def queryset(self, request, queryset): + if self.value() == 'True': + return queryset.exclude(forward='') + elif self.value() == 'False': + return queryset.filter(forward='') + return queryset + + +class HasAddressListFilter(HasMailboxListFilter): + """ Filter addresses whether they have any mailbox or not """ + title = _("has address") + parameter_name = 'has_address' + + def queryset(self, request, queryset): + if self.value() == 'True': + return queryset.filter(addresses__isnull=False) + elif self.value() == 'False': + return queryset.filter(addresses__isnull=True) + return queryset diff --git a/orchestra/contrib/mailboxes/forms.py b/orchestra/contrib/mailboxes/forms.py new file mode 100644 index 0000000..522b608 --- /dev/null +++ b/orchestra/contrib/mailboxes/forms.py @@ -0,0 +1,79 @@ +from django import forms +from django.contrib.admin import widgets +from django.core.exceptions import ValidationError +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ + +from orchestra.forms import UserCreationForm, UserChangeForm +from orchestra.utils.python import AttrDict + +from . import settings +from .models import Address, Mailbox + + +class MailboxForm(forms.ModelForm): + """ hacky form for adding reverse M2M form field for Mailbox.addresses """ + # TODO keep track of this ticket for future reimplementation + # https://code.djangoproject.com/ticket/897 + addresses = forms.ModelMultipleChoiceField(required=False, + queryset=Address.objects.select_related('domain'), + widget=widgets.FilteredSelectMultiple(verbose_name=_('addresses'), is_stacked=False)) + + def __init__(self, *args, **kwargs): + super(MailboxForm, self).__init__(*args, **kwargs) + # Hack the widget in order to display add button + remote_field_mock = AttrDict(**{ + 'model': Address, + 'get_related_field': lambda: AttrDict(name='id'), + + }) + widget = self.fields['addresses'].widget + self.fields['addresses'].widget = widgets.RelatedFieldWidgetWrapper( + widget, remote_field_mock, self.modeladmin.admin_site, can_add_related=True) + + account = self.modeladmin.account + # Filter related addresses by account + old_render = self.fields['addresses'].widget.render + def render(*args, **kwargs): + output = old_render(*args, **kwargs) + args = 'account=%i&mailboxes=%s' % (account.pk, self.instance.pk) + output = output.replace('/add/?', '/add/?%s&' % args) + return mark_safe(output) + self.fields['addresses'].widget.render = render + queryset = self.fields['addresses'].queryset + realted_addresses = queryset.filter(account_id=account.pk).order_by('name') + self.fields['addresses'].queryset = realted_addresses + + if self.instance and self.instance.pk: + self.fields['addresses'].initial = self.instance.addresses.all() + + def clean_name(self): + name = self.cleaned_data['name'] + max_length = settings.MAILBOXES_NAME_MAX_LENGTH + if len(name) > max_length: + raise ValidationError("Name length should be less than %i." % max_length) + return name + + +class MailboxChangeForm(UserChangeForm, MailboxForm): + pass + + +class MailboxCreationForm(UserCreationForm, MailboxForm): + def clean_name(self): + # Since model.clean() will check this, this is redundant, + # but it sets a nicer error message than the ORM and avoids conflicts with contrib.auth + name = super().clean_name() + try: + self._meta.model._default_manager.get(name=name) + except self._meta.model.DoesNotExist: + return name + raise forms.ValidationError(self.error_messages['duplicate_username']) + + +class AddressForm(forms.ModelForm): + def clean(self): + cleaned_data = super(AddressForm, self).clean() + forward = cleaned_data.get('forward', '') + if not cleaned_data.get('mailboxes', True) and not forward: + raise ValidationError(_("Mailboxes or forward address should be provided.")) diff --git a/orchestra/contrib/mailboxes/models.py b/orchestra/contrib/mailboxes/models.py new file mode 100644 index 0000000..7122b5e --- /dev/null +++ b/orchestra/contrib/mailboxes/models.py @@ -0,0 +1,178 @@ +import os +import re +from collections import defaultdict + +from django.contrib.auth.hashers import make_password +from django.core.validators import RegexValidator, ValidationError +from django.db import models +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ + +from . import validators, settings + + +class Mailbox(models.Model): + CUSTOM = 'CUSTOM' + + name = models.CharField(_("name"), unique=True, db_index=True, + max_length=settings.MAILBOXES_NAME_MAX_LENGTH, + help_text=_("Required. %s characters or fewer. Letters, digits and ./-/_ only.") % + settings.MAILBOXES_NAME_MAX_LENGTH, + validators=[ + RegexValidator(r'^[\w.-]+$', _("Enter a valid mailbox name.")), + ]) + password = models.CharField(_("password"), max_length=128) + account = models.ForeignKey('accounts.Account', verbose_name=_("account"), + related_name='mailboxes', on_delete=models.CASCADE) + filtering = models.CharField(max_length=16, + default=settings.MAILBOXES_MAILBOX_DEFAULT_FILTERING, + choices=[(k, v[0]) for k,v in sorted(settings.MAILBOXES_MAILBOX_FILTERINGS.items())]) + custom_filtering = models.TextField(_("filtering"), blank=True, + validators=[validators.validate_sieve], + help_text=_("Arbitrary email filtering in " + "sieve language. " + "This overrides any automatic junk email filtering")) + is_active = models.BooleanField(_("active"), default=True) + + class Meta: + verbose_name_plural = _("mailboxes") + + def __str__(self): + return self.name + + @cached_property + def active(self): + try: + return self.is_active and self.account.is_active + except type(self).account.field.related_model.DoesNotExist: + return self.is_active + + def disable(self): + self.is_active = False + self.save(update_fields=('is_active',)) + + def enable(self): + self.is_active = False + self.save(update_fields=('is_active',)) + + def set_password(self, raw_password): + self.password = make_password(raw_password) + + def get_home(self): + context = { + 'name': self.name, + 'username': self.name, + } + return os.path.normpath(settings.MAILBOXES_HOME % context) + + def clean(self): + if self.filtering == self.CUSTOM and not self.custom_filtering: + raise ValidationError({ + 'custom_filtering': _("Custom filtering is selected but not provided.") + }) + + def get_filtering(self): + name, content = settings.MAILBOXES_MAILBOX_FILTERINGS[self.filtering] + if callable(content): + # Custom filtering + content = content(self) + return (name, content) + + def get_local_address(self): + if not settings.MAILBOXES_LOCAL_DOMAIN: + raise AttributeError("Mailboxes do not have a defined local address domain.") + return '@'.join((self.name, settings.MAILBOXES_LOCAL_DOMAIN)) + + def get_forwards(self): + return Address.objects.filter(forward__regex=r'(^|.*\s)%s(\s.*|$)' % self.name) + + def get_addresses(self): + mboxes = self.addresses.all() + forwards = self.get_forwards() + return set(mboxes).union(set(forwards)) + + +class Address(models.Model): + name = models.CharField(_("name"), max_length=64, blank=True, + validators=[validators.validate_emailname], + help_text=_("Address name, left blank for a catch-all address")) + domain = models.ForeignKey(settings.MAILBOXES_DOMAIN_MODEL, + verbose_name=_("domain"), related_name='addresses', on_delete=models.CASCADE) + mailboxes = models.ManyToManyField(Mailbox, verbose_name=_("mailboxes"), + related_name='addresses', blank=True) + forward = models.CharField(_("forward"), max_length=256, blank=True, + validators=[validators.validate_forward], + help_text=_("Space separated email addresses or mailboxes")) + account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), + related_name='addresses', on_delete=models.CASCADE) + + class Meta: + verbose_name_plural = _("addresses") + unique_together = ('name', 'domain') + + def __str__(self): + return self.email + + @property + def email(self): + return "%s@%s" % (self.name, self.domain) + + @cached_property + def destination(self): + destinations = list(self.mailboxes.values_list('name', flat=True)) + if self.forward: + destinations += self.forward.split() + return ' '.join(destinations) + + def clean(self): + errors = defaultdict(list) + local_domain = settings.MAILBOXES_LOCAL_DOMAIN + if local_domain: + forwards = self.forward.split() + for ix, forward in enumerate(forwards): + if forward.endswith('@%s' % local_domain): + name = forward.split('@')[0] + if Mailbox.objects.filter(name=name).exists(): + forwards[ix] = name + self.forward = ' '.join(forwards) + if self.account_id: + for mailbox in self.get_forward_mailboxes(): + if mailbox.account_id == self.account_id: + errors['forward'].append( + _("Please use mailboxes field for '%s' mailbox.") % mailbox + ) + if self.domain: + for forward in self.forward.split(): + if self.email == forward: + errors['forward'].append( + _("'%s' forwards to itself.") % forward + ) + if errors: + raise ValidationError(errors) + + def get_forward_mailboxes(self): + rm_local_domain = re.compile(r'@%s$' % settings.MAILBOXES_LOCAL_DOMAIN) + mailboxes = [] + for forward in self.forward.split(): + forward = rm_local_domain.sub('', forward) + if '@' not in forward: + mailboxes.append(forward) + return Mailbox.objects.filter(name__in=mailboxes) + + def get_mailboxes(self): + for mailbox in self.mailboxes.all(): + yield mailbox + for mailbox in self.get_forward_mailboxes(): + yield mailbox + + +class Autoresponse(models.Model): + address = models.OneToOneField(Address, verbose_name=_("address"), + related_name='autoresponse', on_delete=models.CASCADE) + # TODO initial_date + subject = models.CharField(_("subject"), max_length=256) + message = models.TextField(_("message")) + enabled = models.BooleanField(_("enabled"), default=False) + + def __str__(self): + return self.address diff --git a/orchestra/contrib/mailboxes/serializers.py b/orchestra/contrib/mailboxes/serializers.py new file mode 100644 index 0000000..1608a6c --- /dev/null +++ b/orchestra/contrib/mailboxes/serializers.py @@ -0,0 +1,107 @@ +from django.db import transaction +from rest_framework import serializers + +from orchestra.api.serializers import SetPasswordHyperlinkedSerializer, RelatedHyperlinkedModelSerializer +from orchestra.contrib.accounts.serializers import AccountSerializerMixin + +from .models import Mailbox, Address + + +class RelatedDomainSerializer(AccountSerializerMixin, RelatedHyperlinkedModelSerializer): + class Meta: + model = Address.domain.field.related_model + fields = ('url', 'id', 'name') + + +class RelatedAddressSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer): + domain = RelatedDomainSerializer() + + class Meta: + model = Address + fields = ('url', 'id', 'name', 'domain', 'forward') +# +# def from_native(self, data, files=None): +# queryset = self.opts.model.objects.filter(account=self.account) +# return get_object_or_404(queryset, name=data['name']) + + +class MailboxSerializer(AccountSerializerMixin, SetPasswordHyperlinkedSerializer): + addresses = RelatedAddressSerializer(many=True, read_only=True) + + class Meta: + model = Mailbox + fields = ( + 'url', 'id', 'name', 'password', 'filtering', 'custom_filtering', 'addresses', 'is_active' + ) + postonly_fields = ('name', 'password') + + +class AddressRelatedField(serializers.HyperlinkedRelatedField): + # Filter addresses by account (user) + def get_queryset(self): + qs = super().get_queryset() + return qs.filter(account=self.context['account']) + + +class MailboxWritableSerializer(AccountSerializerMixin, SetPasswordHyperlinkedSerializer): + addresses = AddressRelatedField(many=True, view_name='address-detail', queryset=Address.objects.all()) + + class Meta: + model = Mailbox + fields = ( + 'url', 'id', 'name', 'password', 'filtering', 'custom_filtering', 'addresses', 'is_active' + ) + postonly_fields = ('name', 'password') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['addresses'].context['account'] = self.account + + @transaction.atomic + def create(self, validated_data): + addresses = validated_data.pop('addresses', []) + instance = super().create(validated_data) + instance.addresses.set(addresses) + return instance + + @transaction.atomic + def update(self, instance, validated_data): + addresses = validated_data.pop('addresses', []) + instance.addresses.set(addresses) + return super().update(instance, validated_data) + + +class RelatedMailboxSerializer(AccountSerializerMixin, RelatedHyperlinkedModelSerializer): + class Meta: + model = Mailbox + fields = ('url', 'id', 'name') + + +class AddressSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer): + domain = RelatedDomainSerializer() + mailboxes = RelatedMailboxSerializer(many=True, required=False) + + class Meta: + model = Address + fields = ('url', 'id', 'name', 'domain', 'mailboxes', 'forward') + + def validate(self, attrs): + attrs = super(AddressSerializer, self).validate(attrs) + mailboxes = attrs.get('mailboxes', []) + forward = attrs.get('forward', '') + if not mailboxes and not forward: + raise serializers.ValidationError("A mailbox or forward address should be provided.") + return attrs + + @transaction.atomic + def create(self, validated_data): + mailboxes = validated_data.pop('mailboxes', []) + obj = super().create(validated_data) + obj.mailboxes.set(mailboxes) + return obj + + @transaction.atomic + def update(self, instance, validated_data): + mailboxes = validated_data.pop('mailboxes', []) + instance.mailboxes.set(mailboxes) + return super().update(instance, validated_data) diff --git a/orchestra/contrib/mailboxes/settings.py b/orchestra/contrib/mailboxes/settings.py new file mode 100644 index 0000000..c941275 --- /dev/null +++ b/orchestra/contrib/mailboxes/settings.py @@ -0,0 +1,205 @@ +import os +import textwrap + +from django.utils.functional import lazy +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.settings import Setting +from orchestra.core.validators import validate_name +from orchestra.settings import ORCHESTRA_BASE_DOMAIN + + +_names = ('name', 'username',) +_backend_names = _names + ('user', 'group', 'home') +mark_safe_lazy = lazy(mark_safe, str) + + +MAILBOXES_DOMAIN_MODEL = Setting('MAILBOXES_DOMAIN_MODEL', 'domains.Domain', + validators=[Setting.validate_model_label] +) + + +MAILBOXES_NAME_MAX_LENGTH = Setting('MAILBOXES_NAME_MAX_LENGTH', + 32, + help_text=_("Limit for system user based mailbox on Linux is 32.") +) + + +MAILBOXES_HOME = Setting('MAILBOXES_HOME', + '/home/%(name)s', + help_text="Available fromat names: %s" % ', '.join(_names), + validators=[Setting.string_format_validator(_names)], +) + + +MAILBOXES_SIEVE_PATH = Setting('MAILBOXES_SIEVE_PATH', + os.path.join('%(home)s/sieve/orchestra.sieve'), + help_text="If you are using Dovecot you can use " + "" + "sieve_before in order to make sure orchestra sieve script is exectued." + "
    Available fromat names: %s" % ', '.join(_names), + validators=[Setting.string_format_validator(_backend_names)], +) + + +MAILBOXES_SIEVETEST_PATH = Setting('MAILBOXES_SIEVETEST_PATH', + '/dev/shm' +) + + +MAILBOXES_SIEVETEST_BIN_PATH = Setting('MAILBOXES_SIEVETEST_BIN_PATH', + '%(orchestra_root)s/bin/sieve-test', + validators=[Setting.string_format_validator(('orchestra_root',))] +) + + +MAILBOXES_VIRTUAL_MAILBOX_MAPS_PATH = Setting('MAILBOXES_VIRTUAL_MAILBOX_MAPS_PATH', + '/etc/postfix/virtual_mailboxes' +) + + +MAILBOXES_VIRTUAL_ALIAS_MAPS_PATH = Setting('MAILBOXES_VIRTUAL_ALIAS_MAPS_PATH', + '/etc/postfix/virtual_aliases' +) + + +MAILBOXES_VIRTUAL_ALIAS_DOMAINS_PATH = Setting('MAILBOXES_VIRTUAL_ALIAS_DOMAINS_PATH', + '/etc/postfix/virtual_domains' +) + + +MAILBOXES_LOCAL_DOMAIN = Setting('MAILBOXES_LOCAL_DOMAIN', + ORCHESTRA_BASE_DOMAIN, + validators=[validate_name], + help_text="Defaults to ORCHESTRA_BASE_DOMAIN." +) + + +MAILBOXES_PASSWD_PATH = Setting('MAILBOXES_PASSWD_PATH', + '/etc/dovecot/passwd' +) + + +MAILBOXES_SPAM_SCORE_HEADER = Setting('MAILBOXES_SPAM_SCORE_HEADER', + 'X-Spam-Score' +) + + +MAILBOXES_SPAM_SCORE_SYMBOL = Setting('MAILBOXES_SPAM_SCORE_SYMBOL', + '', + help_text="Blank for numeric spam score.", +) + + +MAILBOXES_MAILBOX_FILTERINGS = Setting('MAILBOXES_MAILBOX_FILTERINGS', + { + # value: (verbose_name, filter) + 'DISABLE': (_("Disable"), ''), + 'REJECT': (mark_safe_lazy(_("Reject spam (Score≥8)")), ( + textwrap.dedent("""\ + if header :contains "%(score_header)s" "%(score_value)s" { + discard; + stop; + }""") if MAILBOXES_SPAM_SCORE_SYMBOL else + textwrap.dedent("""\ + require ["relational","comparator-i;ascii-numeric"]; + if allof ( + not header :matches "%(score_header)s" "-*", + header :value "ge" :comparator "i;ascii-numeric" "%(score_header)s" "8" ) + { + discard; + stop; + }""")) % { + 'score_header': MAILBOXES_SPAM_SCORE_HEADER, + 'score_value': MAILBOXES_SPAM_SCORE_SYMBOL*8 + } + ), + 'REJECT5': (mark_safe_lazy(_("Reject spam (Score≥5)")), ( + textwrap.dedent("""\ + if header :contains "%(score_header)s" "%(score_value)s" { + discard; + stop; + }""") if MAILBOXES_SPAM_SCORE_SYMBOL else + textwrap.dedent("""\ + require ["relational","comparator-i;ascii-numeric"]; + if allof ( + not header :matches "%(score_header)s" "-*", + header :value "ge" :comparator "i;ascii-numeric" "%(score_header)s" "5" ) + { + discard; + stop; + }""")) % { + 'score_header': MAILBOXES_SPAM_SCORE_HEADER, + 'score_value': MAILBOXES_SPAM_SCORE_SYMBOL*5 + } + ), + 'REDIRECT': (mark_safe_lazy(_("Archive spam (Score≥8)")), ( + textwrap.dedent("""\ + require "fileinto"; + if header :contains "%(score_header)s" "%(score_value)s" { + fileinto "Spam"; + stop; + }""") if MAILBOXES_SPAM_SCORE_SYMBOL else + textwrap.dedent("""\ + require ["fileinto","relational","comparator-i;ascii-numeric"]; + if allof ( + not header :matches "%(score_header)s" "-*", + header :value "ge" :comparator "i;ascii-numeric" "%(score_header)s" "8" ) + { + fileinto "Spam"; + stop; + }""")) % { + 'score_header': MAILBOXES_SPAM_SCORE_HEADER, + 'score_value': MAILBOXES_SPAM_SCORE_SYMBOL*8 + } + ), + 'REDIRECT5': (mark_safe_lazy(_("Archive spam (Score≥5)")), ( + textwrap.dedent("""\ + require "fileinto"; + if header :contains "%(score_header)s" "%(score_value)s" { + fileinto "Spam"; + stop; + }""") if MAILBOXES_SPAM_SCORE_SYMBOL else + textwrap.dedent("""\ + require ["fileinto","relational","comparator-i;ascii-numeric"]; + if allof ( + not header :matches "%(score_header)s" "-*", + header :value "ge" :comparator "i;ascii-numeric" "%(score_header)s" "5" ) + { + fileinto "Spam"; + stop; + }""")) % { + 'score_header': MAILBOXES_SPAM_SCORE_HEADER, + 'score_value': MAILBOXES_SPAM_SCORE_SYMBOL*5 + } + ), + 'CUSTOM': (_("Custom filtering"), lambda mailbox: mailbox.custom_filtering), + } +) + + +MAILBOXES_MAILBOX_DEFAULT_FILTERING = Setting('MAILBOXES_MAILBOX_DEFAULT_FILTERING', + 'REDIRECT', + choices=tuple((k, v[0]) for k,v in MAILBOXES_MAILBOX_FILTERINGS.items()) +) + + +MAILBOXES_MAILDIRSIZE_PATH = Setting('MAILBOXES_MAILDIRSIZE_PATH', + '%(home)s/Maildir/maildirsize', + help_text="Available fromat names: %s" % ', '.join(_backend_names), + validators=[Setting.string_format_validator(_backend_names)], +) + + + +MAILBOXES_MAIL_LOG_PATH = Setting('MAILBOXES_MAIL_LOG_PATH', + '/var/log/mail.log' +) + + +MAILBOXES_MOVE_ON_DELETE_PATH = Setting('MAILBOXES_MOVE_ON_DELETE_PATH', + '', + help_text="Available fromat names: %s" % ', '.join(_backend_names), + validators=[Setting.string_format_validator(_backend_names)], +) diff --git a/orchestra/contrib/mailboxes/signals.py b/orchestra/contrib/mailboxes/signals.py new file mode 100644 index 0000000..5cfdcef --- /dev/null +++ b/orchestra/contrib/mailboxes/signals.py @@ -0,0 +1,51 @@ +from django.db.models.signals import pre_save, post_delete, post_save +from django.dispatch import receiver + +from . import settings +from .models import Mailbox, Address + + +# Admin bulk deletion doesn't call model.delete() +# So, signals are used instead of model method overriding + +@receiver(post_delete, sender=Mailbox, dispatch_uid='mailboxes.delete_forwards') +def delete_forwards(sender, *args, **kwargs): + # Cleanup related addresses + instance = kwargs['instance'] + for address in instance.get_forwards(): + forward = address.forward.split() + forward.remove(instance.name) + address.forward = ' '.join(forward) + if not address.destination: + address.delete() + else: + address.save() + + +@receiver(pre_save, sender=Mailbox, dispatch_uid='mailboxes.create_local_address') +def create_local_address(sender, *args, **kwargs): + mbox = kwargs['instance'] + local_domain = settings.MAILBOXES_LOCAL_DOMAIN + if not mbox.pk and local_domain: + Domain = Address._meta.get_field('domain').remote_field.model + try: + domain = Domain.objects.get(name=local_domain) + except Domain.DoesNotExist: + pass + else: + addr, created = Address.objects.get_or_create( + name=mbox.name, domain=domain, account_id=domain.account_id) + if created: + if domain.account_id == mbox.account_id: + mbox._post_save_add_address = addr + else: + addr.forward = mbox.name + addr.save(update_fields=('forward',)) + + +@receiver(post_save, sender=Mailbox, dispatch_uid='mailboxes.add_local_address') +def add_local_address(sender, *args, **kwargs): + mbox = kwargs['instance'] + addr = getattr(mbox, '_post_save_add_address', None) + if addr: + addr.mailboxes.add(mbox) diff --git a/orchestra/contrib/mailboxes/tests/__init__.py b/orchestra/contrib/mailboxes/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestra/contrib/mailboxes/tests/functional_tests/__init__.py b/orchestra/contrib/mailboxes/tests/functional_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestra/contrib/mailboxes/tests/functional_tests/tests.py b/orchestra/contrib/mailboxes/tests/functional_tests/tests.py new file mode 100644 index 0000000..6ae693b --- /dev/null +++ b/orchestra/contrib/mailboxes/tests/functional_tests/tests.py @@ -0,0 +1,380 @@ +import imaplib +import os +import poplib +import smtplib +import time +import textwrap +import unittest +from email.mime.text import MIMEText + +from django.apps import apps +from django.conf import settings as djsettings +from django.contrib.contenttypes.models import ContentType +from django.core.management.base import CommandError +from django.urls import reverse +from selenium.webdriver.support.select import Select + +from orchestra.contrib.orchestration.models import Server, Route +from orchestra.contrib.resources.models import Resource +from orchestra.utils.sys import sshrun +from orchestra.utils.tests import BaseLiveServerTestCase, random_ascii, snapshot_on_error, save_response_on_error + +from ... import backends, settings +from ...models import Mailbox + +TEST_REST_API = int(os.getenv('TEST_REST_API', '0')) + + +class MailboxMixin(object): + MASTER_SERVER = os.environ.get('ORCHESTRA_SLAVE_SERVER', 'localhost') + DEPENDENCIES = ( + 'orchestra.contrib.orchestration', + 'orchestra.contrib.mails', + 'orchestra.contrib.resources', + ) + + def setUp(self): + super(MailboxMixin, self).setUp() + self.add_route() + # clean resource relation from other tests + apps.get_app_config('resources').reload_relations() + djsettings.DEBUG = True + + def add_route(self): + server = Server.objects.create(name=self.MASTER_SERVER) + backend = backends.RoundcubeIdentityController.get_name() + Route.objects.create(backend=backend, match=True, host=server) + backend = backends.PostfixAddressController.get_name() + Route.objects.create(backend=backend, match=True, host=server) + + def add_quota_resource(self): + Resource.objects.create( + name='disk', + content_type=ContentType.objects.get_for_model(Mailbox), + period=Resource.LAST, + verbose_name='Mail quota', + unit='MB', + scale=10**6, + on_demand=False, + default_allocation=2000 + ) + + def save(self): + raise NotImplementedError + + def add(self): + raise NotImplementedError + + def delete(self): + raise NotImplementedError + + def update(self): + raise NotImplementedError + + def disable(self): + raise NotImplementedError + + def add_group(self, username, groupname): + raise NotImplementedError + + def login_imap(self, username, password): + mail = imaplib.IMAP4_SSL(self.MASTER_SERVER) + status, msg = mail.login(username, password) + self.assertEqual('OK', status) + self.assertEqual(['Logged in'], msg) + return mail + + def login_pop3(self, username, password): + pop = poplib.POP3(self.MASTER_SERVER) + pop.user(username) + pop.pass_(password) + return pop + + def send_email(self, to, token): + msg = MIMEText(token) + msg['To'] = to + msg['From'] = 'orchestra@%s' % self.MASTER_SERVER + msg['Subject'] = 'test' + server = smtplib.SMTP(self.MASTER_SERVER, 25) + try: + server.ehlo() + server.starttls() + server.ehlo() + server.sendmail(msg['From'], msg['To'], msg.as_string()) + finally: + server.quit() + + def validate_mailbox(self, username): + sshrun(self.MASTER_SERVER, "doveadm search -u %s ALL" % username, display=False) + + def validate_email(self, username, token): + home = Mailbox.objects.get(name=username).get_home() + sshrun(self.MASTER_SERVER, "grep '%s' %s/Maildir/new/*" % (token, home), display=False) + + def test_add(self): + username = '%s_mailbox' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + self.add(username, password) + self.addCleanup(self.delete, username) + imap = self.login_imap(username, password) + self.validate_mailbox(username) + + def test_change_password(self): + username = '%s_systemuser' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + self.add(username, password) + self.addCleanup(self.delete, username) + imap = self.login_imap(username, password) + new_password = '@!?%spppP001' % random_ascii(5) + self.change_password(username, new_password) + imap = self.login_imap(username, new_password) + + def test_quota(self): + username = '%s_mailbox' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + self.add_quota_resource() + quota = 100 + self.add(username, password, quota=quota) + self.addCleanup(self.delete, username) + get_quota = "doveadm quota get -u %s 2>&1|grep STORAGE|awk {'print $5'}" % username + stdout = sshrun(self.MASTER_SERVER, get_quota, display=False).stdout + self.assertEqual(quota*1024, int(stdout)) + imap = self.login_imap(username, password) + imap_quota = int(imap.getquotaroot("INBOX")[1][1][0].split(' ')[-1].split(')')[0]) + self.assertEqual(quota*1024, imap_quota) + + def test_send_email(self): + username = '%s_mailbox' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + self.add(username, password) + self.addCleanup(self.delete, username) + msg = MIMEText("Hola bishuns") + msg['To'] = 'noexists@example.com' + msg['From'] = '%s@%s' % (username, self.MASTER_SERVER) + msg['Subject'] = "test" + server = smtplib.SMTP(self.MASTER_SERVER, 25) + server.login(username, password) + try: + server.sendmail(msg['From'], msg['To'], msg.as_string()) + finally: + server.quit() + + def test_address(self): + username = '%s_mailbox' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + self.add(username, password) + self.addCleanup(self.delete, username) + domain = '%s_domain.lan' % random_ascii(5) + name = '%s_name' % random_ascii(5) + domain = self.account.domains.create(name=domain) + self.add_address(username, name, domain) + token = random_ascii(100) + self.send_email("%s@%s" % (name, domain), token) + self.validate_email(username, token) + + def test_disable(self): + username = '%s_systemuser' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + self.add(username, password) + self.validate_mailbox(username) +# self.addCleanup(self.delete, username) + imap = self.login_imap(username, password) + self.disable(username) + self.assertRaises(imap.error, self.login_imap, username, password) + + def test_delete(self): + username = '%s_systemuser' % random_ascii(10) + password = '@!?%sppppP001' % random_ascii(5) + self.add(username, password) + imap = self.login_imap(username, password) + self.validate_mailbox(username) + mailbox = Mailbox.objects.get(name=username) + home = mailbox.get_home() + self.delete(username) + self.assertRaises(Mailbox.DoesNotExist, Mailbox.objects.get, name=username) + self.assertRaises(CommandError, self.validate_mailbox, username) + self.assertRaises(imap.error, self.login_imap, username, password) + self.assertRaises(CommandError, + sshrun, self.MASTER_SERVER, 'ls %s' % home, display=False) + + def test_delete_address(self): + username = '%s_mailbox' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + self.add(username, password) + self.addCleanup(self.delete, username) + domain = '%s_domain.lan' % random_ascii(5) + name = '%s_name' % random_ascii(5) + domain = self.account.domains.create(name=domain) + self.add_address(username, name, domain) + token = random_ascii(100) + self.send_email("%s@%s" % (name, domain), token) + self.validate_email(username, token) + self.delete_address(username) + self.send_email("%s@%s" % (name, domain), token) + self.validate_email(username, token) + + def test_custom_filtering(self): + username = '%s_mailbox' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + folder = random_ascii(5) + filtering = textwrap.dedent(""" + require "fileinto"; + if true { + fileinto "%s"; + stop; + }""" % folder) + self.add(username, password, filtering=filtering) + self.addCleanup(self.delete, username) + imap = self.login_imap(username, password) + imap.create(folder) + self.validate_mailbox(username) + token = random_ascii(100) + self.send_email("%s@%s" % (username, settings.MAILBOXES_VIRTUAL_MAILBOX_DEFAULT_DOMAIN), token) + home = Mailbox.objects.get(name=username).get_home() + sshrun(self.MASTER_SERVER, + "grep '%s' %s/Maildir/.%s/new/*" % (token, home, folder), display=False) + +# TODO test update shit +# TODO test autoreply + + +@unittest.skipUnless(TEST_REST_API, "REST API tests") +class RESTMailboxMixin(MailboxMixin): + def setUp(self): + super(RESTMailboxMixin, self).setUp() + self.rest_login() + + @save_response_on_error + def add(self, username, password, quota=None, filtering=None): + extra = {} + if quota: + extra.update({ + "resources": [ + { + "name": "disk", + "allocated": quota + }, + ] + }) + if filtering: + extra.update({ + 'filtering': 'CUSTOM', + 'custom_filtering': filtering, + }) + self.rest.mailboxes.create(name=username, password=password, **extra) + + @save_response_on_error + def delete(self, username): + mailbox = self.rest.mailboxes.retrieve(name=username).get() + mailbox.delete() + + @save_response_on_error + def change_password(self, username, password): + mailbox = self.rest.mailboxes.retrieve(name=username).get() + mailbox.change_password(password) + + @save_response_on_error + def add_address(self, username, name, domain): + mailbox = self.rest.mailboxes.retrieve(name=username).get() + domain = self.rest.domains.retrieve(name=domain.name).get() + self.rest.addresses.create(name=name, domain=domain, mailboxes=[mailbox]) + + @save_response_on_error + def delete_address(self, username): + mailbox = self.rest.mailboxes.retrieve(name=username).get() + self.rest.addresses.delete() + + @save_response_on_error + def disable(self, username): + mailbox = self.rest.mailboxes.retrieve(name=username).get() + mailbox.update(is_active=False) + + +class AdminMailboxMixin(MailboxMixin): + def setUp(self): + super(AdminMailboxMixin, self).setUp() + self.admin_login() + + @snapshot_on_error + def add(self, username, password, quota=None, filtering=None): + url = self.live_server_url + reverse('admin:mailboxes_mailbox_add') + self.selenium.get(url) + +# account_input = self.selenium.find_element_by_id('id_account') +# account_select = Select(account_input) +# account_select.select_by_value(str(self.account.pk)) + + name_field = self.selenium.find_element_by_id('id_name') + name_field.send_keys(username) + + password_field = self.selenium.find_element_by_id('id_password1') + password_field.send_keys(password) + password_field = self.selenium.find_element_by_id('id_password2') + password_field.send_keys(password) + + if quota is not None: + quota_id = 'id_resources-resourcedata-content_type-object_id-0-allocated' + quota_field = self.selenium.find_element_by_id(quota_id) + quota_field.clear() + quota_field.send_keys(quota) + + if filtering is not None: + filtering_input = self.selenium.find_element_by_id('id_filtering') + filtering_select = Select(filtering_input) + filtering_select.select_by_value("CUSTOM") + filtering_inline = self.selenium.find_element_by_id('fieldsetcollapser0') + filtering_inline.click() + time.sleep(0.5) + filtering_field = self.selenium.find_element_by_id('id_custom_filtering') + filtering_field.send_keys(filtering) + + name_field.submit() + self.assertNotEqual(url, self.selenium.current_url) + + @snapshot_on_error + def delete(self, username): + mailbox = Mailbox.objects.get(name=username) + self.admin_delete(mailbox) + + @snapshot_on_error + def change_password(self, username, password): + mailbox = Mailbox.objects.get(name=username) + self.admin_change_password(mailbox, password) + + @snapshot_on_error + def add_address(self, username, name, domain): + url = self.live_server_url + reverse('admin:mailboxes_address_add') + self.selenium.get(url) + + name_field = self.selenium.find_element_by_id('id_name') + name_field.send_keys(name) + + domain_input = self.selenium.find_element_by_id('id_domain') + domain_select = Select(domain_input) + domain_select.select_by_value(str(domain.pk)) + + mailboxes = self.selenium.find_element_by_id('id_mailboxes_add_all_link') + mailboxes.click() + time.sleep(0.5) + name_field.submit() + + self.assertNotEqual(url, self.selenium.current_url) + + @snapshot_on_error + def delete_address(self, username): + mailbox = Mailbox.objects.get(name=username) + address = mailbox.addresses.get() + self.admin_delete(address) + + @snapshot_on_error + def disable(self, username): + mailbox = Mailbox.objects.get(name=username) + self.admin_disable(mailbox) + + +class RESTMailboxTest(RESTMailboxMixin, BaseLiveServerTestCase): + pass + + +class AdminMailboxTest(AdminMailboxMixin, BaseLiveServerTestCase): + pass diff --git a/orchestra/contrib/mailboxes/validators.py b/orchestra/contrib/mailboxes/validators.py new file mode 100644 index 0000000..5a33b16 --- /dev/null +++ b/orchestra/contrib/mailboxes/validators.py @@ -0,0 +1,70 @@ +import hashlib +import os +import re + +from django.core.validators import ValidationError, EmailValidator +from django.utils.translation import gettext_lazy as _ + +from orchestra.utils import paths +from orchestra.utils.sys import run + +from . import settings + + +def validate_emailname(value): + msg = _("'%s' is not a correct email name." % value) + if '@' in value: + raise ValidationError(msg) + value += '@localhost' + try: + EmailValidator()(value) + except ValidationError: + raise ValidationError(msg) + + +def validate_forward(value): + """ space separated mailboxes or emails """ + from .models import Mailbox + errors = [] + destinations = [] + for destination in value.split(): + if destination in destinations: + errors.append(ValidationError( + _("'%s' is already present.") % destination + )) + destinations.append(destination) + if '@' in destination: + try: + EmailValidator()(destination) + except ValidationError: + errors.append(ValidationError( + _("'%s' is not a valid email address.") % destination + )) + elif not Mailbox.objects.filter(name=destination).exists(): + errors.append(ValidationError( + _("'%s' is not an existent mailbox.") % destination + )) + if errors: + raise ValidationError(errors) + + +def validate_sieve(value): + sieve_name = '%s.sieve' % hashlib.md5(value.encode('utf8')).hexdigest() + test_path = os.path.join(settings.MAILBOXES_SIEVETEST_PATH, sieve_name) + with open(test_path, 'w') as f: + f.write(value) + context = { + 'orchestra_root': paths.get_orchestra_dir() + } + sievetest = settings.MAILBOXES_SIEVETEST_BIN_PATH % context + try: + test = run(' '.join([sievetest, test_path, '/dev/null']), silent=True) + finally: + os.unlink(test_path) + if test.exit_code: + errors = [] + for line in test.stderr.decode('utf8').splitlines(): + error = re.match(r'^.*(line\s+[0-9]+:.*)', line) + if error: + errors += error.groups() + raise ValidationError(' '.join(errors)) diff --git a/orchestra/contrib/mailboxes/widgets.py b/orchestra/contrib/mailboxes/widgets.py new file mode 100644 index 0000000..ad92adc --- /dev/null +++ b/orchestra/contrib/mailboxes/widgets.py @@ -0,0 +1,33 @@ +import textwrap + +from django import forms + + +class OpenCustomFilteringOnSelect(forms.Select): + def __init__(self, *args, **kwargs): + collapse = self.get_dynamic_collapse() + attrs = kwargs.get('attrs', {}) + attrs.update({ + 'onClick': collapse, + 'onChange': collapse, + }) + attrs.update(kwargs.get('attrs', {})) + kwargs['attrs'] = attrs + super(OpenCustomFilteringOnSelect, self).__init__(*args, **kwargs) + + def get_dynamic_collapse(self): + return textwrap.dedent("""\ + value = this.options[this.selectedIndex].value; + fieldset = $(this).closest("fieldset"); + fieldset = $(".collapse"); + if ( value == 'CUSTOM' ) { + if (fieldset.hasClass("collapsed")) { + fieldset.removeClass("collapsed").trigger("show.fieldset", [$(this).attr("id")]); + } + } else { + if (! $(this).closest("fieldset").hasClass("collapsed")) { + fieldset.addClass("collapsed").trigger("hide.fieldset", [$(this).attr("id")]); + } + } + """ + ) diff --git a/orchestra/contrib/mailer/README.md b/orchestra/contrib/mailer/README.md new file mode 100644 index 0000000..1b7eae1 --- /dev/null +++ b/orchestra/contrib/mailer/README.md @@ -0,0 +1,5 @@ +This is a simplified clone of [django-mailer](https://github.com/pinax/django-mailer). + +Using `orchestra.contrib.mailer.backends.EmailBackend` as your email backend will have the following effects: + * E-mails sent with Django's `send_mass_mail()` will be queued and sent by an out-of-band perioic task. + * E-mails sent with Django's `send_mail()` will be sent right away by an asynchronous background task. diff --git a/orchestra/contrib/mailer/__init__.py b/orchestra/contrib/mailer/__init__.py new file mode 100644 index 0000000..335e52d --- /dev/null +++ b/orchestra/contrib/mailer/__init__.py @@ -0,0 +1 @@ +default_app_config = 'orchestra.contrib.mailer.apps.MailerConfig' diff --git a/orchestra/contrib/mailer/actions.py b/orchestra/contrib/mailer/actions.py new file mode 100644 index 0000000..1ba1b90 --- /dev/null +++ b/orchestra/contrib/mailer/actions.py @@ -0,0 +1,8 @@ +from django.urls import reverse +from django.shortcuts import redirect + + +def last(modeladmin, request, queryset): + last = queryset.model.objects.latest('id') + url = reverse('admin:mailer_message_change', args=(last.pk,)) + return redirect(url) diff --git a/orchestra/contrib/mailer/admin.py b/orchestra/contrib/mailer/admin.py new file mode 100644 index 0000000..9d508c1 --- /dev/null +++ b/orchestra/contrib/mailer/admin.py @@ -0,0 +1,157 @@ +import base64 +import email + +from django import forms +from django.contrib import admin +from django.urls import reverse +from django.db.models import Count +from django.shortcuts import redirect +from django.utils.html import format_html +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ + +from orchestra.admin import ExtendedModelAdmin +from orchestra.admin.utils import admin_link, admin_colored, admin_date, wrap_admin_view +from orchestra.contrib.tasks import task + +from .actions import last +from .engine import send_pending +from .models import Message, SMTPLog + + +COLORS = { + Message.QUEUED: 'purple', + Message.SENT: 'green', + Message.DEFERRED: 'darkorange', + Message.FAILED: 'red', + SMTPLog.SUCCESS: 'green', + SMTPLog.FAILURE: 'red', +} + + +class MessageAdmin(ExtendedModelAdmin): + list_display = ( + 'display_subject', 'colored_state', 'priority', 'to_address', 'from_address', + 'created_at_delta', 'display_retries', 'last_try_delta', + ) + list_filter = ('state', 'priority', 'retries') + list_prefetch_related = ('logs',) + search_fields = ('to_address', 'from_address', 'subject',) + fieldsets = ( + (None, { + 'fields': ('state', 'priority', ('retries', 'last_try_delta', 'created_at_delta'), + 'display_full_subject', 'display_from', 'display_to', + 'display_content'), + }), + (_("Edit"), { + 'classes': ('collapse',), + 'fields': ('subject', 'from_address', 'to_address', 'content'), + }), + ) + readonly_fields = ( + 'retries', 'last_try_delta', 'created_at_delta', 'display_full_subject', + 'display_to', 'display_from', 'display_content', + ) + date_hierarchy = 'created_at' + change_view_actions = (last,) + + colored_state = admin_colored('state', colors=COLORS) + created_at_delta = admin_date('created_at') + last_try_delta = admin_date('last_try') + + def display_subject(self, instance): + subject = instance.subject + if len(subject) > 64: + return mark_safe(subject[:64] + '…') + return subject + display_subject.short_description = _("Subject") + display_subject.admin_order_field = 'subject' + + def display_retries(self, instance): + num_logs = instance.logs__count + if num_logs == 1: + pk = instance.logs.all()[0].id + url = reverse('admin:mailer_smtplog_change', args=(pk,)) + else: + url = reverse('admin:mailer_smtplog_changelist') + url += '?&message=%i' % instance.pk + return format_html('{}', url, instance.retries) + display_retries.short_description = _("Retries") + display_retries.admin_order_field = 'retries' + + def display_content(self, instance): + part = email.message_from_string(instance.content) + payload = part.get_payload() + if isinstance(payload, list): + for cpart in payload: + cpayload = cpart.get_payload() + if cpart.get_content_type().startswith('text/'): + part = cpart + payload = cpayload + if cpart.get_content_type() == 'text/html': + payload = '
    %s
    ' % payload + # prioritize HTML + break + if part.get('Content-Transfer-Encoding') == 'base64': + payload = base64.b64decode(payload) + charset = part.get_charsets()[0] + if charset: + payload = payload.decode(charset) + if part.get_content_type() == 'text/plain': + payload = payload.replace('\n', '
    ').replace(' ', ' ') + return mark_safe(payload) + display_content.short_description = _("Content") + + def display_full_subject(self, instance): + return instance.subject + display_full_subject.short_description = _("Subject") + + def display_from(self, instance): + return instance.from_address + display_from.short_description = _("From") + + def display_to(self, instance): + return instance.to_address + display_to.short_description = _("To") + + def get_urls(self): + from django.urls import re_path as url + urls = super().get_urls() + info = self.model._meta.app_label, self.model._meta.model_name + urls.insert(0, + url(r'^send-pending/$', + wrap_admin_view(self, self.send_pending_view), + name='%s_%s_send_pending' % info) + ) + return urls + + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs.annotate(Count('logs')).defer('content') + + def send_pending_view(self, request): + task(send_pending).apply_async() + self.message_user(request, _("Pending messages are being sent on the background.")) + return redirect('..') + + def formfield_for_dbfield(self, db_field, **kwargs): + if db_field.name == 'subject': + kwargs['widget'] = forms.TextInput(attrs={'size':'100'}) + return super().formfield_for_dbfield(db_field, **kwargs) + + +class SMTPLogAdmin(admin.ModelAdmin): + list_display = ( + 'id', 'message_link', 'colored_result', 'date_delta', 'log_message' + ) + list_filter = ('result',) + fields = ('message_link', 'colored_result', 'date_delta', 'log_message') + readonly_fields = fields + + message_link = admin_link('message') + colored_result = admin_colored('result', colors=COLORS, bold=False) + date_delta = admin_date('date') + + +admin.site.register(Message, MessageAdmin) +admin.site.register(SMTPLog, SMTPLogAdmin) diff --git a/orchestra/contrib/mailer/apps.py b/orchestra/contrib/mailer/apps.py new file mode 100644 index 0000000..c680cef --- /dev/null +++ b/orchestra/contrib/mailer/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + +from orchestra.core import administration + + +class MailerConfig(AppConfig): + name = 'orchestra.contrib.mailer' + verbose_name = "Mailer" + + def ready(self): + from .models import Message + administration.register(Message, icon='Mail-send.png') diff --git a/orchestra/contrib/mailer/backends.py b/orchestra/contrib/mailer/backends.py new file mode 100644 index 0000000..dd66caf --- /dev/null +++ b/orchestra/contrib/mailer/backends.py @@ -0,0 +1,53 @@ +from django.conf import settings as djsettings +from django.core.mail import get_connection +from django.core.mail.backends.base import BaseEmailBackend + +from orchestra.core.caches import get_request_cache + +from . import settings +from .models import Message +from .tasks import send_message + + +class EmailBackend(BaseEmailBackend): + """ + A wrapper that manages a queued SMTP system. + """ + def send_messages(self, email_messages): + if not email_messages: + return + # Count messages per request + cache = get_request_cache() + key = 'mailer.sent_messages' + sent_messages = cache.get(key) or 0 + sent_messages += 1 + cache.set(key, sent_messages) + + is_bulk = len(email_messages) > 1 + if sent_messages > settings.MAILER_NON_QUEUED_PER_REQUEST_THRESHOLD: + is_bulk = True + default_priority = Message.NORMAL if is_bulk else Message.CRITICAL + num_sent = 0 + connection = None + for message in email_messages: + priority = message.extra_headers.get('X-Mail-Priority', default_priority) + content = message.message().as_string() + for to_email in message.recipients(): + message = Message( + priority=priority, + to_address=to_email, + from_address=getattr(message, 'from_email', djsettings.DEFAULT_FROM_EMAIL), + subject=message.subject, + content=content, + ) + if priority == Message.CRITICAL: + # send immidiately + if connection is None: + connection = get_connection(backend='django.core.mail.backends.smtp.EmailBackend') + send_message.apply_async(message, connection=connection) + else: + message.save() + num_sent += 1 + if connection is not None: + connection.close() + return num_sent diff --git a/orchestra/contrib/mailer/engine.py b/orchestra/contrib/mailer/engine.py new file mode 100644 index 0000000..898f204 --- /dev/null +++ b/orchestra/contrib/mailer/engine.py @@ -0,0 +1,76 @@ + +import smtplib +from datetime import timedelta +from socket import error as SocketError + +from django.core.mail import get_connection +from django.db.models import Q +from django.utils import timezone + +from orchestra.utils.sys import LockFile, OperationLocked + +from . import settings +from .models import Message + + +def send_message(message, connection=None, bulk=settings.MAILER_BULK_MESSAGES): + message.last_try = timezone.now() + update_fields = ['last_try'] + if message.state != message.QUEUED: + message.retries += 1 + update_fields.append('retries') + message.save(update_fields=update_fields) + if connection is None: + connection = get_connection(backend='django.core.mail.backends.smtp.EmailBackend') + if connection.connection is None: + try: + connection.open() + except Exception as err: + message.defer() + message.log(err) + return + error = None + try: + connection.connection.sendmail(message.from_address, [message.to_address], message.content.encode()) + except (SocketError, + smtplib.SMTPSenderRefused, + smtplib.SMTPRecipientsRefused, + smtplib.SMTPAuthenticationError) as err: + message.defer() + error = err + else: + message.sent() + message.log(error) + return connection + + +def send_pending(bulk=settings.MAILER_BULK_MESSAGES): + try: + with LockFile('/dev/shm/mailer.send_pending.lock'): + connection = get_connection(backend='django.core.mail.backends.smtp.EmailBackend') + cur, total = 0, 0 + for message in Message.objects.filter(state=Message.QUEUED).order_by('priority', 'last_try', 'created_at'): + if cur >= bulk: + connection.close() + cur = 0 + send_message(message, connection, bulk) + cur += 1 + total += 1 + now = timezone.now() + qs = Q() + for retries, seconds in enumerate(settings.MAILER_DEFERE_SECONDS): + delta = timedelta(seconds=seconds) + qs = qs | Q(retries=retries, last_try__lte=now-delta) + for message in Message.objects.filter(state=Message.DEFERRED).filter(qs).order_by('priority', 'last_try'): + if cur >= bulk: + connection.close() + cur = 0 + send_message(message, connection, bulk) + cur += 1 + total += 1 + return total + except OperationLocked: + pass + finally: + if 'connection' in vars() and connection.connection is not None: + connection.close() diff --git a/orchestra/contrib/mailer/management/commands/sendpendingmessages.py b/orchestra/contrib/mailer/management/commands/sendpendingmessages.py new file mode 100644 index 0000000..23ba00c --- /dev/null +++ b/orchestra/contrib/mailer/management/commands/sendpendingmessages.py @@ -0,0 +1,12 @@ +from django.core.management.base import BaseCommand + +from orchestra.contrib.tasks.decorators import keep_state + +from ...engine import send_pending + + +class Command(BaseCommand): + help = 'Runs Orchestra method.' + + def handle(self, *args, **options): + keep_state(send_pending)() diff --git a/orchestra/contrib/mailer/models.py b/orchestra/contrib/mailer/models.py new file mode 100644 index 0000000..c905768 --- /dev/null +++ b/orchestra/contrib/mailer/models.py @@ -0,0 +1,73 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from . import settings + + +class Message(models.Model): + QUEUED = 'QUEUED' + SENT = 'SENT' + DEFERRED = 'DEFERRED' + FAILED = 'FAILED' + STATES = ( + (QUEUED, _("Queued")), + (SENT, _("Sent")), + (DEFERRED, _("Deferred")), + (FAILED, _("Failed")), + ) + + CRITICAL = 0 + HIGH = 1 + NORMAL = 2 + LOW = 3 + PRIORITIES = ( + (CRITICAL, _("Critical (not queued)")), + (HIGH, _("High")), + (NORMAL, _("Normal")), + (LOW, _("Low")), + ) + + state = models.CharField(_("State"), max_length=16, choices=STATES, default=QUEUED, + db_index=True) + priority = models.PositiveIntegerField(_("Priority"), choices=PRIORITIES, default=NORMAL, + db_index=True) + to_address = models.CharField(max_length=256) + from_address = models.CharField(max_length=256) + subject = models.TextField(_("subject")) + content = models.TextField(_("content")) + created_at = models.DateTimeField(_("created at"), auto_now_add=True) + retries = models.PositiveIntegerField(_("retries"), default=0, db_index=True) + last_try = models.DateTimeField(_("last try"), null=True, db_index=True) + + def __str__(self): + return '%s to %s' % (self.subject, self.to_address) + + def defer(self): + self.state = self.DEFERRED + # Max tries + if self.retries >= len(settings.MAILER_DEFERE_SECONDS): + self.state = self.FAILED + self.save(update_fields=('state',)) + + def sent(self): + self.state = self.SENT + self.save(update_fields=('state',)) + + def log(self, error): + result = SMTPLog.SUCCESS + if error: + result= SMTPLog.FAILURE + self.logs.create(log_message=str(error), result=result) + + +class SMTPLog(models.Model): + SUCCESS = 'SUCCESS' + FAILURE = 'FAILURE' + RESULTS = ( + (SUCCESS, _("Success")), + (FAILURE, _("Failure")), + ) + message = models.ForeignKey(Message, editable=False, related_name='logs', on_delete=models.CASCADE) + result = models.CharField(max_length=16, choices=RESULTS, default=SUCCESS) + date = models.DateTimeField(auto_now_add=True) + log_message = models.TextField() diff --git a/orchestra/contrib/mailer/settings.py b/orchestra/contrib/mailer/settings.py new file mode 100644 index 0000000..e040a52 --- /dev/null +++ b/orchestra/contrib/mailer/settings.py @@ -0,0 +1,24 @@ +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.settings import Setting + + +MAILER_DEFERE_SECONDS = Setting('MAILER_DEFERE_SECONDS', + (300, 600, 60*60, 60*60*24), +) + + +MAILER_MESSAGES_CLEANUP_DAYS = Setting('MAILER_MESSAGES_CLEANUP_DAYS', + 7 +) + + +MAILER_NON_QUEUED_PER_REQUEST_THRESHOLD = Setting('MAILER_NON_QUEUED_PER_REQUEST_THRESHOLD', + 2, + help_text=_("Number of emails that will be sent immediately before starting to queue them."), +) + + +MAILER_BULK_MESSAGES = Setting('MAILER_BULK_MESSAGES', + 500, +) diff --git a/orchestra/contrib/mailer/tasks.py b/orchestra/contrib/mailer/tasks.py new file mode 100644 index 0000000..c05d9fe --- /dev/null +++ b/orchestra/contrib/mailer/tasks.py @@ -0,0 +1,23 @@ +from datetime import timedelta + +from django.utils import timezone +from celery.task.schedules import crontab + +from orchestra.contrib.tasks import task, periodic_task + +from . import engine, settings + + +@task +def send_message(message, connection=None): + message.save() + engine.send_message(message, connection=connection) + + +@periodic_task(run_every=crontab(hour=7, minute=30)) +def cleanup_messages(): + from .models import Message + delta = timedelta(days=settings.MAILER_MESSAGES_CLEANUP_DAYS) + now = timezone.now() + epoch = (now-delta) + return Message.objects.filter(state=Message.SENT, created_at__lt=epoch).only('id').delete() diff --git a/orchestra/contrib/mailer/templates/admin/mailer/message/change_list.html b/orchestra/contrib/mailer/templates/admin/mailer/message/change_list.html new file mode 100644 index 0000000..d39ecb9 --- /dev/null +++ b/orchestra/contrib/mailer/templates/admin/mailer/message/change_list.html @@ -0,0 +1,14 @@ +{% extends "admin/change_list.html" %} +{% load i18n admin_urls static admin_list %} + + +{% block object-tools-items %} +
  15. + {% url cl.opts|admin_urlname:'send_pending' as send_pending_url %} + + {% blocktrans with cl.opts.verbose_name as name %}Send pending{% endblocktrans %} + +
  16. + {{ block.super }} +{% endblock %} + diff --git a/orchestra/contrib/miscellaneous/__init__.py b/orchestra/contrib/miscellaneous/__init__.py new file mode 100644 index 0000000..6294909 --- /dev/null +++ b/orchestra/contrib/miscellaneous/__init__.py @@ -0,0 +1 @@ +default_app_config = 'orchestra.contrib.miscellaneous.apps.MiscellaneousConfig' diff --git a/orchestra/contrib/miscellaneous/admin.py b/orchestra/contrib/miscellaneous/admin.py new file mode 100644 index 0000000..2fce198 --- /dev/null +++ b/orchestra/contrib/miscellaneous/admin.py @@ -0,0 +1,150 @@ +from django import forms +from django.contrib import admin +from django.urls import reverse +from django.db import models +from django.utils.html import format_html +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ + +from orchestra.admin import ExtendedModelAdmin +from orchestra.admin.actions import disable, enable +from orchestra.admin.utils import admin_link +from orchestra.contrib.accounts.admin import AccountAdminMixin +from orchestra.contrib.accounts.filters import IsActiveListFilter +from orchestra.plugins import PluginModelAdapter +from orchestra.plugins.admin import SelectPluginAdminMixin +from orchestra.utils.python import import_class + +from . import settings +from .models import MiscService, Miscellaneous + + +class MiscServicePlugin(PluginModelAdapter): + model = MiscService + name_field = 'name' + plugin_field = 'service' + + +class MiscServiceAdmin(ExtendedModelAdmin): + list_display = ( + 'display_name', 'display_verbose_name', 'num_instances', 'has_identifier', 'has_amount', 'is_active' + ) + list_editable = ('is_active',) + list_filter = ('has_identifier', 'has_amount', IsActiveListFilter) + fields = ( + 'verbose_name', 'name', 'description', 'has_identifier', 'has_amount', 'is_active' + ) + prepopulated_fields = {'name': ('verbose_name',)} + change_readonly_fields = ('name',) + actions = (disable, enable) + + def display_name(self, misc): + return format_html('{}', misc.description, misc.name) + display_name.short_description = _("name") + display_name.admin_order_field = 'name' + + def display_verbose_name(self, misc): + return format_html('{}', misc.description, misc.verbose_name) + display_verbose_name.short_description = _("verbose name") + display_verbose_name.admin_order_field = 'verbose_name' + + def num_instances(self, misc): + """ return num slivers as a link to slivers changelist view """ + num = misc.instances__count + url = reverse('admin:miscellaneous_miscellaneous_changelist') + url += '?service__name={}'.format(misc.name) + return mark_safe('{1}'.format(url, num)) + num_instances.short_description = _("Instances") + num_instances.admin_order_field = 'instances__count' + + def get_queryset(self, request): + qs = super(MiscServiceAdmin, self).get_queryset(request) + return qs.annotate(models.Count('instances', distinct=True)) + + def formfield_for_dbfield(self, db_field, **kwargs): + """ Make value input widget bigger """ + if db_field.name == 'description': + kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 2}) + return super(MiscServiceAdmin, self).formfield_for_dbfield(db_field, **kwargs) + + +class MiscellaneousAdmin(SelectPluginAdminMixin, AccountAdminMixin, ExtendedModelAdmin): + list_display = ( + '__str__', 'service_link', 'amount', 'account_link', 'dispaly_active' + ) + list_filter = ('service__name', 'is_active') + list_select_related = ('service', 'account') + readonly_fields = ('account_link', 'service_link') + add_fields = ('service', 'account', 'description', 'is_active') + fields = ('service_link', 'account', 'description', 'is_active') + change_readonly_fields = ('identifier', 'service') + search_fields = ('identifier', 'description', 'account__username') + actions = (disable, enable) + plugin_field = 'service' + plugin = MiscServicePlugin + + service_link = admin_link('service') + + def dispaly_active(self, instance): + return instance.active + dispaly_active.short_description = _("Active") + dispaly_active.boolean = True + dispaly_active.admin_order_field = 'is_active' + + def get_service(self, obj): + if obj is None: + return self.plugin.get(self.plugin_value).related_instance + else: + return obj.service + + def get_fieldsets(self, request, obj=None): + fieldsets = super().get_fieldsets(request, obj) + fields = list(fieldsets[0][1]['fields']) + service = self.get_service(obj) + if obj: + fields.insert(1, 'account_link') + if service.has_amount: + fields.insert(-1, 'amount') + if service.has_identifier: + fields.insert(2, 'identifier') + fieldsets[0][1]['fields'] = fields + return fieldsets + + def get_form(self, request, obj=None, **kwargs): + if obj: + plugin = self.plugin.get(obj.service.name)() + else: + plugin = self.plugin.get(self.plugin_value)() + self.form = plugin.get_form() + self.plugin_instance = plugin + service = self.get_service(obj) + form = super(SelectPluginAdminMixin, self).get_form(request, obj, **kwargs) + def clean_identifier(self, service=service): + identifier = self.cleaned_data['identifier'] + validator_path = settings.MISCELLANEOUS_IDENTIFIER_VALIDATORS.get(service.name, None) + if validator_path: + validator = import_class(validator_path) + validator(identifier) + return identifier + + form.clean_identifier = clean_identifier + return form + + def formfield_for_dbfield(self, db_field, **kwargs): + """ Make value input widget bigger """ + if db_field.name == 'description': + kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 4}) + return super(MiscellaneousAdmin, self).formfield_for_dbfield(db_field, **kwargs) + + def save_model(self, request, obj, form, change): + if not change: + plugin = self.plugin + kwargs = { + plugin.name_field: self.plugin_value + } + setattr(obj, self.plugin_field, plugin.model.objects.get(**kwargs)) + obj.save() + + +admin.site.register(MiscService, MiscServiceAdmin) +admin.site.register(Miscellaneous, MiscellaneousAdmin) diff --git a/orchestra/contrib/miscellaneous/apps.py b/orchestra/contrib/miscellaneous/apps.py new file mode 100644 index 0000000..2f5763a --- /dev/null +++ b/orchestra/contrib/miscellaneous/apps.py @@ -0,0 +1,15 @@ +from django.apps import AppConfig + +from orchestra.core import services, administration +from orchestra.core.translations import ModelTranslation + + +class MiscellaneousConfig(AppConfig): + name = 'orchestra.contrib.miscellaneous' + verbose_name = 'Miscellaneous' + + def ready(self): + from .models import MiscService, Miscellaneous + services.register(Miscellaneous, icon='applications-other.png') + administration.register(MiscService, icon='Misc-Misc-Box-icon.png') + ModelTranslation.register(MiscService, ('verbose_name',)) diff --git a/orchestra/contrib/miscellaneous/models.py b/orchestra/contrib/miscellaneous/models.py new file mode 100644 index 0000000..2a54983 --- /dev/null +++ b/orchestra/contrib/miscellaneous/models.py @@ -0,0 +1,85 @@ +from django.db import models +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ + +from orchestra.core.validators import validate_name +from orchestra.models.fields import NullableCharField + + +class MiscService(models.Model): + name = models.CharField(_("name"), max_length=32, unique=True, validators=[validate_name], + help_text=_("Raw name used for internal referenciation, i.e. service match definition")) + verbose_name = models.CharField(_("verbose name"), max_length=256, blank=True, + help_text=_("Human readable name")) + description = models.TextField(_("description"), blank=True, + help_text=_("Optional description")) + has_identifier = models.BooleanField(_("has identifier"), default=True, + help_text=_("Designates if this service has a unique text field that " + "identifies it or not.")) + has_amount = models.BooleanField(_("has amount"), default=False, + help_text=_("Designates whether this service has amount " + "property or not.")) + is_active = models.BooleanField(_("active"), default=True, + help_text=_("Whether new instances of this service can be created " + "or not. Unselect this instead of deleting services.")) + + def __str__(self): + return self.name + + def clean(self): + self.verbose_name = self.verbose_name.strip() + + def get_verbose_name(self): + return self.verbose_name or self.name + + def disable(self): + self.is_active = False + self.save(update_fields=('is_active',)) + + def enable(self): + self.is_active = False + self.save(update_fields=('is_active',)) + + +class Miscellaneous(models.Model): + service = models.ForeignKey(MiscService, on_delete=models.CASCADE, + verbose_name=_("service"), related_name='instances') + account = models.ForeignKey('accounts.Account', on_delete=models.CASCADE, + verbose_name=_("account"), related_name='miscellaneous') + identifier = NullableCharField(_("identifier"), max_length=256, null=True, unique=True, + db_index=True, help_text=_("A unique identifier for this service.")) + description = models.TextField(_("description"), blank=True) + amount = models.PositiveIntegerField(_("amount"), default=1) + is_active = models.BooleanField(_("active"), default=True, + help_text=_("Designates whether this service should be treated as " + "active. Unselect this instead of deleting services.")) + + class Meta: + verbose_name_plural = _("miscellaneous") + + def __str__(self): + return self.identifier or self.description[:32] or str(self.service) + + @cached_property + def active(self): + return self.is_active and self.service.is_active and self.account.is_active + + def get_description(self): + return ' '.join((str(self.amount), self.service.description or self.service.verbose_name)) + + def disable(self): + self.is_active = False + self.save(update_fields=('is_active',)) + + def enable(self): + self.is_active = False + self.save(update_fields=('is_active',)) + + @cached_property + def service_class(self): + return self.service + + def clean(self): + if self.identifier: + self.identifier = self.identifier.strip().lower() + self.description = self.description.strip() diff --git a/orchestra/contrib/miscellaneous/settings.py b/orchestra/contrib/miscellaneous/settings.py new file mode 100644 index 0000000..bc0ef3e --- /dev/null +++ b/orchestra/contrib/miscellaneous/settings.py @@ -0,0 +1,8 @@ +from orchestra.contrib.settings import Setting + + +MISCELLANEOUS_IDENTIFIER_VALIDATORS = Setting('MISCELLANEOUS_IDENTIFIER_VALIDATORS', + { + # : + } +) diff --git a/orchestra/contrib/orchestration/README.md b/orchestra/contrib/orchestration/README.md new file mode 100644 index 0000000..cd87ee8 --- /dev/null +++ b/orchestra/contrib/orchestration/README.md @@ -0,0 +1,80 @@ +# Orchestration + +This module handles the management of the services controlled by Orchestra. This app provides the means for detecting changes on the data model and execute scripts on the servers to reflect those changes. + +Orchestration module has the following pieces: + +* `Operation` encapsulates an operation, storing the related object, the action and the backend +* `OperationsMiddleware` collects and executes all save and delete operations, more on [next section](#operationsmiddleware) +* `manager` it manage the execution of the operations +* `backends` defines the logic that will be executed on the servers in order to control a particular service +* `router` determines in which server an operation should be executed +* `Server` defines a server hosting services +* `methods` script execution methods, e.g. SSH +* `ScriptLog` it logs the script execution + +Routes +====== + +This application provides support for mapping Orchestra service instances to server machines accross the network. + +It supports _routing_ based on Python expression, which means that you can efectively +control services that are distributed accross several machines. For example, different +websites that are distributed accross _n_ web servers on a _shared hosting_ +environment. + +### OperationsMiddleware + +`middlewares.OperationsMiddleware` automatically executes the service backends when a change on the data model occurs. The main steps that performs are: + +1. Collect all `save` and `delete` model signals triggered on each HTTP request +2. Find related backends using the routing backend +3. Generate a single script per server (_unit of work_) +4. Execute the generated scripts on the servers via SSH + + +### Service Management Properties + +We can identify three different characteristics regarding service management: + +* **Authority**: Whether or not Orchestra is the only source of the service configuration. When Orchestra is the authority then service configuration is _completely generated_ from the Orchestra database (or services are configured to read their configuration directly from Orchestra database). Otherwise Orchestra will execute small tasks translating model changes into configuration changes, allowing manual configurations to be preserved. +* **Flow**: _push_, when Orchestra drives the execution or _pull_, when external services connects to Orchestra. +* **Execution**: _synchronous_, when the execution blocks the HTTP request, or _asynchronous_ when it doesn't. Asynchronous execution means concurrency, and concurrency scalability and complexity (i.e. reporting user feedback of success/failed backend executions). + + +### Registry vs Synchronization vs Task +From the above management properties we can extract three main service management strategies: (a) _task based management_, (b) _synchronization based management_ and (c) _registry based management_. Orchestra provides support for all of them, it is left to you to decide which one suits your requirements better. + +Following a brief description and evaluation of the tradeoffs to help on your decision making. + +#### a. Task Based Management (prefered) +This model refers when Orchestra is _not the only source of configuration_. Therefore, Orchestra translates isolated data model changes directly into localized changes on the service configuration, and executing them using a **push** strategy. For example `save()` or `delete()` object-level operations may have sibling configuration management operations. In contrast to _synchronization_, tasks are able to preserve configuration not performed by Orchestra. + +This model is intuitive, efficient and also very consistent when tasks are execute **synchronously** with the request/response cycle. However, **asynchronous** task execution can have _consistency issues_; tasks have state, and this state can be lost when: +- A failure occur while applying some changes, e.g. network error or worker crash while deleting a service +- Scripts are executed out of order, e.g. create and delete a service is applied in inverse order + +In general, _synchornous execution of tasks is preferred_ over asynchornous, unless response delays are not tolerable. + + +#### b. Synchronization Based Management +When Orchestra is the configuration **authority** and also _the responsible of applying the changes_ on the servers (**push** flow). The configuration files are **regenerated** every time by Orchestra, deleting any existing manual configuration. This model is very consistent since it only depends on the current state of the system (_memoryless_). Therefore, it makes sense to execute the synchronization operation in **asynchronous** fashion. + +In contrast to registry based management, synchronization management is _fully centralized_, all the management operations are driven by Orchestra so you don't need to install nor configure anything on your servers. + +#### c. Registry Based Management +When Orchestra acts as a pure **configuration registry (authority)**, doing nothing more than store service's configuration on the database. The configuration is **pulled** from Orchestra by the servers themselves, so it is **asynchronous** by nature. + +This strategy considers two different implementations: + +- The service is configured to read the configuration directly from Orchestra database (or REST API). This approach simplifies configuration management but also can make Orchestra a single point of failure on your architecture. +- An application (_agent_) periodically fetches the service configuration from the Orchestra database and regenerates the service configuration files. This approach is very tolerant to failures, since the services will keep working independenlty from orchestra, and the new configuration will be applied after recovering. A delay may occur until the changes are applied to the services (_eventual consistency_), but it can be mitigated by notifying the application when a relevant change occur. User feedback about the success or failure of appling the configuration needs to be implemented by the agent. + +##### What state does actually mean? +Lets assume you have deleted a mailbox, and Orchestra has created an script that deletes that mailbox on the mail server. However a failure has occurred and the mailbox deletion task has been lost. Since the state has also been lost it is not easy to tell what to do now in order to maintain consistency. + + +### Additional Notes +* The script that manage the service needs to be idempotent, i.e. the outcome of running the script is always the same, no matter how many times it is executed. +* Renaming of attributes may lead to undesirable effects, e.g. changing a database name will create a new database rather than just changing its name. +* The system does not magically perform data migrations between servers when its _route_ has changed diff --git a/orchestra/contrib/orchestration/__init__.py b/orchestra/contrib/orchestration/__init__.py new file mode 100644 index 0000000..c477448 --- /dev/null +++ b/orchestra/contrib/orchestration/__init__.py @@ -0,0 +1,91 @@ +import collections +import copy + +from orchestra.utils.python import AttrDict + +from .backends import ServiceBackend, ServiceController, replace + + +default_app_config = 'orchestra.contrib.orchestration.apps.OrchestrationConfig' + + +class Operation(): + DELETE = 'delete' + SAVE = 'save' + MONITOR = 'monitor' + EXCEEDED = 'exceeded' + RECOVERY = 'recovery' + + def __str__(self): + return '%s.%s(%s)' % (self.backend, self.action, self.instance) + + def __repr__(self): + return str(self) + + def __hash__(self): + """ set() """ + return hash((self.backend, self.instance, self.action)) + + def __eq__(self, operation): + """ set() """ + return hash(self) == hash(operation) + + def __init__(self, backend, instance, action, routes=None): + self.backend = backend + # instance should maintain any dynamic attribute until backend execution + # deep copy is prefered over copy otherwise objects will share same atributes (queryset cache) + self.instance = copy.deepcopy(instance) + self.action = action + self.routes = routes + + @classmethod + def execute(cls, operations, serialize=False, run_async=None): + from . import manager + scripts, backend_serialize = manager.generate(operations) + return manager.execute(scripts, serialize=(serialize or backend_serialize), run_async=run_async) + + @classmethod + def create_for_action(cls, instances, action): + if not isinstance(instances, collections.Iterable): + instances = [instances] + operations = [] + for instance in instances: + backends = ServiceBackend.get_backends(instance=instance, action=action) + for backend_cls in backends: + operations.append( + cls(backend_cls, instance, action) + ) + return operations + + @classmethod + def execute_action(cls, instances, action): + """ instances can be an object or an iterable for batch processing """ + operations = cls.create_for_action(instances, action) + return cls.execute(operations) + + def preload_context(self): + """ + Heuristic: Running get_context will prevent most of related objects do not exist errors + """ + if self.action == self.DELETE: + if hasattr(self.backend, 'get_context'): + self.backend().get_context(self.instance) + + def store(self, log): + from .models import BackendOperation + return BackendOperation.objects.create( + log=log, + backend=self.backend.get_name(), + instance=self.instance, + action=self.action, + ) + + @classmethod + def load(cls, operation, log=None): + routes = None + if log: + routes = { + (operation.backend, operation.action): AttrDict(host=log.server) + } + return cls(operation.backend_class, operation.instance, operation.action, routes=routes) + diff --git a/orchestra/contrib/orchestration/actions.py b/orchestra/contrib/orchestration/actions.py new file mode 100644 index 0000000..042f19a --- /dev/null +++ b/orchestra/contrib/orchestration/actions.py @@ -0,0 +1,134 @@ +from collections import defaultdict + +from django.contrib import messages +from django.contrib.admin import helpers +from django.shortcuts import render +from django.utils.safestring import mark_safe +from django.utils.translation import ngettext, gettext_lazy as _ + +from orchestra.admin.utils import get_object_from_url, change_url +from orchestra.contrib.orchestration.helpers import message_user +from orchestra.utils.python import OrderedSet + +from . import manager, Operation +from .models import BackendOperation, Route, Server + + +def retry_backend(modeladmin, request, queryset): + related_operations = queryset.values_list('operations__id', flat=True).distinct() + related_operations = BackendOperation.objects.filter(pk__in=related_operations) + related_operations = related_operations.select_related('log__server').prefetch_related('instance') + if request.POST.get('post') == 'generic_confirmation': + operations = [] + for operation in related_operations: + if operation.instance: + op = Operation.load(operation) + operations.append(op) + if not operations: + messages.warning(request, _("No backend operation has been executed.")) + else: + logs = Operation.execute(operations) + message_user(request, logs) + for backendlog in queryset: + modeladmin.log_change(request, backendlog, 'Retried') + return + opts = modeladmin.model._meta + display_objects = [] + deleted_objects = [] + for op in related_operations: + if not op.instance: + deleted_objects.append(op) + else: + context = { + 'backend': op.log.backend, + 'action': op.action, + 'instance': op.instance, + 'instance_url': change_url(op.instance), + 'server': op.log.server, + 'server_url': change_url(op.log.server), + } + display_objects.append(mark_safe( + '%(backend)s.%(action)s(%(instance)s) @ %(server)s' % context + )) + context = { + 'title': _("Are you sure to execute the following backends?"), + 'action_name': _('Retry backend'), + 'action_value': 'retry_backend', + 'display_objects': display_objects, + 'deleted_objects': deleted_objects, + 'queryset': queryset, + 'opts': opts, + 'app_label': opts.app_label, + 'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME, + 'obj': get_object_from_url(modeladmin, request), + } + return render(request, 'admin/orchestration/backends/retry.html', context) +retry_backend.short_description = _("Retry") +retry_backend.url_name = 'retry' + + +def orchestrate(modeladmin, request, queryset): + operations = set() + action = Operation.SAVE + operations = OrderedSet() + if queryset.model is Route: + for route in queryset: + routes = [route] + backend = route.backend_class + if action not in backend.actions: + continue + for instance in backend.model_class().objects.all(): + if route.matches(instance): + operations.add(Operation(backend, instance, action, routes=routes)) + elif queryset.model is Server: + models = set() + for server in queryset: + routes = server.routes.all() + for route in routes.filter(is_active=True): + model = route.backend_class.model_class() + models.add(model) + querysets = [model.objects.order_by('id') for model in models] + + route_cache = {} + for model in models: + for instance in model.objects.all(): + manager.collect(instance, action, operations=operations, route_cache=route_cache) + routes = [] + result = [] + for operation in operations: + routes = [route for route in operation.routes if route.host in queryset] + operation.routes = routes + if routes: + result.append(operation) + operations = result + if not operations: + messages.warning(request, _("No related operations.")) + return + + if request.POST.get('post') == 'generic_confirmation': + logs = Operation.execute(operations) + message_user(request, logs) + for obj in queryset: + modeladmin.log_change(request, obj, 'Orchestrated') + return + + opts = modeladmin.model._meta + display_objects = {} + for operation in operations: + try: + display_objects[operation.backend].append(operation) + except KeyError: + display_objects[operation.backend] = [operation] + context = { + 'title': _("Are you sure to execute the following operations?"), + 'action_name': _('Orchestrate'), + 'action_value': 'orchestrate', + 'display_objects': display_objects, + 'queryset': queryset, + 'opts': opts, + 'app_label': opts.app_label, + 'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME, + 'obj': get_object_from_url(modeladmin, request), + } + return render(request, 'admin/orchestration/orchestrate.html', context) +orchestrate.help_text = _("Execute all related operations on the server(s)") diff --git a/orchestra/contrib/orchestration/admin.py b/orchestra/contrib/orchestration/admin.py new file mode 100644 index 0000000..703fba8 --- /dev/null +++ b/orchestra/contrib/orchestration/admin.py @@ -0,0 +1,196 @@ +from django.contrib import admin, messages +from django.utils.html import escape +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ + +from orchestra.admin import ExtendedModelAdmin, ChangeViewActionsMixin +from orchestra.admin.utils import admin_link, admin_date, admin_colored, display_mono, display_code +from orchestra.plugins.admin import display_plugin_field + +from . import settings, helpers +from .actions import retry_backend, orchestrate +from .backends import ServiceBackend +from .forms import RouteForm +from .models import Server, Route, BackendLog, BackendOperation +from .utils import retrieve_state +from .widgets import RouteBackendSelect + + +STATE_COLORS = { + BackendLog.RECEIVED: 'darkorange', + BackendLog.TIMEOUT: 'red', + BackendLog.STARTED: 'blue', + BackendLog.SUCCESS: 'green', + BackendLog.FAILURE: 'red', + BackendLog.ERROR: 'red', + BackendLog.REVOKED: 'magenta', + BackendLog.NOTHING: 'green', +} + + +class RouteAdmin(ExtendedModelAdmin): + list_display = ( + 'display_backend', 'host', 'match', 'display_model', 'display_actions', 'run_async', + 'is_active' + ) + list_editable = ('host', 'match', 'run_async', 'is_active') + list_filter = ('host', 'is_active', 'run_async', 'backend') + list_prefetch_related = ('host',) + ordering = ('backend',) + add_fields = ('backend', 'host', 'match', 'run_async', 'is_active') + change_form = RouteForm + actions = (orchestrate,) + change_view_actions = actions + + BACKEND_HELP_TEXT = helpers.get_backends_help_text(ServiceBackend.get_backends()) + DEFAULT_MATCH = { + backend.get_name(): backend.default_route_match for backend in ServiceBackend.get_backends() + } + + display_backend = display_plugin_field('backend') + + def display_model(self, route): + try: + return route.backend_class.model + except KeyError: + return mark_safe("NOT AVAILABLE") + display_model.short_description = _("model") + + @mark_safe + def display_actions(self, route): + try: + return '
    '.join(route.backend_class.get_actions()) + except KeyError: + return "NOT AVAILABLE" + display_actions.short_description = _("actions") + + def formfield_for_dbfield(self, db_field, **kwargs): + """ Provides dynamic help text on backend form field """ + if db_field.name == 'backend': + kwargs['widget'] = RouteBackendSelect( + 'this.id', self.BACKEND_HELP_TEXT, self.DEFAULT_MATCH) + field = super(RouteAdmin, self).formfield_for_dbfield(db_field, **kwargs) + if db_field.name == 'host': + # Cache host choices + request = kwargs['request'] + choices = getattr(request, '_host_choices_cache', None) + if choices is None: + request._host_choices_cache = choices = list(field.choices) + field.choices = choices + return field + + def get_form(self, request, obj=None, **kwargs): + """ Include dynamic help text for existing objects """ + form = super(RouteAdmin, self).get_form(request, obj, **kwargs) + if obj: + form.base_fields['backend'].help_text = self.BACKEND_HELP_TEXT.get(obj.backend, '') + return form + + def show_orchestration_disabled(self, request): + if settings.ORCHESTRATION_DISABLE_EXECUTION: + msg = _("Orchestration execution is disabled by ORCHESTRATION_DISABLE_EXECUTION setting.") + self.message_user(request, mark_safe(msg), messages.WARNING) + + def changelist_view(self, request, extra_context=None): + self.show_orchestration_disabled(request) + return super(RouteAdmin, self).changelist_view(request, extra_context) + + def changeform_view(self, request, object_id=None, form_url='', extra_context=None): + self.show_orchestration_disabled(request) + return super(RouteAdmin, self).changeform_view( + request, object_id, form_url, extra_context) + + +class BackendOperationInline(admin.TabularInline): + model = BackendOperation + fields = ('action', 'instance_link') + readonly_fields = ('action', 'instance_link') + extra = 0 + can_delete = False + + class Media: + css = { + 'all': ('orchestra/css/hide-inline-id.css',) + } + + def instance_link(self, operation): + link = admin_link('instance')(self, operation) + if link == '---': + return _("Deleted {0}").format(operation.instance_repr or '-'.join( + (escape(operation.content_type), escape(operation.object_id)))) + return link + instance_link.short_description = _("Instance") + + def has_add_permission(self, *args, **kwargs): + return False + + def get_queryset(self, request): + queryset = super(BackendOperationInline, self).get_queryset(request) + return queryset.prefetch_related('instance') + + +class BackendLogAdmin(ChangeViewActionsMixin, admin.ModelAdmin): + list_display = ( + 'id', 'backend', 'server_link', 'display_state', 'exit_code', + 'display_created', 'execution_time', + ) + list_display_links = ('id', 'backend') + list_filter = ('state', 'server', 'backend', 'operations__action') + search_fields = ('script',) + date_hierarchy = 'created_at' + inlines = (BackendOperationInline,) + fields = ( + 'backend', 'server_link', 'state', 'display_script', 'mono_stdout', + 'mono_stderr', 'mono_traceback', 'exit_code', 'task_id', 'display_created', + 'execution_time' + ) + readonly_fields = fields + actions = (retry_backend,) + change_view_actions = actions + + server_link = admin_link('server') + display_created = admin_date('created_at', short_description=_("Created")) + display_state = admin_colored('state', colors=STATE_COLORS) + display_script = display_code('script') + mono_stdout = display_mono('stdout') + mono_stderr = display_mono('stderr') + mono_traceback = display_mono('traceback') + + class Media: + css = { + 'all': ('orchestra/css/pygments/github.css',) + } + + def get_queryset(self, request): + """ Order by structured name and imporve performance """ + qs = super(BackendLogAdmin, self).get_queryset(request) + return qs.select_related('server').defer('script', 'stdout') + + def has_add_permission(self, *args, **kwargs): + return False + + +class ServerAdmin(ExtendedModelAdmin): + list_display = ('name', 'address', 'os', 'display_ping', 'display_uptime') + list_filter = ('os',) + actions = (orchestrate,) + change_view_actions = actions + + def display_ping(self, instance): + return mark_safe(self._remote_state[instance.pk][0]) + display_ping.short_description = _("Ping") + + def display_uptime(self, instance): + return mark_safe(self._remote_state[instance.pk][1]) + display_uptime.short_description = _("Uptime") + + def get_queryset(self, request): + """ Order by structured name and imporve performance """ + qs = super(ServerAdmin, self).get_queryset(request) + if request.method == 'GET' and request.resolver_match.func.__name__ == 'changelist_view': + self._remote_state = retrieve_state(qs) + return qs + +admin.site.register(Server, ServerAdmin) +admin.site.register(BackendLog, BackendLogAdmin) +admin.site.register(Route, RouteAdmin) diff --git a/orchestra/contrib/orchestration/apps.py b/orchestra/contrib/orchestration/apps.py new file mode 100644 index 0000000..6de145d --- /dev/null +++ b/orchestra/contrib/orchestration/apps.py @@ -0,0 +1,14 @@ +from django.apps import AppConfig + +from orchestra.core import administration + + +class OrchestrationConfig(AppConfig): + name = 'orchestra.contrib.orchestration' + verbose_name = "Orchestration" + + def ready(self): + from .models import Server, Route, BackendLog + administration.register(BackendLog, icon='scriptlog.png') + administration.register(Server, parent=BackendLog, icon='vps.png') + administration.register(Route, parent=BackendLog, icon='hal.png') diff --git a/orchestra/contrib/orchestration/backends.py b/orchestra/contrib/orchestration/backends.py new file mode 100644 index 0000000..f1a04fe --- /dev/null +++ b/orchestra/contrib/orchestration/backends.py @@ -0,0 +1,249 @@ +import logging +import textwrap +from functools import partial + +from django.apps import apps +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from orchestra import plugins + +from . import methods + +logger = logging.getLogger(__name__) + +def replace(context, pattern, repl): + """ applies replace to all context str values """ + for key, value in context.items(): + if isinstance(value, str): + context[key] = value.replace(pattern, repl) + return context + + +class ServiceMount(plugins.PluginMount): + def __init__(cls, name, bases, attrs): + # Make sure backends specify a model attribute + if not (attrs.get('abstract', False) or name == 'ServiceBackend' or cls.model): + raise AttributeError("'%s' does not have a defined model attribute." % cls) + super(ServiceMount, cls).__init__(name, bases, attrs) + + +class ServiceBackend(plugins.Plugin, metaclass=ServiceMount): + """ + Service management backend base class + + It uses the _unit of work_ design principle, which allows bulk operations to + be conviniently supported. Each backend generates the configuration for all + the changes of all modified objects, reloading the daemon just once. + """ + model = None + related_models = () # ((model, accessor__attribute),) + script_method = methods.SSH + script_executable = '/bin/bash' + function_method = methods.Python + type = 'task' # 'sync' + # Don't wait for the backend to finish before continuing with request/response + ignore_fields = [] + actions = [] + default_route_match = 'True' + # Force the backend manager to block in multiple backend executions executing them synchronously + serialize = False + doc_settings = None + # By default backend will not run if actions do not generate insctructions, + # If your backend uses prepare() or commit() only then you should set force_empty_action_execution = True + force_empty_action_execution = False + + def __str__(self): + return type(self).__name__ + + def __init__(self): + self.head = [] + self.content = [] + self.tail = [] + + def __getattribute__(self, attr): + """ Select head, content or tail section depending on the method name """ + IGNORE_ATTRS = ( + 'append', + 'cmd_section', + 'head', + 'tail', + 'content', + 'script_method', + 'function_method', + 'set_head', + 'set_tail', + 'set_content', + 'actions', + ) + if attr == 'prepare': + self.set_head() + elif attr == 'commit': + self.set_tail() + elif attr not in IGNORE_ATTRS and attr in self.actions: + self.set_content() + return super(ServiceBackend, self).__getattribute__(attr) + + def set_head(self): + self.cmd_section = self.head + + def set_tail(self): + self.cmd_section = self.tail + + def set_content(self): + self.cmd_section = self.content + + @classmethod + def get_actions(cls): + return [ action for action in cls.actions if action in dir(cls) ] + + @classmethod + def get_name(cls): + return cls.__name__ + + @classmethod + def is_main(cls, obj): + opts = obj._meta + return cls.model == '%s.%s' % (opts.app_label, opts.object_name) + + @classmethod + def get_related(cls, obj): + opts = obj._meta + model = '%s.%s' % (opts.app_label, opts.object_name) + logger.debug('Model: {}'.format(model)) + for rel_model, field in cls.related_models: + logger.debug('rel_model: {}'.format(rel_model)) + logger.debug('field: {}'.format(field)) + if rel_model == model: + related = obj + for attribute in field.split('__'): + related = getattr(related, attribute) + if type(related).__name__ == 'RelatedManager': + return related.all() + return [related] + return [] + + @classmethod + def get_backends(cls, instance=None, action=None): + backends = cls.get_plugins() + included = [] + # Filter for instance or action + for backend in backends: + include = True + if instance: + opts = instance._meta + if backend.model != '.'.join((opts.app_label, opts.object_name)): + include = False + if include and action: + if action not in backend.get_actions(): + include = False + if include: + included.append(backend) + return included + + @classmethod + def get_backend(cls, name): + return cls.get(name) + + @classmethod + def model_class(cls): + return apps.get_model(cls.model) + + @property + def scripts(self): + """ group commands based on their method """ + if not self.content: + return [] + scripts = {} + for method, cmd in self.content: + scripts[method] = [] + for method, commands in self.head + self.content + self.tail: + try: + scripts[method] += commands + except KeyError: + pass + return list(scripts.items()) + + def get_banner(self): + now = timezone.localtime(timezone.now()) + time = now.strftime("%h %d, %Y %I:%M:%S %Z") + return "Generated by Orchestra at %s" % time + + def create_log(self, server, **kwargs): + from .models import BackendLog + state = BackendLog.RECEIVED + run = bool(self.scripts) or (self.force_empty_action_execution or bool(self.content)) + if not run: + state = BackendLog.NOTHING + using = kwargs.pop('using', None) + manager = BackendLog.objects + if using: + manager = manager.using(using) + log = manager.create(backend=self.get_name(), state=state, server=server) + return log + + def execute(self, server, run_async=False, log=None): + from .models import BackendLog + if log is None: + log = self.create_log(server) + run = log.state != BackendLog.NOTHING + if run: + scripts = self.scripts + for method, commands in scripts: + method(log, server, commands, run_async) + if log.state != BackendLog.SUCCESS: + break + return log + + def append(self, *cmd): + # aggregate commands acording to its execution method + if isinstance(cmd[0], str): + method = self.script_method + cmd = cmd[0] + else: + method = self.function_method + cmd = partial(*cmd) + if not self.cmd_section or self.cmd_section[-1][0] != method: + self.cmd_section.append((method, [cmd])) + else: + self.cmd_section[-1][1].append(cmd) + + def get_context(self, obj): + return {} + + def prepare(self): + """ + hook for executing something at the beging + define functions or initialize state + """ + self.append(textwrap.dedent("""\ + set -e + set -o pipefail + exit_code=0""") + ) + + def commit(self): + """ + hook for executing something at the end + apply the configuration, usually reloading a service + reloading a service is done in a separated method in order to reload + the service once in bulk operations + """ + self.append('exit $exit_code') + + +class ServiceController(ServiceBackend): + actions = ('save', 'delete') + abstract = True + + @classmethod + def get_verbose_name(cls): + return _("[S] %s") % super(ServiceController, cls).get_verbose_name() + + @classmethod + def get_backends(cls): + """ filter controller classes """ + backends = super(ServiceController, cls).get_backends() + return [ + backend for backend in backends if issubclass(backend, ServiceController) + ] diff --git a/orchestra/contrib/orchestration/forms.py b/orchestra/contrib/orchestration/forms.py new file mode 100644 index 0000000..7ea3353 --- /dev/null +++ b/orchestra/contrib/orchestration/forms.py @@ -0,0 +1,20 @@ +from django import forms + +from orchestra.forms.widgets import SpanWidget, PaddingCheckboxSelectMultiple + + +class RouteForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + super(RouteForm, self).__init__(*args, **kwargs) + if self.instance: + self.fields['backend'].required = False + try: + backend_class = self.instance.backend_class + except KeyError: + self.fields['backend'].widget = SpanWidget( + display='%s NOT AVAILABLE' % self.instance.backend) + else: + self.fields['backend'].widget = SpanWidget() + actions = backend_class.actions + self.fields['async_actions'].widget = PaddingCheckboxSelectMultiple(45) + self.fields['async_actions'].choices = ((action, action) for action in actions) diff --git a/orchestra/contrib/orchestration/helpers.py b/orchestra/contrib/orchestration/helpers.py new file mode 100644 index 0000000..68296ab --- /dev/null +++ b/orchestra/contrib/orchestration/helpers.py @@ -0,0 +1,173 @@ +import textwrap + +from django.contrib import messages +from django.core.mail import mail_admins +from django.urls import reverse, NoReverseMatch +from django.utils.html import escape +from django.utils.safestring import mark_safe +from django.utils.translation import ngettext, gettext_lazy as _ + +from orchestra import settings as orchestra_settings +from orchestra.admin.utils import change_url + + +def get_backends_help_text(backends): + help_texts = {} + for backend in backends: + help_text = backend.__doc__ or '' + context = { + 'model': backend.model, + 'related_models': str(backend.related_models), + 'script_executable': backend.script_executable, + 'script_method': '.'.join( + (backend.script_method.__module__, backend.script_method.__name__)), + 'function_method': '.'.join( + (backend.function_method.__module__, backend.function_method.__name__)), + 'actions': str(backend.actions), + } + help_text += textwrap.dedent(""" + - Model: '%(model)s' + - Related models: %(related_models)s + - Script executable: %(script_executable)s + - Script method: %(script_method)s + - Function method: %(function_method)s + - Actions: %(actions)s + """ + ) % context + help_text = help_text.lstrip().splitlines() + help_settings = [''] + if backend.doc_settings: + module, names = backend.doc_settings + for name in names: + value = getattr(module, name) + if isinstance(value, str): + help_settings.append("%s = '%s'" % (name, value)) + else: + help_settings.append("%s = %s" % (name, value)) + help_text += help_settings + help_texts[backend.get_name()] = '
    '.join(help_text) + return help_texts + + +def get_instance_url(operation): + try: + url = change_url(operation.instance) + except NoReverseMatch: + alt_repr = '%s-%s' % (operation.content_type, operation.object_id) + return _("Deleted {0}").format(operation.instance_repr or alt_repr) + return orchestra_settings.ORCHESTRA_SITE_URL + url + + +def send_report(method, args, log): + server = args[0] + backend = method.__self__.__class__.__name__ + subject = '[Orchestra] %s execution %s on %s' % (backend, log.state, server) + separator = "\n%s\n\n" % ('~ '*40,) + operations = '\n'.join( + [' '.join((op.action, get_instance_url(op))) for op in log.operations.all()] + ) + log_url = reverse('admin:orchestration_backendlog_change', args=(log.pk,)) + log_url = orchestra_settings.ORCHESTRA_SITE_URL + log_url + message = separator.join([ + "[EXIT CODE] %s" % log.exit_code, + "[STDERR]\n%s" % log.stderr, + "[STDOUT]\n%s" % log.stdout, + "[SCRIPT]\n%s" % log.script, + "[TRACEBACK]\n%s" % log.traceback, + "[OPERATIONS]\n%s" % operations, + "[BACKEND LOG] %s" % log_url, + ]) + html_message = '\n\n'.join([ + '

    Exit code %s

    ' % log.exit_code, + '

    Stderr

    ' + '
    %s
    ' % escape(log.stderr), + '

    Stdout

    ' + '
    %s
    ' % escape(log.stdout), + '

    Script

    ' + '
    %s
    ' % escape(log.script), + '

    Traceback

    ' + '
    %s
    ' % escape(log.traceback), + '

    Operations

    ' + '
    %s
    ' % escape(operations), + '

    Backend log %s

    ' % (log_url, log_url), + ]) + mail_admins(subject, message, html_message=html_message) + + +def get_backend_url(ids): + if len(ids) == 1: + return reverse('admin:orchestration_backendlog_change', args=ids) + elif len(ids) > 1: + url = reverse('admin:orchestration_backendlog_changelist') + return url + '?id__in=%s' % ','.join(map(str, ids)) + return '' + + +def get_messages(logs): + messages = [] + total, successes, run_async = 0, 0, 0 + ids = [] + async_ids = [] + for log in logs: + total += 1 + try: + # Some EXCEPTION logs are not stored on the database + ids.append(log.pk) + except AttributeError: + pass + if log.is_success: + successes += 1 + elif not log.has_finished: + run_async += 1 + async_ids.append(log.id) + errors = total-successes-run_async + url = get_backend_url(ids) + async_url = get_backend_url(async_ids) + async_msg = '' + if run_async: + async_msg = ngettext( + _('{name} is running on the background'), + _('{run_async} backends are running on the background'), + run_async) + if errors: + if total == 1: + msg = _('{name} has fail to execute') + else: + msg = ngettext( + _('{errors} out of {total} backends has fail to execute'), + _('{errors} out of {total} backends have fail to execute'), + errors) + if async_msg: + msg += ', ' + str(async_msg) + msg = msg.format(errors=errors, run_async=run_async, async_url=async_url, total=total, url=url, + name=log.backend) + messages.append(('error', msg + '.')) + elif successes: + if async_msg: + if total == 1: + msg = _('{name} has been executed') + else: + msg = ngettext( + _('{successes} out of {total} backends has been executed'), + _('{successes} out of {total} backends have been executed'), + successes) + msg += ', ' + str(async_msg) + else: + msg = ngettext( + _('{name} has been executed'), + _('{total} backends have been executed'), + total) + msg = msg.format( + total=total, url=url, async_url=async_url, run_async=run_async, successes=successes, + name=log.backend + ) + messages.append(('success', msg + '.')) + else: + msg = async_msg.format(url=url, async_url=async_url, run_async=run_async, name=log.backend) + messages.append(('success', msg + '.')) + return messages + + +def message_user(request, logs): + for func, msg in get_messages(logs): + getattr(messages, func)(request, mark_safe(msg)) diff --git a/orchestra/contrib/orchestration/management/__init__.py b/orchestra/contrib/orchestration/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestra/contrib/orchestration/management/commands/__init__.py b/orchestra/contrib/orchestration/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestra/contrib/orchestration/management/commands/orchestrate.py b/orchestra/contrib/orchestration/management/commands/orchestrate.py new file mode 100644 index 0000000..211f1b5 --- /dev/null +++ b/orchestra/contrib/orchestration/management/commands/orchestrate.py @@ -0,0 +1,137 @@ +import time +from django.apps import apps +from django.core.management.base import BaseCommand, CommandError +from django.db.models import Q + +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 OrderedSet +from orchestra.utils.sys import confirm + + +class Command(BaseCommand): + help = 'Runs orchestration backends.' + + def add_arguments(self, parser): + parser.add_argument('model', nargs='?', + help='Label of a model to execute the orchestration.') + parser.add_argument('query', nargs='*', + help='Query arguments for filter().') + parser.add_argument('--noinput', action='store_false', dest='interactive', default=True, + help='Tells Django to NOT prompt the user for input of any kind.') + parser.add_argument('-a', '--action', action='store', dest='action', + default='save', help='Executes action. Defaults to "save".') + parser.add_argument('-s', '--servers', action='store', dest='servers', + default='', help='Overrides route server resolution with the provided server.') + parser.add_argument('-b', '--backends', action='store', dest='backends', + default='', help='Overrides backend.') + parser.add_argument('-l', '--listbackends', action='store_true', dest='list_backends', default=False, + help='List available baclends.') + parser.add_argument('--dry-run', action='store_true', dest='dry', default=False, + help='Only prints scrtipt.') + + + def collect_operations(self, **options): + model = options.get('model') + backends = options.get('backends') or set() + if backends: + backends = set(backends.split(',')) + servers = options.get('servers') or set() + if servers: + servers = set([Server.objects.get(Q(address=server)|Q(name=server)) for server in servers.split(',')]) + action = options.get('action') + if not model: + models = set() + if servers: + for server in servers: + if backends: + routes = server.routes.filter(backend__in=backends) + else: + routes = server.routes.all() + elif backends: + routes = Route.objects.filter(backend__in=backends) + else: + raise CommandError("Model or --servers or --backends?") + for route in routes.filter(is_active=True): + model = route.backend_class.model_class() + models.add(model) + querysets = [model.objects.order_by('id') for model in models] + else: + kwargs = {} + for comp in options.get('query', []): + comps = iter(comp.split('=')) + for arg in comps: + kwargs[arg] = next(comps).strip().rstrip(',') + model = apps.get_model(*model.split('.')) + queryset = model.objects.filter(**kwargs).order_by('id') + querysets = [queryset] + + operations = OrderedSet() + route_cache = {} + for queryset in querysets: + for instance in queryset: + manager.collect(instance, action, operations=operations, route_cache=route_cache) + if backends: + result = [] + for operation in operations: + if operation.backend in backends: + result.append(operation) + operations = result + if servers: + routes = [] + result = [] + for operation in operations: + routes = [route for route in operation.routes if route.host in servers] + operation.routes = routes + if routes: + result.append(operation) + operations = result + return operations + + def handle(self, *args, **options): + list_backends = options.get('list_backends') + if list_backends: + for backend in ServiceBackend.get_backends(): + self.stdout.write(str(backend).split("'")[1]) + return + interactive = options.get('interactive') + dry = options.get('dry') + operations = self.collect_operations(**options) + scripts, serialize = manager.generate(operations) + servers = set() + # Print scripts + for key, value in scripts.items(): + route, __, __ = key + backend, operations = value + servers.add(str(route.host)) + self.stdout.write('# Execute %s on %s' % (backend.get_name(), route.host)) + for method, commands in backend.scripts: + script = '\n'.join(commands) + self.stdout.write(script.encode('ascii', errors='replace').decode()) + if interactive: + context = { + 'servers': ', '.join(servers), + } + if not confirm("\n\nAre your sure to execute the previous scripts on %(servers)s (yes/no)? " % context): + return + if not dry: + logs = manager.execute(scripts, serialize=serialize, run_async=True) + running = list(logs) + stdout = 0 + stderr = 0 + while running: + for log in running: + cstdout = len(log.stdout) + cstderr = len(log.stderr) + if cstdout > stdout: + self.stdout.write(log.stdout[stdout:]) + stdout = cstdout + if cstderr > stderr: + self.stderr.write(log.stderr[stderr:]) + stderr = cstderr + if log.has_finished: + running.remove(log) + time.sleep(0.05) + for log in logs: + self.stdout.write(' '.join((log.backend, log.state))) diff --git a/orchestra/contrib/orchestration/manager.py b/orchestra/contrib/orchestration/manager.py new file mode 100644 index 0000000..62572d0 --- /dev/null +++ b/orchestra/contrib/orchestration/manager.py @@ -0,0 +1,209 @@ +import logging +import threading +import traceback +from collections import OrderedDict + +from django.core.mail import mail_admins + +from orchestra.utils import db +from orchestra.utils.python import import_class, OrderedSet + +from . import settings, Operation +from .backends import ServiceBackend +from .helpers import send_report +from .models import BackendLog +from .signals import pre_action, post_action, pre_commit, post_commit, pre_prepare, post_prepare + + +logger = logging.getLogger(__name__) +router = import_class(settings.ORCHESTRATION_ROUTER) + + +def keep_log(execute, log, operations): + def wrapper(*args, **kwargs): + """ send report """ + # Remember that threads have their oun connection poll + # No need to EVER temper with the transaction here + log = kwargs['log'] + try: + log = execute(*args, **kwargs) + except Exception as e: + trace = traceback.format_exc() + log.state = log.EXCEPTION + log.stderr += trace + log.save() + subject = 'EXCEPTION executing backend(s) %s %s' % (args, kwargs) + logger.error(subject) + logger.error(trace) + mail_admins(subject, trace) + # We don't propagate the exception further to avoid transaction rollback + finally: + # Store and log the operation + for operation in operations: + logger.info("Executed %s" % operation) + operation.store(log) + if not log.is_success: + send_report(execute, args, log) + stdout = log.stdout.strip() + stdout and logger.debug('STDOUT %s', stdout.encode('ascii', errors='replace').decode()) + stderr = log.stderr.strip() + stderr and logger.debug('STDERR %s', stderr.encode('ascii', errors='replace').decode()) + return wrapper + + +def generate(operations): + scripts = OrderedDict() + cache = {} + serialize = False + # Generate scripts per route+backend + for operation in operations: + logger.debug("Queued %s" % operation) + if operation.routes is None: + operation.routes = router.objects.get_for_operation(operation, cache=cache) + for route in operation.routes: + # TODO key by action.async + async_action = route.action_is_async(operation.action) + key = (route, operation.backend, async_action) + if key not in scripts: + backend, operations = (operation.backend(), [operation]) + scripts[key] = (backend, operations) + backend.set_head() + pre_prepare.send(sender=backend.__class__, backend=backend) + backend.prepare() + post_prepare.send(sender=backend.__class__, backend=backend) + else: + scripts[key][1].append(operation) + # Get and call backend action method + backend = scripts[key][0] + method = getattr(backend, operation.action) + kwargs = { + 'sender': backend.__class__, + 'backend': backend, + 'instance': operation.instance, + 'action': operation.action, + } + backend.set_content() + pre_action.send(**kwargs) + method(operation.instance) + post_action.send(**kwargs) + if backend.serialize: + serialize = True + for value in scripts.values(): + backend, operations = value + backend.set_tail() + pre_commit.send(sender=backend.__class__, backend=backend) + backend.commit() + post_commit.send(sender=backend.__class__, backend=backend) + return scripts, serialize + + +def execute(scripts, serialize=False, run_async=None): + """ + executes the operations on the servers + + serialize: execute one backend at a time + run_async: do not join threads (overrides route.run_async) + """ + if settings.ORCHESTRATION_DISABLE_EXECUTION: + logger.info('Orchestration execution is dissabled by ORCHESTRATION_DISABLE_EXECUTION.') + return [] + # Execute scripts on each server + executions = [] + threads_to_join = [] + logs = [] + for key, value in scripts.items(): + route, __, async_action = key + backend, operations = value + args = (route.host,) + if run_async is None: + is_async = not serialize and (route.run_async or async_action) + else: + is_async = not serialize and (run_async or async_action) + kwargs = { + 'run_async': is_async, + } + # we clone the connection just in case we are isolated inside a transaction + with db.clone(model=BackendLog) as handle: + log = backend.create_log(*args, using=handle.target) + log._state.db = handle.origin + kwargs['log'] = log + task = keep_log(backend.execute, log, operations) + logger.debug('%s is going to be executed on %s.' % (backend, route.host)) + if serialize: + # Execute one backend at a time, no need for threads + task(*args, **kwargs) + else: + task = db.close_connection(task) + thread = threading.Thread(target=task, args=args, kwargs=kwargs) + thread.start() + if not is_async: + threads_to_join.append(thread) + logs.append(log) + [ thread.join() for thread in threads_to_join ] + return logs + + +def collect(instance, action, **kwargs): + """ collect operations """ + 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 + instances = [] + if action in backend_cls.actions: + if backend_cls.is_main(instance): + instances = [(instance, action)] + else: + for candidate in backend_cls.get_related(instance): + if candidate.__class__.__name__ == 'ManyRelatedManager': + if 'pk_set' in kwargs: + # m2m_changed signal + candidates = kwargs['model'].objects.filter(pk__in=kwargs['pk_set']) + else: + candidates = candidate.all() + else: + candidates = [candidate] + for candidate in candidates: + # Check if a delete for candidate is in operations + delete_mock = Operation(backend_cls, candidate, Operation.DELETE) + if delete_mock not in operations: + # related objects with backend.model trigger save() + instances.append((candidate, Operation.SAVE)) + for selected, iaction in instances: + # Maintain consistent state of operations based on save/delete behaviour + # Prevent creating a deleted selected by deleting existing saves + if iaction == Operation.DELETE: + save_mock = Operation(backend_cls, selected, Operation.SAVE) + try: + operations.remove(save_mock) + except KeyError: + pass + else: + update_fields = kwargs.get('update_fields', None) + if update_fields is not None: + # TODO remove this, django does not execute post_save if update_fields=[]... + # Maybe open a ticket at Djangoproject ? + # INITIAL INTENTION: "update_fields=[]" is a convention for explicitly executing backend + # i.e. account.disable() + if update_fields != []: + execute = False + for field in update_fields: + if field not in backend_cls.ignore_fields: + execute = True + break + if not execute: + continue + operation = Operation(backend_cls, selected, iaction) + # Only schedule operations if the router has execution routes + routes = router.objects.get_for_operation(operation, cache=route_cache) + if routes: + logger.debug("Operation %s collected for execution" % operation) + operation.routes = routes + if iaction != Operation.DELETE: + # usually we expect to be using last object state, + # except when we are deleting it + operations.discard(operation) + elif iaction == Operation.DELETE: + operation.preload_context() + operations.add(operation) + return operations diff --git a/orchestra/contrib/orchestration/managers.py b/orchestra/contrib/orchestration/managers.py new file mode 100644 index 0000000..f91ae29 --- /dev/null +++ b/orchestra/contrib/orchestration/managers.py @@ -0,0 +1,81 @@ +import sys +from threading import local + +from django.contrib.admin.models import LogEntry +from django.db.models.signals import pre_delete, post_save, m2m_changed +from django.dispatch import receiver +from django.utils.decorators import ContextDecorator + +from orchestra.utils.python import OrderedSet + +from . import manager, Operation, helpers +from .middlewares import OperationsMiddleware +from .models import BackendLog, BackendOperation + + +@receiver(post_save, dispatch_uid='orchestration.post_save_manager_collector') +def post_save_collector(sender, *args, **kwargs): + if sender not in (BackendLog, BackendOperation, LogEntry): + instance = kwargs.get('instance') + orchestrate.collect(Operation.SAVE, **kwargs) + + +@receiver(pre_delete, dispatch_uid='orchestration.pre_delete_manager_collector') +def pre_delete_collector(sender, *args, **kwargs): + if sender not in (BackendLog, BackendOperation, LogEntry): + orchestrate.collect(Operation.DELETE, **kwargs) + + +@receiver(m2m_changed, dispatch_uid='orchestration.m2m_manager_collector') +def m2m_collector(sender, *args, **kwargs): + # m2m relations without intermediary models are shit. Model.post_save is not sent and + # by the time related.post_save is sent rel objects are not accessible via RelatedManager.all() + if kwargs.pop('action') == 'post_add' and kwargs['pk_set']: + orchestrate.collect(Operation.SAVE, **kwargs) + + +class orchestrate(ContextDecorator): + """ + Context manager for triggering backend operations out of request-response cycle, e.g. shell + + with orchestrate(): + user = SystemUser.objects.get(username='rata') + user.shell = '/dev/null' + user.save(update_fields=('shell',)) + """ + thread_locals = local() + thread_locals.pending_operations = None + thread_locals.route_cache = None + + @classmethod + def collect(cls, action, **kwargs): + """ Collects all pending operations derived from model signals """ + if cls.thread_locals.pending_operations is None: + # No active orchestrate context manager + return + kwargs['operations'] = cls.thread_locals.pending_operations + kwargs['route_cache'] = cls.thread_locals.route_cache + instance = kwargs.pop('instance') + manager.collect(instance, action, **kwargs) + + def __enter__(self): + cls = type(self) + self.old_pending_operations = cls.thread_locals.pending_operations + cls.thread_locals.pending_operations = OrderedSet() + self.old_route_cache = cls.thread_locals.route_cache + cls.thread_locals.route_cache = {} + + def __exit__(self, exc_type, exc_value, traceback): + cls = type(self) + if not exc_type: + operations = cls.thread_locals.pending_operations + if operations: + scripts, serialize = manager.generate(operations) + logs = manager.execute(scripts, serialize=serialize) + for t, msg in helpers.get_messages(logs): + if t == 'error': + sys.stderr.write('%s: %s\n' % (t, msg)) + else: + sys.stdout.write('%s: %s\n' % (t, msg)) + cls.thread_locals.pending_operations = self.old_pending_operations + cls.thread_locals.route_cache = self.old_route_cache diff --git a/orchestra/contrib/orchestration/methods.py b/orchestra/contrib/orchestration/methods.py new file mode 100644 index 0000000..cd3d7a2 --- /dev/null +++ b/orchestra/contrib/orchestration/methods.py @@ -0,0 +1,186 @@ +import inspect +import logging +import socket +import sys +import select +import textwrap + +from celery.datastructures import ExceptionInfo + +from orchestra.settings import ORCHESTRA_SSH_DEFAULT_USER +from orchestra.utils.sys import sshrun +from orchestra.utils.python import CaptureStdout, import_class + +from . import settings + + +logger = logging.getLogger(__name__) + + +def Paramiko(backend, log, server, cmds, run_async=False, paramiko_connections={}): + """ + Executes cmds to remote server using Pramaiko + """ + import paramiko + script = '\n'.join(cmds) + script = script.replace('\r', '') + log.state = log.STARTED + log.script = script + log.save(update_fields=('script', 'state', 'updated_at')) + if not cmds: + return + channel = None + ssh = None + try: + addr = server.get_address() + # ssh connection + ssh = paramiko_connections.get(addr) + if not ssh: + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + key = settings.ORCHESTRATION_SSH_KEY_PATH + try: + ssh.connect(addr, username=ORCHESTRA_SSH_DEFAULT_USER, key_filename=key) + except socket.error as e: + logger.error('%s timed out on %s' % (backend, addr)) + log.state = log.TIMEOUT + log.stderr = str(e) + log.save(update_fields=('state', 'stderr', 'updated_at')) + return + paramiko_connections[addr] = ssh + transport = ssh.get_transport() + channel = transport.open_session() + channel.exec_command(backend.script_executable) + channel.sendall(script) + channel.shutdown_write() + # Log results + logger.debug('%s running on %s' % (backend, server)) + if run_async: + second = False + while True: + # Non-blocking is the secret ingridient in the async sauce + select.select([channel], [], []) + if channel.recv_ready(): + part = channel.recv(1024).decode('utf-8') + while part: + log.stdout += part + part = channel.recv(1024).decode('utf-8') + if channel.recv_stderr_ready(): + part = channel.recv_stderr(1024).decode('utf-8') + while part: + log.stderr += part + part = channel.recv_stderr(1024).decode('utf-8') + log.save(update_fields=('stdout', 'stderr', 'updated_at')) + if channel.exit_status_ready(): + if second: + break + second = True + else: + log.stdout += channel.makefile('rb', -1).read().decode('utf-8') + log.stderr += channel.makefile_stderr('rb', -1).read().decode('utf-8') + + log.exit_code = channel.recv_exit_status() + log.state = log.SUCCESS if log.exit_code == 0 else log.FAILURE + logger.debug('%s execution state on %s is %s' % (backend, server, log.state)) + log.save() + except: + log.state = log.ERROR + log.traceback = ExceptionInfo(sys.exc_info()).traceback + logger.error('Exception while executing %s on %s' % (backend, server)) + logger.debug(log.traceback) + log.save() + finally: + if log.state == log.STARTED: + log.state = log.ABORTED + log.save(update_fields=('state', 'updated_at')) + if channel is not None: + channel.close() + + +def OpenSSH(backend, log, server, cmds, run_async=False): + """ + Executes cmds to remote server using SSH with connection resuse for maximum performance + """ + script = '\n'.join(cmds) + script = script.replace('\r', '') + log.state = log.STARTED + log.script = '\n'.join((log.script, script)) + log.save(update_fields=('script', 'state', 'updated_at')) + if not cmds: + return + try: + ssh = sshrun(server.get_address(), script, executable=backend.script_executable, + persist=True, run_async=run_async, silent=True) + logger.debug('%s running on %s' % (backend, server)) + if run_async: + for state in ssh: + log.stdout += state.stdout.decode('utf8') + log.stderr += state.stderr.decode('utf8') + log.save(update_fields=('stdout', 'stderr', 'updated_at')) + exit_code = state.exit_code + else: + log.stdout += ssh.stdout.decode('utf8') + log.stderr += ssh.stderr.decode('utf8') + exit_code = ssh.exit_code + if not log.exit_code: + log.exit_code = exit_code + if exit_code == 255 and log.stderr.startswith('ssh: connect to host'): + log.state = log.TIMEOUT + else: + log.state = log.SUCCESS if exit_code == 0 else log.FAILURE + logger.debug('%s execution state on %s is %s' % (backend, server, log.state)) + log.save() + except: + log.state = log.ERROR + log.traceback = ExceptionInfo(sys.exc_info()).traceback + logger.error('Exception while executing %s on %s' % (backend, server)) + logger.debug(log.traceback) + log.save() + finally: + if log.state == log.STARTED: + log.state = log.ABORTED + log.save(update_fields=('state', 'updated_at')) + + +def SSH(*args, **kwargs): + """ facade function enabling to chose between multiple SSH backends""" + method = import_class(settings.ORCHESTRATION_SSH_METHOD_BACKEND) + return method(*args, **kwargs) + + +def Python(backend, log, server, cmds, run_async=False): + script = '' + functions = set() + for cmd in cmds: + if cmd.func not in functions: + functions.add(cmd.func) + script += textwrap.dedent(''.join(inspect.getsourcelines(cmd.func)[0])) + script += '\n' + for cmd in cmds: + script += '# %s %s\n' % (cmd.func.__name__, cmd.args) + log.state = log.STARTED + log.script = '\n'.join((log.script, script)) + log.save(update_fields=('script', 'state', 'updated_at')) + stdout = '' + try: + for cmd in cmds: + with CaptureStdout() as stdout: + result = cmd(server) + for line in stdout: + log.stdout += line + '\n' + if result: + log.stdout += '# Result: %s\n' % result + if run_async: + log.save(update_fields=('stdout', 'updated_at')) + except: + log.exit_code = 1 + log.state = log.FAILURE + log.stdout += '\n'.join(stdout) + log.traceback += ExceptionInfo(sys.exc_info()).traceback + logger.error('Exception while executing %s on %s' % (backend, server)) + else: + if not log.exit_code: + log.exit_code = 0 + log.state = log.SUCCESS + logger.debug('%s execution state on %s is %s' % (backend, server, log.state)) + log.save() diff --git a/orchestra/contrib/orchestration/middlewares.py b/orchestra/contrib/orchestration/middlewares.py new file mode 100644 index 0000000..61333c5 --- /dev/null +++ b/orchestra/contrib/orchestration/middlewares.py @@ -0,0 +1,116 @@ +from threading import local + +from django.contrib.admin.models import LogEntry +from django.db import transaction +from django.db.models.signals import m2m_changed, post_save, pre_delete +from django.dispatch import receiver +from django.http.response import HttpResponseServerError +from django.urls import resolve +from django.utils.deprecation import MiddlewareMixin +from orchestra.utils.python import OrderedSet + +from . import Operation, manager +from .helpers import message_user +from .models import BackendLog, BackendOperation + + +@receiver(post_save, dispatch_uid='orchestration.post_save_collector') +def post_save_collector(sender, *args, **kwargs): + if sender not in (BackendLog, BackendOperation, LogEntry): + instance = kwargs.get('instance') + OperationsMiddleware.collect(Operation.SAVE, **kwargs) + + +@receiver(pre_delete, dispatch_uid='orchestration.pre_delete_collector') +def pre_delete_collector(sender, *args, **kwargs): + if sender not in (BackendLog, BackendOperation, LogEntry): + OperationsMiddleware.collect(Operation.DELETE, **kwargs) + + +@receiver(m2m_changed, dispatch_uid='orchestration.m2m_collector') +def m2m_collector(sender, *args, **kwargs): + # m2m relations without intermediary models are shit. Model.post_save is not sent and + # by the time related.post_save is sent rel objects are not accessible via RelatedManager.all() + if kwargs.pop('action') == 'post_add' and kwargs['pk_set']: + OperationsMiddleware.collect(Operation.SAVE, **kwargs) + + +class OperationsMiddleware(MiddlewareMixin): + """ + Stores all the operations derived from save and delete signals and executes them + at the end of the request/response cycle + + It also works as a transaction middleware, making requets to run within an atomic block. + """ + # Thread local is used because request object is not available on model signals + thread_locals = local() + + @classmethod + def get_pending_operations(cls): + # Check if an error poped up before OperationsMiddleware.process_request() + if hasattr(cls.thread_locals, 'request'): + request = cls.thread_locals.request + if not hasattr(request, 'pending_operations'): + request.pending_operations = OrderedSet() + return request.pending_operations + return set() + + @classmethod + def get_route_cache(cls): + """ chache the routes to save sql queries """ + if hasattr(cls.thread_locals, 'request'): + request = cls.thread_locals.request + if not hasattr(request, 'route_cache'): + request.route_cache = {} + return request.route_cache + return {} + + @classmethod + def collect(cls, action, **kwargs): + """ Collects all pending operations derived from model signals """ + request = getattr(cls.thread_locals, 'request', None) + if request is None: + return + kwargs['operations'] = cls.get_pending_operations() + kwargs['route_cache'] = cls.get_route_cache() + instance = kwargs.pop('instance') + manager.collect(instance, action, **kwargs) + + def enter_transaction_management(self): + type(self).thread_locals.transaction = transaction.atomic() + type(self).thread_locals.transaction.__enter__() + + def leave_transaction_management(self, exception=None): + locals = type(self).thread_locals + if hasattr(locals, 'transaction'): + # Don't fucking know why sometimes thread_locals does not contain a transaction + locals.transaction.__exit__(exception, None, None) + + def process_request(self, request): + """ Store request on a thread local variable """ + type(self).thread_locals.request = request + self.enter_transaction_management() + + def process_exception(self, request, exception): + """Rolls back the database and leaves transaction management""" + self.leave_transaction_management(exception) + + def process_response(self, request, response): + """ Processes pending backend operations """ + if response.status_code != 500: + operations = self.get_pending_operations() + if operations: + try: + scripts, serialize = manager.generate(operations) + except Exception as exception: + self.leave_transaction_management(exception) + raise + # We commit transaction just before executing operations + # because here is when IntegrityError show up + self.leave_transaction_management() + logs = manager.execute(scripts, serialize=serialize) + if logs and resolve(request.path).app_name == 'admin': + message_user(request, logs) + return response + self.leave_transaction_management() + return response diff --git a/orchestra/contrib/orchestration/models.py b/orchestra/contrib/orchestration/models.py new file mode 100644 index 0000000..4f8606d --- /dev/null +++ b/orchestra/contrib/orchestration/models.py @@ -0,0 +1,266 @@ +import logging +import socket + +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.encoding import force_str +from django.utils.functional import cached_property +from django.utils.module_loading import autodiscover_modules +from django.utils.translation import gettext_lazy as _ + +from orchestra.core.validators import validate_ip_address, validate_hostname, OrValidator +from orchestra.models.fields import NullableCharField, MultiSelectField + +from . import settings +from .backends import ServiceBackend + + +logger = logging.getLogger(__name__) + + +class Server(models.Model): + """ Machine runing daemons (services) """ + name = models.CharField(_("name"), max_length=256, unique=True, + help_text=_("Verbose name or hostname of this server.")) + address = NullableCharField(_("address"), max_length=256, blank=True, + validators=[OrValidator(validate_ip_address, validate_hostname)], + null=True, unique=True, help_text=_( + "Optional IP address or domain name. If blank, name field will be used for address resolution.
    " + "If the IP address never changes you can set this field and save DNS requests.")) + description = models.TextField(_("description"), blank=True) + os = models.CharField(_("operative system"), max_length=32, + choices=settings.ORCHESTRATION_OS_CHOICES, + default=settings.ORCHESTRATION_DEFAULT_OS) + + def __str__(self): + return self.name or str(self.address) + + def get_address(self): + if self.address: + return self.address + return self.name + + def get_ip(self): + address = self.get_address() + try: + return validate_ip_address(address) + except ValidationError: + return socket.gethostbyname(self.name) + + def clean(self): + self.name = self.name.strip() + if self.address: + self.address = self.address.strip() + elif self.name: + validate = OrValidator(validate_ip_address, validate_hostname) + validate_hostname(self.name) + try: + validate(self.name) + except ValidationError as err: + raise ValidationError({ + 'name': _("Name should be a valid hostname or IP address when address is not provided.") + }) + + +class BackendLog(models.Model): + RECEIVED = 'RECEIVED' + TIMEOUT = 'TIMEOUT' + STARTED = 'STARTED' + SUCCESS = 'SUCCESS' + FAILURE = 'FAILURE' + ERROR = 'ERROR' + REVOKED = 'REVOKED' + ABORTED = 'ABORTED' + NOTHING = 'NOTHING' + # Special state for mocked backendlogs + EXCEPTION = 'EXCEPTION' + + STATES = ( + (RECEIVED, RECEIVED), + (TIMEOUT, TIMEOUT), + (STARTED, STARTED), + (SUCCESS, SUCCESS), + (FAILURE, FAILURE), + (ERROR, ERROR), + (ABORTED, ABORTED), + (REVOKED, REVOKED), + (NOTHING, NOTHING), + ) + + backend = models.CharField(_("backend"), max_length=256) + state = models.CharField(_("state"), max_length=16, choices=STATES, default=RECEIVED) + server = models.ForeignKey(Server, verbose_name=_("server"), related_name='execution_logs', on_delete=models.CASCADE) + script = models.TextField(_("script")) + stdout = models.TextField(_("stdout")) + stderr = models.TextField(_("stderr")) + traceback = models.TextField(_("traceback")) + exit_code = models.IntegerField(_("exit code"), null=True) + task_id = models.CharField(_("task ID"), max_length=36, unique=True, null=True, + help_text="Celery task ID when used as execution backend") + created_at = models.DateTimeField(_("created"), auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(_("updated"), auto_now=True) + + class Meta: + get_latest_by = 'id' + + def __str__(self): + return "%s@%s" % (self.backend, self.server) + + @property + def execution_time(self): + return (self.updated_at-self.created_at).total_seconds() + + @property + def has_finished(self): + return self.state not in (self.STARTED, self.RECEIVED) + + @property + def is_success(self): + return self.state in (self.SUCCESS, self.NOTHING) + + def backend_class(self): + return ServiceBackend.get_backend(self.backend) + + +class BackendOperationQuerySet(models.QuerySet): + def create(self, **kwargs): + instance = kwargs.get('instance') + if instance and 'instance_repr' not in kwargs: + kwargs['instance_repr'] = force_str(instance)[:256] + return super(BackendOperationQuerySet, self).create(**kwargs) + + +class BackendOperation(models.Model): + """ + Encapsulates an operation, storing its related object, the action and the backend. + """ + log = models.ForeignKey('orchestration.BackendLog', related_name='operations', on_delete=models.CASCADE) + backend = models.CharField(_("backend"), max_length=256) + action = models.CharField(_("action"), max_length=64) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField(null=True) + instance_repr = models.CharField(_("instance representation"), max_length=256) + + instance = GenericForeignKey('content_type', 'object_id') + objects = BackendOperationQuerySet.as_manager() + + class Meta: + verbose_name = _("Operation") + verbose_name_plural = _("Operations") + index_together = ( + ('content_type', 'object_id'), + ) + + def __str__(self): + return '%s.%s(%s)' % (self.backend, self.action, self.instance or self.instance_repr) + + @cached_property + def backend_class(self): + return ServiceBackend.get_backend(self.backend) + + +autodiscover_modules('backends') + + +class RouteQuerySet(models.QuerySet): + def get_for_operation(self, operation, **kwargs): + cache = kwargs.get('cache', {}) + if not cache: + for route in self.filter(is_active=True).select_related('host'): + try: + backend_class = route.backend_class + except KeyError: + logger.warning("Backed '%s' not installed." % route.backend) + else: + for action in backend_class.get_actions(): + key = (route.backend, action) + try: + cache[key].append(route) + except KeyError: + cache[key] = [route] + routes = [] + backend_cls = operation.backend + key = (backend_cls.get_name(), operation.action) + try: + target_routes = cache[key] + except KeyError: + pass + else: + for route in target_routes: + if route.matches(operation.instance): + routes.append(route) + return routes + + +class Route(models.Model): + """ + Defines the routing that determine in which server a backend is executed + """ + backend = models.CharField(_("backend"), max_length=256, + choices=ServiceBackend.get_choices()) + host = models.ForeignKey(Server, verbose_name=_("host"), related_name='routes', on_delete=models.CASCADE) + match = models.CharField(_("match"), max_length=256, blank=True, default='True', + help_text=_("Python expression used for selecting the targe host, " + "instance referes to the current object.")) + run_async = models.BooleanField(default=False, + help_text=_("Whether or not block the request/response cycle waitting this backend to " + "finish its execution. Usually you want slave servers to run asynchronously.")) + async_actions = MultiSelectField(max_length=256, blank=True, choices=[], + help_text=_("Specify individual actions to be executed asynchronoulsy.")) +# method = models.CharField(_("method"), max_lenght=32, choices=method_choices, +# default=MethodBackend.get_default()) + is_active = models.BooleanField(_("active"), default=True) + + objects = RouteQuerySet.as_manager() + + class Meta: + unique_together = ('backend', 'host') + + def __str__(self): + return "%s@%s" % (self.backend, self.host) + + @cached_property + def backend_class(self): + return ServiceBackend.get_backend(self.backend) + + def clean(self): + if not self.match: + self.match = 'True' + if self.backend: + try: + backend_class = self.backend_class + except KeyError: + raise ValidationError({ + 'backend': _("Backend '%s' is not installed.") % self.backend + }) + backend_model = backend_class.model_class() + try: + obj = backend_model.objects.all()[0] + except IndexError: + return + try: + bool(self.matches(obj)) + except Exception as exception: + name = type(exception).__name__ + raise ValidationError(': '.join((name, str(exception)))) + + def action_is_async(self, action): + return action in self.async_actions + + def matches(self, instance): + safe_locals = { + 'instance': instance, + 'obj': instance, + instance._meta.model_name: instance, + } + return eval(self.match, safe_locals) + + def enable(self): + self.is_active = True + self.save() + + def disable(self): + self.is_active = False + self.save() diff --git a/orchestra/contrib/orchestration/settings.py b/orchestra/contrib/orchestration/settings.py new file mode 100644 index 0000000..fee41c4 --- /dev/null +++ b/orchestra/contrib/orchestration/settings.py @@ -0,0 +1,51 @@ +from os import path + +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.settings import Setting + + +ORCHESTRATION_OS_CHOICES = Setting('ORCHESTRATION_OS_CHOICES', + ( + ('LINUX', "Linux"), + ), + validators=[Setting.validate_choices] +) + + +ORCHESTRATION_DEFAULT_OS = Setting('ORCHESTRATION_DEFAULT_OS', + 'LINUX', + choices=ORCHESTRATION_OS_CHOICES +) + + +ORCHESTRATION_SSH_KEY_PATH = Setting('ORCHESTRATION_SSH_KEY_PATH', + path.join(path.expanduser('~'), '.ssh/id_rsa') +) + + +ORCHESTRATION_ROUTER = Setting('ORCHESTRATION_ROUTER', + 'orchestra.contrib.orchestration.models.Route', + validators=[Setting.validate_import_class] +) + + + +ORCHESTRATION_DISABLE_EXECUTION = Setting('ORCHESTRATION_DISABLE_EXECUTION', + False +) + + +ORCHESTRATION_BACKEND_CLEANUP_DAYS = Setting('ORCHESTRATION_BACKEND_CLEANUP_DAYS', + 20 +) + + +ORCHESTRATION_SSH_METHOD_BACKEND = Setting('ORCHESTRATION_SSH_METHOD_BACKEND', + 'orchestra.contrib.orchestration.methods.OpenSSH', + help_text=_("Two methods are provided:
    " + "1) orchestra.contrib.orchestration.methods.OpenSSH with ControlPersist.
    " + "2) orchestra.contrib.orchestration.methods.Paramiko with connection pool.
    " + "Both perform similarly, but OpenSSH has the advantage that the connections are shared between workers. " + "Paramiko, in contrast, has a per worker connection pool.") +) diff --git a/orchestra/contrib/orchestration/signals.py b/orchestra/contrib/orchestration/signals.py new file mode 100644 index 0000000..a49f493 --- /dev/null +++ b/orchestra/contrib/orchestration/signals.py @@ -0,0 +1,14 @@ +import django.dispatch + +pre_action = django.dispatch.Signal() + +post_action = django.dispatch.Signal() + +pre_prepare = django.dispatch.Signal() + +post_prepare = django.dispatch.Signal() + +pre_commit = django.dispatch.Signal() + +post_commit = django.dispatch.Signal() + diff --git a/orchestra/contrib/orchestration/tasks.py b/orchestra/contrib/orchestration/tasks.py new file mode 100644 index 0000000..35146c2 --- /dev/null +++ b/orchestra/contrib/orchestration/tasks.py @@ -0,0 +1,16 @@ +from datetime import timedelta + +from celery.task.schedules import crontab +from django.utils import timezone + +from orchestra.contrib.tasks import periodic_task + +from . import settings +from .models import BackendLog + + +@periodic_task(run_every=crontab(hour=7, minute=0)) +def backend_logs_cleanup(): + days = settings.ORCHESTRATION_BACKEND_CLEANUP_DAYS + epoch = timezone.now()-timedelta(days=days) + return BackendLog.objects.filter(created_at__lt=epoch).only('id').delete() diff --git a/orchestra/contrib/orchestration/templates/admin/orchestration/backends/retry.html b/orchestra/contrib/orchestration/templates/admin/orchestration/backends/retry.html new file mode 100644 index 0000000..65f2147 --- /dev/null +++ b/orchestra/contrib/orchestration/templates/admin/orchestration/backends/retry.html @@ -0,0 +1,9 @@ +{% extends "admin/orchestra/generic_confirmation.html" %} + + +{% block form %} + {% if deleted_objects %} +

    The following operations refere to deleted objects and will not be executed

    +
      {{ deleted_objects | unordered_list }}
    + {% endif %} +{% endblock %} diff --git a/orchestra/contrib/orchestration/templates/admin/orchestration/orchestrate.html b/orchestra/contrib/orchestration/templates/admin/orchestration/orchestrate.html new file mode 100644 index 0000000..df43832 --- /dev/null +++ b/orchestra/contrib/orchestration/templates/admin/orchestration/orchestrate.html @@ -0,0 +1,16 @@ +{% extends "admin/orchestra/generic_confirmation.html" %} +{% load utils %} + +{% block display_objects %} +
      + {% for backend, operations in display_objects.items %} +
    • Backend: {{ backend }} +
        + {% for operation in operations %} +
      • {{ operation.instance|admin_link }} @ {% for route in operation.routes %}{{ route.host|admin_link }}{% if not forloop.last %},{% endif %} {% endfor %}
      • + {% endfor %} +
      +
    • + {% endfor %} +
    +{% endblock %} diff --git a/orchestra/contrib/orchestration/tests/__init__.py b/orchestra/contrib/orchestration/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestra/contrib/orchestration/tests/test_route.py b/orchestra/contrib/orchestration/tests/test_route.py new file mode 100644 index 0000000..390661e --- /dev/null +++ b/orchestra/contrib/orchestration/tests/test_route.py @@ -0,0 +1,41 @@ +from orchestra.utils.tests import BaseTestCase + +from .. import backends, Operation +from ..models import Route, Server + + +class RouterTests(BaseTestCase): + def setUp(self): + self.host = Server.objects.create(name='web.example.com') + self.host1 = Server.objects.create(name='web1.example.com') + self.host2 = Server.objects.create(name='web2.example.com') + + def test_list_backends(self): + # TODO count actual, register and compare + choices = list(Route._meta.get_field('backend').choices) + self.assertLess(1, len(choices)) + + def test_get_instances(self): + + class TestBackend(backends.ServiceController): + verbose_name = 'Route' + models = ['routes.Route'] + + def save(self, instance): + pass + + choices = backends.ServiceBackend.get_choices() + Route._meta.get_field('backend').choices = choices + backend = TestBackend.get_name() + + route = Route.objects.create(backend=backend, host=self.host, match='True') + operation = Operation(backend=TestBackend, instance=route, action='save') + self.assertEqual(1, len(Route.objects.get_for_operation(operation))) + + route = Route.objects.create(backend=backend, host=self.host1, + match='route.backend == "%s"' % TestBackend.get_name()) + self.assertEqual(2, len(Route.objects.get_for_operation(operation))) + + route = Route.objects.create(backend=backend, host=self.host2, + match='route.backend == "something else"') + self.assertEqual(2, len(Route.objects.get_for_operation(operation))) diff --git a/orchestra/contrib/orchestration/utils.py b/orchestra/contrib/orchestration/utils.py new file mode 100644 index 0000000..df59f8c --- /dev/null +++ b/orchestra/contrib/orchestration/utils.py @@ -0,0 +1,37 @@ +from orchestra.utils.sys import run, sshrun, join + + +def retrieve_state(servers): + uptimes = [] + pings = [] + for server in servers: + address = server.get_address() + ping = run('ping -c 1 -w 1 %s' % address, run_async=True) + pings.append(ping) + uptime = sshrun(address, 'uptime', persist=True, run_async=True, options={'ConnectTimeout': 1}) + uptimes.append(uptime) + + state = {} + for server, ping, uptime in zip(servers, pings, uptimes): + ping = join(ping, silent=True) + + try: + ping = ping.stdout.splitlines()[-1].decode() + except IndexError: + ping = '' + + if ping.startswith('rtt'): + ping = '%s ms' % ping.split('/')[4] + else: + ping = 'Offline' + + uptime = join(uptime, silent=True) + uptime_stderr = uptime.stderr.decode() + uptime = uptime.stdout.decode().split() + if uptime: + uptime = 'Up %s %s load %s %s %s' % (uptime[2], uptime[3], uptime[-3], uptime[-2], uptime[-1]) + else: + uptime = '%s' % uptime_stderr + state[server.pk] = (ping, uptime) + + return state diff --git a/orchestra/contrib/orchestration/widgets.py b/orchestra/contrib/orchestration/widgets.py new file mode 100644 index 0000000..576de4e --- /dev/null +++ b/orchestra/contrib/orchestration/widgets.py @@ -0,0 +1,24 @@ +import textwrap + +from orchestra.forms.widgets import DynamicHelpTextSelect + + +class RouteBackendSelect(DynamicHelpTextSelect): + """ Updates matches input field based on selected backend """ + def __init__(self, target, help_text, route_matches, *args, **kwargs): + kwargs['attrs'] = { + 'onfocus': "this.oldvalue = this.value;", + } + self.route_matches = route_matches + super(RouteBackendSelect, self).__init__(target, help_text, *args, **kwargs) + + def get_dynamic_help_text(self, target, help_text): + help_text = super(RouteBackendSelect, self).get_dynamic_help_text(target, help_text) + return help_text + textwrap.dedent("""\ + routematches = {route_matches}; + match = $("#id_match"); + if ( this.oldvalue == "" || match.value == routematches[this.oldvalue]) + match.value = routematches[this.options[this.selectedIndex].value]; + this.oldvalue = this.value; + """.format(route_matches=self.route_matches) + ) diff --git a/orchestra/contrib/orders/__init__.py b/orchestra/contrib/orders/__init__.py new file mode 100644 index 0000000..753c362 --- /dev/null +++ b/orchestra/contrib/orders/__init__.py @@ -0,0 +1 @@ +default_app_config = 'orchestra.contrib.orders.apps.OrdersConfig' diff --git a/orchestra/contrib/orders/actions.py b/orchestra/contrib/orders/actions.py new file mode 100644 index 0000000..ba2244e --- /dev/null +++ b/orchestra/contrib/orders/actions.py @@ -0,0 +1,174 @@ +from django.contrib import admin, messages +from django.urls import reverse +from django.db import transaction +from django.utils import timezone +from django.utils.safestring import mark_safe +from django.utils.translation import ngettext, gettext_lazy as _ +from django.shortcuts import render + +from orchestra.admin.utils import change_url + +from .forms import BillSelectedOptionsForm, BillSelectConfirmationForm, BillSelectRelatedForm + + +class BillSelectedOrders(object): + """ Form wizard for billing orders admin action """ + short_description = _("Bill selected orders") + verbose_name = _("Bill") + template = 'admin/orders/order/bill_selected_options.html' + __name__ = 'bill_selected_orders' + + def __call__(self, modeladmin, request, queryset): + """ make this monster behave like a function """ + self.modeladmin = modeladmin + self.queryset = queryset + opts = modeladmin.model._meta + app_label = opts.app_label + self.context = { + 'opts': opts, + 'app_label': app_label, + 'queryset': queryset, + 'action_checkbox_name': admin.helpers.ACTION_CHECKBOX_NAME, + } + ret = self.set_options(request) + del(self.queryset) + del(self.context) + return ret + + def set_options(self, request): + form = BillSelectedOptionsForm() + if request.POST.get('step'): + form = BillSelectedOptionsForm(request.POST) + if form.is_valid(): + self.options = dict( + billing_point=form.cleaned_data['billing_point'], + fixed_point=form.cleaned_data['fixed_point'], + proforma=form.cleaned_data['proforma'], + new_open=form.cleaned_data['new_open'], + ) + if int(request.POST.get('step')) != 3: + return self.select_related(request) + else: + return self.confirmation(request) + self.context.update({ + 'title': _("Options for billing selected orders, step 1 / 3"), + 'step': 1, + 'form': form, + }) + return render(request, self.template, self.context) + + def select_related(self, request): + # TODO use changelist ? + related = self.queryset.get_related().select_related('account', 'service') + if not related: + return self.confirmation(request) + self.options['related_queryset'] = related + form = BillSelectRelatedForm(initial=self.options) + if int(request.POST.get('step')) >= 2: + form = BillSelectRelatedForm(request.POST, initial=self.options) + if form.is_valid(): + select_related = form.cleaned_data['selected_related'] + self.queryset = self.queryset | select_related + return self.confirmation(request) + self.context.update({ + 'title': _("Select related order for billing, step 2 / 3"), + 'step': 2, + 'form': form, + }) + return render(request, self.template, self.context) + + @transaction.atomic + def confirmation(self, request): + form = BillSelectConfirmationForm(initial=self.options) + if int(request.POST.get('step')) >= 3: + bills = self.queryset.bill(commit=True, **self.options) + for order in self.queryset: + self.modeladmin.log_change(request, order, _("Billed")) + if not bills: + msg = _("Selected orders do not have pending billing") + self.modeladmin.message_user(request, msg, messages.WARNING) + else: + num = len(bills) + if num == 1: + url = change_url(bills[0]) + else: + url = reverse('admin:bills_bill_changelist') + ids = ','.join([str(b.id) for b in bills]) + url += '?id__in=%s' % ids + msg = ngettext( + 'One bill has been created.', + '{num} bills have been created.', + num).format(url=url, num=num) + msg = mark_safe(msg) + self.modeladmin.message_user(request, msg, messages.INFO) + return + bills = self.queryset.bill(commit=False, **self.options) + bills_with_total = [] + for account, lines in bills: + total = 0 + for line in lines: + discount = sum([discount.total for discount in line.discounts]) + total += line.subtotal + discount + bills_with_total.append((account, total, lines)) + self.context.update({ + 'title': _("Confirmation for billing selected orders"), + 'step': 3, + 'form': form, + 'bills': sorted(bills_with_total, key=lambda i: -i[1]), + }) + return render(request, self.template, self.context) + + +@transaction.atomic +def mark_as_ignored(modeladmin, request, queryset): + """ Mark orders as ignored """ + for order in queryset: + order.mark_as_ignored() + modeladmin.log_change(request, order, 'Marked as ignored') + num = len(queryset) + msg = ngettext( + _("Selected order has been marked as ignored."), + _("%i selected orders have been marked as ignored.") % num, + num) + modeladmin.message_user(request, msg) + + +@transaction.atomic +def mark_as_not_ignored(modeladmin, request, queryset): + """ Mark orders as ignored """ + for order in queryset: + order.mark_as_not_ignored() + modeladmin.log_change(request, order, 'Marked as not ignored') + num = len(queryset) + msg = ngettext( + _("Selected order has been marked as not ignored."), + _("%i selected orders have been marked as not ignored.") % num, + num) + modeladmin.message_user(request, msg) + + +def report(modeladmin, request, queryset): + services = {} + totals = [0, 0, None, 0] + now = timezone.now().date() + for order in queryset.select_related('service'): + name = order.service.description + active, cancelled = (1, 0) if not order.cancelled_on or order.cancelled_on > now else (0, 1) + try: + info = services[name] + except KeyError: + nominal_price = order.service.nominal_price + info = [active, cancelled, nominal_price, 1] + services[name] = info + else: + info[0] += active + info[1] += cancelled + info[3] += 1 + totals[0] += active + totals[1] += cancelled + totals[3] += 1 + context = { + 'services': sorted(services.items(), key=lambda n: -n[1][0]), + 'totals': totals, + } + return render(request, 'admin/orders/order/report.html', context) diff --git a/orchestra/contrib/orders/admin.py b/orchestra/contrib/orders/admin.py new file mode 100644 index 0000000..3c3086b --- /dev/null +++ b/orchestra/contrib/orders/admin.py @@ -0,0 +1,207 @@ +from datetime import datetime +from django import forms +from django.contrib import admin +from django.urls import reverse, NoReverseMatch +from django.db.models import Prefetch +from django.utils import timezone +from django.utils.html import escape, format_html +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ + +from orchestra.admin import ExtendedModelAdmin +from orchestra.admin.utils import admin_link, admin_date, change_url +from orchestra.contrib.accounts.actions import list_accounts +from orchestra.contrib.accounts.admin import AccountAdminMixin +from orchestra.utils.humanize import naturaldate + +from .actions import BillSelectedOrders, mark_as_ignored, mark_as_not_ignored, report +from .filters import IgnoreOrderListFilter, ActiveOrderListFilter, BilledOrderListFilter +from .models import Order, MetricStorage + + +class MetricStorageInline(admin.TabularInline): + model = MetricStorage + readonly_fields = ('value', 'created_on', 'updated_on') + extra = 0 + + def has_add_permission(self, request, obj=None): + return False + + def get_fieldsets(self, request, obj=None): + if obj: + url = reverse('admin:orders_metricstorage_changelist') + url += '?order=%i' % obj.pk + title = _('Metric storage, last 10 entries, (See all)') + self.verbose_name_plural = mark_safe(title % url) + return super(MetricStorageInline, self).get_fieldsets(request, obj) + + def get_queryset(self, request): + qs = super(MetricStorageInline, self).get_queryset(request) + change_view = bool(self.parent_object and self.parent_object.pk) + if change_view: + qs = qs.order_by('-id') + parent_id = self.parent_object.pk + try: + tenth_id = qs.filter(order_id=parent_id).values_list('id', flat=True)[9] + except IndexError: + pass + else: + return qs.filter(pk__gte=tenth_id) + return qs + + +class OrderAdmin(AccountAdminMixin, ExtendedModelAdmin): + list_display = ( + 'display_description', 'service_link', 'account_link', 'content_object_link', + 'display_registered_on', 'display_billed_until', 'display_cancelled_on', + 'display_metric' + ) + list_filter = ( + ActiveOrderListFilter, IgnoreOrderListFilter, BilledOrderListFilter, 'account__type', + 'service', + ) + default_changelist_filters = ( + ('ignore', '0'), + ) + actions = ( + BillSelectedOrders(), mark_as_ignored, mark_as_not_ignored, report, list_accounts + ) + change_view_actions = (BillSelectedOrders(), mark_as_ignored, mark_as_not_ignored) + date_hierarchy = 'registered_on' + inlines = (MetricStorageInline,) + add_inlines = () + search_fields = ('account__username', 'content_object_repr', 'description',) + list_prefetch_related = ( + 'content_object', + Prefetch('metrics', queryset=MetricStorage.objects.order_by('-id')), + ) + list_select_related = ('account', 'service') + add_fieldsets = ( + (None, { + 'fields': ('account', 'service') + }), + (_("Object"), { + 'fields': ('content_type', 'object_id',), + }), + (_("State"), { + 'fields': ('registered_on', 'cancelled_on', 'billed_on', 'billed_metric', + 'billed_until' ) + }), + (None, { + 'fields': ('description', 'ignore',), + }), + ) + fieldsets = ( + (None, { + 'fields': ('account_link', 'service_link', 'content_object_link'), + }), + (_("State"), { + 'fields': ('registered_on', 'cancelled_on', 'billed_on', 'billed_metric', + 'billed_until' ) + }), + (None, { + 'fields': ('description', 'ignore', 'bills_links'), + }), + ) + readonly_fields = ( + 'content_object_repr', 'content_object_link', 'bills_links', 'account_link', + 'service_link' + ) + + service_link = admin_link('service') + display_registered_on = admin_date('registered_on') + display_cancelled_on = admin_date('cancelled_on') + + def display_description(self, order): + return format_html(order.description[:64]) + display_description.short_description = _("Description") + display_description.admin_order_field = 'description' + + def content_object_link(self, order): + if order.content_object: + try: + url = change_url(order.content_object) + except NoReverseMatch: + # Does not has admin + return order.content_object_repr + description = str(order.content_object) + return format_html('{description}', + url=url, description=description) + return order.content_object_repr + content_object_link.short_description = _("Content object") + content_object_link.admin_order_field = 'content_object_repr' + + @mark_safe + def bills_links(self, order): + bills = [] + make_link = admin_link() + for line in order.lines.select_related('bill').distinct('bill'): + bills.append(make_link(line.bill)) + return '
    '.join(bills) + bills_links.short_description = _("Bills") + + def display_billed_until(self, order): + billed_until = order.billed_until + red = False + human = escape(naturaldate(billed_until)) + if billed_until: + if order.cancelled_on and order.cancelled_on <= billed_until: + pass + elif order.service.billing_period == order.service.NEVER: + human = _("Forever") + elif order.service.payment_style == order.service.POSTPAY: + boundary = order.service.handler.get_billing_point(order) + if billed_until < boundary: + red = True + elif billed_until < timezone.now().date(): + red = True + color = mark_safe('style="color:red;"') if red else '' + return format_html( + '{human}', + raw=escape(str(billed_until)), color=color, human=human, + ) + display_billed_until.short_description = _("billed until") + display_billed_until.admin_order_field = 'billed_until' + + def display_metric(self, order): + """ + dispalys latest metric value, don't uses latest() because not loosing prefetch_related + """ + try: + metric = order.metrics.all()[0] + except IndexError: + return '' + return metric.value + display_metric.short_description = _("Metric") + + def formfield_for_dbfield(self, db_field, **kwargs): + """ Make value input widget bigger """ + if db_field.name == 'description': + kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 2}) + return super().formfield_for_dbfield(db_field, **kwargs) + +# def get_changelist(self, request, **kwargs): +# ChangeList = super(OrderAdmin, self).get_changelist(request, **kwargs) +# class OrderFilterChangeList(ChangeList): +# def get_filters(self, request): +# filters = super(OrderFilterChangeList, self).get_filters(request) +# tail = [] +# filters_copy = [] +# for list_filter in filters[0]: +# if getattr(list_filter, 'apply_last', False): +# tail.append(list_filter) +# else: +# filters_copy.append(list_filter) +# filters = ((filters_copy+tail),) + filters[1:] +# return filters +# return OrderFilterChangeList + + +class MetricStorageAdmin(admin.ModelAdmin): + list_display = ('order', 'value', 'created_on', 'updated_on') + list_filter = ('order__service',) + raw_id_fields = ('order',) + + +admin.site.register(Order, OrderAdmin) +admin.site.register(MetricStorage, MetricStorageAdmin) diff --git a/orchestra/contrib/orders/api.py b/orchestra/contrib/orders/api.py new file mode 100644 index 0000000..922632e --- /dev/null +++ b/orchestra/contrib/orders/api.py @@ -0,0 +1,15 @@ +from rest_framework import viewsets + +from orchestra.api import router +from orchestra.contrib.accounts.api import AccountApiMixin + +from .models import Order +from .serializers import OrderSerializer + + +class OrderViewSet(AccountApiMixin, viewsets.ModelViewSet): + queryset = Order.objects.all() + serializer_class = OrderSerializer + + +router.register(r'orders', OrderViewSet) diff --git a/orchestra/contrib/orders/apps.py b/orchestra/contrib/orders/apps.py new file mode 100644 index 0000000..1d52698 --- /dev/null +++ b/orchestra/contrib/orders/apps.py @@ -0,0 +1,13 @@ +from django.apps import AppConfig + +from orchestra.core import accounts + + +class OrdersConfig(AppConfig): + name = 'orchestra.contrib.orders' + verbose_name = 'Orders' + + def ready(self): + from .models import Order + accounts.register(Order, icon='basket.png', search=False) + from . import signals diff --git a/orchestra/contrib/orders/billing.py b/orchestra/contrib/orders/billing.py new file mode 100644 index 0000000..a6daee3 --- /dev/null +++ b/orchestra/contrib/orders/billing.py @@ -0,0 +1,96 @@ +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.bills.models import Invoice, Fee, ProForma + + +class BillsBackend(object): + def create_bills(self, account, lines, **options): + bill = None + ant_bill = None + bills = [] + 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 proforma: + if ant_bill is None: + if create_new: + bill = ProForma.objects.create(account=account) + else: + bill = ProForma.objects.filter(account=account, is_open=True).last() + if bill: + bill.updated() + else: + bill = ProForma.objects.create(account=account, is_open=True) + bills.append(bill) + else: + bill = ant_bill + ant_bill = bill + elif service.is_fee: + bill = Fee.objects.create(account=account) + bills.append(bill) + else: + if ant_bill is None: + if create_new: + bill = Invoice.objects.create(account=account) + else: + bill = Invoice.objects.filter(account=account, is_open=True).last() + if bill: + bill.updated() + else: + bill = Invoice.objects.create(account=account, is_open=True) + bills.append(bill) + else: + bill = ant_bill + ant_bill = bill + # Create bill line + 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 get_line_description(self, line): + service = line.order.service + description = line.order.description + return description + + def get_verbose_quantity(self, line): + metric = format(line.metric, '.2f').rstrip('0').rstrip('.') + metric = metric.strip('0').strip('.') + size = format(line.size, '.2f').rstrip('0').rstrip('.') + size = size.strip('0').strip('.') + if metric == '1': + return size + if size == '1': + return metric + return "%s×%s" % (metric, size) + + def create_sublines(self, line, discounts): + for discount in discounts: + line.sublines.create( + description=_("Discount per %s") % discount.type.lower(), + total=discount.total, + type=discount.type, + ) diff --git a/orchestra/contrib/orders/filters.py b/orchestra/contrib/orders/filters.py new file mode 100644 index 0000000..4693529 --- /dev/null +++ b/orchestra/contrib/orders/filters.py @@ -0,0 +1,131 @@ +from datetime import timedelta + +from django.apps import apps +from django.contrib.admin import SimpleListFilter +from django.db.models import Q, Prefetch, F +from django.utils import timezone +from django.utils.encoding import force_str +from django.utils.translation import gettext_lazy as _ + +from . import settings +from .models import MetricStorage + + +class ActiveOrderListFilter(SimpleListFilter): + """ Filter tickets by created_by according to request.user """ + title = _("is active") + parameter_name = 'is_active' + + def lookups(self, request, model_admin): + return ( + ('True', _("Active")), + ('False', _("Inactive")), + ) + + def queryset(self, request, queryset): + if self.value() == 'True': + return queryset.active() + elif self.value() == 'False': + return queryset.inactive() + return queryset + + +class BilledOrderListFilter(SimpleListFilter): + """ Filter tickets by created_by according to request.user """ + title = _("billed") + parameter_name = 'billed' +# apply_last = True + + def lookups(self, request, model_admin): + return ( + ('yes', _("Billed")), + ('no', _("Not billed")), + ('pending', _("Pending (re-evaluate metric)")), + ('not_pending', _("Not pending (re-evaluate metric)")), + ) + + def get_pending_metric_pks(self, queryset): + mindelta = timedelta(days=2) # TODO + metric_pks = [] + prefetch_valid_metrics = Prefetch('metrics', to_attr='valid_metrics', + queryset=MetricStorage.objects.filter(created_on__gt=F('order__billed_on'), + created_on__lte=(F('updated_on')-mindelta)).exclude(value=0) + ) + metric_queryset = queryset.exclude(service__metric='').exclude(billed_on__isnull=True) + for order in metric_queryset.prefetch_related(prefetch_valid_metrics): + for metric in order.valid_metrics: + if metric.created_on <= order.billed_on: + raise ValueError("This value should already be filtered on the prefetch query.") + if metric.value > order.billed_metric: + metric_pks.append(order.pk) + break + return metric_pks + + def filter_pending(self, queryset, reverse=False): + now = timezone.now() + Service = apps.get_model(settings.ORDERS_SERVICE_MODEL) + ignore_qs = Q() + for order in queryset.distinct('service_id').only('service'): + service = order.service + delta = service.handler.get_ignore_delta() + if delta is not None: + ignore_qs = ignore_qs | Q(service_id=service.id, registered_on__gt=now-delta) + ignore_qs = queryset.exclude(ignore_qs) + pending_qs = Q( + Q(pk__in=self.get_pending_metric_pks(ignore_qs)) | + Q(billed_until__isnull=True) | Q(~Q(service__billing_period=Service.NEVER) & + Q(billed_until__lte=now)) + ) + if reverse: + return queryset.exclude(pending_qs) + else: + return ignore_qs.filter(pending_qs) + + def queryset(self, request, queryset): + now = timezone.now() + if self.value() == 'yes': + return queryset.filter(billed_until__isnull=False, billed_until__gte=now) + elif self.value() == 'no': + return queryset.exclude(billed_until__isnull=False, billed_until__gte=now) + elif self.value() == 'pending': + return self.filter_pending(queryset) + elif self.value() == 'not_pending': + return self.filter_pending(queryset, reverse=True) + return queryset + + +class IgnoreOrderListFilter(SimpleListFilter): + """ Filter Nodes by group according to request.user """ + title = _("Ignore") + parameter_name = 'ignore' + + def lookups(self, request, model_admin): + return ( + ('0', _("Not ignored")), + ('1', _("Ignored")), + ('2', _("All")), + + ) + + def queryset(self, request, queryset): + if self.value() == '0': + return queryset.filter(ignore=False) + elif self.value() == '1': + return queryset.filter(ignore=True) + return queryset + + def choices(self, cl): + """ Enable default selection different than All """ + for lookup, title in self.lookup_choices: + title = title._proxy____args[0] + selected = self.value() == force_str(lookup) + if not selected and title == "Not ignored" and self.value() is None: + selected = True + # end of workaround + yield { + 'selected': selected, + 'query_string': cl.get_query_string({ + self.parameter_name: lookup, + }, []), + 'display': title, + } diff --git a/orchestra/contrib/orders/forms.py b/orchestra/contrib/orders/forms.py new file mode 100644 index 0000000..0dd9a0a --- /dev/null +++ b/orchestra/contrib/orders/forms.py @@ -0,0 +1,70 @@ +from django import forms +from django.contrib.admin import widgets +from django.utils import timezone +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ + +from orchestra.admin.forms import AdminFormMixin +from orchestra.admin.utils import change_url + +from .models import Order + + +class BillSelectedOptionsForm(AdminFormMixin, forms.Form): + billing_point = forms.DateField(initial=timezone.now, + label=_("Billing point"), widget=widgets.AdminDateWidget, + help_text=_("Date you want to bill selected orders")) + fixed_point = forms.BooleanField(initial=False, required=False, + label=_("Fixed point"), + help_text=_("Deisgnates whether you want the billing point to be an " + "exact date, or adapt it to the billing period.")) + proforma = forms.BooleanField(initial=False, required=False, + label=_("Pro-forma (billing simulation)"), + help_text=_("Creates a Pro Forma instead of billing the orders.")) + new_open = forms.BooleanField(initial=False, required=False, + label=_("Create a new open bill"), + help_text=_("Deisgnates whether you want to put this orders on a new " + "open bill, or allow to reuse an existing one.")) + + +def selected_related_choices(queryset): + for order in queryset: + verbose = '{description} ' + verbose += '' + if order.ignore: + verbose += ' (ignored)' + verbose = verbose.format( + order_url=change_url(order), description=order.description, + account_url=change_url(order.account), account=str(order.account) + ) + yield (order.pk, mark_safe(verbose)) + + +class BillSelectRelatedForm(AdminFormMixin, forms.Form): + # This doesn't work well with reordering after billing +# pricing_with_all = forms.BooleanField(label=_("Do pricing with all orders"), +# initial=False, required=False, help_text=_("The price may vary " +# "depending on the billed orders. This options designates whether " +# "all existing orders will be used for price computation or not.")) + select_all = forms.BooleanField(label=_("Select all"), required=False) + selected_related = forms.ModelMultipleChoiceField(label=_("Related orders"), + queryset=Order.objects.none(), widget=forms.CheckboxSelectMultiple, + required=False) + billing_point = forms.DateField(widget=forms.HiddenInput()) + fixed_point = forms.BooleanField(widget=forms.HiddenInput(), required=False) + proforma = forms.BooleanField(widget=forms.HiddenInput(), required=False) + new_open = forms.BooleanField(widget=forms.HiddenInput(), required=False) + + def __init__(self, *args, **kwargs): + super(BillSelectRelatedForm, self).__init__(*args, **kwargs) + queryset = kwargs['initial'].get('related_queryset', None) + if queryset: + self.fields['selected_related'].queryset = queryset + self.fields['selected_related'].choices = selected_related_choices(queryset) + + +class BillSelectConfirmationForm(AdminFormMixin, forms.Form): + billing_point = forms.DateField(widget=forms.HiddenInput()) + fixed_point = forms.BooleanField(widget=forms.HiddenInput(), required=False) + proforma = forms.BooleanField(widget=forms.HiddenInput(), required=False) + new_open = forms.BooleanField(widget=forms.HiddenInput(), required=False) diff --git a/orchestra/contrib/orders/helpers.py b/orchestra/contrib/orders/helpers.py new file mode 100644 index 0000000..ce0d191 --- /dev/null +++ b/orchestra/contrib/orders/helpers.py @@ -0,0 +1,39 @@ +from django.core.exceptions import ObjectDoesNotExist + +from orchestra.core import services + + +def get_related_object(origin, max_depth=2): + """ + Introspects origin object and return the first related service object + + WARNING this is NOT an exhaustive search but a compromise between cost and + flexibility. A more comprehensive approach may be considered if + a use-case calls for it. + """ + def related_iterator(node): + for field in node._meta.private_fields: + if hasattr(field, 'ct_field'): + yield getattr(node, field.name) + for field in node._meta.fields: + if field.remote_field: + try: + yield getattr(node, field.name) + except ObjectDoesNotExist: + pass + + # BFS model relation transversal + queue = [[origin]] + while queue: + models = queue.pop(0) + if len(models) > max_depth: + return None + node = models[-1] + if len(models) > 1: + if type(node) in services: + return node + for related in related_iterator(node): + if related and related not in models: + new_models = list(models) + new_models.append(related) + queue.append(new_models) diff --git a/orchestra/contrib/orders/models.py b/orchestra/contrib/orders/models.py new file mode 100644 index 0000000..db6febc --- /dev/null +++ b/orchestra/contrib/orders/models.py @@ -0,0 +1,310 @@ +import datetime +import decimal +import logging + +from django.db import models +from django.db.models import F, Q, Sum +from django.apps import apps +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from orchestra.models import queryset +from orchestra.utils.python import import_class + +from . import settings + + +logger = logging.getLogger(__name__) + + +class OrderQuerySet(models.QuerySet): + group_by = queryset.group_by + + def bill(self, **options): + bills = [] + bill_backend = Order.get_bill_backend() + qs = self.select_related('account', 'service') + commit = options.get('commit', True) + for account, services in qs.group_by('account', 'service').items(): + bill_lines = [] + for service, orders in services.items(): + for order in orders: + # Saved for undoing support + order.old_billed_on = order.billed_on + order.old_billed_until = order.billed_until + lines = service.handler.generate_bill_lines(orders, account, **options) + bill_lines.extend(lines) + # TODO make this consistent always returning the same fucking types + if commit: + 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): + return self.cancelled_and_billed().filter(billed_until__gt=ini, registered_on__lt=end) + + def cancelled_and_billed(self, exclude=False): + qs = dict(cancelled_on__isnull=False, billed_until__isnull=False, + cancelled_on__lte=F('billed_until')) + if exclude: + return self.exclude(**qs) + return self.filter(**qs) + + def get_related(self, **options): + """ returns related orders that could have a pricing effect """ + Service = apps.get_model(settings.ORDERS_SERVICE_MODEL) + conflictive = self.filter(service__metric='') + conflictive = conflictive.exclude(service__billing_period=Service.NEVER) + # Exclude rates null or all rates with quantity 0 + conflictive = conflictive.annotate(quantity_sum=Sum('service__rates__quantity')) + conflictive = conflictive.exclude(quantity_sum=0).select_related('service').distinct() + qs = Q() + for account_id, services in conflictive.group_by('account_id', 'service').items(): + for service, orders in services.items(): + ini = datetime.date.max + end = datetime.date.min + bp = None + for order in orders: + bp = service.handler.get_billing_point(order, **options) + end = max(end, bp) + ini = min(ini, order.billed_until or order.registered_on) + qs |= Q( + Q(service=service, account=account_id, registered_on__lt=end) & Q( + Q(billed_until__isnull=True) | Q(billed_until__lt=end) + ) & Q( + Q(cancelled_on__isnull=True) | Q(cancelled_on__gt=ini) + ) + ) + if not qs: + return self.model.objects.none() + ids = self.values_list('id', flat=True) + return self.model.objects.filter(qs).exclude(id__in=ids) + + def pricing_orders(self, ini, end): + return self.filter(billed_until__isnull=False, billed_until__gt=ini, + registered_on__lt=end) + + def by_object(self, obj, **kwargs): + ct = ContentType.objects.get_for_model(obj) + return self.filter(object_id=obj.pk, content_type=ct, **kwargs) + + def active(self, **kwargs): + """ return active orders """ + return self.filter( + Q(cancelled_on__isnull=True) | Q(cancelled_on__gt=timezone.now()) + ).filter(**kwargs) + + def inactive(self, **kwargs): + """ return inactive orders """ + return self.filter(cancelled_on__lte=timezone.now(), **kwargs) + + def update_by_instance(self, instance, service=None, commit=True): + updates = [] + if service is None: + Service = apps.get_model(settings.ORDERS_SERVICE_MODEL) + services = Service.objects.filter_by_instance(instance) + else: + services = [service] + for service in services: + orders = Order.objects.by_object(instance, service=service) + orders = orders.select_related('service').active() + if service.handler.matches(instance): + if not orders: + account_id = getattr(instance, 'account_id', instance.pk) + if account_id is None: + # New account workaround -> user.account_id == None + continue + ignore = service.handler.get_ignore(instance) + order = self.model( + content_object=instance, + content_object_repr=str(instance), + service=service, + account_id=account_id, + ignore=ignore) + if commit: + order.save() + updates.append((order, 'created')) + logger.info("CREATED new order id: {id}".format(id=order.id)) + else: + if len(orders) > 1: + raise ValueError("A single active order was expected.") + order = orders[0] + updates.append((order, 'updated')) + if commit: + order.update() + elif orders: + if len(orders) > 1: + raise ValueError("A single active order was expected.") + order = orders[0] + order.cancel(commit=commit) + logger.info("CANCELLED order id: {id}".format(id=order.id)) + updates.append((order, 'cancelled')) + return updates + + +class Order(models.Model): + account = models.ForeignKey('accounts.Account', on_delete=models.CASCADE, + verbose_name=_("account"), related_name='orders') + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField(null=True) + service = models.ForeignKey(settings.ORDERS_SERVICE_MODEL, on_delete=models.PROTECT, + verbose_name=_("service"), related_name='orders') + registered_on = models.DateField(_("registered"), default=timezone.now, db_index=True) + cancelled_on = models.DateField(_("cancelled"), null=True, blank=True) + billed_on = models.DateField(_("billed"), null=True, blank=True) + billed_metric = models.DecimalField(_("billed metric"), max_digits=16, decimal_places=2, + null=True, blank=True) + billed_until = models.DateField(_("billed until"), null=True, blank=True) + ignore = models.BooleanField(_("ignore"), default=False) + description = models.TextField(_("description"), blank=True) + content_object_repr = models.CharField(_("content object representation"), max_length=256, + editable=False, help_text=_("Used for searches.")) + + content_object = GenericForeignKey() + objects = OrderQuerySet.as_manager() + + class Meta: + get_latest_by = 'id' + index_together = ( + ('content_type', 'object_id'), + ) + + def __str__(self): + return str(self.service) + + @classmethod + def get_bill_backend(cls): + return import_class(settings.ORDERS_BILLING_BACKEND)() + + def clean(self): + if self.billed_on and self.billed_on < self.registered_on: + raise ValidationError(_("Billed date can not be earlier than registered on.")) + if self.billed_until and not self.billed_on: + raise ValidationError(_("Billed on is missing while billed until is being provided.")) + + def update(self): + instance = self.content_object + if instance is None: + return + handler = self.service.handler + metric = '' + if handler.metric: + metric = handler.get_metric(instance) + if metric is not None: + MetricStorage.objects.store(self, metric) + metric = ', metric:{}'.format(metric) + description = handler.get_order_description(instance) + logger.info("UPDATED order id:{id}, description:{description}{metric}".format( + id=self.id, description=description, metric=metric).encode('ascii', 'replace') + ) + update_fields = [] + if self.description != description: + self.description = description + update_fields.append('description') + content_object_repr = str(instance) + if self.content_object_repr != content_object_repr: + self.content_object_repr = content_object_repr + update_fields.append('content_object_repr') + if update_fields: + self.save(update_fields=update_fields) + + def cancel(self, commit=True): + self.cancelled_on = timezone.now() + self.ignore = self.service.handler.get_order_ignore(self) + if commit: + self.save(update_fields=['cancelled_on', 'ignore']) + logger.info("CANCELLED order id: {id}".format(id=self.id)) + + def mark_as_ignored(self): + self.ignore = True + self.save(update_fields=['ignore']) + + def mark_as_not_ignored(self): + self.ignore = False + self.save(update_fields=['ignore']) + + def get_metric(self, *args, **kwargs): + if kwargs.pop('changes', False): + ini, end = args + result = [] + prev = None + for metric in self.metrics.filter(created_on__lt=end).order_by('id'): + created = metric.created_on + if created > ini: + if prev is None: + raise ValueError("Metric storage information for order %i is inconsistent." % self.id) + cini = prev.created_on + if not result: + cini = ini + result.append((cini, created, prev.value)) + prev = metric + if created < end: + result.append((created, end, metric.value)) + return result + if kwargs: + raise AttributeError + if len(args) == 2: + # Slot + ini, end = args + metrics = self.metrics.filter(created_on__lt=end, updated_on__gte=ini) + elif len(args) == 1: + # On effect on date + date = args[0] + date = datetime.date(year=date.year, month=date.month, day=date.day) + date += datetime.timedelta(days=1) + metrics = self.metrics.filter(created_on__lte=date) + elif not args: + return self.metrics.latest('updated_on').value + else: + raise AttributeError + try: + return metrics.latest('updated_on').value + except MetricStorage.DoesNotExist: + return decimal.Decimal(0) + + +class MetricStorageQuerySet(models.QuerySet): + def store(self, order, value): + now = timezone.now() + try: + last = self.filter(order=order).latest() + except self.model.DoesNotExist: + self.create(order=order, value=value, updated_on=now) + else: + # Metric storage has per-day granularity (last value of the day is what counts) + if last.created_on == now.date(): + last.value = value + last.updated_on = now + last.save() + else: + error = decimal.Decimal(str(settings.ORDERS_METRIC_ERROR)) + if (value > last.value+error or value < last.value-error) or (value == 0 and last.value > 0): + self.create(order=order, value=value, updated_on=now) + else: + last.updated_on = now + last.save(update_fields=['updated_on']) + + +class MetricStorage(models.Model): + """ Stores metric state for future billing """ + order = models.ForeignKey(Order, on_delete=models.CASCADE, + verbose_name=_("order"), related_name='metrics') + value = models.DecimalField(_("value"), max_digits=16, decimal_places=2) + created_on = models.DateField(_("created"), auto_now_add=True, editable=True) + # TODO time field? + updated_on = models.DateTimeField(_("updated")) + + objects = MetricStorageQuerySet.as_manager() + + class Meta: + get_latest_by = 'id' + + def __str__(self): + return str(self.order) diff --git a/orchestra/contrib/orders/serializers.py b/orchestra/contrib/orders/serializers.py new file mode 100644 index 0000000..ea30240 --- /dev/null +++ b/orchestra/contrib/orders/serializers.py @@ -0,0 +1,14 @@ +from rest_framework import serializers + +from orchestra.contrib.accounts.serializers import AccountSerializerMixin + +from .models import Order + + +class OrderSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer): + class Meta: + model = Order + fields = ( + 'url', 'id', 'registered_on', 'cancelled_on', 'billed_on', 'billed_until', + 'description' + ) diff --git a/orchestra/contrib/orders/settings.py b/orchestra/contrib/orders/settings.py new file mode 100644 index 0000000..2dbed0b --- /dev/null +++ b/orchestra/contrib/orders/settings.py @@ -0,0 +1,46 @@ +from orchestra.contrib.settings import Setting + + +ORDERS_BILLING_BACKEND = Setting('ORDERS_BILLING_BACKEND', + 'orchestra.contrib.orders.billing.BillsBackend', + validators=[Setting.validate_import_class], + help_text="Pluggable backend for bill generation.", +) + + +ORDERS_SERVICE_MODEL = Setting('ORDERS_SERVICE_MODEL', + 'services.Service', + validators=[Setting.validate_model_label], + help_text="Pluggable service class.", +) + + +ORDERS_EXCLUDED_APPS = Setting('ORDERS_EXCLUDED_APPS', + ( + 'orders', + 'admin', + 'contenttypes', + 'auth', + 'migrations', + 'sessions', + 'orchestration', + 'bills', + 'services', + 'mailer', + 'issues', + ), + help_text="Prevent inspecting these apps for service accounting." +) + + +ORDERS_METRIC_ERROR = Setting('ORDERS_METRIC_ERROR', + 0.05, + help_text=("Only account for significative changes.
    " + "metric_storage new value: lastvalue*(1+threshold) > currentvalue or lastvalue*threshold < currentvalue."), +) + + +ORDERS_BILLED_METRIC_CLEANUP_DAYS = Setting('ORDERS_BILLED_METRIC_CLEANUP_DAYS', + 40, + help_text=("Number of days after a billed stored metric is deleted."), +) diff --git a/orchestra/contrib/orders/signals.py b/orchestra/contrib/orders/signals.py new file mode 100644 index 0000000..1778a3d --- /dev/null +++ b/orchestra/contrib/orders/signals.py @@ -0,0 +1,39 @@ +from django.db.models.signals import post_delete, post_save +from django.dispatch import receiver + +from orchestra.core import services + +from . import helpers, settings +from .models import Order + + +# TODO perhas use cache = caches.get_request_cache() to cache an account delete and don't processes get_related_objects() if the case +# FIXME https://code.djangoproject.com/ticket/24576 +# TODO build a cache hash table {model: related, model: None} +@receiver(post_delete, dispatch_uid="orders.cancel_orders") +def cancel_orders(sender, **kwargs): + if sender._meta.app_label not in settings.ORDERS_EXCLUDED_APPS: + instance = kwargs['instance'] + # Account delete will delete all related orders, no need to maintain order consistency + if isinstance(instance, Order.account.field.related_model): + return + if type(instance) in services: + for order in Order.objects.by_object(instance).active(): + order.cancel() + elif not hasattr(instance, 'account'): + # FIXME Indeterminate behaviour + related = helpers.get_related_object(instance) + if related and related != instance: + type(related).objects.get(pk=related.pk) + + +@receiver(post_save, dispatch_uid="orders.update_orders") +def update_orders(sender, **kwargs): + if sender._meta.app_label not in settings.ORDERS_EXCLUDED_APPS: + instance = kwargs['instance'] + if type(instance) in services: + Order.objects.update_by_instance(instance) + elif not hasattr(instance, 'account'): + related = helpers.get_related_object(instance) + if related and related != instance: + Order.objects.update_by_instance(related) diff --git a/orchestra/contrib/orders/tasks.py b/orchestra/contrib/orders/tasks.py new file mode 100644 index 0000000..6089e65 --- /dev/null +++ b/orchestra/contrib/orders/tasks.py @@ -0,0 +1,50 @@ +import datetime + +from celery.task.schedules import crontab +from django.apps import apps + +from orchestra.contrib.tasks import periodic_task + +from . import settings + + +@periodic_task(run_every=crontab(hour=4, minute=30), name='orders.cleanup_metrics') +def cleanup_metrics(): + from .models import MetricStorage, Order + Service = apps.get_model(settings.ORDERS_SERVICE_MODEL) + + # General cleaning: order.billed_on-delta + general = 0 + delta = datetime.timedelta(days=settings.ORDERS_BILLED_METRIC_CLEANUP_DAYS) + for order in Order.objects.filter(billed_on__isnull=False): + epoch = order.billed_on-delta + try: + latest = order.metrics.filter(updated_on__lt=epoch).latest('updated_on') + except MetricStorage.DoesNotExist: + pass + else: + general += order.metrics.exclude(pk=latest.pk).filter(updated_on__lt=epoch).count() + order.metrics.exclude(pk=latest.pk).filter(updated_on__lt=epoch).only('id').delete() + + # Reduce monthly metrics to latest + monthly = 0 + monthly_services = Service.objects.exclude(metric='').filter( + billing_period=Service.MONTHLY, pricing_period=Service.BILLING_PERIOD + ) + for service in monthly_services: + for order in Order.objects.filter(service=service): + dates = order.metrics.values_list('created_on', flat=True) + months = set((date.year, date.month) for date in dates) + for year, month in months: + metrics = order.metrics.filter( + created_on__year=year, created_on__month=month, + updated_on__year=year, updated_on__month=month) + try: + latest = metrics.latest('updated_on') + except MetricStorage.DoesNotExist: + pass + else: + monthly += metrics.exclude(pk=latest.pk).count() + metrics.exclude(pk=latest.pk).only('id').delete() + + return (general, monthly) diff --git a/orchestra/contrib/orders/templates/admin/orders/order/bill_selected_options.html b/orchestra/contrib/orders/templates/admin/orders/order/bill_selected_options.html new file mode 100644 index 0000000..6dd023f --- /dev/null +++ b/orchestra/contrib/orders/templates/admin/orders/order/bill_selected_options.html @@ -0,0 +1,98 @@ +{% extends "admin/base_site.html" %} +{% load i18n l10n static admin_urls utils orders %} + +{% block extrastyle %} +{{ block.super }} + + +{% endblock %} + +{% block extrahead %} +{{ block.super }} + + + +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + + +{% block content %} +
    {% csrf_token %} +
    +
    + {% if bills %} + {% for account, total, lines in bills %} +
    + +
    + {% endfor %} + {{ form.as_table }} + {% else %} + {{ form.as_admin }} + {% endif %} +
    + {% for obj in queryset %} + + {% endfor %} + + + +
    +
    +{% endblock %} diff --git a/orchestra/contrib/orders/templates/admin/orders/order/report.html b/orchestra/contrib/orders/templates/admin/orders/order/report.html new file mode 100644 index 0000000..87ef5f0 --- /dev/null +++ b/orchestra/contrib/orders/templates/admin/orders/order/report.html @@ -0,0 +1,62 @@ +{% load i18n utils %} + + + + Transaction Report + + + + + + + + + + + + + +{% for service, info in services %} + + + + + + + +{% endfor %} + + + + + + +
    {% trans "Services" %}{% trans "Active" %}{% trans "Cancelled" %}{% trans "Nominal price" %}{% trans "Number" %}
    {{ service }}{{ info.0 }}{{ info.1 }}{{ info.2 }}{{ info.3 }}
    {% trans "TOTAL" %}{{ totals.0 }}{{ totals.1 }}{{ totals.2 }}
    + + + diff --git a/orchestra/contrib/orders/templatetags/orders.py b/orchestra/contrib/orders/templatetags/orders.py new file mode 100644 index 0000000..8b7bf85 --- /dev/null +++ b/orchestra/contrib/orders/templatetags/orders.py @@ -0,0 +1,19 @@ +import datetime + +from django import template +from django.template.defaultfilters import date + + +register = template.Library() + + +@register.filter +def periodformat(line): + if line.ini == line.end: + return date(line.ini) + if line.ini.day == 1 and line.end.day == 1: + end = line.end - datetime.timedelta(days=1) + if line.ini.month == end.month: + return date(line.ini, "N Y") + return '%s to %s' % (date(line.ini, "N Y"), date(end, "N Y")) + return '%s to %s' % (date(line.ini), date(line.end)) diff --git a/orchestra/contrib/orders/tests/__init__.py b/orchestra/contrib/orders/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/orchestra/contrib/orders/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/orchestra/contrib/payments/__init__.py b/orchestra/contrib/payments/__init__.py new file mode 100644 index 0000000..970bd43 --- /dev/null +++ b/orchestra/contrib/payments/__init__.py @@ -0,0 +1 @@ +default_app_config = 'orchestra.contrib.payments.apps.PaymentsConfig' diff --git a/orchestra/contrib/payments/actions.py b/orchestra/contrib/payments/actions.py new file mode 100644 index 0000000..ae53155 --- /dev/null +++ b/orchestra/contrib/payments/actions.py @@ -0,0 +1,220 @@ +from functools import partial + +from django.contrib import messages +from django.contrib.admin import actions +from django.urls import reverse +from django.db import transaction +from django.shortcuts import render, redirect +from django.utils.safestring import mark_safe +from django.utils.text import capfirst +from django.utils.translation import ngettext, gettext_lazy as _ + +from orchestra.admin.decorators import action_with_confirmation +from orchestra.admin.utils import change_url + +from . import helpers +from .methods import PaymentMethod +from .models import Transaction + + +@transaction.atomic +def process_transactions(modeladmin, request, queryset): + processes = [] + if queryset.exclude(state=Transaction.WAITTING_PROCESSING).exists(): + messages.error(request, + _("Selected transactions must be on '{state}' state").format( + state=Transaction.WAITTING_PROCESSING) + ) + return + for method, transactions in queryset.group_by('source__method').items(): + if method is not None: + method = PaymentMethod.get(method) + procs = method.process(transactions) + processes += procs + for transaction in transactions: + modeladmin.log_change(request, transaction, _("Processed")) + if not processes: + return + opts = modeladmin.model._meta + num = len(queryset) + context = { + 'title': ngettext( + _("One selected transaction has been processed."), + _("%s Selected transactions have been processed.") % num, + num), + 'content_message': ngettext( + _("The following transaction process has been generated, " + "you may want to save it on your computer now."), + _("The following %s transaction processes have been generated, " + "you may want to save it on your computer now.") % len(processes), + len(processes)), + 'action_name': _("Process"), + 'processes': processes, + 'opts': opts, + 'app_label': opts.app_label, + } + return render(request, 'admin/payments/transaction/get_processes.html', context) + + +@transaction.atomic +@action_with_confirmation() +def mark_as_executed(modeladmin, request, queryset): + for transaction in queryset: + transaction.mark_as_executed() + modeladmin.log_change(request, transaction, _("Executed")) + num = len(queryset) + msg = ngettext( + _("One selected transaction has been marked as executed."), + _("%s selected transactions have been marked as executed.") % num, + num) + modeladmin.message_user(request, msg) +mark_as_executed.url_name = 'execute' +mark_as_executed.short_description = _("Mark as executed") + + +@transaction.atomic +@action_with_confirmation() +def mark_as_secured(modeladmin, request, queryset): + for transaction in queryset: + transaction.mark_as_secured() + modeladmin.log_change(request, transaction, _("Secured")) + num = len(queryset) + msg = ngettext( + _("One selected transaction has been marked as secured."), + _("%s selected transactions have been marked as secured.") % num, + num) + modeladmin.message_user(request, msg) +mark_as_secured.url_name = 'secure' +mark_as_secured.short_description = _("Mark as secured") + + +@transaction.atomic +@action_with_confirmation() +def mark_as_rejected(modeladmin, request, queryset): + for transaction in queryset: + transaction.mark_as_rejected() + modeladmin.log_change(request, transaction, _("Rejected")) + num = len(queryset) + msg = ngettext( + _("One selected transaction has been marked as rejected."), + _("%s selected transactions have been marked as rejected.") % num, + num) + modeladmin.message_user(request, msg) +mark_as_rejected.url_name = 'reject' +mark_as_rejected.short_description = _("Mark as rejected") + + +def _format_display_objects(modeladmin, request, queryset, related): + objects = [] + opts = modeladmin.model._meta + for obj in queryset: + objects.append( + mark_safe('{0}: {2}'.format( + capfirst(opts.verbose_name), change_url(obj), obj)) + ) + subobjects = [] + attr, verb = related + for trans in getattr(obj.transactions, attr)(): + subobjects.append( + mark_safe('Transaction: {} will be marked as {}'.format( + change_url(trans), trans, verb)) + ) + objects.append(subobjects) + return {'display_objects': objects} + +_format_executed = partial(_format_display_objects, related=('all', 'executed')) +_format_abort = partial(_format_display_objects, related=('processing', 'aborted')) +_format_commit = partial(_format_display_objects, related=('all', 'secured')) + + +@transaction.atomic +@action_with_confirmation(extra_context=_format_executed) +def mark_process_as_executed(modeladmin, request, queryset): + for process in queryset: + process.mark_as_executed() + modeladmin.log_change(request, process, _("Executed")) + num = len(queryset) + msg = ngettext( + _("One selected process has been marked as executed."), + _("%s selected processes have been marked as executed.") % num, + num) + modeladmin.message_user(request, msg) +mark_process_as_executed.url_name = 'executed' +mark_process_as_executed.short_description = _("Mark as executed") + + +@transaction.atomic +@action_with_confirmation(extra_context=_format_abort) +def abort(modeladmin, request, queryset): + for process in queryset: + process.abort() + modeladmin.log_change(request, process, _("Aborted")) + num = len(queryset) + msg = ngettext( + _("One selected process has been aborted."), + _("%s selected processes have been aborted.") % num, + num) + modeladmin.message_user(request, msg) +abort.url_name = 'abort' +abort.short_description = _("Abort") + + +@transaction.atomic +@action_with_confirmation(extra_context=_format_commit) +def commit(modeladmin, request, queryset): + for transaction in queryset: + transaction.mark_as_rejected() + modeladmin.log_change(request, transaction, _("Rejected")) + num = len(queryset) + msg = ngettext( + _("One selected transaction has been marked as rejected."), + _("%s selected transactions have been marked as rejected.") % num, + num) + modeladmin.message_user(request, msg) +commit.url_name = 'commit' +commit.short_description = _("Commit") + + +def report(modeladmin, request, queryset): + if queryset.model == Transaction: + transactions = queryset + else: + transactions = queryset.values_list('transactions__id', flat=True).distinct() + transactions = Transaction.objects.filter(id__in=transactions) + states = {} + total = 0 + transactions = transactions.order_by('bill__number') + for transaction in transactions: + state = transaction.get_state_display() + try: + states[state] += transaction.amount + except KeyError: + states[state] = transaction.amount + total += transaction.amount + context = { + 'states': states, + 'total': total, + 'transactions': transactions, + } + return render(request, 'admin/payments/transaction/report.html', context) + + +def reissue(modeladmin, request, queryset): + if len(queryset) != 1: + messages.error(request, _("One transaction should be selected.")) + return + trans = queryset[0] + if trans.state != trans.REJECTED: + messages.error(request, + _("Only rejected transactions can be reissued, " + "please reject current transaction if necessary.")) + return + url = reverse('admin:payments_transaction_add') + url += '?account=%i&bill=%i&source=%s&amount=%s¤cy=%s' % ( + trans.bill.account_id, + trans.bill_id, + trans.source_id or '', + trans.amount, + trans.currency, + ) + return redirect(url) diff --git a/orchestra/contrib/payments/admin.py b/orchestra/contrib/payments/admin.py new file mode 100644 index 0000000..e134c2e --- /dev/null +++ b/orchestra/contrib/payments/admin.py @@ -0,0 +1,245 @@ +from django.contrib import admin +from django.urls import reverse +from django.http import HttpResponseRedirect +from django.utils.html import format_html +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ + +from orchestra.admin import ChangeViewActionsMixin, ExtendedModelAdmin +from orchestra.admin.utils import admin_colored, admin_link, admin_date +from orchestra.contrib.accounts.actions import list_accounts +from orchestra.contrib.accounts.admin import AccountAdminMixin, SelectAccountAdminMixin +from orchestra.plugins.admin import SelectPluginAdminMixin + +from . import actions, helpers +from .methods import PaymentMethod +from .models import PaymentSource, Transaction, TransactionProcess + + +STATE_COLORS = { + Transaction.WAITTING_PROCESSING: 'darkorange', + Transaction.WAITTING_EXECUTION: 'magenta', + Transaction.EXECUTED: 'olive', + Transaction.SECURED: 'green', + Transaction.REJECTED: 'red', +} + +PROCESS_STATE_COLORS = { + TransactionProcess.CREATED: 'blue', + TransactionProcess.EXECUTED: 'olive', + TransactionProcess.ABORTED: 'red', + TransactionProcess.COMMITED: 'green', +} + + +class PaymentSourceAdmin(SelectPluginAdminMixin, AccountAdminMixin, ExtendedModelAdmin): + list_display = ('label', 'method', 'number', 'account_link', 'is_active') + list_filter = ('method', 'is_active') + change_readonly_fields = ('method',) + search_fields = ('account__username', 'account__full_name', 'data') + plugin = PaymentMethod + plugin_field = 'method' + + +class TransactionInline(admin.TabularInline): + model = Transaction + can_delete = False + extra = 0 + fields = ( + 'transaction_link', 'bill_link', 'source_link', 'display_state', + 'amount', 'currency' + ) + readonly_fields = fields + + transaction_link = admin_link('__str__', short_description=_("ID")) + bill_link = admin_link('bill') + source_link = admin_link('source') + display_state = admin_colored('state', colors=STATE_COLORS) + + class Media: + css = { + 'all': ('orchestra/css/hide-inline-id.css',) + } + + def has_add_permission(self, *args, **kwargs): + return False + + def get_queryset(self, *args, **kwargs): + qs = super().get_queryset(*args, **kwargs) + return qs.select_related('source', 'bill') + + +class TransactionAdmin(SelectAccountAdminMixin, ExtendedModelAdmin): + list_display = ( + 'id', 'bill_link', 'account_link', 'source_link', 'display_created_at', + 'display_modified_at', 'display_state', 'amount', 'process_link' + ) + list_filter = ('source__method', 'state') + fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ( + 'account_link', + 'bill_link', + 'source_link', + 'display_state', + 'amount', + 'currency', + 'process_link' + ) + }), + (_("Dates"), { + 'classes': ('wide',), + 'fields': ('display_created_at', 'display_modified_at'), + }), + ) + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ( + 'bill', + 'source', + 'display_state', + 'amount', + 'currency', + ) + }), + ) + change_view_actions = ( + actions.process_transactions, actions.mark_as_executed, actions.mark_as_secured, + actions.mark_as_rejected, actions.reissue + ) + search_fields = ('bill__number', 'bill__account__username', 'id') + actions = change_view_actions + (actions.report, list_accounts,) + filter_by_account_fields = ('bill', 'source') + readonly_fields = ( + 'bill_link', 'display_state', 'process_link', 'account_link', 'source_link', + 'display_created_at', 'display_modified_at' + ) + list_select_related = ('source', 'bill__account', 'process') + date_hierarchy = 'created_at' + + bill_link = admin_link('bill') + source_link = admin_link('source') + process_link = admin_link('process', short_description=_("proc")) + account_link = admin_link('bill__account') + display_created_at = admin_date('created_at', short_description=_("Created")) + display_modified_at = admin_date('modified_at', short_description=_("Modified")) + + def has_delete_permission(self, *args, **kwargs): + return False + + def get_actions(self, request): + actions = super().get_actions(request) + if 'delete_selected' in actions: + del actions['delete_selected'] + return actions + + def get_change_readonly_fields(self, request, obj): + if obj.state in (Transaction.WAITTING_PROCESSING, Transaction.WAITTING_EXECUTION): + return () + return ('amount', 'currency') + + def get_change_view_actions(self, obj=None): + actions = super(TransactionAdmin, self).get_change_view_actions() + exclude = [] + if obj: + if obj.state == Transaction.WAITTING_PROCESSING: + exclude = ['mark_as_executed', 'mark_as_secured', 'reissue'] + elif obj.state == Transaction.WAITTING_EXECUTION: + exclude = ['process_transactions', 'mark_as_secured', 'reissue'] + if obj.state == Transaction.EXECUTED: + exclude = ['process_transactions', 'mark_as_executed', 'reissue'] + elif obj.state == Transaction.REJECTED: + exclude = ['process_transactions', 'mark_as_executed', 'mark_as_secured', 'mark_as_rejected'] + elif obj.state == Transaction.SECURED: + return [] + return [action for action in actions if action.__name__ not in exclude] + + @mark_safe + def display_state(self, obj): + state = admin_colored('state', colors=STATE_COLORS)(obj) + help_text = obj.get_state_help() + state = state.replace('{}', process.file.url, process.file.name) + file_url.admin_order_field = 'file' + + @mark_safe + def display_transactions(self, process): + ids = [] + lines = [] + counter = 0 + for trans in process.transactions.all(): + color = STATE_COLORS.get(trans.state, 'black') + state = trans.get_state_display() + ids.append('%i' % (color, state, trans.id)) + counter += 1 + len(str(trans.id)) + if counter > 100: + counter = 0 + lines.append(','.join(ids)) + ids = [] + lines.append(','.join(ids)) + transactions = '
    '.join(lines) + url = reverse('admin:payments_transaction_changelist') + url += '?process_id=%i' % process.id + return '%s' % (url, transactions) + display_transactions.short_description = _("Transactions") + + def has_add_permission(self, *args, **kwargs): + return False + + def get_change_view_actions(self, obj=None): + actions = super().get_change_view_actions() + exclude = [] + if obj: + if obj.state == TransactionProcess.EXECUTED: + exclude.append('mark_process_as_executed') + elif obj.state == TransactionProcess.COMMITED: + exclude = ['mark_process_as_executed', 'abort', 'commit'] + elif obj.state == TransactionProcess.ABORTED: + exclude = ['mark_process_as_executed', 'abort', 'commit'] + return [action for action in actions if action.__name__ not in exclude] + + def delete_view(self, request, object_id, extra_context=None): + queryset = self.model.objects.filter(id=object_id) + related_transactions = helpers.pre_delete_processes(self, request, queryset) + response = super().delete_view(request, object_id, extra_context) + if isinstance(response, HttpResponseRedirect): + helpers.post_delete_processes(self, request, related_transactions) + return response + + def delete_queryset(self, request, queryset): + # override default admin action delete behaviour + related_transactions = helpers.pre_delete_processes(self, request, queryset) + super().delete_queryset(self, request, queryset) + helpers.post_delete_processes(self, request, related_transactions) + + +admin.site.register(PaymentSource, PaymentSourceAdmin) +admin.site.register(Transaction, TransactionAdmin) +admin.site.register(TransactionProcess, TransactionProcessAdmin) diff --git a/orchestra/contrib/payments/api.py b/orchestra/contrib/payments/api.py new file mode 100644 index 0000000..7fd65f2 --- /dev/null +++ b/orchestra/contrib/payments/api.py @@ -0,0 +1,21 @@ +from rest_framework import viewsets + +from orchestra.api import router, LogApiMixin +from orchestra.contrib.accounts.api import AccountApiMixin + +from .models import PaymentSource, Transaction +from .serializers import PaymentSourceSerializer, TransactionSerializer + + +class PaymentSourceViewSet(LogApiMixin, AccountApiMixin, viewsets.ModelViewSet): + serializer_class = PaymentSourceSerializer + queryset = PaymentSource.objects.all() + + +class TransactionViewSet(LogApiMixin, viewsets.ModelViewSet): + serializer_class = TransactionSerializer + queryset = Transaction.objects.all() + + +router.register(r'payment-sources', PaymentSourceViewSet) +router.register(r'transactions', TransactionViewSet) diff --git a/orchestra/contrib/payments/apps.py b/orchestra/contrib/payments/apps.py new file mode 100644 index 0000000..22eb60f --- /dev/null +++ b/orchestra/contrib/payments/apps.py @@ -0,0 +1,14 @@ +from django.apps import AppConfig + +from orchestra.core import accounts + + +class PaymentsConfig(AppConfig): + name = 'orchestra.contrib.payments' + verbose_name = "Payments" + + def ready(self): + from .models import PaymentSource, Transaction, TransactionProcess + accounts.register(PaymentSource, dashboard=False) + accounts.register(Transaction, icon='transaction.png', search=False) + accounts.register(TransactionProcess, icon='transactionprocess.png', dashboard=False, search=False) diff --git a/orchestra/contrib/payments/helpers.py b/orchestra/contrib/payments/helpers.py new file mode 100644 index 0000000..59163a0 --- /dev/null +++ b/orchestra/contrib/payments/helpers.py @@ -0,0 +1,36 @@ +from django.contrib import messages +from django.utils.translation import ngettext, gettext_lazy as _ + +from .models import Transaction + + +def pre_delete_processes(modeladmin, request, queryset): + if not queryset: + messages.warning(request, + _("No transaction process selected.")) + return + if queryset.exclude(transactions__state=Transaction.WAITTING_EXECUTION).exists(): + messages.error(request, + _("Done nothing. Not all related transactions in waitting execution.")) + return + # Store before deleting + related_transactions = [] + for process in queryset: + waitting_execution = process.transactions.filter(state=Transaction.WAITTING_EXECUTION) + related_transactions.extend(waitting_execution) + return related_transactions + + +def post_delete_processes(modeladmin, request, related_transactions): + # Confirmation + num = 0 + for transaction in related_transactions: + transaction.state = Transaction.WAITTING_PROCESSING + transaction.save(update_fields=('state', 'modified_at')) + num += 1 + modeladmin.log_change(request, transaction, _("Unprocessed")) + messages.success(request, ngettext( + "One related transaction has been marked as waitting for processing", + "%i related transactions have been marked as waitting for processing." % num, + num + )) diff --git a/orchestra/contrib/payments/locale/ca/LC_MESSAGES/django.mo b/orchestra/contrib/payments/locale/ca/LC_MESSAGES/django.mo new file mode 100644 index 0000000000000000000000000000000000000000..647bb80b7c1570671599da5585ea868946be17f4 GIT binary patch literal 740 zcmah{&2G~`5Do|~K62(T9FSTyo5n?zBFBm9CQdQfNytwnxUKE2v#q^rcGp1Ofhz}Y zeFq*P@6l&roU{oyKIz-_uDo57+)|9!$p8jFnOu66uVm106$ilH$n$dDT5z**ZqCw7~( zJ7C++`MZ-g0)=G8zVZ6!kGjvkHzU!63_W;1aiZYl%Vg|ZA=QQ~6cswBMk)tJ%P@qo z7y01&kr#v)-f%ec=kq>FMOM*_kp-iL1EDv*TGp1`FucflORDUkL90;^`G@-KR@)j# zVVJ;l>k@q%_QRZ(Ipw0eeP7K?Z@Cze&Ouk(x?zg=B9SQEcVMRPKes?ZLI za(mRhvo7pDdq)D^^uRvuT8*bh3^w-)TMs2ukjjE5Jm)mwn?L)$G=YAtm^Kt{Xkst} pFb#=}6;+%2f99dU5`YO{IhIhNq9_fkCnBMW11;j+Ec|y0{sQgR;>Z91 literal 0 HcmV?d00001 diff --git a/orchestra/contrib/payments/locale/ca/LC_MESSAGES/django.po b/orchestra/contrib/payments/locale/ca/LC_MESSAGES/django.po new file mode 100644 index 0000000..07f2e6a --- /dev/null +++ b/orchestra/contrib/payments/locale/ca/LC_MESSAGES/django.po @@ -0,0 +1,342 @@ +# 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 , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-10-08 11:53+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \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:24 +msgid "Selected transactions must be on '{state}' state" +msgstr "" + +#: actions.py:34 +msgid "Processed" +msgstr "" + +#: actions.py:41 +msgid "One selected transaction has been processed." +msgstr "" + +#: actions.py:42 +#, python-format +msgid "%s Selected transactions have been processed." +msgstr "" + +#: actions.py:45 +msgid "" +"The following transaction process has been generated, you may want to save " +"it on your computer now." +msgstr "" + +#: actions.py:47 +#, python-format +msgid "" +"The following %s transaction processes have been generated, you may want to " +"save it on your computer now." +msgstr "" + +#: actions.py:50 +msgid "Process" +msgstr "" + +#: actions.py:63 actions.py:134 models.py:97 models.py:172 +msgid "Executed" +msgstr "" + +#: actions.py:66 +msgid "One selected transaction has been marked as executed." +msgstr "" + +#: actions.py:67 +#, python-format +msgid "%s selected transactions have been marked as executed." +msgstr "" + +#: actions.py:71 actions.py:142 +msgid "Mark as executed" +msgstr "" + +#: actions.py:79 models.py:98 +msgid "Secured" +msgstr "" + +#: actions.py:82 +msgid "One selected transaction has been marked as secured." +msgstr "" + +#: actions.py:83 +#, python-format +msgid "%s selected transactions have been marked as secured." +msgstr "" + +#: actions.py:87 +msgid "Mark as secured" +msgstr "" + +#: actions.py:95 actions.py:166 models.py:99 +msgid "Rejected" +msgstr "" + +#: actions.py:98 actions.py:169 +msgid "One selected transaction has been marked as rejected." +msgstr "" + +#: actions.py:99 actions.py:170 +#, python-format +msgid "%s selected transactions have been marked as rejected." +msgstr "" + +#: actions.py:103 +msgid "Mark as rejected" +msgstr "" + +#: actions.py:137 +msgid "One selected process has been marked as executed." +msgstr "" + +#: actions.py:138 +#, python-format +msgid "%s selected processes have been marked as executed." +msgstr "" + +#: actions.py:150 models.py:173 +msgid "Aborted" +msgstr "" + +#: actions.py:153 +msgid "One selected process has been aborted." +msgstr "" + +#: actions.py:154 +#, python-format +msgid "%s selected processes have been aborted." +msgstr "" + +#: actions.py:158 +msgid "Abort" +msgstr "" + +#: actions.py:174 +msgid "Commit" +msgstr "" + +#: admin.py:44 +msgid "ID" +msgstr "" + +#: admin.py:106 +msgid "proc" +msgstr "" + +#: admin.py:129 templates/admin/payments/transaction/report.html:62 +msgid "State" +msgstr "" + +#: admin.py:168 +msgid "Transactions" +msgstr "" + +#: helpers.py:11 +msgid "No transaction process selected." +msgstr "" + +#: helpers.py:15 +msgid "Done nothing. Not all related transactions in waitting execution." +msgstr "" + +#: helpers.py:32 +msgid "Unprocessed" +msgstr "" + +#: helpers.py:34 +#, python-format +msgid "" +"One related transaction has been marked as waitting for processing" +msgid_plural "" +"%i related transactions have been marked as waitting for processing." +msgstr[0] "" +msgstr[1] "" + +#: methods/creditcard.py:11 +msgid "Label" +msgstr "" + +#: methods/creditcard.py:12 +msgid "Use a name such as \"Jo's Visa\" to remember which card it is." +msgstr "" + +#: methods/creditcard.py:30 +msgid "Credit card" +msgstr "" + +#: methods/sepadirectdebit.py:23 methods/sepadirectdebit.py:30 +msgid "Name" +msgstr "" + +#: methods/sepadirectdebit.py:39 +msgid "SEPA Direct Debit" +msgstr "" + +#: methods/sepadirectdebit.py:47 +msgid "" +"The transaction is created and requires the generation of the SEPA direct " +"debit XML file." +msgstr "" + +#: methods/sepadirectdebit.py:49 +msgid "" +"SEPA Direct Debit XML file is generated but needs to be sent to the " +"financial institution." +msgstr "" + +#: models.py:20 +msgid "account" +msgstr "" + +#: models.py:22 +msgid "method" +msgstr "" + +#: models.py:24 models.py:177 +msgid "data" +msgstr "" + +#: models.py:25 +msgid "active" +msgstr "" + +#: models.py:95 +msgid "Waitting processing" +msgstr "" + +#: models.py:96 +msgid "Waitting execution" +msgstr "" + +#: models.py:102 +msgid "" +"The transaction is created and requires processing by the specific payment " +"method." +msgstr "" + +#: models.py:104 +msgid "" +"The transaction is processed and its pending execution on the related " +"financial institution." +msgstr "" + +#: models.py:106 +msgid "The transaction is executed on the financial institution." +msgstr "" + +#: models.py:107 +msgid "The transaction ammount is secured." +msgstr "" + +#: models.py:108 +msgid "" +"The transaction has failed and the ammount is lost, a new transaction should " +"be created for recharging." +msgstr "" + +#: models.py:112 +msgid "bill" +msgstr "" + +#: models.py:115 +msgid "source" +msgstr "" + +#: models.py:117 +msgid "process" +msgstr "" + +#: models.py:118 models.py:179 +msgid "state" +msgstr "" + +#: models.py:120 +msgid "amount" +msgstr "" + +#: models.py:122 models.py:180 +msgid "created" +msgstr "" + +#: models.py:123 +msgid "modified" +msgstr "" + +#: models.py:138 +msgid "New transactions can not be allocated for this bill." +msgstr "" + +#: models.py:171 templates/admin/payments/transaction/report.html:63 +msgid "Created" +msgstr "" + +#: models.py:174 +msgid "Commited" +msgstr "" + +#: models.py:178 +msgid "file" +msgstr "" + +#: models.py:181 +msgid "updated" +msgstr "" + +#: models.py:184 +msgid "Transaction processes" +msgstr "" + +#: settings.py:14 +msgid "" +"Direct debit, this bill will be automatically charged to " +"your bank account with IBAN number
    %(number)s." +msgstr "" +"Càrrec per domiciliació, aquesta factura es cobrarà " +"automaticament en el teu compte bancari amb IBAN
    %(number)s." + +#: templates/admin/payments/transaction/report.html:38 +msgid "Summary" +msgstr "" + +#: templates/admin/payments/transaction/report.html:39 +#: templates/admin/payments/transaction/report.html:61 +msgid "Amount" +msgstr "" + +#: templates/admin/payments/transaction/report.html:48 +msgid "TOTAL" +msgstr "" + +#: templates/admin/payments/transaction/report.html:57 +msgid "Bill" +msgstr "" + +#: templates/admin/payments/transaction/report.html:58 +msgid "Account" +msgstr "" + +#: templates/admin/payments/transaction/report.html:59 +msgid "Contact" +msgstr "" + +#: templates/admin/payments/transaction/report.html:64 +msgid "Updated" +msgstr "" diff --git a/orchestra/contrib/payments/locale/es/LC_MESSAGES/django.mo b/orchestra/contrib/payments/locale/es/LC_MESSAGES/django.mo new file mode 100644 index 0000000000000000000000000000000000000000..de1e91c13224550e19053d6b759d66943c24b4a2 GIT binary patch literal 735 zcmah{&5qMB5H1KVIdbMO9FTUkb+@TNs?xODY@05Dq%Hkf32u(#P0c2DWjjFMfg?Ns z&%h(>d+f6?X?I(0eA4G+tZzJ@J#+u!*{=rTDe@(AIOi$+b502J$`>8 z-(v!X2h{mH_{M4_^T9}H&Mag+71lq0*ldDb3j?Vr3iwtpDF<3vRZ=S$Es8C$HPt!K zz$)0PN<&KJEl|c(B`v1dHAI)e1Y}jFT>GgWJl1=Cvfn#3hia|HOsg;Gf5>dJM`KEo5w+ZdPTPG;+%{=< z!R>hNo3l0og=EgZiu&*e-KQ{^lVnOp0lb@fNp$vkItiVa8cUX%N>fm)vRh~kT2G@6Ht#Q>!;uV~K6lGD, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-10-08 12:14+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \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:24 +msgid "Selected transactions must be on '{state}' state" +msgstr "" + +#: actions.py:34 +msgid "Processed" +msgstr "" + +#: actions.py:41 +msgid "One selected transaction has been processed." +msgstr "" + +#: actions.py:42 +#, python-format +msgid "%s Selected transactions have been processed." +msgstr "" + +#: actions.py:45 +msgid "" +"The following transaction process has been generated, you may want to save " +"it on your computer now." +msgstr "" + +#: actions.py:47 +#, python-format +msgid "" +"The following %s transaction processes have been generated, you may want to " +"save it on your computer now." +msgstr "" + +#: actions.py:50 +msgid "Process" +msgstr "" + +#: actions.py:63 actions.py:134 models.py:97 models.py:172 +msgid "Executed" +msgstr "" + +#: actions.py:66 +msgid "One selected transaction has been marked as executed." +msgstr "" + +#: actions.py:67 +#, python-format +msgid "%s selected transactions have been marked as executed." +msgstr "" + +#: actions.py:71 actions.py:142 +msgid "Mark as executed" +msgstr "" + +#: actions.py:79 models.py:98 +msgid "Secured" +msgstr "" + +#: actions.py:82 +msgid "One selected transaction has been marked as secured." +msgstr "" + +#: actions.py:83 +#, python-format +msgid "%s selected transactions have been marked as secured." +msgstr "" + +#: actions.py:87 +msgid "Mark as secured" +msgstr "" + +#: actions.py:95 actions.py:166 models.py:99 +msgid "Rejected" +msgstr "" + +#: actions.py:98 actions.py:169 +msgid "One selected transaction has been marked as rejected." +msgstr "" + +#: actions.py:99 actions.py:170 +#, python-format +msgid "%s selected transactions have been marked as rejected." +msgstr "" + +#: actions.py:103 +msgid "Mark as rejected" +msgstr "" + +#: actions.py:137 +msgid "One selected process has been marked as executed." +msgstr "" + +#: actions.py:138 +#, python-format +msgid "%s selected processes have been marked as executed." +msgstr "" + +#: actions.py:150 models.py:173 +msgid "Aborted" +msgstr "" + +#: actions.py:153 +msgid "One selected process has been aborted." +msgstr "" + +#: actions.py:154 +#, python-format +msgid "%s selected processes have been aborted." +msgstr "" + +#: actions.py:158 +msgid "Abort" +msgstr "" + +#: actions.py:174 +msgid "Commit" +msgstr "" + +#: admin.py:44 +msgid "ID" +msgstr "" + +#: admin.py:106 +msgid "proc" +msgstr "" + +#: admin.py:129 templates/admin/payments/transaction/report.html:62 +msgid "State" +msgstr "" + +#: admin.py:168 +msgid "Transactions" +msgstr "" + +#: helpers.py:11 +msgid "No transaction process selected." +msgstr "" + +#: helpers.py:15 +msgid "Done nothing. Not all related transactions in waitting execution." +msgstr "" + +#: helpers.py:32 +msgid "Unprocessed" +msgstr "" + +#: helpers.py:34 +#, python-format +msgid "" +"One related transaction has been marked as waitting for processing" +msgid_plural "" +"%i related transactions have been marked as waitting for processing." +msgstr[0] "" +msgstr[1] "" + +#: methods/creditcard.py:11 +msgid "Label" +msgstr "" + +#: methods/creditcard.py:12 +msgid "Use a name such as \"Jo's Visa\" to remember which card it is." +msgstr "" + +#: methods/creditcard.py:30 +msgid "Credit card" +msgstr "" + +#: methods/sepadirectdebit.py:23 methods/sepadirectdebit.py:30 +msgid "Name" +msgstr "" + +#: methods/sepadirectdebit.py:39 +msgid "SEPA Direct Debit" +msgstr "" + +#: methods/sepadirectdebit.py:47 +msgid "" +"The transaction is created and requires the generation of the SEPA direct " +"debit XML file." +msgstr "" + +#: methods/sepadirectdebit.py:49 +msgid "" +"SEPA Direct Debit XML file is generated but needs to be sent to the " +"financial institution." +msgstr "" + +#: models.py:20 +msgid "account" +msgstr "" + +#: models.py:22 +msgid "method" +msgstr "" + +#: models.py:24 models.py:177 +msgid "data" +msgstr "" + +#: models.py:25 +msgid "active" +msgstr "" + +#: models.py:95 +msgid "Waitting processing" +msgstr "" + +#: models.py:96 +msgid "Waitting execution" +msgstr "" + +#: models.py:102 +msgid "" +"The transaction is created and requires processing by the specific payment " +"method." +msgstr "" + +#: models.py:104 +msgid "" +"The transaction is processed and its pending execution on the related " +"financial institution." +msgstr "" + +#: models.py:106 +msgid "The transaction is executed on the financial institution." +msgstr "" + +#: models.py:107 +msgid "The transaction ammount is secured." +msgstr "" + +#: models.py:108 +msgid "" +"The transaction has failed and the ammount is lost, a new transaction should " +"be created for recharging." +msgstr "" + +#: models.py:112 +msgid "bill" +msgstr "" + +#: models.py:115 +msgid "source" +msgstr "" + +#: models.py:117 +msgid "process" +msgstr "" + +#: models.py:118 models.py:179 +msgid "state" +msgstr "" + +#: models.py:120 +msgid "amount" +msgstr "" + +#: models.py:122 models.py:180 +msgid "created" +msgstr "" + +#: models.py:123 +msgid "modified" +msgstr "" + +#: models.py:138 +msgid "New transactions can not be allocated for this bill." +msgstr "" + +#: models.py:171 templates/admin/payments/transaction/report.html:63 +msgid "Created" +msgstr "" + +#: models.py:174 +msgid "Commited" +msgstr "" + +#: models.py:178 +msgid "file" +msgstr "" + +#: models.py:181 +msgid "updated" +msgstr "" + +#: models.py:184 +msgid "Transaction processes" +msgstr "" + +#: settings.py:14 +msgid "" +"Direct debit, this bill will be automatically charged to " +"your bank account with IBAN number
    %(number)s." +msgstr "" +"Adeudo por domiciliación, esta factura se cobrará " +"automaticamente en tu cuenta bancaria con IBAN
    %(number)s." + +#: templates/admin/payments/transaction/report.html:38 +msgid "Summary" +msgstr "" + +#: templates/admin/payments/transaction/report.html:39 +#: templates/admin/payments/transaction/report.html:61 +msgid "Amount" +msgstr "" + +#: templates/admin/payments/transaction/report.html:48 +msgid "TOTAL" +msgstr "" + +#: templates/admin/payments/transaction/report.html:57 +msgid "Bill" +msgstr "" + +#: templates/admin/payments/transaction/report.html:58 +msgid "Account" +msgstr "" + +#: templates/admin/payments/transaction/report.html:59 +msgid "Contact" +msgstr "" + +#: templates/admin/payments/transaction/report.html:64 +msgid "Updated" +msgstr "" diff --git a/orchestra/contrib/payments/methods/__init__.py b/orchestra/contrib/payments/methods/__init__.py new file mode 100644 index 0000000..9a9aaa5 --- /dev/null +++ b/orchestra/contrib/payments/methods/__init__.py @@ -0,0 +1 @@ +from .options import PaymentMethod diff --git a/orchestra/contrib/payments/methods/creditcard.py b/orchestra/contrib/payments/methods/creditcard.py new file mode 100644 index 0000000..14e5851 --- /dev/null +++ b/orchestra/contrib/payments/methods/creditcard.py @@ -0,0 +1,32 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from orchestra.plugins.forms import PluginDataForm + +from .options import PaymentMethod + + +class CreditCardForm(PluginDataForm): + label = forms.CharField(max_length=128, label=_("Label"), + help_text=_("Use a name such as \"Jo's Visa\" to remember which " + "card it is.")) + first_name = forms.CharField(max_length=128) + last_name = forms.CharField(max_length=128) + address = forms.CharField(max_length=128) + zip = forms.CharField(max_length=128) + city = forms.CharField(max_length=128) + country = forms.CharField(max_length=128) + card_number = forms.CharField(max_length=128) + expiration_date = forms.CharField(max_length=128) + security_code = forms.CharField(max_length=128) + + +class CreditCardSerializer(serializers.Serializer): + pass + + +class CreditCard(PaymentMethod): + verbose_name = _("Credit card") + form = CreditCardForm + serializer = CreditCardSerializer diff --git a/orchestra/contrib/payments/methods/options.py b/orchestra/contrib/payments/methods/options.py new file mode 100644 index 0000000..5e19cbd --- /dev/null +++ b/orchestra/contrib/payments/methods/options.py @@ -0,0 +1,42 @@ +import importlib +import logging +import os +from dateutil import relativedelta +from functools import lru_cache + +from orchestra import plugins +from orchestra.utils.python import import_class + +from .. import settings + + +class PaymentMethod(plugins.Plugin, metaclass=plugins.PluginMount): + label_field = 'label' + number_field = 'number' + allow_recharge = False + due_delta = relativedelta.relativedelta(months=1) + plugin_field = 'method' + state_help = {} + + @classmethod + @lru_cache() + def get_plugins(cls, all=False): + if all: + for module in os.listdir(os.path.dirname(__file__)): + if module not in ('options.py', '__init__.py') and module[-3:] == '.py': + importlib.import_module('.'+module[:-3], __package__) + plugins = super().get_plugins() + else: + plugins = [] + for cls in settings.PAYMENTS_ENABLED_METHODS: + plugins.append(import_class(cls)) + return plugins + + def get_label(self): + return self.instance.data[self.label_field] + + def get_number(self): + return self.instance.data[self.number_field] + + def get_bill_message(self): + return '' diff --git a/orchestra/contrib/payments/methods/pain.001.001.03.xsd b/orchestra/contrib/payments/methods/pain.001.001.03.xsd new file mode 100644 index 0000000..4f65ddc --- /dev/null +++ b/orchestra/contrib/payments/methods/pain.001.001.03.xsd @@ -0,0 +1,921 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orchestra/contrib/payments/methods/pain.008.001.02.xsd b/orchestra/contrib/payments/methods/pain.008.001.02.xsd new file mode 100644 index 0000000..394b804 --- /dev/null +++ b/orchestra/contrib/payments/methods/pain.008.001.02.xsd @@ -0,0 +1,879 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/orchestra/contrib/payments/methods/sepadirectdebit.py b/orchestra/contrib/payments/methods/sepadirectdebit.py new file mode 100644 index 0000000..8ce1bf3 --- /dev/null +++ b/orchestra/contrib/payments/methods/sepadirectdebit.py @@ -0,0 +1,319 @@ +import datetime +import logging +import os +from io import StringIO + +from django import forms +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from django_iban.validators import IBANValidator, IBAN_COUNTRY_CODE_LENGTH +from rest_framework import serializers + +from orchestra.plugins.forms import PluginDataForm + +from .. import settings +from .options import PaymentMethod + + +logger = logging.getLogger(__name__) + +try: + import lxml +except ImportError: + logger.error('Error loading lxml, module not installed.') + + +class SEPADirectDebitForm(PluginDataForm): + iban = forms.CharField(label='IBAN', + widget=forms.TextInput(attrs={'size': '50'})) + name = forms.CharField(max_length=128, label=_("Name"), + widget=forms.TextInput(attrs={'size': '50'})) + + +class SEPADirectDebitSerializer(serializers.Serializer): + iban = serializers.CharField(label='IBAN', validators=[IBANValidator()], + min_length=min(IBAN_COUNTRY_CODE_LENGTH.values()), max_length=34) + name = serializers.CharField(label=_("Name"), max_length=128) + + def validate(self, data): + data['iban'] = data['iban'].strip() + data['name'] = data['name'].strip() + return data + + +class SEPADirectDebit(PaymentMethod): + verbose_name = _("SEPA Direct Debit") + label_field = 'name' + number_field = 'iban' + allow_recharge = True + form = SEPADirectDebitForm + serializer = SEPADirectDebitSerializer + due_delta = datetime.timedelta(days=5) + state_help = { + 'WAITTING_PROCESSING': _("The transaction is created and requires the generation of " + "the SEPA direct debit XML file."), + 'WAITTING_EXECUTION': _("SEPA Direct Debit XML file is generated but needs to be sent " + "to the financial institution."), + } + + def get_bill_message(self): + context = { + 'number': self.instance.number + } + return settings.PAYMENTS_DD_BILL_MESSAGE % context + + @classmethod + def process(cls, transactions): + debts = [] + credits = [] + for transaction in transactions: + if transaction.amount < 0: + credits.append(transaction) + else: + debts.append(transaction) + processes = [] + if debts: + proc = cls.process_debts(debts) + processes.append(proc) + if credits: + proc = cls.process_credits(credits) + processes.append(proc) + return processes + + @classmethod + def process_credits(cls, transactions): + import lxml.builder + from lxml.builder import E + from ..models import TransactionProcess + process = TransactionProcess.objects.create() + context = cls.get_context(transactions) + # http://businessbanking.bankofireland.com/fs/doc/wysiwyg/b22440-mss130725-pain001-xml-file-structure-dec13.pdf + sepa = lxml.builder.ElementMaker( + nsmap = { + 'xsi': 'http://www.w3.org/2001/XMLSchema-instance', + None: 'urn:iso:std:iso:20022:tech:xsd:pain.001.001.03', + } + ) + sepa = sepa.Document( + E.CstmrCdtTrfInitn( + cls.get_header(context, process), + E.PmtInf( # Payment Info + E.PmtInfId(str(process.id)), # Payment Id + E.PmtMtd("TRF"), # Payment Method + E.NbOfTxs(context['num_transactions']), # Number of Transactions + E.CtrlSum(context['total']), # Control Sum + E.ReqdExctnDt( # Requested Execution Date + (context['now']+datetime.timedelta(days=10)).strftime("%Y-%m-%d") + ), + E.Dbtr( # Debtor + E.Nm(context['name']) + ), + E.DbtrAcct( # Debtor Account + E.Id( + E.IBAN(context['iban']) + ) + ), + E.DbtrAgt( # Debtor Agent + E.FinInstnId( # Financial Institution Id + E.BIC(context['bic']) + ) + ), + *list(cls.get_credit_transactions(transactions, process)) # Transactions + ) + ) + ) + file_name = 'credit-transfer-%i.xml' % process.id + cls.process_xml(sepa, 'pain.001.001.03.xsd', file_name, process) + return process + + @classmethod + def process_debts(cls, transactions): + import lxml.builder + from lxml.builder import E + from ..models import TransactionProcess + process = TransactionProcess.objects.create() + context = cls.get_context(transactions) + # http://businessbanking.bankofireland.com/fs/doc/wysiwyg/sepa-direct-debit-pain-008-001-02-xml-file-structure-july-2013.pdf + sepa = lxml.builder.ElementMaker( + nsmap = { + 'xsi': 'http://www.w3.org/2001/XMLSchema-instance', + None: 'urn:iso:std:iso:20022:tech:xsd:pain.008.001.02', + } + ) + sepa = sepa.Document( + E.CstmrDrctDbtInitn( + cls.get_header(context, process), + E.PmtInf( # Payment Info + E.PmtInfId(str(process.id)), # Payment Id + E.PmtMtd("DD"), # Payment Method + E.NbOfTxs(context['num_transactions']), # Number of Transactions + E.CtrlSum(context['total']), # Control Sum + E.PmtTpInf( # Payment Type Info + E.SvcLvl( # Service Level + E.Cd("SEPA") # Code + ), + E.LclInstrm( # Local Instrument + E.Cd("CORE") # Code + ), + E.SeqTp("RCUR") # Sequence Type + ), + E.ReqdColltnDt( # Requested Collection Date + context['now'].strftime("%Y-%m-%d") + ), + E.Cdtr( # Creditor + E.Nm(context['name']) + ), + E.CdtrAcct( # Creditor Account + E.Id( + E.IBAN(context['iban']) + ) + ), + E.CdtrAgt( # Creditor Agent + E.FinInstnId( # Financial Institution Id + E.BIC(context['bic']) + ) + ), + *list(cls.get_debt_transactions(transactions, process)) # Transactions + ) + ) + ) + file_name = 'direct-debit-%i.xml' % process.id + cls.process_xml(sepa, 'pain.008.001.02.xsd', file_name, process) + return process + + @classmethod + def get_context(cls, transactions): + return { + 'name': settings.PAYMENTS_DD_CREDITOR_NAME, + 'iban': settings.PAYMENTS_DD_CREDITOR_IBAN, + 'bic': settings.PAYMENTS_DD_CREDITOR_BIC, + 'at02_id': settings.PAYMENTS_DD_CREDITOR_AT02_ID, + 'now': timezone.now(), + 'total': str(sum([abs(transaction.amount) for transaction in transactions])), + 'num_transactions': str(len(transactions)), + } + + @classmethod + def get_debt_transactions(cls, transactions, process): + import lxml.builder + from lxml.builder import E + for transaction in transactions: + transaction.process = process + transaction.state = transaction.WAITTING_EXECUTION + transaction.save(update_fields=('state', 'process', 'modified_at')) + account = transaction.account + data = transaction.source.data + yield E.DrctDbtTxInf( # Direct Debit Transaction Info + E.PmtId( # Payment Id + E.EndToEndId( # Payment Id/End to End + str(transaction.bill.number)+'-'+str(transaction.id) + ) + ), + E.InstdAmt( # Instructed Amount + str(abs(transaction.amount)), + Ccy=transaction.currency.upper() + ), + E.DrctDbtTx( # Direct Debit Transaction + E.MndtRltdInf( # Mandate Related Info + # + 10000 xk vam canviar de sistema per generar aquestes IDs i volem evitar colisions amb els + # numeros usats antigament + E.MndtId(str(transaction.source_id+10000)), # Mandate Id + E.DtOfSgntr( # Date of Signature + account.date_joined.strftime("%Y-%m-%d") + ) + ) + ), + E.DbtrAgt( # Debtor Agent + E.FinInstnId( # Financial Institution Id + E.Othr( + E.Id('NOTPROVIDED') + ) + ) + ), + E.Dbtr( # Debtor + E.Nm(account.billcontact.get_name()), # Name + ), + E.DbtrAcct( # Debtor Account + E.Id( + E.IBAN(data['iban'].replace(' ', '')) + ), + ), + ) + + @classmethod + def get_credit_transactions(cls, transactions, process): + import lxml.builder + from lxml.builder import E + for transaction in transactions: + transaction.process = process + transaction.state = transaction.WAITTING_EXECUTION + transaction.save(update_fields=('state', 'process', 'modified_at')) + account = transaction.account + data = transaction.source.data + yield E.CdtTrfTxInf( # Credit Transfer Transaction Info + E.PmtId( # Payment Id + E.EndToEndId(str(transaction.id)) # Payment Id/End to End + ), + E.Amt( # Amount + E.InstdAmt( # Instructed Amount + str(abs(transaction.amount)), + Ccy=transaction.currency.upper() + ) + ), + E.CdtrAgt( # Creditor Agent + E.FinInstnId( # Financial Institution Id + E.Othr( + E.Id('NOTPROVIDED') + ) + ) + ), + E.Cdtr( # Debtor + E.Nm(account.name), # Name + ), + E.CdtrAcct( # Creditor Account + E.Id( + E.IBAN(data['iban'].replace(' ', '')) + ), + ), + ) + + @classmethod + def get_header(cls, context, process): + import lxml.builder + from lxml.builder import E + return E.GrpHdr( # Group Header + E.MsgId(str(process.id)), # Message Id + E.CreDtTm( # Creation Date Time + context['now'].strftime("%Y-%m-%dT%H:%M:%S") + ), + E.NbOfTxs(context['num_transactions']), # Number of Transactions + E.CtrlSum(context['total']), # Control Sum + E.InitgPty( # Initiating Party + E.Nm(context['name']), # Name + E.Id( # Identification + E.OrgId( # Organisation Id + E.Othr( + E.Id(context['at02_id']) + ) + ) + ) + ) + ) + + @classmethod + def process_xml(cls, sepa, xsd, file_name, process): + from lxml import etree + # http://www.iso20022.org/documents/messages/1_0_version/pain/schemas/pain.008.001.02.zip + path = os.path.dirname(os.path.realpath(__file__)) + xsd_path = os.path.join(path, xsd) + schema_doc = etree.parse(xsd_path) + schema = etree.XMLSchema(schema_doc) + sepa = StringIO(etree.tostring(sepa).decode('utf8')) + sepa = etree.parse(sepa) + schema.assertValid(sepa) + process.file = file_name + process.save(update_fields=['file']) + sepa.write(process.file.path, + pretty_print=True, + xml_declaration=True, + encoding='UTF-8') diff --git a/orchestra/contrib/payments/models.py b/orchestra/contrib/payments/models.py new file mode 100644 index 0000000..9617ce4 --- /dev/null +++ b/orchestra/contrib/payments/models.py @@ -0,0 +1,210 @@ +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ +from jsonfield import JSONField + +from orchestra.models.fields import PrivateFileField +from orchestra.models.queryset import group_by + +from . import settings +from .methods import PaymentMethod + + +class PaymentSourcesQueryset(models.QuerySet): + def get_default(self): + return self.filter(is_active=True).first() + + +class PaymentSource(models.Model): + account = models.ForeignKey('accounts.Account', verbose_name=_("account"), + related_name='paymentsources', on_delete=models.CASCADE) + method = models.CharField(_("method"), max_length=32, + choices=PaymentMethod.get_choices()) + data = JSONField(_("data"), default={}) + is_active = models.BooleanField(_("active"), default=True) + + objects = PaymentSourcesQueryset.as_manager() + + def __str__(self): + return "%s (%s)" % (self.label, self.method_class.verbose_name) + + @cached_property + def method_class(self): + return PaymentMethod.get(self.method) + + @cached_property + def method_instance(self): + """ Per request lived method_instance """ + return self.method_class(self) + + @cached_property + def label(self): + return self.method_instance.get_label() + + @cached_property + def number(self): + return self.method_instance.get_number() + + def get_bill_context(self): + method = self.method_instance + return { + 'message': method.get_bill_message(), + } + + def get_due_delta(self): + return self.method_instance.due_delta + + def clean(self): + self.data = self.method_instance.clean_data() + + +class TransactionQuerySet(models.QuerySet): + group_by = group_by + + def create(self, **kwargs): + source = kwargs.get('source') + if source is None or not hasattr(source.method_class, 'process'): + # Manual payments don't need processing + kwargs['state'] = self.model.WAITTING_EXECUTION + amount = kwargs.get('amount') + if amount == 0: + kwargs['state'] = self.model.SECURED + return super(TransactionQuerySet, self).create(**kwargs) + + def secured(self): + return self.filter(state=Transaction.SECURED) + + def exclude_rejected(self): + return self.exclude(state=Transaction.REJECTED) + + def amount(self): + return next(iter(self.aggregate(models.Sum('amount')).values())) or 0 + + def processing(self): + return self.filter(state__in=[Transaction.EXECUTED, Transaction.WAITTING_EXECUTION]) + + +class Transaction(models.Model): + WAITTING_PROCESSING = 'WAITTING_PROCESSING' # CREATED + WAITTING_EXECUTION = 'WAITTING_EXECUTION' # PROCESSED + EXECUTED = 'EXECUTED' + SECURED = 'SECURED' + REJECTED = 'REJECTED' + STATES = ( + (WAITTING_PROCESSING, _("Waitting processing")), + (WAITTING_EXECUTION, _("Waitting execution")), + (EXECUTED, _("Executed")), + (SECURED, _("Secured")), + (REJECTED, _("Rejected")), + ) + STATE_HELP = { + WAITTING_PROCESSING: _("The transaction is created and requires processing by the " + "specific payment method."), + WAITTING_EXECUTION: _("The transaction is processed and its pending execution on " + "the related financial institution."), + EXECUTED: _("The transaction is executed on the financial institution."), + SECURED: _("The transaction ammount is secured."), + REJECTED: _("The transaction has failed and the ammount is lost, a new transaction " + "should be created for recharging."), + } + + bill = models.ForeignKey('bills.bill', on_delete=models.CASCADE, verbose_name=_("bill"), + related_name='transactions') + source = models.ForeignKey(PaymentSource, null=True, blank=True, on_delete=models.SET_NULL, + verbose_name=_("source"), related_name='transactions') + process = models.ForeignKey('payments.TransactionProcess', null=True, blank=True, + on_delete=models.SET_NULL, verbose_name=_("process"), related_name='transactions') + state = models.CharField(_("state"), max_length=32, choices=STATES, + default=WAITTING_PROCESSING) + amount = models.DecimalField(_("amount"), max_digits=12, decimal_places=2) + currency = models.CharField(max_length=10, default=settings.PAYMENT_CURRENCY) + created_at = models.DateTimeField(_("created"), auto_now_add=True) + modified_at = models.DateTimeField(_("modified"), auto_now=True) + + objects = TransactionQuerySet.as_manager() + + def __str__(self): + return "#%i" % self.id + + @property + def account(self): + return self.bill.account + + def clean(self): + if not self.pk: + amount = self.bill.transactions.exclude(state=self.REJECTED).amount() + if amount >= self.bill.total: + raise ValidationError( + _("Bill %(number)s already has valid transactions that cover bill total amount (%(amount)s).") % { + 'number': self.bill.number, + 'amount': amount, + } + ) + + def get_state_help(self): + if self.source: + return self.source.method_instance.state_help.get(self.state) or self.STATE_HELP.get(self.state) + return self.STATE_HELP.get(self.state) + + def mark_as_processed(self): + self.state = self.WAITTING_EXECUTION + self.save(update_fields=('state', 'modified_at')) + + def mark_as_executed(self): + self.state = self.EXECUTED + self.save(update_fields=('state', 'modified_at')) + + def mark_as_secured(self): + self.state = self.SECURED + self.save(update_fields=('state', 'modified_at')) + + def mark_as_rejected(self): + self.state = self.REJECTED + self.save(update_fields=('state', 'modified_at')) + + +class TransactionProcess(models.Model): + """ + Stores arbitrary data generated by payment methods while processing transactions + """ + CREATED = 'CREATED' + EXECUTED = 'EXECUTED' + ABORTED = 'ABORTED' + COMMITED = 'COMMITED' + STATES = ( + (CREATED, _("Created")), + (EXECUTED, _("Executed")), + (ABORTED, _("Aborted")), + (COMMITED, _("Commited")), + ) + + data = JSONField(_("data"), blank=True) + file = PrivateFileField(_("file"), blank=True) + state = models.CharField(_("state"), max_length=16, choices=STATES, default=CREATED) + created_at = models.DateTimeField(_("created"), auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(_("updated"), auto_now=True) + + class Meta: + verbose_name_plural = _("Transaction processes") + + def __str__(self): + return '#%i' % self.id + + def mark_as_executed(self): + self.state = self.EXECUTED + for transaction in self.transactions.all(): + transaction.mark_as_executed() + self.save(update_fields=('state', 'updated_at')) + + def abort(self): + self.state = self.ABORTED + for transaction in self.transactions.all(): + transaction.mark_as_rejected() + self.save(update_fields=('state', 'updated_at')) + + def commit(self): + self.state = self.COMMITED + for transaction in self.transactions.processing(): + transaction.mark_as_secured() + self.save(update_fields=('state', 'updated_at')) diff --git a/orchestra/contrib/payments/serializers.py b/orchestra/contrib/payments/serializers.py new file mode 100644 index 0000000..93ae9f7 --- /dev/null +++ b/orchestra/contrib/payments/serializers.py @@ -0,0 +1,46 @@ +from rest_framework import serializers + +from orchestra.contrib.accounts.serializers import AccountSerializerMixin + +from .methods import PaymentMethod +from .models import PaymentSource, Transaction + + +class PaymentSourceSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer): + class Meta: + model = PaymentSource + fields = ('url', 'id', 'method', 'data', 'is_active') + + def validate(self, data): + """ validate data according to method """ + data = super(PaymentSourceSerializer, self).validate(data) + plugin = PaymentMethod.get(data['method']) + serializer_class = plugin().get_serializer() + serializer = serializer_class(data=data['data']) + if not serializer.is_valid(): + raise serializers.ValidationError(serializer.errors) + return data + + def transform_data(self, obj, value): + if not obj: + return {} + if obj.method: + plugin = PaymentMethod.get(obj.method) + serializer_class = plugin().get_serializer() + return serializer_class().to_native(obj.data) + return obj.data + + # TODO + def metadata(self): + meta = super(PaymentSourceSerializer, self).metadata() + meta['data'] = { + method.get_name(): method().get_serializer()().metadata() + for method in PaymentMethod.get_plugins() + } + return meta + + +class TransactionSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer): + class Meta: + model = Transaction + exclude = ('process',) diff --git a/orchestra/contrib/payments/settings.py b/orchestra/contrib/payments/settings.py new file mode 100644 index 0000000..65e5702 --- /dev/null +++ b/orchestra/contrib/payments/settings.py @@ -0,0 +1,46 @@ +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.settings import Setting + +from .. import payments + + +PAYMENT_CURRENCY = Setting('PAYMENT_CURRENCY', + 'Eur' +) + + +PAYMENTS_DD_BILL_MESSAGE = Setting('PAYMENTS_DD_BILL_MESSAGE', + _("Direct debit, this bill will be automatically charged " + "to your bank account with IBAN number
    %(number)s."), +) + +PAYMENTS_DD_CREDITOR_NAME = Setting('PAYMENTS_DD_CREDITOR_NAME', + 'Orchestra' +) + + +PAYMENTS_DD_CREDITOR_IBAN = Setting('PAYMENTS_DD_CREDITOR_IBAN', + 'IE98BOFI90393912121212' +) + + +PAYMENTS_DD_CREDITOR_BIC = Setting('PAYMENTS_DD_CREDITOR_BIC', + 'BOFIIE2D' +) + + +PAYMENTS_DD_CREDITOR_AT02_ID = Setting('PAYMENTS_DD_CREDITOR_AT02_ID', + 'InvalidAT02ID' +) + + +PAYMENTS_ENABLED_METHODS = Setting('PAYMENTS_ENABLED_METHODS', + ( + 'orchestra.contrib.payments.methods.sepadirectdebit.SEPADirectDebit', + 'orchestra.contrib.payments.methods.creditcard.CreditCard', + ), + # lazy loading + choices=lambda : ((m.get_class_path(), m.get_class_path()) for m in payments.methods.PaymentMethod.get_plugins(all=True)), + multiple=True, +) diff --git a/orchestra/contrib/payments/templates/admin/payments/transaction/get_processes.html b/orchestra/contrib/payments/templates/admin/payments/transaction/get_processes.html new file mode 100644 index 0000000..d214ca6 --- /dev/null +++ b/orchestra/contrib/payments/templates/admin/payments/transaction/get_processes.html @@ -0,0 +1,19 @@ +{% extends "admin/orchestra/generic_confirmation.html" %} +{% load i18n admin_urls utils %} + + +{% block content %} +

    {{ content_message }}

    +
      + {% for proc in processes %} +
    • Process #{{ proc.id }} + {% if proc.file %} + + {% endif %} + {% if proc.data %} +
      • Data: {{ proc.data }}
      + {% endif %} +
    • + {% endfor %} +
    +{% endblock %} diff --git a/orchestra/contrib/payments/templates/admin/payments/transaction/report.html b/orchestra/contrib/payments/templates/admin/payments/transaction/report.html new file mode 100644 index 0000000..185e1b7 --- /dev/null +++ b/orchestra/contrib/payments/templates/admin/payments/transaction/report.html @@ -0,0 +1,81 @@ +{% load i18n %} + + + + Transaction Report + + + + + + + + + + +{% for state, amount in states.items %} + + + + +{% endfor %} + + + + +
    {% trans "Summary" %}{% trans "Amount" %}
    {{ state }}{{ amount }}
    {% trans "TOTAL" %}{{ total }}
    + + + + + + + + + + + + + + +{% for transaction in transactions %} + + + + + + + + + + + +{% endfor %} +
    ID{% trans "Bill" %}{% trans "Contact" %}IBAN{% trans "Amount" %}{% trans "State" %}{% trans "Created" %}{% trans "Updated" %}
    {{ transaction.id }}{{ transaction.bill.number }}{{ transaction.bill.buyer.get_name }}{{ transaction.source.data.iban }}{{ transaction.amount }}{{ transaction.get_state_display }}{{ transaction.created_at|date }}{% if transaction.created_at|date != transaction.modified_at|date %}{{ transaction.modified_at|date }}{% else %} --- {% endif %}
    + + diff --git a/orchestra/contrib/plans/__init__.py b/orchestra/contrib/plans/__init__.py new file mode 100644 index 0000000..e09642b --- /dev/null +++ b/orchestra/contrib/plans/__init__.py @@ -0,0 +1 @@ +default_app_config = 'orchestra.contrib.plans.apps.PlansConfig' diff --git a/orchestra/contrib/plans/admin.py b/orchestra/contrib/plans/admin.py new file mode 100644 index 0000000..0a9ebac --- /dev/null +++ b/orchestra/contrib/plans/admin.py @@ -0,0 +1,59 @@ +from django.contrib import admin +from django.urls import reverse +from django.db import models +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ + +from orchestra.admin import ExtendedModelAdmin +from orchestra.admin.utils import insertattr, admin_link +from orchestra.contrib.accounts.actions import list_accounts +from orchestra.contrib.accounts.admin import AccountAdminMixin +from orchestra.contrib.services.models import Service + +from .models import Plan, ContractedPlan, Rate + + +class RateInline(admin.TabularInline): + model = Rate + ordering = ('service', 'plan', 'quantity') + + +class PlanAdmin(ExtendedModelAdmin): + list_display = ( + 'name', 'is_default', 'is_combinable', 'allow_multiple', 'is_active', 'num_contracts', + ) + list_filter = ('is_default', 'is_combinable', 'allow_multiple', 'is_active') + fields = ('verbose_name', 'name', 'is_default', 'is_combinable', 'allow_multiple') + prepopulated_fields = { + 'name': ('verbose_name',) + } + change_readonly_fields = ('name',) + inlines = [RateInline] + + def num_contracts(self, plan): + num = plan.contracts__count + url = reverse('admin:plans_contractedplan_changelist') + url += '?plan__name={}'.format(plan.name) + return format_html('{1}', url, num) + num_contracts.short_description = _("Contracts") + num_contracts.admin_order_field = 'contracts__count' + + def get_queryset(self, request): + qs = super(PlanAdmin, self).get_queryset(request) + return qs.annotate(models.Count('contracts', distinct=True)) + + +class ContractedPlanAdmin(AccountAdminMixin, admin.ModelAdmin): + list_display = ('id', 'plan_link', 'account_link') + list_filter = ('plan__name',) + list_select_related = ('plan', 'account') + search_fields = ('account__username', 'plan__name', 'id') + actions = (list_accounts,) + + plan_link = admin_link('plan') + + +admin.site.register(Plan, PlanAdmin) +admin.site.register(ContractedPlan, ContractedPlanAdmin) + +insertattr(Service, 'inlines', RateInline) diff --git a/orchestra/contrib/plans/apps.py b/orchestra/contrib/plans/apps.py new file mode 100644 index 0000000..45d3077 --- /dev/null +++ b/orchestra/contrib/plans/apps.py @@ -0,0 +1,16 @@ +from django.apps import AppConfig + +from orchestra.core import administration, accounts, services +from orchestra.core.translations import ModelTranslation + + +class PlansConfig(AppConfig): + name = 'orchestra.contrib.plans' + verbose_name = 'Plans' + + def ready(self): + from .models import Plan, ContractedPlan + accounts.register(ContractedPlan, icon='ContractedPack.png') + services.register(ContractedPlan, menu=False, dashboard=False) + administration.register(Plan, icon='Pack.png') + ModelTranslation.register(Plan, ('verbose_name',)) diff --git a/orchestra/contrib/plans/models.py b/orchestra/contrib/plans/models.py new file mode 100644 index 0000000..429ecc3 --- /dev/null +++ b/orchestra/contrib/plans/models.py @@ -0,0 +1,104 @@ +from functools import lru_cache + +from django.core.validators import ValidationError +from django.db import models +from django.db.models import Q +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ + +from orchestra.core.validators import validate_name +from orchestra.models import queryset +from orchestra.utils.python import import_class + +from . import settings + + +class Plan(models.Model): + name = models.CharField(_("name"), max_length=32, unique=True, validators=[validate_name]) + verbose_name = models.CharField(_("verbose_name"), max_length=128, blank=True) + is_active = models.BooleanField(_("active"), default=True, + help_text=_("Designates whether this account should be treated as active. " + "Unselect this instead of deleting accounts.")) + is_default = models.BooleanField(_("default"), default=False, + help_text=_("Designates whether this plan is used by default or not.")) + is_combinable = models.BooleanField(_("combinable"), default=True, + help_text=_("Designates whether this plan can be combined with other plans or not.")) + allow_multiple = models.BooleanField(_("allow multiple"), default=False, + help_text=_("Designates whether this plan allow for multiple contractions.")) + + def __str__(self): + return self.get_verbose_name() + + def clean(self): + self.verbose_name = self.verbose_name.strip() + + def get_verbose_name(self): + return self.verbose_name or self.name + + +class ContractedPlan(models.Model): + plan = models.ForeignKey(Plan, on_delete=models.CASCADE, + verbose_name=_("plan"), related_name='contracts') + account = models.ForeignKey('accounts.Account', on_delete=models.CASCADE, + verbose_name=_("account"), related_name='plans') + + class Meta: + verbose_name_plural = _("plans") + + def __str__(self): + return str(self.plan) + + @cached_property + def active(self): + return self.plan.is_active and self.account.is_active + + def clean(self): + if not self.pk and not self.plan.allow_multiple: + if ContractedPlan.objects.filter(plan=self.plan, account=self.account).exists(): + raise ValidationError("A contracted plan for this account already exists.") + + +class RateQuerySet(models.QuerySet): + group_by = queryset.group_by + + def by_account(self, account): + # Default allways selected + return self.filter( + Q(plan__is_default=True) | + Q(plan__contracts__account=account) + ).order_by('plan', 'quantity').select_related('plan', 'service') + + +class Rate(models.Model): + service = models.ForeignKey('services.Service', on_delete=models.CASCADE, + verbose_name=_("service"), related_name='rates') + plan = models.ForeignKey(Plan, on_delete=models.SET_NULL, null=True, blank=True, + verbose_name=_("plan"), related_name='rates') + quantity = models.PositiveIntegerField(_("quantity"), null=True, blank=True, + help_text=_("See rate algorihm help text.")) + price = models.DecimalField(_("price"), max_digits=12, decimal_places=2) + + objects = RateQuerySet.as_manager() + + class Meta: + unique_together = ('service', 'plan', 'quantity') + + def __str__(self): + return "{}-{}".format(str(self.price), self.quantity) + + @classmethod + @lru_cache() + def get_methods(cls): + return dict((method, import_class(method)) for method in settings.PLANS_RATE_METHODS) + + @classmethod + @lru_cache() + def get_choices(cls): + choices = [] + for name, method in cls.get_methods().items(): + choices.append((name, method.verbose_name)) + return choices + + @classmethod + def get_default(cls): + return settings.PLANS_DEFAULT_RATE_METHOD diff --git a/orchestra/contrib/plans/ratings.py b/orchestra/contrib/plans/ratings.py new file mode 100644 index 0000000..1c1cd9e --- /dev/null +++ b/orchestra/contrib/plans/ratings.py @@ -0,0 +1,212 @@ +import sys + +from django.utils.translation import gettext_lazy as _ + +from orchestra.utils.python import AttrDict + + +def _compute_steps(rates, metric): + value = 0 + num = len(rates) + accumulated = 0 + barrier = 1 + next_barrier = None + end = False + ix = 0 + steps = [] + while ix < num and not end: + fold = 1 + # Multiple contractions + while ix < num-1 and rates[ix] == rates[ix+1]: + ix += 1 + fold += 1 + if ix+1 == num: + quantity = metric - accumulated + next_barrier = quantity + else: + quantity = rates[ix+1].quantity - max(rates[ix].quantity, 1) + next_barrier = quantity + if rates[ix+1].price > rates[ix].price: + quantity *= fold + if accumulated+quantity > metric: + quantity = metric - accumulated + end = True + price = rates[ix].price + steps.append(AttrDict(**{ + 'quantity': quantity, + 'price': price, + 'barrier': barrier, + })) + accumulated += quantity + barrier += next_barrier + value += quantity*price + ix += 1 + return value, steps + + +def _standardize(rates): + """ + Support for incomplete rates + When first rate (quantity=5, price=10) defaults to nominal_price + """ + std_rates = [] + minimal = rates[0].quantity + for rate in rates: + #if rate.quantity == 0: + # rate.quantity = 1 + if rate.quantity == minimal and rate.quantity > 0: + service = rate.service + rate_class = type(rate) + std_rates.append( + rate_class(service=service, plan=rate.plan, quantity=0, price=service.nominal_price) + ) + std_rates.append(rate) + return std_rates + + +def step_price(rates, metric): + if rates.query.order_by != ('plan', 'quantity'): + raise ValueError("rates queryset should be ordered by 'plan' and 'quantity'") + # Step price + group = [] + minimal = (sys.maxsize, []) + for plan, rates in rates.group_by('plan').items(): + rates = _standardize(rates) + value, steps = _compute_steps(rates, metric) + if plan.is_combinable: + group.append(steps) + else: + minimal = min(minimal, (value, steps), key=lambda v: v[0]) + if len(group) == 1: + value, steps = _compute_steps(rates, metric) + minimal = min(minimal, (value, steps), key=lambda v: v[0]) + elif len(group) > 1: + # Merge + steps = [] + for rates in group: + steps += rates + steps.sort(key=lambda s: s.price) + result = [] + counter = 0 + value = 0 + ix = 0 + targets = [] + while counter < metric: + barrier = steps[ix].barrier + if barrier <= counter+1: + price = steps[ix].price + quantity = steps[ix].quantity + if quantity + counter > metric: + quantity = metric - counter + else: + for target in targets: + if counter + quantity >= target: + quantity = (counter+quantity+1) - target + steps[ix].quantity -= quantity + if not steps[ix].quantity: + steps.pop(ix) + break + else: + steps.pop(ix) + counter += quantity + value += quantity*price + if result and result[-1].price == price: + result[-1].quantity += quantity + else: + result.append(AttrDict(quantity=quantity, price=price)) + ix = 0 + targets = [] + else: + targets.append(barrier) + ix += 1 + minimal = min(minimal, (value, result), key=lambda v: v[0]) + return minimal[1] +step_price.verbose_name = _("Step price") +step_price.help_text = _("All rates with a quantity lower or equal than the metric are applied. " + "Nominal price will be used when initial block is missing.") + + +def match_price(rates, metric): + if rates.query.order_by != ('plan', 'quantity'): + raise ValueError("rates queryset should be ordered by 'plan' and 'quantity'") + candidates = [] + selected = False + prev = None + rates = _standardize(rates.distinct()) + for rate in rates: + if prev: + if prev.plan != rate.plan: + if not selected and prev.quantity <= metric: + candidates.append(prev) + selected = False + if not selected and rate.quantity > metric: + if prev.quantity <= metric: + candidates.append(prev) + selected = True + prev = rate + if not selected and prev.quantity <= metric: + candidates.append(prev) + candidates.sort(key=lambda r: r.price) + if candidates: + return [AttrDict(**{ + 'quantity': metric, + 'price': candidates[0].price, + })] + return None +match_price.verbose_name = _("Match price") +match_price.help_text = _("Only the rate with a) inmediate inferior metric and b) lower price is applied. " + "Nominal price will be used when initial block is missing.") + + +def best_price(rates, metric): + if rates.query.order_by != ('plan', 'quantity'): + raise ValueError("rates queryset should be ordered by 'plan' and 'quantity'") + candidates = [] + for plan, rates in rates.group_by('plan').items(): + rates = _standardize(rates) + plan_candidates = [] + for rate in rates: + if rate.quantity > metric: + break + if plan_candidates: + ant = plan_candidates[-1] + if ant.price == rate.price: + # Multiple plans support + ant.fold += 1 + else: + ant.quantity = rate.quantity-1 + plan_candidates.append(AttrDict( + price=rate.price, + quantity=metric, + fold=1, + )) + else: + plan_candidates.append(AttrDict( + price=rate.price, + quantity=metric, + fold=1, + )) + candidates.extend(plan_candidates) + results = [] + accumulated = 0 + for candidate in sorted(candidates, key=lambda c: c.price): + if candidate.quantity < accumulated: + # Out of barrier + continue + candidate.quantity *= candidate.fold + if accumulated+candidate.quantity > metric: + quantity = metric - accumulated + else: + quantity = candidate.quantity + accumulated += quantity + if quantity: + if results and results[-1].price == candidate.price: + results[-1].quantity += quantity + else: + results.append(AttrDict(**{ + 'quantity': quantity, + 'price': candidate.price + })) + return results +best_price.verbose_name = _("Best price") +best_price.help_text = _("Produces the best possible price given all active rating lines (those with quantity lower or equal to the metric).") diff --git a/orchestra/contrib/plans/settings.py b/orchestra/contrib/plans/settings.py new file mode 100644 index 0000000..853f6e4 --- /dev/null +++ b/orchestra/contrib/plans/settings.py @@ -0,0 +1,15 @@ +from orchestra.contrib.settings import Setting + + +PLANS_RATE_METHODS = Setting('PLANS_RATE_METHODS', + ( + 'orchestra.contrib.plans.ratings.step_price', + 'orchestra.contrib.plans.ratings.match_price', + 'orchestra.contrib.plans.ratings.best_price', + ) +) + + +PLANS_DEFAULT_RATE_METHOD = Setting('PLANS_DEFAULT_RATE_METHOD', + 'orchestra.contrib.plans.ratings.step_price', +) diff --git a/orchestra/contrib/resources/__init__.py b/orchestra/contrib/resources/__init__.py new file mode 100644 index 0000000..7c273a5 --- /dev/null +++ b/orchestra/contrib/resources/__init__.py @@ -0,0 +1,4 @@ +from .backends import ServiceMonitor + + +default_app_config = 'orchestra.contrib.resources.apps.ResourcesConfig' diff --git a/orchestra/contrib/resources/actions.py b/orchestra/contrib/resources/actions.py new file mode 100644 index 0000000..3120213 --- /dev/null +++ b/orchestra/contrib/resources/actions.py @@ -0,0 +1,47 @@ +from django.urls import reverse +from django.shortcuts import redirect, render +from django.utils.safestring import mark_safe +from django.utils.translation import ngettext, gettext_lazy as _ + + +def run_monitor(modeladmin, request, queryset): + """ Resource and ResourceData run monitors """ + referer = request.META.get('HTTP_REFERER') + run_async = modeladmin.model.monitor.__defaults__[0] + logs = set() + for resource in queryset: + rlogs = resource.monitor() + if not run_async: + logs = logs.union(set([str(log.pk) for log in rlogs])) + modeladmin.log_change(request, resource, _("Run monitors")) + if run_async: + num = len(queryset) + # TODO listfilter by uuid: task.request.id + ?task_id__in=ids + link = reverse('admin:djcelery_taskstate_changelist') + msg = ngettext( + _("One selected resource has been scheduled for monitoring.") % link, + _("%s selected resource have been scheduled for monitoring.") % (num, link), + num) + else: + num = len(logs) + if num == 1: + log_pk = int(logs.pop()) + link = reverse('admin:orchestration_backendlog_change', args=(log_pk,)) + msg = _("One related monitor has been executed.") % link + elif num >= 1: + link = reverse('admin:orchestration_backendlog_changelist') + link += '?id__in=%s' % ','.join(logs) + msg = _("%s related monitors have been executed.") % (num, link) + else: + msg = _("No related monitors have been executed.") + modeladmin.message_user(request, mark_safe(msg)) + if referer: + return redirect(referer) +run_monitor.url_name = 'monitor' + + +def show_history(modeladmin, request, queryset): + context = { + 'ids': ','.join(map(str, queryset.values_list('id', flat=True))), + } + return render(request, 'admin/resources/resourcedata/history.html', context) diff --git a/orchestra/contrib/resources/admin.py b/orchestra/contrib/resources/admin.py new file mode 100644 index 0000000..53f573e --- /dev/null +++ b/orchestra/contrib/resources/admin.py @@ -0,0 +1,356 @@ +from urllib.parse import parse_qs + +from django.apps import apps +from django.urls import re_path as url +from django.contrib import admin, messages +from django.contrib.contenttypes.admin import GenericTabularInline +from django.contrib.contenttypes.forms import BaseGenericInlineFormSet +from django.contrib.admin.utils import unquote +from django.urls import reverse +from django.db.models import Q +from django.shortcuts import redirect +from django.templatetags.static import static +from django.utils.functional import cached_property +from django.utils.html import format_html +from django.utils.safestring import mark_safe +from django.utils.translation import ngettext, gettext_lazy as _ + +from orchestra.admin import ExtendedModelAdmin +from orchestra.admin.utils import insertattr, get_modeladmin, admin_link, admin_date +from orchestra.contrib.orchestration.models import Route +from orchestra.core import services +from orchestra.utils import db, sys +from orchestra.utils.functional import cached + +from .actions import run_monitor, show_history +from .api import history_data +from .filters import ResourceDataListFilter +from .forms import ResourceForm +from .models import Resource, ResourceData, MonitorData + + +class ResourceAdmin(ExtendedModelAdmin): + list_display = ( + 'id', 'verbose_name', 'content_type', 'aggregation', 'on_demand', + 'default_allocation', 'unit', 'crontab', 'is_active' + ) + list_display_links = ('id', 'verbose_name') + list_editable = ('default_allocation', 'crontab', 'is_active',) + list_filter = ( + ('content_type', admin.RelatedOnlyFieldListFilter), 'aggregation', 'on_demand', + 'disable_trigger' + ) + fieldsets = ( + (None, { + 'fields': ('verbose_name', 'name', 'content_type', 'aggregation'), + }), + (_("Configuration"), { + 'fields': ('unit', 'scale', 'on_demand', 'default_allocation', 'disable_trigger', + 'is_active'), + }), + (_("Monitoring"), { + 'fields': ('monitors', 'crontab'), + }), + ) + actions = (run_monitor,) + change_view_actions = actions + change_readonly_fields = ('name', 'content_type') + prepopulated_fields = { + 'name': ('verbose_name',) + } + list_select_related = ('content_type', 'crontab',) + + def change_view(self, request, object_id, form_url='', extra_context=None): + """ Remaind user when monitor routes are not configured """ + if request.method == 'GET': + resource = self.get_object(request, unquote(object_id)) + backends = Route.objects.values_list('backend', flat=True) + not_routed = [] + for monitor in resource.monitors: + if monitor not in backends: + not_routed.append(monitor) + if not_routed: + messages.warning(request, ngettext( + _("%(not_routed)s monitor doesn't have any configured route."), + _("%(not_routed)s monitors don't have any configured route."), + len(not_routed), + ) % { + 'not_routed': ', '.join(not_routed) + }) + return super(ResourceAdmin, self).change_view(request, object_id, form_url=form_url, + extra_context=extra_context) + + def save_model(self, request, obj, form, change): + super(ResourceAdmin, self).save_model(request, obj, form, change) + # best-effort + model = obj.content_type.model_class() + modeladmin = type(get_modeladmin(model)) + resources = obj.content_type.resource_set.filter(is_active=True) + inlines = [] + for inline in modeladmin.inlines: + if inline.model is ResourceData: + inline = resource_inline_factory(resources) + inlines.append(inline) + modeladmin.inlines = inlines + # reload Not always work + sys.touch_wsgi() + + def formfield_for_dbfield(self, db_field, **kwargs): + """ filter service content_types """ + if db_field.name == 'content_type': + models = [ model._meta.model_name for model in services.get() ] + kwargs['queryset'] = db_field.remote_field.model.objects.filter(model__in=models) + return super(ResourceAdmin, self).formfield_for_dbfield(db_field, **kwargs) + + +def content_object_link(data): + ct = data.content_type + url = reverse('admin:%s_%s_change' % (ct.app_label, ct.model), args=(data.object_id,)) + return format_html('{}', url, data.content_object_repr) +content_object_link.short_description = _("Content object") +content_object_link.admin_order_field = 'content_object_repr' + + +class ResourceDataAdmin(ExtendedModelAdmin): + list_display = ( + 'id', 'resource_link', content_object_link, 'allocated', 'display_used', + 'display_updated' + ) + list_filter = ('resource',) + fields = ( + 'resource_link', 'content_type', content_object_link, 'display_updated', 'display_used', + 'allocated', + ) + search_fields = ('content_object_repr',) + readonly_fields = fields + actions = (run_monitor, show_history) + change_view_actions = actions + ordering = ('-updated_at',) + list_select_related = ('resource__content_type', 'content_type') + + resource_link = admin_link('resource') + display_updated = admin_date('updated_at', short_description=_("Updated")) + + def get_urls(self): + """Returns the additional urls for the change view links""" + urls = super(ResourceDataAdmin, self).get_urls() + admin_site = self.admin_site + opts = self.model._meta + return [ + url('^(\d+)/used-monitordata/$', + admin_site.admin_view(self.used_monitordata_view), + name='%s_%s_used_monitordata' % (opts.app_label, opts.model_name) + ), + url('^history_data/$', + admin_site.admin_view(history_data), + name='%s_%s_history_data' % (opts.app_label, opts.model_name) + ), + url('^list-related/(.+)/(.+)/(\d+)/$', + admin_site.admin_view(self.list_related_view), + name='%s_%s_list_related' % (opts.app_label, opts.model_name) + ), + ] + urls + + def display_used(self, rdata): + if rdata.used is None: + return '' + url = reverse('admin:resources_resourcedata_used_monitordata', args=(rdata.pk,)) + return format_html('{} {}', url, rdata.used, rdata.unit) + display_used.short_description = _("Used") + display_used.admin_order_field = 'used' + + def has_add_permission(self, *args, **kwargs): + return False + + def used_monitordata_view(self, request, object_id): + url = reverse('admin:resources_monitordata_changelist') + url += '?resource_data=%s' % object_id + return redirect(url) + + def list_related_view(self, request, app_name, model_name, object_id): + resources = Resource.objects.select_related('content_type') + resource_models = {r.content_type.model_class(): r.content_type_id for r in resources} + # Self + model = apps.get_model(app_name, model_name) + obj = model.objects.get(id=int(object_id)) + ct_id = resource_models[model] + qset = Q(content_type_id=ct_id, object_id=obj.id, resource__is_active=True) + # Related + for field, rel in obj._meta.fields_map.items(): + try: + ct_id = resource_models[rel.related_model] + except KeyError: + pass + else: + manager = getattr(obj, field) + ids = manager.values_list('id', flat=True) + qset = Q(qset) | Q(content_type_id=ct_id, object_id__in=ids, resource__is_active=True) + related = ResourceData.objects.filter(qset) + related_ids = related.values_list('id', flat=True) + related_ids = ','.join(map(str, related_ids)) + url = reverse('admin:resources_resourcedata_changelist') + url += '?id__in=%s' % related_ids + return redirect(url) + + +class MonitorDataAdmin(ExtendedModelAdmin): + list_display = ('id', 'monitor', content_object_link, 'display_created', 'value') + list_filter = ('monitor', ResourceDataListFilter) + add_fields = ('monitor', 'content_type', 'object_id', 'created_at', 'value') + fields = ('monitor', 'content_type', content_object_link, 'display_created', 'value', 'state') + change_readonly_fields = fields + list_select_related = ('content_type',) + search_fields = ('content_object_repr',) + date_hierarchy = 'created_at' + + display_created = admin_date('created_at', short_description=_("Created")) + + def filter_used_monitordata(self, request, queryset): + query_string = parse_qs(request.META['QUERY_STRING']) + resource_data = query_string.get('resource_data') + if resource_data: + mdata = ResourceData.objects.get(pk=int(resource_data[0])) + resource = mdata.resource + ids = [] + for monitor, dataset in mdata.get_monitor_datasets(): + dataset = resource.aggregation_instance.filter(dataset) + if isinstance(dataset, MonitorData): + ids.append(dataset.id) + else: + ids += dataset.values_list('id', flat=True) + return queryset.filter(id__in=ids) + return queryset + + def get_queryset(self, request): + queryset = super(MonitorDataAdmin, self).get_queryset(request) + queryset = self.filter_used_monitordata(request, queryset) + return queryset.prefetch_related('content_object') + + +admin.site.register(Resource, ResourceAdmin) +admin.site.register(ResourceData, ResourceDataAdmin) +admin.site.register(MonitorData, MonitorDataAdmin) + + +# Mokey-patching + +def resource_inline_factory(resources): + class ResourceInlineFormSet(BaseGenericInlineFormSet): + def total_form_count(self, resources=resources): + return len(resources) + + @cached + def get_queryset(self): + """ Filter disabled resources """ + queryset = super(ResourceInlineFormSet, self).get_queryset() + return queryset.filter(resource__is_active=True).select_related('resource') + + @cached_property + def forms(self, resources=resources): + forms = [] + resources_copy = list(resources) + # Remove queryset disabled objects + queryset = [rdata for rdata in self.get_queryset() if rdata.resource in resources] + if self.instance.pk: + # Create missing resource data + queryset_resources = [rdata.resource for rdata in queryset] + for resource in resources: + if resource not in queryset_resources: + kwargs = { + 'content_object': self.instance, + 'content_object_repr': str(self.instance), + } + if resource.default_allocation: + kwargs['allocated'] = resource.default_allocation + rdata = resource.dataset.create(**kwargs) + queryset.append(rdata) + # Existing dataset + for i, rdata in enumerate(queryset): + forms.append(self._construct_form(i, resource=rdata.resource)) + try: + resources_copy.remove(rdata.resource) + except ValueError: + pass + # Missing dataset + for i, resource in enumerate(resources_copy, len(queryset)): + forms.append(self._construct_form(i, resource=resource)) + return forms + + class ResourceInline(GenericTabularInline): + model = ResourceData + verbose_name_plural = _("resources") + form = ResourceForm + formset = ResourceInlineFormSet + can_delete = False + fields = ( + 'verbose_name', 'display_used', 'display_updated', 'allocated', 'unit', + ) + readonly_fields = ('display_used', 'display_updated',) + + class Media: + css = { + 'all': ('orchestra/css/hide-inline-id.css',) + } + + display_updated = admin_date('updated_at', default=_("Never")) + + def get_fieldsets(self, request, obj=None): + if obj: + opts = self.parent_model._meta + url = reverse('admin:resources_resourcedata_list_related', + args=(opts.app_label, opts.model_name, obj.id)) + link = '%s' % (url, _("List related")) + self.verbose_name_plural = mark_safe(_("Resources") + ' ' + link) + return super(ResourceInline, self).get_fieldsets(request, obj) + + @mark_safe + def display_used(self, rdata): + update = '' + history = '' + if rdata.pk: + context = { + 'title': _("Update"), + 'url': reverse('admin:resources_resourcedata_monitor', args=(rdata.pk,)), + 'image': '' % static('orchestra/images/reload.png'), + } + update = '%(image)s' % context + context.update({ + 'title': _("Show history"), + 'image': '' % static('orchestra/images/history.png'), + 'url': reverse('admin:resources_resourcedata_show_history', args=(rdata.pk,)), + 'popup': 'onclick="return showAddAnotherPopup(this);"', + }) + history = '%(image)s' % context + if rdata.used is not None: + used_url = reverse('admin:resources_resourcedata_used_monitordata', args=(rdata.pk,)) + used = '%s %s' % (used_url, rdata.used, rdata.unit) + return ' '.join(map(str, (used, update, history))) + if rdata.resource.monitors: + return _("Unknonw %s %s") % (update, history) + return _("No monitor") + display_used.short_description = _("Used") + + def has_add_permission(self, *args, **kwargs): + """ Hidde add another """ + return False + + return ResourceInline + + +def insert_resource_inlines(): + # Clean previous state + for related in Resource._related: + modeladmin = get_modeladmin(related) + modeladmin_class = type(modeladmin) + for inline in getattr(modeladmin_class, 'inlines', []): + if inline.__name__ == 'ResourceInline': + modeladmin_class.inlines.remove(inline) + resources = Resource.objects.filter(is_active=True) + for ct, resources in resources.group_by('content_type').items(): + inline = resource_inline_factory(resources) + model = ct.model_class() + insertattr(model, 'inlines', inline) + + +if db.database_ready(): + insert_resource_inlines() diff --git a/orchestra/contrib/resources/aggregations.py b/orchestra/contrib/resources/aggregations.py new file mode 100644 index 0000000..f43e7ff --- /dev/null +++ b/orchestra/contrib/resources/aggregations.py @@ -0,0 +1,169 @@ +import datetime +import decimal +import itertools + +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from orchestra.utils.python import AttrDict + +from orchestra import plugins + + +class Aggregation(plugins.Plugin, metaclass=plugins.PluginMount): + """ filters and computes dataset usage """ + aggregated_history = False + + def filter(self, dataset): + """ Filter the dataset to get the relevant data according to the period """ + raise NotImplementedError + + def compute_usage(self, dataset): + """ given a dataset computes its usage according to the method (avg, sum, ...) """ + raise NotImplementedError + + def aggregate_history(self, dataset): + raise NotImplementedError + + +class Last(Aggregation): + """ Sum of the last value of all monitors """ + name = 'last' + verbose_name = _("Last value") + + def filter(self, dataset, date=None): + dataset = dataset.order_by('object_id', '-id').distinct('monitor') + if date is not None: + dataset = dataset.filter(created_at__lte=date) + return dataset + + def compute_usage(self, dataset): + values = dataset.values_list('value', flat=True) + if values: + return sum(values) + return None + + def aggregate_history(self, dataset): + prev_object_id = None + prev_object_repr = None + for mdata in dataset.order_by('object_id', 'created_at'): + object_id = mdata.object_id + if object_id != prev_object_id: + if prev_object_id is not None: + yield (prev_object_repr, datas) + datas = [mdata] + else: + datas.append(mdata) + prev_object_id = object_id + prev_object_repr = mdata.content_object_repr + if prev_object_id is not None: + yield (prev_object_repr, datas) + + +class MonthlySum(Last): + """ Monthly sum the values of all monitors """ + name = 'monthly-sum' + verbose_name = _("Monthly Sum") + aggregated_history = True + + def filter(self, dataset, date=None): + if date is None: + date = timezone.now().date() + return dataset.filter( + created_at__year=date.year, + created_at__month=date.month, + ) + + def aggregate_history(self, dataset): + prev = None + prev_object_id = None + datas = [] + sink = AttrDict(object_id=-1, value=-1, content_object_repr='', + created_at=AttrDict(year=-1, month=-1)) + for mdata in itertools.chain(dataset.order_by('object_id', 'created_at'), [sink]): + object_id = mdata.object_id + ymonth = (mdata.created_at.year, mdata.created_at.month) + if object_id != prev_object_id or ymonth != prev.ymonth: + if prev_object_id is not None: + data = AttrDict( + date=datetime.date( + year=prev.ymonth[0], + month=prev.ymonth[1], + day=1 + ), + value=current, + content_object_repr=prev.content_object_repr + ) + datas.append(data) + current = mdata.value + else: + current += mdata.value + if object_id != prev_object_id: + if prev_object_id is not None: + yield (prev.content_object_repr, datas) + datas = [] + prev = mdata + prev.ymonth = ymonth + prev_object_id = object_id + + +class MonthlyAvg(MonthlySum): + """ sum of the monthly averages of each monitor """ + name = 'monthly-avg' + verbose_name = _("Monthly AVG") + aggregated_history = False + + def get_epoch(self, date=None): + if date is None: + date = timezone.now().date() + return datetime.date( + year=date.year, + month=date.month, + day=1, + ) + + def compute_usage(self, dataset): + result = 0 + has_result = False + aggregate = [] + for object_id, dataset in dataset.order_by('created_at').group_by('object_id').items(): + try: + last = dataset[-1] + except IndexError: + continue + epoch = self.get_epoch(date=last.created_at) + total = (last.created_at-epoch).total_seconds() + ini = epoch + current = 0 + for mdata in dataset: + has_result = True + slot = (mdata.created_at-ini).total_seconds() + current += mdata.value * decimal.Decimal(str(slot/total)) + ini = mdata.created_at + else: + result += current + if has_result: + return result + return None + + def aggregate_history(self, dataset): + yield from super(MonthlySum, self).aggregate_history(dataset) + + +class Last10DaysAvg(MonthlyAvg): + """ sum of the last 10 days averages of each monitor """ + name = 'last-10-days-avg' + verbose_name = _("Last 10 days AVG") + days = 10 + + def get_epoch(self, date=None): + if date is None: + date = timezone.now().date() + return date - datetime.timedelta(days=self.days) + + def filter(self, dataset, date=None): + epoch = self.get_epoch(date=date) + dataset = dataset.filter(created_at__gt=epoch) + if date is not None: + dataset = dataset.filter(created_at__lte=date) + return dataset diff --git a/orchestra/contrib/resources/api.py b/orchestra/contrib/resources/api.py new file mode 100644 index 0000000..9f89fb1 --- /dev/null +++ b/orchestra/contrib/resources/api.py @@ -0,0 +1,15 @@ +import json +from urllib.parse import parse_qs + +from django.http import HttpResponse + +from .helpers import get_history_data +from .models import ResourceData + + +def history_data(request): + ids = map(int, parse_qs(request.META['QUERY_STRING'])['ids'][0].split(',')) + queryset = ResourceData.objects.filter(id__in=ids) + history = get_history_data(queryset) + response = json.dumps(history, indent=4) + return HttpResponse(response, content_type="application/json") diff --git a/orchestra/contrib/resources/apps.py b/orchestra/contrib/resources/apps.py new file mode 100644 index 0000000..1b0c90e --- /dev/null +++ b/orchestra/contrib/resources/apps.py @@ -0,0 +1,32 @@ +from django import db +from django.apps import AppConfig + +from orchestra.core import administration +from orchestra.utils.db import database_ready + + +class ResourcesConfig(AppConfig): + name = 'orchestra.contrib.resources' + verbose_name = 'Resources' + + def ready(self): + if database_ready(): + from .models import create_resource_relation + try: + create_resource_relation() + except db.utils.OperationalError: + # Not ready afterall + pass + from .models import Resource, ResourceData, MonitorData + administration.register(Resource, icon='gauge.png') + administration.register(ResourceData, parent=Resource, icon='monitor.png') + administration.register(MonitorData, parent=Resource, dashboard=False) + from . import signals + + def reload_relations(self): + from .admin import insert_resource_inlines + from .models import create_resource_relation + from .serializers import insert_resource_serializers + insert_resource_inlines() + insert_resource_serializers() + create_resource_relation() diff --git a/orchestra/contrib/resources/backends.py b/orchestra/contrib/resources/backends.py new file mode 100644 index 0000000..a997df5 --- /dev/null +++ b/orchestra/contrib/resources/backends.py @@ -0,0 +1,100 @@ +import datetime + +from django.utils import timezone +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.orchestration import ServiceBackend + +from . import helpers + + +class ServiceMonitor(ServiceBackend): + TRAFFIC = 'traffic' + DISK = 'disk' + MEMORY = 'memory' + CPU = 'cpu' + # TODO UNITS + actions = ('monitor', 'exceeded', 'recovery') + abstract = True + delete_old_equal_values = False + monthly_sum_old_values = False + + @classmethod + def get_plugins(cls): + """ filter controller classes """ + return [ + plugin for plugin in cls.plugins if issubclass(plugin, ServiceMonitor) + ] + + @classmethod + def get_verbose_name(cls): + return _("[M] %s") % super(ServiceMonitor, cls).get_verbose_name() + + @cached_property + def current_date(self): + return timezone.now() + + @cached_property + def content_type(self): + from django.contrib.contenttypes.models import ContentType + app_label, model = self.model.split('.') + model = model.lower() + return ContentType.objects.get_by_natural_key(app_label, model) + + def get_last_data(self, object_id): + from .models import MonitorData + try: + return MonitorData.objects.filter(content_type=self.content_type, + monitor=self.get_name(), object_id=object_id).latest() + except MonitorData.DoesNotExist: + return None + + def get_last_date(self, object_id): + data = self.get_last_data(object_id) + if data is None: + return self.current_date - datetime.timedelta(days=1) + return data.created_at + + def process(self, line): + """ line -> object_id, value, state""" + result = line.split() + if len(result) != 2: + cls_name = self.__class__.__name__ + raise ValueError("%s expected ' ' got '%s'" % (cls_name, line)) + # State is None, unless your monitor needs to keep track of it + result.append(None) + return result + + def store(self, log): + """ stores monitored values from stdout """ + from django.contrib.contenttypes.models import ContentType + from .models import MonitorData + name = self.get_name() + app_label, model_name = self.model.split('.') + ct = ContentType.objects.get_by_natural_key(app_label, model_name.lower()) + for line in log.stdout.splitlines(): + line = line.strip() + object_id, value, state = self.process(line) + if isinstance(value, bytes): + value = value.decode('ascii') + if isinstance(state, bytes): + state = state.decode('ascii') + content_object = ct.get_object_for_this_type(pk=object_id) + MonitorData.objects.create( + monitor=name, object_id=object_id, content_type=ct, value=value, state=state, + created_at=self.current_date, content_object_repr=str(content_object), + ) + + def execute(self, *args, **kwargs): + log = super(ServiceMonitor, self).execute(*args, **kwargs) + if log.state == log.SUCCESS: + self.store(log) + return log + + @classmethod + def aggregate(cls, dataset): + if cls.delete_old_equal_values: + return helpers.delete_old_equal_values(dataset) + elif cls.monthly_sum_old_values: + return helpers.monthly_sum_old_values(dataset) diff --git a/orchestra/contrib/resources/filters.py b/orchestra/contrib/resources/filters.py new file mode 100644 index 0000000..fe8392c --- /dev/null +++ b/orchestra/contrib/resources/filters.py @@ -0,0 +1,17 @@ +from django.contrib.admin import SimpleListFilter +from django.utils.translation import gettext_lazy as _ + + +class ResourceDataListFilter(SimpleListFilter): + """ Mock filter to avoid e=1 """ + title = _("Resource data") + parameter_name = 'resource_data' + + def lookups(self, request, model_admin): + return () + + def queryset(self, request, queryset): + return queryset + + def choices(self, cl): + return [] diff --git a/orchestra/contrib/resources/forms.py b/orchestra/contrib/resources/forms.py new file mode 100644 index 0000000..75d5b85 --- /dev/null +++ b/orchestra/contrib/resources/forms.py @@ -0,0 +1,44 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + +from orchestra.forms import ReadOnlyFormMixin +from orchestra.forms.widgets import SpanWidget + + +class ResourceForm(ReadOnlyFormMixin, forms.ModelForm): + verbose_name = forms.CharField(label=_("Name"), required=False, + widget=SpanWidget(tag='')) + allocated = forms.DecimalField(label=_("Allocated")) + unit = forms.CharField(label=_("Unit"), required=False) + + class Meta: + fields = ('verbose_name', 'used', 'last_update', 'allocated', 'unit') + readonly_fields = ('verbose_name', 'unit') + + def __init__(self, *args, **kwargs): + self.resource = kwargs.pop('resource', None) + if self.resource: + initial = kwargs.get('initial', {}) + initial.update({ + 'verbose_name': self.resource.get_verbose_name(), + 'unit': self.resource.unit, + }) + kwargs['initial'] = initial + super(ResourceForm, self).__init__(*args, **kwargs) + if self.resource: + if self.resource.on_demand: + self.fields['allocated'].required = False + self.fields['allocated'].widget = SpanWidget(original=None, display='') + else: + self.fields['allocated'].required = True + self.fields['allocated'].initial = self.resource.default_allocation + +# def has_changed(self): +# """ Make sure resourcedata objects are created for all resources """ +# if not self.instance.pk: +# return True +# return super(ResourceForm, self).has_changed() + + def save(self, *args, **kwargs): + self.instance.resource_id = self.resource.pk + return super(ResourceForm, self).save(*args, **kwargs) diff --git a/orchestra/contrib/resources/helpers.py b/orchestra/contrib/resources/helpers.py new file mode 100644 index 0000000..d0d4987 --- /dev/null +++ b/orchestra/contrib/resources/helpers.py @@ -0,0 +1,134 @@ +import decimal + +from django.template.defaultfilters import date as date_format + + +def get_history_data(queryset): + resources = {} + needs_aggregation = False + for rdata in queryset: + resource = rdata.resource + try: + (options, aggregation) = resources[resource] + except KeyError: + aggregation = resource.aggregation_instance + options = { + 'aggregation': str(aggregation.verbose_name), + 'aggregated_history': aggregation.aggregated_history, + 'content_type': rdata.content_type.model, + 'content_object': rdata.content_object_repr, + 'unit': resource.unit, + 'scale': resource.get_scale(), + 'verbose_name': str(resource.verbose_name), + 'dates': set() if aggregation.aggregated_history else None, + 'objects': [], + } + resources[resource] = (options, aggregation) + if aggregation.aggregated_history: + needs_aggregation = True + monitors = [] + scale = options['scale'] + all_dates = options['dates'] + for monitor_name, dataset in rdata.get_monitor_datasets(): + datasets = {} + for content_object, datas in aggregation.aggregate_history(dataset): + if aggregation.aggregated_history: + serie = {} + for data in datas: + value = round(float(data.value)/scale, 3) if data.value is not None else None + all_dates.add(data.date) + serie[data.date] = value + else: + serie = [] + for data in datas: + date = data.created_at.timestamp() + date = int(str(date).split('.')[0] + '000') + value = round(float(data.value)/scale, 3) if data.value is not None else None + serie.append( + (date, value) + ) + datasets[content_object] = serie + monitors.append({ + 'name': monitor_name, + 'datasets': datasets, + }) + options['objects'].append({ + 'object_name': rdata.content_object_repr, + 'current': round(float(rdata.used or 0), 3), + 'allocated': float(rdata.allocated) if rdata.allocated is not None else None, + 'updated_at': rdata.updated_at.isoformat() if rdata.updated_at else None, + 'monitors': monitors, + }) + if needs_aggregation: + result = [] + for options, aggregation in resources.values(): + if aggregation.aggregated_history: + all_dates = sorted(options['dates']) + options['dates'] = [date_format(date) for date in all_dates] + for obj in options['objects']: + for monitor in obj['monitors']: + series = [] + for content_object, dataset in monitor['datasets'].items(): + data = [] + for date in all_dates: + data.append(dataset.get(date, 0.0)) + series.append({ + 'name': content_object, + 'data': data, + }) + monitor['datasets'] = series + result.append(options) + else: + result = [resource[0] for resource in resources.values()] + return result + + +def delete_old_equal_values(dataset): + """ only first and last values of an equal serie (+-error) are kept """ + prev_value = None + prev_key = None + delete_count = 0 + error = decimal.Decimal('0.005') + third = False + for mdata in dataset.order_by('content_type_id', 'object_id', 'created_at'): + key = (mdata.content_type_id, mdata.object_id) + if prev_key == key: + if prev_value is not None and mdata.value*(1-error) < prev_value < mdata.value*(1+error): + if third: + prev.delete() + delete_count += 1 + else: + third = True + else: + third = False + prev_value = mdata.value + prev_key = key + else: + prev_value = None + prev_key = key + prev = mdata + return delete_count + + +def monthly_sum_old_values(dataset): + aggregated = 0 + prev_key = None + prev = None + to_delete = [] + delete_count = 0 + for mdata in dataset.order_by('content_type_id', 'object_id', 'created_at'): + key = (mdata.content_type_id, mdata.object_id, mdata.created_at.year, mdata.created_at.month) + if prev_key is not None and prev_key != key: + if prev.value != aggregated: + prev.value = aggregated + prev.save(update_fields=('value',)) + for obj in to_delete[:-1]: + obj.delete() + delete_count += 1 + aggregated = 0 + to_delete = [] + prev = mdata + prev_key = key + aggregated += mdata.value + to_delete.append(mdata) + return delete_count diff --git a/orchestra/contrib/resources/models.py b/orchestra/contrib/resources/models.py new file mode 100644 index 0000000..7b1e0ff --- /dev/null +++ b/orchestra/contrib/resources/models.py @@ -0,0 +1,353 @@ +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType +from django.apps import apps +from django.db import models +from django.utils import timezone +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ +from djcelery.models import PeriodicTask + +from orchestra.core import validators +from orchestra.models import queryset, fields +from orchestra.models.utils import get_model_field_path + +from . import tasks +from .backends import ServiceMonitor +from .aggregations import Aggregation +from .validators import validate_scale + + +class ResourceQuerySet(models.QuerySet): + group_by = queryset.group_by + + +class Resource(models.Model): + """ + Defines a resource, a resource is basically an interpretation of data + gathered by a Monitor + """ + + LAST = 'LAST' + MONTHLY_SUM = 'MONTHLY_SUM' + MONTHLY_AVG = 'MONTHLY_AVG' + PERIODS = ( + (LAST, _("Last")), + (MONTHLY_SUM, _("Monthly sum")), + (MONTHLY_AVG, _("Monthly avg")), + ) + _related = set() # keeps track of related models for resource cleanup + + name = models.CharField(_("name"), max_length=32, + help_text=_("Required. 32 characters or fewer. Lowercase letters, " + "digits and hyphen only."), + validators=[validators.validate_name]) + verbose_name = models.CharField(_("verbose name"), max_length=256) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, + help_text=_("Model where this resource will be hooked.")) + aggregation = models.CharField(_("aggregation"), max_length=16, + choices=Aggregation.get_choices(), default=Aggregation.get_choices()[0][0], + help_text=_("Method used for aggregating this resource monitored data.")) + on_demand = models.BooleanField(_("on demand"), default=False, + help_text=_("If enabled the resource will not be pre-allocated, " + "but allocated under the application demand")) + default_allocation = models.PositiveIntegerField(_("default allocation"), + null=True, blank=True, + help_text=_("Default allocation value used when this is not an " + "on demand resource")) + unit = models.CharField(_("unit"), max_length=16, + help_text=_("The unit in which this resource is represented. " + "For example GB, KB or subscribers")) + scale = models.CharField(_("scale"), max_length=32, validators=[validate_scale], + help_text=_("Scale in which this resource monitoring resoults should " + "be prorcessed to match with unit. e.g. 10**9")) + disable_trigger = models.BooleanField(_("disable trigger"), default=True, + help_text=_("Disables monitors exeeded and recovery triggers")) + crontab = models.ForeignKey('djcelery.CrontabSchedule', verbose_name=_("crontab"), + null=True, blank=True, on_delete=models.SET_NULL, + help_text=_("Crontab for periodic execution. " + "Leave it empty to disable periodic monitoring")) + monitors = fields.MultiSelectField(_("monitors"), max_length=256, blank=True, + choices=ServiceMonitor.get_choices(), + help_text=_("Monitor backends used for monitoring this resource.")) + is_active = models.BooleanField(_("active"), default=True) + + objects = ResourceQuerySet.as_manager() + + class Meta: + unique_together = ( + ('name', 'content_type'), + ('verbose_name', 'content_type') + ) + + def __str__(self): + return "%s-%s" % (self.content_type, self.name) + + @cached_property + def aggregation_class(self): + return Aggregation.get(self.aggregation) + + @cached_property + def aggregation_instance(self): + """ Per request lived type_instance """ + return self.aggregation_class(self) + + def clean(self): + self.verbose_name = self.verbose_name.strip() + if self.on_demand and self.default_allocation: + raise validators.ValidationError({ + 'default_allocation': _("Default allocation can not be set for 'on demand' services") + }) + # Validate that model path exists between ct and each monitor.model + monitor_errors = [] + for monitor in self.monitors: + try: + self.get_model_path(monitor) + except (RuntimeError, LookupError): + model = apps.get_model(ServiceMonitor.get_backend(monitor).model) + monitor_errors.append(model._meta.model_name) + if monitor_errors: + model_name = self.content_type.model_class()._meta.model_name + raise validators.ValidationError({ + 'monitors': [ + _("Path does not exists between '%s' and '%s'") % ( + error, + model_name, + ) for error in monitor_errors + ]}) + + def save(self, *args, **kwargs): + super(Resource, self).save(*args, **kwargs) + # This only works on tests (multiprocessing used on real deployments) + apps.get_app_config('resources').reload_relations() + + def sync_periodic_task(self, delete=False): + """ sync periodic task on save/delete resource operations """ + name = 'monitor.%s' % self + if delete or not self.crontab or not self.is_active: + PeriodicTask.objects.filter(name=name).delete() + elif self.pk: + try: + task = PeriodicTask.objects.get(name=name) + except PeriodicTask.DoesNotExist: + if self.is_active: + PeriodicTask.objects.create( + name=name, + task='resources.Monitor', + args=[self.pk], + crontab=self.crontab + ) + else: + if task.crontab != self.crontab: + task.crontab = self.crontab + task.save(update_fields=['crontab']) + + def get_model_path(self, monitor): + """ returns a model path between self.content_type and monitor.model """ + resource_model = self.content_type.model_class() + monitor_model = ServiceMonitor.get_backend(monitor).model_class() + return get_model_field_path(monitor_model, resource_model) + + def get_scale(self): + return eval(self.scale) + + def get_verbose_name(self): + return self.verbose_name or self.name + + def monitor(self, run_async=True): + if run_async: + return tasks.monitor.apply_async(self.pk) + return tasks.monitor(self.pk) + + +class ResourceDataQuerySet(models.QuerySet): + def get_or_create(self, obj, resource): + ct = ContentType.objects.get_for_model(type(obj)) + try: + return self.get( + content_type=ct, + object_id=obj.pk, + resource=resource + ), False + except self.model.DoesNotExist: + return self.create( + content_object=obj, + resource=resource, + allocated=resource.default_allocation + ), True + + +class ResourceData(models.Model): + """ Stores computed resource usage and allocation """ + resource = models.ForeignKey(Resource, on_delete=models.CASCADE, related_name='dataset', verbose_name=_("resource")) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, verbose_name=_("content type")) + object_id = models.PositiveIntegerField(_("object id")) + used = models.DecimalField(_("used"), max_digits=16, decimal_places=3, null=True, + editable=False) + updated_at = models.DateTimeField(_("updated"), null=True, editable=False) + allocated = models.PositiveIntegerField(_("allocated"), null=True, blank=True) + content_object_repr = models.CharField(_("content object representation"), max_length=256, + editable=False) + + content_object = GenericForeignKey() + objects = ResourceDataQuerySet.as_manager() + + class Meta: + unique_together = ('resource', 'content_type', 'object_id') + verbose_name_plural = _("resource data") + index_together = ( + ('content_type', 'object_id'), + ) + + def __str__(self): + return "%s: %s" % (self.resource, self.content_object) + + @property + def unit(self): + return self.resource.unit + + @property + def verbose_name(self): + return self.resource.verbose_name + + def get_used(self): + resource = self.resource + total = 0 + has_result = False + for monitor, dataset in self.get_monitor_datasets(): + dataset = resource.aggregation_instance.filter(dataset) + usage = resource.aggregation_instance.compute_usage(dataset) + if usage is not None: + has_result = True + total += usage + return float(total)/resource.get_scale() if has_result else None + + def update(self, current=None): + if current is None: + current = self.get_used() + self.used = current or 0 + self.updated_at = timezone.now() + self.content_object_repr = str(self.content_object) + self.save(update_fields=('used', 'updated_at', 'content_object_repr')) + + def monitor(self, run_async=False): + ids = (self.object_id,) + if run_async: + return tasks.monitor.delay(self.resource_id, ids=ids) + return tasks.monitor(self.resource_id, ids=ids) + + def get_monitor_datasets(self): + resource = self.resource + for monitor in resource.monitors: + path = resource.get_model_path(monitor) + if path == []: + dataset = MonitorData.objects.filter( + monitor=monitor, + content_type=self.content_type_id, + object_id=self.object_id, + ) + else: + fields = '__'.join(path) + monitor_model = ServiceMonitor.get_backend(monitor).model_class() + objects = monitor_model.objects.filter(**{fields: self.object_id}) + pks = objects.values_list('id', flat=True) + ct = ContentType.objects.get_for_model(monitor_model) + dataset = MonitorData.objects.filter( + monitor=monitor, + content_type=ct, + object_id__in=pks, + ) + yield monitor, dataset + + +class MonitorDataQuerySet(models.QuerySet): + group_by = queryset.group_by + + +class MonitorData(models.Model): + """ Stores monitored data """ + monitor = models.CharField(_("monitor"), max_length=256, db_index=True, + choices=ServiceMonitor.get_choices()) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, verbose_name=_("content type")) + object_id = models.PositiveIntegerField(_("object id")) + created_at = models.DateTimeField(_("created"), default=timezone.now, db_index=True) + value = models.DecimalField(_("value"), max_digits=16, decimal_places=2) + state = models.DecimalField(_("state"), max_digits=16, decimal_places=2, null=True, + help_text=_("Optional field used to store current state needed for diff-based monitoring.")) + content_object_repr = models.CharField(_("content object representation"), max_length=256, + editable=False) + + content_object = GenericForeignKey() + objects = MonitorDataQuerySet.as_manager() + + class Meta: + get_latest_by = 'id' + verbose_name_plural = _("monitor data") + index_together = ( + ('content_type', 'object_id'), + ) + + def __str__(self): + return str(self.monitor) + + @cached_property + def unit(self): + return self.resource.unit + + +def create_resource_relation(): + class ResourceHandler(object): + """ account.resources.web """ + def __getattr__(self, attr): + """ get or build ResourceData """ + if attr.startswith('_'): + raise AttributeError + try: + return self.obj.__resource_cache[attr] + except AttributeError: + self.obj.__resource_cache = {} + except KeyError: + pass + try: + rdata = self.obj.resource_set.get(resource__name=attr) + except ResourceData.DoesNotExist: + model = self.obj._meta.model_name + resource = Resource.objects.get( + content_type__model=model, + name=attr, + is_active=True + ) + rdata = ResourceData( + content_object=self.obj, + content_object_repr=str(self.obj), + resource=resource, + allocated=resource.default_allocation + ) + self.obj.__resource_cache[attr] = rdata + return rdata + + def __get__(self, obj, cls): + """ proxy handled object """ + self.obj = obj + return self + + def __iter__(self): + return iter(self.obj.resource_set.all()) + + # Clean previous state + for related in Resource._related: + try: + delattr(related, 'resource_set') + delattr(related, 'resources') + except AttributeError: + pass + else: + related._meta.private_fields = [ + field for field in related._meta.private_fields if field.remote_field.model != ResourceData + ] + + for ct, resources in Resource.objects.group_by('content_type').items(): + model = ct.model_class() + relation = GenericRelation('resources.ResourceData') + model.add_to_class('resource_set', relation) + model.resources = ResourceHandler() + Resource._related.add(model) diff --git a/orchestra/contrib/resources/serializers.py b/orchestra/contrib/resources/serializers.py new file mode 100644 index 0000000..140674e --- /dev/null +++ b/orchestra/contrib/resources/serializers.py @@ -0,0 +1,93 @@ +from rest_framework import serializers + +from orchestra.api import router +from orchestra.utils.db import database_ready + +from .models import Resource, ResourceData + + +class ResourceSerializer(serializers.ModelSerializer): + name = serializers.SerializerMethodField() + unit = serializers.ReadOnlyField() + + class Meta: + model = ResourceData + fields = ('name', 'used', 'allocated', 'unit') + read_only_fields = ('used',) + + def to_internal_value(self, raw_data): + data = super(ResourceSerializer, self).to_internal_value(raw_data) + if not data.resource_id: + data.resource = Resource.objects.get(name=raw_data['name']) + return data + + def get_name(self, instance): + return instance.resource.name + + def get_identity(self, data): + return data.get('name') + + +# Monkey-patching section + +def insert_resource_serializers(): + # clean previous state + for related in Resource._related: + try: + viewset = router.get_viewset(related) + except KeyError: + # API viewset not registered + pass + else: + fields = list(viewset.serializer_class.Meta.fields) + try: + fields.remove('resources') + except ValueError: + pass + viewset.serializer_class.Meta.fields = fields + # Create nested serializers on target models + for ct, resources in Resource.objects.group_by('content_type').items(): + model = ct.model_class() + try: + router.insert(model, 'resources', ResourceSerializer, required=False, many=True, source='resource_set') + except KeyError: + continue + # TODO this is a fucking workaround, reimplement this on the proper place + def validate_resources(self, posted, _resources=resources): + """ Creates missing resources """ + result = [] + resources = list(_resources) + for data in posted: + resource = data.resource + if resource not in resources: + msg = "Unknown or duplicated resource '%s'." % resource + raise serializers.ValidationError(msg) + resources.remove(resource) + if not resource.on_demand and not data.allocated: + data.allocated = resource.default_allocation + result.append(data) + for resource in resources: + data = ResourceData(resource=resource) + if not resource.on_demand: + data.allocated = resource.default_allocation + result.append(data) + return result + viewset = router.get_viewset(model) + viewset.serializer_class.validate_resources = validate_resources + + old_options = viewset.options + def options(self, request, resources=resources): + """ Provides available resources description """ + metadata = old_options(self, request) + metadata.data['available_resources'] = [ + { + 'name': resource.name, + 'on_demand': resource.on_demand, + 'default_allocation': resource.default_allocation + } for resource in resources + ] + return metadata + viewset.options = options + +if database_ready(): + insert_resource_serializers() diff --git a/orchestra/contrib/resources/settings.py b/orchestra/contrib/resources/settings.py new file mode 100644 index 0000000..be4ac62 --- /dev/null +++ b/orchestra/contrib/resources/settings.py @@ -0,0 +1,6 @@ +from orchestra.contrib.settings import Setting + + +RESOURCES_OLD_MONITOR_DATA_DAYS = Setting('RESOURCES_OLD_MONITOR_DATA_DAYS', + 40, +) diff --git a/orchestra/contrib/resources/signals.py b/orchestra/contrib/resources/signals.py new file mode 100644 index 0000000..6ce7376 --- /dev/null +++ b/orchestra/contrib/resources/signals.py @@ -0,0 +1,18 @@ +from django.db.models.signals import post_delete, post_save +from django.dispatch import receiver + +from .models import Resource + + +@receiver(post_save, sender=Resource, dispatch_uid="resources.sync_periodic_task") +def sync_periodic_task(sender, **kwargs): + """ useing signals instead of Model.delete() override beucause of admin bulk delete() """ + instance = kwargs['instance'] + instance.sync_periodic_task() + + +@receiver(post_delete, sender=Resource, dispatch_uid="resources.delete_periodic_task") +def delete_periodic_task(sender, **kwargs): + """ useing signals instead of Model.delete() override beucause of admin bulk delete() """ + instance = kwargs['instance'] + instance.sync_periodic_task(delete=True) diff --git a/orchestra/contrib/resources/tasks.py b/orchestra/contrib/resources/tasks.py new file mode 100644 index 0000000..10f8072 --- /dev/null +++ b/orchestra/contrib/resources/tasks.py @@ -0,0 +1,77 @@ +import datetime + +from celery.task.schedules import crontab +from django.db import transaction +from django.utils import timezone + +from orchestra.contrib.orchestration import Operation +from orchestra.contrib.tasks import task, periodic_task +from orchestra.models.utils import get_model_field_path +from orchestra.utils.sys import LockFile + +from . import settings +from .backends import ServiceMonitor + + +@task(name='resources.Monitor') +def monitor(resource_id, ids=None): + with LockFile('/dev/shm/resources.monitor-%i.lock' % resource_id, expire=60*60, unlocked=bool(ids)): + from .models import ResourceData, Resource + resource = Resource.objects.get(pk=resource_id) + resource_model = resource.content_type.model_class() + logs = [] + # Execute monitors + for monitor_name in resource.monitors: + backend = ServiceMonitor.get_backend(monitor_name) + model = backend.model_class() + kwargs = {} + if ids: + path = get_model_field_path(model, resource_model) + path = '%s__in' % ('__'.join(path) or 'id') + kwargs = { + path: ids + } + # Execute monitor + monitorings = [] + for obj in model.objects.filter(**kwargs): + op = Operation(backend, obj, Operation.MONITOR) + monitorings.append(op) + logs += Operation.execute(monitorings, run_async=False) + + kwargs = {'id__in': ids} if ids else {} + # Update used resources and trigger resource exceeded and revovery + triggers = [] + model = resource.content_type.model_class() + for obj in model.objects.filter(**kwargs): + data, __ = ResourceData.objects.get_or_create(obj, resource) + data.update() + if not resource.disable_trigger: + a = data.used + b = data.allocated + if data.used > (data.allocated or 0): + op = Operation(backend, obj, Operation.EXCEEDED) + triggers.append(op) + elif data.used < (data.allocated or 0): + op = Operation(backend, obj, Operation.RECOVERY) + triggers.append(op) + Operation.execute(triggers) + return logs + + +@periodic_task(run_every=crontab(hour=2, minute=30), name='resources.cleanup_old_monitors') +@transaction.atomic +def cleanup_old_monitors(queryset=None): + if queryset is None: + from .models import MonitorData + queryset = MonitorData.objects.all() + delta = datetime.timedelta(days=settings.RESOURCES_OLD_MONITOR_DATA_DAYS) + threshold = timezone.now() - delta + queryset = queryset.filter(created_at__lt=threshold) + delete_counts = [] + for monitor in ServiceMonitor.get_plugins(): + dataset = queryset.filter(monitor=monitor) + delete_count = monitor.aggregate(dataset) + delete_counts.append( + (monitor.get_name(), delete_count) + ) + return delete_counts diff --git a/orchestra/contrib/resources/templates/admin/resources/resourcedata/history.html b/orchestra/contrib/resources/templates/admin/resources/resourcedata/history.html new file mode 100644 index 0000000..e98eb69 --- /dev/null +++ b/orchestra/contrib/resources/templates/admin/resources/resourcedata/history.html @@ -0,0 +1,251 @@ +{% load i18n utils static %} + + + + Resource history + + + + + + + + + +
    ♦Notice that resources used by deleted services will not appear.
    +
    +
    + > crunching data ... +
    +
    + + diff --git a/orchestra/contrib/resources/validators.py b/orchestra/contrib/resources/validators.py new file mode 100644 index 0000000..710fb5f --- /dev/null +++ b/orchestra/contrib/resources/validators.py @@ -0,0 +1,11 @@ +from django.core.validators import ValidationError +from django.utils.translation import gettext_lazy as _ + + +def validate_scale(value): + try: + int(eval(value)) + except Exception as e: + raise ValidationError( + _("'%s' is not a valid scale expression. (%s)") % (value, e) + ) diff --git a/orchestra/contrib/saas/README.md b/orchestra/contrib/saas/README.md new file mode 100644 index 0000000..46fcb88 --- /dev/null +++ b/orchestra/contrib/saas/README.md @@ -0,0 +1,90 @@ + + +# SaaS - Software as a Service + + +This app provides support for services that follow the SaaS model. Traditionally known as multi-site or multi-tenant web applications where a single installation of a CMS provides accounts for multiple isolated tenants. + + +## Service declaration + +Each service is defined by a `SoftwareService` subclass, you can find examples on the [`services` module](services). + +The minimal service declaration will be: + +```python +class DrupalService(SoftwareService): + name = 'drupal' + verbose_name = "Drupal" + icon = 'orchestra/icons/apps/Drupal.png' + site_domain = settings.SAAS_MOODLE_DOMAIN +``` + +Additional attributes can be used to further customize the service class to your needs. + +### Custom forms +If a service needs to keep track of additional information (other than a user/site name, is_active, custom_url, or database) an extra form and serializer should be provided. For example, WordPress requires to provide an *email address* for account creation, and the assigned *blog ID* is required for effectively identify the account for update and delete operations. In this case we provide two forms, one for account creation and another for change: + +```python +class WordPressForm(SaaSBaseForm): + email = forms.EmailField(label=_("Email"), + help_text=_("A new user will be created if the above email address is not in the database.
    " + "The username and password will be mailed to this email address.")) + +class WordPressChangeForm(WordPressForm): + blog_id = forms.IntegerField(label=("Blog ID"), widget=widgets.SpanWidget, required=False, + help_text=_("ID of this blog used by WordPress, the only attribute that doesn't change.")) +``` + +`WordPressForm` provides the email field, and `WordPressChangeForm` adds the `blog_id` on top of it. `blog_id` will be represented as a *readonly* field on the form (`widget=widgets.SpanWidget`), so no modification will be allowed. + +Additionally, `SaaSPasswordForm` provides a password field for the common case when a password needs to be provided in order to create a new account. You can subclass `SaaSPasswordForm` or use it directly on the `Service.form` field. + + +### Serializer for extra data + +In case we need to save extra information of the service (email and blog_id in our current example) we should provide a serializer that serializes this bits of information into JSON format so they can be saved and retrieved from the database data field. + +```python +class WordPressDataSerializer(serializers.Serializer): + email = serializers.EmailField(label=_("Email")) + blog_id = serializers.IntegerField(label=_("Blog ID"), allow_null=True, required=False) +``` + +Now we have everything needed for declaring the WordPress service. + +```python +class WordPressService(SoftwareService): + name = 'wordpress' + verbose_name = "WordPress" + form = WordPressForm + change_form = WordPressChangeForm + serializer = WordPressDataSerializer + icon = 'orchestra/icons/apps/WordPress.png' + change_readonly_fields = ('email', 'blog_id') + site_domain = settings.SAAS_WORDPRESS_DOMAIN + allow_custom_url = settings.SAAS_WORDPRESS_ALLOW_CUSTOM_URL +``` + +Notice that two optional forms can be provided `form` and `change_form`. When non of them is provided, SaaS will provide a default one for you. When only `form` is provided, it will be used for both, *add view* and *change view*. If both are provided, `form` will be used for the *add view* and `change_form` for the *change view*. This last option allows us to display the `blog_id` back to the user, only when we know its value (after creation). + +`change_readonly_fields` is a tuple with the name of the fields that can **not** be edited once the service has been created. + +`allow_custom_url` is a boolean flag that defines whether this service is allowed to have custom URL's (URL of any form) or not. In case it does, additional steps are required for interfacing with `orchestra.contrib.websites`, such as having an enabled website directive (`WEBSITES_ENABLED_DIRECTIVES`) that knows where the SaaS webapp is running, such as `'orchestra.contrib.websites.directives.WordPressSaaS'`. + + +## Backend +A backend class is required to interface with the web application and perform `save()` and `delete()` operations on it. + +- The more reliable way of interfacing with the application is by means of a CLI (e.g. [Moodle](backends/moodle.py)), but not all CMS come with this tool. +- The second preferable way is using some sort of networked API, possibly HTTP-based (e.g. [gitLab](backends/gitlab.py)). This is less reliable because additional moving parts are used underneath the interface; a busy web server can timeout our requests. +- The least preferred way is interfacing with an HTTP-HTML interface designed for human consumption, really painful to implement but sometimes is the only way (e.g. [WordPress](backends/wordpressmu.py)). + +Some applications do not support multi-tenancy by default, but we can hack the configuration file of such apps and generate *table prefix* or *database name* based on some property of the URL. Example of this services are [moodle](backends/moodle.py) and [phplist](backends/phplist.py) respectively. + + +## Settings + +Enabled services should be added into the `SAAS_ENABLED_SERVICES` settings tuple, providing its full module path, e.g. `'orchestra.contrib.saas.services.moodle.MoodleService'`. + +Parameters that should allow easy configuration on each deployment should be defined as settings. e.g. `SAAS_WORDPRESS_DOMAIN`. Take a look at the [`settings` module](settings.py). diff --git a/orchestra/contrib/saas/__init__.py b/orchestra/contrib/saas/__init__.py new file mode 100644 index 0000000..0bc8af8 --- /dev/null +++ b/orchestra/contrib/saas/__init__.py @@ -0,0 +1 @@ +default_app_config = 'orchestra.contrib.saas.apps.SaaSConfig' diff --git a/orchestra/contrib/saas/admin.py b/orchestra/contrib/saas/admin.py new file mode 100644 index 0000000..90176d8 --- /dev/null +++ b/orchestra/contrib/saas/admin.py @@ -0,0 +1,60 @@ +from django.contrib import admin +from django.core.exceptions import ObjectDoesNotExist +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 disable, enable +from orchestra.admin.utils import change_url +from orchestra.contrib.accounts.actions import list_accounts +from orchestra.contrib.accounts.admin import AccountAdminMixin +from orchestra.contrib.accounts.filters import IsActiveListFilter +from orchestra.plugins.admin import SelectPluginAdminMixin +from orchestra.utils.apps import isinstalled +from orchestra.utils.html import get_on_site_link + +from .filters import CustomURLListFilter +from .models import SaaS +from .services import SoftwareService + + +class SaaSAdmin(SelectPluginAdminMixin, ChangePasswordAdminMixin, AccountAdminMixin, ExtendedModelAdmin): + list_display = ('name', 'service', 'display_url', 'account_link', 'display_active') + list_filter = ('service', IsActiveListFilter, CustomURLListFilter) + search_fields = ('name', 'account__username') + change_readonly_fields = ('service',) + plugin = SoftwareService + plugin_field = 'service' + plugin_title = 'Software as a Service' + actions = (disable, enable, list_accounts) + + @mark_safe + def display_url(self, saas): + site_domain = saas.get_site_domain() + site_link = '%s' % (site_domain, site_domain) + links = [site_link] + if saas.custom_url and isinstalled('orchestra.contrib.websites'): + try: + website = saas.service_instance.get_website() + except ObjectDoesNotExist: + warning = _("Related website directive does not exist for this custom URL.") + link = '%s' % (warning, saas.custom_url) + else: + website_link = get_on_site_link(saas.custom_url) + admin_url = change_url(website) + link = '%s %s' % ( + admin_url, saas.custom_url, website_link + ) + links.append(link) + return '
    '.join(links) + display_url.short_description = _("URL") + display_url.admin_order_field = 'name' + + def get_fields(self, *args, **kwargs): + fields = super(SaaSAdmin, self).get_fields(*args, **kwargs) + if not self.plugin_instance.allow_custom_url: + return [field for field in fields if field != 'custom_url'] + return fields + + +admin.site.register(SaaS, SaaSAdmin) diff --git a/orchestra/contrib/saas/api.py b/orchestra/contrib/saas/api.py new file mode 100644 index 0000000..de226b3 --- /dev/null +++ b/orchestra/contrib/saas/api.py @@ -0,0 +1,16 @@ +from rest_framework import viewsets + +from orchestra.api import router, LogApiMixin +from orchestra.contrib.accounts.api import AccountApiMixin + +from .models import SaaS +from .serializers import SaaSSerializer + + +class SaaSViewSet(LogApiMixin, AccountApiMixin, viewsets.ModelViewSet): + queryset = SaaS.objects.all() + serializer_class = SaaSSerializer + filter_fields = ('name',) + + +router.register(r'saas', SaaSViewSet) diff --git a/orchestra/contrib/saas/apps.py b/orchestra/contrib/saas/apps.py new file mode 100644 index 0000000..8ad3f8c --- /dev/null +++ b/orchestra/contrib/saas/apps.py @@ -0,0 +1,13 @@ +from django.apps import AppConfig + +from orchestra.core import services + + +class SaaSConfig(AppConfig): + name = 'orchestra.contrib.saas' + verbose_name = 'Saas' + + def ready(self): + from . import signals + from .models import SaaS + services.register(SaaS, icon='saas.png') diff --git a/orchestra/contrib/saas/backends/__init__.py b/orchestra/contrib/saas/backends/__init__.py new file mode 100644 index 0000000..b1c0797 --- /dev/null +++ b/orchestra/contrib/saas/backends/__init__.py @@ -0,0 +1,132 @@ +import pkgutil +import textwrap + +from orchestra.contrib.resources import ServiceMonitor + +from .. import settings + + +class ApacheTrafficByHost(ServiceMonitor): + """ + Parses apache logs, + looking for the size of each request on the last word of the log line. + + Compatible log format: + LogFormat "%h %l %u %t \"%r\" %>s %O %{Host}i" host + or if include_received_bytes: + LogFormat "%h %l %u %t \"%r\" %>s %I %O %{Host}i" host + CustomLog /home/pangea/logs/apache/host_blog.pangea.org.log host + """ + model = 'saas.SaaS' + script_executable = '/usr/bin/python' + monthly_sum_old_values = True + abstract = True + include_received_bytes = False + + def prepare(self): + access_log = self.log_path + context = { + 'access_logs': str((access_log, access_log+'.1')), + 'current_date': self.current_date.strftime("%Y-%m-%d %H:%M:%S %Z"), + 'ignore_hosts': str(settings.SAAS_TRAFFIC_IGNORE_HOSTS), + 'include_received_bytes': str(self.include_received_bytes), + } + self.append(textwrap.dedent("""\ + import sys + from datetime import datetime + from dateutil import tz + + def to_local_timezone(date, tzlocal=tz.tzlocal()): + date = datetime.strptime(date, '%Y-%m-%d %H:%M:%S %Z') + date = date.replace(tzinfo=tz.tzutc()) + date = date.astimezone(tzlocal) + return date + + # Use local timezone + end_date = to_local_timezone('{current_date}') + end_date = int(end_date.strftime('%Y%m%d%H%M%S')) + access_logs = {access_logs} + sites = {{}} + months = {{ + 'Jan': '01', + 'Feb': '02', + 'Mar': '03', + 'Apr': '04', + 'May': '05', + 'Jun': '06', + 'Jul': '07', + 'Aug': '08', + 'Sep': '09', + 'Oct': '10', + 'Nov': '11', + 'Dec': '12', + }} + + def prepare(object_id, site_domain, ini_date): + global sites + ini_date = to_local_timezone(ini_date) + ini_date = int(ini_date.strftime('%Y%m%d%H%M%S')) + sites[site_domain] = [ini_date, object_id, 0] + + def monitor(sites, end_date, months, access_logs): + include_received = {include_received_bytes} + for access_log in access_logs: + try: + with open(access_log, 'r') as handler: + for line in handler.readlines(): + line = line.split() + host, __, __, date = line[:4] + if host in {ignore_hosts}: + continue + size, hostname = line[-2:] + size = int(size) + if include_received: + size += int(line[-3]) + try: + site = sites[hostname] + except KeyError: + continue + else: + # [16/Sep/2015:11:40:38 + day, month, date = date[1:].split('/') + year, hour, min, sec = date.split(':') + date = year + months[month] + day + hour + min + sec + if site[0] < int(date) < end_date: + site[2] += size + except IOError as e: + sys.stderr.write(str(e)+'\\n') + for opts in sites.values(): + ini_date, object_id, size = opts + sys.stdout.write('%s %s\\n' % (object_id, size)) + """).format(**context) + ) + + def monitor(self, saas): + context = self.get_context(saas) + self.append("prepare(%(object_id)s, '%(site_domain)s', '%(last_date)s')" % context) + + def commit(self): + self.append('monitor(sites, end_date, months, access_logs)') + + def get_context(self, saas): + return { + 'site_domain': saas.get_site_domain(), + 'last_date': self.get_last_date(saas.pk).strftime("%Y-%m-%d %H:%M:%S %Z"), + 'object_id': saas.pk, + } + + +class ApacheTrafficByName(ApacheTrafficByHost): + __doc__ = ApacheTrafficByHost.__doc__ + + def get_context(self, saas): + return { + 'site_domain': saas.name, + 'last_date': self.get_last_date(saas.pk).strftime("%Y-%m-%d %H:%M:%S %Z"), + 'object_id': saas.pk, + } + + +for __, module_name, __ in pkgutil.walk_packages(__path__): + # sorry for the exec(), but Import module function fails :( + exec('from . import %s' % module_name) diff --git a/orchestra/contrib/saas/backends/bscw.py b/orchestra/contrib/saas/backends/bscw.py new file mode 100644 index 0000000..0d0a115 --- /dev/null +++ b/orchestra/contrib/saas/backends/bscw.py @@ -0,0 +1,59 @@ +import textwrap + +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.orchestration import ServiceController, replace + +from .. import settings + + +class BSCWController(ServiceController): + verbose_name = _("BSCW SaaS") + model = 'saas.SaaS' + default_route_match = "saas.service == 'bscw'" + actions = ('save', 'delete', 'validate_creation') + doc_settings = (settings, + ('SAAS_BSCW_BSADMIN_PATH',) + ) + + def validate_creation(self, saas): + context = self.get_context(saas) + self.append(textwrap.dedent("""\ + if [[ $(%(bsadmin)s register %(email)s) ]]; then + echo 'ValidationError: email-exists' + fi + if [[ $(%(bsadmin)s users -n %(username)s) ]]; then + echo 'ValidationError: user-exists' + fi""") % context + ) + + def save(self, saas): + context = self.get_context(saas) + if hasattr(saas, 'password'): + self.append(textwrap.dedent("""\ + if [[ ! $(%(bsadmin)s register %(email)s) && ! $(%(bsadmin)s users -n %(username)s) ]]; then + # Create new user + %(bsadmin)s register -r %(email)s %(username)s '%(password)s' + else + # Change password + %(bsadmin)s chpwd %(username)s '%(password)s' + fi + """) % context + ) + elif saas.active: + self.append("%(bsadmin)s chpwd -u %(username)s" % context) + else: + self.append("%(bsadmin)s chpwd -l %(username)s" % context) + + def delete(self, saas): + context = self.get_context(saas) + self.append("%(bsadmin)s rmuser -n %(username)s" % context) + + def get_context(self, saas): + context = { + 'bsadmin': settings.SAAS_BSCW_BSADMIN_PATH, + 'email': saas.data.get('email'), + 'username': saas.name, + 'password': getattr(saas, 'password', None), + } + return replace(context, "'", '"') diff --git a/orchestra/contrib/saas/backends/dokuwikimu.py b/orchestra/contrib/saas/backends/dokuwikimu.py new file mode 100644 index 0000000..21a4d44 --- /dev/null +++ b/orchestra/contrib/saas/backends/dokuwikimu.py @@ -0,0 +1,115 @@ +import crypt +import os +import textwrap +from urllib.parse import urlparse + +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.orchestration import ServiceController +from orchestra.utils.python import random_ascii + +from . import ApacheTrafficByHost +from .. import settings + + +class DokuWikiMuController(ServiceController): + """ + Creates a DokuWiki site on a DokuWiki multisite installation. + """ + name = 'dokuwiki' + verbose_name = _("DokuWiki multisite") + model = 'saas.SaaS' + default_route_match = "saas.service == 'dokuwiki'" + doc_settings = (settings, ( + 'SAAS_DOKUWIKI_TEMPLATE_PATH', + 'SAAS_DOKUWIKI_FARM_PATH', + 'SAAS_DOKUWIKI_USER', + 'SAAS_DOKUWIKI_GROUP', + )) + + def save(self, saas): + context = self.get_context(saas) + self.append(textwrap.dedent(""" + if [[ ! -e %(app_path)s ]]; then + mkdir %(app_path)s + tar xfz %(template)s -C %(app_path)s + chown -R %(user)s:%(group)s %(app_path)s + fi""") % context + ) + if context['password']: + self.append(textwrap.dedent("""\ + if grep '^admin:' %(users_path)s > /dev/null; then + sed -i 's#^admin:.*$#admin:%(password)s:admin:%(email)s:admin,user#' %(users_path)s + else + echo 'admin:%(password)s:admin:%(email)s:admin,user' >> %(users_path)s + fi""") % context + ) + self.append(textwrap.dedent("""\ + # Update custom domain link + find %(farm_path)s \\ + -maxdepth 1 \\ + -type l \\ + -exec bash -c ' + if [[ $(readlink {}) == "%(domain)s" && $(basename {}) != "%(custom_domain)s" ]]; then + rm {} + fi' \;\ + """) % context + ) + if context['custom_domain']: + self.append(textwrap.dedent("""\ + if [[ ! -e %(farm_path)s/%(custom_domain)s ]]; then + ln -s %(domain)s %(farm_path)s/%(custom_domain)s + chown -h %(user)s:%(group) %(farm_path)s/%(custom_domain)s + fi""") % context + ) + + def delete(self, saas): + context = self.get_context(saas) + self.append("rm -fr %(app_path)s" % context) + self.append(textwrap.dedent("""\ + # Delete custom domain link + find %(farm_path)s \\ + -maxdepth 1 \\ + -type l \\ + -exec bash -c ' + if [[ $(readlink {}) == "%(domain)s" ]]; then + rm {} + fi' \;\ + """) % context + ) + + def get_context(self, saas): + context = super(DokuWikiMuController, self).get_context(saas) + domain = saas.get_site_domain() + context.update({ + 'template': settings.SAAS_DOKUWIKI_TEMPLATE_PATH, + 'farm_path': os.path.normpath(settings.SAAS_DOKUWIKI_FARM_PATH), + 'app_path': os.path.join(settings.SAAS_DOKUWIKI_FARM_PATH, domain), + 'user': settings.SAAS_DOKUWIKI_USER, + 'group': settings.SAAS_DOKUWIKI_GROUP, + 'email': saas.account.email, + 'custom_url': saas.custom_url, + 'domain': domain, + }) + if saas.custom_url: + custom_url = urlparse(saas.custom_url) + context.update({ + 'custom_domain': custom_url.netloc, + }) + password = getattr(saas, 'password', None) + salt = random_ascii(8) + context.update({ + 'password': crypt.crypt(password, '$1$'+salt) if password else None, + 'users_path': os.path.join(context['app_path'], 'conf/users.auth.php'), + }) + return context + + +class DokuWikiMuTraffic(ApacheTrafficByHost): + __doc__ = ApacheTrafficByHost.__doc__ + verbose_name = _("DokuWiki MU Traffic") + default_route_match = "saas.service == 'dokuwiki'" + doc_settings = (settings, + ('SAAS_TRAFFIC_IGNORE_HOSTS', 'SAAS_DOKUWIKI_LOG_PATH') + ) + log_path = settings.SAAS_DOKUWIKI_LOG_PATH diff --git a/orchestra/contrib/saas/backends/drupalmu.py b/orchestra/contrib/saas/backends/drupalmu.py new file mode 100644 index 0000000..944903c --- /dev/null +++ b/orchestra/contrib/saas/backends/drupalmu.py @@ -0,0 +1,46 @@ +import os +import textwrap + +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.orchestration import ServiceController, replace + +from .. import settings + + +class DrupalMuController(ServiceController): + """ + Creates a Drupal site on a Drupal multisite installation + """ + verbose_name = _("Drupal multisite") + model = 'saas.SaaS' + default_route_match = "saas.service == 'drupal'" + doc_settings = (settings, + ('SAAS_DRUPAL_SITES_PATH',) + ) + + def save(self, webapp): + context = self.get_context(webapp) + # TODO set password + self.append(textwrap.dedent("""\ + mkdir %(drupal_path)s + chown -R www-data %(drupal_path)s + + # the following assumes settings.php to be previously configured + REGEX='^\s*$databases\[.default.\]\[.default.\]\[.prefix.\]' + CONFIG='$databases[\'default\'][\'default\'][\'prefix\'] = \'%(app_name)s_\';' + if ! grep $REGEX %(drupal_settings)s > /dev/null; then + echo $CONFIG >> %(drupal_settings)s + fi""") % context + ) + + def delete(self, webapp): + context = self.get_context(webapp) + # TODO delete tables + self.append("rm -fr %(app_path)s" % context) + + def get_context(self, webapp): + context = super(DrupalMuController, self).get_context(webapp) + context['drupal_path'] = settings.SAAS_DRUPAL_SITES_PATH % context + context['drupal_settings'] = os.path.join(context['drupal_path'], 'settings.php') + return replace(context, "'", '"') diff --git a/orchestra/contrib/saas/backends/gitlab.py b/orchestra/contrib/saas/backends/gitlab.py new file mode 100644 index 0000000..042c82a --- /dev/null +++ b/orchestra/contrib/saas/backends/gitlab.py @@ -0,0 +1,119 @@ +import json + +import requests +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.orchestration import ServiceController + +from .. import settings + + +class GitLabSaaSController(ServiceController): + verbose_name = _("GitLab SaaS") + model = 'saas.SaaS' + default_route_match = "saas.service == 'gitlab'" + serialize = True + actions = ('save', 'delete', 'validate_creation') + doc_settings = (settings, + ('SAAS_GITLAB_DOMAIN', 'SAAS_GITLAB_ROOT_PASSWORD', 'SAAS_GITLAB_VERIFY_SSL'), + ) + verify = settings.SAAS_GITLAB_VERIFY_SSL + + def get_base_url(self): + return 'https://%s/api/v3' % settings.SAAS_GITLAB_DOMAIN + + def get_user_url(self, saas): + user_id = saas.data['user_id'] + return self.get_base_url() + '/users/%i' % user_id + + 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 response.json() + + def authenticate(self): + login_url = self.get_base_url() + '/session' + data = { + 'login': 'root', + 'password': settings.SAAS_GITLAB_ROOT_PASSWORD, + } + response = requests.post(login_url, data=data, verify=self.verify) + session = self.validate_response(response, 201) + token = session['private_token'] + self.headers = { + 'PRIVATE-TOKEN': token, + } + + def create_user(self, saas, server): + self.authenticate() + user_url = self.get_base_url() + '/users' + data = { + 'email': saas.data['email'], + 'password': saas.password, + 'username': saas.name, + 'name': saas.account.get_full_name(), + } + response = requests.post(user_url, data=data, headers=self.headers, verify=self.verify) + user = self.validate_response(response, 201) + saas.data['user_id'] = user['id'] + # Using queryset update to avoid triggering backends with the post_save signal + type(saas).objects.filter(pk=saas.pk).update(data=saas.data) + print(json.dumps(user, indent=4)) + + def change_password(self, saas, server): + self.authenticate() + user_url = self.get_user_url(saas) + response = requests.get(user_url, headers=self.headers, verify=self.verify) + user = self.validate_response(response, 200) + user = response.json() + user['password'] = saas.password + response = requests.put(user_url, data=user, headers=self.headers, verify=self.verify) + user = self.validate_response(response, 200) + print(json.dumps(user, indent=4)) + + def set_state(self, saas, server): + # TODO http://feedback.gitlab.com/forums/176466-general/suggestions/4098632-add-administrative-api-call-to-block-users + return + self.authenticate() + user_url = self.get_user_url(saas) + response = requests.get(user_url, headers=self.headers, verify=self.verify) + user = self.validate_response(response, 200) + user['state'] = 'active' if saas.active else 'blocked', + response = requests.patch(user_url, data=user, headers=self.headers, verify=self.verify) + user = self.validate_response(response, 200) + print(json.dumps(user, indent=4)) + + def delete_user(self, saas, server): + self.authenticate() + user_url = self.get_user_url(saas) + response = requests.delete(user_url, headers=self.headers, verify=self.verify) + user = self.validate_response(response, 200, 404) + print(json.dumps(user, indent=4)) + + def _validate_creation(self, saas, server): + """ checks if a saas object is valid for creation on the server side """ + self.authenticate() + username = saas.name + email = saas.data['email'] + users_url = self.get_base_url() + '/users/' + response = requests.get(users_url, headers=self.headers, verify=self.verify) + users = response.json() + for user in users: + if user['username'] == username: + print('ValidationError: user-exists') + if user['email'] == email: + print('ValidationError: email-exists') + + def validate_creation(self, saas): + self.append(self._validate_creation, saas) + + def save(self, saas): + if hasattr(saas, 'password'): + if saas.data.get('user_id', None): + self.append(self.change_password, saas) + else: + self.append(self.create_user, saas) + self.append(self.set_state, saas) + + def delete(self, saas): + self.append(self.delete_user, saas) diff --git a/orchestra/contrib/saas/backends/moodle.py b/orchestra/contrib/saas/backends/moodle.py new file mode 100644 index 0000000..d942675 --- /dev/null +++ b/orchestra/contrib/saas/backends/moodle.py @@ -0,0 +1,170 @@ +import textwrap +from urllib.parse import urlparse + +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.orchestration import ServiceController + +from .. import settings + + +class MoodleMuController(ServiceController): + """ + Creates a Moodle site on a Moodle multisite installation + + // config.php + // map custom domains to sites + $site_map = array( + // "" => ["", ""], + ); + + $site = getenv("SITE"); + if ( $site == '' ) { + $http_host = $_SERVER['HTTP_HOST']; + if (array_key_exists($http_host, $site_map)) { + $site = $site_map[$http_host][0]; + $wwwroot = $site_map[$http_host][1]; + } elseif (strpos($http_host, '-courses.') !== false) { + $site = array_shift((explode("-courses.", $http_host))); + $wwwroot = "https://{$site}-courses.pangea.org"; + } else { + $site = array_shift((explode(".", $http_host))); + $wwwroot = "https://{$site}-courses.pangea.org"; + } + } else { + $wwwroot = "https://{$site}-courses.pangea.org"; + foreach ($site_map as $key => $value) { + if ($value[0] == $site) { + $wwwroot = $value[1]; + break; + } + } + } + + $prefix = str_replace('-', '_', $site); + $CFG->prefix = "${prefix}_"; + $CFG->wwwroot = $wwwroot; + $CFG->dataroot = "/home/pangea/moodledata/{$site}/"; + """ + verbose_name = _("Moodle multisite") + model = 'saas.SaaS' + default_route_match = "saas.service == 'moodle'" + + def save(self, webapp): + context = self.get_context(webapp) + self.delete_site_map(context) + if context['custom_url']: + self.insert_site_map(context) + self.append(textwrap.dedent("""\ + mkdir -p %(moodledata_path)s + chown %(user)s:%(user)s %(moodledata_path)s + export SITE=%(site_name)s + CHANGE_PASSWORD=0 + # TODO su moodle user + php %(moodle_path)s/admin/cli/install_database.php \\ + --fullname="%(site_name)s" \\ + --shortname="%(site_name)s" \\ + --adminpass="%(password)s" \\ + --adminemail="%(email)s" \\ + --non-interactive \\ + --agree-license \\ + --allow-unstable || CHANGE_PASSWORD=1 + """) % context + ) + if context['password']: + self.append(textwrap.dedent("""\ + mysql \\ + --host="%(db_host)s" \\ + --user="%(db_user)s" \\ + --password="%(db_pass)s" \\ + --execute='UPDATE %(db_prefix)s_user + SET password=MD5("%(password)s") + WHERE username="admin";' \\ + %(db_name)s + """) % context + ) + if context['crontab']: + context['escaped_crontab'] = context['crontab'].replace('$', '\\$') + self.append(textwrap.dedent("""\ + # Configuring Moodle crontabs + if ! crontab -u %(user)s -l | grep 'Moodle:"%(site_name)s"' > /dev/null; then + cat << EOF | su - %(user)s --shell /bin/bash -c 'crontab' + $(crontab -u %(user)s -l) + + # %(banner)s - Moodle:"%(site_name)s" + %(escaped_crontab)s + EOF + fi""") % context + ) + + def delete_site_map(self, context): + self.append(textwrap.dedent("""\ + sed -i '/^\s*"[^\s]*"\s*=>\s*\["%(site_name)s",\s*".*/d' %(moodle_path)s/config.php + """) % context + ) + + def insert_site_map(self, context): + self.append(textwrap.dedent("""\ + regex='\s*\$site_map\s+=\s+array\(' + newline=' "%(custom_domain)s" => ["%(site_name)s", "%(custom_url)s"], // %(banner)s' + sed -i -r "s#$regex#\$site_map = array(\\n$newline#" %(moodle_path)s/config.php + """) % context + ) + + def delete(self, saas): + context = self.get_context(saas) + self.append(textwrap.dedent(""" + rm -rf %(moodledata_path)s + # Delete tables with prefix %(db_prefix)s + mysql -Nrs \\ + --host="%(db_host)s" \\ + --user="%(db_user)s" \\ + --password="%(db_pass)s" \\ + --execute='SET GROUP_CONCAT_MAX_LEN=10000; + SET @tbls = (SELECT GROUP_CONCAT(TABLE_NAME) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = "%(db_name)s" + AND TABLE_NAME LIKE "%(db_prefix)s_%%"); + SET @delStmt = CONCAT("DROP TABLE ", @tbls); + -- SELECT @delStmt; + PREPARE stmt FROM @delStmt; + EXECUTE stmt; + DEALLOCATE PREPARE stmt;' \\ + %(db_name)s + """) % context + ) + if context['crontab']: + context['crontab_regex'] = '\\|'.join(context['crontab'].splitlines()) + context['crontab_regex'] = context['crontab_regex'].replace('*', '\\*') + self.append(textwrap.dedent("""\ + crontab -u %(user)s -l \\ + | grep -v 'Moodle:"%(site_name)s"\\|%(crontab_regex)s' \\ + | su - %(user)s --shell /bin/bash -c 'crontab' + """) % context + ) + self.delete_site_map(context) + + def get_context(self, saas): + context = { + 'banner': self.get_banner(), + 'name': saas.name, + 'site_name': saas.name, + 'full_name': "%s course" % saas.name.capitalize(), + 'moodle_path': settings.SAAS_MOODLE_PATH, + 'user': settings.SAAS_MOODLE_SYSTEMUSER, + 'db_user': settings.SAAS_MOODLE_DB_USER, + 'db_pass': settings.SAAS_MOODLE_DB_PASS, + 'db_name': settings.SAAS_MOODLE_DB_NAME, + 'db_host': settings.SAAS_MOODLE_DB_HOST, + 'db_prefix': saas.name.replace('-', '_'), + 'email': saas.account.email, + 'password': getattr(saas, 'password', None), + 'custom_url': saas.custom_url.rstrip('/'), + 'custom_domain': urlparse(saas.custom_url).netloc if saas.custom_url else None, + } + context.update({ + 'crontab': settings.SAAS_MOODLE_CRONTAB % context, + 'db_name': context['db_name'] % context, + 'moodledata_path': settings.SAAS_MOODLE_DATA_PATH % context, + }) + return context diff --git a/orchestra/contrib/saas/backends/nextcloud.py b/orchestra/contrib/saas/backends/nextcloud.py new file mode 100644 index 0000000..17df098 --- /dev/null +++ b/orchestra/contrib/saas/backends/nextcloud.py @@ -0,0 +1,175 @@ +import re +import sys +import textwrap +import time +import xml.etree.ElementTree as ET +from urllib.parse import urlparse + +import requests +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.orchestration import ServiceController +from orchestra.contrib.resources import ServiceMonitor + +from . import ApacheTrafficByName +from .. import settings + + +class NextCloudAPIMixin(object): + def validate_response(self, response): + request = response.request + context = (request.method, response.url, request.body, response.status_code) + sys.stderr.write("%s %s '%s' HTTP %s\n" % context) + if response.status_code != requests.codes.ok: + raise RuntimeError("%s %s '%s' HTTP %s" % context) + root = ET.fromstring(response.text) + statuscode = root.find("./meta/statuscode").text + if statuscode != '100': + message = root.find("./meta/status").text + request = response.request + context = (request.method, response.url, request.body, statuscode, message) + raise RuntimeError("%s %s '%s' ERROR %s, %s" % context) + + def api_call(self, action, url_path, *args, **kwargs): + BASE_URL = settings.SAAS_NEXTCLOUD_API_URL.rstrip('/') + url = '/'.join((BASE_URL, url_path)) + response = action(url, headers={'OCS-APIRequest':'true'}, verify=False, *args, **kwargs) + self.validate_response(response) + return response + + def api_get(self, url_path, *args, **kwargs): + return self.api_call(requests.get, url_path, *args, **kwargs) + + def api_post(self, url_path, *args, **kwargs): + return self.api_call(requests.post, url_path, *args, **kwargs) + + def api_put(self, url_path, *args, **kwargs): + return self.api_call(requests.put, url_path, *args, **kwargs) + + def api_delete(self, url_path, *args, **kwargs): + return self.api_call(requests.delete, url_path, *args, **kwargs) + + def create(self, saas): + data = { + 'userid': saas.name, + 'password': saas.password + } + self.api_post('users', data) + + def update(self, saas): + """ + key: email|quota|display|password + value: el valor a modificar. + Si es un email, tornarà un error si la direcció no te la "@" + Si es una quota, sembla que algo per l'estil "5G", "100M", etc. funciona. Quota 0 = infinit + "display" es el display name, no crec que el fem servir, és cosmetic + """ + data = { + 'key': 'password', + 'value': saas.password, + } + self.api_put('users/%s' % saas.name, data) + + def get_user(self, saas): + """ + { + 'displayname' + 'email' + 'quota' => + { + 'free' (en Bytes) + 'relative' (en tant per cent sense signe %, e.g. 68.17) + 'total' (en Bytes) + 'used' (en Bytes) + } + } + """ + response = self.api_get('users/%s' % saas.name) + root = ET.fromstring(response.text) + ret = {} + for data in root.find('./data'): + ret[data.tag] = data.text + ret['quota'] = {} + for data in root.find('.data/quota'): + ret['quota'][data.tag] = data.text + return ret + + +class NextCloudController(NextCloudAPIMixin, ServiceController): + """ + Creates a wordpress site on a WordPress MultiSite installation. + + You should point it to the database server + """ + verbose_name = _("nextCloud SaaS") + model = 'saas.SaaS' + default_route_match = "saas.service == 'nextcloud'" + doc_settings = (settings, + ('SAAS_NEXTCLOUD_API_URL',) + ) + + def update_or_create(self, saas, server): + try: + self.api_get('users/%s' % saas.name) + except RuntimeError: + if getattr(saas, 'password'): + self.create(saas) + else: + raise + else: + if getattr(saas, 'password'): + self.update(saas) + + def remove(self, saas, server): + self.api_delete('users/%s' % saas.name) + + def save(self, saas): + # TODO disable user https://github.com/owncloud/core/issues/12601 + self.append(self.update_or_create, saas) + + def delete(self, saas): + self.append(self.remove, saas) + + +class NextcloudTraffic(ApacheTrafficByName): + __doc__ = ApacheTrafficByName.__doc__ + verbose_name = _("nextCloud SaaS Traffic") + default_route_match = "saas.service == 'nextcloud'" + doc_settings = (settings, + ('SAAS_TRAFFIC_IGNORE_HOSTS', 'SAAS_NEXTCLOUD_LOG_PATH') + ) + log_path = settings.SAAS_NEXTCLOUD_LOG_PATH + + +class NextCloudDiskQuota(NextCloudAPIMixin, ServiceMonitor): + model = 'saas.SaaS' + verbose_name = _("nextCloud SaaS Disk Quota") + default_route_match = "saas.service == 'nextcloud'" + resource = ServiceMonitor.DISK + delete_old_equal_values = True + + def monitor(self, user): + context = self.get_context(user) + self.append("echo %(object_id)s $(monitor %(base_home)s)" % context) + + def get_context(self, user): + context = { + 'object_id': user.pk, + 'base_home': user.get_base_home(), + } + return replace(context, "'", '"') + + def get_quota(self, saas, server): + try: + user = self.get_user(saas) + except requests.exceptions.ConnectionError: + time.sleep(2) + user = self.get_user(saas) + context = { + 'object_id': saas.pk, + 'used': int(user['quota'].get('used', 0)), + } + sys.stdout.write('%(object_id)i %(used)i\n' % context) + + def monitor(self, saas): + self.append(self.get_quota, saas) diff --git a/orchestra/contrib/saas/backends/owncloud.py b/orchestra/contrib/saas/backends/owncloud.py new file mode 100644 index 0000000..a6496a4 --- /dev/null +++ b/orchestra/contrib/saas/backends/owncloud.py @@ -0,0 +1,175 @@ +import re +import sys +import textwrap +import time +import xml.etree.ElementTree as ET +from urllib.parse import urlparse + +import requests +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.orchestration import ServiceController +from orchestra.contrib.resources import ServiceMonitor + +from . import ApacheTrafficByName +from .. import settings + + +class OwnClouwAPIMixin(object): + def validate_response(self, response): + request = response.request + context = (request.method, response.url, request.body, response.status_code) + sys.stderr.write("%s %s '%s' HTTP %s\n" % context) + if response.status_code != requests.codes.ok: + raise RuntimeError("%s %s '%s' HTTP %s" % context) + root = ET.fromstring(response.text) + statuscode = root.find("./meta/statuscode").text + if statuscode != '100': + message = root.find("./meta/status").text + request = response.request + context = (request.method, response.url, request.body, statuscode, message) + raise RuntimeError("%s %s '%s' ERROR %s, %s" % context) + + def api_call(self, action, url_path, *args, **kwargs): + BASE_URL = settings.SAAS_OWNCLOUD_API_URL.rstrip('/') + url = '/'.join((BASE_URL, url_path)) + response = action(url, *args, **kwargs) + self.validate_response(response) + return response + + def api_get(self, url_path, *args, **kwargs): + return self.api_call(requests.get, url_path, *args, **kwargs) + + def api_post(self, url_path, *args, **kwargs): + return self.api_call(requests.post, url_path, *args, **kwargs) + + def api_put(self, url_path, *args, **kwargs): + return self.api_call(requests.put, url_path, *args, **kwargs) + + def api_delete(self, url_path, *args, **kwargs): + return self.api_call(requests.delete, url_path, *args, **kwargs) + + def create(self, saas): + data = { + 'userid': saas.name, + 'password': saas.password + } + self.api_post('users', data) + + def update(self, saas): + """ + key: email|quota|display|password + value: el valor a modificar. + Si es un email, tornarà un error si la direcció no te la "@" + Si es una quota, sembla que algo per l'estil "5G", "100M", etc. funciona. Quota 0 = infinit + "display" es el display name, no crec que el fem servir, és cosmetic + """ + data = { + 'key': 'password', + 'value': saas.password, + } + self.api_put('users/%s' % saas.name, data) + + def get_user(self, saas): + """ + { + 'displayname' + 'email' + 'quota' => + { + 'free' (en Bytes) + 'relative' (en tant per cent sense signe %, e.g. 68.17) + 'total' (en Bytes) + 'used' (en Bytes) + } + } + """ + response = self.api_get('users/%s' % saas.name) + root = ET.fromstring(response.text) + ret = {} + for data in root.find('./data'): + ret[data.tag] = data.text + ret['quota'] = {} + for data in root.find('.data/quota'): + ret['quota'][data.tag] = data.text + return ret + + +class OwnCloudController(OwnClouwAPIMixin, ServiceController): + """ + Creates a wordpress site on a WordPress MultiSite installation. + + You should point it to the database server + """ + verbose_name = _("ownCloud SaaS") + model = 'saas.SaaS' + default_route_match = "saas.service == 'owncloud'" + doc_settings = (settings, + ('SAAS_OWNCLOUD_API_URL',) + ) + + def update_or_create(self, saas, server): + try: + self.api_get('users/%s' % saas.name) + except RuntimeError: + if getattr(saas, 'password'): + self.create(saas) + else: + raise + else: + if getattr(saas, 'password'): + self.update(saas) + + def remove(self, saas, server): + self.api_delete('users/%s' % saas.name) + + def save(self, saas): + # TODO disable user https://github.com/owncloud/core/issues/12601 + self.append(self.update_or_create, saas) + + def delete(self, saas): + self.append(self.remove, saas) + + +class OwncloudTraffic(ApacheTrafficByName): + __doc__ = ApacheTrafficByName.__doc__ + verbose_name = _("ownCloud SaaS Traffic") + default_route_match = "saas.service == 'owncloud'" + doc_settings = (settings, + ('SAAS_TRAFFIC_IGNORE_HOSTS', 'SAAS_OWNCLOUD_LOG_PATH') + ) + log_path = settings.SAAS_OWNCLOUD_LOG_PATH + + +class OwnCloudDiskQuota(OwnClouwAPIMixin, ServiceMonitor): + model = 'saas.SaaS' + verbose_name = _("ownCloud SaaS Disk Quota") + default_route_match = "saas.service == 'owncloud'" + resource = ServiceMonitor.DISK + delete_old_equal_values = True + + def monitor(self, user): + context = self.get_context(user) + self.append("echo %(object_id)s $(monitor %(base_home)s)" % context) + + def get_context(self, user): + context = { + 'object_id': user.pk, + 'base_home': user.get_base_home(), + } + return replace(context, "'", '"') + + def get_quota(self, saas, server): + try: + user = self.get_user(saas) + except requests.exceptions.ConnectionError: + time.sleep(2) + user = self.get_user(saas) + context = { + 'object_id': saas.pk, + 'used': int(user['quota'].get('used', 0)), + } + sys.stdout.write('%(object_id)i %(used)i\n' % context) + + def monitor(self, saas): + self.append(self.get_quota, saas) diff --git a/orchestra/contrib/saas/backends/phplist.py b/orchestra/contrib/saas/backends/phplist.py new file mode 100644 index 0000000..2b2992e --- /dev/null +++ b/orchestra/contrib/saas/backends/phplist.py @@ -0,0 +1,239 @@ +import hashlib +import re +import sys +import textwrap + +import requests +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.orchestration import ServiceController +from orchestra.contrib.resources import ServiceMonitor +from orchestra.utils.sys import sshrun + +from .. import settings + + +class PhpListSaaSController(ServiceController): + """ + Creates a new phplist instance on a phpList multisite installation. + The site is created by means of creating a new database per phpList site, + but all sites share the same code. + + Different databases are used instead of prefixes because php-list reacts by launching + the installation process. + + // config/config.php + $site = getenv("SITE"); + if ( $site == '' ) { + $site = array_shift((explode(".",$_SERVER['HTTP_HOST']))); + } + $database_name = "phplist_mu_{$site}"; + """ + verbose_name = _("phpList SaaS") + model = 'saas.SaaS' + default_route_match = "saas.service == 'phplist'" + serialize = True + + def error(self, msg): + sys.stderr.write(msg + '\n') + raise RuntimeError(msg) + + def _install_or_change_password(self, saas, server): + """ configures the database for the new site through HTTP to /admin/ """ + admin_link = 'https://%s/admin/' % saas.get_site_domain() + sys.stdout.write('admin_link: %s\n' % admin_link) + admin_content = requests.get(admin_link, verify=settings.SAAS_PHPLIST_VERIFY_SSL) + admin_content = admin_content.content.decode('utf8') + if admin_content.startswith('Cannot connect to Database'): + self.error("Database is not yet configured.") + install = re.search(r'([^"]+firstinstall[^"]+)', admin_content) + if install: + if not hasattr(saas, 'password'): + self.error("Password is missing.") + install_path = install.groups()[0] + install_link = admin_link + install_path[1:] + post = { + 'adminname': saas.name, + 'orgname': saas.account.username, + 'adminemail': saas.account.username, + 'adminpassword': saas.password, + } + response = requests.post( + install_link, data=post, verify=settings.SAAS_PHPLIST_VERIFY_SSL) + sys.stdout.write(response.content.decode('utf8')+'\n') + if response.status_code != 200: + self.error("Bad status code %i." % response.status_code) + else: + md5_password = hashlib.md5() + md5_password.update(saas.password.encode('ascii')) + context = self.get_context(saas) + context['digest'] = md5_password.hexdigest() + cmd = textwrap.dedent("""\ + mysql \\ + --host=%(db_host)s \\ + --user=%(db_user)s \\ + --password=%(db_pass)s \\ + --execute='UPDATE phplist_admin SET password="%(digest)s" where ID=1; \\ + UPDATE phplist_user_user SET password="%(digest)s" where ID=1;' \\ + %(db_name)s""") % context + sys.stdout.write('cmd: %s\n' % cmd) + sshrun(server.get_address(), cmd, persist=True) + + def save(self, saas): + if hasattr(saas, 'password'): + self.append(self._install_or_change_password, saas) + context = self.get_context(saas) + if context['crontab']: + context['escaped_crontab'] = context['crontab'].replace('$', '\\$') + self.append(textwrap.dedent("""\ + # Configuring phpList crontabs + if ! crontab -u %(user)s -l | grep 'phpList:"%(site_name)s"' > /dev/null; then + cat << EOF | su - %(user)s --shell /bin/bash -c 'crontab' + $(crontab -u %(user)s -l) + + # %(banner)s - phpList:"%(site_name)s" + %(escaped_crontab)s + EOF + fi""") % context + ) + + def delete(self, saas): + context = self.get_context(saas) + if context['crontab']: + context['crontab_regex'] = '\\|'.join(context['crontab'].splitlines()) + context['crontab_regex'] = context['crontab_regex'].replace('*', '\\*') + self.append(textwrap.dedent("""\ + crontab -u %(user)s -l \\ + | grep -v 'phpList:"%(site_name)s"\\|%(crontab_regex)s' \\ + | su - %(user)s --shell /bin/bash -c 'crontab' + """) % context + ) + + def get_context(self, saas): + context = { + 'banner': self.get_banner(), + 'name': saas.name, + 'site_name': saas.name, + 'phplist_path': settings.SAAS_PHPLIST_PATH, + 'user': settings.SAAS_PHPLIST_SYSTEMUSER, + 'db_user': settings.SAAS_PHPLIST_DB_USER, + 'db_pass': settings.SAAS_PHPLIST_DB_PASS, + 'db_name': settings.SAAS_PHPLIST_DB_NAME, + 'db_host': settings.SAAS_PHPLIST_DB_HOST, + } + context.update({ + 'crontab': settings.SAAS_PHPLIST_CRONTAB % context, + 'db_name': context['db_name'] % context, + }) + return context + + +class PhpListTraffic(ServiceMonitor): + verbose_name = _("phpList SaaS Traffic") + model = 'saas.SaaS' + default_route_match = "saas.service == 'phplist'" + resource = ServiceMonitor.TRAFFIC + script_executable = '/usr/bin/python' + monthly_sum_old_values = True + doc_settings = (settings, + ('SAAS_PHPLIST_MAIL_LOG_PATH',) + ) + + def prepare(self): + mail_log = settings.SAAS_PHPLIST_MAIL_LOG_PATH + context = { + 'current_date': self.current_date.strftime("%Y-%m-%d %H:%M:%S %Z"), + 'mail_logs': str((mail_log, mail_log+'.1')), + } + self.append(textwrap.dedent("""\ + import sys + from datetime import datetime + from dateutil import tz + + def prepare(object_id, list_domain, ini_date): + global lists + ini_date = to_local_timezone(ini_date) + ini_date = int(ini_date.strftime('%Y%m%d%H%M%S')) + lists[list_domain] = [ini_date, object_id, 0] + + def inside_period(month, day, time, ini_date): + global months + global end_datetime + # Mar 9 17:13:22 + month = months[month] + year = end_datetime.year + if month == '12' and end_datetime.month == 1: + year = year+1 + if len(day) == 1: + day = '0' + day + date = str(year) + month + day + date += time.replace(':', '') + return ini_date < int(date) < end_date + + def to_local_timezone(date, tzlocal=tz.tzlocal()): + # Converts orchestra's UTC dates to local timezone + date = datetime.strptime(date, '%Y-%m-%d %H:%M:%S %Z') + date = date.replace(tzinfo=tz.tzutc()) + date = date.astimezone(tzlocal) + return date + + maillogs = {mail_logs} + end_datetime = to_local_timezone('{current_date}') + end_date = int(end_datetime.strftime('%Y%m%d%H%M%S')) + months = ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec') + months = dict((m, '%02d' % n) for n, m in enumerate(months, 1)) + + lists = {{}} + id_to_domain = {{}} + + def monitor(lists, id_to_domain, maillogs): + for maillog in maillogs: + try: + with open(maillog, 'r') as maillog: + for line in maillog.readlines(): + if ': message-id=<' in line: + # Sep 15 09:36:51 web postfix/cleanup[8138]: C20FF244283: message-id= + month, day, time, __, __, id, message_id = line.split()[:7] + list_domain = message_id.split('@')[1][:-1] + try: + opts = lists[list_domain] + except KeyError: + pass + else: + ini_date = opts[0] + if inside_period(month, day, time, ini_date): + id = id[:-1] + id_to_domain[id] = list_domain + elif '>, size=' in line: + # Sep 15 09:36:51 web postfix/qmgr[2296]: C20FF244283: from=, size=12252, nrcpt=1 (queue active) + month, day, time, __, __, id, __, size = line.split()[:8] + id = id[:-1] + try: + list_domain = id_to_domain[id] + except KeyError: + pass + else: + opts = lists[list_domain] + size = int(size[5:-1]) + opts[2] += size + except IOError as e: + sys.stderr.write(str(e)+'\\n') + for opts in lists.values(): + print opts[1], opts[2] + """).format(**context) + ) + + def commit(self): + self.append('monitor(lists, id_to_domain, maillogs)') + + def monitor(self, saas): + context = self.get_context(saas) + self.append("prepare(%(object_id)s, '%(list_domain)s', '%(last_date)s')" % context) + + def get_context(self, saas): + context = { + 'list_domain': saas.get_site_domain(), + 'object_id': saas.pk, + 'last_date': self.get_last_date(saas.pk).strftime("%Y-%m-%d %H:%M:%S %Z"), + } + return context diff --git a/orchestra/contrib/saas/backends/wordpressmu.py b/orchestra/contrib/saas/backends/wordpressmu.py new file mode 100644 index 0000000..7a8e75d --- /dev/null +++ b/orchestra/contrib/saas/backends/wordpressmu.py @@ -0,0 +1,279 @@ +import re +import sys +import textwrap +import time +from functools import partial +from urllib.parse import urlparse + +import requests +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.orchestration import ServiceController + +from . import ApacheTrafficByHost +from .. import settings + + +class WordpressMuController(ServiceController): + """ + Creates a wordpress site on a WordPress MultiSite installation. + + You should point it to the database server + """ + verbose_name = _("Wordpress multisite") + model = 'saas.SaaS' + default_route_match = "saas.service == 'wordpress'" + doc_settings = (settings, + ('SAAS_WORDPRESS_ADMIN_PASSWORD', 'SAAS_WORDPRESS_MAIN_URL', 'SAAS_WORDPRESS_VERIFY_SSL') + ) + VERIFY = settings.SAAS_WORDPRESS_VERIFY_SSL + + def with_retry(self, method, *args, retries=1, sleep=0.5, **kwargs): + for i in range(retries): + try: + return method(*args, verify=self.VERIFY, **kwargs) + except requests.exceptions.ConnectionError: + if i >= retries: + raise + sys.stderr.write("Connection error while {method}{args}, retry {i}/{retries}\n".format( + method=method.__name__, args=str(args), i=i, retries=retries)) + time.sleep(sleep) + + def login(self, session): + main_url = self.get_main_url() + login_url = main_url + '/wp-login.php' + login_data = { + 'log': 'admin', + 'pwd': settings.SAAS_WORDPRESS_ADMIN_PASSWORD, + 'redirect_to': '/wp-admin/' + } + sys.stdout.write("Login URL: %s\n" % login_url) + response = self.with_retry(session.post, login_url, data=login_data) + if response.url != main_url + '/wp-admin/': + raise IOError("Failure login to remote application (%s)" % login_url) + + def get_main_url(self): + main_url = settings.SAAS_WORDPRESS_MAIN_URL + return main_url.rstrip('/') + + def validate_response(self, response): + if response.status_code != 200: + content = response.content.decode('utf8') + errors = re.findall(r'\n\t

    (.*)

    ', content) + raise RuntimeError(errors[0] if errors else 'Unknown %i error' % response.status_code) + + def get_id(self, session, saas): + blog_id = saas.data.get('blog_id') + search = self.get_main_url() + search += '/wp-admin/network/sites.php?s=%s&action=blogs' % saas.name + regex = re.compile( + '%s' % saas.name + ) + sys.stdout.write("Search URL: %s\n" % search) + response = self.with_retry(session.get, search) + content = response.content.decode('utf8') + # Get id + ids = regex.search(content) + if not ids and not blog_id: + raise RuntimeError("Blog '%s' not found" % saas.name) + if ids: + ids = ids.groups() + if len(ids) > 1 and not blog_id: + raise ValueError("Multiple matches") + return blog_id or int(ids[0]), content + + def create_blog(self, saas, server): + if saas.data.get('blog_id'): + return + + session = requests.Session() + self.login(session) + + # Check if blog already exists + try: + blog_id, content = self.get_id(session, saas) + except RuntimeError: + url = self.get_main_url() + url += '/wp-admin/network/site-new.php' + sys.stdout.write("Create URL: %s\n" % url) + content = self.with_retry(session.get, url).content.decode('utf8') + + wpnonce = re.compile('name="_wpnonce_add-blog"\s+value="([^"]*)"') + try: + wpnonce = wpnonce.search(content).groups()[0] + except AttributeError: + raise RuntimeError("wpnonce not foud in %s" % content) + + url += '?action=add-site' + data = { + 'blog[domain]': saas.name, + 'blog[title]': saas.name, + 'blog[email]': saas.account.email, + '_wpnonce_add-blog': wpnonce, + } + + # Validate response + response = self.with_retry(session.post, url, data=data) + self.validate_response(response) + blog_id = re.compile(r'') + content = response.content.decode('utf8') + blog_id = blog_id.search(content).groups()[0] + sys.stdout.write("Created blog ID: %s\n" % blog_id) + saas.data['blog_id'] = int(blog_id) + saas.save(update_fields=('data',)) + return True + else: + sys.stdout.write("Retrieved blog ID: %s\n" % blog_id) + saas.data['blog_id'] = int(blog_id) + saas.save(update_fields=('data',)) + + def do_action(self, action, session, id, content, saas): + url_regex = r"""]*)['"]>""" % action + action_url = re.search(url_regex, content).groups()[0].replace("&", '&') + sys.stdout.write("%s confirm URL: %s\n" % (action, action_url)) + + content = self.with_retry(session.get, action_url).content.decode('utf8') + wpnonce = re.compile('name="_wpnonce"\s+value="([^"]*)"') + try: + wpnonce = wpnonce.search(content).groups()[0] + except AttributeError: + raise RuntimeError(re.search(r'([^<]+)<', content).groups()[0]) + data = { + 'action': action, + 'id': id, + '_wpnonce': wpnonce, + '_wp_http_referer': '/wp-admin/network/sites.php', + } + action_url = self.get_main_url() + action_url += '/wp-admin/network/sites.php?action=%sblog' % action + sys.stdout.write("%s URL: %s\n" % (action, action_url)) + response = self.with_retry(session.post, action_url, data=data) + self.validate_response(response) + + def is_active(self, content): + return bool( + re.findall(r"""Warning: ' + 'Related website directive does not exist for %s URL !' % + self.instance.custom_url) + else: + url = change_url(website) + link = '
    Related website:
    %s' % (url, website.name) + self.fields['custom_url'].help_text += link + else: + site_domain = self.plugin.site_domain + context = { + 'site_name': '<site_name>', + 'name': '<site_name>', + } + site_domain = site_domain % context + if '<site_name>' in site_domain: + site_link = site_domain + else: + site_link = '%s' % (site_domain, site_domain) + self.fields['site_url'].widget.display = site_link + self.fields['name'].label = _("Site name") if '%(' in self.plugin.site_domain else _("Username") + + +class SaaSPasswordForm(SaaSBaseForm): + password = forms.CharField(label=_("Password"), required=False, + widget=SpanWidget(display='Unknown password'), + validators=[ + validators.validate_password, + RegexValidator(r'^[^"\'\\]+$', + _('Enter a valid password. ' + 'This value may contain any ascii character except for ' + ' \'/"/\\/ characters.'), 'invalid'), + ], + help_text=_("Passwords are not stored, so there is no way to see this " + "service's password, but you can change the password using " + "this form.")) + password1 = forms.CharField(label=_("Password"), + widget=forms.PasswordInput(attrs={'autocomplete': 'off'}), + validators=[validators.validate_password]) + password2 = forms.CharField(label=_("Password confirmation"), + widget=forms.PasswordInput, + help_text=_("Enter the same password as above, for verification.")) + + def __init__(self, *args, **kwargs): + super(SaaSPasswordForm, self).__init__(*args, **kwargs) + if self.is_change: + self.fields['password1'].required = False + self.fields['password1'].widget = forms.HiddenInput() + self.fields['password2'].required = False + self.fields['password2'].widget = forms.HiddenInput() + else: + self.fields['password'].widget = forms.HiddenInput() + self.fields['password1'].help_text = _("Suggestion: %s") % random_ascii(10) + + def clean_password2(self): + if not self.is_change: + password1 = self.cleaned_data.get("password1") + password2 = self.cleaned_data.get("password2") + if password1 and password2 and password1 != password2: + msg = _("The two password fields didn't match.") + raise forms.ValidationError(msg) + return password2 + + def save(self, commit=True): + obj = super(SaaSPasswordForm, self).save(commit=commit) + if not self.is_change: + obj.set_password(self.cleaned_data["password1"]) + return obj diff --git a/orchestra/contrib/saas/models.py b/orchestra/contrib/saas/models.py new file mode 100644 index 0000000..967bf7b --- /dev/null +++ b/orchestra/contrib/saas/models.py @@ -0,0 +1,87 @@ +from django.db import models +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ +from jsonfield import JSONField + +from orchestra.core import validators + +from .fields import VirtualDatabaseRelation +from .services import SoftwareService + + +class SaaSQuerySet(models.QuerySet): + def create(self, **kwargs): + """ Sets password if provided, all within a single DB operation """ + password = kwargs.pop('password') + saas = SaaS(**kwargs) + if password: + saas.set_password(password) + saas.save() + return saas + + +class SaaS(models.Model): + service = models.CharField(_("service"), max_length=32, + choices=SoftwareService.get_choices()) + name = models.CharField(_("Name"), max_length=64, + help_text=_("Required. 64 characters or fewer. Letters, digits and ./- only."), + validators=[validators.validate_hostname]) + account = models.ForeignKey('accounts.Account', on_delete=models.CASCADE, + verbose_name=_("account"), related_name='saas') + is_active = models.BooleanField(_("active"), default=True, + help_text=_("Designates whether this service should be treated as active. ")) + data = JSONField(_("data"), default={}, + help_text=_("Extra information dependent of each service.")) + custom_url = models.URLField(_("custom URL"), blank=True, + help_text=_("Optional and alternative URL for accessing this service instance. " + "i.e. https://wiki.mydomain/doku/
    " + "A related website will be automatically configured if needed.")) + database = models.ForeignKey('databases.Database', + on_delete=models.SET_NULL, null=True, blank=True) + + # Some SaaS sites may need a database, with this virtual field we tell the ORM to delete them + databases = VirtualDatabaseRelation('databases.Database') + objects = SaaSQuerySet.as_manager() + + class Meta: + verbose_name = "SaaS" + verbose_name_plural = "SaaS" + unique_together = ( + ('name', 'service'), + ) + + def __str__(self): + return "%s@%s" % (self.name, self.service) + + @cached_property + def service_class(self): + return SoftwareService.get(self.service) + + @cached_property + def service_instance(self): + """ Per request lived service_instance """ + return self.service_class(self) + + @cached_property + def active(self): + return self.is_active and self.account.is_active + + def disable(self): + self.is_active = False + self.save(update_fields=('is_active',)) + + def enable(self): + self.is_active = True + self.save(update_fields=('is_active',)) + + def clean(self): + if not self.pk: + self.name = self.name.lower() + self.service_instance.clean() + self.data = self.service_instance.clean_data() + + def get_site_domain(self): + return self.service_instance.get_site_domain() + + def set_password(self, password): + self.password = password diff --git a/orchestra/contrib/saas/serializers.py b/orchestra/contrib/saas/serializers.py new file mode 100644 index 0000000..a0da1a1 --- /dev/null +++ b/orchestra/contrib/saas/serializers.py @@ -0,0 +1,28 @@ +from django.forms import widgets +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from orchestra.api.serializers import SetPasswordHyperlinkedSerializer +from orchestra.contrib.accounts.serializers import AccountSerializerMixin +from orchestra.core import validators + +from .models import SaaS + + +class SaaSSerializer(AccountSerializerMixin, SetPasswordHyperlinkedSerializer): + data = serializers.DictField(required=False) + password = serializers.CharField(write_only=True, required=False, + style={'widget': widgets.PasswordInput}, + validators=[ + validators.validate_password, + RegexValidator(r'^[^"\'\\]+$', + _('Enter a valid password. ' + 'This value may contain any ascii character except for ' + ' \'/"/\\/ characters.'), 'invalid'), + ]) + + class Meta: + model = SaaS + fields = ('url', 'id', 'name', 'service', 'is_active', 'data', 'password') + postonly_fields = ('name', 'service', 'password') diff --git a/orchestra/contrib/saas/services/__init__.py b/orchestra/contrib/saas/services/__init__.py new file mode 100644 index 0000000..4720b7c --- /dev/null +++ b/orchestra/contrib/saas/services/__init__.py @@ -0,0 +1 @@ +from .options import SoftwareService diff --git a/orchestra/contrib/saas/services/bscw.py b/orchestra/contrib/saas/services/bscw.py new file mode 100644 index 0000000..7fef1cd --- /dev/null +++ b/orchestra/contrib/saas/services/bscw.py @@ -0,0 +1,25 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from .. import settings +from ..forms import SaaSPasswordForm +from .options import SoftwareService + + +class BSCWForm(SaaSPasswordForm): + email = forms.EmailField(label=_("Email"), widget=forms.TextInput(attrs={'size': '40'})) + + +class BSCWDataSerializer(serializers.Serializer): + email = serializers.EmailField(label=_("Email")) + + +class BSCWService(SoftwareService): + name = 'bscw' + verbose_name = "BSCW" + form = BSCWForm + serializer = BSCWDataSerializer + icon = 'orchestra/icons/apps/BSCW.png' + site_domain = settings.SAAS_BSCW_DOMAIN + change_readonly_fields = ('email',) diff --git a/orchestra/contrib/saas/services/dokuwiki.py b/orchestra/contrib/saas/services/dokuwiki.py new file mode 100644 index 0000000..5195f84 --- /dev/null +++ b/orchestra/contrib/saas/services/dokuwiki.py @@ -0,0 +1,24 @@ +from urllib.parse import urlparse + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +from .options import SoftwareService +from .. import settings + + +class DokuWikiService(SoftwareService): + name = 'dokuwiki' + verbose_name = "Dowkuwiki" + icon = 'orchestra/icons/apps/Dokuwiki.png' + site_domain = settings.SAAS_DOKUWIKI_DOMAIN + allow_custom_url = settings.SAAS_DOKUWIKI_ALLOW_CUSTOM_URL + + def clean(self): + if self.allow_custom_url and self.instance.custom_url: + url = urlparse(self.instance.custom_url) + if url.path and url.path != '/': + raise ValidationError({ + 'custom_url': _("Support for specific URL paths (%s) is not implemented.") % url.path + }) + super(DokuWikiService, self).clean() diff --git a/orchestra/contrib/saas/services/drupal.py b/orchestra/contrib/saas/services/drupal.py new file mode 100644 index 0000000..e40291e --- /dev/null +++ b/orchestra/contrib/saas/services/drupal.py @@ -0,0 +1,10 @@ +from .options import SoftwareService + +from .. import settings + + +class DrupalService(SoftwareService): + name = 'drupal' + verbose_name = "Drupal" + icon = 'orchestra/icons/apps/Drupal.png' + site_domain = settings.SAAS_DRUPAL_DOMAIN diff --git a/orchestra/contrib/saas/services/gitlab.py b/orchestra/contrib/saas/services/gitlab.py new file mode 100644 index 0000000..b8d62cf --- /dev/null +++ b/orchestra/contrib/saas/services/gitlab.py @@ -0,0 +1,35 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from orchestra.forms import widgets + +from .. import settings +from ..forms import SaaSPasswordForm +from .options import SoftwareService + + +class GitLabForm(SaaSPasswordForm): + email = forms.EmailField(label=_("Email"), + help_text=_("Initial email address, changes on the GitLab server are not reflected here.")) + + +class GitLaChangeForm(GitLabForm): + user_id = forms.IntegerField(label=("User ID"), widget=widgets.SpanWidget, + help_text=_("ID of this user used by GitLab, the only attribute that doesn't change.")) + + +class GitLabSerializer(serializers.Serializer): + email = serializers.EmailField(label=_("Email")) + user_id = serializers.IntegerField(label=_("User ID"), allow_null=True, required=False) + + +class GitLabService(SoftwareService): + name = 'gitlab' + form = GitLabForm + change_form = GitLaChangeForm + serializer = GitLabSerializer + site_domain = settings.SAAS_GITLAB_DOMAIN + change_readonly_fields = ('email', 'user_id',) + verbose_name = "GitLab" + icon = 'orchestra/icons/apps/gitlab.png' diff --git a/orchestra/contrib/saas/services/helpers.py b/orchestra/contrib/saas/services/helpers.py new file mode 100644 index 0000000..0deb36f --- /dev/null +++ b/orchestra/contrib/saas/services/helpers.py @@ -0,0 +1,134 @@ +from collections import defaultdict +from urllib.parse import urlparse + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.websites.models import Website, WebsiteDirective, Content +from orchestra.contrib.websites.validators import validate_domain_protocol +from orchestra.contrib.orchestration.models import Server +from orchestra.utils.python import AttrDict + + +def full_clean(obj, exclude=None): + try: + obj.full_clean(exclude=exclude) + except ValidationError as e: + raise ValidationError({ + 'custom_url': _("Error validating related %s: %s") % (type(obj).__name__, e), + }) + + +def clean_custom_url(saas): + instance = saas.instance + instance.custom_url = instance.custom_url.strip() + url = urlparse(instance.custom_url) + if not url.path: + instance.custom_url += '/' + url = urlparse(instance.custom_url) + try: + protocol, valid_protocols = saas.PROTOCOL_MAP[url.scheme] + except KeyError: + raise ValidationError({ + 'custom_url': _("%s scheme not supported (http/https)") % url.scheme, + }) + account = instance.account + # get or create website + try: + website = Website.objects.get( + protocol__in=valid_protocols, + domains__name=url.netloc, + account=account, + ) + except Website.DoesNotExist: + # get or create domain + Domain = Website.domains.field.related_model + try: + domain = Domain.objects.get(name=url.netloc) + except Domain.DoesNotExist: + raise ValidationError({ + 'custom_url': _("Domain %s does not exist.") % url.netloc, + }) + if domain.account != account: + raise ValidationError({ + 'custom_url': _("Domain %s does not belong to account %s, it's from %s.") % + (url.netloc, account, domain.account), + }) + # Create new website for custom_url + # Changed by daniel: hardcode target_server to web.pangea.lan, consider putting it into settings.py + tgt_server = Server.objects.get(name='web.pangea.lan') + website = Website(name=url.netloc, protocol=protocol, account=account, target_server=tgt_server) + full_clean(website) + try: + validate_domain_protocol(website, domain, protocol) + except ValidationError as e: + raise ValidationError({ + 'custom_url': _("Error validating related %s: %s") % (type(website).__name__, e), + }) + # get or create directive + try: + directive = website.directives.get(name=saas.get_directive_name()) + except WebsiteDirective.DoesNotExist: + directive = WebsiteDirective(name=saas.get_directive_name(), value=url.path) + if not directive.pk or directive.value != url.path: + directive.value = url.path + if website.pk: + directive.website = website + full_clean(directive) + # Adaptation of orchestra.websites.forms.WebsiteDirectiveInlineFormSet.clean() + locations = set( + Content.objects.filter(website=website).values_list('path', flat=True) + ) + values = defaultdict(list) + directives = WebsiteDirective.objects.filter(website=website) + for wdirective in directives.exclude(pk=directive.pk): + fdirective = AttrDict({ + 'name': wdirective.name, + 'value': wdirective.value + }) + try: + wdirective.directive_instance.validate_uniqueness(fdirective, values, locations) + except ValidationError as err: + raise ValidationError({ + 'custom_url': _("Another directive with this URL path exists (%s)." % err) + }) + else: + full_clean(directive, exclude=('website',)) + return directive + + +def create_or_update_directive(saas): + instance = saas.instance + url = urlparse(instance.custom_url) + protocol, valid_protocols = saas.PROTOCOL_MAP[url.scheme] + account = instance.account + # get or create website + try: + website = Website.objects.get( + protocol__in=valid_protocols, + domains__name=url.netloc, + account=account, + ) + except Website.DoesNotExist: + Domain = Website.domains.field.related_model + domain = Domain.objects.get(name=url.netloc) + # Create new website for custom_url + tgt_server = Server.objects.get(name='web.pangea.lan') + website = Website(name=url.netloc, protocol=protocol, account=account, target_server=tgt_server) + website.save() + website.domains.add(domain) + # get or create directive + try: + directive = website.directives.get(name=saas.get_directive_name()) + except WebsiteDirective.DoesNotExist: + directive = WebsiteDirective(name=saas.get_directive_name(), value=url.path) + if not directive.pk or directive.value != url.path: + directive.value = url.path + directive.website = website + directive.save() + return directive + + +def update_directive(saas): + saas.instance.custom_url = saas.instance.custom_url.strip() + url = urlparse(saas.instance.custom_url) diff --git a/orchestra/contrib/saas/services/moodle.py b/orchestra/contrib/saas/services/moodle.py new file mode 100644 index 0000000..2b6580e --- /dev/null +++ b/orchestra/contrib/saas/services/moodle.py @@ -0,0 +1,25 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + +from orchestra.forms.widgets import SpanWidget + +from .. import settings +from ..forms import SaaSPasswordForm +from .options import SoftwareService + + +class MoodleForm(SaaSPasswordForm): + admin_username = forms.CharField(label=_("Admin username"), required=False, + widget=SpanWidget(display='admin')) + + +class MoodleService(SoftwareService): + name = 'moodle' + verbose_name = "Moodle" + form = MoodleForm + description_field = 'site_name' + icon = 'orchestra/icons/apps/Moodle.png' + site_domain = settings.SAAS_MOODLE_DOMAIN + allow_custom_url = settings.SAAS_MOODLE_ALLOW_CUSTOM_URL + db_name = settings.SAAS_MOODLE_DB_NAME + db_user = settings.SAAS_MOODLE_DB_USER diff --git a/orchestra/contrib/saas/services/nextcloud.py b/orchestra/contrib/saas/services/nextcloud.py new file mode 100644 index 0000000..2027352 --- /dev/null +++ b/orchestra/contrib/saas/services/nextcloud.py @@ -0,0 +1,13 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from .. import settings +from .options import SoftwareService + + +class NextCloudService(SoftwareService): + name = 'nextcloud' + verbose_name = "nextCloud" + icon = 'orchestra/icons/apps/nextCloud.png' + site_domain = settings.SAAS_NEXTCLOUD_DOMAIN diff --git a/orchestra/contrib/saas/services/options.py b/orchestra/contrib/saas/services/options.py new file mode 100644 index 0000000..37413a8 --- /dev/null +++ b/orchestra/contrib/saas/services/options.py @@ -0,0 +1,205 @@ +import importlib +import os +from functools import lru_cache +from urllib.parse import urlparse + +from django.core.exceptions import ValidationError, ObjectDoesNotExist +from django.utils.translation import gettext_lazy as _ + +from orchestra import plugins +from orchestra.contrib.databases.models import Database, DatabaseUser +from orchestra.contrib.orchestration import Operation +from orchestra.contrib.websites.models import Website, WebsiteDirective +from orchestra.utils.apps import isinstalled +from orchestra.utils.functional import cached +from orchestra.utils.python import import_class + +from . import helpers +from .. import settings +from ..forms import SaaSPasswordForm + + +class SoftwareService(plugins.Plugin, metaclass=plugins.PluginMount): + PROTOCOL_MAP = { + 'http': (Website.HTTP, (Website.HTTP, Website.HTTP_AND_HTTPS)), + 'https': (Website.HTTPS_ONLY, (Website.HTTPS, Website.HTTP_AND_HTTPS, Website.HTTPS_ONLY)), + } + + name = None + verbose_name = None + form = SaaSPasswordForm + site_domain = None + has_custom_domain = False + icon = 'orchestra/icons/apps.png' + class_verbose_name = _("Software as a Service") + plugin_field = 'service' + allow_custom_url = False + + @classmethod + @lru_cache() + def get_plugins(cls, all=False): + if all: + for module in os.listdir(os.path.dirname(__file__)): + if module not in ('options.py', '__init__.py') and module[-3:] == '.py': + importlib.import_module('.'+module[:-3], __package__) + plugins = super().get_plugins() + else: + plugins = [] + for cls in settings.SAAS_ENABLED_SERVICES: + plugins.append(import_class(cls)) + return plugins + + def get_change_readonly_fields(cls): + fields = super(SoftwareService, cls).get_change_readonly_fields() + return fields + ('name',) + + def get_site_domain(self): + context = { + 'site_name': self.instance.name, + 'name': self.instance.name, + } + return self.site_domain % context + + def clean(self): + if self.allow_custom_url: + if self.instance.custom_url: + if isinstalled('orchestra.contrib.websites'): + helpers.clean_custom_url(self) + elif self.instance.custom_url: + raise ValidationError({ + 'custom_url': _("Custom URL not allowed for this service."), + }) + + def clean_data(self): + data = super(SoftwareService, self).clean_data() + if not self.instance.pk: + try: + log = Operation.execute_action(self.instance, 'validate_creation')[0] + 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.") + if 'email-exists' in log.stdout: + errors['email'] = _("User with this email address already exists.") + if errors: + raise ValidationError(errors) + return data + + def get_directive_name(self): + return '%s-saas' % self.name + + def get_directive(self, *args): + if not args: + instance = self.instance + else: + instance = args[0] + url = urlparse(instance.custom_url) + account = instance.account + return WebsiteDirective.objects.get( + name=self.get_directive_name(), + value=url.path, + website__protocol__in=self.PROTOCOL_MAP[url.scheme][1], + website__domains__name=url.netloc, + website__account=account, + ) + + def get_website(self): + url = urlparse(self.instance.custom_url) + account = self.instance.account + return Website.objects.get( + protocol__in=self.PROTOCOL_MAP[url.scheme][1], + domains__name=url.netloc, + account=account, + directives__name=self.get_directive_name(), + directives__value=url.path, + ) + + def create_or_update_directive(self): + return helpers.create_or_update_directive(self) + + def delete_directive(self): + directive = None + try: + old = type(self.instance).objects.get(pk=self.instance.pk) + if old.custom_url: + directive = self.get_directive(old) + except ObjectDoesNotExist: + return + if directive is not None: + directive.delete() + + def save(self): + # pre instance.save() + if isinstalled('orchestra.contrib.websites'): + if self.instance.custom_url: + self.create_or_update_directive() + elif self.instance.pk: + self.delete_directive() + + def delete(self): + if isinstalled('orchestra.contrib.websites'): + self.delete_directive() + + def get_related(self): + return [] + + +class DBSoftwareService(SoftwareService): + db_name = None + db_user = None + abstract = True + + def get_db_name(self): + context = { + 'name': self.instance.name, + 'site_name': self.instance.name, + } + db_name = self.db_name % context + # Limit for mysql database names + return db_name[:65] + + def get_db_user(self): + return self.db_user + + @cached + def get_account(self): + account_model = self.instance._meta.get_field('account') + return account_model.remote_field.model.objects.get_main() + + def validate(self): + super(DBSoftwareService, self).validate() + create = not self.instance.pk + if create: + account = self.get_account() + # Validated Database + db_user = self.get_db_user() + try: + DatabaseUser.objects.get(username=db_user) + except DatabaseUser.DoesNotExist: + raise ValidationError( + _("Global database user for PHPList '%(db_user)s' does not exists.") % { + 'db_user': db_user + } + ) + db = Database(name=self.get_db_name(), account=account) + try: + db.full_clean() + except ValidationError as e: + raise ValidationError({ + 'name': e.messages, + }) + + def save(self): + super(DBSoftwareService, self).save() + account = self.get_account() + # Database + db_name = self.get_db_name() + db_user = self.get_db_user() + db, db_created = account.databases.get_or_create(name=db_name, type=Database.MYSQL) + user = DatabaseUser.objects.get(username=db_user) + db.users.add(user) + self.instance.database_id = db.pk diff --git a/orchestra/contrib/saas/services/owncloud.py b/orchestra/contrib/saas/services/owncloud.py new file mode 100644 index 0000000..2a6d121 --- /dev/null +++ b/orchestra/contrib/saas/services/owncloud.py @@ -0,0 +1,13 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from .. import settings +from .options import SoftwareService + + +class OwnCloudService(SoftwareService): + name = 'owncloud' + verbose_name = "ownCloud" + icon = 'orchestra/icons/apps/ownCloud.png' + site_domain = settings.SAAS_OWNCLOUD_DOMAIN diff --git a/orchestra/contrib/saas/services/phplist.py b/orchestra/contrib/saas/services/phplist.py new file mode 100644 index 0000000..9b97fe7 --- /dev/null +++ b/orchestra/contrib/saas/services/phplist.py @@ -0,0 +1,122 @@ +from django import forms +from django.core import validators +from django.core.exceptions import ValidationError +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 orchestra.contrib.mailboxes.models import Mailbox +from orchestra.forms.widgets import SpanWidget + +from .. import settings +from ..forms import SaaSPasswordForm +from .options import DBSoftwareService + + +class PHPListForm(SaaSPasswordForm): + admin_username = forms.CharField(label=_("Admin username"), required=False, + widget=SpanWidget(display='admin')) + database = forms.CharField(label=_("Database"), required=False, + help_text=_("Database dedicated to this phpList instance."), + widget=SpanWidget(display=settings.SAAS_PHPLIST_DB_NAME.replace( + '%(', '<').replace(')s', '>'))) + mailbox = forms.CharField(label=_("Bounces mailbox"), required=False, + help_text=_("Dedicated mailbox used for reciving bounces."), + widget=SpanWidget(display=settings.SAAS_PHPLIST_BOUNCES_MAILBOX_NAME.replace( + '%(', '<').replace(')s', '>'))) + + def __init__(self, *args, **kwargs): + super(PHPListForm, self).__init__(*args, **kwargs) + self.fields['name'].label = _("Site name") + context = { + 'site_name': '<site_name>', + 'name': '<site_name>', + } + domain = self.plugin.site_domain % context + help_text = _("Admin URL http://{}/admin/").format(domain) + self.fields['site_url'].help_text = help_text + validator = validators.MaxLengthValidator(settings.SAAS_PHPLIST_NAME_MAX_LENGTH) + self.fields['name'].validators.append(validator) + + +class PHPListChangeForm(PHPListForm): + def __init__(self, *args, **kwargs): + super(PHPListChangeForm, self).__init__(*args, **kwargs) + site_domain = self.instance.get_site_domain() + admin_url = "http://%s/admin/" % site_domain + help_text = _("Admin URL {0}").format(admin_url) + self.fields['site_url'].help_text = help_text + # DB link + db = self.instance.database + db_url = reverse('admin:databases_database_change', args=(db.pk,)) + db_link = mark_safe('%s' % (db_url, db.name)) + self.fields['database'].widget = SpanWidget(original=db.name, display=db_link) + # Mailbox link + mailbox_id = self.instance.data.get('mailbox_id') + if mailbox_id: + try: + mailbox = Mailbox.objects.get(id=mailbox_id) + except Mailbox.DoesNotExist: + pass + else: + mailbox_url = reverse('admin:mailboxes_mailbox_change', args=(mailbox.pk,)) + mailbox_link = mark_safe('%s' % (mailbox_url, mailbox.name)) + self.fields['mailbox'].widget = SpanWidget( + original=mailbox.name, display=mailbox_link) + + +class PHPListService(DBSoftwareService): + name = 'phplist' + verbose_name = "phpList" + form = PHPListForm + change_form = PHPListChangeForm + icon = 'orchestra/icons/apps/Phplist.png' + site_domain = settings.SAAS_PHPLIST_DOMAIN + allow_custom_url = settings.SAAS_PHPLIST_ALLOW_CUSTOM_URL + db_name = settings.SAAS_PHPLIST_DB_NAME + db_user = settings.SAAS_PHPLIST_DB_USER + + def get_mailbox_name(self): + context = { + 'name': self.instance.name, + 'site_name': self.instance.name, + } + return settings.SAAS_PHPLIST_BOUNCES_MAILBOX_NAME % context + + def validate(self): + super(PHPListService, self).validate() + create = not self.instance.pk + if create: + account = self.get_account() + # Validate mailbox + mailbox = Mailbox(name=self.get_mailbox_name(), account=account) + try: + mailbox.full_clean() + except ValidationError as e: + raise ValidationError({ + 'name': e.messages, + }) + + def save(self): + super(PHPListService, self).save() + account = self.get_account() + # Mailbox + mailbox_name = self.get_mailbox_name() + mailbox, mb_created = account.mailboxes.get_or_create(name=mailbox_name) + if mb_created: + mailbox.set_password(settings.SAAS_PHPLIST_BOUNCES_MAILBOX_PASSWORD) + mailbox.save(update_fields=('password',)) + self.instance.data.update({ + 'mailbox_id': mailbox.pk, + 'mailbox_name': mailbox_name, + }) + + def delete(self): + super(PHPListService, self).save() + account = self.get_account() + # delete Mailbox (database will be deleted by ORM's cascade behaviour + mailbox_name = self.instance.data.get('mailbox_name') or self.get_mailbox_name() + mailbox_id = self.instance.data.get('mailbox_id') + qs = Q(Q(name=mailbox_name) | Q(id=mailbox_id)) + account.mailboxes.filter(qs).delete() diff --git a/orchestra/contrib/saas/services/seafile.py b/orchestra/contrib/saas/services/seafile.py new file mode 100644 index 0000000..bc29a42 --- /dev/null +++ b/orchestra/contrib/saas/services/seafile.py @@ -0,0 +1,31 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from .. import settings +from ..forms import SaaSPasswordForm +from .options import SoftwareService + + +# TODO monitor quota since out of sync? + +class SeaFileForm(SaaSPasswordForm): + email = forms.EmailField(label=_("Email"), widget=forms.TextInput(attrs={'size':'40'})) + quota = forms.IntegerField(label=_("Quota"), initial=settings.SAAS_SEAFILE_DEFAULT_QUOTA, + help_text=_("Disk quota in MB.")) + + +class SeaFileDataSerializer(serializers.Serializer): + email = serializers.EmailField(label=_("Email")) + quota = serializers.IntegerField(label=_("Quota"), default=settings.SAAS_SEAFILE_DEFAULT_QUOTA, + help_text=_("Disk quota in MB.")) + + +class SeaFileService(SoftwareService): + name = 'seafile' + verbose_name = "SeaFile" + form = SeaFileForm + serializer = SeaFileDataSerializer + icon = 'orchestra/icons/apps/seafile.png' + site_domain = settings.SAAS_SEAFILE_DOMAIN + change_readonly_fields = ('email',) diff --git a/orchestra/contrib/saas/services/wordpress.py b/orchestra/contrib/saas/services/wordpress.py new file mode 100644 index 0000000..141bb48 --- /dev/null +++ b/orchestra/contrib/saas/services/wordpress.py @@ -0,0 +1,45 @@ +from django import forms +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from orchestra.forms import widgets + +from .options import SoftwareService +from .. import settings +from ..forms import SaaSBaseForm + + +class WordPressForm(SaaSBaseForm): + email = forms.EmailField(label=_("Email"), + help_text=_("A new user will be created if the above email address is not in the database.
    " + "The username and password will be mailed to this email address.")) + + def __init__(self, *args, **kwargs): + super(WordPressForm, self).__init__(*args, **kwargs) + if self.is_change: + admin_url = 'http://%s/wp-admin/' % self.instance.get_site_domain() + help_text = 'Admin URL: {0}'.format(admin_url) + self.fields['site_url'].help_text = mark_safe(help_text) + + +class WordPressChangeForm(WordPressForm): + blog_id = forms.IntegerField(label=("Blog ID"), widget=widgets.SpanWidget, required=False, + help_text=_("ID of this blog used by WordPress, the only attribute that doesn't change.")) + + +class WordPressDataSerializer(serializers.Serializer): + email = serializers.EmailField(label=_("Email")) + blog_id = serializers.IntegerField(label=_("Blog ID"), allow_null=True, required=False) + + +class WordPressService(SoftwareService): + name = 'wordpress' + verbose_name = "WordPress" + form = WordPressForm + change_form = WordPressChangeForm + serializer = WordPressDataSerializer + icon = 'orchestra/icons/apps/WordPress.png' + change_readonly_fields = ('email', 'blog_id') + site_domain = settings.SAAS_WORDPRESS_DOMAIN + allow_custom_url = settings.SAAS_WORDPRESS_ALLOW_CUSTOM_URL diff --git a/orchestra/contrib/saas/settings.py b/orchestra/contrib/saas/settings.py new file mode 100644 index 0000000..837d1e1 --- /dev/null +++ b/orchestra/contrib/saas/settings.py @@ -0,0 +1,341 @@ +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.settings import Setting +from orchestra.core.validators import validate_ip_address +from orchestra.settings import ORCHESTRA_BASE_DOMAIN + +from . import validators +from .. import saas + + +SAAS_ENABLED_SERVICES = Setting('SAAS_ENABLED_SERVICES', + ( + 'orchestra.contrib.saas.services.moodle.MoodleService', + 'orchestra.contrib.saas.services.bscw.BSCWService', + 'orchestra.contrib.saas.services.gitlab.GitLabService', + 'orchestra.contrib.saas.services.phplist.PHPListService', + 'orchestra.contrib.saas.services.wordpress.WordPressService', + 'orchestra.contrib.saas.services.dokuwiki.DokuWikiService', + 'orchestra.contrib.saas.services.drupal.DrupalService', + 'orchestra.contrib.saas.services.owncloud.OwnCloudService', + 'orchestra.contrib.saas.services.nextcloud.NextCloudService', +# 'orchestra.contrib.saas.services.seafile.SeaFileService', + ), + # lazy loading + choices=lambda: ((s.get_class_path(), s.get_class_path()) for s in saas.services.SoftwareService.get_plugins(all=True)), + multiple=True, +) + + +SAAS_TRAFFIC_IGNORE_HOSTS = Setting('SAAS_TRAFFIC_IGNORE_HOSTS', + ('127.0.0.1',), + help_text=_("IP addresses to ignore during traffic accountability."), + validators=[lambda hosts: (validate_ip_address(host) for host in hosts)] +) + + +# WordPress + +SAAS_WORDPRESS_ALLOW_CUSTOM_URL = Setting('SAAS_WORDPRESS_ALLOW_CUSTOM_URL', + True, + help_text=_("Whether allow custom URL to be specified or not."), + validators=[validators.validate_website_saas_directives('wordpress-saas')], +) + +SAAS_WORDPRESS_LOG_PATH = Setting('SAAS_WORDPRESS_LOG_PATH', + '', + help_text=_('Filesystem path for the webserver access logs.
    ' + 'LogFormat "%h %l %u %t \"%r\" %>s %O \"%{Host}i\"" host'), +) + +SAAS_WORDPRESS_ADMIN_PASSWORD = Setting('SAAS_WORDPRESS_ADMIN_PASSWORD', + 'secret' +) + +SAAS_WORDPRESS_MAIN_URL = Setting('SAAS_WORDPRESS_MAIN_URL', + 'https://blogs.{}/'.format(ORCHESTRA_BASE_DOMAIN), + help_text="Uses ORCHESTRA_BASE_DOMAIN by default.", +) + +SAAS_WORDPRESS_DOMAIN = Setting('SAAS_WORDPRESS_DOMAIN', + '%(site_name)s.blogs.{}'.format(ORCHESTRA_BASE_DOMAIN), +) + +SAAS_WORDPRESS_DB_NAME = Setting('SAAS_WORDPRESS_DB_NAME', + 'wordpressmu', + help_text=_("Needed for domain mapping when SAAS_WORDPRESS_ALLOW_CUSTOM_URL is enabled."), +) + +SAAS_WORDPRESS_VERIFY_SSL = Setting('SAAS_WORDPRESS_VERIFY_SSL', + True, + help_text=_("Verify SSL certificate on the HTTP requests performed by the backend."), +) + + +# DokuWiki + +SAAS_DOKUWIKI_ALLOW_CUSTOM_URL = Setting('SAAS_DOKUWIKI_ALLOW_CUSTOM_URL', + True, + help_text=_("Whether allow custom URL to be specified or not."), + validators=[validators.validate_website_saas_directives('dokuwiki-saas')], +) + +SAAS_DOKUWIKI_TEMPLATE_PATH = Setting('SAAS_DOKUWIKI_TEMPLATE_PATH', + '/home/httpd/htdocs/wikifarm/template.tar.gz' +) + +SAAS_DOKUWIKI_FARM_PATH = Setting('WEBSITES_DOKUWIKI_FARM_PATH', + '/home/httpd/htdocs/wikifarm/farm' +) + +SAAS_DOKUWIKI_DOMAIN = Setting('SAAS_DOKUWIKI_DOMAIN', + '%(site_name)s.dokuwiki.{}'.format(ORCHESTRA_BASE_DOMAIN), +) + +SAAS_DOKUWIKI_TEMPLATE_PATH = Setting('SAAS_DOKUWIKI_TEMPLATE_PATH', + '/var/www/wikifarm/template.tar.gz', +) + +SAAS_DOKUWIKI_FARM_PATH = Setting('SAAS_DOKUWIKI_FARM_PATH', + '/var/www/wikifarm/farm' +) + +SAAS_DOKUWIKI_USER = Setting('SAAS_DOKUWIKI_USER', + 'orchestra' +) + +SAAS_DOKUWIKI_GROUP = Setting('SAAS_DOKUWIKI_GROUP', + 'orchestra' +) + +SAAS_DOKUWIKI_LOG_PATH = Setting('SAAS_DOKUWIKI_LOG_PATH', + '', +) + + +# Drupal + +SAAS_DRUPAL_ALLOW_CUSTOM_URL = Setting('SAAS_DRUPAL_ALLOW_CUSTOM_URL', + True, + help_text=_("Whether allow custom URL to be specified or not."), + validators=[validators.validate_website_saas_directives('drupal-saas')], +) + +SAAS_DRUPAL_SITES_PATH = Setting('WEBSITES_DRUPAL_SITES_PATH', + '/home/httpd/htdocs/drupal-mu/sites/%(site_name)s', +) + +SAAS_DRUPAL_DOMAIN = Setting('SAAS_DRUPAL_DOMAIN', + '%(site_name)s.drupal.{}'.format(ORCHESTRA_BASE_DOMAIN), + help_text="Uses ORCHESTRA_BASE_DOMAIN by default.", +) + + +# PhpList + +SAAS_PHPLIST_ALLOW_CUSTOM_URL = Setting('SAAS_PHPLIST_ALLOW_CUSTOM_URL', + False, + help_text=_("Whether allow custom URL to be specified or not."), + validators=[validators.validate_website_saas_directives('phplist-saas')], +) + +SAAS_PHPLIST_DB_USER = Setting('SAAS_PHPLIST_DB_USER', + 'phplist_mu', + help_text=_("Needed for password changing support."), +) + +SAAS_PHPLIST_DB_PASS = Setting('SAAS_PHPLIST_DB_PASS', + 'secret', + help_text=_("Needed for password changing support."), +) + +SAAS_PHPLIST_DB_NAME = Setting('SAAS_PHPLIST_DB_NAME', + 'phplist_mu_%(site_name)s', + help_text=_("Needed for password changing support."), +) + +SAAS_PHPLIST_DB_HOST = Setting('SAAS_PHPLIST_DB_HOST', + 'loclahost', + help_text=_("Needed for password changing support."), +) + +SAAS_PHPLIST_BOUNCES_MAILBOX_NAME = Setting('SAAS_PHPLIST_BOUNCES_MAILBOX_NAME', + '%(site_name)s-list-bounces', +) + +SAAS_PHPLIST_NAME_MAX_LENGTH = Setting('SAAS_PHPLIST_NAME_MAX_LENGTH', + 32-13, + help_text=_("Because of max system group name of the bounces mailbox is 32."), +) + +SAAS_PHPLIST_BOUNCES_MAILBOX_PASSWORD = Setting('SAAS_PHPLIST_BOUNCES_MAILBOX_PASSWORD', + 'secret', +) + +SAAS_PHPLIST_DOMAIN = Setting('SAAS_PHPLIST_DOMAIN', + '%(site_name)s.lists.{}'.format(ORCHESTRA_BASE_DOMAIN), + help_text="Uses ORCHESTRA_BASE_DOMAIN by default.", +) + +SAAS_PHPLIST_VERIFY_SSL = Setting('SAAS_PHPLIST_VERIFY_SSL', + True, + help_text=_("Verify SSL certificate on the HTTP requests performed by the backend."), +) + +SAAS_PHPLIST_PATH = Setting('SAAS_PHPLIST_PATH', + '/var/www/phplist-mu', + help_text=_("Filesystem path to the phpList source code installed on the server. " + "Used by SAAS_PHPLIST_CRONTAB.") +) + +SAAS_PHPLIST_SYSTEMUSER = Setting('SAAS_PHPLIST_SYSTEMUSER', + 'root', + help_text=_("System user running phpList on the server." + "Used by SAAS_PHPLIST_CRONTAB.") +) + +SAAS_PHPLIST_CRONTAB = Setting('SAAS_PHPLIST_CRONTAB', + ('*/10 * * * * PHPLIST=%(phplist_path)s; export SITE="%(site_name)s"; php $PHPLIST/admin/index.php -c $PHPLIST/config/config.php -p processqueue > /dev/null\n' + '*/40 * * * * PHPLIST=%(phplist_path)s; export SITE="%(site_name)s"; php $PHPLIST/admin/index.php -c $PHPLIST/config/config.php -p processbounces > /dev/null'), + help_text=_("processqueue and processbounce phpList cron execution. " + "Left blank if you don't want crontab to be configured") +) + +SAAS_PHPLIST_MAIL_LOG_PATH = Setting('SAAS_PHPLIST_MAIL_LOG_PATH', + '/var/log/mail.log', +) + + +# SeaFile + +SAAS_SEAFILE_DOMAIN = Setting('SAAS_SEAFILE_DOMAIN', + 'seafile.{}'.format(ORCHESTRA_BASE_DOMAIN), + help_text="Uses ORCHESTRA_BASE_DOMAIN by default.", +) + +SAAS_SEAFILE_DEFAULT_QUOTA = Setting('SAAS_SEAFILE_DEFAULT_QUOTA', + 50 +) + + +# ownCloud + +SAAS_OWNCLOUD_DOMAIN = Setting('SAAS_OWNCLOUD_DOMAIN', + 'owncloud.{}'.format(ORCHESTRA_BASE_DOMAIN), + help_text="Uses ORCHESTRA_BASE_DOMAIN by default.", +) + +SAAS_OWNCLOUD_API_URL = Setting('SAAS_OWNCLOUD_API_URL', + 'https://admin:secret@owncloud.{}/ocs/v1.php/cloud/'.format(ORCHESTRA_BASE_DOMAIN), +) + +SAAS_OWNCLOUD_LOG_PATH = Setting('SAAS_OWNCLOUD_LOG_PATH', + '', + help_text=_('Filesystem path for the webserver access logs.
    ' + 'LogFormat "%h %l %u %t \"%r\" %>s %O \"%{Host}i\"" host'), +) + + +# nextCloud +SAAS_NEXTCLOUD_DOMAIN = Setting('SAAS_NEXTCLOUD_DOMAIN', + 'nextcloud.{}'.format(ORCHESTRA_BASE_DOMAIN), + help_text="Uses ORCHESTRA_BASE_DOMAIN by default.", +) + +SAAS_NEXTCLOUD_API_URL = Setting('SAAS_NEXTCLOUD_API_URL', + 'https://admin:secret@nextcloud.{}/ocs/v1.php/cloud'.format(ORCHESTRA_BASE_DOMAIN), +) + +SAAS_NEXTCLOUD_LOG_PATH = Setting('SAAS_NEXTCLOUD_LOG_PATH', + '', + help_text=_('Filesystem path for the webserver access logs.
    ' + 'LogFormat "%h %l %u %t \"%r\" %>s %O \"%{Host}i\"" host'), +) + + +# BSCW + +SAAS_BSCW_DOMAIN = Setting('SAAS_BSCW_DOMAIN', + 'bscw.{}'.format(ORCHESTRA_BASE_DOMAIN), + help_text="Uses ORCHESTRA_BASE_DOMAIN by default.", +) + +SAAS_BSCW_DEFAULT_QUOTA = Setting('SAAS_BSCW_DEFAULT_QUOTA', + 50, +) + +SAAS_BSCW_BSADMIN_PATH = Setting('SAAS_BSCW_BSADMIN_PATH', + '/home/httpd/bscw/bin/bsadmin', +) + + +# GitLab + +SAAS_GITLAB_ROOT_PASSWORD = Setting('SAAS_GITLAB_ROOT_PASSWORD', + 'secret', +) + +SAAS_GITLAB_DOMAIN = Setting('SAAS_GITLAB_DOMAIN', + 'gitlab.{}'.format(ORCHESTRA_BASE_DOMAIN), + help_text="Uses ORCHESTRA_BASE_DOMAIN by default.", +) + +SAAS_GITLAB_VERIFY_SSL = Setting('SAAS_GITLAB_VERIFY_SSL', + True, +) + +# Moodle + +SAAS_MOODLE_ALLOW_CUSTOM_URL = Setting('SAAS_MOODLE_ALLOW_CUSTOM_URL', + True, + help_text=_("Whether allow custom URL to be specified or not."), + validators=[validators.validate_website_saas_directives('moodle-saas')], +) + +SAAS_MOODLE_DB_USER = Setting('SAAS_MOODLE_DB_USER', + 'moodle_mu', + help_text=_("Needed for password changing support."), +) + +SAAS_MOODLE_DB_PASS = Setting('SAAS_MOODLE_DB_PASS', + 'secret', + help_text=_("Needed for password changing support."), +) + +SAAS_MOODLE_DB_NAME = Setting('SAAS_MOODLE_DB_NAME', + 'moodle_mu', + help_text=_("Needed for password changing support."), +) + +SAAS_MOODLE_DB_HOST = Setting('SAAS_MOODLE_DB_HOST', + 'loclahost', + help_text=_("Needed for password changing support."), +) + +SAAS_MOODLE_DOMAIN = Setting('SAAS_MOODLE_DOMAIN', + '%(site_name)s.courses.{}'.format(ORCHESTRA_BASE_DOMAIN), + help_text="Uses ORCHESTRA_BASE_DOMAIN by default.", +) + +SAAS_MOODLE_PATH = Setting('SAAS_MOODLE_PATH', + '/var/www/moodle-mu', + help_text=_("Filesystem path to the Moodle source code installed on the server. " + "Used by SAAS_MOODLE_CRONTAB.") +) + +SAAS_MOODLE_DATA_PATH = Setting('SAAS_MOODLE_DATA_PATH', + '/var/moodledata/%(site_name)s', + help_text=_("Filesystem path to the Moodle source code installed on the server. " + "Used by SAAS_MOODLE_CRONTAB.") +) + +SAAS_MOODLE_SYSTEMUSER = Setting('SAAS_MOODLE_SYSTEMUSER', + 'root', + help_text=_("System user running Moodle on the server." + "Used by SAAS_MOODLE_CRONTAB.") +) + +SAAS_MOODLE_CRONTAB = Setting('SAAS_MOODLE_CRONTAB', + '*/15 * * * * export SITE="%(site_name)s"; php %(moodle_path)s/admin/cli/cron.php >/dev/null', + help_text=_("Left blank if you don't want crontab to be configured") +) diff --git a/orchestra/contrib/saas/signals.py b/orchestra/contrib/saas/signals.py new file mode 100644 index 0000000..c3354b1 --- /dev/null +++ b/orchestra/contrib/saas/signals.py @@ -0,0 +1,22 @@ +from django.db.models.signals import pre_save, pre_delete +from django.dispatch import receiver + +from .models import SaaS + + +# Admin bulk deletion doesn't call model.delete() +# So, signals are used instead of model method overriding + +@receiver(pre_save, sender=SaaS, dispatch_uid='saas.service.save') +def type_save(sender, *args, **kwargs): + instance = kwargs['instance'] + instance.service_instance.save() + + +@receiver(pre_delete, sender=SaaS, dispatch_uid='saas.service.delete') +def type_delete(sender, *args, **kwargs): + instance = kwargs['instance'] + try: + instance.service_instance.delete() + except KeyError: + pass diff --git a/orchestra/contrib/saas/validators.py b/orchestra/contrib/saas/validators.py new file mode 100644 index 0000000..ac21bb8 --- /dev/null +++ b/orchestra/contrib/saas/validators.py @@ -0,0 +1,14 @@ +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +from orchestra.utils.apps import isinstalled + + +def validate_website_saas_directives(app): + def validator(enabled, app=app): + if enabled and isinstalled('orchestra.contrib.websites'): + from orchestra.contrib.websites import settings + if app not in settings.WEBSITES_SAAS_DIRECTIVES: + raise ValidationError(_("Allow custom URL is enabled for '%s', " + "but has no associated WEBSITES_SAAS_DIRECTIVES" % app)) + return validator diff --git a/orchestra/contrib/services/__init__.py b/orchestra/contrib/services/__init__.py new file mode 100644 index 0000000..cafed62 --- /dev/null +++ b/orchestra/contrib/services/__init__.py @@ -0,0 +1 @@ +default_app_config = 'orchestra.contrib.services.apps.ServicesConfig' diff --git a/orchestra/contrib/services/actions.py b/orchestra/contrib/services/actions.py new file mode 100644 index 0000000..4e48b2d --- /dev/null +++ b/orchestra/contrib/services/actions.py @@ -0,0 +1,84 @@ +from django.contrib.admin import helpers +from django.urls import reverse +from django.db import transaction +from django.shortcuts import render, redirect +from django.template.response import TemplateResponse +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ + +from orchestra.admin.utils import get_object_from_url + + +@transaction.atomic +def update_orders(modeladmin, request, queryset, extra_context=None): + if not queryset: + return + if request.POST.get('post') == 'confirmation': + num = 0 + services = [] + for service in queryset: + updates = service.update_orders() + num += len(updates) + services.append(str(service.pk)) + modeladmin.log_change(request, service, _("Orders updated")) + if num == 1: + url = reverse('admin:orders_order_change', args=(updates[0][0].pk,)) + msg = _('One related order has benn updated') % url + else: + url = reverse('admin:orders_order_changelist') + url += '?service__in=%s' % ','.join(services) + msg = _('%s related orders have been updated') % (url, num) + modeladmin.message_user(request, mark_safe(msg)) + return + updates = [] + for service in queryset: + updates += service.update_orders(commit=False) + opts = modeladmin.model._meta + context = { + 'title': _("Update orders will cause the following."), + 'action_name': 'Update orders', + 'action_value': 'update_orders', + 'updates': updates, + 'queryset': queryset, + 'opts': opts, + 'app_label': opts.app_label, + 'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME, + 'obj': get_object_from_url(modeladmin, request), + } + return render(request, 'admin/services/service/update_orders.html', context) +update_orders.url_name = 'update-orders' +update_orders.short_description = _("Update orders") + + +def view_help(modeladmin, request, queryset): + opts = modeladmin.model._meta + context = { + 'title': _("Need some help?"), + 'opts': opts, + 'queryset': queryset, + 'obj': queryset.get(), + 'action_name': _("help"), + 'app_label': opts.app_label, + } + return TemplateResponse(request, 'admin/services/service/help.html', context) +view_help.url_name = 'help' +view_help.tool_description = _("Help") + + +def clone(modeladmin, request, queryset): + service = queryset.get() + fields = modeladmin.get_fields(request) + query = [] + for field in fields: + model_field = type(service)._meta.get_field(field) + if model_field.rel: + value = getattr(service, field + '_id') + elif 'Boolean' in model_field.__class__.__name__: + value = 'True' if getattr(service, field) else '' + else: + value = getattr(service, field) + query.append('%s=%s' % (field, value)) + opts = service._meta + url = reverse('admin:%s_%s_add' % (opts.app_label, opts.model_name)) + url += '?%s' % '&'.join(query) + return redirect(url) diff --git a/orchestra/contrib/services/admin.py b/orchestra/contrib/services/admin.py new file mode 100644 index 0000000..2f4b835 --- /dev/null +++ b/orchestra/contrib/services/admin.py @@ -0,0 +1,107 @@ +from django import forms +from django.urls import re_path as url +from django.contrib import admin +from django.urls import reverse +from django.template.response import TemplateResponse +from django.utils import timezone +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ + +from orchestra.admin import ChangeViewActionsMixin +from orchestra.admin.actions import disable, enable +from orchestra.core import services + +from .actions import update_orders, view_help, clone +from .models import Service + + +class ServiceAdmin(ChangeViewActionsMixin, admin.ModelAdmin): + list_display = ( + 'description', 'content_type', 'handler_type', 'num_orders', 'is_active' + ) + list_filter = ( + 'is_active', 'handler_type', 'is_fee', + ('content_type', admin.RelatedOnlyFieldListFilter), + ) + fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('description', 'content_type', 'match', 'periodic_update', + 'handler_type', 'ignore_superusers', 'is_active') + }), + (_("Billing options"), { + 'classes': ('wide',), + 'fields': ('billing_period', 'billing_point', 'is_fee', 'order_description', + 'ignore_period') + }), + (_("Pricing options"), { + 'classes': ('wide',), + 'fields': ('metric', 'pricing_period', 'rate_algorithm', + 'on_cancel', 'payment_style', 'tax', 'nominal_price') + }), + ) + actions = (update_orders, clone, disable, enable) + change_view_actions = actions + (view_help,) + change_form_template = 'admin/services/service/change_form.html' + + def get_urls(self): + """Returns the additional urls for the change view links""" + urls = super(ServiceAdmin, self).get_urls() + admin_site = self.admin_site + opts = self.model._meta + return [ + url('^add/help/$', + admin_site.admin_view(self.help_view), + name='%s_%s_help' % (opts.app_label, opts.model_name) + ) + ] + urls + + def formfield_for_dbfield(self, db_field, **kwargs): + """ Improve performance of account field and filter by account """ + if db_field.name == 'content_type': + models = [model._meta.model_name for model in services.get()] + queryset = db_field.remote_field.model.objects + kwargs['queryset'] = queryset.filter(model__in=models) + if db_field.name in ['match', 'metric', 'order_description']: + kwargs['widget'] = forms.TextInput(attrs={'size':'160'}) + return super(ServiceAdmin, self).formfield_for_dbfield(db_field, **kwargs) + + def num_orders(self, service): + num = service.orders__count + url = reverse('admin:orders_order_changelist') + url += '?service__id__exact=%i&is_active=True' % service.pk + return format_html('{}', url, num) + num_orders.short_description = _("Orders") + num_orders.admin_order_field = 'orders__count' + + def get_queryset(self, request): + qs = super(ServiceAdmin, self).get_queryset(request) + # Count active orders + qs = qs.extra(select={ + 'orders__count': ( + "SELECT COUNT(*) " + "FROM orders_order " + "WHERE orders_order.service_id = services_service.id AND (" + " orders_order.cancelled_on IS NULL OR" + " orders_order.cancelled_on > '%s' " + ")" % timezone.now() + ) + }) + return qs + + def help_view(self, request, *args): + opts = self.model._meta + context = { + 'add': True, + 'title': _("Need some help?"), + 'opts': opts, + 'obj': args[0].get() if args else None, + 'action_name': _("help"), + 'app_label': opts.app_label, + } + return TemplateResponse(request, 'admin/services/service/help.html', context) + help_view.url_name = 'help' + help_view.verbose_name = _("Help") + + +admin.site.register(Service, ServiceAdmin) diff --git a/orchestra/contrib/services/apps.py b/orchestra/contrib/services/apps.py new file mode 100644 index 0000000..0b6d76e --- /dev/null +++ b/orchestra/contrib/services/apps.py @@ -0,0 +1,14 @@ +from django.apps import AppConfig + +from orchestra.core import administration +from orchestra.core.translations import ModelTranslation + + +class ServicesConfig(AppConfig): + name = 'orchestra.contrib.services' + verbose_name = 'Services' + + def ready(self): + from .models import Service + administration.register(Service, icon='price.png') + ModelTranslation.register(Service, ('description',)) diff --git a/orchestra/contrib/services/handlers.py b/orchestra/contrib/services/handlers.py new file mode 100644 index 0000000..64ceaf8 --- /dev/null +++ b/orchestra/contrib/services/handlers.py @@ -0,0 +1,662 @@ +import calendar +import datetime +import decimal +import math +from functools import cmp_to_key + +from dateutil import relativedelta +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.utils import timezone, translation +from django.utils.translation import gettext, gettext_lazy as _ + +from orchestra import plugins +from orchestra.utils.humanize import text2int +from orchestra.utils.python import AttrDict, format_exception + +from . import settings, helpers + + +class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount): + """ + Separates all the logic of billing handling from the model allowing to better + customize its behaviout + + Relax and enjoy the journey. + """ + _PLAN = 'plan' + _COMPENSATION = 'compensation' + _PREPAY = 'prepay' + + model = None + + def __init__(self, service): + self.service = service + + def __getattr__(self, attr): + if attr.startswith('__'): + raise AttributeError(f'{self} does not have attribute {attr}') + return getattr(self.service, attr) + + @classmethod + def get_choices(cls): + choices = super().get_choices() + return [('', _("Default"))] + choices + + def validate_content_type(self, service): + pass + + def validate_expression(self, service, method): + try: + obj = service.content_type.model_class().objects.all()[0] + except IndexError: + return + try: + bool(getattr(self, method)(obj)) + except Exception as exc: + raise ValidationError(format_exception(exc)) + + def validate_match(self, service): + if not service.match: + service.match = 'True' + self.validate_expression(service, 'matches') + + def validate_metric(self, service): + 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: + return self.content_type + 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, + 'gettext': gettext, + '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 = self.get_expression_context(instance) + return eval(self.match, safe_locals) + + def get_ignore_delta(self): + if self.ignore_period == self.NEVER: + return None + value, unit = self.ignore_period.split('_') + value = text2int(value) + if unit.lower().startswith('day'): + return datetime.timedelta(days=value) + if unit.lower().startswith('month'): + return datetime.timedelta(months=value) + else: + raise ValueError("Unknown unit %s" % unit) + + def get_order_ignore(self, order): + """ service trial delta """ + ignore_delta = self.get_ignore_delta() + if ignore_delta and (order.cancelled_on-ignore_delta).date() <= order.registered_on: + return True + return order.ignore + + def get_ignore(self, instance): + if self.ignore_superusers: + account = getattr(instance, 'account', instance) + if account.type in settings.SERVICES_IGNORE_ACCOUNT_TYPE: + return True + if 'superuser' in settings.SERVICES_IGNORE_ACCOUNT_TYPE and account.is_superuser: + return True + return False + + def get_metric(self, instance): + if self.metric: + safe_locals = self.get_expression_context(instance) + try: + return eval(self.metric, safe_locals) + except Exception as exc: + raise type(exc)("'%s' evaluating metric for '%s' service" % (exc, self.service)) + + def get_order_description(self, instance): + safe_locals = self.get_expression_context(instance) + account = getattr(instance, 'account', instance) + with translation.override(account.language): + if not self.order_description: + return '%s: %s' % (gettext(self.description), instance) + return eval(self.order_description, safe_locals) + + def get_billing_point(self, order, bp=None, **options): + cachable = bool(self.billing_point == self.FIXED_DATE and not options.get('fixed_point')) + if not cachable or bp is None: + bp = options.get('billing_point') or timezone.now().date() + if not options.get('fixed_point'): + msg = ("Support for '%s' period and '%s' point is not implemented" + % (self.get_billing_period_display(), self.get_billing_point_display())) + if self.billing_period == self.MONTHLY: + date = bp + if self.payment_style == self.PREPAY: + date += relativedelta.relativedelta(months=1) + else: + date = timezone.now().date() + if self.billing_point == self.ON_REGISTER: + # handle edge cases of last day of the month: + # e.g. on March is 31 but on April 30 + last_day_of_month = calendar.monthrange(date.year, date.month)[1] + day = min(last_day_of_month, order.registered_on.day) + elif self.billing_point == self.FIXED_DATE: + day = 1 + else: + raise NotImplementedError(msg) + bp = datetime.date(year=date.year, month=date.month, day=day) + elif self.billing_period == self.ANUAL: + if self.billing_point == self.ON_REGISTER: + month = order.registered_on.month + day = order.registered_on.day + elif self.billing_point == self.FIXED_DATE: + month = settings.SERVICES_SERVICE_ANUAL_BILLING_MONTH + day = 1 + else: + raise NotImplementedError(msg) + year = bp.year + if self.payment_style == self.POSTPAY: + year = bp.year - relativedelta.relativedelta(years=1) + if bp.month >= month: + year = bp.year + 1 + + # handle edge cases of last day of the month: + # e.g. on March is 31 but on April 30 + last_day_of_month = calendar.monthrange(year,month)[1] + day = min(last_day_of_month, day) + bp = datetime.date(year=year, month=month, day=day) + elif self.billing_period == self.NEVER: + bp = order.registered_on + else: + raise NotImplementedError(msg) + if self.on_cancel != self.NOTHING and order.cancelled_on and order.cancelled_on < bp: + bp = order.cancelled_on + return bp + +# def aligned(self, date): +# if self.granularity == self.DAILY: +# return date +# elif self.granularity == self.MONTHLY: +# return datetime.date(year=date.year, month=date.month, day=1) +# elif self.granularity == self.ANUAL: +# return datetime.date(year=date.year, month=1, day=1) +# raise NotImplementedError + + def get_price_size(self, ini, end): + rdelta = relativedelta.relativedelta(end, ini) + anual_prepay_of_monthly_pricing = bool( + self.billing_period == self.ANUAL and + self.payment_style == self.PREPAY and + self.get_pricing_period() == self.MONTHLY) + if self.billing_period == self.MONTHLY or anual_prepay_of_monthly_pricing: + size = rdelta.years * 12 + size += rdelta.months + days = calendar.monthrange(end.year, end.month)[1] + size += decimal.Decimal(str(rdelta.days))/days + elif self.billing_period == self.ANUAL: + size = rdelta.years + size += decimal.Decimal(str(rdelta.months))/12 + days = 366 if calendar.isleap(end.year) else 365 + size += decimal.Decimal(str(rdelta.days))/days + elif self.billing_period == self.NEVER: + size = 1 + else: + raise NotImplementedError + size = round(size, 2) + return decimal.Decimal(str(size)) + + def get_pricing_slots(self, ini, end): + day = 1 + month = settings.SERVICES_SERVICE_ANUAL_BILLING_MONTH + if self.billing_point == self.ON_REGISTER: + day = ini.day + month = ini.month + period = self.get_pricing_period() + rdelta = self.get_pricing_rdelta() + if period == self.MONTHLY: + ini = datetime.date(year=ini.year, month=ini.month, day=day) + elif period == self.ANUAL: + ini = datetime.date(year=ini.year, month=month, day=day) + elif period == self.NEVER: + yield ini, end + raise StopIteration + else: + raise NotImplementedError + while True: + next = ini + rdelta + yield ini, next + if next >= end: + break + ini = next + + def get_pricing_rdelta(self): + period = self.get_pricing_period() + if period == self.MONTHLY: + return relativedelta.relativedelta(months=1) + elif period == self.ANUAL: + return relativedelta.relativedelta(years=1) + elif period == self.NEVER: + return None + + def generate_discount(self, line, dtype, price): + line.discounts.append(AttrDict(**{ + 'type': dtype, + 'total': price, + })) + + def generate_line(self, order, price, *dates, metric=1, discounts=None, computed=False): + """ + discounts: extra discounts to apply + computed: price = price*size already performed + """ + if len(dates) == 2: + ini, end = dates + elif len(dates) == 1: + ini, end = dates[0], dates[0] + else: + raise AttributeError("WTF is '%s'?" % dates) + discounts = discounts or () + + size = self.get_price_size(ini, end) + if not computed: + price = price * size + subtotal = self.nominal_price * size * metric + line = AttrDict(**{ + 'order': order, + 'subtotal': subtotal, + 'ini': ini, + 'end': end, + 'size': size, + 'metric': metric, + 'discounts': [], + }) + + if subtotal > price: + plan_discount = price-subtotal + self.generate_discount(line, self._PLAN, plan_discount) + subtotal += plan_discount + for dtype, dprice in discounts: + subtotal += dprice + # Prevent compensations/prepays to refund money + if dtype in (self._COMPENSATION, self._PREPAY) and subtotal < 0: + dprice -= subtotal + if dprice: + self.generate_discount(line, dtype, dprice) + return line + + def assign_compensations(self, givers, receivers, **options): + compensations = [] + for order in givers: + if order.billed_until and order.cancelled_on and order.cancelled_on < order.billed_until: + interval = helpers.Interval(order.cancelled_on, order.billed_until, order) + compensations.append(interval) + for order in receivers: + if not order.billed_until or order.billed_until < order.new_billed_until: + # receiver + ini = order.billed_until or order.registered_on + end = order.cancelled_on or datetime.date.max + interval = helpers.Interval(ini, end) + compensations, used_compensations = helpers.compensate(interval, compensations) + order._compensations = used_compensations + for comp in used_compensations: + comp.order.new_billed_until = min(comp.order.billed_until, comp.ini, + getattr(comp.order, 'new_billed_until', datetime.date.max)) + if options.get('commit', True): + for order in givers: + if hasattr(order, 'new_billed_until'): + order.billed_until = order.new_billed_until + order.save(update_fields=['billed_until']) + + def apply_compensations(self, order, only_beyond=False): + dsize = 0 + ini = order.billed_until or order.registered_on + end = order.new_billed_until + beyond = end + cend = None + new_end = None + for comp in getattr(order, '_compensations', []): + intersect = comp.intersect(helpers.Interval(ini=ini, end=end)) + if intersect: + cini, cend = intersect.ini, intersect.end + if comp.end > beyond: + cend = comp.end + new_end = cend + if only_beyond: + cini = beyond + elif only_beyond: + continue + dsize += self.get_price_size(cini, cend) + # Extend billing point a little bit to benefit from a substantial discount + elif comp.end > beyond and (comp.end-comp.ini).days > 3*(comp.ini-beyond).days: + cend = comp.end + new_end = cend + dsize += self.get_price_size(comp.ini, cend) + return dsize, new_end + + def get_register_or_renew_events(self, porders, ini, end): + counter = 0 + for order in porders: + bu = getattr(order, 'new_billed_until', order.billed_until) + if bu: + registered = order.registered_on + if registered > ini and registered <= end: + counter += 1 + if registered != bu and bu > ini and bu <= end: + counter += 1 + if order.billed_until and order.billed_until != bu: + if registered != order.billed_until and order.billed_until > ini and order.billed_until <= end: + counter += 1 + return counter + + def bill_concurrent_orders(self, account, porders, rates, ini, end): + # Concurrent + # Get pricing orders + priced = {} + for ini, end, orders in helpers.get_chunks(porders, ini, end): + size = self.get_price_size(ini, end) + metric = len(orders) + interval = helpers.Interval(ini=ini, end=end) + for position, order in enumerate(orders, start=1): + csize = 0 + compensations = getattr(order, '_compensations', []) + # Compensations < new_billed_until + for comp in compensations: + intersect = comp.intersect(interval) + if intersect: + csize += self.get_price_size(intersect.ini, intersect.end) + price = self.get_price(account, metric, position=position, rates=rates) + cprice = price * csize + price = price * size + if order in priced: + priced[order][0] += price + priced[order][1] += cprice + else: + priced[order] = [price, cprice] + lines = [] + for order, prices in priced.items(): + if hasattr(order, 'new_billed_until'): + discounts = () + # Generate lines and discounts from order.nominal_price + price, cprice = prices + a = order.id + # Compensations > new_billed_until + dsize, new_end = self.apply_compensations(order, only_beyond=True) + cprice += dsize*price + if cprice: + discounts = ( + (self._COMPENSATION, -cprice), + ) + if new_end: + size = self.get_price_size(order.new_billed_until, new_end) + price += price*size + order.new_billed_until = new_end + ini = order.billed_until or order.registered_on + end = new_end or order.new_billed_until + line = self.generate_line( + order, price, ini, end, discounts=discounts, computed=True) + lines.append(line) + return lines + + def bill_registered_or_renew_events(self, account, porders, rates): + # Before registration + lines = [] + rdelta = self.get_pricing_rdelta() + if not rdelta: + raise NotImplementedError + for position, order in enumerate(porders, start=1): + if hasattr(order, 'new_billed_until'): + pend = order.billed_until or order.registered_on + pini = pend - rdelta + metric = self.get_register_or_renew_events(porders, pini, pend) + position = min(position, metric) + price = self.get_price(account, metric, position=position, rates=rates) + ini = order.billed_until or order.registered_on + end = order.new_billed_until + discounts = () + dsize, new_end = self.apply_compensations(order) + if dsize: + discounts=( + (self._COMPENSATION, -dsize*price), + ) + if new_end: + order.new_billed_until = new_end + end = new_end + line = self.generate_line(order, price, ini, end, discounts=discounts) + lines.append(line) + return lines + + def bill_with_orders(self, orders, account, **options): + # For the "boundary conditions" just think that: + # date(2011, 1, 1) is equivalent to datetime(2011, 1, 1, 0, 0, 0) + # In most cases: + # ini >= registered_date, end < registered_date + # boundary lookup and exclude cancelled and billed + orders_ = [] + bp = None + ini = datetime.date.max + end = datetime.date.min + for order in orders: + cini = order.registered_on + if order.billed_until: + # exclude cancelled and billed + if self.on_cancel != self.REFUND: + if order.cancelled_on and order.billed_until > order.cancelled_on: + continue + cini = order.billed_until + bp = self.get_billing_point(order, bp=bp, **options) + if order.billed_until and order.billed_until >= bp: + continue + order.new_billed_until = bp + ini = min(ini, cini) + end = max(end, bp) + orders_.append(order) + orders = orders_ + + # Compensation + related_orders = account.orders.filter(service=self.service) + if self.payment_style == self.PREPAY and self.on_cancel == self.COMPENSATE: + # Get orders pending for compensation + givers = list(related_orders.givers(ini, end)) + givers = sorted(givers, key=cmp_to_key(helpers.cmp_billed_until_or_registered_on)) + orders = sorted(orders, key=cmp_to_key(helpers.cmp_billed_until_or_registered_on)) + self.assign_compensations(givers, orders, **options) + rates = self.get_rates(account) + has_billing_period = self.billing_period != self.NEVER + has_pricing_period = self.get_pricing_period() != self.NEVER + if rates and (has_billing_period or has_pricing_period): + concurrent = has_billing_period and not has_pricing_period + if not concurrent: + rdelta = self.get_pricing_rdelta() + ini -= rdelta + porders = related_orders.pricing_orders(ini, end) + porders = list(set(orders).union(set(porders))) + porders = sorted(porders, key=cmp_to_key(helpers.cmp_billed_until_or_registered_on)) + if concurrent: + # Periodic billing with no pricing period + lines = self.bill_concurrent_orders(account, porders, rates, ini, end) + else: + # Periodic and one-time billing with pricing period + lines = self.bill_registered_or_renew_events(account, porders, rates) + else: + # No rates optimization or one-time billing without pricing period + lines = [] + price = self.nominal_price + # Calculate nominal price + for order in orders: + ini = order.billed_until or order.registered_on + end = order.new_billed_until + discounts = () + dsize, new_end = self.apply_compensations(order) + if dsize: + discounts=( + (self._COMPENSATION, -dsize*price), + ) + if new_end: + order.new_billed_until = new_end + end = new_end + line = self.generate_line(order, price, ini, end, discounts=discounts) + lines.append(line) + return lines + + def bill_with_metric(self, orders, account, **options): + lines = [] + bp = None + for order in orders: + prepay_discount = 0 + bp = self.get_billing_point(order, bp=bp, **options) + recharged_until = datetime.date.min + + if (self.billing_period != self.NEVER and + self.get_pricing_period() == self.NEVER and + self.payment_style == self.PREPAY and order.billed_on): + # Recharge + if self.payment_style == self.PREPAY and order.billed_on: + recharges = [] + rini = order.billed_on + rend = min(bp, order.billed_until) + bmetric = order.billed_metric + if bmetric is None: + bmetric = order.get_metric(order.billed_on) + bsize = self.get_price_size(rini, rend) + prepay_discount = self.get_price(account, bmetric) * bsize + prepay_discount = round(prepay_discount, 2) + for cini, cend, metric in order.get_metric(rini, rend, changes=True): + cini = max(cini, rini) + size = self.get_price_size(cini, cend) + price = self.get_price(account, metric) * size + discounts = () + discount = min(price, max(prepay_discount, 0)) + prepay_discount -= price + if discount > 0: + price -= discount + discounts = ( + (self._PREPAY, -discount), + ) + # Don't overdload bills with lots of lines + if price > 0: + recharges.append((order, price, cini, cend, metric, discounts)) + if prepay_discount < 0: + # User has prepaid less than the actual consumption + for order, price, cini, cend, metric, discounts in recharges: + if discounts: + price -= discounts[0][1] + line = self.generate_line(order, price, cini, cend, metric=metric, + computed=True, discounts=discounts) + lines.append(line) + recharged_until = cend + if order.billed_until and order.cancelled_on and order.cancelled_on >= order.billed_until: + # Cancelled order + continue + if self.billing_period != self.NEVER: + ini = order.billed_until or order.registered_on +# ini = max(order.billed_until or order.registered_on, recharged_until) + # Periodic billing + if bp <= ini: + # Already billed + continue + order.new_billed_until = bp + if self.get_pricing_period() == self.NEVER: + # Changes (Mailbox disk-like) + for cini, cend, metric in order.get_metric(ini, bp, changes=True): + cini = max(recharged_until, cini) + price = self.get_price(account, metric) + discounts = () + # Since the current datamodel can't guarantee to retrieve the exact + # state for calculating prepay_discount (service price could have change) + # maybe is it better not to discount anything. +# discount = min(price, max(prepay_discount, 0)) +# if discount > 0: +# price -= discount +# prepay_discount -= discount +# discounts = ( +# (self._PREPAY, -discount), +# ) + if metric > 0: + line = self.generate_line(order, price, cini, cend, metric=metric, + discounts=discounts) + lines.append(line) + elif self.get_pricing_period() == self.billing_period: + # pricing_slots (Traffic-like) + if self.payment_style == self.PREPAY: + raise NotImplementedError( + "Metric with prepay and pricing_period == billing_period") + for cini, cend in self.get_pricing_slots(ini, bp): + metric = order.get_metric(cini, cend) + price = self.get_price(account, metric) + discounts = () +# discount = min(price, max(prepay_discount, 0)) +# if discount > 0: +# price -= discount +# prepay_discount -= discount +# discounts = ( +# (self._PREPAY, -discount), +# ) + if metric > 0: + line = self.generate_line(order, price, cini, cend, metric=metric, + discounts=discounts) + lines.append(line) + elif self.get_pricing_period() in (self.MONTHLY, self.ANUAL): + if self.payment_style == self.PREPAY: + # Traffic Prepay + metric = order.get_metric(timezone.now().date()) + if metric > 0: + price = self.get_price(account, metric) + for cini, cend in self.get_pricing_slots(ini, bp): + line = self.generate_line(order, price, cini, cend, metric=metric) + lines.append(line) + else: + raise NotImplementedError( + "Metric with postpay and pricing_period in (monthly, anual)") + else: + raise NotImplementedError + else: + # One-time billing + if order.billed_until: + continue + date = timezone.now().date() + order.new_billed_until = date + if self.get_pricing_period() == self.NEVER: + # get metric (Job-like) + metric = order.get_metric(date) + price = self.get_price(account, metric) + line = self.generate_line(order, price, date, metric=metric) + lines.append(line) + else: + raise NotImplementedError + # Last processed metric for futrue recharges + order.new_billed_metric = metric + return lines + + def generate_bill_lines(self, orders, account, **options): + if options.get('proforma', False): + options['commit'] = False + if not self.metric: + lines = self.bill_with_orders(orders, account, **options) + else: + lines = self.bill_with_metric(orders, account, **options) + if options.get('commit', True): + now = timezone.now().date() + for line in lines: + order = line.order + order.billed_on = now + order.billed_metric = getattr(order, 'new_billed_metric', order.billed_metric) + order.billed_until = getattr(order, 'new_billed_until', order.billed_until) + order.save(update_fields=('billed_on', 'billed_until', 'billed_metric')) + return lines diff --git a/orchestra/contrib/services/helpers.py b/orchestra/contrib/services/helpers.py new file mode 100644 index 0000000..6804659 --- /dev/null +++ b/orchestra/contrib/services/helpers.py @@ -0,0 +1,149 @@ +from django.utils.text import format_lazy +from django.utils.translation import gettext_lazy + + +def get_chunks(porders, ini, end, ix=0): + if ix >= len(porders): + return [[ini, end, []]] + order = porders[ix] + ix += 1 + bu = getattr(order, 'new_billed_until', order.billed_until) + if not bu or bu <= ini or order.registered_on >= end: + return get_chunks(porders, ini, end, ix=ix) + result = [] + if order.registered_on < end and order.registered_on > ini: + ro = order.registered_on + result = get_chunks(porders, ini, ro, ix=ix) + ini = ro + if bu < end: + result += get_chunks(porders, bu, end, ix=ix) + end = bu + chunks = get_chunks(porders, ini, end, ix=ix) + for chunk in chunks: + chunk[2].insert(0, order) + result.append(chunk) + return result + + +def cmp_billed_until_or_registered_on(a, b): + """ + 1) billed_until greater first + 2) registered_on smaller first + """ + if a.billed_until == b.billed_until: + # Use pk which is more reliable than registered_on date + return a.id-b.id + elif a.billed_until and b.billed_until: + return (b.billed_until-a.billed_until).days + elif a.billed_until: + return (b.registered_on-a.billed_until).days + return (b.billed_until-a.registered_on).days + + +class Interval(object): + def __init__(self, ini, end, order=None): + self.ini = ini + self.end = end + self.order = order + + def __len__(self): + return max((self.end-self.ini).days, 0) + + def __sub__(self, other): + remaining = [] + if self.ini < other.ini: + remaining.append(Interval(self.ini, min(self.end, other.ini), self.order)) + if self.end > other.end: + remaining.append(Interval(max(self.ini,other.end), self.end, self.order)) + return remaining + + def __repr__(self): + return "".format( + ini=self.ini.strftime('%Y-%-m-%-d'), + end=self.end.strftime('%Y-%-m-%-d') + ) + + def intersect(self, other, remaining_self=None, remaining_other=None): + if remaining_self is not None: + remaining_self += (self - other) + if remaining_other is not None: + remaining_other += (other - self) + result = Interval(max(self.ini, other.ini), min(self.end, other.end), self.order) + if len(result)>0: + return result + else: + return None + + def intersect_set(self, others, remaining_self=None, remaining_other=None): + intersections = [] + for interval in others: + intersection = self.intersect(interval, remaining_self, remaining_other) + if intersection: + intersections.append(intersection) + return intersections + + +def get_intersections(order_intervals, compensations): + intersections = [] + for compensation in compensations: + intersection = compensation.intersect_set(order_intervals) + length = 0 + for intersection_interval in intersection: + length += len(intersection_interval) + intersections.append((length, compensation)) + return sorted(intersections, key=lambda i: i[0]) + + +def intersect(compensation, order_intervals): + # Intervals should not overlap + compensated = [] + not_compensated = [] + unused_compensation = [] + for interval in order_intervals: + compensated.append(compensation.intersect(interval, unused_compensation, not_compensated)) + return (compensated, not_compensated, unused_compensation) + + +def apply_compensation(order, compensation): + remaining_order = [] + remaining_compensation = [] + applied_compensation = compensation.intersect_set(order, remaining_compensation, remaining_order) + return applied_compensation, remaining_order, remaining_compensation + + +def update_intersections(not_compensated, compensations): + # TODO can be optimized + compensation_intervals = [] + for __, compensation in compensations: + compensation_intervals.append(compensation) + return get_intersections(not_compensated, compensation_intervals) + + +def compensate(order, compensations): + remaining_interval = [order] + ordered_intersections = get_intersections(remaining_interval, compensations) + applied_compensations = [] + remaining_compensations = [] + while ordered_intersections and ordered_intersections[len(ordered_intersections)-1][0]>0: + # Apply the first compensation: + __, compensation = ordered_intersections.pop() + (applied_compensation, remaining_interval, remaining_compensation) = apply_compensation(remaining_interval, compensation) + remaining_compensations += remaining_compensation + applied_compensations += applied_compensation + ordered_intersections = update_intersections(remaining_interval, ordered_intersections) + for __, compensation in ordered_intersections: + remaining_compensations.append(compensation) + return remaining_compensations, applied_compensations + + +def get_rate_methods_help_text(rate_class): + method_help_texts = [ + format_lazy('{}' * 4, *['
      ', method.verbose_name, ': ', method.help_text]) + for method in rate_class.get_methods().values() + ] + prefix = gettext_lazy("Algorithm used to interprete the rating table.") + help_text_items = [prefix] + method_help_texts + return format_lazy( + '{}' * len(help_text_items), + *help_text_items + ) diff --git a/orchestra/contrib/services/models.py b/orchestra/contrib/services/models.py new file mode 100644 index 0000000..0dc8bb8 --- /dev/null +++ b/orchestra/contrib/services/models.py @@ -0,0 +1,266 @@ +import calendar +import decimal +from orchestra.contrib.services import helpers + +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.apps import apps +from django.utils.functional import cached_property +from django.utils.module_loading import autodiscover_modules +from django.utils.translation import gettext_lazy as _ + +from orchestra.core import caches, validators +from orchestra.utils.python import import_class + +from . import settings +from .handlers import ServiceHandler +from .helpers import get_rate_methods_help_text + + +autodiscover_modules('handlers') + +rate_class = import_class(settings.SERVICES_RATE_CLASS) + + +class ServiceQuerySet(models.QuerySet): + def filter_by_instance(self, instance): + cache = caches.get_request_cache() + ct = ContentType.objects.get_for_model(instance) + key = 'services.Service-%i' % ct.pk + services = cache.get(key) + if services is None: + services = self.filter(content_type=ct, is_active=True) + cache.set(key, services) + return services + + +class Service(models.Model): + NEVER = '' +# DAILY = 'DAILY' + MONTHLY = 'MONTHLY' + ANUAL = 'ANUAL' + ONE_DAY = 'ONE_DAY' + TWO_DAYS = 'TWO_DAYS' + TEN_DAYS = 'TEN_DAYS' + ONE_MONTH = 'ONE_MONTH' + ALWAYS = 'ALWAYS' + ON_REGISTER = 'ON_REGISTER' + FIXED_DATE = 'ON_FIXED_DATE' + BILLING_PERIOD = 'BILLING_PERIOD' + REGISTER_OR_RENEW = 'REGISTER_OR_RENEW' + CONCURRENT = 'CONCURRENT' + NOTHING = 'NOTHING' + DISCOUNT = 'DISCOUNT' + COMPENSATE = 'COMPENSATE' + REFUND = 'REFUND' + PREPAY = 'PREPAY' + POSTPAY = 'POSTPAY' + + _ignore_types = ' and '.join( + ', '.join(settings.SERVICES_IGNORE_ACCOUNT_TYPE).rsplit(', ', 1)).lower() + + description = models.CharField(_("description"), max_length=256, unique=True) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, + verbose_name=_("content type"), + help_text=_("Content type of the related service objects.")) + match = models.CharField(_("match"), max_length=256, blank=True, + help_text=_( + "Python expression " + "that designates wheter a content_type object is related to this service " + "or not, always evaluates True when left blank. " + "Related instance can be instantiated with instance keyword or " + "content_type.model_name.
    " + " databaseuser.type == 'MYSQL'
    " + " miscellaneous.active and str(miscellaneous.identifier).endswith(('.org', '.net', '.com'))
    " + " contractedplan.plan.name == 'association_fee''
    " + " instance.active")) + periodic_update = models.BooleanField(_("periodic update"), default=False, + help_text=_("Whether a periodic update of this service orders should be performed or not. " + "Needed for match definitions that depend on complex model interactions, " + "where content type model save and delete operations are not enought.")) + handler_type = models.CharField(_("handler"), max_length=256, blank=True, + help_text=_("Handler used for processing this Service. A handler enables customized " + "behaviour far beyond what options here allow to."), + choices=ServiceHandler.get_choices()) + is_active = models.BooleanField(_("active"), default=True) + ignore_superusers = models.BooleanField(_("ignore %s") % _ignore_types, default=True, + help_text=_("Designates whether %s orders are marked as ignored by default or not.") % _ignore_types) + # Billing + billing_period = models.CharField(_("billing period"), max_length=16, + help_text=_("Renewal period for recurring invoicing."), + choices=( + (NEVER, _("One time service")), + (MONTHLY, _("Monthly billing")), + (ANUAL, _("Anual billing")), + ), + default=ANUAL, blank=True) + billing_point = models.CharField(_("billing point"), max_length=16, + help_text=_("Reference point for calculating the renewal date on recurring invoices"), + choices=( + (ON_REGISTER, _("Registration date")), + (FIXED_DATE, _("Every %(month)s") % { + 'month': calendar.month_name[settings.SERVICES_SERVICE_ANUAL_BILLING_MONTH] + }), + ), + default=FIXED_DATE) + is_fee = models.BooleanField(_("fee"), default=False, + help_text=_("Designates whether this service should be billed as membership fee or not")) + order_description = models.CharField(_("Order description"), max_length=256, blank=True, + help_text=_( + "Python expression " + "used for generating the description for the bill lines of this services.
    " + "Defaults to '%s: %s' % (gettext(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. " + "Useful for designating trial periods"), + choices=( + (NEVER, _("Never")), + (ONE_DAY, _("One day")), + (TWO_DAYS, _("Two days")), + (TEN_DAYS, _("Ten days")), + (ONE_MONTH, _("One month")), + ), + default=settings.SERVICES_DEFAULT_IGNORE_PERIOD) + # Pricing + metric = models.CharField(_("metric"), max_length=256, blank=True, + help_text=_( + "Python expression " + "used for obtinging the metric value for the pricing rate computation. " + "Number of orders is used when left blank. Related instance can be instantiated " + "with instance keyword or content_type.model_name.
    " + " max((mailbox.resources.disk.allocated or 0) -1, 0)
    " + " miscellaneous.amount
    " + " max((account.resources.traffic.used or 0) -" + " getattr(account.miscellaneous.filter(is_active=True," + " service__name='traffic-prepay').last(), 'amount', 0), 0)")) + nominal_price = models.DecimalField(_("nominal price"), max_digits=12, + decimal_places=2) + tax = models.PositiveIntegerField(_("tax"), choices=settings.SERVICES_SERVICE_TAXES, + default=settings.SERVICES_SERVICE_DEFAULT_TAX) + pricing_period = models.CharField(_("pricing period"), max_length=16, blank=True, + help_text=_("Time period that is used for computing the rate metric."), + choices=( + (NEVER, _("Current value")), + (BILLING_PERIOD, _("Same as billing period")), + (MONTHLY, _("Monthly data")), + (ANUAL, _("Anual data")), + ), + default=BILLING_PERIOD) + rate_algorithm = models.CharField( + _("rate algorithm"), max_length=64, + choices=rate_class.get_choices(), + default=rate_class.get_default(), + help_text=get_rate_methods_help_text(rate_class), + ) + on_cancel = models.CharField(_("on cancel"), max_length=16, + help_text=_("Defines the cancellation behaviour of this service."), + choices=( + (NOTHING, _("Nothing")), + (DISCOUNT, _("Discount")), + (COMPENSATE, _("Compensat")), + (REFUND, _("Refund")), + ), + default=DISCOUNT) + payment_style = models.CharField(_("payment style"), max_length=16, + help_text=_("Designates whether this service should be paid after " + "consumtion (postpay/on demand) or prepaid."), + choices=( + (PREPAY, _("Prepay")), + (POSTPAY, _("Postpay (on demand)")), + ), + default=PREPAY) + + objects = ServiceQuerySet.as_manager() + + def __str__(self): + return self.description + + @cached_property + def handler(self): + """ Accessor of this service handler instance """ + if self.handler_type: + return ServiceHandler.get(self.handler_type)(self) + return ServiceHandler(self) + + def clean(self): + self.description = self.description.strip() + if hasattr(self, 'content_type'): + validators.all_valid({ + '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): + if self.pricing_period == self.BILLING_PERIOD: + return self.billing_period + return self.pricing_period + + def get_price(self, account, metric, rates=None, position=None): + """ + if position is provided an specific price for that position is returned, + accumulated price is returned otherwise + """ + if rates is None: + rates = self.get_rates(account) + if rates: + rates = self.rate_method(rates, metric) + if not rates: + rates = [{ + 'quantity': metric, + 'price': self.nominal_price, + }] + counter = 0 + if position is None: + ant_counter = 0 + accumulated = 0 + for rate in rates: + counter += rate['quantity'] + if counter >= metric: + counter = metric + accumulated += (counter - ant_counter) * rate['price'] + accumulated = round(accumulated, 2) + return decimal.Decimal(str(accumulated)) + ant_counter = counter + accumulated += rate['price'] * rate['quantity'] + raise RuntimeError("Rating algorithm bad result") + else: + if metric < position: + raise ValueError("Metric can not be less than the position.") + for rate in rates: + counter += rate['quantity'] + if counter >= position: + price = round(rate['price'], 2) + return decimal.Decimal(str(rate['price'])) + raise RuntimeError("Rating algorithm bad result") + + def get_rates(self, account, cache=True): + # rates are cached per account + if not cache: + return self.rates.by_account(account) + if not hasattr(self, '__cached_rates'): + self.__cached_rates = {} + try: + return self.__cached_rates[account.id] + except KeyError: + rates = self.rates.by_account(account) + self.__cached_rates[account.id] = rates + return rates + + @property + def rate_method(self): + return rate_class.get_methods()[self.rate_algorithm] + + def update_orders(self, commit=True): + order_model = apps.get_model(settings.SERVICES_ORDER_MODEL) + manager = order_model.objects + related_model = self.content_type.model_class() + updates = [] + queryset = related_model.objects.all() + if related_model._meta.model_name != 'account': + queryset = queryset.select_related('account').all() + for instance in queryset: + updates += manager.update_by_instance(instance, service=self, commit=commit) + return updates diff --git a/orchestra/contrib/services/settings.py b/orchestra/contrib/services/settings.py new file mode 100644 index 0000000..5c78306 --- /dev/null +++ b/orchestra/contrib/services/settings.py @@ -0,0 +1,51 @@ +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.settings import Setting + + +SERVICES_SERVICE_TAXES = Setting('SERVICES_SERVICE_TAXES', + ( + (0, _("Duty free")), + (21, "21%"), + ), + validators=[Setting.validate_choices] +) + + +SERVICES_SERVICE_DEFAULT_TAX = Setting('SERVICES_SERVICE_DEFAULT_TAX', + 0, + choices=SERVICES_SERVICE_TAXES +) + + +SERVICES_SERVICE_ANUAL_BILLING_MONTH = Setting('SERVICES_SERVICE_ANUAL_BILLING_MONTH', + 1, + choices=tuple(enumerate( + ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'), 1)) +) + + +SERVICES_ORDER_MODEL = Setting('SERVICES_ORDER_MODEL', + 'orders.Order', + validators=[Setting.validate_model_label] +) + + +SERVICES_RATE_CLASS = Setting('SERVICES_RATE_CLASS', + 'orchestra.contrib.plans.models.Rate', + validators=[Setting.validate_import_class] +) + + +SERVICES_DEFAULT_IGNORE_PERIOD = Setting('SERVICES_DEFAULT_IGNORE_PERIOD', + 'TEN_DAYS' +) + + +SERVICES_IGNORE_ACCOUNT_TYPE = Setting('SERVICES_IGNORE_ACCOUNT_TYPE', + ( + 'superuser', + 'STAFF', + 'FRIEND', + ), +) diff --git a/orchestra/contrib/services/static/services/img/services.png b/orchestra/contrib/services/static/services/img/services.png new file mode 100644 index 0000000000000000000000000000000000000000..0cf8792cb54ab665bc1ed5fde4e532f2008aa889 GIT binary patch literal 93392 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 + + + + + + + + + image/svg+xml + + + + + + + Orders + Metric + Periodicbilling + One-timeservice + Pricingperiod + No pricingperiod + Periodicbilling + One-timeservice + Pricingperiod + No pricingperiod + Pricingperiod + No pricingperiod + Pricingperiod + No pricingperiod + Mail accountsConcurrent (changes)Compensate on prepay + DomainsRegister or renew eventsCompensate on prepay + PlansAlways one order + CMS installationRegister or renew events + Traffic consumptionMetric period lookupPrepay and != billing_period NotImplemented + Mailbox sizeConcurrent (changes) + JobsLast known metric + NotImplement + + + + + + + + + + + + + + diff --git a/orchestra/contrib/services/tasks.py b/orchestra/contrib/services/tasks.py new file mode 100644 index 0000000..87bf7bb --- /dev/null +++ b/orchestra/contrib/services/tasks.py @@ -0,0 +1,13 @@ +from celery.task.schedules import crontab + +from orchestra.contrib.tasks import periodic_task + +from .models import Service + + +@periodic_task(run_every=crontab(hour=5, minute=30)) +def update_service_orders(): + updates = [] + for service in Service.objects.filter(periodic_update=True): + updates += service.update_orders(commit=True) + return updates diff --git a/orchestra/contrib/services/templates/admin/services/service/change_form.html b/orchestra/contrib/services/templates/admin/services/service/change_form.html new file mode 100644 index 0000000..8a32497 --- /dev/null +++ b/orchestra/contrib/services/templates/admin/services/service/change_form.html @@ -0,0 +1,180 @@ +{% extends "orchestra/admin/change_form.html" %} +{% load i18n admin_urls static admin_modify utils %} + + +{% block object-tools %} +{% if add %} +
      +
    • +
    • + {% trans "Help" %} +
    • +
    +{% endif %} +{{ block.super }} +{% endblock %} diff --git a/orchestra/contrib/services/templates/admin/services/service/help.html b/orchestra/contrib/services/templates/admin/services/service/help.html new file mode 100644 index 0000000..27e29c4 --- /dev/null +++ b/orchestra/contrib/services/templates/admin/services/service/help.html @@ -0,0 +1,12 @@ +{% extends "admin/orchestra/generic_confirmation.html" %} +{% load i18n l10n %} +{% load admin_urls static utils %} + + +{% block content %} +
    +
    + +
    +
    +{% endblock %} diff --git a/orchestra/contrib/services/templates/admin/services/service/update_orders.html b/orchestra/contrib/services/templates/admin/services/service/update_orders.html new file mode 100644 index 0000000..0188221 --- /dev/null +++ b/orchestra/contrib/services/templates/admin/services/service/update_orders.html @@ -0,0 +1,52 @@ +{% extends "admin/orchestra/generic_confirmation.html" %} +{% load i18n l10n %} +{% load admin_urls static utils %} + + +{% block content %} +
    + +
    + +
    {% csrf_token %} + {% for obj in queryset %} + + {% endfor %} + + + +
    +{% endblock %} + diff --git a/orchestra/contrib/services/tests/__init__.py b/orchestra/contrib/services/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestra/contrib/services/tests/functional_tests/__init__.py b/orchestra/contrib/services/tests/functional_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestra/contrib/services/tests/functional_tests/test_domain.py b/orchestra/contrib/services/tests/functional_tests/test_domain.py new file mode 100644 index 0000000..a45c3ec --- /dev/null +++ b/orchestra/contrib/services/tests/functional_tests/test_domain.py @@ -0,0 +1,138 @@ +from django.contrib.contenttypes.models import ContentType + +from orchestra.contrib.miscellaneous.models import MiscService, Miscellaneous +from orchestra.contrib.plans.models import Plan +from orchestra.utils.tests import random_ascii, BaseTestCase + +from ...models import Service + + +class DomainBillingTest(BaseTestCase): + def create_domain_service(self): + service = Service.objects.create( + description="Domain .ES", + content_type=ContentType.objects.get_for_model(Miscellaneous), + match="miscellaneous.is_active and miscellaneous.service.name.lower() == 'domain .es'", + billing_period=Service.ANUAL, + billing_point=Service.ON_REGISTER, + is_fee=False, + metric='', + pricing_period=Service.BILLING_PERIOD, + rate_algorithm='orchestra.contrib.plans.ratings.step_price', + on_cancel=Service.NOTHING, + payment_style=Service.PREPAY, + tax=0, + nominal_price=10 + ) + plan = Plan.objects.create(is_default=True, name='Default') + service.rates.create(plan=plan, quantity=0, price=0) + service.rates.create(plan=plan, quantity=2, price=10) + service.rates.create(plan=plan, quantity=4, price=9) + service.rates.create(plan=plan, quantity=6, price=6) + return service + + def create_domain(self, account=None): + if not account: + account = self.create_account() + domain_name = '%s.es' % random_ascii(10) + domain_service, __ = MiscService.objects.get_or_create(name='domain .es', + description='Domain .ES') + return account.miscellaneous.create(service=domain_service, description=domain_name) + + def test_domain(self): + self.create_domain_service() + account = self.create_account() + self.create_domain(account=account) + bills = account.orders.bill() + self.assertEqual(0, bills[0].total) + self.create_domain(account=account) + bills = account.orders.bill() + self.assertEqual(10, bills[0].total) + self.create_domain(account=account) + bills = account.orders.bill() + self.assertEqual(20, bills[0].total) + self.create_domain(account=account) + bills = account.orders.bill() + self.assertEqual(29, bills[0].total) + self.create_domain(account=account) + bills = account.orders.bill() + self.assertEqual(38, bills[0].total) + self.create_domain(account=account) + bills = account.orders.bill() + self.assertEqual(44, bills[0].total) + self.create_domain(account=account) + bills = account.orders.bill() + self.assertEqual(50, bills[0].total) + self.create_domain(account=account) + bills = account.orders.bill() + self.assertEqual(56, bills[0].total) + + def test_domain_proforma(self): + self.create_domain_service() + account = self.create_account() + self.create_domain(account=account) + bills = account.orders.bill(proforma=True, new_open=True) + self.assertEqual(0, bills[0].total) + self.create_domain(account=account) + bills = account.orders.bill(proforma=True, new_open=True) + self.assertEqual(10, bills[0].total) + self.create_domain(account=account) + bills = account.orders.bill(proforma=True, new_open=True) + self.assertEqual(20, bills[0].total) + self.create_domain(account=account) + bills = account.orders.bill(proforma=True, new_open=True) + self.assertEqual(29, bills[0].total) + self.create_domain(account=account) + bills = account.orders.bill(proforma=True, new_open=True) + self.assertEqual(38, bills[0].total) + self.create_domain(account=account) + bills = account.orders.bill(proforma=True, new_open=True) + self.assertEqual(44, bills[0].total) + self.create_domain(account=account) + bills = account.orders.bill(proforma=True, new_open=True) + self.assertEqual(50, bills[0].total) + self.create_domain(account=account) + bills = account.orders.bill(proforma=True, new_open=True) + self.assertEqual(56, bills[0].total) + + def test_domain_cumulative(self): + self.create_domain_service() + account = self.create_account() + self.create_domain(account=account) + bills = account.orders.bill(proforma=True) + self.assertEqual(0, bills[0].total) + self.create_domain(account=account) + bills = account.orders.bill(proforma=True) + self.assertEqual(10, bills[0].total) + self.create_domain(account=account) + bills = account.orders.bill(proforma=True) + self.assertEqual(30, bills[0].total) + + def test_domain_new_open(self): + self.create_domain_service() + account = self.create_account() + self.create_domain(account=account) + bills = account.orders.bill(new_open=True) + self.assertEqual(0, bills[0].total) + self.create_domain(account=account) + bills = account.orders.bill(new_open=True) + self.assertEqual(10, bills[0].total) + self.create_domain(account=account) + bills = account.orders.bill(new_open=True) + self.assertEqual(10, bills[0].total) + self.create_domain(account=account) + bills = account.orders.bill(new_open=True) + self.assertEqual(9, bills[0].total) + self.create_domain(account=account) + bills = account.orders.bill(new_open=True) + self.assertEqual(9, bills[0].total) + self.create_domain(account=account) + bills = account.orders.bill(new_open=True) + self.assertEqual(6, bills[0].total) + self.create_domain(account=account) + bills = account.orders.bill(new_open=True) + self.assertEqual(6, bills[0].total) + self.create_domain(account=account) + bills = account.orders.bill(new_open=True) + self.assertEqual(6, bills[0].total) + diff --git a/orchestra/contrib/services/tests/functional_tests/test_ftp.py b/orchestra/contrib/services/tests/functional_tests/test_ftp.py new file mode 100644 index 0000000..03f51be --- /dev/null +++ b/orchestra/contrib/services/tests/functional_tests/test_ftp.py @@ -0,0 +1,102 @@ +import decimal +import datetime + +from dateutil.relativedelta import relativedelta +from django.contrib.contenttypes.models import ContentType +from django.utils import timezone + +from orchestra.contrib.systemusers.models import SystemUser +from orchestra.utils.tests import random_ascii, BaseTestCase + +from ... import settings +from ...models import Service + + +class FTPBillingTest(BaseTestCase): + DEPENDENCIES = ( + 'orchestra.contrib.orders', + 'orchestra.contrib.plans', + 'orchestra.contrib.systemusers', + ) + + def create_ftp_service(self): + return Service.objects.create( + description="FTP Account", + content_type=ContentType.objects.get_for_model(SystemUser), + match='not systemuser.is_main', + billing_period=Service.ANUAL, + billing_point=Service.FIXED_DATE, + is_fee=False, + metric='', + pricing_period=Service.NEVER, + rate_algorithm='orchestra.contrib.plans.ratings.step_price', + on_cancel=Service.COMPENSATE, + payment_style=Service.PREPAY, + tax=0, + nominal_price=10, + ) + + def create_ftp(self, account=None): + if not account: + account = self.create_account() + username = '%s_ftp' % random_ascii(10) + return SystemUser.objects.create_user(username, account=account) + + def test_ftp_account_1_year_fiexed(self): + service = self.create_ftp_service() + self.create_ftp() + self.assertEqual(1, service.orders.count()) + bp = timezone.now().date() + relativedelta(years=1) + bills = service.orders.bill(billing_point=bp, fixed_point=True) + self.assertEqual(10, bills[0].total) + + def test_ftp_account_2_year_fiexed(self): + service = self.create_ftp_service() + self.create_ftp() + bp = timezone.now().date() + relativedelta(years=2) + bills = service.orders.bill(billing_point=bp, fixed_point=True) + self.assertEqual(20, bills[0].total) + + def test_ftp_account_6_month_fixed(self): + service = self.create_ftp_service() + self.create_ftp() + bp = timezone.now().date() + relativedelta(months=6) + bills = service.orders.bill(billing_point=bp, fixed_point=True) + self.assertEqual(5, bills[0].total) + + def test_ftp_account_next_billing_point(self): + service = self.create_ftp_service() + self.create_ftp() + now = timezone.now().date() + bp_month = settings.SERVICES_SERVICE_ANUAL_BILLING_MONTH + if now.month > bp_month: + bp = datetime.date(year=now.year+1, month=bp_month, day=1) + else: + bp = datetime.date(year=now.year, month=bp_month, day=1) + bills = service.orders.bill(billing_point=now, fixed_point=False) + size = decimal.Decimal((bp - now).days)/365 + error = decimal.Decimal(0.05) + self.assertGreater(10*size+error*(10*size), bills[0].total) + self.assertLess(10*size-error*(10*size), bills[0].total) + + def test_ftp_account_with_compensation(self): + account = self.create_account() + self.create_ftp_service() + user = self.create_ftp(account=account) + first_bp = timezone.now().date() + relativedelta(years=2) + bills = account.orders.bill(billing_point=first_bp, fixed_point=True) + self.assertEqual(1, account.orders.active().count()) + user.delete() + self.assertEqual(0, account.orders.active().count()) + user = self.create_ftp(account=account) + self.assertEqual(1, account.orders.active().count()) + self.assertEqual(2, account.orders.count()) + bp = timezone.now().date() + relativedelta(years=1) + bills = account.orders.bill(billing_point=bp, fixed_point=True, new_open=True) + discount = bills[0].lines.order_by('id')[0].sublines.get() + self.assertEqual(decimal.Decimal(-20), discount.total) + order = account.orders.order_by('id').first() + self.assertEqual(order.cancelled_on, order.billed_until) + order = account.orders.order_by('-id').first() + self.assertEqual(first_bp, order.billed_until) + self.assertEqual(decimal.Decimal(0), bills[0].total) diff --git a/orchestra/contrib/services/tests/functional_tests/test_job.py b/orchestra/contrib/services/tests/functional_tests/test_job.py new file mode 100644 index 0000000..4e81d7e --- /dev/null +++ b/orchestra/contrib/services/tests/functional_tests/test_job.py @@ -0,0 +1,49 @@ +from django.contrib.contenttypes.models import ContentType + +from orchestra.contrib.miscellaneous.models import MiscService, Miscellaneous +from orchestra.contrib.plans.models import Plan +from orchestra.utils.tests import random_ascii, BaseTestCase + +from ...models import Service + + +class JobBillingTest(BaseTestCase): + def create_job_service(self): + service = Service.objects.create( + description="Random job", + content_type=ContentType.objects.get_for_model(Miscellaneous), + match="miscellaneous.is_active and miscellaneous.service.name.lower() == 'job'", + billing_period=Service.NEVER, + billing_point=Service.ON_REGISTER, + is_fee=False, + metric='miscellaneous.amount', + pricing_period=Service.BILLING_PERIOD, + rate_algorithm='orchestra.contrib.plans.ratings.match_price', + on_cancel=Service.NOTHING, + payment_style=Service.POSTPAY, + tax=0, + nominal_price=20 + ) + plan = Plan.objects.create(is_default=True, name='Default') + service.rates.create(plan=plan, quantity=1, price=20) + service.rates.create(plan=plan, quantity=10, price=15) + return service + + def create_job(self, amount, account=None): + if not account: + account = self.create_account() + description = 'Random Job %s' % random_ascii(10) + service, __ = MiscService.objects.get_or_create(name='job', has_amount=True) + return account.miscellaneous.create(service=service, description=description, amount=amount) + + def test_job(self): + self.create_job_service() + account = self.create_account() + + self.create_job(5, account=account) + bill = account.orders.bill()[0] + self.assertEqual(5*20, bill.total) + + self.create_job(100, account=account) + bill = account.orders.bill(new_open=True)[0] + self.assertEqual(100*15, bill.total) diff --git a/orchestra/contrib/services/tests/functional_tests/test_mailbox.py b/orchestra/contrib/services/tests/functional_tests/test_mailbox.py new file mode 100644 index 0000000..5756cf6 --- /dev/null +++ b/orchestra/contrib/services/tests/functional_tests/test_mailbox.py @@ -0,0 +1,176 @@ +from dateutil.relativedelta import relativedelta +from django.contrib.contenttypes.models import ContentType +from django.utils import timezone +from freezegun import freeze_time + +from orchestra.contrib.mailboxes.models import Mailbox +from orchestra.contrib.plans.models import Plan +from orchestra.contrib.resources.models import Resource, ResourceData +from orchestra.utils.tests import random_ascii, BaseTestCase + +from ...models import Service + + +class MailboxBillingTest(BaseTestCase): + def create_mailbox_service(self): + service = Service.objects.create( + description="Mailbox", + content_type=ContentType.objects.get_for_model(Mailbox), + match="True", + billing_period=Service.ANUAL, + billing_point=Service.FIXED_DATE, + is_fee=False, + metric='', + pricing_period=Service.NEVER, + rate_algorithm='orchestra.contrib.plans.ratings.step_price', + on_cancel=Service.COMPENSATE, + payment_style=Service.PREPAY, + tax=0, + nominal_price=10 + ) + plan = Plan.objects.create(is_default=True, name='Default') + service.rates.create(plan=plan, quantity=1, price=0) + service.rates.create(plan=plan, quantity=5, price=10) + return service + + def create_mailbox_disk_service(self): + service = Service.objects.create( + description="Mailbox disk", + content_type=ContentType.objects.get_for_model(Mailbox), + match="True", + billing_period=Service.ANUAL, + billing_point=Service.FIXED_DATE, + is_fee=False, + metric='max((mailbox.resources.disk.allocated or 0) -1, 0)', + pricing_period=Service.NEVER, + rate_algorithm='orchestra.contrib.plans.ratings.step_price', + on_cancel=Service.DISCOUNT, + payment_style=Service.PREPAY, + tax=0, + nominal_price=10 + ) + plan, __ = Plan.objects.get_or_create(is_default=True, name='Default') + service.rates.create(plan=plan, quantity=1, price=10) + return service + + def create_disk_resource(self): + self.resource = Resource.objects.create( + name='disk', + content_type=ContentType.objects.get_for_model(Mailbox), + aggregation='last', + verbose_name='Mailbox disk', + unit='GB', + scale=10**9, + on_demand=False, + monitors='MaildirDisk', + ) + return self.resource + + def allocate_disk(self, mailbox, value): + data, __ = ResourceData.objects.get_or_create(mailbox, self.resource) + data.allocated = value + data.save() + + def create_mailbox(self, account=None): + if not account: + account = self.create_account() + mailbox_name = '%s@orchestra.lan' % random_ascii(10) + return Mailbox.objects.create(name=mailbox_name, account=account) + + def test_mailbox_size(self): + service = self.create_mailbox_service() + disk_service = self.create_mailbox_disk_service() + self.create_disk_resource() + account = self.create_account() + mailbox = self.create_mailbox(account=account) + self.allocate_disk(mailbox, 10) + bill = service.orders.bill()[0] + self.assertEqual(0, bill.total) + bp = timezone.now().date() + relativedelta(years=1) + bill = disk_service.orders.bill(billing_point=bp, fixed_point=True)[0] + self.assertEqual(90, bill.total) + mailbox = self.create_mailbox(account=account) + mailbox = self.create_mailbox(account=account) + mailbox = self.create_mailbox(account=account) + mailbox = self.create_mailbox(account=account) + mailbox = self.create_mailbox(account=account) + mailbox = self.create_mailbox(account=account) + bill = service.orders.bill(billing_point=bp, fixed_point=True)[0] + self.assertEqual(120, bill.total) + + def test_mailbox_size_with_changes(self): + service = self.create_mailbox_disk_service() + self.create_disk_resource() + account = self.create_account() + mailbox = self.create_mailbox(account=account) + now = timezone.now() + bp = now.date() + relativedelta(years=1) + options = dict(billing_point=bp, fixed_point=True, proforma=True, new_open=True) + + self.allocate_disk(mailbox, 10) + bill = service.orders.bill(**options).pop() + self.assertEqual(9*10, bill.total) + + with freeze_time(now+relativedelta(months=6)): + self.allocate_disk(mailbox, 20) + bill = service.orders.bill(**options).pop() + total = 9*10*0.5 + 19*10*0.5 + self.assertEqual(total, bill.total) + + with freeze_time(now+relativedelta(months=9)): + self.allocate_disk(mailbox, 30) + bill = service.orders.bill(**options).pop() + total = 9*10*0.5 + 19*10*0.25 + 29*10*0.25 + self.assertEqual(total, bill.total) + + with freeze_time(now+relativedelta(years=1)): + self.allocate_disk(mailbox, 10) + bill = service.orders.bill(**options).pop() + total = 9*10*0.5 + 19*10*0.25 + 29*10*0.25 + self.assertEqual(total, bill.total) + + def test_mailbox_with_recharge(self): + service = self.create_mailbox_disk_service() + self.create_disk_resource() + account = self.create_account() + mailbox = self.create_mailbox(account=account) + now = timezone.now() + bp = now.date() + relativedelta(years=1) + options = dict(billing_point=bp, fixed_point=True) + + self.allocate_disk(mailbox, 100) + bill = service.orders.bill(**options).pop() + self.assertEqual(99*10, bill.total) + + with freeze_time(now+relativedelta(months=6)): + bills = service.orders.bill(new_open=True, **options) + self.assertEqual([], bills) + + with freeze_time(now+relativedelta(months=6)): + self.allocate_disk(mailbox, 50) + bills = service.orders.bill(**options) + self.assertEqual([], bills) + + with freeze_time(now+relativedelta(months=6)): + self.allocate_disk(mailbox, 200) + bill = service.orders.bill(new_open=True, **options).pop() + self.assertEqual((199-99)*10*0.5, bill.total) + + + def test_mailbox_second_billing(self): + service = self.create_mailbox_disk_service() + self.create_disk_resource() + account = self.create_account() + mailbox = self.create_mailbox(account=account) + now = timezone.now() + bp = now.date() + relativedelta(years=1) + options = dict(billing_point=bp, fixed_point=True) + bills = service.orders.bill(**options) + + with freeze_time(now+relativedelta(years=1, months=1)): + mailbox = self.create_mailbox(account=account) + alt_now = timezone.now() + bp = alt_now.date() + relativedelta(years=1) + options = dict(billing_point=bp, fixed_point=True) + bills = service.orders.bill(**options) + print(bills) diff --git a/orchestra/contrib/services/tests/functional_tests/test_plan.py b/orchestra/contrib/services/tests/functional_tests/test_plan.py new file mode 100644 index 0000000..d6c98af --- /dev/null +++ b/orchestra/contrib/services/tests/functional_tests/test_plan.py @@ -0,0 +1,52 @@ +from django.contrib.contenttypes.models import ContentType + +from orchestra.contrib.plans.models import Plan, ContractedPlan +from orchestra.utils.tests import BaseTestCase + +from ...models import Service + + +class PlanBillingTest(BaseTestCase): + def create_plan_service(self): + service = Service.objects.create( + description="Association membership fee", + content_type=ContentType.objects.get_for_model(ContractedPlan), + match="contractedplan.plan.name == 'association_fee'", + billing_period=Service.ANUAL, + billing_point=Service.FIXED_DATE, + is_fee=True, + metric='', + pricing_period=Service.BILLING_PERIOD, + rate_algorithm='orchestra.contrib.plans.ratings.step_price', + on_cancel=Service.DISCOUNT, + payment_style=Service.PREPAY, + tax=0, + nominal_price=20 + ) + return service + + def create_plan(self, account=None): + if not account: + account = self.create_account() + plan, __ = Plan.objects.get_or_create(name='association_fee') + return plan.contracts.create(account=account) + + def test_update_orders(self): + account = self.create_account() + account1 = self.create_account() + self.create_plan(account=account) + self.create_plan(account=account1) + service = self.create_plan_service() + self.assertEqual(0, service.orders.count()) + service.update_orders() + self.assertEqual(2, service.orders.count()) + + def test_plan(self): + account = self.create_account() + self.create_plan_service() + self.create_plan(account=account) + bill = account.orders.bill().pop() + self.assertEqual(bill.FEE, bill.type) + + +# TODO test price with multiple plans diff --git a/orchestra/contrib/services/tests/functional_tests/test_traffic.py b/orchestra/contrib/services/tests/functional_tests/test_traffic.py new file mode 100644 index 0000000..e7334de --- /dev/null +++ b/orchestra/contrib/services/tests/functional_tests/test_traffic.py @@ -0,0 +1,169 @@ +from dateutil.relativedelta import relativedelta +from django.contrib.contenttypes.models import ContentType +from django.utils import timezone +from freezegun import freeze_time + +from orchestra.contrib.accounts.models import Account +from orchestra.contrib.miscellaneous.models import MiscService, Miscellaneous +from orchestra.contrib.plans.models import Plan +from orchestra.contrib.resources.models import Resource, ResourceData, MonitorData +from orchestra.contrib.resources.backends import ServiceMonitor +from orchestra.utils.tests import BaseTestCase + +from ...models import Service + + +class FTPTrafficMonitor(ServiceMonitor): + model = 'systemusers.SystemUser' + + +class BaseTrafficBillingTest(BaseTestCase): + TRAFFIC_METRIC = 'account.resources.traffic.used' + DEPENDENCIES = ('orchestra.contrib.resources',) + + def create_traffic_service(self): + service = Service.objects.create( + description="Traffic", + content_type=ContentType.objects.get_for_model(Account), + match="account.is_active", + billing_period=Service.MONTHLY, + billing_point=Service.FIXED_DATE, + is_fee=False, + metric=self.TRAFFIC_METRIC, + pricing_period=Service.BILLING_PERIOD, + rate_algorithm='orchestra.contrib.plans.ratings.step_price', + on_cancel=Service.NOTHING, + payment_style=Service.POSTPAY, + tax=0, + nominal_price=10 + ) + plan = Plan.objects.create(is_default=True, name='Default') + service.rates.create(plan=plan, quantity=1, price=0) + service.rates.create(plan=plan, quantity=11, price=10) + return service + + def create_traffic_resource(self): + self.resource = Resource.objects.create( + name='traffic', + content_type=ContentType.objects.get_for_model(Account), + aggregation='monthly-sum', + verbose_name='Account Traffic', + unit='GB', + scale='10**9', + on_demand=True, + # TODO + monitors=[FTPTrafficMonitor.get_name()], + ) + return self.resource + + def report_traffic(self, account, value): + MonitorData.objects.create(monitor=FTPTrafficMonitor.get_name(), content_object=account.systemusers.get(), value=value) + data, __ = ResourceData.objects.get_or_create(account, self.resource) + data.update() + + +class TrafficBillingTest(BaseTrafficBillingTest): + def test_traffic(self): + self.create_traffic_service() + self.create_traffic_resource() + account = self.create_account() + now = timezone.now() + + self.report_traffic(account, 10**9) + bill = account.orders.bill(commit=False)[0] + self.assertEqual((account, []), bill) + self.report_traffic(account, 10**9*9) + + with freeze_time(now+relativedelta(months=1)): + bill = account.orders.bill(proforma=True)[0] + self.report_traffic(account, 10**10*9) + self.assertEqual(0, bill.total) + + with freeze_time(now+relativedelta(months=3)): + bill = account.orders.bill(proforma=True)[0] + self.assertEqual((90-10)*10, bill.total) + + def test_multiple_traffics(self): + self.create_traffic_service() + self.create_traffic_resource() + account1 = self.create_account() + account2 = self.create_account() + self.report_traffic(account1, 10**10) + self.report_traffic(account2, 10**10*5) + with freeze_time(timezone.now()+relativedelta(months=1)): + bill1 = account1.orders.bill().pop() + bill2 = account2.orders.bill().pop() + self.assertNotEqual(bill1.total, bill2.total) + + +class TrafficPrepayBillingTest(BaseTrafficBillingTest): + TRAFFIC_METRIC = ("max(" + "(account.resources.traffic.used or 0) - " + "getattr(account.miscellaneous.filter(is_active=True, service__name='traffic prepay').last(), 'amount', 0)" + ", 0)" + ) + + def create_prepay_service(self): + service = Service.objects.create( + description="Traffic prepay", + content_type=ContentType.objects.get_for_model(Miscellaneous), + match="miscellaneous.is_active and miscellaneous.service.name.lower() == 'traffic prepay'", + billing_period=Service.MONTHLY, + # make sure full months are always paid + billing_point=Service.ON_REGISTER, + is_fee=False, + metric="miscellaneous.amount", + pricing_period=Service.NEVER, + rate_algorithm='orchestra.contrib.plans.ratings.step_price', + on_cancel=Service.NOTHING, + payment_style=Service.PREPAY, + tax=0, + nominal_price=50 + ) + return service + + def create_prepay(self, amount, account=None): + if not account: + account = self.create_account() + name = 'traffic prepay' + service, __ = MiscService.objects.get_or_create(name='traffic prepay', + description='Traffic prepay', has_amount=True) + return account.miscellaneous.create(service=service, description=name, amount=amount) + + def test_traffic_prepay(self): + self.create_traffic_service() + self.create_prepay_service() + self.create_traffic_resource() + account = self.create_account() + now = timezone.now() + + self.create_prepay(10, account=account) + bill = account.orders.bill(proforma=True)[0] + self.assertEqual(10*50, bill.total) + + self.report_traffic(account, 10**10) + with freeze_time(now+relativedelta(months=1)): + bill = account.orders.bill(proforma=True, new_open=True)[0] + self.assertEqual(2*10*50 + 0*10, bill.total) + + # TODO RuntimeWarning: DateTimeField MetricStorage.updated_on received a naive + self.report_traffic(account, 10**10) + with freeze_time(now+relativedelta(months=1)): + bill = account.orders.bill(proforma=True, new_open=True)[0] + self.assertEqual(2*10*50 + 0*10, bill.total) + + self.report_traffic(account, 10**10) + with freeze_time(now+relativedelta(months=1)): + bill = account.orders.bill(proforma=True, new_open=True)[0] + self.assertEqual(2*10*50 + (30-10-10)*10, bill.total) + + with freeze_time(now+relativedelta(months=2)): + self.report_traffic(account, 10**11) + with freeze_time(now+relativedelta(months=1)): + bill = account.orders.bill(proforma=True, new_open=True)[0] + self.assertEqual(2*10*50 + (30-10-10)*10, bill.total) + + with freeze_time(now+relativedelta(months=3)): + bill = account.orders.bill(proforma=True, new_open=True)[0] + self.assertEqual(4*10*50 + (30-10-10)*10 + (100-10-10)*10, bill.total) + diff --git a/orchestra/contrib/services/tests/test_handler.py b/orchestra/contrib/services/tests/test_handler.py new file mode 100644 index 0000000..17d5d15 --- /dev/null +++ b/orchestra/contrib/services/tests/test_handler.py @@ -0,0 +1,537 @@ +import datetime +import decimal +from functools import cmp_to_key + +from django.contrib.contenttypes.models import ContentType +from django.utils import timezone + +from orchestra.contrib.systemusers.models import SystemUser +from orchestra.contrib.plans.models import Plan +from orchestra.utils.tests import BaseTestCase + +from .. import helpers +from ..models import Service + + +class Order(object): + """ Fake order for testing """ + last_id = 0 + + def __init__(self, **kwargs): + self.registered_on = kwargs.get('registered_on') or timezone.now().date() + self.billed_until = kwargs.get('billed_until', None) + self.cancelled_on = kwargs.get('cancelled_on', None) + type(self).last_id += 1 + self.id = self.last_id + self.pk = self.id + + +class HandlerTests(BaseTestCase): + DEPENDENCIES = ( + 'orchestra.contrib.orders', + 'orchestra.contrib.systemusers', + ) + + def create_ftp_service(self, **kwargs): + default = dict( + description="FTP Account", + content_type=ContentType.objects.get_for_model(SystemUser), + match='not systemuser.is_main', + billing_period=Service.ANUAL, + billing_point=Service.FIXED_DATE, + is_fee=False, + metric='', + pricing_period=Service.NEVER, + rate_algorithm='orchestra.contrib.plans.ratings.step_price', + on_cancel=Service.DISCOUNT, + payment_style=Service.PREPAY, + tax=0, + nominal_price=10 + ) + default.update(kwargs) + service = Service.objects.create(**default) + return service + + def validate_results(self, rates, results): + self.assertEqual(len(rates), len(results)) + for rate, result in zip(rates, results): + self.assertEqual(rate['price'], result.price) + self.assertEqual(rate['quantity'], result.quantity) + + def test_get_chunks(self): + service = self.create_ftp_service() + handler = service.handler + porders = [] + now = timezone.now().date() + + order = Order() + porders.append(order) + end = handler.get_billing_point(order) + chunks = helpers.get_chunks(porders, now, end) + self.assertEqual(1, len(chunks)) + self.assertIn([now, end, []], chunks) + + order1 = Order( + billed_until=now+datetime.timedelta(days=2) + ) + porders.append(order1) + chunks = helpers.get_chunks(porders, now, end) + self.assertEqual(2, len(chunks)) + self.assertIn([order1.registered_on, order1.billed_until, [order1]], chunks) + self.assertIn([order1.billed_until, end, []], chunks) + + order2 = Order( + billed_until = now+datetime.timedelta(days=700) + ) + porders.append(order2) + chunks = helpers.get_chunks(porders, now, end) + self.assertEqual(2, len(chunks)) + self.assertIn([order.registered_on, order1.billed_until, [order1, order2]], chunks) + self.assertIn([order1.billed_until, end, [order2]], chunks) + + order3 = Order( + billed_until = now+datetime.timedelta(days=700) + ) + porders.append(order3) + chunks = helpers.get_chunks(porders, now, end) + self.assertEqual(2, len(chunks)) + self.assertIn([order.registered_on, order1.billed_until, [order1, order2, order3]], chunks) + self.assertIn([order1.billed_until, end, [order2, order3]], chunks) + + order4 = Order( + registered_on=now+datetime.timedelta(days=5), + billed_until = now+datetime.timedelta(days=10) + ) + porders.append(order4) + chunks = helpers.get_chunks(porders, now, end) + self.assertEqual(4, len(chunks)) + self.assertIn([order.registered_on, order1.billed_until, [order1, order2, order3]], chunks) + self.assertIn([order1.billed_until, order4.registered_on, [order2, order3]], chunks) + self.assertIn([order4.registered_on, order4.billed_until, [order2, order3, order4]], chunks) + self.assertIn([order4.billed_until, end, [order2, order3]], chunks) + + order5 = Order( + registered_on=now+datetime.timedelta(days=700), + billed_until=now+datetime.timedelta(days=780) + ) + porders.append(order5) + chunks = helpers.get_chunks(porders, now, end) + self.assertEqual(4, len(chunks)) + self.assertIn([order.registered_on, order1.billed_until, [order1, order2, order3]], chunks) + self.assertIn([order1.billed_until, order4.registered_on, [order2, order3]], chunks) + self.assertIn([order4.registered_on, order4.billed_until, [order2, order3, order4]], chunks) + self.assertIn([order4.billed_until, end, [order2, order3]], chunks) + + order6 = Order( + registered_on=now+datetime.timedelta(days=780), + billed_until=now+datetime.timedelta(days=700) + ) + porders.append(order6) + chunks = helpers.get_chunks(porders, now, end) + self.assertEqual(4, len(chunks)) + self.assertIn([order.registered_on, order1.billed_until, [order1, order2, order3]], chunks) + self.assertIn([order1.billed_until, order4.registered_on, [order2, order3]], chunks) + self.assertIn([order4.registered_on, order4.billed_until, [order2, order3, order4]], chunks) + self.assertIn([order4.billed_until, end, [order2, order3]], chunks) + + def test_sort_billed_until_or_registered_on(self): + now = timezone.now().date() + order = Order( + billed_until=now+datetime.timedelta(days=200)) + order1 = Order( + registered_on=now+datetime.timedelta(days=5), + billed_until=now+datetime.timedelta(days=200)) + order2 = Order( + registered_on=now+datetime.timedelta(days=6), + billed_until=now+datetime.timedelta(days=200)) + order3 = Order( + registered_on=now+datetime.timedelta(days=6), + billed_until=now+datetime.timedelta(days=201)) + order4 = Order( + registered_on=now+datetime.timedelta(days=6)) + order5 = Order( + registered_on=now+datetime.timedelta(days=7)) + order6 = Order( + registered_on=now+datetime.timedelta(days=8)) + orders = [order3, order, order1, order2, order4, order5, order6] + self.assertEqual(orders, sorted(orders, key=cmp_to_key(helpers.cmp_billed_until_or_registered_on))) + + def test_compensation(self): + now = timezone.now().date() + order = Order( + description='0', + billed_until=now+datetime.timedelta(days=220), + cancelled_on=now+datetime.timedelta(days=100)) + order1 = Order( + description='1', + registered_on=now+datetime.timedelta(days=5), + cancelled_on=now+datetime.timedelta(days=190), + billed_until=now+datetime.timedelta(days=200)) + order2 = Order( + description='2', + registered_on=now+datetime.timedelta(days=6), + cancelled_on=now+datetime.timedelta(days=200), + billed_until=now+datetime.timedelta(days=200)) + order3 = Order( + description='3', + registered_on=now+datetime.timedelta(days=6), + billed_until=now+datetime.timedelta(days=200)) + + tests = [] + order4 = Order( + description='4', + registered_on=now+datetime.timedelta(days=6), + billed_until=now+datetime.timedelta(days=102)) + order4.new_billed_until = now+datetime.timedelta(days=200) + tests.append([ + [now+datetime.timedelta(days=102), now+datetime.timedelta(days=220), order], + ]) + order5 = Order( + description='5', + registered_on=now+datetime.timedelta(days=7), + billed_until=now+datetime.timedelta(days=102)) + order5.new_billed_until = now+datetime.timedelta(days=195) + tests.append([ + [now+datetime.timedelta(days=190), now+datetime.timedelta(days=200), order1] + ]) + order6 = Order( + description='6', + registered_on=now+datetime.timedelta(days=8)) + order6.new_billed_until = now+datetime.timedelta(days=200) + tests.append([ + [now+datetime.timedelta(days=100), now+datetime.timedelta(days=102), order], + ]) + porders = [order3, order, order1, order2, order4, order5, order6] + porders = sorted(porders, key=cmp_to_key(helpers.cmp_billed_until_or_registered_on)) + compensations = [] + receivers = [] + for order in porders: + if order.billed_until and order.cancelled_on and order.cancelled_on < order.billed_until: + compensations.append(helpers.Interval(order.cancelled_on, order.billed_until, order=order)) + elif hasattr(order, 'new_billed_until') and (not order.billed_until or order.billed_until < order.new_billed_until): + receivers.append(order) + for order, test in zip(receivers, tests): + ini = order.billed_until or order.registered_on + end = order.cancelled_on or now+datetime.timedelta(days=20000) + order_interval = helpers.Interval(ini, end) + (compensations, used_compensations) = helpers.compensate(order_interval, compensations) + for compensation, test_line in zip(used_compensations, test): + self.assertEqual(test_line[0], compensation.ini) + self.assertEqual(test_line[1], compensation.end) + self.assertEqual(test_line[2], compensation.order) + + def test_rates(self): + service = self.create_ftp_service() + account = self.create_account() + superplan = Plan.objects.create( + name='SUPER', allow_multiple=False, is_combinable=True) + service.rates.create(plan=superplan, quantity=0, price=0) + service.rates.create(plan=superplan, quantity=3, price=10) + service.rates.create(plan=superplan, quantity=4, price=9) + service.rates.create(plan=superplan, quantity=10, price=1) + account.plans.create(plan=superplan) + results = service.get_rates(account, cache=False) + results = service.rate_method(results, 30) + rates = [ + { + 'price': decimal.Decimal('0.00'), + 'quantity': 2 + }, { + 'price': decimal.Decimal('10.00'), + 'quantity': 1 + }, { + 'price': decimal.Decimal('9.00'), + 'quantity': 6 + }, { + 'price': decimal.Decimal('1.00'), + 'quantity': 21 + } + ] + self.validate_results(rates, results) + + dupeplan = Plan.objects.create( + name='DUPE', allow_multiple=True, is_combinable=True) + service.rates.create(plan=dupeplan, quantity=1, price=0) + service.rates.create(plan=dupeplan, quantity=3, price=9) + results = service.get_rates(account, cache=False) + results = service.rate_method(results, 30) + self.validate_results(rates, results) + + account.plans.create(plan=dupeplan) + results = service.get_rates(account, cache=False) + results = service.rate_method(results, 30) + rates = [ + {'price': decimal.Decimal('0.00'), 'quantity': 4}, + {'price': decimal.Decimal('9.00'), 'quantity': 5}, + {'price': decimal.Decimal('1.00'), 'quantity': 21}, + ] + self.validate_results(rates, results) + + hyperplan = Plan.objects.create( + name='HYPER', allow_multiple=False, is_combinable=False) + service.rates.create(plan=hyperplan, quantity=0, price=0) + service.rates.create(plan=hyperplan, quantity=20, price=5) + account.plans.create(plan=hyperplan) + results = service.get_rates(account, cache=False) + results = service.rate_method(results, 30) + rates = [ + {'price': decimal.Decimal('0.00'), 'quantity': 19}, + {'price': decimal.Decimal('5.00'), 'quantity': 11} + ] + self.validate_results(rates, results) + + hyperplan.is_combinable = True + hyperplan.save() + results = service.get_rates(account, cache=False) + results = service.rate_method(results, 30) + rates = [ + {'price': decimal.Decimal('0.00'), 'quantity': 23}, + {'price': decimal.Decimal('1.00'), 'quantity': 7} + ] + self.validate_results(rates, results) + + service.rate_algorithm = 'orchestra.contrib.plans.ratings.match_price' + service.save() + results = service.get_rates(account, cache=False) + results = service.rate_method(results, 30) + self.assertEqual(1, len(results)) + self.assertEqual(decimal.Decimal('1.00'), results[0].price) + self.assertEqual(30, results[0].quantity) + + hyperplan.delete() + results = service.get_rates(account, cache=False) + results = service.rate_method(results, 8) + self.assertEqual(1, len(results)) + self.assertEqual(decimal.Decimal('9.00'), results[0].price) + self.assertEqual(8, results[0].quantity) + + superplan.delete() + results = service.get_rates(account, cache=False) + results = service.rate_method(results, 30) + self.assertEqual(1, len(results)) + self.assertEqual(decimal.Decimal('9.00'), results[0].price) + self.assertEqual(30, results[0].quantity) + + def test_incomplete_rates(self): + service = self.create_ftp_service() + account = self.create_account() + superplan = Plan.objects.create( + name='SUPER', allow_multiple=False, is_combinable=True) + service.rates.create(plan=superplan, quantity=4, price=9) + service.rates.create(plan=superplan, quantity=10, price=1) + account.plans.create(plan=superplan) + results = service.get_rates(account, cache=False) + results = service.rate_method(results, 30) + rates = [ + { + 'price': decimal.Decimal('10.00'), + 'quantity': 3 + }, { + 'price': decimal.Decimal('9.00'), + 'quantity': 6 + }, { + 'price': decimal.Decimal('1.00'), + 'quantity': 21 + } + ] + self.validate_results(rates, results) + + def test_zero_rates(self): + service = self.create_ftp_service() + account = self.create_account() + superplan = Plan.objects.create( + name='SUPER', allow_multiple=False, is_combinable=True) + service.rates.create(plan=superplan, quantity=0, price=0) + service.rates.create(plan=superplan, quantity=3, price=10) + service.rates.create(plan=superplan, quantity=4, price=9) + service.rates.create(plan=superplan, quantity=10, price=1) + account.plans.create(plan=superplan) + results = service.get_rates(account, cache=False) + results = service.rate_method(results, 30) + rates = [ + {'price': decimal.Decimal('0.00'), 'quantity': 2}, + {'price': decimal.Decimal('10.00'), 'quantity': 1}, + {'price': decimal.Decimal('9.00'), 'quantity': 6}, + {'price': decimal.Decimal('1.00'), 'quantity': 21} + ] + self.validate_results(rates, results) + + def test_rates_allow_multiple(self): + service = self.create_ftp_service() + account = self.create_account() + dupeplan = Plan.objects.create( + name='DUPE', allow_multiple=True, is_combinable=True) + account.plans.create(plan=dupeplan) + service.rates.create(plan=dupeplan, quantity=0, price=0) + service.rates.create(plan=dupeplan, quantity=3, price=9) + results = service.get_rates(account, cache=False) + results = service.rate_method(results, 30) + rates = [ + {'price': decimal.Decimal('0.00'), 'quantity': 2}, + {'price': decimal.Decimal('9.00'), 'quantity': 28}, + ] + self.validate_results(rates, results) + + account.plans.create(plan=dupeplan) + results = service.get_rates(account, cache=False) + results = service.rate_method(results, 30) + rates = [ + {'price': decimal.Decimal('0.00'), 'quantity': 4}, + {'price': decimal.Decimal('9.00'), 'quantity': 26}, + ] + self.validate_results(rates, results) + + account.plans.create(plan=dupeplan) + results = service.get_rates(account, cache=False) + results = service.rate_method(results, 30) + rates = [ + {'price': decimal.Decimal('0.00'), 'quantity': 6}, + {'price': decimal.Decimal('9.00'), 'quantity': 24}, + ] + self.validate_results(rates, results) + + def test_best_price(self): + service = self.create_ftp_service(rate_algorithm='orchestra.contrib.plans.ratings.best_price') + account = self.create_account() + dupeplan = Plan.objects.create(name='DUPE') + account.plans.create(plan=dupeplan) + service.rates.create(plan=dupeplan, quantity=0, price=0) + service.rates.create(plan=dupeplan, quantity=2, price=9) + service.rates.create(plan=dupeplan, quantity=3, price=8) + service.rates.create(plan=dupeplan, quantity=4, price=7) + service.rates.create(plan=dupeplan, quantity=5, price=10) + service.rates.create(plan=dupeplan, quantity=10, price=5) + raw_rates = service.get_rates(account, cache=False) + results = service.rate_method(raw_rates, 2) + rates = [ + { + 'price': decimal.Decimal('0.00'), + 'quantity': 1 + }, + { + 'price': decimal.Decimal('9.00'), + 'quantity': 1 + }, + ] + self.validate_results(rates, results) + + results = service.rate_method(raw_rates, 3) + rates = [ + { + 'price': decimal.Decimal('0.00'), + 'quantity': 1 + }, + { + 'price': decimal.Decimal('8.00'), + 'quantity': 2 + }, + ] + self.validate_results(rates, results) + + results = service.rate_method(raw_rates, 5) + rates = [ + { + 'price': decimal.Decimal('0.00'), + 'quantity': 1 + }, + { + 'price': decimal.Decimal('7.00'), + 'quantity': 4 + }, + ] + self.validate_results(rates, results) + + results = service.rate_method(raw_rates, 9) + rates = [ + { + 'price': decimal.Decimal('0.00'), + 'quantity': 1 + }, + { + 'price': decimal.Decimal('7.00'), + 'quantity': 4 + }, + { + 'price': decimal.Decimal('10.00'), + 'quantity': 4 + }, + ] + self.validate_results(rates, results) + + results = service.rate_method(raw_rates, 10) + rates = [ + { + 'price': decimal.Decimal('0.00'), + 'quantity': 1 + }, + { + 'price': decimal.Decimal('5.00'), + 'quantity': 9 + }, + ] + self.validate_results(rates, results) + + def test_best_price_multiple(self): + service = self.create_ftp_service(rate_algorithm='orchestra.contrib.plans.ratings.best_price') + account = self.create_account() + dupeplan = Plan.objects.create(name='DUPE') + account.plans.create(plan=dupeplan) + account.plans.create(plan=dupeplan) + service.rates.create(plan=dupeplan, quantity=0, price=0) + service.rates.create(plan=dupeplan, quantity=2, price=9) + service.rates.create(plan=dupeplan, quantity=3, price=8) + service.rates.create(plan=dupeplan, quantity=4, price=7) + service.rates.create(plan=dupeplan, quantity=5, price=10) + service.rates.create(plan=dupeplan, quantity=10, price=5) + raw_rates = service.get_rates(account, cache=False) + + results = service.rate_method(raw_rates, 3) + rates = [ + { + 'price': decimal.Decimal('0.00'), + 'quantity': 2 + }, + { + 'price': decimal.Decimal('8.00'), + 'quantity': 1 + }, + ] + self.validate_results(rates, results) + + results = service.rate_method(raw_rates, 10) + rates = [ + { + 'price': decimal.Decimal('0.00'), + 'quantity': 2 + }, + { + 'price': decimal.Decimal('5.00'), + 'quantity': 8 + }, + ] + self.validate_results(rates, results) + + account.plans.create(plan=dupeplan) + raw_rates = service.get_rates(account, cache=False) + + results = service.rate_method(raw_rates, 3) + rates = [ + { + 'price': decimal.Decimal('0.00'), + 'quantity': 3 + }, + ] + self.validate_results(rates, results) + + results = service.rate_method(raw_rates, 10) + rates = [ + { + 'price': decimal.Decimal('0.00'), + 'quantity': 3 + }, + { + 'price': decimal.Decimal('5.00'), + 'quantity': 7 + }, + ] + self.validate_results(rates, results) diff --git a/orchestra/contrib/settings/README.md b/orchestra/contrib/settings/README.md new file mode 100644 index 0000000..4de7305 --- /dev/null +++ b/orchestra/contrib/settings/README.md @@ -0,0 +1,18 @@ +```python +>>> from orchestra.contrib.settings import Setting, parser +>>> Setting.settings['TASKS_BACKEND'].value +'thread' +>>> Setting.settings['TASKS_BACKEND'].default +'thread' +>>> Setting.settings['TASKS_BACKEND'].validate_value('rata') +Traceback (most recent call last): + File "", line 1, in + File "/home/orchestra/django-orchestra/orchestra/contrib/settings/__init__.py", line 99, in validate_value + raise ValidationError("'%s' not in '%s'" % (value, ', '.join(choices))) +django.core.exceptions.ValidationError: ["'rata' not in 'thread, process, celery'"] +>>> parser.apply({'TASKS_BACKEND': 'process'}) +... +>>> parser.apply({'TASKS_BACKEND': parser.Remove()}) +... +``` + diff --git a/orchestra/contrib/settings/__init__.py b/orchestra/contrib/settings/__init__.py new file mode 100644 index 0000000..1dc831a --- /dev/null +++ b/orchestra/contrib/settings/__init__.py @@ -0,0 +1,113 @@ +import re +from collections import OrderedDict + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.utils.functional import Promise + +from orchestra.core import validators +from orchestra.utils.python import import_class, format_exception + + +default_app_config = 'orchestra.contrib.settings.apps.SettingsConfig' + + +class Setting(object): + """ + Keeps track of the defined settings and provides extra batteries like value validation. + """ + conf_settings = settings + settings = OrderedDict() + + def __str__(self): + return self.name + + def __repr__(self): + value = str(self.value) + value = ("'%s'" if isinstance(value, str) else '%s') % value + return '<%s: %s>' % (self.name, value) + + def __new__(cls, name, default, help_text="", choices=None, editable=True, serializable=True, + multiple=False, validators=[], types=[], call_init=False): + if call_init: + return super(Setting, cls).__new__(cls) + cls.settings[name] = cls(name, default, help_text=help_text, choices=choices, editable=editable, + serializable=serializable, multiple=multiple, validators=validators, types=types, call_init=True) + return cls.get_value(name, default) + + def __init__(self, *args, **kwargs): + self.name, self.default = args + for name, value in kwargs.items(): + setattr(self, name, value) + self.value = self.get_value(self.name, self.default) + self.settings[name] = self + + @classmethod + def validate_choices(cls, value): + if not isinstance(value, (list, tuple)): + raise ValidationError("%s is not a valid choices." % value) + for choice in value: + if not isinstance(choice, (list, tuple)) or len(choice) != 2: + raise ValidationError("%s is not a valid choice." % choice) + value, verbose = choice + if not isinstance(verbose, (str, Promise)): + raise ValidationError("%s is not a valid verbose name." % value) + + @classmethod + def validate_import_class(cls, value): + try: + import_class(value) + except Exception as exc: + raise ValidationError(format_exception(exc)) + + @classmethod + def validate_model_label(cls, value): + from django.apps import apps + try: + apps.get_model(*value.split('.')) + except Exception as exc: + raise ValidationError(format_exception(exc)) + + @classmethod + def string_format_validator(cls, names, modulo=True): + def validate_string_format(value, names=names, modulo=modulo): + errors = [] + regex = r'%\(([^\)]+)\)' if modulo else r'{([^}]+)}' + for n in re.findall(regex, value): + if n not in names: + errors.append( + ValidationError('%s is not a valid format name.' % n) + ) + if errors: + raise ValidationError(errors) + return validate_string_format + + def validate_value(self, value): + if value: + validators.all_valid(value, self.validators) + valid_types = list(self.types) + if self.choices: + choices = self.choices + if callable(choices): + choices = choices() + choices = [n for n,v in choices] + values = value + if not isinstance(values, (list, tuple)): + values = [value] + for cvalue in values: + if cvalue not in choices: + raise ValidationError("'%s' not in '%s'" % (value, ', '.join(choices))) + if isinstance(self.default, (list, tuple)): + valid_types.extend([list, tuple]) + valid_types.append(type(self.default)) + if not isinstance(value, tuple(valid_types)): + raise ValidationError("%s is not a valid type (%s)." % + (type(value).__name__, ', '.join(t.__name__ for t in valid_types)) + ) + + def validate(self): + self.validate_value(self.value) + + @classmethod + def get_value(cls, name, default): + return getattr(cls.conf_settings, name, default) diff --git a/orchestra/contrib/settings/admin.py b/orchestra/contrib/settings/admin.py new file mode 100644 index 0000000..6cefcb0 --- /dev/null +++ b/orchestra/contrib/settings/admin.py @@ -0,0 +1,110 @@ +from django.contrib import admin, messages +from django.shortcuts import render +from django.views import generic +from django.utils.translation import ngettext, gettext_lazy as _ + +from orchestra.contrib.settings import Setting +from orchestra.utils import sys + +from . import parser +from .forms import SettingFormSet + + +class SettingView(generic.edit.FormView): + template_name = 'admin/settings/change_form.html' + reload_template_name = 'admin/settings/reload.html' + form_class = SettingFormSet + success_url = '.' + + def get_context_data(self, **kwargs): + context = super(SettingView, self).get_context_data(**kwargs) + context.update({ + 'title': _("Change settings"), + 'settings_file': parser.get_settings_file(), + }) + return context + + def get_initial(self): + initial_data = [] + prev_app = None + account = 0 + for name, setting in Setting.settings.items(): + app = name.split('_')[0] + initial = { + 'name': setting.name, + 'help_text': setting.help_text, + 'default': setting.default, + 'type': type(setting.default), + 'value': setting.value, + 'setting': setting, + 'app': app, + } + if app == 'ORCHESTRA': + initial_data.insert(account, initial) + account += 1 + else: + initial_data.append(initial) + return initial_data + + def form_valid(self, form): + settings = Setting.settings + changes = {} + for data in form.cleaned_data: + setting = settings[data['name']] + if not isinstance(data['value'], parser.NotSupported) and setting.editable: + if setting.value != data['value']: + # Ignore differences between lists and tuples + if (type(setting.value) != type(data['value']) and + isinstance(data['value'], list) and + tuple(data['value']) == setting.value): + continue + if setting.default == data['value']: + changes[setting.name] = parser.Remove() + else: + changes[setting.name] = data['value'] + if changes: + # Display confirmation + if not self.request.POST.get('confirmation'): + settings_file = parser.get_settings_file() + new_content = parser.apply(changes) + cmd = "cat < + +{% endblock %} + + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} + + +
    +
    {% csrf_token %} + {% if diff %} + {% blocktrans %} +

    The following changes will be performed to {{ settings_file }} file.

    + {% endblocktrans %} +
    {{ diff }}
    + {{ form.management_form }} + + {% for form in form %} + {{ form }} + {% endfor %} +
    + +
    + {% else %} + {% blocktrans %} +

    {{ settings_file }} file will be automatically updated and Orchestra restarted according to your changes. + {% endblocktrans %} + {% if form.errors %} +

    + {% trans "Please correct the errors below." %} +

    + {{ form.non_form_errors.as_ul }} + {% endif %} + {{ form.management_form }} + {% regroup form.forms by app as formlist %} + {% for app in formlist %} +
    +

    {{ app.grouper|lower|capfirst }}

    + + {% for form in app.list %} + {{ form.non_field_errors }} + {% if forloop.first %} + + {% for field in form.visible_fields %} + + {% endfor %} + + {% endif %} + + {% for field in form.visible_fields %} + + {% endfor %} + + {% endfor %} +
    {{ field.label|capfirst }}
    + {# Include the hidden fields in the form #} + {% if forloop.first %} + {% for hidden in form.hidden_fields %} + {{ hidden }} + {% endfor %} + {% endif %} + {{ field.errors.as_ul }} +
    {{ field }}{% if forloop.last %}{% if form.changed %}
    *
    {% endif %}{% endif %}
    +

    {{ field.help_text }}

    +
    +
    + {% endfor %} +
    + {% endif %} +
    +{% endblock %} diff --git a/orchestra/contrib/settings/templates/admin/settings/reload.html b/orchestra/contrib/settings/templates/admin/settings/reload.html new file mode 100644 index 0000000..7f18307 --- /dev/null +++ b/orchestra/contrib/settings/templates/admin/settings/reload.html @@ -0,0 +1,54 @@ +{% load static %} + + + + + + + + + +
    +
    notice: {{ message }}
    Refreshing in 2.
    +
    + + diff --git a/orchestra/contrib/settings/templates/admin/settings/view.html b/orchestra/contrib/settings/templates/admin/settings/view.html new file mode 100644 index 0000000..9f8ead5 --- /dev/null +++ b/orchestra/contrib/settings/templates/admin/settings/view.html @@ -0,0 +1,28 @@ +{% extends "admin/base_site.html" %} +{% load i18n l10n %} +{% load admin_urls static utils %} + +{% block extrastyle %} +{{ block.super }} + + +{% endblock %} + + +{% block breadcrumbs %} + +{% endblock %} + + +{% block content %} +
    + {% blocktrans %} +

    Current {{ settings_file }} content.

    + {% endblocktrans %} +
    {{ content }}
    +
    +{% endblock %} diff --git a/orchestra/contrib/systemusers/__init__.py b/orchestra/contrib/systemusers/__init__.py new file mode 100644 index 0000000..1fbedd5 --- /dev/null +++ b/orchestra/contrib/systemusers/__init__.py @@ -0,0 +1 @@ +default_app_config = 'orchestra.contrib.systemusers.apps.SystemUsersConfig' diff --git a/orchestra/contrib/systemusers/actions.py b/orchestra/contrib/systemusers/actions.py new file mode 100644 index 0000000..1916aa4 --- /dev/null +++ b/orchestra/contrib/systemusers/actions.py @@ -0,0 +1,130 @@ +import os + +from django.contrib import messages, admin +from django.core.exceptions import PermissionDenied +from django.template.response import TemplateResponse +from django.utils.translation import ngettext, gettext_lazy as _ + +from orchestra.contrib.orchestration import Operation, helpers + +from .forms import PermissionForm, LinkForm + + +def get_verbose_choice(choices, value): + for choice, verbose in choices: + if choice == value: + return verbose + + +def set_permission(modeladmin, request, queryset): + account_id = None + for user in queryset: + account_id = account_id or user.account_id + if user.account_id != account_id: + messages.error(request, "Users from the same account should be selected.") + return + user = queryset[0] + form = PermissionForm(user) + action_value = 'set_permission' + if request.POST.get('post') == 'generic_confirmation': + form = PermissionForm(user, request.POST) + if form.is_valid(): + cleaned_data = form.cleaned_data + operations = [] + for user in queryset: + base_home = cleaned_data['base_home'] + extension = cleaned_data['home_extension'] + action = cleaned_data['set_action'] + perms = cleaned_data['permissions'] + user.set_perm_action = action + user.set_perm_base_home = base_home + user.set_perm_home_extension = extension + user.set_perm_perms = perms + operations.extend(Operation.create_for_action(user, 'set_permission')) + verbose_action = get_verbose_choice(form.fields['set_action'].choices, + user.set_perm_action) + verbose_permissions = get_verbose_choice(form.fields['permissions'].choices, + user.set_perm_perms) + context = { + 'action': verbose_action, + 'perms': verbose_permissions.lower(), + 'to': os.path.join(user.set_perm_base_home, user.set_perm_home_extension), + } + msg = _("%(action)s %(perms)s permission to %(to)s") % context + modeladmin.log_change(request, user, msg) + if not operations: + messages.error(request, _("No backend operation has been executed.")) + else: + logs = Operation.execute(operations) + helpers.message_user(request, logs) + return + opts = modeladmin.model._meta + app_label = opts.app_label + context = { + 'title': _("Set permission"), + 'action_name': _("Set permission"), + 'action_value': action_value, + 'queryset': queryset, + 'opts': opts, + 'obj': user, + 'app_label': app_label, + 'action_checkbox_name': admin.helpers.ACTION_CHECKBOX_NAME, + 'form': form, + } + return TemplateResponse(request, 'admin/systemusers/systemuser/set_permission.html', context) +set_permission.url_name = 'set-permission' +set_permission.tool_description = _("Set permission") + + +def create_link(modeladmin, request, queryset): + account_id = None + for user in queryset: + account_id = account_id or user.account_id + if user.account_id != account_id: + messages.error(request, "Users from the same account should be selected.") + return + user = queryset[0] + form = LinkForm(user, queryset=queryset) + action_value = 'create_link' + if request.POST.get('post') == 'generic_confirmation': + form = LinkForm(user, request.POST, queryset=queryset) + if form.is_valid(): + cleaned_data = form.cleaned_data + operations = [] + for user in queryset: + base_home = cleaned_data['base_home'] + extension = cleaned_data['home_extension'] + target = os.path.join(base_home, extension) + default_name = os.path.join(user.home, os.path.basename(target)) + link_name = cleaned_data['link_name'] or default_name + user.create_link_target = target + user.create_link_name = link_name + operations.extend(Operation.create_for_action(user, 'create_link')) + context = { + 'target': target, + 'link_name': link_name, + } + msg = _("Created link from %(target)s to %(link_name)s") % context + modeladmin.log_change(request, request.user, msg) + logs = Operation.execute(operations) + if logs: + helpers.message_user(request, logs) + else: + messages.error(request, "No backend operation has been executed.") + return + opts = modeladmin.model._meta + app_label = opts.app_label + context = { + 'title': _("Create link"), + 'action_name': _("Create link"), + 'action_value': action_value, + 'queryset': queryset, + 'opts': opts, + 'obj': user, + 'app_label': app_label, + 'action_checkbox_name': admin.helpers.ACTION_CHECKBOX_NAME, + 'form': form, + } + return TemplateResponse(request, 'admin/systemusers/systemuser/create_link.html', context) +create_link.url_name = 'create-link' +create_link.tool_description = _("Create link") diff --git a/orchestra/contrib/systemusers/admin.py b/orchestra/contrib/systemusers/admin.py new file mode 100644 index 0000000..f00d725 --- /dev/null +++ b/orchestra/contrib/systemusers/admin.py @@ -0,0 +1,111 @@ +from django.contrib import admin, messages +from django.utils.translation import gettext_lazy as _ + +from orchestra.admin import ExtendedModelAdmin, ChangePasswordAdminMixin +from orchestra.admin.actions import disable, enable +from orchestra.contrib.accounts.actions import list_accounts +from orchestra.contrib.accounts.admin import SelectAccountAdminMixin +from orchestra.contrib.accounts.filters import IsActiveListFilter + +from .actions import set_permission, create_link +from .filters import IsMainListFilter +from .forms import SystemUserCreationForm, SystemUserChangeForm, WebappUserChangeForm, WebappUserCreationForm +from .models import SystemUser, WebappUsers + + +class SystemUserAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedModelAdmin): + list_display = ( + 'username', 'account_link', 'shell', 'display_home', 'display_active', 'display_main' + ) + list_filter = (IsActiveListFilter, 'shell', IsMainListFilter) + fieldsets = ( + (None, { + 'fields': ('username', 'password', 'account_link', 'is_active') + }), + (_("System"), { + 'fields': ('shell', ('home', 'directory'), 'groups'), + }), + ) + add_fieldsets = ( + (None, { + 'fields': ('account_link', 'username', 'password1', 'password2') + }), + (_("System"), { + 'fields': ('shell', ('home', 'directory'), 'groups'), + }), + ) + search_fields = ('username', 'account__username') + readonly_fields = ('account_link',) + change_readonly_fields = ('username',) + filter_horizontal = ('groups',) + filter_by_account_fields = ('groups',) + add_form = SystemUserCreationForm + form = SystemUserChangeForm + ordering = ('-id',) + change_view_actions = (set_permission, create_link) + actions = (disable, enable, list_accounts) + change_view_actions + + def display_main(self, user): + return user.is_main + display_main.short_description = _("Main") + display_main.boolean = True + + def display_home(self, user): + return user.get_home() + display_home.short_description = _("Home") + display_home.admin_order_field = 'home' + + def get_form(self, request, obj=None, **kwargs): + form = super(SystemUserAdmin, self).get_form(request, obj, **kwargs) + form.account = self.account + if obj: + # Has to be done here and not in the form because of strange phenomenon + # derived from monkeypatching formfield.widget.render on AccountAdminMinxin, + # don't ask. + formfield = form.base_fields['groups'] + formfield.queryset = formfield.queryset.exclude(id=obj.id) + return form + + def has_delete_permission(self, request, obj=None): + if obj and obj.is_main: + self.message_user(request, _( + "You have selected one main system user (%(account)s), which can not be deleted.", + ) % {'account': obj}, + messages.ERROR, + ) + + return False + return super(SystemUserAdmin, self).has_delete_permission(request, obj) + + + +class WebappUserAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedModelAdmin): + list_display = ( + 'username', 'account_link', 'home', 'target_server' + ) + fieldsets = ( + (None, { + 'fields': ('account_link', 'username', 'password', ) + }), + (_("System"), { + 'fields': ('shell', 'home', 'target_server'), + }), + ) + add_fieldsets = ( + (None, { + 'fields': ('account_link', 'username', 'password1', 'password2') + }), + (_("System"), { + 'fields': ('shell', 'home', 'target_server'), + }), + ) + search_fields = ('username', 'account__username') + readonly_fields = ('account_link',) + change_readonly_fields = ('username', 'home', 'target_server') + add_form = WebappUserCreationForm + form = WebappUserChangeForm + ordering = ('-id',) + + +admin.site.register(SystemUser, SystemUserAdmin) +admin.site.register(WebappUsers, WebappUserAdmin) \ No newline at end of file diff --git a/orchestra/contrib/systemusers/api.py b/orchestra/contrib/systemusers/api.py new file mode 100644 index 0000000..b803c81 --- /dev/null +++ b/orchestra/contrib/systemusers/api.py @@ -0,0 +1,23 @@ +from django.utils.translation import gettext_lazy as _ +from rest_framework import viewsets, exceptions + +from orchestra.api import router, SetPasswordApiMixin, LogApiMixin +from orchestra.contrib.accounts.api import AccountApiMixin + +from .models import SystemUser +from .serializers import SystemUserSerializer + + +class SystemUserViewSet(LogApiMixin, AccountApiMixin, SetPasswordApiMixin, viewsets.ModelViewSet): + queryset = SystemUser.objects.all() + serializer_class = SystemUserSerializer + filter_fields = ('username',) + + def destroy(self, request, pk=None): + user = self.get_object() + if user.is_main: + raise exceptions.PermissionDenied(_("Main system user can not be deleted.")) + return super(SystemUserViewSet, self).destroy(request, pk=pk) + + +router.register(r'systemusers', SystemUserViewSet) diff --git a/orchestra/contrib/systemusers/apps.py b/orchestra/contrib/systemusers/apps.py new file mode 100644 index 0000000..d4bdedc --- /dev/null +++ b/orchestra/contrib/systemusers/apps.py @@ -0,0 +1,30 @@ +import sys + +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 + + +class SystemUsersConfig(AppConfig): + name = 'orchestra.contrib.systemusers' + verbose_name = "System users" + + def ready(self): + from .models import SystemUser, WebappUsers + services.register(SystemUser, icon='roleplaying.png') + if 'migrate' in sys.argv and 'accounts' not in sys.argv: + post_migrate.connect(self.create_initial_systemuser, + dispatch_uid="orchestra.contrib.systemusers.apps.create_initial_systemuser") + services.register(WebappUsers, icon='roleplaying.png', verbose_name =_('WebApp User'), verbose_name_plural=_("Webapp users")) + + def create_initial_systemuser(self, **kwargs): + from .models import SystemUser + Account = SystemUser.account.field.remote_field.model + for account in Account.objects.filter(is_superuser=True, main_systemuser_id__isnull=True): + systemuser = SystemUser.objects.create(username=account.username, + password=account.password, account=account) + account.main_systemuser = systemuser + account.save() + sys.stdout.write("Created initial systemuser %s.\n" % systemuser.username) diff --git a/orchestra/contrib/systemusers/backends.py b/orchestra/contrib/systemusers/backends.py new file mode 100644 index 0000000..789b81c --- /dev/null +++ b/orchestra/contrib/systemusers/backends.py @@ -0,0 +1,833 @@ +import fnmatch +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 + +from . import settings + + +class UNIXUserController(ServiceController): + """ + Basic UNIX system user/group support based on useradd, usermod, userdel and groupdel. + Autodetects and uses ACL if available, for better permission management. + """ + verbose_name = _("UNIX user") + model = 'systemusers.SystemUser' + # actions = ('save', 'delete', 'set_permission', 'validate_paths_exist', 'create_link') + actions = ('save', 'delete', 'set_permission', 'create_link') + doc_settings = (settings, ( + 'SYSTEMUSERS_DEFAULT_GROUP_MEMBERS', + 'SYSTEMUSERS_MOVE_ON_DELETE_PATH', + 'SYSTEMUSERS_FORBIDDEN_PATHS' + )) + + def save(self, user): + context = self.get_context(user) + if not context['user']: + return + + if context['home'] != context['base_home']: + self.append(textwrap.dedent(""" + if [[ ! -e '%(home)s' ]]; then + echo "%(home)s path does not exists." >&2 + exit 0 + fi""") % context + ) + + if not user.active: + self.append(textwrap.dedent(""" + #Just disable that user, if it exists + if id %(user)s ; then + usermod %(user)s --password '%(password)s' + fi + """) % context) + return + # TODO userd add will fail if %(user)s group already exists + self.append(textwrap.dedent(""" + # Update/create user state for %(user)s + if id %(user)s ; then + usermod %(user)s --home '%(home)s' \\ + --password '%(password)s' \\ + --shell '%(shell)s' \\ + --groups '%(groups)s' + else + useradd_code=0 + useradd %(user)s --home '%(home)s' \\ + --password '%(password)s' \\ + --shell '%(shell)s' \\ + --groups '%(groups)s' || useradd_code=$? + if [[ $useradd_code -eq 8 ]]; then + # User is logged in, kill and retry + pkill -u %(user)s; sleep 2 + pkill -9 -u %(user)s; sleep 1 + useradd %(user)s --home '%(home)s' \\ + --password '%(password)s' \\ + --shell '%(shell)s' \\ + --groups '%(groups)s' + elif [[ $useradd_code -ne 0 ]]; then + exit $useradd_code + fi + fi + mkdir -p '%(base_home)s' + chmod 750 '%(base_home)s' + """) % context + ) + if context['home'] != context['base_home']: + self.append(textwrap.dedent("""\ + # Set extra permissions: %(user)s home is inside %(mainuser)s home + if true; then + # if mount | grep "^$(df %(home)s|grep '^/'|cut -d' ' -f1)\s" | grep acl > /dev/null; then + # Account group as the owner + chown %(mainuser)s:%(mainuser)s '%(home)s' + chmod g+s '%(home)s' + # Home access + setfacl -m u:%(user)s:--x '%(mainuser_home)s' + # Grant perms to future files within the directory + setfacl -m d:u:%(user)s:rwx '%(home)s' + # Grant access to main user + setfacl -m d:u:%(mainuser)s:rwx '%(home)s' + else + chmod g+rxw %(home)s + fi""") % context + ) + else: + self.append(textwrap.dedent("""\ + chown %(user)s:%(group)s '%(home)s' + ls -A /etc/skel/ | while read line; do + if [[ ! -e "%(home)s/${line}" ]]; then + cp -a "/etc/skel/${line}" "%(home)s/${line}" && \\ + chown -R %(user)s:%(group)s "%(home)s/${line}" + fi + done + """) % context + ) + for member in settings.SYSTEMUSERS_DEFAULT_GROUP_MEMBERS: + context['member'] = member + self.append('usermod -a -G %(user)s %(member)s || exit_code=$?' % context) + if not user.is_main: + self.append('usermod -a -G %(user)s %(mainuser)s || exit_code=$?' % context) + + def delete(self, user): + context = self.get_context(user) + if not context['user']: + return + self.append(textwrap.dedent(""" + if ! id %(user)s &> /dev/null; then + echo "user %(user)s not exitst" >&2; + exit 0 + fi + + # Delete %(user)s user + nohup bash -c 'sleep 2 && killall -u %(user)s -s KILL' &> /dev/null & + killall -u %(user)s || true + userdel %(user)s || exit_code=$? + groupdel %(group)s || exit_code=$?\ + """) % context + ) + if context['deleted_home']: + self.append(textwrap.dedent("""\ + # Move home into SYSTEMUSERS_MOVE_ON_DELETE_PATH, nesting if exists. + deleted_home="%(deleted_home)s" + while [[ -e "$deleted_home" ]]; do + deleted_home="${deleted_home}/$(basename ${deleted_home})" + done + mv '%(base_home)s' "$deleted_home" || exit_code=$? + """) % context + ) + else: + self.append("rm -fr -- '%(base_home)s'" % context) + + def grant_permissions(self, user, context): + context['perms'] = user.set_perm_perms + # Capital X adds execution permissions for directories, not files + context['perms_X'] = context['perms'] + 'X' + self.append(textwrap.dedent("""\ + # Grant execution permissions to every parent directory + for access_path in %(access_paths)s; do + # Preserve existing ACLs + acl=$(getfacl -a "$access_path" | grep '^user:%(user)s:') && { + perms=$(echo "$acl" | cut -d':' -f3) + perms=$(echo "$perms" | cut -c 1,2)x + setfacl -m u:%(user)s:$perms "$access_path" + } || setfacl -m u:%(user)s:--x "$access_path" + done + # Grant perms to existing files, excluding execution + find '%(perm_to)s' -type f %(exclude_acl)s \\ + -exec setfacl -m u:%(user)s:%(perms)s {} \\; + # Grant perms to extisting directories and set defaults for future content + find '%(perm_to)s' -type d %(exclude_acl)s \\ + -exec setfacl -m u:%(user)s:%(perms_X)s -m d:u:%(user)s:%(perms_X)s {} \\; + # Account group as the owner of new files + chmod g+s '%(perm_to)s'""") % context + ) + if not user.is_main: + self.append(textwrap.dedent("""\ + # Grant access to main user + find '%(perm_to)s' -type d %(exclude_acl)s \\ + -exec setfacl -m d:u:%(mainuser)s:rwx {} \\;\ + """) % context + ) + + def revoke_permissions(self, user, context): + revoke_perms = { + 'rw': '', + 'r': 'w', + 'w': 'r', + } + context.update({ + 'perms': revoke_perms[user.set_perm_perms], + 'option': '-x' if user.set_perm_perms == 'rw' else '-m' + }) + self.append(textwrap.dedent("""\ + # Revoke permissions + find '%(perm_to)s' %(exclude_acl)s \\ + -exec setfacl %(option)s u:%(user)s:%(perms)s {} \\;\ + """) % context + ) + + def set_permission(self, user): + context = self.get_context(user) + context.update({ + 'perm_action': user.set_perm_action, + 'perm_to': os.path.join(user.set_perm_base_home, user.set_perm_home_extension), + }) + exclude_acl = [] + for exclude in settings.SYSTEMUSERS_FORBIDDEN_PATHS: + context['exclude_acl'] = os.path.join(user.set_perm_base_home, exclude) + exclude_acl.append('-not -path "%(exclude_acl)s"' % context) + context['exclude_acl'] = ' \\\n -a '.join(exclude_acl) if exclude_acl else '' + # Access paths + head = user.set_perm_base_home + relative = '' + access_paths = ["'%s'" % head] + for tail in user.set_perm_home_extension.split(os.sep)[:-1]: + relative = os.path.join(relative, tail) + for exclude in settings.SYSTEMUSERS_FORBIDDEN_PATHS: + if fnmatch.fnmatch(relative, exclude): + break + else: + # No match + head = os.path.join(head, tail) + access_paths.append("'%s'" % head) + context['access_paths'] = ' '.join(access_paths) + + if user.set_perm_action == 'grant': + self.grant_permissions(user, context) + elif user.set_perm_action == 'revoke': + self.revoke_permissions(user, context) + else: + raise NotImplementedError() + + def create_link(self, user): + context = self.get_context(user) + context.update({ + 'link_target': user.create_link_target, + 'link_name': user.create_link_name, + }) + self.append(textwrap.dedent("""\ + # Create link + su - %(user)s --shell /bin/bash << 'EOF' || exit_code=1 + if [[ ! -e '%(link_name)s' ]]; then + ln -s '%(link_target)s' '%(link_name)s' + else + echo "%(link_name)s already exists, doing nothing." >&2 + exit 1 + fi + EOF""") % context + ) + + def validate_paths_exist(self, user): + for path in user.paths_to_validate: + context = { + 'path': path, + } + self.append(textwrap.dedent(""" + if [[ ! -e '%(path)s' ]]; then + echo "%(path)s path does not exists." >&2 + fi""") % context + ) + + def get_groups(self, user): + if user.is_main: + return user.account.systemusers.exclude(username=user.username).values_list('username', flat=True) + return list(user.groups.values_list('username', flat=True)) + + def get_context(self, user): + context = { + 'object_id': user.pk, + 'user': user.username, + 'group': user.username, + 'groups': ','.join(self.get_groups(user)), + 'password': user.password if user.active else '*%s' % user.password, + 'shell': user.shell, + 'mainuser': user.username if user.is_main else user.account.username, + 'home': user.get_home(), + 'base_home': user.get_base_home(), + 'mainuser_home': user.main.get_home(), + } + context['deleted_home'] = settings.SYSTEMUSERS_MOVE_ON_DELETE_PATH % context + return replace(context, "'", '"') + + +class UNIXUserDisk(ServiceMonitor): + """ + du -bs <home> + """ + model = 'systemusers.SystemUser' + resource = ServiceMonitor.DISK + verbose_name = _('UNIX user disk') + delete_old_equal_values = True + + def prepare(self): + super(UNIXUserDisk, self).prepare() + self.append(textwrap.dedent("""\ + function monitor () { + { SIZE=$(du -bs "$1") && echo $SIZE || echo 0; } | awk {'print $1'} + }""" + )) + + def monitor(self, user): + context = self.get_context(user) + self.append("echo %(object_id)s $(monitor %(base_home)s)" % context) + + def get_context(self, user): + context = { + 'object_id': user.pk, + 'base_home': user.get_base_home(), + } + return replace(context, "'", '"') + + +class Exim4Traffic(ServiceMonitor): + """ + Exim4 mainlog parser for mails sent on the webserver by system users (e.g. via PHP mail()) + """ + model = 'systemusers.SystemUser' + resource = ServiceMonitor.TRAFFIC + verbose_name = _("Exim4 traffic") + script_executable = '/usr/bin/python' + monthly_sum_old_values = True + doc_settings = (settings, + ('SYSTEMUSERS_MAIL_LOG_PATH',) + ) + + def prepare(self): + mainlog = settings.SYSTEMUSERS_MAIL_LOG_PATH + context = { + 'current_date': self.current_date.strftime("%Y-%m-%d %H:%M:%S %Z"), + 'mainlogs': str((mainlog, mainlog+'.1')), + } + self.append(textwrap.dedent("""\ + import re + import sys + from datetime import datetime + from dateutil import tz + + def to_local_timezone(date, tzlocal=tz.tzlocal()): + date = datetime.strptime(date, '%Y-%m-%d %H:%M:%S %Z') + date = date.replace(tzinfo=tz.tzutc()) + date = date.astimezone(tzlocal) + return date + + mainlogs = {mainlogs} + # Use local timezone + end_date = to_local_timezone('{current_date}') + end_date = int(end_date.strftime('%Y%m%d%H%M%S')) + users = {{}} + + def prepare(object_id, username, ini_date): + global users + ini_date = to_local_timezone(ini_date) + ini_date = int(ini_date.strftime('%Y%m%d%H%M%S')) + users[username] = [ini_date, object_id, 0] + + def monitor(users, end_date, mainlogs): + user_regex = re.compile(r' U=([^ ]+) ') + for mainlog in mainlogs: + try: + with open(mainlog, 'r') as mainlog: + for line in mainlog.readlines(): + if ' <= ' in line and 'P=local' in line: + username = user_regex.search(line).groups()[0] + try: + sender = users[username] + except KeyError: + continue + else: + date, time, id, __, __, user, protocol, size = line.split()[:8] + date = date.replace('-', '') + date += time.replace(':', '') + if sender[0] < int(date) < end_date: + sender[2] += int(size[2:]) + except IOError as e: + sys.stderr.write(str(e)) + + for username, opts in users.iteritems(): + __, object_id, size = opts + print object_id, size + """).format(**context) + ) + + def commit(self): + self.append('monitor(users, end_date, mainlogs)') + + def monitor(self, user): + context = self.get_context(user) + self.append("prepare(%(object_id)s, '%(username)s', '%(last_date)s')" % context) + + def get_context(self, user): + context = { + 'username': user.username, + 'object_id': user.pk, + 'last_date': self.get_last_date(user.pk).strftime("%Y-%m-%d %H:%M:%S %Z"), + } + return context + + +class VsFTPdTraffic(ServiceMonitor): + """ + vsFTPd log parser. + """ + model = 'systemusers.SystemUser' + resource = ServiceMonitor.TRAFFIC + verbose_name = _('VsFTPd traffic') + script_executable = '/usr/bin/python' + monthly_sum_old_values = True + doc_settings = (settings, + ('SYSTEMUSERS_FTP_LOG_PATH',) + ) + + def prepare(self): + vsftplog = settings.SYSTEMUSERS_FTP_LOG_PATH + context = { + 'current_date': self.current_date.strftime("%Y-%m-%d %H:%M:%S %Z"), + 'vsftplogs': str((vsftplog, vsftplog+'.1')), + } + self.append(textwrap.dedent("""\ + import re + import sys + from datetime import datetime + from dateutil import tz + + def to_local_timezone(date, tzlocal=tz.tzlocal()): + date = datetime.strptime(date, '%Y-%m-%d %H:%M:%S %Z') + date = date.replace(tzinfo=tz.tzutc()) + date = date.astimezone(tzlocal) + return date + + vsftplogs = {vsftplogs} + # Use local timezone + end_date = to_local_timezone('{current_date}') + end_date = int(end_date.strftime('%Y%m%d%H%M%S')) + users = {{}} + months = ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec') + months = dict((m, '%02d' % n) for n, m in enumerate(months, 1)) + + def prepare(object_id, username, ini_date): + global users + ini_date = to_local_timezone(ini_date) + ini_date = int(ini_date.strftime('%Y%m%d%H%M%S')) + users[username] = [ini_date, object_id, 0] + + def monitor(users, end_date, months, vsftplogs): + user_regex = re.compile(r'\] \[([^ ]+)\] (OK|FAIL) ') + bytes_regex = re.compile(r', ([0-9]+) bytes, ') + for vsftplog in vsftplogs: + try: + with open(vsftplog, 'r') as vsftplog: + for line in vsftplog.readlines(): + if ' bytes, ' in line: + username = user_regex.search(line).groups()[0] + try: + user = users[username] + except KeyError: + continue + else: + __, month, day, time, year = line.split()[:5] + date = year + months[month] + day + time.replace(':', '') + if user[0] < int(date) < end_date: + bytes = bytes_regex.search(line).groups()[0] + user[2] += int(bytes) + except IOError as e: + sys.stderr.write(str(e)) + + for username, opts in users.items(): + __, object_id, size = opts + print object_id, size + """).format(**context) + ) + + def monitor(self, user): + context = self.get_context(user) + self.append("prepare(%(object_id)s, '%(username)s', '%(last_date)s')" % context) + + def commit(self): + self.append('monitor(users, end_date, months, vsftplogs)') + + def get_context(self, user): + context = { + 'last_date': self.get_last_date(user.pk).strftime("%Y-%m-%d %H:%M:%S %Z"), + 'object_id': user.pk, + 'username': user.username, + } + return replace(context, "'", '"') + + + +# ----------------------------------------------------------------------------------------------------------------------------------------- + + +class UNIXUserControllerNewServers(ServiceController): + """ + Basic UNIX system user/group support based on useradd, usermod, userdel and groupdel. + Autodetects and uses ACL if available, for better permission management. + """ + verbose_name = _("UNIX user new servers") + model = 'systemusers.SystemUser' + # actions = ('save', 'delete', 'set_permission', 'validate_paths_exist', 'create_link') + actions = ('save', 'delete', 'set_permission', 'create_link') + doc_settings = (settings, ( + 'SYSTEMUSERS_DEFAULT_GROUP_MEMBERS', + 'SYSTEMUSERS_MOVE_ON_DELETE_PATH', + 'SYSTEMUSERS_FORBIDDEN_PATHS' + )) + + def save(self, user): + context = self.get_context(user) + if not context['user']: + return + + if not user.active: + self.append(textwrap.dedent(""" + #Just disable that user, if it exists + if id %(user)s ; then + usermod %(user)s --password '%(password)s' + fi + """) % context) + return + if user.is_main: + # TODO userd add will fail if %(user)s group already exists + self.append(textwrap.dedent(""" + # Update/create user state for %(user)s + if id %(user)s &> /dev/null; then + usermod %(user)s --home '%(home)s/%(user)s' \\ + --password '%(password)s' \\ + --shell '%(shell)s' \\ + --groups '%(groups)s' + else + useradd_code=0 + useradd %(user)s --home '%(home)s/%(user)s' \\ + --password '%(password)s' \\ + --shell '%(shell)s' \\ + --groups '%(groups)s' || useradd_code=$? + if [[ $useradd_code -eq 8 ]]; then + # User is logged in, kill and retry + pkill -u %(user)s; sleep 2 + pkill -9 -u %(user)s; sleep 1 + useradd %(user)s --home '%(home)s/%(user)s' \\ + --password '%(password)s' \\ + --shell '%(shell)s' \\ + --groups '%(groups)s' + elif [[ $useradd_code -ne 0 ]]; then + exit $useradd_code + fi + fi + mkdir -p '%(base_home)s/%(user)s' + chown root:%(user)s %(base_home)s + chmod 710 '%(base_home)s' + setfacl -m 'u:%(user)s:rx' %(base_home)s + + chown %(user)s:%(user)s '%(base_home)s/%(user)s' + chmod 700 '%(base_home)s/%(user)s' + """) % context + ) + self.append(textwrap.dedent("""\ + ls -A /etc/skel/ | while read line; do + if [[ ! -e "%(home)s/${line}" ]]; then + cp -a "/etc/skel/${line}" "%(base_home)s/%(user)s/${line}" && \\ + chown -R %(user)s:%(user)s "%(base_home)s/%(user)s/${line}" + fi + done + """) % context + ) + + for member in settings.SYSTEMUSERS_DEFAULT_GROUP_MEMBERS: + context['member'] = member + self.append('usermod -a -G %(user)s %(member)s || exit_code=$?' % context) + + + def delete(self, user): + context = self.get_context(user) + if not context['user']: + return + if user.is_main: + self.append(textwrap.dedent("""\ + # Delete %(user)s user + nohup bash -c 'sleep 2 && killall -u %(user)s -s KILL' &> /dev/null & + killall -u %(user)s || true + userdel %(user)s || exit_code=$? + groupdel %(group)s || exit_code=$? + """) % context + ) + if context['deleted_home']: + self.append(textwrap.dedent("""\ + # Move home into SYSTEMUSERS_MOVE_ON_DELETE_PATH, nesting if exists. + deleted_home="%(deleted_home)s" + while [[ -e "$deleted_home" ]]; do + deleted_home="${deleted_home}/$(basename ${deleted_home})" + done + mv '%(base_home)s' "$deleted_home" || exit_code=$? + """) % context + ) + else: + self.append("rm -fr -- '%(base_home)s'" % context) + + # TODO: comprovar funciones que no se suelen utilizar + def grant_permissions(self, user, context): + context['perms'] = user.set_perm_perms + # Capital X adds execution permissions for directories, not files + context['perms_X'] = context['perms'] + 'X' + self.append(textwrap.dedent("""\ + # Grant execution permissions to every parent directory + for access_path in %(access_paths)s; do + # Preserve existing ACLs + acl=$(getfacl -a "$access_path" | grep '^user:%(user)s:') && { + perms=$(echo "$acl" | cut -d':' -f3) + perms=$(echo "$perms" | cut -c 1,2)x + setfacl -m u:%(user)s:$perms "$access_path" + } || setfacl -m u:%(user)s:--x "$access_path" + done + # Grant perms to existing files, excluding execution + find '%(perm_to)s' -type f %(exclude_acl)s \\ + -exec setfacl -m u:%(user)s:%(perms)s {} \\; + # Grant perms to extisting directories and set defaults for future content + find '%(perm_to)s' -type d %(exclude_acl)s \\ + -exec setfacl -m u:%(user)s:%(perms_X)s -m d:u:%(user)s:%(perms_X)s {} \\; + # Account group as the owner of new files + chmod g+s '%(perm_to)s'""") % context + ) + if not user.is_main: + self.append(textwrap.dedent("""\ + # Grant access to main user + find '%(perm_to)s' -type d %(exclude_acl)s \\ + -exec setfacl -m d:u:%(mainuser)s:rwx {} \\;\ + """) % context + ) + + def revoke_permissions(self, user, context): + revoke_perms = { + 'rw': '', + 'r': 'w', + 'w': 'r', + } + context.update({ + 'perms': revoke_perms[user.set_perm_perms], + 'option': '-x' if user.set_perm_perms == 'rw' else '-m' + }) + self.append(textwrap.dedent("""\ + # Revoke permissions + find '%(perm_to)s' %(exclude_acl)s \\ + -exec setfacl %(option)s u:%(user)s:%(perms)s {} \\;\ + """) % context + ) + + def set_permission(self, user): + context = self.get_context(user) + context.update({ + 'perm_action': user.set_perm_action, + 'perm_to': os.path.join(user.set_perm_base_home, user.set_perm_home_extension), + }) + exclude_acl = [] + for exclude in settings.SYSTEMUSERS_FORBIDDEN_PATHS: + context['exclude_acl'] = os.path.join(user.set_perm_base_home, exclude) + exclude_acl.append('-not -path "%(exclude_acl)s"' % context) + context['exclude_acl'] = ' \\\n -a '.join(exclude_acl) if exclude_acl else '' + # Access paths + head = user.set_perm_base_home + relative = '' + access_paths = ["'%s'" % head] + for tail in user.set_perm_home_extension.split(os.sep)[:-1]: + relative = os.path.join(relative, tail) + for exclude in settings.SYSTEMUSERS_FORBIDDEN_PATHS: + if fnmatch.fnmatch(relative, exclude): + break + else: + # No match + head = os.path.join(head, tail) + access_paths.append("'%s'" % head) + context['access_paths'] = ' '.join(access_paths) + + if user.set_perm_action == 'grant': + self.grant_permissions(user, context) + elif user.set_perm_action == 'revoke': + self.revoke_permissions(user, context) + else: + raise NotImplementedError() + + def create_link(self, user): + context = self.get_context(user) + context.update({ + 'link_target': user.create_link_target, + 'link_name': user.create_link_name, + }) + self.append(textwrap.dedent("""\ + # Create link + su - %(user)s --shell /bin/bash << 'EOF' || exit_code=1 + if [[ ! -e '%(link_name)s' ]]; then + ln -s '%(link_target)s' '%(link_name)s' + else + echo "%(link_name)s already exists, doing nothing." >&2 + exit 1 + fi + EOF""") % context + ) + + def validate_paths_exist(self, user): + for path in user.paths_to_validate: + context = { + 'path': path, + } + self.append(textwrap.dedent(""" + if [[ ! -e '%(path)s' ]]; then + echo "%(path)s path does not exists." >&2 + fi""") % context + ) + + def get_groups(self, user): + groups = [] + if user.is_main: + groups = list(user.account.systemusers.exclude(username=user.username).values_list('username', flat=True)) + groups.append("main-systemusers") + return groups + + def get_context(self, user): + context = { + 'object_id': user.pk, + 'user': user.username, + 'group': user.username, + 'groups': ','.join(self.get_groups(user)), + 'password': user.password if user.active else '*%s' % user.password, + 'shell': user.shell, + 'mainuser': user.username if user.is_main else user.account.username, + 'home': user.get_home(), + 'base_home': user.get_base_home(), + 'mainuser_home': user.main.get_home(), + } + context['deleted_home'] = settings.SYSTEMUSERS_MOVE_ON_DELETE_PATH % context + return replace(context, "'", '"') + + + + +class WebappUserController(ServiceController): + """ + Basic UNIX system user/group support based on useradd, usermod, userdel and groupdel. + Autodetects and uses ACL if available, for better permission management. + """ + verbose_name = _("SFTP Webapp user") + model = 'systemusers.WebappUsers' + actions = ('save', 'delete',) + doc_settings = (settings, ( + 'SYSTEMUSERS_DEFAULT_GROUP_MEMBERS', + 'SYSTEMUSERS_MOVE_ON_DELETE_PATH', + 'SYSTEMUSERS_FORBIDDEN_PATHS' + )) + + def save(self, user): + context = self.get_context(user) + if not context['user']: + return + + self.append(textwrap.dedent(""" + # Update/create user state for %(user)s + if id %(user)s &> /dev/null; then + usermod %(user)s --home '/%(home)s' \\ + --password '%(password)s' \\ + --shell '%(shell)s' \\ + --groups '%(groups)s' + else + useradd_code=0 + useradd %(user)s --home '/%(home)s' \\ + --password '%(password)s' \\ + --shell '%(shell)s' \\ + --groups '%(groups)s' || useradd_code=$? + if [[ $useradd_code -eq 8 ]]; then + # User is logged in, kill and retry + pkill -u %(user)s; sleep 2 + pkill -9 -u %(user)s; sleep 1 + useradd %(user)s --home '/%(home)s' \\ + --password '%(password)s' \\ + --shell '%(shell)s' \\ + --groups '%(groups)s' + elif [[ $useradd_code -ne 0 ]]; then + exit $useradd_code + fi + usermod -aG %(user)s www-data + fi + usermod -aG %(user)s %(parent)s + + # Ensure homedir exists and has correct perms + mkdir -p '%(webapp_path)s' || exit_code=1 + chown %(user)s:%(user)s %(webapp_path)s || exit_code=1 + chmod 750 '%(webapp_path)s' || exit_code=1 + + # Create /chroots/$uid symlink into /home/$user.parent/webapps/ + uid=$(id -u "%(user)s") + ln -n -f -s %(base_home)s/webapps /chroots/$uid || exit_code=1 + """) % context + ) + + + def delete(self, user): + context = self.get_context(user) + if not context['user']: + return + + self.append(textwrap.dedent("""\ + # Delete %(user)s user + uid=$(id -u "%(user)s") + + nohup bash -c 'sleep 2 && killall -u %(user)s -s KILL' &> /dev/null & + killall -u %(user)s || true + userdel %(user)s || exit_code=$? + groupdel %(group)s || exit_code=$? + + # Delete /chroots/$uid symlink into /home/$user.parent/webapps/ + rm /chroots/$uid + """) % context + ) + if context['deleted_home']: + self.append(textwrap.dedent("""\ + # Move home into SYSTEMUSERS_MOVE_ON_DELETE_PATH, nesting if exists. + mv '%(webapp_path)s' '%(deleted_home)s' || exit_code=$? + """) % context + ) + else: + self.append("rm -fr -- '%(webapp_path)s'" % context) + + + def get_groups(self, user): + groups = [] + groups = list(user.account.systemusers.exclude(username=user.username).values_list('username', flat=True)) + groups.append("webapp-systemusers") + return groups + + def get_context(self, user): + context = { + 'object_id': user.pk, + 'user': user.username, + 'group': user.username, + 'groups': ','.join(self.get_groups(user)), + 'password': user.password, #if user.active else '*%s' % user.password, + 'shell': user.shell, + 'home': user.home, + 'base_home': user.get_base_home(), + 'webapp_path': os.path.normpath(user.get_base_home() + "/webapps/" + user.home), + 'parent': user.get_parent(), + } + context['deleted_home'] = context['webapp_path'] + ".deleted" + return replace(context, "'", '"') diff --git a/orchestra/contrib/systemusers/filters.py b/orchestra/contrib/systemusers/filters.py new file mode 100644 index 0000000..7d1d972 --- /dev/null +++ b/orchestra/contrib/systemusers/filters.py @@ -0,0 +1,20 @@ +from django.contrib.admin import SimpleListFilter +from django.utils.translation import gettext_lazy as _ + + +class IsMainListFilter(SimpleListFilter): + """ Filter Nodes by group according to request.user """ + title = _("main") + parameter_name = 'is_main' + + def lookups(self, request, model_admin): + return ( + ('True', _("Yes")), + ('False', _("No")), + ) + + def queryset(self, request, queryset): + if self.value() == 'True': + return queryset.by_is_main() + if self.value() == 'False': + return queryset.by_is_main(is_main=False) diff --git a/orchestra/contrib/systemusers/forms.py b/orchestra/contrib/systemusers/forms.py new file mode 100644 index 0000000..c22d24c --- /dev/null +++ b/orchestra/contrib/systemusers/forms.py @@ -0,0 +1,189 @@ +import os +import textwrap + +from django import forms +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +from orchestra.forms import UserCreationForm, UserChangeForm +from orchestra.settings import NEW_SERVERS + +from . import settings +from .models import SystemUser +from .validators import validate_home, validate_paths_exist + + +class SystemUserFormMixin(object): + MOCK_USERNAME = '' + + def __init__(self, *args, **kwargs): + super(SystemUserFormMixin, self).__init__(*args, **kwargs) + duplicate = lambda n: (n, n) + if self.instance.pk: + username = self.instance.username + choices=( + duplicate(self.account.main_systemuser.get_base_home()), + duplicate(self.instance.get_base_home()), + ) + else: + username = self.MOCK_USERNAME + choices=( + duplicate(self.account.main_systemuser.get_base_home()), + duplicate(SystemUser(username=username).get_base_home()), + ) + self.fields['home'].widget = forms.Select(choices=choices) + if self.instance.pk and (self.instance.is_main or self.instance.has_shell): + # hidde home option for shell users + self.fields['home'].widget.input_type = 'hidden' + self.fields['directory'].widget.input_type = 'hidden' + elif self.instance.pk and (self.instance.get_base_home() == self.instance.home): + self.fields['directory'].widget = forms.HiddenInput() + else: + self.fields['directory'].widget = forms.TextInput(attrs={'size':'70'}) + if not self.instance.pk or not self.instance.is_main: + # Some javascript for hidde home/directory inputs when convinient + self.fields['shell'].widget.attrs['onChange'] = textwrap.dedent("""\ + field = $(".field-home, .field-directory"); + input = $("#id_home, #id_directory"); + if ($.inArray(this.value, %s) < 0) { + field.addClass("hidden"); + } else { + field.removeClass("hidden"); + input.removeAttr("type"); + };""" % list(settings.SYSTEMUSERS_DISABLED_SHELLS) + ) + self.fields['home'].widget.attrs['onChange'] = textwrap.dedent("""\ + field = $(".field-box.field-directory"); + input = $("#id_directory"); + if (this.value.search("%s") > 0) { + field.addClass("hidden"); + } else { + field.removeClass("hidden"); + input.removeAttr("type"); + };""" % username + ) + + def clean_directory(self): + directory = self.cleaned_data['directory'] + return directory.lstrip('/') + + def clean(self): + super(SystemUserFormMixin, self).clean() + cleaned_data = self.cleaned_data + home = cleaned_data.get('home') + shell = cleaned_data.get('shell') + if home and self.MOCK_USERNAME in home: + username = cleaned_data.get('username', '') + cleaned_data['home'] = home.replace(self.MOCK_USERNAME, username) + elif home and shell not in settings.SYSTEMUSERS_DISABLED_SHELLS: + cleaned_data['home'] = '' + cleaned_data['directory'] = '' + validate_home(self.instance, cleaned_data, self.account) + return cleaned_data + + +class SystemUserCreationForm(SystemUserFormMixin, UserCreationForm): + pass + + +class SystemUserChangeForm(SystemUserFormMixin, UserChangeForm): + pass + + +class LinkForm(forms.Form): + base_home = forms.ChoiceField(label=_("Target path"), choices=(), + help_text=_("Target link will be under this directory.")) + home_extension = forms.CharField(label=_("Home extension"), required=False, initial='', + widget=forms.TextInput(attrs={'size':'70'}), + help_text=_("Relative path to chosen directory.")) + link_name = forms.CharField(label=_("Link name"), required=False, initial='', + widget=forms.TextInput(attrs={'size':'70'})) + + def __init__(self, *args, **kwargs): + self.instance = args[0] + self.queryset = kwargs.pop('queryset', []) + super_args = [] + if len(args) > 1: + super_args.append(args[1]) + super(LinkForm, self).__init__(*super_args, **kwargs) + related_users = type(self.instance).objects.filter(account=self.instance.account_id) + self.fields['base_home'].choices = ( + (user.get_base_home(), user.get_base_home()) for user in related_users + ) + if len(self.queryset) == 1: + user = self.instance + help_text = _("If left blank or relative path: the link will be created in %s home.") % user + else: + help_text = _("If left blank or relative path: the link will be created in each user home.") + self.fields['link_name'].help_text = help_text + + def clean_home_extension(self): + home_extension = self.cleaned_data['home_extension'] + return home_extension.lstrip('/') + + def clean_link_name(self): + link_name = self.cleaned_data['link_name'] + if link_name: + if link_name.startswith('/'): + if len(self.queryset) > 1: + raise ValidationError( + _("Link name can not be a full path when multiple users.")) + link_names = [os.path.dirname(link_name)] + else: + dir_name = os.path.dirname(link_name) + link_names = [os.path.join(user.home, dir_name) for user in self.queryset] + validate_paths_exist(self.instance, link_names) + return link_name + + def clean(self): + cleaned_data = super(LinkForm, self).clean() + path = os.path.join(cleaned_data['base_home'], cleaned_data['home_extension']) + try: + validate_paths_exist(self.instance, [path]) + except ValidationError as err: + raise ValidationError({ + 'home_extension': err, + }) + return cleaned_data + + +class PermissionForm(LinkForm): + set_action = forms.ChoiceField(label=_("Action"), initial='grant', + choices=( + ('grant', _("Grant")), + ('revoke', _("Revoke")) + )) + base_home = forms.ChoiceField(label=_("Set permissions to"), choices=(), + help_text=_("User will be granted/revoked access to this directory.")) + home_extension = forms.CharField(label=_("Home extension"), required=False, initial='', + widget=forms.TextInput(attrs={'size':'70'}), help_text=_("Relative to chosen home.")) + permissions = forms.ChoiceField(label=_("Permissions"), initial='read-write', + choices=( + ('rw', _("Read and write")), + ('r', _("Read only")), + ('w', _("Write only")) + )) + +# ---------------------------- + + +class WebappUserFormMixin(object): + + def __init__(self, *args, **kwargs): + super(WebappUserFormMixin, self).__init__(*args, **kwargs) + + def clean(self): + if not self.instance.pk: + server = self.cleaned_data.get('target_server') + if server: + if server.name not in NEW_SERVERS: + self.add_error("target_server", _(f"{server} does not belong to the new servers")) + return self.cleaned_data + +class WebappUserCreationForm(WebappUserFormMixin, UserCreationForm): + pass + + +class WebappUserChangeForm(WebappUserFormMixin, UserChangeForm): + pass + diff --git a/orchestra/contrib/systemusers/migrations/0001_initial.py b/orchestra/contrib/systemusers/migrations/0001_initial.py new file mode 100644 index 0000000..30ea85f --- /dev/null +++ b/orchestra/contrib/systemusers/migrations/0001_initial.py @@ -0,0 +1,32 @@ +# Generated by Django 2.2.28 on 2023-07-22 08:04 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import orchestra.core.validators + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='SystemUser', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('username', models.CharField(help_text='Required. 32 characters or fewer. Letters, digits and ./-/_ only.', max_length=32, unique=True, validators=[orchestra.core.validators.validate_username], verbose_name='username')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('home', models.CharField(blank=True, help_text='Starting location when login with this no-shell user.', max_length=256, verbose_name='home')), + ('directory', models.CharField(blank=True, help_text="Optional directory relative to user's home.", max_length=256, verbose_name='directory')), + ('shell', models.CharField(choices=[('/dev/null', 'No shell, FTP only'), ('/bin/rssh', 'No shell, SFTP/RSYNC only'), ('/usr/bin/git-shell', 'No shell, GIT only'), ('/bin/bash', '/bin/bash')], default='/dev/null', max_length=32, verbose_name='shell')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this account should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='systemusers', to=settings.AUTH_USER_MODEL, verbose_name='Account')), + ('groups', models.ManyToManyField(blank=True, help_text='A new group will be created for the user. Which additional groups would you like them to be a member of?', to='systemusers.SystemUser')), + ], + ), + ] diff --git a/orchestra/contrib/systemusers/migrations/0002_webappusers.py b/orchestra/contrib/systemusers/migrations/0002_webappusers.py new file mode 100644 index 0000000..1a552f5 --- /dev/null +++ b/orchestra/contrib/systemusers/migrations/0002_webappusers.py @@ -0,0 +1,33 @@ +# Generated by Django 2.2.28 on 2023-07-22 08:05 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import orchestra.core.validators + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('orchestration', '__first__'), + ('systemusers', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='WebappUsers', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('username', models.CharField(help_text='Required. 32 characters or fewer. Letters, digits and ./-/_ only.', max_length=32, unique=True, validators=[orchestra.core.validators.validate_username], verbose_name='username')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('home', models.CharField(blank=True, help_text='Starting location when login with this no-shell user.', max_length=256, verbose_name='home')), + ('shell', models.CharField(choices=[('/dev/null', 'No shell, FTP only'), ('/bin/rssh', 'No shell, SFTP/RSYNC only'), ('/usr/bin/git-shell', 'No shell, GIT only'), ('/bin/bash', '/bin/bash')], default='/dev/null', max_length=32, verbose_name='shell')), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accounts', to=settings.AUTH_USER_MODEL, verbose_name='Account')), + ('target_server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='orchestration.Server', verbose_name='Server')), + ], + options={ + 'unique_together': {('username', 'target_server')}, + }, + ), + ] diff --git a/orchestra/contrib/systemusers/migrations/0003_auto_20230724_1813.py b/orchestra/contrib/systemusers/migrations/0003_auto_20230724_1813.py new file mode 100644 index 0000000..1e1a246 --- /dev/null +++ b/orchestra/contrib/systemusers/migrations/0003_auto_20230724_1813.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.28 on 2023-07-24 16:13 + +from django.db import migrations, models +import orchestra.core.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ('systemusers', '0002_webappusers'), + ] + + operations = [ + migrations.AlterModelOptions( + name='webappusers', + options={'verbose_name': 'WebAppUser', 'verbose_name_plural': 'WebappUsers'}, + ), + migrations.AlterField( + model_name='webappusers', + name='home', + field=models.CharField(blank=True, help_text='name dir webapp /home/<main>/webapps/<DirName>', max_length=256, validators=[orchestra.core.validators.validate_string_dir], verbose_name='WebappDir'), + ), + ] diff --git a/orchestra/contrib/systemusers/migrations/0004_auto_20230813_0920.py b/orchestra/contrib/systemusers/migrations/0004_auto_20230813_0920.py new file mode 100644 index 0000000..88509a1 --- /dev/null +++ b/orchestra/contrib/systemusers/migrations/0004_auto_20230813_0920.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.28 on 2023-08-13 07:20 + +from django.db import migrations, models +import orchestra.core.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ('systemusers', '0003_auto_20230724_1813'), + ] + + operations = [ + migrations.AlterField( + model_name='webappusers', + name='username', + field=models.CharField(help_text='Required. 32 characters or fewer. Letters, digits and ./-/_ only.', max_length=32, validators=[orchestra.core.validators.validate_username], verbose_name='username'), + ), + ] diff --git a/orchestra/contrib/systemusers/migrations/__init__.py b/orchestra/contrib/systemusers/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestra/contrib/systemusers/models.py b/orchestra/contrib/systemusers/models.py new file mode 100644 index 0000000..062930f --- /dev/null +++ b/orchestra/contrib/systemusers/models.py @@ -0,0 +1,178 @@ +import fnmatch +import os + +from django.contrib.auth.hashers import make_password +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import F +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ + +from orchestra.core import validators + +from . import settings + + +class SystemUserQuerySet(models.QuerySet): + def create_user(self, username, password='', **kwargs): + user = super(SystemUserQuerySet, self).create(username=username, **kwargs) + user.set_password(password) + user.save(update_fields=['password']) + return user + + def by_is_main(self, is_main=True, **kwargs): + if is_main: + return self.filter(account__main_systemuser_id=F('id')) + else: + return self.exclude(account__main_systemuser_id=F('id')) + + +class SystemUser(models.Model): + """ + System users + + 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.validate_username]) + password = models.CharField(_("password"), max_length=128) + account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), + related_name='systemusers', on_delete=models.CASCADE) + home = models.CharField(_("home"), max_length=256, blank=True, + help_text=_("Starting location when login with this no-shell user.")) + directory = models.CharField(_("directory"), max_length=256, blank=True, + help_text=_("Optional directory relative to user's home.")) + shell = models.CharField(_("shell"), max_length=32, choices=settings.SYSTEMUSERS_SHELLS, + default=settings.SYSTEMUSERS_DEFAULT_SHELL) + groups = models.ManyToManyField('self', blank=True, symmetrical=False, + help_text=_("A new group will be created for the user. " + "Which additional groups would you like them to be a member of?")) + is_active = models.BooleanField(_("active"), default=True, + help_text=_("Designates whether this account should be treated as active. " + "Unselect this instead of deleting accounts.")) + + objects = SystemUserQuerySet.as_manager() + + def __str__(self): + return self.username + + @cached_property + def active(self): + try: + return self.is_active and self.account.is_active + except type(self).account.field.related_model.DoesNotExist: + return self.is_active + + @cached_property + def is_main(self): + # TODO on account delete + # On account creation main_systemuser_id is still None + if self.account.main_systemuser_id: + return self.account.main_systemuser_id == self.pk + return self.account.username == self.username + + @cached_property + def main(self): + # On account creation main_systemuser_id is still None + if self.account.main_systemuser_id: + return self.account.main_systemuser + return type(self).objects.get(username=self.account.username) + + @property + def has_shell(self): + return self.shell not in settings.SYSTEMUSERS_DISABLED_SHELLS + + def disable(self): + self.is_active = False + self.save(update_fields=('is_active',)) + + def enable(self): + self.is_active = True + self.save(update_fields=('is_active',)) + + def get_description(self): + return self.get_shell_display() + + def save(self, *args, **kwargs): + if not self.home: + self.home = self.get_base_home() + super(SystemUser, self).save(*args, **kwargs) + + def clean(self): + self.directory = self.directory.lstrip('/') + if self.home: + self.home = os.path.normpath(self.home) + if self.directory: + self.directory = os.path.normpath(self.directory) + dir_errors = [] + if self.has_shell: + dir_errors.append(_("Directory with shell users can not be specified.")) + elif self.account_id and self.is_main: + dir_errors.append(_("Directory with main system users can not be specified.")) + elif self.home == self.get_base_home(): + dir_errors.append(_("Directory on the user's base home is not allowed.")) + for pattern in settings.SYSTEMUSERS_FORBIDDEN_PATHS: + if fnmatch.fnmatch(self.directory, pattern): + dir_errors.append(_("Provided directory is forbidden.")) + if dir_errors: + raise ValidationError({ + 'directory': [ValidationError(error) for error in dir_errors] + }) + if self.has_shell and self.home and self.home != self.get_base_home(): + raise ValidationError({ + 'home': _("Shell users should use their own home."), + }) + + def set_password(self, raw_password): + self.password = make_password(raw_password) + + def get_base_home(self): + context = { + 'user': self.username, + 'username': self.username, + } + return os.path.normpath(settings.SYSTEMUSERS_HOME % context) + + def get_home(self): + return os.path.normpath(os.path.join(self.home, self.directory)) + + + +# ------------------ + +class WebappUsers(models.Model): + """ + System users for webapp + Username max_length determined by LINUX system user/group lentgh: 32 + """ + username = models.CharField(_("username"), max_length=32, + help_text=_("Required. 32 characters or fewer. Letters, digits and ./-/_ only."), + validators=[validators.validate_username]) + password = models.CharField(_("password"), max_length=128) + account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), + related_name='accounts', on_delete=models.CASCADE) + home = models.CharField(_("WebappDir"), max_length=256, blank=True, + help_text=_("name dir webapp /home/<main>/webapps/<DirName>"), + validators=[validators.validate_string_dir]) + shell = models.CharField(_("shell"), max_length=32, choices=settings.WEBAPPUSERS_SHELLS, + default='/dev/null') + target_server = models.ForeignKey('orchestration.Server', on_delete=models.CASCADE, + verbose_name=_("Server")) + + class Meta: + unique_together = ('username', 'target_server') + verbose_name = 'WebAppUser' + verbose_name_plural = 'WebappUsers' + + def __str__(self): + return self.username + + def set_password(self, raw_password): + self.password = make_password(raw_password) + + def get_base_home(self): + return os.path.normpath(self.account.main_systemuser.home) + + def get_parent(self): + return self.account.main_systemuser \ No newline at end of file diff --git a/orchestra/contrib/systemusers/serializers.py b/orchestra/contrib/systemusers/serializers.py new file mode 100644 index 0000000..5083b68 --- /dev/null +++ b/orchestra/contrib/systemusers/serializers.py @@ -0,0 +1,43 @@ +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from orchestra.api.serializers import SetPasswordHyperlinkedSerializer, RelatedHyperlinkedModelSerializer +from orchestra.contrib.accounts.serializers import AccountSerializerMixin + +from .models import SystemUser +from .validators import validate_home + + +class RelatedGroupSerializer(AccountSerializerMixin, RelatedHyperlinkedModelSerializer): + class Meta: + model = SystemUser + fields = ('url', 'id', 'username',) + + +class SystemUserSerializer(AccountSerializerMixin, SetPasswordHyperlinkedSerializer): + groups = RelatedGroupSerializer(many=True, required=False) + + class Meta: + model = SystemUser + fields = ( + 'url', 'id', 'username', 'password', 'home', 'directory', 'shell', 'groups', 'is_active', + ) + postonly_fields = ('username', 'password') + + def validate_directory(self, directory): + return directory.lstrip('/') + + def validate(self, data): + data = super(SystemUserSerializer, self).validate(data) + user = SystemUser( + username=data.get('username') or self.instance.username, + shell=data.get('shell') or self.instance.shell, + ) + validate_home(user, data, self.get_account()) + groups = data.get('groups') + if groups: + for group in groups: + if group.username == data['username']: + raise serializers.ValidationError( + _("Do not make the user member of its group")) + return data diff --git a/orchestra/contrib/systemusers/settings.py b/orchestra/contrib/systemusers/settings.py new file mode 100644 index 0000000..044f58d --- /dev/null +++ b/orchestra/contrib/systemusers/settings.py @@ -0,0 +1,73 @@ +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.settings import Setting + + +_names = ('user', 'username') +_backend_names = _names + ('group', 'shell', 'mainuser', 'home', 'base_home') + + +WEBAPPUSERS_SHELLS = Setting('SYSTEMUSERS_SHELLS', + ( + ('/dev/null', _("No shell, SFTP only")), + ('/bin/bash', "/bin/bash"), + ), +) + +SYSTEMUSERS_SHELLS = Setting('SYSTEMUSERS_SHELLS', + ( + ('/dev/null', _("No shell, FTP only")), + ('/bin/rssh', _("No shell, SFTP/RSYNC only")), + ('/bin/bash', "/bin/bash"), + ), + validators=[Setting.validate_choices] +) + + +SYSTEMUSERS_DEFAULT_SHELL = Setting('SYSTEMUSERS_DEFAULT_SHELL', + '/dev/null', + choices=SYSTEMUSERS_SHELLS +) + + +SYSTEMUSERS_DISABLED_SHELLS = Setting('SYSTEMUSERS_DISABLED_SHELLS', + default=( + '/dev/null', + '/bin/rssh', + ), +) + + +SYSTEMUSERS_HOME = Setting('SYSTEMUSERS_HOME', + '/home/%(user)s', + help_text="Available fromat names: %s" % ', '.join(_names), + validators=[Setting.string_format_validator(_names)], +) + + +SYSTEMUSERS_FTP_LOG_PATH = Setting('SYSTEMUSERS_FTP_LOG_PATH', + '/var/log/vsftpd.log' +) + + +SYSTEMUSERS_MAIL_LOG_PATH = Setting('SYSTEMUSERS_MAIL_LOG_PATH', + '/var/log/exim4/mainlog' +) + +SYSTEMUSERS_DEFAULT_GROUP_MEMBERS = Setting('SYSTEMUSERS_DEFAULT_GROUP_MEMBERS', + ('www-data',) +) + + +SYSTEMUSERS_MOVE_ON_DELETE_PATH = Setting('SYSTEMUSERS_MOVE_ON_DELETE_PATH', + '', + help_text="Available fromat names: %s" % ', '.join(_backend_names), + validators=[Setting.string_format_validator(_backend_names)], +) + + +SYSTEMUSERS_FORBIDDEN_PATHS = Setting('SYSTEMUSERS_FORBIDDEN_PATHS', + (), + help_text=("Exlude ACL operations or home locations on provided globs, relative to user's home.
    " + "e.g. ('logs', 'logs/apache*', 'webapps')"), +) diff --git a/orchestra/contrib/systemusers/templates/admin/systemusers/systemuser/create_link.html b/orchestra/contrib/systemusers/templates/admin/systemusers/systemuser/create_link.html new file mode 100644 index 0000000..d55af69 --- /dev/null +++ b/orchestra/contrib/systemusers/templates/admin/systemusers/systemuser/create_link.html @@ -0,0 +1,73 @@ +{% extends "admin/base_site.html" %} +{% load i18n l10n %} +{% load admin_urls static utils %} + +{% block extrastyle %} +{{ block.super }} + + +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
    +
    + {% block introduction %} + Create simbolic link for {% for user in queryset %}{{ user.username }}{% if not forloop.last %}, {% endif %}{% endfor %}. + {% endblock %} +
      {{ display_objects | unordered_list }}
    +
    {% csrf_token %} +
    + {{ form.non_field_errors }} + {% block prefields %} + {% endblock %} +
    +
    + {{ form.base_home.errors }} + + {{ form.base_home }}{% for x in ""|ljust:"50" %} {% endfor %} +

    {{ form.base_home.help_text|safe }}

    +
    +
    + {{ form.home_extension.errors }} + + {{ form.home_extension }} +

    {{ form.home_extension.help_text|safe }}

    +
    +
    + {% block postfields %} +
    + {{ form.link_name.errors }} + + {{ form.link_name }} +

    {{ form.link_name.help_text|safe }}

    +
    + {% endblock %} +
    +
    + {% for obj in queryset %} + + {% endfor %} + + + +
    +
    +{% endblock %} + diff --git a/orchestra/contrib/systemusers/templates/admin/systemusers/systemuser/set_permission.html b/orchestra/contrib/systemusers/templates/admin/systemusers/systemuser/set_permission.html new file mode 100644 index 0000000..6d05125 --- /dev/null +++ b/orchestra/contrib/systemusers/templates/admin/systemusers/systemuser/set_permission.html @@ -0,0 +1,26 @@ +{% extends "admin/systemusers/systemuser/create_link.html" %} +{% load i18n l10n %} +{% load admin_urls static utils %} + +{% block introduction %} +Set permissions for {% for user in queryset %}{{ user.username }}{% if not forloop.last %}, {% endif %}{% endfor %} system user(s). +{% endblock %} + + +{% block prefields %} +
    + {{ form.set_action.errors }} + + {{ form.set_action }}{% for x in ""|ljust:"50" %} {% endfor %} +

    {{ form.set_action.help_text|safe }}

    +
    +{% endblock %} + +{% block postfields %} +
    + {{ form.permissions.errors }} + + {{ form.permissions }}{% for x in ""|ljust:"50" %} {% endfor %} +

    {{ form.permissions.help_text|safe }}

    +
    +{% endblock %} diff --git a/orchestra/contrib/systemusers/tests/__init__.py b/orchestra/contrib/systemusers/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestra/contrib/systemusers/tests/functional_tests/__init__.py b/orchestra/contrib/systemusers/tests/functional_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestra/contrib/systemusers/tests/functional_tests/tests.py b/orchestra/contrib/systemusers/tests/functional_tests/tests.py new file mode 100644 index 0000000..9d3cf2e --- /dev/null +++ b/orchestra/contrib/systemusers/tests/functional_tests/tests.py @@ -0,0 +1,378 @@ +import ftplib +import os +import re +import time +import unittest +from functools import partial + +import paramiko +from django.conf import settings as djsettings +from django.core.management.base import CommandError +from django.urls import reverse +from selenium.webdriver.support.select import Select + +from orchestra.admin.utils import change_url +from orchestra.contrib.accounts.models import Account +from orchestra.contrib.orchestration.models import Server, Route +from orchestra.utils.sys import run, sshrun +from orchestra.utils.tests import (BaseLiveServerTestCase, random_ascii, snapshot_on_error, + save_response_on_error) + +from ... import backends +from ...models import SystemUser + + +TEST_REST_API = int(os.getenv('TEST_REST_API', '0')) +r = partial(run, silent=True, display=False) +sshr = partial(sshrun, silent=True, display=False) + + +class SystemUserMixin(object): + MASTER_SERVER = os.environ.get('ORCHESTRA_MASTER_SERVER', 'localhost') + DEPENDENCIES = ( + 'orchestra.contrib.orchestration', + 'orcgestra.apps.systemusers', + ) + + def setUp(self): + super(SystemUserMixin, self).setUp() + self.add_route() + djsettings.DEBUG = True + + def add_route(self): + master = Server.objects.create(name=self.MASTER_SERVER) + backend = backends.UNIXUserController.get_name() + Route.objects.create(backend=backend, match=True, host=master) + + def save(self): + raise NotImplementedError + + def add(self): + raise NotImplementedError + + def delete(self): + raise NotImplementedError + + def update(self): + raise NotImplementedError + + def disable(self): + raise NotImplementedError + + def add_group(self, username, groupname): + raise NotImplementedError + + def validate_user(self, username): + idcmd = sshr(self.MASTER_SERVER, "id %s" % username) + self.assertEqual(0, idcmd.exit_code) + user = SystemUser.objects.get(username=username) + groups = list(user.groups.values_list('username', flat=True)) + groups.append(user.username) + idgroups = idcmd.stdout.strip().split(' ')[2] + idgroups = re.findall(r'\d+\((\w+)\)', idgroups) + self.assertEqual(set(groups), set(idgroups)) + + def validate_delete(self, username): + self.assertRaises(SystemUser.DoesNotExist, SystemUser.objects.get, username=username) + self.assertRaises(CommandError, + sshrun, self.MASTER_SERVER, 'id %s' % username, display=False) + self.assertRaises(CommandError, + sshrun, self.MASTER_SERVER, 'grep "^%s:" /etc/groups' % username, display=False) + self.assertRaises(CommandError, + sshrun, self.MASTER_SERVER, 'grep "^%s:" /etc/passwd' % username, display=False) + self.assertRaises(CommandError, + sshrun, self.MASTER_SERVER, 'grep "^%s:" /etc/shadow' % username, display=False) + # Home will be deleted on account delete, see test_delete_account + + def validate_ftp(self, username, password): + ftp = ftplib.FTP(self.MASTER_SERVER) + ftp.login(user=username, passwd=password) + ftp.close() + + def validate_sftp(self, username, password): + transport = paramiko.Transport((self.MASTER_SERVER, 22)) + transport.connect(username=username, password=password) + sftp = paramiko.SFTPClient.from_transport(transport) + sftp.listdir() + sftp.close() + + def validate_ssh(self, username, password): + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(self.MASTER_SERVER, username=username, password=password) + transport = ssh.get_transport() + channel = transport.open_session() + channel.exec_command('ls') + self.assertEqual(0, channel.recv_exit_status()) + channel.close() + + def test_add(self): + username = '%s_systemuser' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + self.add(username, password) + self.addCleanup(self.delete, username) + self.validate_user(username) + + def test_ftp(self): + username = '%s_systemuser' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + self.add(username, password, shell='/dev/null') + self.addCleanup(self.delete, username) + self.assertRaises(paramiko.AuthenticationException, + self.validate_sftp, username, password) + self.assertRaises(paramiko.AuthenticationException, + self.validate_ssh, username, password) + + def test_sftp(self): + username = '%s_systemuser' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + self.add(username, password, shell='/bin/rssh') + self.addCleanup(self.delete, username) + self.validate_sftp(username, password) + self.assertRaises(AssertionError, self.validate_ssh, username, password) + + def test_ssh(self): + username = '%s_systemuser' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + self.add(username, password, shell='/bin/bash') + self.addCleanup(self.delete, username) + self.validate_ssh(username, password) + + def test_delete(self): + username = '%s_systemuser' % random_ascii(10) + password = '@!?%sppppP001' % random_ascii(5) + self.add(username, password) + self.validate_user(username) + self.delete(username) + self.validate_delete(username) + self.assertRaises(Exception, self.delete, self.account.username) + + def test_add_group(self): + username = '%s_systemuser' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + self.add(username, password) + self.addCleanup(self.delete, username) + self.validate_user(username) + username2 = '%s_systemuser' % random_ascii(10) + password2 = '@!?%spppP001' % random_ascii(5) + self.add(username2, password2) + self.addCleanup(self.delete, username2) + self.validate_user(username2) + self.add_group(username, username2) + user = SystemUser.objects.get(username=username) + groups = list(user.groups.values_list('username', flat=True)) + self.assertIn(username2, groups) + self.validate_user(username) + + def test_disable(self): + username = '%s_systemuser' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + self.add(username, password, shell='/dev/null') + self.addCleanup(self.delete, username) + self.validate_ftp(username, password) + self.disable(username) + self.validate_user(username) + self.assertRaises(ftplib.error_perm, self.validate_ftp, username, password) + + def test_change_password(self): + username = '%s_systemuser' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + self.add(username, password) + self.addCleanup(self.delete, username) + self.validate_ftp(username, password) + new_password = '@!?%spppP001' % random_ascii(5) + self.change_password(username, new_password) + self.validate_ftp(username, new_password) + +# TODO test resources + + +@unittest.skipUnless(TEST_REST_API, "REST API tests") +class RESTSystemUserMixin(SystemUserMixin): + def setUp(self): + super(RESTSystemUserMixin, self).setUp() + self.rest_login() + # create main user + self.save(self.account.username) + self.addCleanup(self.delete_account, self.account.username) + + @save_response_on_error + def add(self, username, password, shell='/dev/null'): + self.rest.systemusers.create(username=username, password=password, shell=shell) + + @save_response_on_error + def delete(self, username): + user = self.rest.systemusers.retrieve(username=username).get() + user.delete() + + @save_response_on_error + def add_group(self, username, groupname): + user = self.rest.systemusers.retrieve(username=username).get() + user.groups.append({'username': groupname}) + user.save() + + @save_response_on_error + def disable(self, username): + user = self.rest.systemusers.retrieve(username=username).get() + user.is_active = False + user.save() + + @save_response_on_error + def save(self, username): + user = self.rest.systemusers.retrieve(username=username).get() + user.save() + + @save_response_on_error + def change_password(self, username, password): + user = self.rest.systemusers.retrieve(username=username).get() + user.set_password(password) + + def delete_account(self, username): + self.rest.account.delete() + + +class AdminSystemUserMixin(SystemUserMixin): + def setUp(self): + super(AdminSystemUserMixin, self).setUp() + self.admin_login() + # create main user + self.save(self.account.username) + self.addCleanup(self.delete_account, self.account.username) + + @snapshot_on_error + def add(self, username, password, shell='/dev/null'): + url = self.live_server_url + reverse('admin:systemusers_systemuser_add') + self.selenium.get(url) + + username_field = self.selenium.find_element_by_id('id_username') + username_field.send_keys(username) + + password_field = self.selenium.find_element_by_id('id_password1') + password_field.send_keys(password) + password_field = self.selenium.find_element_by_id('id_password2') + password_field.send_keys(password) + + shell_input = self.selenium.find_element_by_id('id_shell') + shell_select = Select(shell_input) + shell_select.select_by_value(shell) + + username_field.submit() + self.assertNotEqual(url, self.selenium.current_url) + + @snapshot_on_error + def delete(self, username): + user = SystemUser.objects.get(username=username) + self.admin_delete(user) + + @snapshot_on_error + def delete_account(self, username): + account = Account.objects.get(username=username) + self.admin_delete(account) + + @snapshot_on_error + def disable(self, username): + user = SystemUser.objects.get(username=username) + self.admin_disable(user) + + @snapshot_on_error + def add_group(self, username, groupname): + user = SystemUser.objects.get(username=username) + url = self.live_server_url + change_url(user) + self.selenium.get(url) + groups = self.selenium.find_element_by_id('id_groups_add_all_link') + groups.click() + time.sleep(0.5) + save = self.selenium.find_element_by_name('_save') + save.submit() + self.assertNotEqual(url, self.selenium.current_url) + + @snapshot_on_error + def save(self, username): + user = SystemUser.objects.get(username=username) + url = self.live_server_url + change_url(user) + self.selenium.get(url) + save = self.selenium.find_element_by_name('_save') + save.submit() + self.assertNotEqual(url, self.selenium.current_url) + + @snapshot_on_error + def change_password(self, username, password): + user = SystemUser.objects.get(username=username) + self.admin_change_password(user, password) + + +class RESTSystemUserTest(RESTSystemUserMixin, BaseLiveServerTestCase): + pass + + +class AdminSystemUserTest(AdminSystemUserMixin, BaseLiveServerTestCase): + @snapshot_on_error + def test_create_account(self): + url = self.live_server_url + reverse('admin:accounts_account_add') + self.selenium.get(url) + + account_username = '%s_account' % random_ascii(10) + username = self.selenium.find_element_by_id('id_username') + username.send_keys(account_username) + + account_password = '@!?%spppP001' % random_ascii(5) + password = self.selenium.find_element_by_id('id_password1') + password.send_keys(account_password) + password = self.selenium.find_element_by_id('id_password2') + password.send_keys(account_password) + + full_name = random_ascii(10) + full_name_field = self.selenium.find_element_by_id('id_full_name') + full_name_field.send_keys(full_name) + + account_email = 'orchestra@orchestra.lan' + email = self.selenium.find_element_by_id('id_email') + email.send_keys(account_email) + + contact_short_name = random_ascii(10) + short_name = self.selenium.find_element_by_id('id_contacts-0-short_name') + short_name.send_keys(contact_short_name) + + email = self.selenium.find_element_by_id('id_contacts-0-email') + email.send_keys(account_email) + email.submit() + self.assertNotEqual(url, self.selenium.current_url) + + self.addCleanup(self.delete_account, account_username) + self.assertEqual(0, sshr(self.MASTER_SERVER, "id %s" % account_username).exit_code) + + @snapshot_on_error + def test_delete_account(self): + home = self.account.main_systemuser.get_home() + self.admin_delete(self.account) + self.assertRaises(CommandError, run, 'ls %s' % home, display=False) + # Recreate a fucking fake account for test cleanup + self.account = self.create_account(username=self.account.username, superuser=True) + self.selenium.delete_all_cookies() + self.admin_login() + + @snapshot_on_error + def test_disable_account(self): + username = '%s_systemuser' % random_ascii(10) + password = '@!?%spppP001' % random_ascii(5) + self.add(username, password) + self.addCleanup(self.delete, username) + self.validate_ftp(username, password) + self.disable(username) + self.validate_user(username) + + disable = reverse('admin:accounts_account_disable', args=(self.account.pk,)) + url = self.live_server_url + disable + self.selenium.get(url) + confirmation = self.selenium.find_element_by_name('post') + confirmation.submit() + self.assertNotEqual(url, self.selenium.current_url) + + self.assertRaises(ftplib.error_perm, self.validate_ftp, username, password) + self.selenium.get(url) + self.assertNotEqual(url, self.selenium.current_url) + + # Reenable for test cleanup + self.account.is_active = True + self.account.save() + self.admin_login() diff --git a/orchestra/contrib/systemusers/validators.py b/orchestra/contrib/systemusers/validators.py new file mode 100644 index 0000000..cafea1c --- /dev/null +++ b/orchestra/contrib/systemusers/validators.py @@ -0,0 +1,48 @@ +import os + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.orchestration import Operation + + +def validate_paths_exist(user, paths): + operations = [] + user.paths_to_validate = paths + operations.extend(Operation.create_for_action(user, 'validate_paths_exist')) + logs = Operation.execute(operations) + stderr = '\n'.join([log.stderr for log in logs]) + if 'path does not exists' in stderr: + raise ValidationError(stderr) + + +def validate_home(user, data, account): + """ validates home based on account and data['shell'] """ + if not 'username' in data and not user.pk: + # other validation will have been raised for required username + return + user = type(user)( + username=data.get('username') or user.username, + shell=data.get('shell') or user.shell, + ) + if 'home' in data and data['home']: + home = os.path.normpath(data['home']) + user_home = user.get_base_home() + account_home = account.main_systemuser.get_home() + if user.has_shell: + if home != user_home: + raise ValidationError({ + 'home': _("Not a valid home directory.") + }) + elif home not in (user_home, account_home): + raise ValidationError({ + 'home': _("Not a valid home directory.") + }) + if 'directory' in data and data['directory']: + path = os.path.join(data['home'], data['directory']) + try: + validate_paths_exist(user, (path,)) + except ValidationError as err: + raise ValidationError({ + 'directory': err, + }) diff --git a/orchestra/contrib/tasks/README.md b/orchestra/contrib/tasks/README.md new file mode 100644 index 0000000..f64f678 --- /dev/null +++ b/orchestra/contrib/tasks/README.md @@ -0,0 +1,6 @@ +This is a wrapper around djcelery and celery `@task` and `@periodic_task` decorators. It provides transparent support for switching between executing a task on a plain Python thread or +the traditional way of pushing the task on a queue (rabbitmq) and wait for a Celery worker to run it. + +A queueless threaded execution has the advantage of 0 moving parts instead of the alternative rabbitmq and celery workers. Less dependencies, less memory footprint, less points of failure, no process keeping, no independent code reloading for the workers. + +If your application needs to run thousands or milions of tasks a day, use celery as your backend, if tens or hundreds, then probably the default thread backend will be your best choice. diff --git a/orchestra/contrib/tasks/__init__.py b/orchestra/contrib/tasks/__init__.py new file mode 100644 index 0000000..61023b6 --- /dev/null +++ b/orchestra/contrib/tasks/__init__.py @@ -0,0 +1,5 @@ +from . import settings +from .decorators import task, periodic_task, keep_state, apply_async + + +default_app_config = 'orchestra.contrib.tasks.apps.TasksConfig' diff --git a/orchestra/contrib/tasks/admin.py b/orchestra/contrib/tasks/admin.py new file mode 100644 index 0000000..d245a5f --- /dev/null +++ b/orchestra/contrib/tasks/admin.py @@ -0,0 +1,10 @@ +from django.utils.translation import gettext_lazy as _ +from djcelery.admin import PeriodicTaskAdmin + +from orchestra.admin.utils import admin_date + + +display_last_run_at = admin_date('last_run_at', short_description=_("Last run")) + + +PeriodicTaskAdmin.list_display = ('__unicode__', display_last_run_at, 'total_run_count', 'enabled') diff --git a/orchestra/contrib/tasks/apps.py b/orchestra/contrib/tasks/apps.py new file mode 100644 index 0000000..ceb4e24 --- /dev/null +++ b/orchestra/contrib/tasks/apps.py @@ -0,0 +1,16 @@ +from django.apps import AppConfig +from django.utils.module_loading import autodiscover_modules + +from orchestra.core import administration + + +class TasksConfig(AppConfig): + name = 'orchestra.contrib.tasks' + verbose_name = "Tasks" + + def ready(self): + from djcelery.models import PeriodicTask, TaskState, WorkerState + administration.register(TaskState, icon='Edit-check-sheet.png') + administration.register(PeriodicTask, parent=TaskState, icon='Appointment.png') + administration.register(WorkerState, parent=TaskState, dashboard=False) + autodiscover_modules('tasks') diff --git a/orchestra/contrib/tasks/beat.py b/orchestra/contrib/tasks/beat.py new file mode 100644 index 0000000..7a4772a --- /dev/null +++ b/orchestra/contrib/tasks/beat.py @@ -0,0 +1,43 @@ +import json + +from celery import current_app +from celery.schedules import crontab_parser as CrontabParser +from django.utils import timezone +from djcelery.models import PeriodicTask + +from .decorators import apply_async + + +def is_due(task, time=None): + if time is None: + time = timezone.now() + crontab = task.crontab + parts = map(int, time.strftime("%M %H %w %d %m").split()) + n_minute, n_hour, n_day_of_week, n_day_of_month, n_month_of_year = parts + return bool( + n_minute in CrontabParser(60).parse(crontab.minute) and + n_hour in CrontabParser(24).parse(crontab.hour) and + n_day_of_week in CrontabParser(7).parse(crontab.day_of_week) and + n_day_of_month in CrontabParser(31, 1).parse(crontab.day_of_month) and + n_month_of_year in CrontabParser(12, 1).parse(crontab.month_of_year) + ) + + +def run_task(task, thread=True, process=False, run_async=False): + args = json.loads(task.args) + kwargs = json.loads(task.kwargs) + task_fn = current_app.tasks.get(task.task) + if run_async: + method = 'process' if process else 'thread' + return apply_async(task_fn, method=method).apply_async(*args, **kwargs) + return task_fn(*args, **kwargs) + + +def run(): + now = timezone.now() + procs = [] + for task in PeriodicTask.objects.enabled().select_related('crontab'): + if is_due(task, now): + proc = run_task(task, process=True, run_async=True) + procs.append(proc) + [proc.join() for proc in procs] diff --git a/orchestra/contrib/tasks/decorators.py b/orchestra/contrib/tasks/decorators.py new file mode 100644 index 0000000..72fd2af --- /dev/null +++ b/orchestra/contrib/tasks/decorators.py @@ -0,0 +1,117 @@ +import logging +import traceback +from functools import partial, wraps, update_wrapper +from multiprocessing import Process +from threading import Thread + +from celery import shared_task as celery_shared_task +from celery import states +from celery.decorators import periodic_task as celery_periodic_task +from django.core.mail import mail_admins +from django.utils import timezone + +from orchestra.utils.db import close_connection +from orchestra.utils.python import AttrDict + +from .utils import get_name, get_id + + +logger = logging.getLogger(__name__) + + +def keep_state(fn): + """ logs task on djcelery's TaskState model """ + @wraps(fn) + def wrapper(*args, _task_id=None, _name=None, **kwargs): + from djcelery.models import TaskState + now = timezone.now() + if _task_id is None: + _task_id = get_id() + if _name is None: + _name = get_name(fn) + state = TaskState.objects.create( + state=states.STARTED, task_id=_task_id, name=_name, + args=str(args), kwargs=str(kwargs), tstamp=now) + try: + result = fn(*args, **kwargs) + except: + trace = traceback.format_exc() + subject = 'EXCEPTION executing task %s(args=%s, kwargs=%s)' % (_name, args, kwargs) + logger.error(subject) + logger.error(trace) + state.state = states.FAILURE + state.traceback = trace + state.runtime = (timezone.now()-now).total_seconds() + state.save() + mail_admins(subject, trace) + raise + else: + state.state = states.SUCCESS + state.result = str(result) + state.runtime = (timezone.now()-now).total_seconds() + state.save() + return result + return wrapper + + +def apply_async(fn, name=None, method='thread'): + """ replaces celery apply_async """ + def inner(fn, name, method, *args, **kwargs): + task_id = get_id() + kwargs.update({ + '_name': name, + '_task_id': task_id, + }) + thread = method(target=fn, args=args, kwargs=kwargs) + thread.start() + # Celery API compat + thread.request = AttrDict(id=task_id) + return thread + + if name is None: + name = get_name(fn) + if method == 'thread': + method = Thread + elif method == 'process': + method = Process + else: + raise NotImplementedError("%s concurrency method is not supported." % method) + fn.apply_async = partial(inner, close_connection(keep_state(fn)), name, method) + fn.delay = fn.apply_async + return fn + + +def task(fn=None, **kwargs): + # TODO override this if 'celerybeat' in sys.argv ? + from . import settings + # register task + if fn is None: + name = kwargs.get('name', None) + if settings.TASKS_BACKEND in ('thread', 'process'): + def decorator(fn): + return apply_async(celery_shared_task(**kwargs)(fn), name=name) + return decorator + else: + return celery_shared_task(**kwargs) + fn = celery_shared_task(fn) + if settings.TASKS_BACKEND in ('thread', 'process'): + fn = apply_async(fn) + return fn + + +def periodic_task(fn=None, **kwargs): + from . import settings + # register task + if fn is None: + name = kwargs.get('name', None) + if settings.TASKS_BACKEND in ('thread', 'process'): + def decorator(fn): + return apply_async(celery_periodic_task(**kwargs)(fn), name=name) + return decorator + else: + return celery_periodic_task(**kwargs) + fn = celery_periodic_task(fn) + if settings.TASKS_BACKEND in ('thread', 'process'): + name = kwargs.pop('name', None) + fn = update_wrapper(apply_async(fn, name), fn) + return fn diff --git a/orchestra/contrib/tasks/management/commands/beat.py b/orchestra/contrib/tasks/management/commands/beat.py new file mode 100644 index 0000000..ba73bc2 --- /dev/null +++ b/orchestra/contrib/tasks/management/commands/beat.py @@ -0,0 +1,10 @@ +from django.core.management.base import BaseCommand + +from ... import beat + + +class Command(BaseCommand): + help = 'Runs periodic tasks.' + + def handle(self, *args, **options): + beat.run() diff --git a/orchestra/contrib/tasks/management/commands/runfunction.py b/orchestra/contrib/tasks/management/commands/runfunction.py new file mode 100644 index 0000000..a1b508e --- /dev/null +++ b/orchestra/contrib/tasks/management/commands/runfunction.py @@ -0,0 +1,32 @@ +from django.core.management.base import BaseCommand + +from orchestra.utils.python import import_class + +from ... import keep_state, get_id, get_name + + +class Command(BaseCommand): + help = 'Runs Orchestra method.' + + def add_arguments(self, parser): + parser.add_argument('method', + help='Python path to the method to execute.') + parser.add_argument('args', nargs='*', + help='Additional arguments passed to the method.') + + def handle(self, *args, **options): + method = import_class(options['method']) + kwargs = {} + arguments = [] + for arg in args: + if '=' in args: + name, value = arg.split('=') + if value.isdigit(): + value = int(value) + kwargs[name] = value + else: + if arg.isdigit(): + arg = int(arg) + arguments.append(arg) + args = arguments + keep_state(method)(get_id(), get_name(method), *args, **kwargs) diff --git a/orchestra/contrib/tasks/management/commands/runtask.py b/orchestra/contrib/tasks/management/commands/runtask.py new file mode 100644 index 0000000..93aac36 --- /dev/null +++ b/orchestra/contrib/tasks/management/commands/runtask.py @@ -0,0 +1,48 @@ +import json + +from celery import current_app +from django.core.management.base import BaseCommand +from django.utils import timezone +from djcelery.models import PeriodicTask + +from ...decorators import keep_state + + +class Command(BaseCommand): + help = 'Runs Orchestra method.' + + def add_arguments(self, parser): + parser.add_argument('task', + help='Periodic task ID or task name.') + parser.add_argument('args', nargs='*', + help='Additional arguments passed to the task, when task name is used.') + + def handle(self, *args, **options): + task = options.get('task') + if task.isdigit(): + # periodic task + ptask = PeriodicTask.objects.get(pk=int(task)) + task = current_app.tasks[ptask.task] + args = json.loads(ptask.args) + kwargs = json.loads(ptask.kwargs) + ptask.last_run_at = timezone.now() + ptask.total_run_count += 1 + ptask.save() + else: + # task name + task = current_app.tasks[task] + kwargs = {} + arguments = [] + for arg in args: + if '=' in args: + name, value = arg.split('=') + if value.isdigit(): + value = int(value) + kwargs[name] = value + else: + if arg.isdigit(): + arg = int(arg) + arguments.append(arg) + args = arguments + # Run task synchronously, but logging TaskState + keep_state(task)(*args, **kwargs) diff --git a/orchestra/contrib/tasks/management/commands/syncperiodictasks.py b/orchestra/contrib/tasks/management/commands/syncperiodictasks.py new file mode 100644 index 0000000..7e9dfc8 --- /dev/null +++ b/orchestra/contrib/tasks/management/commands/syncperiodictasks.py @@ -0,0 +1,15 @@ +from django.core.management.base import BaseCommand +from djcelery.app import app +from djcelery.schedulers import DatabaseScheduler + + +class Command(BaseCommand): + help = 'Runs Orchestra method.' + + def handle(self, *args, **options): + dbschedule = DatabaseScheduler(app=app) + self.stdout.write('\033[1m%i periodic tasks have been syncronized:\033[0m' % len(dbschedule.schedule)) + size = max([len(name) for name in dbschedule.schedule])+1 + for name, task in dbschedule.schedule.items(): + spaces = ' '*(size-len(name)) + self.stdout.write(' %s%s%s' % (name, spaces, task.schedule)) diff --git a/orchestra/contrib/tasks/parser.py b/orchestra/contrib/tasks/parser.py new file mode 100644 index 0000000..23cc2fa --- /dev/null +++ b/orchestra/contrib/tasks/parser.py @@ -0,0 +1,61 @@ +import os + + +# Rename module to handler.py +class CronHandler(object): + def __init__(self, filename): + self.content = None + self.filename = filename + + def read(self): + comments = [] + self.content = [] + with open(self.filename, 'r') as handler: + for line in handler.readlines(): + line = line.strip() + if line.startswith('#'): + comments.append(line) + else: + schedule = line.split()[:5] + command = ' '.join(line.split()[5:]).strip() + self.content.append((schedule, command, comments)) + comments = [] + + def save(self, backup=True): + if self.content is None: + raise Exception("First read() the cron file!") + if backup: + os.rename(self.filename, self.filename + '.backup') + with open(self.filename, 'w') as handler: + handler.write('\n'.join(self.content)) + handler.truncate() + self.reload() + + def reload(self): + pass + # TODO + + def remove(self, command): + if self.content is None: + raise Exception("First read() the cron file!") + new_content = [] + for c_schedule, c_command, c_comments in self.content: + if command != c_command: + new_content.append((c_schedule, c_command, c_comments)) + self.content = new_content + + def add_or_update(self, schedule, command, comments=None): + """ if content contains an equal command, its schedule is updated """ + if self.content is None: + raise Exception("First read() the cron file!") + new_content = [] + replaced = False + for c_schedule, c_command, c_comments in self.content: + if command == c_command: + replaced = True + new_content.append((schedule, command, comments or c_comments)) + else: + new_content.append((c_schedule, c_command, c_comments)) + if not replaced: + new_content.append((schedule, command, comments or [])) + self.content = new_content diff --git a/orchestra/contrib/tasks/schedules.py b/orchestra/contrib/tasks/schedules.py new file mode 100644 index 0000000..c5af3bf --- /dev/null +++ b/orchestra/contrib/tasks/schedules.py @@ -0,0 +1,118 @@ +#import re + + +#class CronTab(object): +# pass + + +#class ParseException(Exception): +# """Raised by crontab_parser when the input can't be parsed.""" + + +## https://github.com/celery/celery/blob/master/celery/schedules.py +#class CrontabParser(object): +# """Parser for crontab expressions. Any expression of the form 'groups' +# (see BNF grammar below) is accepted and expanded to a set of numbers. +# These numbers represent the units of time that the crontab needs to +# run on:: +# digit :: '0'..'9' +# dow :: 'a'..'z' +# number :: digit+ | dow+ +# steps :: number +# range :: number ( '-' number ) ? +# numspec :: '*' | range +# expr :: numspec ( '/' steps ) ? +# groups :: expr ( ',' expr ) * +# The parser is a general purpose one, useful for parsing hours, minutes and +# day_of_week expressions. Example usage:: +# >>> minutes = crontab_parser(60).parse('*/15') +# [0, 15, 30, 45] +# >>> hours = crontab_parser(24).parse('*/4') +# [0, 4, 8, 12, 16, 20] +# >>> day_of_week = crontab_parser(7).parse('*') +# [0, 1, 2, 3, 4, 5, 6] +# It can also parse day_of_month and month_of_year expressions if initialized +# with an minimum of 1. Example usage:: +# >>> days_of_month = crontab_parser(31, 1).parse('*/3') +# [1, 4, 7, 10, 13, 16, 19, 22, 25, 28, 31] +# >>> months_of_year = crontab_parser(12, 1).parse('*/2') +# [1, 3, 5, 7, 9, 11] +# >>> months_of_year = crontab_parser(12, 1).parse('2-12/2') +# [2, 4, 6, 8, 10, 12] +# The maximum possible expanded value returned is found by the formula:: +# max_ + min_ - 1 +# """ +# ParseException = ParseException + +# _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 diff --git a/orchestra/contrib/tasks/settings.py b/orchestra/contrib/tasks/settings.py new file mode 100644 index 0000000..03adc5f --- /dev/null +++ b/orchestra/contrib/tasks/settings.py @@ -0,0 +1,23 @@ +from orchestra.contrib.settings import Setting + + +TASKS_BACKEND = Setting('TASKS_BACKEND', + 'thread', + choices=( + ('thread', "threading.Thread (no queue)"), + ('process', "multiprocess.Process (no queue)"), + ('celery', "Celery (with queue)"), + ) +) + + +TASKS_ENABLE_UWSGI_CRON_BEAT = Setting('TASKS_ENABLE_UWSGI_CRON_BEAT', + False, + help_text="Not implemented.", +) + + + +TASKS_BACKEND_CLEANUP_DAYS = Setting('TASKS_BACKEND_CLEANUP_DAYS', + 10, +) diff --git a/orchestra/contrib/tasks/tasks.py b/orchestra/contrib/tasks/tasks.py new file mode 100644 index 0000000..2b37758 --- /dev/null +++ b/orchestra/contrib/tasks/tasks.py @@ -0,0 +1,14 @@ +from datetime import timedelta + +from celery.task.schedules import crontab +from django.utils import timezone +from djcelery.models import TaskState + +from . import periodic_task, settings + + +@periodic_task(run_every=crontab(hour=6, minute=0)) +def backend_logs_cleanup(): + days = settings.TASKS_BACKEND_CLEANUP_DAYS + epoch = timezone.now()-timedelta(days=days) + return TaskState.objects.filter(tstamp__lt=epoch).only('id').delete() diff --git a/orchestra/contrib/tasks/utils.py b/orchestra/contrib/tasks/utils.py new file mode 100644 index 0000000..96b5bf0 --- /dev/null +++ b/orchestra/contrib/tasks/utils.py @@ -0,0 +1,19 @@ +import threading +from uuid import uuid4 + +from orchestra.utils.db import close_connection + + +def get_id(): + return str(uuid4()) + + +def get_name(fn): + return '.'.join((fn.__module__, fn.__name__)) + + +def run(method, *args, **kwargs): + run_async = kwargs.pop('run_async', True) + thread = threading.Thread(target=close_connection(method), args=args, kwargs=kwargs) + thread = Process(target=close_connection(counter)) + thread.start() diff --git a/orchestra/contrib/vps/__init__.py b/orchestra/contrib/vps/__init__.py new file mode 100644 index 0000000..96cf972 --- /dev/null +++ b/orchestra/contrib/vps/__init__.py @@ -0,0 +1 @@ +default_app_config = 'orchestra.contrib.vps.apps.VPSConfig' diff --git a/orchestra/contrib/vps/admin.py b/orchestra/contrib/vps/admin.py new file mode 100644 index 0000000..346ddaf --- /dev/null +++ b/orchestra/contrib/vps/admin.py @@ -0,0 +1,47 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + +from orchestra.admin import ExtendedModelAdmin, ChangePasswordAdminMixin +from orchestra.contrib.accounts.actions import list_accounts +from orchestra.contrib.accounts.admin import AccountAdminMixin +from orchestra.contrib.accounts.filters import IsActiveListFilter +from orchestra.forms import UserCreationForm, NonStoredUserChangeForm + +from .models import VPS + + +class VPSAdmin(ChangePasswordAdminMixin, AccountAdminMixin, ExtendedModelAdmin): + list_display = ('hostname', 'type', 'template', 'display_active', 'account_link') + list_filter = ('type', IsActiveListFilter, 'template') + form = NonStoredUserChangeForm + add_form = UserCreationForm + readonly_fields = ('account_link',) + search_fields = ('hostname', 'account__username', 'template') + change_readonly_fields = ('account', 'hostname', 'type', 'template') + fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('account_link', 'hostname', 'type', 'template', 'is_active') + }), + (_("Login"), { + 'classes': ('wide',), + 'fields': ('password',) + }) + ) + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('account', 'hostname', 'type', 'template') + }), + (_("Login"), { + 'classes': ('wide',), + 'fields': ('password1', 'password2',) + }), + ) + actions = (list_accounts,) + + def get_change_password_username(self, obj): + return 'root@%s' % obj.hostname + + +admin.site.register(VPS, VPSAdmin) diff --git a/orchestra/contrib/vps/apps.py b/orchestra/contrib/vps/apps.py new file mode 100644 index 0000000..919bb54 --- /dev/null +++ b/orchestra/contrib/vps/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + +from orchestra.core import services + + +class VPSConfig(AppConfig): + name = 'orchestra.contrib.vps' + verbose_name = 'VPS' + + def ready(self): + from .models import VPS + services.register(VPS, icon='TuxBox.png') diff --git a/orchestra/contrib/vps/backends.py b/orchestra/contrib/vps/backends.py new file mode 100644 index 0000000..8b817b4 --- /dev/null +++ b/orchestra/contrib/vps/backends.py @@ -0,0 +1,154 @@ +import decimal +import textwrap + +from orchestra.contrib.orchestration import ServiceController +from orchestra.contrib.resources import ServiceMonitor + +from . import settings + + +class ProxmoxOVZ(ServiceController): + model = 'vps.VPS' + + RESOURCES = ( + ('memory', 'mem'), + ('swap', 'swap'), + ('disk', 'disk') + ) + GET_PROXMOX_INFO = textwrap.dedent(""" + function get_vz_info () { + hostname=$1 + version=$(pveversion | cut -d '/' -f2 | cut -d'.' -f1) + if [[ $version -lt 2 ]]; then + conf=$(grep "CID\\|:$hostname:" /var/lib/pve-manager/vzlist | grep -B1 ":$hostname:") + CID=$(echo "$conf" | head -n1 | cut -d':' -f2) + CTID=$(echo "$conf" | tail -n1 | cut -d':' -f1) + node=$(pveca -l | grep "^\\s*$CID\\s*:" | awk {'print $3'}) + else + conf=$(grep -r "HOSTNAME=\\"$hostname\\"" /etc/pve/nodes/*/openvz/*.conf) + node=$(echo "${conf}" | cut -d"/" -f5) + CTID=$(echo "${conf}" | cut -d"/" -f7 | cut -d"\\." -f1) + fi + echo $CTID $node + }""") + + def prepare(self): + super(ProxmoxOVZ, self).prepare() + self.append(self.GET_PROXMOX_INFO) + + def get_vzset_args(self, context): + args = list(settings.VPS_DEFAULT_VZSET_ARGS) + for resource, arg_name in self.RESOURCES: + try: + allocation = context[resource] + except KeyError: + pass + else: + args.append('--%s %i' % (arg_name, allocation)) + return ' '.join(args) + + def run_ssh_commands(self, ssh_commands): + commands = '\n '.join(ssh_commands) + self.append(textwrap.dedent("""\ + cat << EOF | ssh root@${info[1]} + %s + EOF""") % commands + ) + + def save(self, vps): + # TODO create the container + context = self.get_context(vps) + self.append(textwrap.dedent(""" + info=( $(get_vz_info %(hostname)s) ) + echo "Managing ${info[@]}"\ + """) % context + ) + ssh_commands = [] + vzset_args = self.get_vzset_args(context) + if vzset_args: + context['vzset_args'] = vzset_args + ssh_commands.append("pvectl vzset ${info[0]} %(vzset_args)s" % context) + if hasattr(vps, 'password'): + context['password'] = vps.password.replace('$', '\\$') + ssh_commands.append(textwrap.dedent("""\ + echo 'root:%(password)s' \\ + | chroot /var/lib/vz/private/${info[0]} chpasswd -e""") % context + ) + self.run_ssh_commands(ssh_commands) + + def get_context(self, vps): + context = { + 'hostname': vps.hostname, + } + for resource, __ in self.RESOURCES: + try: + allocation = getattr(vps.resources, resource).allocated + except AttributeError: + pass + else: + context[resource] = allocation + return context + + +class ProxmoxOpenVZTraffic(ServiceMonitor): + model = 'vps.VPS' + resource = ServiceMonitor.TRAFFIC + monthly_sum_old_values = True + GET_PROXMOX_INFO = ProxmoxOVZ.GET_PROXMOX_INFO + + def prepare(self): + super(ProxmoxOpenVZTraffic, self).prepare() + self.append(self.GET_PROXMOX_INFO) + self.append(textwrap.dedent(""" + function monitor () { + object_id=$1 + hostname=$2 + info=( $(get_vz_info $hostname) ) + cat << EOF | ssh root@${info[1]} + vzctl exec ${info[0]} cat /proc/net/dev \\ + | grep venet0 \\ + | tr ':' ' ' \\ + | awk '{sum=\\$2+\\$10} END {printf ("$object_id %0.0f\\n", sum)}' + EOF + } + """) + ) + + def process(self, line): + """ diff with last stored state """ + object_id, value, state = super(ProxmoxOpenVZTraffic, self).process(line) + value = decimal.Decimal(value) + last = self.get_last_data(object_id) + if not last or last.state > value: + return object_id, value, value + return object_id, value-last.state, value + + def monitor(self, vps): + """ Get OpenVZ container traffic on a Proxmox cluster """ + context = self.get_context(vps) + self.append('monitor %(object_id)s %(hostname)s' % context) + + def get_context(self, vps): + return { + 'object_id': vps.id, + 'hostname': vps.hostname, + } + + +class LxcController(ServiceController): + model = 'vps.VPS' + + RESOURCES = ( + ('memory', 'mem'), + ('disk', 'disk'), + ('vcpu', 'vcpu') + ) + + def prepare(self): + super(LxcController, self).prepare() + + def save(self, vps): + # TODO create the container + pass + + diff --git a/orchestra/contrib/vps/backends.py.new b/orchestra/contrib/vps/backends.py.new new file mode 100644 index 0000000..2ead22c --- /dev/null +++ b/orchestra/contrib/vps/backends.py.new @@ -0,0 +1,135 @@ +import decimal +import textwrap + +from orchestra.contrib.orchestration import ServiceController +from orchestra.contrib.resources import ServiceMonitor + +from . import settings + + +class ProxmoxOVZ(ServiceController): + model = 'vps.VPS' + + RESOURCES = ( + ('memory', 'mem'), + ('swap', 'swap'), + ('disk', 'disk') + ) + GET_PROXMOX_INFO = textwrap.dedent(""" + function get_vz_info () { + hostname=$1 + version=$(pveversion | cut -d '/' -f2 | cut -d'.' -f1) + if [[ $version -lt 2 ]]; then + conf=$(grep "CID\\|:$hostname:" /var/lib/pve-manager/vzlist | grep -B1 ":$hostname:") + CID=$(echo "$conf" | head -n1 | cut -d':' -f2) + CTID=$(echo "$conf" | tail -n1 | cut -d':' -f1) + node=$(pveca -l | grep "^\\s*$CID\\s*:" | awk {'print $3'}) + else + conf=$(grep -r "HOSTNAME=\\"$hostname\\"" /etc/pve/nodes/*/openvz/*.conf) + node=$(echo "${conf}" | cut -d"/" -f5) + CTID=$(echo "${conf}" | cut -d"/" -f7 | cut -d"\\." -f1) + fi + echo $CTID $node + }""") + + def prepare(self): + super(ProxmoxOVZ, self).prepare() + self.append(self.GET_PROXMOX_INFO) + + def get_vzset_args(self, context): + args = list(settings.VPS_DEFAULT_VZSET_ARGS) + for resource, arg_name in self.RESOURCES: + try: + allocation = context[resource] + except KeyError: + pass + else: + args.append('--%s %i' % (arg_name, allocation)) + return ' '.join(args) + + def run_ssh_commands(self, ssh_commands): + commands = '\n '.join(ssh_commands) + self.append(textwrap.dedent("""\ + cat << EOF | ssh root@${info[1]} + %s + EOF""") % commands + ) + + def save(self, vps): + # TODO create the container + context = self.get_context(vps) + self.append(textwrap.dedent(""" + info=( $(get_vz_info %(hostname)s) ) + echo "Managing ${info[@]}"\ + """) % context + ) + ssh_commands = [] + vzset_args = self.get_vzset_args(context) + if vzset_args: + context['vzset_args'] = vzset_args + ssh_commands.append("pvectl vzset ${info[0]} %(vzset_args)s" % context) + if hasattr(vps, 'password'): + context['password'] = vps.password.replace('$', '\\$') + ssh_commands.append(textwrap.dedent("""\ + echo 'root:%(password)s' \\ + | chroot /var/lib/vz/private/${info[0]} chpasswd -e""") % context + ) + self.run_ssh_commands(ssh_commands) + + def get_context(self, vps): + context = { + 'hostname': vps.hostname, + } + for resource, __ in self.RESOURCES: + try: + allocation = getattr(vps.resources, resource).allocated + except AttributeError: + pass + else: + context[resource] = allocation + return context + + +class ProxmoxOpenVZTraffic(ServiceMonitor): + model = 'vps.VPS' + resource = ServiceMonitor.TRAFFIC + monthly_sum_old_values = True + GET_PROXMOX_INFO = ProxmoxOVZ.GET_PROXMOX_INFO + + def prepare(self): + super(ProxmoxOpenVZTraffic, self).prepare() + self.append(self.GET_PROXMOX_INFO) + self.append(textwrap.dedent(""" + function monitor () { + object_id=$1 + hostname=$2 + info=( $(get_vz_info $hostname) ) + cat << EOF | ssh root@${info[1]} + vzctl exec ${info[0]} cat /proc/net/dev \\ + | grep venet0 \\ + | tr ':' ' ' \\ + | awk '{sum=\\$2+\\$10} END {printf ("$object_id %0.0f\\n", sum)}' + EOF + } + """) + ) + + def process(self, line): + """ diff with last stored state """ + object_id, value, state = super(ProxmoxOpenVZTraffic, self).process(line) + value = decimal.Decimal(value) + last = self.get_last_data(object_id) + if not last or last.state > value: + return object_id, value, value + return object_id, value-last.state, value + + def monitor(self, vps): + """ Get OpenVZ container traffic on a Proxmox cluster """ + context = self.get_context(vps) + self.append('monitor %(object_id)s %(hostname)s' % context) + + def get_context(self, vps): + return { + 'object_id': vps.id, + 'hostname': vps.hostname, + } diff --git a/orchestra/contrib/vps/models.py b/orchestra/contrib/vps/models.py new file mode 100644 index 0000000..f5b19f7 --- /dev/null +++ b/orchestra/contrib/vps/models.py @@ -0,0 +1,46 @@ +from django.contrib.auth.hashers import make_password +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from orchestra.core.validators import validate_hostname + +from . import settings + + +class VPS(models.Model): + hostname = models.CharField(_("hostname"), max_length=256, unique=True, + validators=[validate_hostname]) + type = models.CharField(_("type"), max_length=64, choices=settings.VPS_TYPES, + default=settings.VPS_DEFAULT_TYPE) + template = models.CharField(_("template"), max_length=64, + choices=settings.VPS_TEMPLATES, default=settings.VPS_DEFAULT_TEMPLATE, + help_text=_("Initial template.")) + account = models.ForeignKey('accounts.Account', on_delete=models.CASCADE, + verbose_name=_("Account"), related_name='vpss') + is_active = models.BooleanField(_("active"), default=True) + + class Meta: + verbose_name = "VPS" + verbose_name_plural = "VPSs" + + def __str__(self): + return self.hostname + + def set_password(self, raw_password): + self.password = make_password(raw_password) + + def get_username(self): + return self.hostname + + def disable(self): + self.is_active = False + self.save(update_fields=('is_active',)) + + def enable(self): + self.is_active = False + self.save(update_fields=('is_active',)) + + @property + def active(self): + return self.is_active and self.account.is_active + diff --git a/orchestra/contrib/vps/settings.py b/orchestra/contrib/vps/settings.py new file mode 100644 index 0000000..ec0e2a1 --- /dev/null +++ b/orchestra/contrib/vps/settings.py @@ -0,0 +1,36 @@ +from orchestra.contrib.settings import Setting + + +VPS_TYPES = Setting('VPS_TYPES', + ( + ('openvz', 'OpenVZ container'), + ('lxc', 'LXC container') + ), + validators=[Setting.validate_choices] +) + + +VPS_DEFAULT_TYPE = Setting('VPS_DEFAULT_TYPE', + 'lxc', + choices=VPS_TYPES +) + + +VPS_TEMPLATES = Setting('VPS_TEMPLATES', + ( + ('debian7', 'Debian 7 - Wheezy'), + ('placeholder', 'LXC placeholder') + ), + validators=[Setting.validate_choices] +) + + +VPS_DEFAULT_TEMPLATE = Setting('VPS_DEFAULT_TEMPLATE', + 'placeholder', + choices=VPS_TEMPLATES +) + + +VPS_DEFAULT_VZSET_ARGS = Setting('VPS_DEFAULT_VZSET_ARGS', + ('--onboot yes',), +) diff --git a/orchestra/contrib/webapps/__init__.py b/orchestra/contrib/webapps/__init__.py new file mode 100644 index 0000000..f08acd2 --- /dev/null +++ b/orchestra/contrib/webapps/__init__.py @@ -0,0 +1 @@ +default_app_config = 'orchestra.contrib.webapps.apps.WebAppsConfig' diff --git a/orchestra/contrib/webapps/admin.py b/orchestra/contrib/webapps/admin.py new file mode 100644 index 0000000..24bd494 --- /dev/null +++ b/orchestra/contrib/webapps/admin.py @@ -0,0 +1,125 @@ +from django import forms +from django.contrib import admin +from django.urls import reverse +from django.utils.encoding import force_str +from django.utils.safestring import mark_safe +from django.utils.translation import gettext, gettext_lazy as _ +from django.shortcuts import resolve_url +from django.contrib.admin.templatetags.admin_urls import admin_urlname + +from orchestra.admin import ExtendedModelAdmin +from orchestra.admin.utils import admin_link, get_modeladmin +from orchestra.contrib.accounts.actions import list_accounts +from orchestra.contrib.accounts.admin import AccountAdminMixin +from orchestra.contrib.systemusers.models import WebappUsers +from orchestra.forms.widgets import DynamicHelpTextSelect +from orchestra.plugins.admin import SelectPluginAdminMixin, display_plugin_field +from orchestra.utils.html import get_on_site_link + +from .filters import HasWebsiteListFilter, DetailListFilter +from .models import WebApp, WebAppOption +from .options import AppOption +from .types import AppType + + +class WebAppOptionInline(admin.TabularInline): + model = WebAppOption + extra = 1 + + OPTIONS_HELP_TEXT = { + op.name: force_str(op.help_text) for op in AppOption.get_plugins() + } + + class Media: + css = { + 'all': ('orchestra/css/hide-inline-id.css',) + } + + def formfield_for_dbfield(self, db_field, **kwargs): + if db_field.name == 'value': + kwargs['widget'] = forms.TextInput(attrs={'size':'100'}) + if db_field.name == 'name': + if self.parent_object: + plugin = self.parent_object.type_class + else: + request = kwargs['request'] + webapp_modeladmin = get_modeladmin(self.parent_model) + plugin_value = webapp_modeladmin.get_plugin_value(request) + plugin = AppType.get(plugin_value) + kwargs['choices'] = plugin.get_group_options_choices() + # Help text based on select widget + target = 'this.id.replace("name", "value")' + kwargs['widget'] = DynamicHelpTextSelect(target, self.OPTIONS_HELP_TEXT) + return super(WebAppOptionInline, self).formfield_for_dbfield(db_field, **kwargs) + + +class WebAppAdmin(SelectPluginAdminMixin, AccountAdminMixin, ExtendedModelAdmin): + list_display = ( + 'name', 'display_type', 'display_detail', 'display_websites', 'account_link', 'target_server', + ) + list_filter = ('type', HasWebsiteListFilter, DetailListFilter) + inlines = [WebAppOptionInline] + readonly_fields = ('account_link',) + change_readonly_fields = ('name', 'type', 'display_websites', 'display_sftpuser', 'target_server',) + search_fields = ('name', 'account__username', 'data', 'website__domains__name') + list_prefetch_related = ('content_set__website', 'content_set__website__domains') + plugin = AppType + plugin_field = 'type' + plugin_title = _("Web application type") + actions = (list_accounts,) + + display_type = display_plugin_field('type') + + def display_sftpuser(self, obj): + salida = "" + if obj.sftpuser is None: + salida = None + else: + url = resolve_url(admin_urlname(WebappUsers._meta, 'change'), obj.sftpuser.id) + salida += f'{obj.sftpuser}
    ' + return mark_safe(salida) + display_sftpuser.short_description = _("user sftp") + + @mark_safe + def display_websites(self, webapp): + websites = [] + for content in webapp.content_set.all(): + site_url = content.get_absolute_url() + site_link = get_on_site_link(site_url) + website = content.website + #name = "%s on %s %s" % (website.name, content.path, site_link) + name = "%s on %s" % (website.name, content.path) + link = admin_link(display=name)(website) + websites.append(link) + if not websites: + add_url = reverse('admin:websites_website_add') + add_url += '?account=%s' % webapp.account_id + plus = '+' + websites.append('%s%s' % (add_url, plus, gettext("Add website"))) + return '
    '.join(websites) + display_websites.short_description = _("web sites") + + def display_detail(self, webapp): + try: + return webapp.type_instance.get_detail() + except KeyError: + return mark_safe("Not available") + display_detail.short_description = _("detail") + + + def save_model(self, request, obj, form, change): + if not change: + user = form.cleaned_data.get('username') + if user: + user = WebappUsers( + username=form.cleaned_data['username'], + account_id=obj.account.pk, + target_server=form.cleaned_data['target_server'], + home=form.cleaned_data['name'] + ) + user.set_password(form.cleaned_data["password1"]) + user.save() + obj.sftpuser = user + super(WebAppAdmin, self).save_model(request, obj, form, change) + +admin.site.register(WebApp, WebAppAdmin) diff --git a/orchestra/contrib/webapps/api.py b/orchestra/contrib/webapps/api.py new file mode 100644 index 0000000..9ba4305 --- /dev/null +++ b/orchestra/contrib/webapps/api.py @@ -0,0 +1,50 @@ +from rest_framework import viewsets + +from orchestra.api import router, LogApiMixin +from orchestra.contrib.accounts.api import AccountApiMixin + +from . import settings +from .models import WebApp +from .options import AppOption +from .serializers import WebAppSerializer +from .types import AppType + + +class WebAppViewSet(LogApiMixin, AccountApiMixin, viewsets.ModelViewSet): + queryset = WebApp.objects.prefetch_related('options').all() + serializer_class = WebAppSerializer + filter_fields = ('name',) + + def options(self, request): + metadata = super(WebAppViewSet, self).options(request) + names = [ + 'WEBAPPS_BASE_DIR', 'WEBAPPS_TYPES', 'WEBAPPS_WEBAPP_OPTIONS', + 'WEBAPPS_PHP_DISABLED_FUNCTIONS', 'WEBAPPS_DEFAULT_TYPE' + ] + metadata.data['settings'] = { + name.lower(): getattr(settings, name, None) for name in names + } + # AppTypes + meta = self.metadata_class() + app_types = {} + for app_type in AppType.get_plugins(): + if app_type.serializer: + data = meta.get_serializer_info(app_type.serializer()) + else: + data = {} + data['option_groups'] = app_type.option_groups + app_types[app_type.get_name()] = data + metadata.data['actions']['types'] = app_types + # Options + options = {} + for option in AppOption.get_plugins(): + options[option.get_name()] = { + 'verbose_name': option.get_verbose_name(), + 'help_text': option.help_text, + 'group': option.group, + } + metadata.data['actions']['options'] = options + return metadata + + +router.register(r'webapps', WebAppViewSet) diff --git a/orchestra/contrib/webapps/apps.py b/orchestra/contrib/webapps/apps.py new file mode 100644 index 0000000..b183c2c --- /dev/null +++ b/orchestra/contrib/webapps/apps.py @@ -0,0 +1,13 @@ +from django.apps import AppConfig + +from orchestra.core import services + + +class WebAppsConfig(AppConfig): + name = 'orchestra.contrib.webapps' + verbose_name = 'Webapps' + + def ready(self): + from .models import WebApp + services.register(WebApp, icon='Applications-other.png') + from . import signals diff --git a/orchestra/contrib/webapps/backends/__init__.py b/orchestra/contrib/webapps/backends/__init__.py new file mode 100644 index 0000000..597db03 --- /dev/null +++ b/orchestra/contrib/webapps/backends/__init__.py @@ -0,0 +1,98 @@ +import pkgutil +import textwrap +from django.template import Template, Context +from .. import settings +from orchestra.settings import NEW_SERVERS + +class WebAppServiceMixin(object): + model = 'webapps.WebApp' + related_models = ( + ('webapps.WebAppOption', 'webapp'), + ) + directive = None + doc_settings = (settings, + ('WEBAPPS_UNDER_CONSTRUCTION_PATH', 'WEBAPPS_MOVE_ON_DELETE_PATH',) + ) + def check_webapp_dir(self, context): + self.append(textwrap.dedent(""" + # Create webapp dir + CREATED=0 + if [[ ! -e %(app_path)s ]]; then + mkdir -p %(app_path)s + #chown %(sftpuser)s:%(sftpuser)s %(app_path)s + CREATED=1 + elif [[ -z $( ls -A %(app_path)s ) ]]; then + CREATED=1 + fi""") % context + ) + + def create_webapp_dir(self, context): + self.append(textwrap.dedent(""" + # Create webapp dir + CREATED=0 + if [[ ! -e %(app_path)s ]]; then + CREATED=1 + mkdir -p %(app_path)s + chown %(user)s:%(group)s %(app_path)s + fi""") % context + ) + + def set_under_construction(self, context): + if context['under_construction_path']: + # cambios de permisos en servidores nuevos + perms = Template(textwrap.dedent("""\ + {% if sftpuser %} + chown -R {{ sftpuser }}:{{ sftpuser }} {{ app_path }}/* {% else %} + chown -R {{ user }}:{{ group }} {{ app_path }}/* + {% endif %} + """ + )) + context.update({'perms' : perms.render(Context(context))}) + self.append(textwrap.dedent(""" + # Set under construction if needed + if [[ $CREATED == 1 && ! $(ls -A %(app_path)s | head -n1) ]]; then + # Async wait some seconds for other backends to lock app_path or cp under construction + nohup bash -c ' + sleep 2 + if [[ ! $(ls -A %(app_path)s | head -n1) ]]; then + cp -r %(under_construction_path)s %(app_path)s + %(perms)s + fi' &> /dev/null & + fi""") % context + ) + + def delete_webapp_dir(self, context): + if context['deleted_app_path']: + self.append(textwrap.dedent("""\ + # Move app into WEBAPPS_MOVE_ON_DELETE_PATH, nesting if exists. + deleted_app_path="%(deleted_app_path)s" + while [[ -e $deleted_app_path ]]; do + deleted_app_path="${deleted_app_path}/$(basename ${deleted_app_path})" + done + mv %(app_path)s $deleted_app_path || exit_code=$? + """) % context + ) + else: + self.append("rm -fr %(app_path)s" % context) + + def get_context(self, webapp): + context = webapp.type_instance.get_directive_context() + context.update({ + 'user': webapp.get_username(), + 'group': webapp.get_groupname(), + 'app_name': webapp.name, + 'app_type': webapp.type, + 'app_path': webapp.get_path(), + 'banner': self.get_banner(), + 'under_construction_path': settings.WEBAPPS_UNDER_CONSTRUCTION_PATH, + 'is_mounted': webapp.content_set.exists(), + 'target_server': webapp.target_server, + 'sftpuser' : webapp.sftpuser.username if webapp.target_server.name in NEW_SERVERS else None + }) + context['deleted_app_path'] = settings.WEBAPPS_MOVE_ON_DELETE_PATH % context + return context + + +for __, module_name, __ in pkgutil.walk_packages(__path__): + # sorry for the exec(), but Import module function fails :( + exec('from . import %s' % module_name) diff --git a/orchestra/contrib/webapps/backends/moodle.py b/orchestra/contrib/webapps/backends/moodle.py new file mode 100644 index 0000000..5579e8a --- /dev/null +++ b/orchestra/contrib/webapps/backends/moodle.py @@ -0,0 +1,108 @@ +import os +import textwrap + +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.orchestration import ServiceController, replace + +from .. import settings + +from . import WebAppServiceMixin + + +class MoodleController(WebAppServiceMixin, ServiceController): + """ + Installs the latest version of Moodle available on download.moodle.org + """ + verbose_name = _("Moodle") + model = 'webapps.WebApp' + default_route_match = "webapp.type == 'moodle-php'" + doc_settings = (settings, + ('WEBAPPS_DEFAULT_MYSQL_DATABASE_HOST',) + ) + + + def save(self, webapp): + context = self.get_context(webapp) + self.append(textwrap.dedent("""\ + if [[ $(ls "%(app_path)s" | wc -l) -gt 1 ]]; then + echo "App directory not empty." 2> /dev/null + exit 0 + fi + mkdir -p %(app_path)s + # Prevent other backends from writting here + touch %(app_path)s/.lock + # Weekly caching + moodle_date=$(date -r $(readlink %(cms_cache_dir)s/moodle) +%%s || echo 0) + if [[ $moodle_date -lt $(($(date +%%s)-7*24*60*60)) ]]; then + moodle_url=$(wget https://download.moodle.org/releases/latest/ -O - -q \\ + | tr ' ' '\\n' \\ + | grep 'moodle-latest.*.tgz"' \\ + | sed -E 's#href="([^"]+)".*#\\1#' \\ + | head -n 1 \\ + | sed "s#download.php/#download.php/direct/#") + filename=${moodle_url##*/} + wget $moodle_url -O - --no-check-certificate \\ + | tee %(cms_cache_dir)s/$filename \\ + | tar -xzvf - -C %(app_path)s --strip-components=1 + rm -f %(cms_cache_dir)s/moodle + ln -s %(cms_cache_dir)s/$filename %(cms_cache_dir)s/moodle + else + tar -xzvf %(cms_cache_dir)s/moodle -C %(app_path)s --strip-components=1 + fi + mkdir %(app_path)s/moodledata && { + chmod 750 %(app_path)s/moodledata + echo -n 'order deny,allow\\ndeny from all' > %(app_path)s/moodledata/.htaccess + } + if [[ ! -e %(app_path)s/config.php ]]; then + cp %(app_path)s/config-dist.php %(app_path)s/config.php + sed -i "s#dbtype\s*= '.*#dbtype = '%(db_type)s';#" %(app_path)s/config.php + sed -i "s#dbhost\s*= '.*#dbhost = '%(db_host)s';#" %(app_path)s/config.php + sed -i "s#dbname\s*= '.*#dbname = '%(db_name)s';#" %(app_path)s/config.php + sed -i "s#dbuser\s*= '.*#dbuser = '%(db_user)s';#" %(app_path)s/config.php + sed -i "s#dbpass\s*= '.*#dbpass = '%(password)s';#" %(app_path)s/config.php + sed -i "s#dataroot\s*= '.*#dataroot = '%(app_path)s/moodledata';#" %(app_path)s/config.php + sed -i "s#wwwroot\s*= '.*#wwwroot = '%(www_root)s';#" %(app_path)s/config.php + + fi + rm %(app_path)s/.lock + chown -R %(user)s:%(group)s %(app_path)s + # Run install moodle cli command on the background, because it takes so long... + stdout=$(mktemp) + stderr=$(mktemp) + nohup su - %(user)s --shell /bin/bash << 'EOF' > $stdout 2> $stderr & + php %(app_path)s/admin/cli/install_database.php \\ + --fullname="%(site_name)s" \\ + --shortname="%(site_name)s" \\ + --adminpass="%(password)s" \\ + --adminemail="%(email)s" \\ + --non-interactive \\ + --agree-license \\ + --allow-unstable + EOF + pid=$! + sleep 2 + if ! ps -p $pid > /dev/null; then + cat $stdout + cat $stderr >&2 + exit_code=$(wait $pid) + fi + rm $stdout $stderr + """) % context + ) + + def get_context(self, webapp): + context = super(MoodleController, self).get_context(webapp) + contents = webapp.content_set.all() + context.update({ + 'db_type': 'mysqli', + 'db_name': webapp.data['db_name'], + 'db_user': webapp.data['db_user'], + 'password': webapp.data['password'], + 'db_host': settings.WEBAPPS_DEFAULT_MYSQL_DATABASE_HOST, + 'email': webapp.account.email, + 'site_name': "%s Courses" % webapp.account.get_full_name(), + 'cms_cache_dir': os.path.normpath(settings.WEBAPPS_CMS_CACHE_DIR), + 'www_root': contents[0].website.get_absolute_url() if contents else 'http://empty' + }) + return replace(context, '"', "'") diff --git a/orchestra/contrib/webapps/backends/php.py b/orchestra/contrib/webapps/backends/php.py new file mode 100644 index 0000000..04018db --- /dev/null +++ b/orchestra/contrib/webapps/backends/php.py @@ -0,0 +1,334 @@ +import os +import textwrap +from collections import OrderedDict + +from django.template import Template, Context +from django.utils.translation import gettext_lazy as _ + +from orchestra.settings import NEW_SERVERS +from orchestra.contrib.orchestration import ServiceController + +from . import WebAppServiceMixin +from .. import settings, utils + + +class PHPController(WebAppServiceMixin, ServiceController): + """ + PHP support for apache-mod-fcgid and php-fpm. + It handles switching between these two PHP process management systemes. + """ + MERGE = settings.WEBAPPS_MERGE_PHP_WEBAPPS + + verbose_name = _("PHP FPM/FCGID") + default_route_match = "webapp.type.endswith('php')" + doc_settings = (settings, ( + 'WEBAPPS_MERGE_PHP_WEBAPPS', + 'WEBAPPS_FPM_DEFAULT_MAX_CHILDREN', + 'WEBAPPS_PHP_CGI_BINARY_PATH', + 'WEBAPPS_PHP_CGI_RC_DIR', + 'WEBAPPS_PHP_CGI_INI_SCAN_DIR', + 'WEBAPPS_FCGID_CMD_OPTIONS_PATH', + 'WEBAPPS_PHPFPM_POOL_PATH', + 'WEBAPPS_PHP_MAX_REQUESTS', + )) + + def save(self, webapp): + self.delete_old_config(webapp) + context = self.get_context(webapp) + + if context.get('target_server').name in NEW_SERVERS: + self.check_webapp_dir(context) + else: + self.create_webapp_dir(context) + + if webapp.type_instance.is_fpm: + self.save_fpm(webapp, context) + elif webapp.type_instance.is_fcgid: + self.save_fcgid(webapp, context) + else: + raise TypeError("Unknown PHP execution type") +# LEGACY CLEANUP FUNCTIONS. TODO REMOVE WHEN SURE NOT NEEDED. +# self.delete_fcgid(webapp, context, preserve=True) +# self.delete_fpm(webapp, context, preserve=True) + self.set_under_construction(context) + + def delete_config(self,webapp): + context = self.get_context(webapp) + to_delete = [] + if webapp.type_instance.is_fpm: + to_delete.append(settings.WEBAPPS_PHPFPM_POOL_PATH % context) + to_delete.append(settings.WEBAPPS_FPM_LISTEN % context) + elif webapp.type_instance.is_fcgid: + to_delete.append(settings.WEBAPPS_FCGID_WRAPPER_PATH % context) + to_delete.append(settings.WEBAPPS_FCGID_CMD_OPTIONS_PATH % context) + for item in to_delete: + self.append('rm -f "{}"'.format(item)) + + def delete_old_config(self,webapp): + # Check if we loaded the old version of the webapp. If so, we're updating + # rather than creating, so we must make sure the old config files are removed. + if hasattr(webapp, '_old_self'): + self.append("# Clean old configuration files") + self.delete_config(webapp._old_self) + else: + self.append("# No old config files to delete") + + def save_fpm(self, webapp, context): + self.append(textwrap.dedent(""" + # Generate FPM configuration + read -r -d '' fpm_config << 'EOF' || true + %(fpm_config)s + EOF + { + echo -e "${fpm_config}" | diff -N -I'^\s*;;' %(fpm_path)s - + } || { + echo -e "${fpm_config}" > %(fpm_path)s + UPDATED_FPM=1 + } + """) % context + ) + + def save_fcgid(self, webapp, context): + self.append("mkdir -p %(wrapper_dir)s" % context) + self.append(textwrap.dedent(""" + # Generate FCGID configuration + read -r -d '' wrapper << 'EOF' || true + %(wrapper)s + EOF + { + echo -e "${wrapper}" | diff -N -I'^\s*#' %(wrapper_path)s - + } || { + echo -e "${wrapper}" > %(wrapper_path)s + if [[ %(is_mounted)i -eq 1 ]]; then + # Reload fcgid wrapper (All PHP versions, because of version changing support) + pkill -SIGHUP -U %(user)s "^php[0-9\.]+-cgi$" || true + fi + } + chmod 550 %(wrapper_dir)s + chmod 550 %(wrapper_path)s + chown -R %(user)s:%(group)s %(wrapper_dir)s""") % context + ) + if context['cmd_options']: + self.append(textwrap.dedent("""\ + # FCGID options + read -r -d '' cmd_options << 'EOF' || true + %(cmd_options)s + EOF + { + echo -e "${cmd_options}" | diff -N -I'^\s*#' %(cmd_options_path)s - + } || { + echo -e "${cmd_options}" > %(cmd_options_path)s + [[ ${UPDATED_APACHE} -eq 0 ]] && UPDATED_APACHE=%(is_mounted)i + } + """ ) % context + ) + else: + self.append("rm -f %(cmd_options_path)s\n" % context) + + def delete(self, webapp): + context = self.get_context(webapp) + self.delete_old_config(webapp) + if context.get('target_server').name in NEW_SERVERS: + webapp.sftpuser.delete() + else: + self.delete_webapp_dir(context) + + def has_sibilings(self, webapp, context): + return type(webapp).objects.filter( + account=webapp.account_id, + data__contains='"php_version":"%s"' % context['php_version'], + ).exclude(id=webapp.pk).exists() + + def all_versions_to_delete(self, webapp, context, preserve=False): + context_copy = dict(context) + for php_version, verbose in settings.WEBAPPS_PHP_VERSIONS: + if preserve and php_version == context['php_version']: + continue + php_version_number = utils.extract_version_number(php_version) + context_copy['php_version'] = php_version + context_copy['php_version_number'] = php_version_number + if not self.MERGE or not self.has_sibilings(webapp, context_copy): + yield context_copy + + def delete_fpm(self, webapp, context, preserve=False): + """ delete all pools in order to efectively support changing php-fpm version """ + for context_copy in self.all_versions_to_delete(webapp, context, preserve): + context_copy['fpm_path'] = settings.WEBAPPS_PHPFPM_POOL_PATH % context_copy + self.append("rm -f %(fpm_path)s" % context_copy) + + def delete_fcgid(self, webapp, context, preserve=False): + """ delete all pools in order to efectively support changing php-fcgid version """ + for context_copy in self.all_versions_to_delete(webapp, context, preserve): + context_copy.update({ + 'wrapper_path': settings.WEBAPPS_FCGID_WRAPPER_PATH % context_copy, + 'cmd_options_path': settings.WEBAPPS_FCGID_CMD_OPTIONS_PATH % context_copy, + }) + self.append("rm -f %(wrapper_path)s" % context_copy) + self.append("rm -f %(cmd_options_path)s" % context_copy) + + def prepare(self): + super(PHPController, self).prepare() + self.append(textwrap.dedent(""" + BACKEND="PHPController" + echo "$BACKEND" >> /dev/shm/reload.apache2 + + function coordinate_apache_reload () { + # Coordinate Apache reload with other concurrent backends (e.g. Apache2Controller) + is_last=0 + counter=0 + while ! mv /dev/shm/reload.apache2 /dev/shm/reload.apache2.locked; do + sleep 0.1; + if [[ $counter -gt 4 ]]; then + echo "[ERROR]: Apache reload synchronization deadlocked!" >&2 + exit 10 + fi + counter=$(($counter+1)) + done + state="$(grep -v -E "^$BACKEND($|\s)" /dev/shm/reload.apache2.locked)" || is_last=1 + [[ $is_last -eq 0 ]] && { + echo "$state" | grep -v ' RELOAD$' || is_last=1 + } + if [[ $is_last -eq 1 ]]; then + echo "[DEBUG]: Last backend to run, update: $UPDATED_APACHE, state: '$state'" + if [[ $UPDATED_APACHE -eq 1 || "$state" =~ .*RELOAD$ ]]; then + if service apache2 status > /dev/null; then + service apache2 reload + else + service apache2 start + fi + fi + rm /dev/shm/reload.apache2.locked + else + echo "$state" > /dev/shm/reload.apache2.locked + if [[ $UPDATED_APACHE -eq 1 ]]; then + echo -e "[DEBUG]: Apache will be reloaded by another backend:\\n${state}" + echo "$BACKEND RELOAD" >> /dev/shm/reload.apache2.locked + fi + mv /dev/shm/reload.apache2.locked /dev/shm/reload.apache2 + fi + }""") + ) + + def commit(self): + context = { + 'reload_pool': settings.WEBAPPS_PHPFPM_RELOAD_POOL, + } + self.append(textwrap.dedent(""" + # Apply changes if needed + if [[ $UPDATED_FPM -eq 1 ]]; then + %(reload_pool)s + fi + coordinate_apache_reload + """) % context + ) + super(PHPController, self).commit() + + def get_fpm_config(self, webapp, context): + options = webapp.type_instance.get_options() + 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), + 'request_terminate_timeout': options.get('timeout', False), + }) + context['fpm_listen'] = webapp.type_instance.FPM_LISTEN % context + fpm_config = Template(textwrap.dedent("""\ + ;; {{ banner }} + [{{ user }}-{{app_name}}] + {% if sftpuser %} + user = {{ sftpuser }} + group = {{ sftpuser }} + + listen = {{ fpm_listen | safe }} + listen.owner = root + listen.group = {{ sftpuser }} + {% else %} + user = {{ user }} + group = {{ group }} + + listen = {{ fpm_listen | safe }} + listen.owner = {{ user }} + listen.group = {{ group }} + {% endif %} + + pm = ondemand + pm.max_requests = {{ max_requests }} + pm.max_children = {{ max_children }} + {% if request_terminate_timeout %} + request_terminate_timeout = {{ request_terminate_timeout }}{% endif %} + {% for name, value in init_vars.items %} + php_admin_value[{{ name | safe }}] = {{ value | safe }}{% endfor %} + """ + )) + return fpm_config.render(Context(context)) + + def get_fcgid_wrapper(self, webapp, context): + opt = webapp.type_instance + # Format PHP init vars + init_vars = opt.get_php_init_vars(merge=self.MERGE) + if init_vars: + init_vars = [ " \\\n -d %s='%s'" % (k, v.replace("'", '"')) for k,v in init_vars.items() ] + init_vars = ''.join(init_vars) + context.update({ + 'php_binary_path': os.path.normpath(settings.WEBAPPS_PHP_CGI_BINARY_PATH % context), + 'php_rc': os.path.normpath(settings.WEBAPPS_PHP_CGI_RC_DIR % context), + 'php_ini_scan': os.path.normpath(settings.WEBAPPS_PHP_CGI_INI_SCAN_DIR % context), + 'php_init_vars': init_vars, + }) + context['php_binary'] = os.path.basename(context['php_binary_path']) + return textwrap.dedent("""\ + #!/bin/sh + # %(banner)s + export PHPRC=%(php_rc)s + export PHP_INI_SCAN_DIR=%(php_ini_scan)s + export PHP_FCGI_MAX_REQUESTS=%(max_requests)s + exec %(php_binary_path)s%(php_init_vars)s""") % context + + def get_fcgid_cmd_options(self, webapp, context): + options = webapp.type_instance.get_options() + maps = OrderedDict( + MaxProcesses=options.get('processes', None), + IOTimeout=options.get('timeout', None), + ) + cmd_options = [] + for directive, value in maps.items(): + if value: + cmd_options.append( + "%s %s" % (directive, value.replace("'", '"')) + ) + if cmd_options: + head = ( + '# %(banner)s\n' + 'FcgidCmdOptions %(wrapper_path)s' + ) % context + cmd_options.insert(0, head) + return ' \\\n '.join(cmd_options) + + def update_fcgid_context(self, webapp, context): + wrapper_path = settings.WEBAPPS_FCGID_WRAPPER_PATH % context + context.update({ + 'wrapper': self.get_fcgid_wrapper(webapp, context), + 'wrapper_path': wrapper_path, + 'wrapper_dir': os.path.dirname(wrapper_path), + }) + context.update({ + 'cmd_options': self.get_fcgid_cmd_options(webapp, context), + 'cmd_options_path': settings.WEBAPPS_FCGID_CMD_OPTIONS_PATH % context, + }) + return context + + def update_fpm_context(self, webapp, context): + context.update({ + 'fpm_config': self.get_fpm_config(webapp, context), + 'fpm_path': settings.WEBAPPS_PHPFPM_POOL_PATH % context, + }) + return context + + def get_context(self, webapp): + context = super().get_context(webapp) + context.update({ + 'max_requests': settings.WEBAPPS_PHP_MAX_REQUESTS, + 'target_server': webapp.target_server, + }) + self.update_fpm_context(webapp, context) + self.update_fcgid_context(webapp, context) + return context diff --git a/orchestra/contrib/webapps/backends/python.py b/orchestra/contrib/webapps/backends/python.py new file mode 100644 index 0000000..63013d5 --- /dev/null +++ b/orchestra/contrib/webapps/backends/python.py @@ -0,0 +1,86 @@ +import os +import textwrap + +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.orchestration import ServiceController, replace + +from . import WebAppServiceMixin +from .. import settings + + +class uWSGIPythonController(WebAppServiceMixin, ServiceController): + """ + Emperor mode + """ + verbose_name = _("Python uWSGI") + default_route_match = "webapp.type.endswith('python')" + doc_settings = (settings, ( + 'WEBAPPS_UWSGI_BASE_DIR', + 'WEBAPPS_PYTHON_MAX_REQUESTS', + 'WEBAPPS_PYTHON_DEFAULT_MAX_WORKERS', + 'WEBAPPS_PYTHON_DEFAULT_TIMEOUT', + )) + + def save(self, webapp): + context = self.get_context(webapp) + self.create_webapp_dir(context) + self.set_under_construction(context) + self.save_uwsgi(webapp, context) + + def delete(self, webapp): + context = self.get_context(webapp) + self.delete_uwsgi(webapp, context) + self.delete_webapp_dir(context) + + def save_uwsgi(self, webapp, context): + self.append("echo '%(uwsgi_config)s' > %(vassal_path)s" % context) + + def delete_uwsgi(self, webapp, context): + self.append("rm -f %(vassal_path)s" % context) + + def get_uwsgi_ini(self, context): + return textwrap.dedent("""\ + # %(banner)s + [uwsgi] + plugins = python{python_version_number} + chdir = {app_path} + module = {app_name}.wsgi + chmod-socket = 660 + stats = /run/uwsgi/%(deb-confnamespace)/%(deb-confname)/statsocket + vacuum = true + uid = {user} + gid = {group} + env = HOME={home} + harakiri = {timeout} + max-requests = {max_requests} + + cheaper-algo = spare + cheaper = 1 + workers = {workers} + cheaper-step = 1 + cheaper-overload = 5""" + ).format(context) + + def update_uwsgi_context(self, webapp, context): + context.update({ + 'uwsgi_ini': self.get_uwsgi_ini(context), + 'uwsgi_dir': settings.WEBAPPS_UWSGI_BASE_DIR, + 'vassal_path': os.path.join(settings.WEBAPPS_UWSGI_BASE_DIR, + 'vassals/%s' % context['app_name']), + }) + return context + + def get_context(self, webapp): + context = super(uWSGIPythonController, self).get_context(webapp) + options = webapp.get_options() + context.update({ + 'python_version': webapp.type_instance.get_python_version(), + 'python_version_number': webapp.type_instance.get_python_version_number(), + 'max_requests': settings.WEBAPPS_PYTHON_MAX_REQUESTS, + 'workers': options.get('processes', settings.WEBAPPS_PYTHON_DEFAULT_MAX_WORKERS), + 'timeout': options.get('timeout', settings.WEBAPPS_PYTHON_DEFAULT_TIMEOUT), + }) + self.update_uwsgi_context(webapp, context) + replace(context, "'", '"') + return context diff --git a/orchestra/contrib/webapps/backends/static.py b/orchestra/contrib/webapps/backends/static.py new file mode 100644 index 0000000..ea2061a --- /dev/null +++ b/orchestra/contrib/webapps/backends/static.py @@ -0,0 +1,30 @@ +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.orchestration import ServiceController + +from . import WebAppServiceMixin +from orchestra.settings import NEW_SERVERS + +class StaticController(WebAppServiceMixin, ServiceController): + """ + Static web pages. + Only creates the webapp dir and leaves the web server the decision to execute CGIs or not. + """ + verbose_name = _("Static") + default_route_match = "webapp.type == 'static'" + + def save(self, webapp): + context = self.get_context(webapp) + if context.get('target_server').name in NEW_SERVERS: + self.check_webapp_dir(context) + self.set_under_construction(context) + else: + self.create_webapp_dir(context) + self.set_under_construction(context) + + def delete(self, webapp): + context = self.get_context(webapp) + if context.get('target_server').name in NEW_SERVERS: + webapp.sftpuser.delete() + else: + self.delete_webapp_dir(context) diff --git a/orchestra/contrib/webapps/backends/symboliclink.py b/orchestra/contrib/webapps/backends/symboliclink.py new file mode 100644 index 0000000..2ba3fac --- /dev/null +++ b/orchestra/contrib/webapps/backends/symboliclink.py @@ -0,0 +1,35 @@ +import textwrap + +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.orchestration import ServiceController, replace + +from .php import PHPController + + +class SymbolicLinkController(PHPController, ServiceController): + """ + Same as PHPController but allows you to have the webapps on a directory diferent than the webapps dir. + """ + verbose_name = _("Symbolic link webapp") + model = 'webapps.WebApp' + default_route_match = "webapp.type == 'symbolic-link'" + + def create_webapp_dir(self, context): + self.append(textwrap.dedent("""\ + if [[ ! -e %(app_path)s ]]; then + ln -s '%(link_path)s' %(app_path)s + fi + chown -h %(user)s:%(group)s %(app_path)s + """) % context + ) + + def set_under_construction(self, context): + pass + + def get_context(self, webapp): + context = super(SymbolicLinkController, self).get_context(webapp) + context.update({ + 'link_path': webapp.data['path'], + }) + return replace(context, "'", '"') diff --git a/orchestra/contrib/webapps/backends/webalizer.py b/orchestra/contrib/webapps/backends/webalizer.py new file mode 100644 index 0000000..c9d3556 --- /dev/null +++ b/orchestra/contrib/webapps/backends/webalizer.py @@ -0,0 +1,22 @@ +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.orchestration import ServiceController + +from . import WebAppServiceMixin + + +# TODO DEPRECATE +class WebalizerAppController(WebAppServiceMixin, ServiceController): + """ + Needed for cleaning up webalizer main folder when webapp deleteion withou related contents + """ + verbose_name = _("Webalizer App") + default_route_match = "webapp.type == 'webalizer'" + + def save(self, webapp): + context = self.get_context(webapp) + self.create_webapp_dir(context) + + def delete(self, webapp): + context = self.get_context(webapp) + self.delete_webapp_dir(context) diff --git a/orchestra/contrib/webapps/backends/wordpress.py b/orchestra/contrib/webapps/backends/wordpress.py new file mode 100644 index 0000000..64907a3 --- /dev/null +++ b/orchestra/contrib/webapps/backends/wordpress.py @@ -0,0 +1,160 @@ +import os +import textwrap + +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.orchestration import ServiceController, replace +from orchestra.settings import NEW_SERVERS +from django.template import Template, Context + +from .. import settings + +from . import WebAppServiceMixin + + +# Based on https://github.com/mtomic/wordpress-install/blob/master/wpinstall.php +class WordPressController(WebAppServiceMixin, ServiceController): + """ + Installs the latest version of WordPress available on www.wordpress.org + It fully configures the wp-config.php (keys included) and sets up the database with initial admin password. + """ + verbose_name = _("Wordpress") + model = 'webapps.WebApp' + default_route_match = "webapp.type == 'wordpress-php'" + script_executable = '/usr/bin/php' + doc_settings = (settings, + ('WEBAPPS_DEFAULT_MYSQL_DATABASE_HOST',) + ) + + def prepare(self): + self.append(textwrap.dedent("""\ + 1) { + die("App directory not empty."); + } + // Download and untar wordpress (with caching system) + shell_exec("mkdir -p %(app_path)s + # Prevent other backends from writting here + touch %(app_path)s/.lock + filename=\\$(wget https://wordpress.org/latest.tar.gz --server-response --spider --no-check-certificate 2>&1 \\ + | grep filename | cut -d'=' -f2) + mkdir -p %(cms_cache_dir)s + if [ ! -e %(cms_cache_dir)s/wordpress ] || [ \\$(basename \\$(readlink %(cms_cache_dir)s/wordpress) 2> /dev/null ) != \\$filename ]; then + wget https://wordpress.org/latest.tar.gz -O - --no-check-certificate \\ + | tee %(cms_cache_dir)s/\\$filename \\ + | tar -xzvf - -C %(app_path)s --strip-components=1 + rm -f %(cms_cache_dir)s/wordpress + ln -s %(cms_cache_dir)s/\\$filename %(cms_cache_dir)s/wordpress + else + tar -xzvf %(cms_cache_dir)s/wordpress -C %(app_path)s --strip-components=1 + fi + mkdir %(app_path)s/wp-content/uploads + chmod 750 %(app_path)s/wp-content/uploads + rm %(app_path)s/.lock + "); + + $config_file = file('%(app_path)s/' . 'wp-config-sample.php'); + $secret_keys = file_get_contents('https://api.wordpress.org/secret-key/1.1/salt/'); + $secret_keys = explode( "\\n", $secret_keys ); + foreach ( $secret_keys as $k => $v ) { + $secret_keys[$k] = substr( $v, 28, 64 ); + } + array_pop($secret_keys); + + // setup wordpress database and keys config + $config_file = str_replace('database_name_here', "%(db_name)s", $config_file); + $config_file = str_replace('username_here', "%(db_user)s", $config_file); + $config_file = str_replace('password_here', "%(password)s", $config_file); + $config_file = str_replace('localhost', "%(db_host)s", $config_file); + $config_file = str_replace("'AUTH_KEY', 'put your unique phrase here'", "'AUTH_KEY', '{$secret_keys[0]}'", $config_file); + $config_file = str_replace("'SECURE_AUTH_KEY', 'put your unique phrase here'", "'SECURE_AUTH_KEY', '{$secret_keys[1]}'", $config_file); + $config_file = str_replace("'LOGGED_IN_KEY', 'put your unique phrase here'", "'LOGGED_IN_KEY', '{$secret_keys[2]}'", $config_file); + $config_file = str_replace("'NONCE_KEY', 'put your unique phrase here'", "'NONCE_KEY', '{$secret_keys[3]}'", $config_file); + $config_file = str_replace("'AUTH_SALT', 'put your unique phrase here'", "'AUTH_SALT', '{$secret_keys[4]}'", $config_file); + $config_file = str_replace("'SECURE_AUTH_SALT', 'put your unique phrase here'", "'SECURE_AUTH_SALT', '{$secret_keys[5]}'", $config_file); + $config_file = str_replace("'LOGGED_IN_SALT', 'put your unique phrase here'", "'LOGGED_IN_SALT', '{$secret_keys[6]}'", $config_file); + $config_file = str_replace("'NONCE_SALT', 'put your unique phrase here'", "'NONCE_SALT', '{$secret_keys[7]}'", $config_file); + + if(file_exists('%(app_path)s/' .'wp-config.php')) { + unlink('%(app_path)s/' .'wp-config.php'); + } + + $fw = fopen('%(app_path)s/' . 'wp-config.php', 'a'); + foreach ( $config_file as $line_num => $line ) { + fwrite($fw, $line); + } + //exc('chown -R %(user)s:%(group)s %(app_path)s'); + %(perms)s + + // Run wordpress installation process + + define('WP_CONTENT_DIR', 'wp-content/'); + define('WP_LANG_DIR', WP_CONTENT_DIR . '/languages' ); + define('WP_USE_THEMES', true); + define('DB_NAME', "%(db_name)s"); + define('DB_USER', "%(db_user)s"); + define('DB_PASSWORD', "%(password)s"); + define('DB_HOST', "%(db_host)s"); + + $_GET['step'] = 2; + $_POST['weblog_title'] = "%(title)s"; + $_POST['user_name'] = "admin"; + $_POST['admin_email'] = "%(email)s"; + $_POST['blog_public'] = true; + $_POST['admin_password'] = "%(password)s"; + $_POST['admin_password2'] = "%(password)s"; + + ob_start(); + require_once('%(app_path)s/wp-admin/install.php'); + $response = ob_get_contents(); + ob_end_clean(); + if (strpos($response, '

    Success!

    ') === false) { + echo "Error has occured during installation\\n"; + echo $msg; + exit(1); + }""") % context + ) + + def commit(self): + self.append('?>') + + def delete(self, webapp): + context = self.get_context(webapp) + self.append("exc('rm -rf %(app_path)s');" % context) + + def get_context(self, webapp): + context = super(WordPressController, self).get_context(webapp) + context.update({ + 'db_name': webapp.data['db_name'], + 'db_user': webapp.data['db_user'], + 'password': webapp.data['password'], + 'db_host': 'localhost' if webapp.target_server.name in NEW_SERVERS else settings.WEBAPPS_DEFAULT_MYSQL_DATABASE_HOST, + 'email': webapp.account.email, + 'title': "%s blog's" % webapp.account.get_full_name(), + 'cms_cache_dir': os.path.normpath(settings.WEBAPPS_CMS_CACHE_DIR), + 'sftpuser': webapp.sftpuser.username if webapp.target_server.name in NEW_SERVERS else None , + }) + return replace(context, '"', "'") diff --git a/orchestra/contrib/webapps/fields.py b/orchestra/contrib/webapps/fields.py new file mode 100644 index 0000000..d430a41 --- /dev/null +++ b/orchestra/contrib/webapps/fields.py @@ -0,0 +1,28 @@ +from django.contrib.contenttypes.fields import GenericRelation +from django.db import DEFAULT_DB_ALIAS + + +class VirtualDatabaseRelation(GenericRelation): + """ Delete related databases if any """ + def bulk_related_objects(self, objs, using=DEFAULT_DB_ALIAS): + pks = [] + for obj in objs: + db_id = obj.data.get('db_id') + if db_id: + pks.append(db_id) + if not pks: + return [] + return self.remote_field.model._base_manager.db_manager(using).filter(pk__in=pks) + + +class VirtualDatabaseUserRelation(GenericRelation): + """ Delete related databases if any """ + def bulk_related_objects(self, objs, using=DEFAULT_DB_ALIAS): + pks = [] + for obj in objs: + db_id = obj.data.get('db_user_id') + if db_id: + pks.append(db_id) + if not pks: + return [] + return self.remote_field.model._base_manager.db_manager(using).filter(pk__in=pks) diff --git a/orchestra/contrib/webapps/filters.py b/orchestra/contrib/webapps/filters.py new file mode 100644 index 0000000..6543487 --- /dev/null +++ b/orchestra/contrib/webapps/filters.py @@ -0,0 +1,51 @@ +from django.contrib.admin import SimpleListFilter +from django.utils.translation import gettext_lazy as _ + +from .types import AppType + + +class HasWebsiteListFilter(SimpleListFilter): + title = _("website") + parameter_name = 'has_website' + + def lookups(self, request, model_admin): + return ( + ('True', _("True")), + ('False', _("False")), + ) + + def queryset(self, request, queryset): + if self.value() == 'True': + return queryset.filter(content__isnull=False) + elif self.value() == 'False': + return queryset.filter(content__isnull=True) + return queryset + + +class DetailListFilter(SimpleListFilter): + title = _("detail") + parameter_name = 'detail' + + def lookups(self, request, model_admin): + ret = set([('empty', _("Empty"))]) + lookup_map = {} + for apptype in AppType.get_plugins(): + for field, values in apptype.get_detail_lookups().items(): + for value in values: + lookup_map[value[0]] = field + ret.add(tuple(value)) + self.lookup_map = lookup_map + return sorted(list(ret), key=lambda e: e[1]) + + def queryset(self, request, queryset): + value = self.value() + if value: + if value == 'empty': + return queryset.filter(data={}) + try: + field = self.lookup_map[value] + except KeyError: + return queryset + else: + return queryset.filter(data__contains=value) + return queryset diff --git a/orchestra/contrib/webapps/migrations/0001_initial.py b/orchestra/contrib/webapps/migrations/0001_initial.py new file mode 100644 index 0000000..041f793 --- /dev/null +++ b/orchestra/contrib/webapps/migrations/0001_initial.py @@ -0,0 +1,51 @@ +# Generated by Django 2.2.28 on 2023-07-24 16:08 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import jsonfield.fields +import orchestra.core.validators + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('orchestration', '__first__'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='WebApp', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='The app will be installed in %(home)s/webapps/%(app_name)s', max_length=128, validators=[orchestra.core.validators.validate_name], verbose_name='name')), + ('type', models.CharField(choices=[('moodle-php', 'Moodle'), ('php', 'PHP'), ('python', 'Python'), ('static', 'Static'), ('symbolic-link', 'Symbolic link'), ('webalizer', 'Webalizer'), ('wordpress-php', 'WordPress')], max_length=32, verbose_name='type')), + ('data', jsonfield.fields.JSONField(blank=True, default={}, help_text='Extra information dependent of each service.', verbose_name='data')), + ('comments', models.TextField(blank=True, default='')), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webapps', to=settings.AUTH_USER_MODEL, verbose_name='Account')), + ('target_server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webapps', to='orchestration.Server', verbose_name='Target Server')), + ], + options={ + 'verbose_name': 'Web App', + 'verbose_name_plural': 'Web Apps', + 'unique_together': {('name', 'account')}, + }, + ), + migrations.CreateModel( + name='WebAppOption', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(choices=[(None, '-------'), ('FileSystem', [('public-root', 'Public root')]), ('Process', [('timeout', 'Process timeout'), ('processes', 'Number of processes')]), ('PHP', [('enable_functions', 'Enable functions'), ('disable_functions', 'Disable functions'), ('allow_url_include', 'Allow URL include'), ('allow_url_fopen', 'Allow URL fopen'), ('auto_append_file', 'Auto append file'), ('auto_prepend_file', 'Auto prepend file'), ('date.timezone', 'date.timezone'), ('default_socket_timeout', 'Default socket timeout'), ('display_errors', 'Display errors'), ('extension', 'Extension'), ('include_path', 'Include path'), ('open_basedir', 'Open basedir'), ('magic_quotes_gpc', 'Magic quotes GPC'), ('magic_quotes_runtime', 'Magic quotes runtime'), ('magic_quotes_sybase', 'Magic quotes sybase'), ('max_input_time', 'Max input time'), ('max_input_vars', 'Max input vars'), ('memory_limit', 'Memory limit'), ('mysql.connect_timeout', 'Mysql connect timeout'), ('output_buffering', 'Output buffering'), ('register_globals', 'Register globals'), ('post_max_size', 'Post max size'), ('sendmail_path', 'Sendmail path'), ('session.bug_compat_warn', 'Session bug compat warning'), ('session.auto_start', 'Session auto start'), ('safe_mode', 'Safe mode'), ('suhosin.post.max_vars', 'Suhosin POST max vars'), ('suhosin.get.max_vars', 'Suhosin GET max vars'), ('suhosin.request.max_vars', 'Suhosin request max vars'), ('suhosin.session.encrypt', 'Suhosin session encrypt'), ('suhosin.simulation', 'Suhosin simulation'), ('suhosin.executor.include.whitelist', 'Suhosin executor include whitelist'), ('upload_max_filesize', 'Upload max filesize'), ('upload_tmp_dir', 'Upload tmp dir'), ('zend_extension', 'Zend extension')])], max_length=128, verbose_name='name')), + ('value', models.CharField(max_length=256, verbose_name='value')), + ('webapp', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='webapps.WebApp', verbose_name='Web application')), + ], + options={ + 'verbose_name': 'option', + 'verbose_name_plural': 'options', + 'unique_together': {('webapp', 'name')}, + }, + ), + ] diff --git a/orchestra/contrib/webapps/migrations/0002_webapp_sftpuser.py b/orchestra/contrib/webapps/migrations/0002_webapp_sftpuser.py new file mode 100644 index 0000000..cfbaec5 --- /dev/null +++ b/orchestra/contrib/webapps/migrations/0002_webapp_sftpuser.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.28 on 2023-07-24 16:13 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('systemusers', '0003_auto_20230724_1813'), + ('webapps', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='webapp', + name='sftpuser', + field=models.ForeignKey(blank=True, help_text='This option is only required for the new webservers.', null=True, on_delete=django.db.models.deletion.CASCADE, to='systemusers.WebappUsers', verbose_name='SFTP user'), + ), + ] diff --git a/orchestra/contrib/webapps/migrations/0003_auto_20230728_1639.py b/orchestra/contrib/webapps/migrations/0003_auto_20230728_1639.py new file mode 100644 index 0000000..68e316b --- /dev/null +++ b/orchestra/contrib/webapps/migrations/0003_auto_20230728_1639.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.28 on 2023-07-28 14:39 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('orchestration', '__first__'), + ('webapps', '0002_webapp_sftpuser'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='webapp', + unique_together={('name', 'account', 'target_server')}, + ), + ] diff --git a/orchestra/contrib/webapps/migrations/0004_auto_20230817_1108.py b/orchestra/contrib/webapps/migrations/0004_auto_20230817_1108.py new file mode 100644 index 0000000..37911f6 --- /dev/null +++ b/orchestra/contrib/webapps/migrations/0004_auto_20230817_1108.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2023-08-17 09:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('webapps', '0003_auto_20230728_1639'), + ] + + operations = [ + migrations.AlterField( + model_name='webapp', + name='type', + field=models.CharField(choices=[('php', 'PHP'), ('static', 'Static'), ('webalizer', 'Webalizer'), ('wordpress-php', 'WordPress')], max_length=32, verbose_name='type'), + ), + ] diff --git a/orchestra/contrib/webapps/migrations/__init__.py b/orchestra/contrib/webapps/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestra/contrib/webapps/models.py b/orchestra/contrib/webapps/models.py new file mode 100644 index 0000000..642922a --- /dev/null +++ b/orchestra/contrib/webapps/models.py @@ -0,0 +1,127 @@ +import os +from collections import OrderedDict + +from django.db import models +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ +from jsonfield import JSONField + +from orchestra.core import validators + +from . import settings +from .fields import VirtualDatabaseRelation, VirtualDatabaseUserRelation +from .options import AppOption +from .types import AppType + + +class WebApp(models.Model): + """ Represents a web application """ + name = models.CharField(_("name"), max_length=128, validators=[validators.validate_name], + help_text=_("The app will be installed in %s") % settings.WEBAPPS_BASE_DIR) + type = models.CharField(_("type"), max_length=32, choices=AppType.get_choices()) + account = models.ForeignKey('accounts.Account', on_delete=models.CASCADE, + verbose_name=_("Account"), related_name='webapps') + data = JSONField(_("data"), blank=True, default={}, + help_text=_("Extra information dependent of each service.")) + target_server = models.ForeignKey('orchestration.Server', on_delete=models.CASCADE, + verbose_name=_("Target Server"), related_name='webapps') + comments = models.TextField(default="", blank=True) + sftpuser = models.ForeignKey('systemusers.WebappUsers', blank=True, null=True, on_delete=models.CASCADE , + verbose_name=_("SFTP user"), help_text=_("This option is only required for the new webservers.")) + + # CMS webapps usually need a database and dbuser, with these virtual fields we tell the ORM to delete them + databases = VirtualDatabaseRelation('databases.Database') + databaseusers = VirtualDatabaseUserRelation('databases.DatabaseUser') + + class Meta: + unique_together = ('name', 'account', 'target_server') + verbose_name = _("Web App") + verbose_name_plural = _("Web Apps") + + def __str__(self): + return self.name + + def get_description(self): + return self.get_type_display() + + @cached_property + def type_class(self): + return AppType.get(self.type) + + @cached_property + def type_instance(self): + """ Per request lived type_instance """ + return self.type_class(self) + + def clean(self): + apptype = self.type_instance + apptype.validate() + a = apptype.clean_data() + self.data = apptype.clean_data() + + def get_options(self, **kwargs): + options = OrderedDict() + qs = WebAppOption.objects.filter(**kwargs) + for name, value in qs.values_list('name', 'value').order_by('name'): + if name in options: + if AppOption.get(name).comma_separated: + options[name] = options[name].rstrip(',') + ',' + value.lstrip(',') + else: + options[name] = max(options[name], value) + else: + options[name] = value + return options + + def get_directive(self): + return self.type_instance.get_directive() + + def get_base_path(self): + context = { + 'home': self.get_user().get_home(), + 'app_name': self.name, + } + return settings.WEBAPPS_BASE_DIR % context + + def get_path(self): + path = self.get_base_path() + public_root = self.options.filter(name='public-root').first() + if public_root: + path = os.path.join(path, public_root.value) + return os.path.normpath(path.replace('//', '/')) + + def get_user(self): + return self.account.main_systemuser + + def get_username(self): + return self.get_user().username + + def get_groupname(self): + return self.get_username() + + +class WebAppOption(models.Model): + webapp = models.ForeignKey(WebApp, on_delete=models.CASCADE, + verbose_name=_("Web application"), related_name='options') + name = models.CharField(_("name"), max_length=128, + choices=AppType.get_group_options_choices()) + value = models.CharField(_("value"), max_length=256) + + class Meta: + unique_together = ('webapp', 'name') + verbose_name = _("option") + verbose_name_plural = _("options") + + def __str__(self): + return self.name + + @cached_property + def option_class(self): + return AppOption.get(self.name) + + @cached_property + def option_instance(self): + """ Per request lived option instance """ + return self.option_class(self) + + def clean(self): + self.option_instance.validate() diff --git a/orchestra/contrib/webapps/options.py b/orchestra/contrib/webapps/options.py new file mode 100644 index 0000000..66a9a37 --- /dev/null +++ b/orchestra/contrib/webapps/options.py @@ -0,0 +1,383 @@ +import os +import re +from functools import lru_cache + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +from orchestra import plugins +from orchestra.utils.python import import_class + +from . import settings + + +class AppOption(plugins.Plugin, metaclass=plugins.PluginMount): + PHP = 'PHP' + PROCESS = 'Process' + FILESYSTEM = 'FileSystem' + + help_text = "" + group = None + comma_separated = False + + @classmethod + @lru_cache() + def get_plugins(cls, all=False): + if all: + plugins = super().get_plugins() + else: + plugins = [] + for cls in settings.WEBAPPS_ENABLED_OPTIONS: + plugins.append(import_class(cls)) + return plugins + + @classmethod + @lru_cache() + def get_option_groups(cls): + groups = {} + for opt in cls.get_plugins(): + try: + groups[opt.group].append(opt) + except KeyError: + groups[opt.group] = [opt] + return groups + + def validate(self): + if self.regex and not re.match(self.regex, self.instance.value): + raise ValidationError({ + 'value': ValidationError(_("'%(value)s' does not match %(regex)s."), + params={ + 'value': self.instance.value, + 'regex': self.regex + }), + }) + + +class PHPAppOption(AppOption): + deprecated = None + group = AppOption.PHP + abstract = True + + def validate(self): + super().validate() + if self.deprecated: + php_version = self.instance.webapp.type_instance.get_php_version_number() + if php_version and self.deprecated and float(php_version) > self.deprecated: + raise ValidationError( + _("This option is deprecated since PHP version %s.") % self.deprecated + ) + + +class PublicRoot(AppOption): + name = 'public-root' + verbose_name = _("Public root") + help_text = _("Document root relative to webapps/<webapp>/") + regex = r'[^ ]+' + group = AppOption.FILESYSTEM + + def validate(self): + super().validate() + base_path = self.instance.webapp.get_base_path() + path = os.path.join(base_path, self.instance.value) + if not os.path.abspath(path).startswith(base_path): + raise ValidationError( + _("Public root path '%s' outside of webapp base path '%s'") % (path, base_path) + ) + + +class Timeout(AppOption): + name = 'timeout' + # FCGID FcgidIOTimeout + # FPM pm.request_terminate_timeout + # PHP max_execution_time ini + verbose_name = _("Process timeout") + help_text = _("Maximum time in seconds allowed for a request to complete (a number between 0 and 999).
    " + "Also sets max_request_time when php-cgi is used.") + regex = r'^[0-9]{1,3}$' + group = AppOption.PROCESS + + +class Processes(AppOption): + name = 'processes' + # FCGID MaxProcesses + # FPM pm.max_children + verbose_name = _("Number of processes") + help_text = _("Maximum number of children that can be alive at the same time (a number between 0 and 99).") + regex = r'^[0-9]{1,3}$' + group = AppOption.PROCESS + + +class PHPEnableFunctions(PHPAppOption): + name = 'enable_functions' + verbose_name = _("Enable functions") + help_text = '%s' % ',
    '.join([ + ','.join(settings.WEBAPPS_PHP_DISABLED_FUNCTIONS[i:i+10]) + for i in range(0, len(settings.WEBAPPS_PHP_DISABLED_FUNCTIONS), 10) + ]) + regex = r'^[\w\.,-]+$' + comma_separated = True + + def validate(self): + # Clean value removing spaces + self.instance.value = self.instance.value.replace(' ', '') + super().validate() + + +class PHPDisableFunctions(PHPAppOption): + name = 'disable_functions' + verbose_name = _("Disable functions") + help_text = _("This directive allows you to disable certain functions for security reasons. " + "It takes on a comma-delimited list of function names. disable_functions is not " + "affected by Safe Mode. Default disabled fuctions include:
    " + "%s") % ',
    '.join([ + ','.join(settings.WEBAPPS_PHP_DISABLED_FUNCTIONS[i:i+10]) + for i in range(0, len(settings.WEBAPPS_PHP_DISABLED_FUNCTIONS), 10) + ]) + regex = r'^[\w\.,-]+$' + comma_separated = True + + def validate(self): + # Clean value removing spaces + self.instance.value = self.instance.value.replace(' ', '') + super().validate() + + +class PHPAllowURLInclude(PHPAppOption): + name = 'allow_url_include' + verbose_name = _("Allow URL include") + help_text = _("Allows the use of URL-aware fopen wrappers with include, include_once, require, " + "require_once (On or Off).") + regex = r'^(On|Off|on|off)$' + + +class PHPAllowURLFopen(PHPAppOption): + name = 'allow_url_fopen' + verbose_name = _("Allow URL fopen") + help_text = _("Enables the URL-aware fopen wrappers that enable accessing URL object like files (On or Off).") + regex = r'^(On|Off|on|off)$' + + +class PHPAutoAppendFile(PHPAppOption): + name = 'auto_append_file' + verbose_name = _("Auto append file") + help_text = _("Specifies the name of a file that is automatically parsed after the main file.") + regex = r'^[\w\.,-/]+$' + + +class PHPAutoPrependFile(PHPAppOption): + name = 'auto_prepend_file' + verbose_name = _("Auto prepend file") + help_text = _("Specifies the name of a file that is automatically parsed before the main file.") + regex = r'^[\w\.,-/]+$' + + +class PHPDateTimeZone(PHPAppOption): + name = 'date.timezone' + verbose_name = _("date.timezone") + help_text = _("Sets the default timezone used by all date/time functions (Timezone string 'Europe/London').") + regex = r'^\w+/\w+$' + + +class PHPDefaultSocketTimeout(PHPAppOption): + name = 'default_socket_timeout' + verbose_name = _("Default socket timeout") + help_text = _("Number between 0 and 999.") + regex = r'^[0-9]{1,3}$' + + +class PHPDisplayErrors(PHPAppOption): + name = 'display_errors' + verbose_name = _("Display errors") + help_text = _("Determines whether errors should be printed to the screen as part of the output or " + "if they should be hidden from the user (On or Off).") + regex = r'^(On|Off|on|off)$' + + +class PHPExtension(PHPAppOption): + name = 'extension' + verbose_name = _("Extension") + regex = r'^[^ ]+$' + + +class PHPIncludePath(PHPAppOption): + name = 'include_path' + verbose_name = _("Include path") + regex = r'^[^ ]+$' + +class PHPOpenBasedir(PHPAppOption): + name = 'open_basedir' + verbose_name = _("Open basedir") + regex = r'^[^ ]+$' + +class PHPMagicQuotesGPC(PHPAppOption): + name = 'magic_quotes_gpc' + verbose_name = _("Magic quotes GPC") + help_text = _("Sets the magic_quotes state for GPC (Get/Post/Cookie) operations (On or Off) " + "DEPRECATED as of PHP 5.3.0.") + regex = r'^(On|Off|on|off)$' + deprecated = 5.3 + + +class PHPMagicQuotesRuntime(PHPAppOption): + name = 'magic_quotes_runtime' + verbose_name = _("Magic quotes runtime") + help_text = _("Functions that return data from any sort of external source will have quotes escaped " + "with a backslash (On or Off) DEPRECATED as of PHP 5.3.0.") + regex = r'^(On|Off|on|off)$' + deprecated = 5.3 + + +class PHPMaginQuotesSybase(PHPAppOption): + name = 'magic_quotes_sybase' + verbose_name = _("Magic quotes sybase") + help_text = _("Single-quote is escaped with a single-quote instead of a backslash (On or Off).") + regex = r'^(On|Off|on|off)$' + + +class PHPMaxInputTime(PHPAppOption): + name = 'max_input_time' + verbose_name = _("Max input time") + help_text = _("Maximum time in seconds a script is allowed to parse input data, like POST and GET " + "(Integer between 0 and 999).") + regex = r'^[0-9]{1,3}$' + + +class PHPMaxInputVars(PHPAppOption): + name = 'max_input_vars' + verbose_name = _("Max input vars") + help_text = _("How many input variables may be accepted (limit is applied to $_GET, $_POST " + "and $_COOKIE superglobal separately) (Integer between 0 and 9999).") + regex = r'^[0-9]{1,4}$' + + +class PHPMemoryLimit(PHPAppOption): + name = 'memory_limit' + verbose_name = _("Memory limit") + help_text = _("This sets the maximum amount of memory in bytes that a script is allowed to allocate " + "(Value between 0M and 999M).") + regex = r'^[0-9]{1,3}M$' + + +class PHPMySQLConnectTimeout(PHPAppOption): + name = 'mysql.connect_timeout' + verbose_name = _("Mysql connect timeout") + help_text = _("Number between 0 and 999.") + regex = r'^([0-9]){1,3}$' + + +class PHPOutputBuffering(PHPAppOption): + name = 'output_buffering' + verbose_name = _("Output buffering") + help_text = _("Turn on output buffering (On or Off).") + regex = r'^(On|Off|on|off)$' + + +class PHPRegisterGlobals(PHPAppOption): + name = 'register_globals' + verbose_name = _("Register globals") + help_text = _("Whether or not to register the EGPCS (Environment, GET, POST, Cookie, Server) " + "variables as global variables (On or Off).") + regex = r'^(On|Off|on|off)$' + + +class PHPPostMaxSize(PHPAppOption): + name = 'post_max_size' + verbose_name = _("Post max size") + help_text = _("Sets max size of post data allowed (Value between 0M and 999M).") + regex = r'^[0-9]{1,3}M$' + + +class PHPSendmailPath(PHPAppOption): + name = 'sendmail_path' + verbose_name = _("Sendmail path") + help_text = _("Where the sendmail program can be found.") + regex = r'^[^ ]+$' + + +class PHPSessionBugCompatWarn(PHPAppOption): + name = 'session.bug_compat_warn' + verbose_name = _("Session bug compat warning") + help_text = _("Enables an PHP bug on session initialization for legacy behaviour (On or Off).") + regex = r'^(On|Off|on|off)$' + + +class PHPSessionAutoStart(PHPAppOption): + name = 'session.auto_start' + verbose_name = _("Session auto start") + help_text = _("Specifies whether the session module starts a session automatically on request " + "startup (On or Off).") + regex = r'^(On|Off|on|off)$' + group = AppOption.PHP + + +class PHPSafeMode(PHPAppOption): + name = 'safe_mode' + verbose_name = _("Safe mode") + help_text = _("Whether to enable PHP's safe mode (On or Off) DEPRECATED as of PHP 5.3.0") + regex = r'^(On|Off|on|off)$' + deprecated=5.3 + + +class PHPSuhosinPostMaxVars(PHPAppOption): + name = 'suhosin.post.max_vars' + verbose_name = _("Suhosin POST max vars") + help_text = _("Number between 0 and 9999.") + regex = r'^[0-9]{1,4}$' + + +class PHPSuhosinGetMaxVars(PHPAppOption): + name = 'suhosin.get.max_vars' + verbose_name = _("Suhosin GET max vars") + help_text = _("Number between 0 and 9999.") + regex = r'^[0-9]{1,4}$' + + +class PHPSuhosinRequestMaxVars(PHPAppOption): + name = 'suhosin.request.max_vars' + verbose_name = _("Suhosin request max vars") + help_text = _("Number between 0 and 9999.") + regex = r'^[0-9]{1,4}$' + + +class PHPSuhosinSessionEncrypt(PHPAppOption): + name = 'suhosin.session.encrypt' + verbose_name = _("Suhosin session encrypt") + help_text = _("On or Off") + regex = r'^(On|Off|on|off)$' + + +class PHPSuhosinSimulation(PHPAppOption): + name = 'suhosin.simulation' + verbose_name = _("Suhosin simulation") + help_text = _("On or Off") + regex = r'^(On|Off|on|off)$' + + +class PHPSuhosinExecutorIncludeWhitelist(PHPAppOption): + name = 'suhosin.executor.include.whitelist' + verbose_name = _("Suhosin executor include whitelist") + regex = r'.*$' + + +class PHPUploadMaxFileSize(PHPAppOption): + name = 'upload_max_filesize' + verbose_name = _("Upload max filesize") + help_text = _("Value between 0M and 999M.") + regex = r'^[0-9]{1,3}M$' + + +class PHPUploadTmpDir(PHPAppOption): + name = 'upload_tmp_dir' + verbose_name = _("Upload tmp dir") + help_text = _("The temporary directory used for storing files when doing file upload. " + "Must be writable by whatever user PHP is running as. " + "If not specified PHP will use the system's default.
    " + "If the directory specified here is not writable, PHP falls back to the " + "system default temporary directory. If open_basedir is on, then the system " + "default directory must be allowed for an upload to succeed.") + regex = r'.*$' + +class PHPZendExtension(PHPAppOption): + name = 'zend_extension' + verbose_name = _("Zend extension") + regex = r'^[^ ]+$' diff --git a/orchestra/contrib/webapps/serializers.py b/orchestra/contrib/webapps/serializers.py new file mode 100644 index 0000000..abfe9dc --- /dev/null +++ b/orchestra/contrib/webapps/serializers.py @@ -0,0 +1,69 @@ +from django.db import models +from rest_framework import serializers + +from orchestra.api.serializers import HyperlinkedModelSerializer +from orchestra.contrib.accounts.serializers import AccountSerializerMixin + +from .models import WebApp, WebAppOption + + +class OptionSerializer(serializers.ModelSerializer): + class Meta: + model = WebAppOption + fields = ('name', 'value') + + def to_representation(self, instance): + return {prop.name: prop.value for prop in instance.all()} + + def to_internal_value(self, data): + return data + + +class DataField(serializers.Field): + def to_representation(self, data): + return data + + +class WebAppSerializer(AccountSerializerMixin, HyperlinkedModelSerializer): + options = OptionSerializer(required=False) + data = DataField() + + class Meta: + model = WebApp + fields = ('url', 'id', 'name', 'type', 'options', 'data',) + postonly_fields = ('name', 'type') + + def __init__(self, *args, **kwargs): + super(WebAppSerializer, self).__init__(*args, **kwargs) + if isinstance(self.instance, models.Model): + type_serializer = self.instance.type_instance.serializer + if type_serializer: + self.fields['data'] = type_serializer() + + def create(self, validated_data): + options_data = validated_data.pop('options') + webapp = super(WebAppSerializer, self).create(validated_data) + for key, value in options_data.items(): + WebAppOption.objects.create(webapp=webapp, name=key, value=value) + return webap + + def update(self, instance, validated_data): + options_data = validated_data.pop('options') + instance = super(WebAppSerializer, self).update(instance, validated_data) + existing = {} + for obj in instance.options.all(): + existing[obj.name] = obj + posted = set() + for key, value in options_data.items(): + posted.add(key) + try: + option = existing[key] + except KeyError: + option = instance.options.create(name=key, value=value) + else: + if option.value != value: + option.value = value + option.save(update_fields=('value',)) + for to_delete in set(existing.keys())-posted: + existing[to_delete].delete() + return instance diff --git a/orchestra/contrib/webapps/settings.py b/orchestra/contrib/webapps/settings.py new file mode 100644 index 0000000..47ed3f4 --- /dev/null +++ b/orchestra/contrib/webapps/settings.py @@ -0,0 +1,298 @@ +from orchestra.contrib.settings import Setting +from orchestra.settings import ORCHESTRA_BASE_DOMAIN + +from .. import webapps + + +_names = ('home', 'user', 'user_id', 'group', 'app_type', 'app_name', 'app_type', 'app_id', 'account_id') +_php_names = _names + ('php_version', 'php_version_number', 'php_version_int') +_python_names = _names + ('python_version', 'python_version_number',) + + +WEBAPPS_BASE_DIR = Setting('WEBAPPS_BASE_DIR', + '%(home)s/webapps/%(app_name)s', + help_text="Available fromat names: %s" % ', '.join(_names), + validators=[Setting.string_format_validator(_names)], +) + + +WEBAPPS_FPM_LISTEN = Setting('WEBAPPS_FPM_LISTEN', + '127.0.0.1:5%(app_id)04d', + help_text=("TCP socket example: 127.0.0.1:5%(app_id)04d
    " + "UDS example: /var/lib/php/sockets/%(user)s-%(app_name)s.sock
    " + "Merged TCP example: 127.0.0.1:%(php_version_int)02d%(account_id)03d
    " + "Merged UDS example: /var/lib/php/sockets/%(user)s-%(php_version)s.sock
    " + "Available fromat names: {}").format(', '.join(_php_names)), + validators=[Setting.string_format_validator(_php_names)], +) + + +WEBAPPS_FPM_DEFAULT_MAX_CHILDREN = Setting('WEBAPPS_FPM_DEFAULT_MAX_CHILDREN', + 3 +) + + +WEBAPPS_PHPFPM_POOL_PATH = Setting('WEBAPPS_PHPFPM_POOL_PATH', + '/etc/php/%(php_version_number)s/fpm/pool.d/%(user)s-%(app_name)s.conf', + help_text="Available fromat names: %s" % ', '.join(_php_names), + validators=[Setting.string_format_validator(_php_names)], +) + +WEBAPPS_PHPFPM_RELOAD_POOL = Setting('WEBAPPS_PHPFPM_RELOAD_POOL', + 'service php5-fpm reload' +) + + +WEBAPPS_FCGID_WRAPPER_PATH = Setting('WEBAPPS_FCGID_WRAPPER_PATH', + '/home/httpd/fcgi-bin.d/%(user)s/%(app_name)s-wrapper', + validators=[Setting.string_format_validator(_php_names)], + help_text=("Inside SuExec Document root.
    " + "Make sure all account wrappers are in the same DIR.
    " + "Available fromat names: %s") % ', '.join(_php_names), +) + + +WEBAPPS_FCGID_CMD_OPTIONS_PATH = Setting('WEBAPPS_FCGID_CMD_OPTIONS_PATH', + '/etc/apache2/fcgid-conf/%(user)s-%(app_name)s.conf', + validators=[Setting.string_format_validator(_php_names)], + help_text="Loaded by Apache. Available fromat names: %s" % ', '.join(_php_names), +) + + +WEBAPPS_PHP_MAX_REQUESTS = Setting('WEBAPPS_PHP_MAX_REQUESTS', + 400, + help_text='Greater or equal to your FcgidMaxRequestsPerProcess' +) + + +WEBAPPS_PHP_ERROR_LOG_PATH = Setting('WEBAPPS_PHP_ERROR_LOG_PATH', + '' +) + + +WEBAPPS_MERGE_PHP_WEBAPPS = Setting('WEBAPPS_MERGE_PHP_WEBAPPS', + False, + help_text=("Combine all fcgid-wrappers/fpm-pools into one per account-php_version " + "to better control num processes per account and save memory") +) + +WEBAPPS_TYPES = Setting('WEBAPPS_TYPES', ( + 'orchestra.contrib.webapps.types.php.PHPApp', + 'orchestra.contrib.webapps.types.misc.StaticApp', + 'orchestra.contrib.webapps.types.misc.WebalizerApp', + 'orchestra.contrib.webapps.types.misc.SymbolicLinkApp', + 'orchestra.contrib.webapps.types.wordpress.WordPressApp', + 'orchestra.contrib.webapps.types.moodle.MoodleApp', + 'orchestra.contrib.webapps.types.python.PythonApp', + ), + # lazy loading + choices=lambda : ((t.get_class_path(), t.get_class_path()) for t in webapps.types.AppType.get_plugins(all=True)), + multiple=True, +) + + +WEBAPPS_PHP_VERSIONS = Setting('WEBAPPS_PHP_VERSIONS', ( + ('5.6-fpm', 'PHP 5.6 FPM'), + ('5.6-cgi', 'PHP 5.6 FCGID'), + ('5.4-fpm', 'PHP 5.4 FPM'), + ('5.4-cgi', 'PHP 5.4 FCGID'), + ('5.3-cgi', 'PHP 5.3 FCGID'), + ('5.2-cgi', 'PHP 5.2 FCGID'), + ('4-cgi', 'PHP 4 FCGID'), + ('7.0-fpm', 'PHP 7 FPM'), + ('7.3-fpm', 'PHP 7.3 FPM'), + ('7.4-fpm', 'PHP 7.4 FPM (web-11)'), + ('8.1-fpm', 'PHP 8.1 FPM (web-12)'), + ('8.2-fpm', 'PHP 8.2 FPM (web-12)'), + ), + help_text="Execution modle choose by ending -fpm or -cgi.", + validators=[Setting.validate_choices] +) + +WEBAPPS_PHP_VERSIONS_SERVERS = Setting('WEBAPPS_PHP_VERSIONS_SERVERS', { + 'web.pangea.lan' : ('php5.6-fpm', '7.0-fpm',), + 'web-ng' : ('5.6-fpm', '7.0-fpm', '7.3-fpm',), + 'web-11.pangea.lan': ('7.4-fpm',), + 'web-12.pangea.lan' : ('8.1-fpm', '8.2-fpm'), + }, + help_text="PHP available for each server", +) + + +WEBAPPS_DEFAULT_PHP_VERSION = Setting('WEBAPPS_DEFAULT_PHP_VERSION', + '5.6-fpm', + choices=WEBAPPS_PHP_VERSIONS +) + + +WEBAPPS_PHP_CGI_BINARY_PATH = Setting('WEBAPPS_PHP_CGI_BINARY_PATH', + '/usr/bin/php%(php_version_number)s-cgi', + help_text="Path of the cgi binary used by fcgid. Available fromat names: %s" % ', '.join(_php_names), + validators=[Setting.string_format_validator(_php_names)], +) + + +WEBAPPS_PHP_CGI_RC_DIR = Setting('WEBAPPS_PHP_CGI_RC_DIR', + '/etc/php%(php_version_number)s/cgi/', + help_text="Path to php.ini. Available fromat names: %s" % ', '.join(_php_names), + validators=[Setting.string_format_validator(_php_names)], +) + + +WEBAPPS_PHP_CGI_INI_SCAN_DIR = Setting('WEBAPPS_PHP_CGI_INI_SCAN_DIR', + '/etc/php%(php_version_number)s/cgi/conf.d', + help_text="Available fromat names: %s" % ', '.join(_php_names), + validators=[Setting.string_format_validator(_php_names)], +) + + +WEBAPPS_PYTHON_VERSIONS = Setting('WEBAPPS_PYTHON_VERSIONS', + ( + ('3.4-uwsgi', 'Python 3.4 uWSGI'), + ('2.7-uwsgi', 'Python 2.7 uWSGI'), + ), + validators=[Setting.validate_choices] +) + + +WEBAPPS_DEFAULT_PYTHON_VERSION = Setting('WEBAPPS_DEFAULT_PYTHON_VERSION', + '3.4-uwsgi', + choices=WEBAPPS_PYTHON_VERSIONS + +) + + +WEBAPPS_UWSGI_SOCKET = Setting('WEBAPPS_UWSGI_SOCKET', + '/var/run/uwsgi/app/%(app_name)s/socket', + help_text="Available fromat names: %s" % ', '.join(_python_names), + validators=[Setting.string_format_validator(_python_names)], +) + + +WEBAPPS_UWSGI_BASE_DIR = Setting('WEBAPPS_UWSGI_BASE_DIR', + '/etc/uwsgi/' +) + + +WEBAPPS_PYTHON_MAX_REQUESTS = Setting('WEBAPPS_PYTHON_MAX_REQUESTS', + 500 +) + + +WEBAPPS_PYTHON_DEFAULT_MAX_WORKERS = Setting('WEBAPPS_PYTHON_DEFAULT_MAX_WORKERS', + 3 +) + + +WEBAPPS_PYTHON_DEFAULT_TIMEOUT = Setting('WEBAPPS_PYTHON_DEFAULT_TIMEOUT', + 30 +) + + +WEBAPPS_UNDER_CONSTRUCTION_PATH = Setting('WEBAPPS_UNDER_CONSTRUCTION_PATH', '', + help_text=("Server-side path where a under construction stock page is " + "'/var/www/undercontruction/index.html'") +) + + +#WEBAPPS_TYPES_OVERRIDE = getattr(settings, 'WEBAPPS_TYPES_OVERRIDE', {}) +#for webapp_type, value in WEBAPPS_TYPES_OVERRIDE.items(): +# if value is None: +# WEBAPPS_TYPES.pop(webapp_type, None) +# else: +# WEBAPPS_TYPES[webapp_type] = value + + +WEBAPPS_PHP_DISABLED_FUNCTIONS = Setting('WEBAPPS_PHP_DISABLED_FUNCTION', ( + 'exec', + 'passthru', + 'shell_exec', + 'system', + 'proc_open', + 'popen', + 'curl_multi_exec', + 'show_source', + 'pcntl_exec', + 'proc_close', + 'proc_get_status', + 'proc_nice', + 'proc_terminate', + 'ini_alter', + 'virtual', + 'openlog', + 'escapeshellcmd', + 'escapeshellarg', + 'dl', + 'fsockopen', + 'pfsockopen', + 'stream_socket_client', + # Used for spamming + 'getmxrr', + # Used in some php shells + 'str_rot13', +)) + + +WEBAPPS_ENABLED_OPTIONS = Setting('WEBAPPS_ENABLED_OPTIONS', ( + 'orchestra.contrib.webapps.options.PublicRoot', + 'orchestra.contrib.webapps.options.Timeout', + 'orchestra.contrib.webapps.options.Processes', + 'orchestra.contrib.webapps.options.PHPEnableFunctions', + 'orchestra.contrib.webapps.options.PHPDisableFunctions', + 'orchestra.contrib.webapps.options.PHPAllowURLInclude', + 'orchestra.contrib.webapps.options.PHPAllowURLFopen', + 'orchestra.contrib.webapps.options.PHPAutoAppendFile', + 'orchestra.contrib.webapps.options.PHPAutoPrependFile', + 'orchestra.contrib.webapps.options.PHPDateTimeZone', + 'orchestra.contrib.webapps.options.PHPDefaultSocketTimeout', + 'orchestra.contrib.webapps.options.PHPDisplayErrors', + 'orchestra.contrib.webapps.options.PHPExtension', + 'orchestra.contrib.webapps.options.PHPIncludePath', + 'orchestra.contrib.webapps.options.PHPOpenBasedir', + 'orchestra.contrib.webapps.options.PHPMagicQuotesGPC', + 'orchestra.contrib.webapps.options.PHPMagicQuotesRuntime', + 'orchestra.contrib.webapps.options.PHPMaginQuotesSybase', + 'orchestra.contrib.webapps.options.PHPMaxInputTime', + 'orchestra.contrib.webapps.options.PHPMaxInputVars', + 'orchestra.contrib.webapps.options.PHPMemoryLimit', + 'orchestra.contrib.webapps.options.PHPMySQLConnectTimeout', + 'orchestra.contrib.webapps.options.PHPOutputBuffering', + 'orchestra.contrib.webapps.options.PHPRegisterGlobals', + 'orchestra.contrib.webapps.options.PHPPostMaxSize', + 'orchestra.contrib.webapps.options.PHPSendmailPath', + 'orchestra.contrib.webapps.options.PHPSessionBugCompatWarn', + 'orchestra.contrib.webapps.options.PHPSessionAutoStart', + 'orchestra.contrib.webapps.options.PHPSafeMode', + 'orchestra.contrib.webapps.options.PHPSuhosinPostMaxVars', + 'orchestra.contrib.webapps.options.PHPSuhosinGetMaxVars', + 'orchestra.contrib.webapps.options.PHPSuhosinRequestMaxVars', + 'orchestra.contrib.webapps.options.PHPSuhosinSessionEncrypt', + 'orchestra.contrib.webapps.options.PHPSuhosinSimulation', + 'orchestra.contrib.webapps.options.PHPSuhosinExecutorIncludeWhitelist', + 'orchestra.contrib.webapps.options.PHPUploadMaxFileSize', + 'orchestra.contrib.webapps.options.PHPUploadTmpDir', + 'orchestra.contrib.webapps.options.PHPZendExtension', + ), + # lazy loading + choices=lambda : ((o.get_class_path(), o.get_class_path()) for o in webapps.options.AppOption.get_plugins(all=True)), + multiple=True, +) + + +WEBAPPS_DEFAULT_MYSQL_DATABASE_HOST = Setting('WEBAPPS_DEFAULT_MYSQL_DATABASE_HOST', + 'mysql.{}'.format(ORCHESTRA_BASE_DOMAIN), + help_text="Uses ORCHESTRA_BASE_DOMAIN by default.", +) + + +WEBAPPS_MOVE_ON_DELETE_PATH = Setting('WEBAPPS_MOVE_ON_DELETE_PATH', + '' +) + + + +WEBAPPS_CMS_CACHE_DIR = Setting('WEBAPPS_CMS_CACHE_DIR', + '/tmp/orchestra_cms_cache', + help_text="Server-side cache directori for CMS tarballs.", +) + diff --git a/orchestra/contrib/webapps/signals.py b/orchestra/contrib/webapps/signals.py new file mode 100644 index 0000000..9bffd7d --- /dev/null +++ b/orchestra/contrib/webapps/signals.py @@ -0,0 +1,25 @@ +from django.db.models.signals import pre_save, pre_delete +from django.dispatch import receiver + +from .models import WebApp + +# Admin bulk deletion doesn't call model.delete() +# So, signals are used instead of model method overriding + +@receiver(pre_save, sender=WebApp, dispatch_uid='webapps.type.save') +def type_save(sender, *args, **kwargs): + instance = kwargs['instance'] + # Since a webapp might need to cleanup its old config files, the data + # from the OLD VERSION of the webapp is needed. + if instance.pk: + instance._old_self = type(instance).objects.get(id=instance.pk) + instance.type_instance.save() + +@receiver(pre_delete, sender=WebApp, dispatch_uid='webapps.type.delete') +def type_delete(sender, *args, **kwargs): + instance = kwargs['instance'] + instance._old_self = type(instance).objects.get(id=instance.pk) + try: + instance.type_instance.delete() + except KeyError: + pass diff --git a/orchestra/contrib/webapps/tests/__init__.py b/orchestra/contrib/webapps/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestra/contrib/webapps/tests/functional_tests/__init__.py b/orchestra/contrib/webapps/tests/functional_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestra/contrib/webapps/tests/functional_tests/tests.py b/orchestra/contrib/webapps/tests/functional_tests/tests.py new file mode 100644 index 0000000..72536f1 --- /dev/null +++ b/orchestra/contrib/webapps/tests/functional_tests/tests.py @@ -0,0 +1,130 @@ +import ftplib +import os +import unittest +from io import StringIO + +from django.conf import settings as djsettings +from orchestra.contrib.orchestration.models import Route, Server +from orchestra.contrib.systemusers.backends import UNIXUserController +from orchestra.utils.tests import BaseLiveServerTestCase, random_ascii, save_response_on_error, snapshot_on_error + +from ... import backends + + +TEST_REST_API = int(os.getenv('TEST_REST_API', '0')) + + +class WebAppMixin(object): + MASTER_SERVER = os.environ.get('ORCHESTRA_MASTER_SERVER', 'localhost') + DEPENDENCIES = ( + 'orchestra.contrib.orchestration', + 'orchestra.contrib.systemusers', + 'orchestra.contrib.webapps', + ) + + def setUp(self): + super(WebAppMixin, self).setUp() + self.add_route() + djsettings.DEBUG = True + + def add_route(self): + server, __ = Server.objects.get_or_create(name=self.MASTER_SERVER) + backend = UNIXUserController.get_name() + Route.objects.get_or_create(backend=backend, match=True, host=server) + backend = self.backend.get_name() + match = 'webapp.type == "%s"' % self.type_value + Route.objects.create(backend=backend, match=match, host=server) + + def upload_webapp(self, name): + try: + ftp = ftplib.FTP(self.MASTER_SERVER) + ftp.login(user=self.account.username, passwd=self.account_password) + ftp.cwd('webapps/%s' % name) + index = StringIO() + index.write(self.page[1]) + index.seek(0) + ftp.storbinary('STOR %s' % self.page[0], index) + index.close() + finally: + ftp.close() + + def test_add(self): + name = '%s_%s_webapp' % (random_ascii(10), self.type_value) + self.add_webapp(name) + self.addCleanup(self.delete_webapp, name) + self.upload_webapp(name) + + +class StaticWebAppMixin(object): + backend = backends.static.StaticController + type_value = 'static' + token = random_ascii(100) + page = ( + 'index.html', + 'Hello World! %s \n' % token, + 'Hello World! %s \n' % token, + ) + + +class PHPFPMWebAppMixin(StaticWebAppMixin): + backend = backends.php.PHPController + type_value = 'php5.5' + token = random_ascii(100) + page = ( + 'index.php', + '\n' % token, + 'Hello World! %s' % token, + ) + + +@unittest.skipUnless(TEST_REST_API, "REST API tests") +class RESTWebAppMixin(object): + def setUp(self): + super(RESTWebAppMixin, self).setUp() + self.rest_login() + # create main user + self.save_systemuser() + + @save_response_on_error + def save_systemuser(self): + systemuser = self.rest.systemusers.retrieve().get() + systemuser.update(is_active=True) + + @save_response_on_error + def add_webapp(self, name, options=[]): + self.rest.webapps.create(name=name, type=self.type_value, options=options) + + @save_response_on_error + def delete_webapp(self, name): + self.rest.webapps.retrieve(name=name).delete() + + +class AdminWebAppMixin(WebAppMixin): + def setUp(self): + super(AdminWebAppMixin, self).setUp() + self.admin_login() + # create main user + self.save_systemuser() + + @snapshot_on_error + def save_systemuser(self): + url = '' + + @snapshot_on_error + def add(self, name, password, admin_email): + pass + + +class StaticRESTWebAppTest(StaticWebAppMixin, RESTWebAppMixin, WebAppMixin, BaseLiveServerTestCase): + pass + + +class PHPFPMRESTWebAppTest(PHPFPMWebAppMixin, RESTWebAppMixin, WebAppMixin, BaseLiveServerTestCase): + pass + + +#class AdminWebAppTest(AdminWebAppMixin, BaseLiveServerTestCase): +# pass + + + diff --git a/orchestra/contrib/webapps/types/__init__.py b/orchestra/contrib/webapps/types/__init__.py new file mode 100644 index 0000000..eb1b7d1 --- /dev/null +++ b/orchestra/contrib/webapps/types/__init__.py @@ -0,0 +1,97 @@ +import importlib +import os +from functools import lru_cache + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +from orchestra import plugins +from orchestra.plugins.forms import PluginDataForm +from orchestra.utils.python import import_class + +from .. import settings +from ..options import AppOption + + +class AppType(plugins.Plugin, metaclass=plugins.PluginMount): + name = None + verbose_name = "" + help_text= "" + form = PluginDataForm + icon = 'orchestra/icons/apps.png' + unique_name = False + option_groups = (AppOption.FILESYSTEM, AppOption.PROCESS, AppOption.PHP) + plugin_field = 'type' + # TODO generic name like 'execution' ? + + @classmethod + @lru_cache() + def get_plugins(cls, all=False): + if all: + for module in os.listdir(os.path.dirname(__file__)): + if module != '__init__.py' and module[-3:] == '.py': + importlib.import_module('.'+module[:-3], __package__) + plugins = super().get_plugins() + else: + plugins = [] + for cls in settings.WEBAPPS_TYPES: + plugins.append(import_class(cls)) + return plugins + + def validate(self): + """ Unique name validation """ + if self.unique_name: + if not self.instance.pk and type(self.instance).objects.filter(name=self.instance.name, type=self.instance.type).exists(): + raise ValidationError({ + 'name': _("A WordPress blog with this name already exists."), + }) + + @classmethod + @lru_cache() + def get_group_options(cls): + """ Get enabled options based on cls.option_groups """ + groups = AppOption.get_option_groups() + options = [] + for group in cls.option_groups: + group_options = groups[group] + if group is None: + options.insert(0, (group, group_options)) + else: + options.append((group, group_options)) + return options + + @classmethod + def get_group_options_choices(cls): + """ Generates grouped choices ready to use in Field.choices """ + # generators can not be @lru_cache + yield (None, '-------') + for group, options in cls.get_group_options(): + if group is None: + for option in options: + yield (option.name, option.verbose_name) + else: + yield (group, [(op.name, op.verbose_name) for op in options]) + + @classmethod + def get_detail_lookups(cls): + """ {'field_name': (('opt1', _("Option 1"),)} """ + return {} + + def get_detail(self): + return '' + + def save(self): + pass + + def delete(self): + pass + + def get_directive_context(self): + return { + 'app_id': self.instance.id, + 'app_name': self.instance.name, + 'user': self.instance.get_username(), + 'user_id': self.instance.account.main_systemuser_id, + 'home': self.instance.account.main_systemuser.get_home(), + 'account_id': self.instance.account_id, + } diff --git a/orchestra/contrib/webapps/types/cms.py b/orchestra/contrib/webapps/types/cms.py new file mode 100644 index 0000000..7707fdc --- /dev/null +++ b/orchestra/contrib/webapps/types/cms.py @@ -0,0 +1,114 @@ +from django import forms +from django.core.exceptions import ValidationError +from django.urls import reverse +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from orchestra.contrib.databases.models import Database, DatabaseUser +from orchestra.contrib.orchestration.models import Server +from orchestra.forms.widgets import SpanWidget +from orchestra.utils.python import random_ascii +from orchestra.settings import NEW_SERVERS + +from .php import PHPApp, PHPAppForm, PHPAppSerializer +from .. import settings + + +class CMSAppForm(PHPAppForm): + db_name = forms.CharField(label=_("Database name"), + help_text=_("Database exclusively used for this webapp.")) + db_user = forms.CharField(label=_("Database user"), + help_text=_("Database user exclusively used for this webapp.")) + password = forms.CharField(label=_("Password"), + help_text=_("Initial database and WordPress admin password.
    " + "Subsequent changes to the admin password will not be reflected.")) + + def __init__(self, *args, **kwargs): + super(CMSAppForm, self).__init__(*args, **kwargs) + if self.instance: + data = self.instance.data + # DB link + db_name = data.get('db_name') + db_id = data.get('db_id') + db_url = reverse('admin:databases_database_change', args=(db_id,)) + db_link = mark_safe('%s' % (db_url, db_name)) + self.fields['db_name'].widget = SpanWidget(original=db_name, display=db_link) + # DB user link + db_user = data.get('db_user') + db_user_id = data.get('db_user_id') + db_user_url = reverse('admin:databases_databaseuser_change', args=(db_user_id,)) + db_user_link = mark_safe('%s' % (db_user_url, db_user)) + self.fields['db_user'].widget = SpanWidget(original=db_user, display=db_user_link) + + +class CMSAppSerializer(PHPAppSerializer): + db_name = serializers.CharField(label=_("Database name"), required=False) + db_user = serializers.CharField(label=_("Database user"), required=False) + password = serializers.CharField(label=_("Password"), required=False) + db_id = serializers.IntegerField(label=_("Database ID"), required=False) + db_user_id = serializers.IntegerField(label=_("Database user ID"), required=False) + + +class CMSApp(PHPApp): + """ Abstract AppType with common CMS functionality """ + serializer = CMSAppSerializer + change_form = CMSAppForm + change_readonly_fields = ('db_name', 'db_user', 'password',) + db_type = Database.MYSQL + abstract = True + db_prefix = 'cms_' + + def get_db_name(self): + db_name = '%s%s_%s' % (self.db_prefix, self.instance.name, self.instance.account) + # Limit for mysql database names + return db_name[:65] + + def get_db_user(self): + db_name = self.get_db_name() + # Limit for mysql user names + return db_name[:16] + + def get_password(self): + return random_ascii(10) + + def get_server(self): + server = self.instance.target_server + return server + + def validate(self): + super(CMSApp, self).validate() + create = not self.instance.pk + if create: + default_server_mysql = Server.objects.get(name=settings.WEBAPPS_DEFAULT_MYSQL_DATABASE_HOST) + server = self.get_server() if self.get_server().name in NEW_SERVERS else default_server_mysql + db = Database(name=self.get_db_name(), account=self.instance.account, type=self.db_type, target_server=server) + user = DatabaseUser(username=self.get_db_user(), password=self.get_password(), + account=self.instance.account, type=self.db_type, target_server=server) + for obj in (db, user): + try: + obj.full_clean() + except ValidationError as e: + raise ValidationError({ + 'name': e.messages, + }) + + def save(self): + db_name = self.get_db_name() + db_user = self.get_db_user() + password = self.get_password() + default_server_mysql = Server.objects.get(name=settings.WEBAPPS_DEFAULT_MYSQL_DATABASE_HOST) + server = self.get_server() if self.get_server().name in NEW_SERVERS else default_server_mysql + db, db_created = self.instance.account.databases.get_or_create(name=db_name, type=self.db_type, target_server=server) + if db_created: + user = DatabaseUser(username=db_user, account=self.instance.account, type=self.db_type, target_server=server) + user.set_password(password) + user.save() + db.users.add(user) + self.instance.data.update({ + 'db_name': db_name, + 'db_user': db_user, + 'password': password, + 'db_id': db.id, + 'db_user_id': user.id, + }) diff --git a/orchestra/contrib/webapps/types/misc.py b/orchestra/contrib/webapps/types/misc.py new file mode 100644 index 0000000..92fd1fa --- /dev/null +++ b/orchestra/contrib/webapps/types/misc.py @@ -0,0 +1,57 @@ +import os + +from django import forms +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers +from orchestra.plugins.forms import ExtendedPluginDataForm + +from ..options import AppOption +from . import AppType +from .php import PHPApp, PHPAppForm, PHPAppSerializer + + +class StaticApp(AppType): + name = 'static' + verbose_name = "Static" + help_text = _("This creates a Static application under ~/webapps/<app_name>
    " + "Apache2 will be used to serve static content and execute CGI files.") + icon = 'orchestra/icons/apps/Static.png' + option_groups = (AppOption.FILESYSTEM,) + form = ExtendedPluginDataForm + + def get_directive(self): + return ('static', self.instance.get_path()) + + +class WebalizerApp(AppType): + name = 'webalizer' + verbose_name = "Webalizer" + directive = ('static', '%(app_path)s%(site_name)s') + help_text = _( + "This creates a Webalizer application under ~/webapps/<app_name>-<site_name>
    " + "Statistics will be collected once this app is mounted into one or more Websites.") + icon = 'orchestra/icons/apps/Stats.png' + option_groups = () + + def get_directive(self): + webalizer_path = os.path.join(self.instance.get_path(), '%(site_name)s') + webalizer_path = os.path.normpath(webalizer_path) + return ('static', webalizer_path) + + +class SymbolicLinkForm(PHPAppForm): + path = forms.CharField(label=_("Path"), widget=forms.TextInput(attrs={'size':'100'}), + help_text=_("Path for the origin of the symbolic link.")) + + +class SymbolicLinkSerializer(PHPAppSerializer): + path = serializers.CharField(label=_("Path")) + + +class SymbolicLinkApp(PHPApp): + name = 'symbolic-link' + verbose_name = "Symbolic link" + form = SymbolicLinkForm + serializer = SymbolicLinkSerializer + icon = 'orchestra/icons/apps/SymbolicLink.png' + change_readonly_fields = ('path',) diff --git a/orchestra/contrib/webapps/types/moodle.py b/orchestra/contrib/webapps/types/moodle.py new file mode 100644 index 0000000..aef8bbc --- /dev/null +++ b/orchestra/contrib/webapps/types/moodle.py @@ -0,0 +1,19 @@ +from django.utils.translation import gettext_lazy as _ + +from .cms import CMSApp + + +class MoodleApp(CMSApp): + name = 'moodle-php' + verbose_name = "Moodle" + help_text = _( + "This installs the latest version of Moodle into the webapp directory.
    " + "A database and database user will automatically be created for this webapp.
    " + "This installer creates a user 'admin' with a randomly generated password.
    " + "The password will be visible in the 'password' field after the installer has finished." + ) + icon = 'orchestra/icons/apps/Moodle.png' + db_prefix = 'modl_' + + def get_detail(self): + return self.instance.data.get('php_version', '') diff --git a/orchestra/contrib/webapps/types/php.py b/orchestra/contrib/webapps/types/php.py new file mode 100644 index 0000000..3b156a2 --- /dev/null +++ b/orchestra/contrib/webapps/types/php.py @@ -0,0 +1,176 @@ +import os +from collections import OrderedDict + +from django import forms +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from orchestra.plugins.forms import PluginDataForm, ExtendedPluginDataForm +from orchestra.utils.functional import cached +from orchestra.utils.python import OrderedSet, random_ascii +from orchestra.settings import NEW_SERVERS + +from .. import settings, utils +from ..options import AppOption + +from . import AppType + + +help_message = _("Version of PHP used to execute this webapp.
    " + "Changing the PHP version may result in application malfunction, " + "make sure that everything continue to work as expected.") + +class PHPAppForm(ExtendedPluginDataForm): + php_version = forms.ChoiceField(label=_("PHP version"), + choices=settings.WEBAPPS_PHP_VERSIONS, + initial=settings.WEBAPPS_DEFAULT_PHP_VERSION, + help_text=help_message) + + def clean_php_version(self): + # valida que la version PHP este asignada al servidor + php_version = self.cleaned_data.get('php_version') + if not self.instance.id: + webapp_server = self.cleaned_data.get("target_server") + else: + webapp_server = self.instance.target_server + + if webapp_server is None: + pass + else: + if php_version not in settings.WEBAPPS_PHP_VERSIONS_SERVERS[webapp_server.name]: + self.add_error("php_version", _(f"Server {webapp_server.name} not allow {php_version}")) + else: + return php_version + + + +class PHPAppSerializer(serializers.Serializer): + php_version = serializers.ChoiceField(label=_("PHP version"), + choices=settings.WEBAPPS_PHP_VERSIONS, + default=settings.WEBAPPS_DEFAULT_PHP_VERSION, + help_text=help_message) + + +class PHPApp(AppType): + name = 'php' + verbose_name = "PHP" + help_text = _("This creates a PHP application under ~/webapps/<app_name>
    ") + form = PHPAppForm + serializer = PHPAppSerializer + icon = 'orchestra/icons/apps/PHP.png' + + DEFAULT_PHP_VERSION = settings.WEBAPPS_DEFAULT_PHP_VERSION + PHP_DISABLED_FUNCTIONS = settings.WEBAPPS_PHP_DISABLED_FUNCTIONS + PHP_ERROR_LOG_PATH = settings.WEBAPPS_PHP_ERROR_LOG_PATH + FPM_LISTEN = settings.WEBAPPS_FPM_LISTEN + FCGID_WRAPPER_PATH = settings.WEBAPPS_FCGID_WRAPPER_PATH + + @property + def is_fpm(self): + return self.get_php_version().endswith('-fpm') + + @property + def is_fcgid(self): + return self.get_php_version().endswith('-cgi') + + def get_detail(self): + return self.instance.data.get('php_version', '') + + @classmethod + def get_detail_lookups(cls): + return { + 'php_version': settings.WEBAPPS_PHP_VERSIONS, + } + + @cached + def get_options(self, merge=settings.WEBAPPS_MERGE_PHP_WEBAPPS): + """ adapter to webapp.get_options that performs merging of PHP options """ + kwargs = { + 'webapp_id': self.instance.pk, + } + if merge: + php_version = self.instance.data.get('php_version', self.DEFAULT_PHP_VERSION) + kwargs = { + # webapp__type is not used because wordpress != php != symlink... + 'webapp__account': self.instance.account_id, + 'webapp__data__contains': '"php_version":"%s"' % php_version, + } + return self.instance.get_options(**kwargs) + + def get_php_init_vars(self, merge=settings.WEBAPPS_MERGE_PHP_WEBAPPS): + """ Prepares PHP options for inclusion on php.ini """ + init_vars = OrderedDict() + options = self.get_options(merge=merge) + php_version_number = float(self.get_php_version_number()) + timeout = None + for name, value in options.items(): + if name == 'timeout': + timeout = value + else: + opt = AppOption.get(name) + # Filter non-deprecated PHP options + if opt.group == opt.PHP and (opt.deprecated or 999) > php_version_number: + init_vars[name] = value + # Disable functions + if self.PHP_DISABLED_FUNCTIONS: + enable_functions = init_vars.pop('enable_functions', None) + enable_functions = OrderedSet(enable_functions.split(',') if enable_functions else ()) + disable_functions = init_vars.pop('disable_functions', None) + disable_functions = OrderedSet(disable_functions.split(',') if disable_functions else ()) + if disable_functions or enable_functions or self.is_fpm: + # FPM: Defining 'disable_functions' or 'disable_classes' will not overwrite previously + # defined php.ini values, but will append the new value + for function in self.PHP_DISABLED_FUNCTIONS: + if function not in enable_functions: + disable_functions.add(function) + init_vars['disable_functions'] = ','.join(disable_functions) + # Process timeout + if timeout: + timeout = max(settings.WEBAPPS_PYTHON_DEFAULT_TIMEOUT, int(timeout)) + # Give a little slack here + timeout = str(timeout-2) + init_vars['max_execution_time'] = timeout + # Custom error log + if self.PHP_ERROR_LOG_PATH and 'error_log' not in init_vars: + context = self.get_directive_context() + error_log_path = os.path.normpath(self.PHP_ERROR_LOG_PATH % context) + init_vars['error_log'] = error_log_path + # Auto update max_post_size + if 'upload_max_filesize' in init_vars: + upload_max_filesize = init_vars['upload_max_filesize'] + post_max_size = init_vars.get('post_max_size', '0') + upload_max_filesize_value = eval(upload_max_filesize.replace('M', '*1024')) + post_max_size_value = eval(post_max_size.replace('M', '*1024')) + init_vars['post_max_size'] = post_max_size + if upload_max_filesize_value > post_max_size_value: + init_vars['post_max_size'] = upload_max_filesize + return init_vars + + def get_directive_context(self): + context = super(PHPApp, self).get_directive_context() + context.update({ + 'php_version': self.get_php_version(), + 'php_version_number': self.get_php_version_number(), + 'php_version_int': int(self.get_php_version_number().replace('.', '')), + }) + return context + + def get_directive(self): + context = self.get_directive_context() + if self.is_fpm: + socket = self.FPM_LISTEN % context + return ('fpm', socket, self.instance.get_path()) + elif self.is_fcgid: + wrapper_path = os.path.normpath(self.FCGID_WRAPPER_PATH % context) + return ('fcgid', self.instance.get_path(), wrapper_path) + else: + php_version = self.get_php_version() + raise ValueError("Unknown directive for php version '%s'" % php_version) + + def get_php_version(self): + default_version = self.DEFAULT_PHP_VERSION + return self.instance.data.get('php_version', default_version) + + def get_php_version_number(self): + php_version = self.get_php_version() + return utils.extract_version_number(php_version) diff --git a/orchestra/contrib/webapps/types/python.py b/orchestra/contrib/webapps/types/python.py new file mode 100644 index 0000000..22ff029 --- /dev/null +++ b/orchestra/contrib/webapps/types/python.py @@ -0,0 +1,64 @@ +import re + +from django import forms +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from orchestra.plugins.forms import PluginDataForm + +from .. import settings +from ..options import AppOption + +from . import AppType + + +help_message = _("Version of Python used to execute this webapp.
    " + "Changing the Python version may result in application malfunction, " + "make sure that everything continue to work as expected.") + + +class PythonAppForm(PluginDataForm): + python_version = forms.ChoiceField(label=_("Python version"), + choices=settings.WEBAPPS_PYTHON_VERSIONS, + initial=settings.WEBAPPS_DEFAULT_PYTHON_VERSION, + help_text=help_message) + + +class PythonAppSerializer(serializers.Serializer): + python_version = serializers.ChoiceField(label=_("Python version"), + choices=settings.WEBAPPS_PYTHON_VERSIONS, + default=settings.WEBAPPS_DEFAULT_PYTHON_VERSION, + help_text=help_message) + + +class PythonApp(AppType): + name = 'python' + verbose_name = "Python" + help_text = _("This creates a Python application under ~/webapps/<app_name>
    ") + form = PythonAppForm + serializer = PythonAppSerializer + option_groups = (AppOption.FILESYSTEM, AppOption.PROCESS) + icon = 'orchestra/icons/apps/Python.png' + + @classmethod + def get_detail_lookups(cls): + return { + 'python_version': settings.WEBAPPS_PYTHON_VERSIONS, + } + + def get_directive(self): + context = self.get_directive_context() + return ('uwsgi', settings.WEBAPPS_UWSGI_SOCKET % context) + + def get_python_version(self): + default_version = self.DEFAULT_PYTHON_VERSION + return self.instance.data.get('python_version', default_version) + + def get_python_version_number(self): + python_version = self.get_python_version() + number = re.findall(r'[0-9]+\.?[0-9]?', python_version) + if not number: + raise ValueError("No version number matches for '%s'" % python_version) + if len(number) > 1: + raise ValueError("Multiple version number matches for '%s'" % python_version) + return number[0] diff --git a/orchestra/contrib/webapps/types/wordpress.py b/orchestra/contrib/webapps/types/wordpress.py new file mode 100644 index 0000000..926f7ac --- /dev/null +++ b/orchestra/contrib/webapps/types/wordpress.py @@ -0,0 +1,19 @@ +from django.utils.translation import gettext_lazy as _ + +from .cms import CMSApp + + +class WordPressApp(CMSApp): + name = 'wordpress-php' + verbose_name = "WordPress" + help_text = _( + "This installs the latest version of WordPress into the webapp directory.
    " + "A database and database user will automatically be created for this webapp.
    " + "This installer creates a user 'admin' with a randomly generated password.
    " + "The password will be visible in the 'password' field after the installer has finished." + ) + icon = 'orchestra/icons/apps/WordPress.png' + db_prefix = 'wp_' + + def get_detail(self): + return self.instance.data.get('php_version', '') diff --git a/orchestra/contrib/webapps/utils.py b/orchestra/contrib/webapps/utils.py new file mode 100644 index 0000000..35388ce --- /dev/null +++ b/orchestra/contrib/webapps/utils.py @@ -0,0 +1,10 @@ +import re + + +def extract_version_number(version): + number = re.findall(r'[0-9]+\.?[0-9]?', version) + if not number: + raise ValueError("No version number matches for '%s'" % version) + if len(number) > 1: + raise ValueError("Multiple version number matches for '%s'" % version) + return number[0] diff --git a/orchestra/contrib/websites/__init__.py b/orchestra/contrib/websites/__init__.py new file mode 100644 index 0000000..93cab2d --- /dev/null +++ b/orchestra/contrib/websites/__init__.py @@ -0,0 +1 @@ +default_app_config = 'orchestra.contrib.websites.apps.WebsitesConfig' diff --git a/orchestra/contrib/websites/admin.py b/orchestra/contrib/websites/admin.py new file mode 100644 index 0000000..6682c4c --- /dev/null +++ b/orchestra/contrib/websites/admin.py @@ -0,0 +1,139 @@ +from django import forms +from django.contrib import admin +from django.urls import resolve +from django.db.models import Q +from django.utils.encoding import force_str +from django.utils.html import format_html +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ + +from orchestra.admin import ExtendedModelAdmin +from orchestra.admin.actions import disable, enable +from orchestra.admin.utils import admin_link, change_url +from orchestra.contrib.accounts.actions import list_accounts +from orchestra.contrib.accounts.admin import AccountAdminMixin, SelectAccountAdminMixin +from orchestra.contrib.accounts.filters import IsActiveListFilter +from orchestra.forms.widgets import DynamicHelpTextSelect +from orchestra.utils.html import get_on_site_link + +from .directives import SiteDirective +from .filters import HasWebAppsListFilter, HasDomainsFilter +from .forms import WebsiteAdminForm, WebsiteDirectiveInlineFormSet +from .models import Content, Website, WebsiteDirective + + +class WebsiteDirectiveInline(admin.TabularInline): + model = WebsiteDirective + formset = WebsiteDirectiveInlineFormSet + extra = 1 + + DIRECTIVES_HELP_TEXT = { + op.name: force_str(op.help_text) for op in SiteDirective.get_plugins() + } + + def formfield_for_dbfield(self, db_field, **kwargs): + if db_field.name == 'value': + kwargs['widget'] = forms.TextInput(attrs={'size':'100'}) + if db_field.name == 'name': + # Help text based on select widget + target = 'this.id.replace("name", "value")' + kwargs['widget'] = DynamicHelpTextSelect(target, self.DIRECTIVES_HELP_TEXT) + return super(WebsiteDirectiveInline, self).formfield_for_dbfield(db_field, **kwargs) + + +class ContentInline(AccountAdminMixin, admin.TabularInline): + model = Content + extra = 1 + fields = ('webapp', 'webapp_link', 'webapp_type', 'path') + readonly_fields = ('webapp_link', 'webapp_type') + filter_by_account_fields = ['webapp'] + + webapp_link = admin_link('webapp', popup=True) + webapp_link.short_description = _("Web App") + + def webapp_type(self, content): + if not content.pk: + return '' + return content.webapp.get_type_display() + webapp_type.short_description = _("Web App type") + + +class WebsiteAdmin(SelectAccountAdminMixin, ExtendedModelAdmin): + list_display = ( + 'name', 'display_domains', 'display_webapps', 'account_link', 'target_server', 'display_active' + ) + list_filter = ( + 'protocol', IsActiveListFilter, HasWebAppsListFilter, HasDomainsFilter + ) + change_readonly_fields = ('name',) + inlines = (ContentInline, WebsiteDirectiveInline) + filter_horizontal = ['domains'] + fieldsets = ( + (None, { + 'classes': ('extrapretty',), + 'fields': ('account_link', 'name', 'protocol', 'target_server', 'domains', 'is_active', 'comments'), + }), + ) + form = WebsiteAdminForm + filter_by_account_fields = ['domains'] + list_prefetch_related = ('domains', 'content_set__webapp') + search_fields = ('name', 'account__username', 'domains__name', 'content__webapp__name') + actions = (disable, enable, list_accounts) + + @mark_safe + def display_domains(self, website): + domains = [] + for domain in website.domains.all(): + url = '%s://%s' % (website.get_protocol(), domain) + domains.append('%s' % (url, url)) + return '
    '.join(domains) + display_domains.short_description = _("domains") + display_domains.admin_order_field = 'domains' + + @mark_safe + def display_webapps(self, website): + webapps = [] + for content in website.content_set.all(): + site_link = get_on_site_link(content.get_absolute_url()) + webapp = content.webapp + detail = _("Edit Webapp") + ' ' + webapp.get_type_display() + try: + detail += ' ' + webapp.type_instance.get_detail() + except KeyError: + pass + url = change_url(webapp) + name = "%s on %s" % (webapp.name, content.path or '/') + webapp_info = format_html('{} {}', url, detail, name, site_link) + webapps.append(webapp_info) + return '
    '.join(webapps) + display_webapps.short_description = _("Web apps") + + def formfield_for_dbfield(self, db_field, **kwargs): + """ + Exclude domains with exhausted ports + has to be done here, on the form doesn't work because of filter_by_account_fields + """ + formfield = super(WebsiteAdmin, self).formfield_for_dbfield(db_field, **kwargs) + if db_field.name == 'domains': + qset = Q( + Q(websites__protocol=Website.HTTPS_ONLY) | + Q(websites__protocol=Website.HTTP_AND_HTTPS) | Q( + Q(websites__protocol=Website.HTTP) & Q(websites__protocol=Website.HTTPS) + ) + ) + object_id = kwargs['request'].resolver_match.kwargs.get('object_id') + if object_id: + qset = Q(qset & ~Q(websites__pk=object_id)) + formfield.queryset = formfield.queryset.exclude(qset) + return formfield + + def _create_formsets(self, request, obj, change): + """ bind contents formset to directive formset for unique location cross-validation """ + formsets, inline_instances = super(WebsiteAdmin, self)._create_formsets(request, obj, change) + if request.method == 'POST': + contents, directives = formsets + directives.content_formset = contents + return formsets, inline_instances + + +admin.site.register(Website, WebsiteAdmin) diff --git a/orchestra/contrib/websites/api.py b/orchestra/contrib/websites/api.py new file mode 100644 index 0000000..bf0d1fe --- /dev/null +++ b/orchestra/contrib/websites/api.py @@ -0,0 +1,25 @@ +from rest_framework import viewsets + +from orchestra.api import router, LogApiMixin +from orchestra.contrib.accounts.api import AccountApiMixin + +from . import settings +from .models import Website +from .serializers import WebsiteSerializer + + +class WebsiteViewSet(LogApiMixin, AccountApiMixin, viewsets.ModelViewSet): + queryset = Website.objects.prefetch_related('domains', 'content_set__webapp', 'directives').all() + serializer_class = WebsiteSerializer + filter_fields = ('name', 'domains__name') + + def options(self, request): + metadata = super(WebsiteViewSet, self).options(request) + names = ['WEBSITES_OPTIONS', 'WEBSITES_PORT_CHOICES'] + metadata.data['settings'] = { + name.lower(): getattr(settings, name, None) for name in names + } + return metadata + + +router.register(r'websites', WebsiteViewSet) diff --git a/orchestra/contrib/websites/apps.py b/orchestra/contrib/websites/apps.py new file mode 100644 index 0000000..4565dbe --- /dev/null +++ b/orchestra/contrib/websites/apps.py @@ -0,0 +1,19 @@ +from django.apps import AppConfig + +from orchestra.core import services +from orchestra.utils.db import database_ready + + +class WebsitesConfig(AppConfig): + name = 'orchestra.contrib.websites' + + def ready(self): + if database_ready(): +# from django.contrib.contenttypes.models import ContentType +# from .models import Content, Website +# qset = Content.content_type.field.get_limit_choices_to() +# for ct in ContentType.objects.filter(qset): +# relation = GenericRelation('websites.Content') +# ct.model_class().add_to_class('content_set', relation) + from .models import Website + services.register(Website, icon='Applications-internet.png') diff --git a/orchestra/contrib/websites/backends/__init__.py b/orchestra/contrib/websites/backends/__init__.py new file mode 100644 index 0000000..6e57f3a --- /dev/null +++ b/orchestra/contrib/websites/backends/__init__.py @@ -0,0 +1,5 @@ +import pkgutil + + +for __, module_name, __ in pkgutil.walk_packages(__path__): + exec('from . import %s' % module_name) diff --git a/orchestra/contrib/websites/backends/apache.py b/orchestra/contrib/websites/backends/apache.py new file mode 100644 index 0000000..40aafde --- /dev/null +++ b/orchestra/contrib/websites/backends/apache.py @@ -0,0 +1,503 @@ +import os +import re +import textwrap + +from django.template import Template, Context +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.orchestration import ServiceController +from orchestra.contrib.resources import ServiceMonitor + +from .. import settings +from ..utils import normurlpath + + +class Apache2Controller(ServiceController): + """ + Apache ≥2.4 backend with support for the following directives: + static, location, fpm, fcgid, uwsgi, \ + ssl, security, redirects, proxies, saas + """ + HTTP_PORT = 80 + HTTPS_PORT = 443 + + model = 'websites.Website' + related_models = ( + ('websites.Content', 'website'), + ('websites.WebsiteDirective', 'website'), + ('webapps.WebApp', 'website_set'), + ) + verbose_name = _("Apache 2") + doc_settings = (settings, ( + 'WEBSITES_VHOST_EXTRA_DIRECTIVES', + 'WEBSITES_DEFAULT_SSL_CERT', + 'WEBSITES_DEFAULT_SSL_KEY', + 'WEBSITES_DEFAULT_SSL_CA', + 'WEBSITES_BASE_APACHE_CONF', + 'WEBSITES_DEFAULT_IPS', + 'WEBSITES_SAAS_DIRECTIVES', + )) + + def get_extra_conf(self, site, context, ssl=False): + extra_conf = self.get_content_directives(site, context) + directives = site.get_directives() + if ssl: + extra_conf += self.get_ssl(directives) + extra_conf += self.get_security(directives) + extra_conf += self.get_redirects(directives) + extra_conf += self.get_proxies(directives) + extra_conf += self.get_saas(directives) + settings_context = site.get_settings_context() + for location, directive in settings.WEBSITES_VHOST_EXTRA_DIRECTIVES: + extra_conf.append((location, directive % settings_context)) + # Order extra conf directives based on directives (longer first) + extra_conf = sorted(extra_conf, key=lambda a: len(a[0]), reverse=True) + return '\n'.join([conf for location, conf in extra_conf]) + + def render_virtual_host(self, site, context, ssl=False): + context.update({ + 'port': self.HTTPS_PORT if ssl else self.HTTP_PORT, + 'vhost_set_fcgid': False, + 'server_alias_lines': ' \\\n '.join(context['server_alias']), + 'suexec_needed': site.target_server == 'web.pangea.lan' + }) + context['extra_conf'] = self.get_extra_conf(site, context, ssl) + return Template(textwrap.dedent("""\ + + IncludeOptional /etc/apache2/site[s]-override/{{ site_unique_name }}.con[f] + ServerName {{ server_name }}\ + {% if server_alias %} + ServerAlias {{ server_alias_lines }}{% endif %}\ + {% if access_log %} + CustomLog {{ access_log }} common{% endif %}\ + {% if error_log %} + ErrorLog {{ error_log }}{% endif %} + {% if suexec_needed %} + SuexecUserGroup {{ user }} {{ group }}{% endif %}\ + {% for line in extra_conf.splitlines %} + {{ line | safe }}{% endfor %} + + """) + ).render(Context(context)) + + def render_redirect_https(self, context): + context['port'] = self.HTTP_PORT + return Template(textwrap.dedent(""" + + ServerName {{ server_name }}\ + {% if server_alias %} + ServerAlias {{ server_alias|join:' ' }}{% endif %}\ + {% if access_log %} + CustomLog {{ access_log }} common{% endif %}\ + {% if error_log %} + ErrorLog {{ error_log }}{% endif %} + RewriteEngine On + RewriteCond %{HTTPS} off + RewriteRule (.*) https://%{HTTP_HOST}%{REQUEST_URI} + + """) + ).render(Context(context)) + + def save(self, site): + context = self.get_context(site) + if context['server_name']: + apache_conf = '# %(banner)s\n' % context + if site.protocol in (site.HTTP, site.HTTP_AND_HTTPS): + apache_conf += self.render_virtual_host(site, context, ssl=False) + if site.protocol in (site.HTTP_AND_HTTPS, site.HTTPS_ONLY, site.HTTPS): + apache_conf += self.render_virtual_host(site, context, ssl=True) + if site.protocol == site.HTTPS_ONLY: + apache_conf += self.render_redirect_https(context) + context['apache_conf'] = apache_conf.strip() + self.append(textwrap.dedent(""" + # Generate Apache config for site %(site_name)s + read -r -d '' apache_conf << 'EOF' || true + %(apache_conf)s + EOF + { + echo -e "${apache_conf}" | diff -N -I'^\s*#' %(sites_available)s - + } || { + echo -e "${apache_conf}" > %(sites_available)s + UPDATED_APACHE=1 + }""") % context + ) + if context['server_name'] and site.active: + self.append(textwrap.dedent(""" + # Enable site %(site_name)s + [[ $(a2ensite %(site_unique_name)s) =~ "already enabled" ]] || UPDATED_APACHE=1\ + """) % context + ) + else: + self.append(textwrap.dedent(""" + # Disable site %(site_name)s + [[ $(a2dissite %(site_unique_name)s) =~ "already disabled" ]] || UPDATED_APACHE=1\ + """) % context + ) + + def delete(self, site): + context = self.get_context(site) + self.append(textwrap.dedent(""" + # Remove site configuration for %(site_name)s + [[ $(a2dissite %(site_unique_name)s) =~ "already disabled" ]] || UPDATED_APACHE=1 + rm -f %(sites_available)s\ + """) % context + ) + + def prepare(self): + super(Apache2Controller, self).prepare() + # Coordinate apache restart with php backend in order not to overdo it + self.append(textwrap.dedent(""" + BACKEND="Apache2Controller" + echo "$BACKEND" >> /dev/shm/reload.apache2 + + function coordinate_apache_reload () { + # Coordinate Apache reload with other concurrent backends (e.g. PHPController) + is_last=0 + counter=0 + while ! mv /dev/shm/reload.apache2 /dev/shm/reload.apache2.locked; do + if [[ $counter -gt 4 ]]; then + echo "[ERROR]: Apache reload synchronization deadlocked!" >&2 + exit 10 + fi + counter=$(($counter+1)) + sleep 0.1; + done + state="$(grep -v -E "^$BACKEND($|\s)" /dev/shm/reload.apache2.locked)" || is_last=1 + [[ $is_last -eq 0 ]] && { + echo "$state" | grep -v ' RELOAD$' || is_last=1 + } + if [[ $is_last -eq 1 ]]; then + echo "[DEBUG]: Last backend to run, update: $UPDATED_APACHE, state: '$state'" + if [[ $UPDATED_APACHE -eq 1 || "$state" =~ .*RELOAD$ ]]; then + if service apache2 status > /dev/null; then + service apache2 reload + else + service apache2 start + fi + fi + rm /dev/shm/reload.apache2.locked + else + echo "$state" > /dev/shm/reload.apache2.locked + if [[ $UPDATED_APACHE -eq 1 ]]; then + echo -e "[DEBUG]: Apache will be reloaded by another backend:\\n${state}" + echo "$BACKEND RELOAD" >> /dev/shm/reload.apache2.locked + fi + mv /dev/shm/reload.apache2.locked /dev/shm/reload.apache2 + fi + }""") + ) + + def commit(self): + """ reload Apache2 if necessary """ + self.append("coordinate_apache_reload") + super(Apache2Controller, self).commit() + + def get_directives(self, directive, context): + method, args = directive[0], directive[1:] + try: + method = getattr(self, 'get_%s_directives' % method) + except AttributeError: + context = (self.__class__.__name__, method) + raise AttributeError("%s does not has suport for '%s' directive." % context) + return method(context, *args) + + def get_content_directives(self, site, context): + directives = [] + for content in site.content_set.all(): + directive = content.webapp.get_directive() + self.set_content_context(content, context) + directives += self.get_directives(directive, context) + return directives + + def get_static_directives(self, context, app_path): + context['app_path'] = os.path.normpath(app_path % context) + directive = self.get_location_filesystem_map(context) + return [ + (context['location'], directive), + ] + + def get_location_filesystem_map(self, context): + if not context['location']: + return 'DocumentRoot %(app_path)s' % context + return 'Alias %(location)s %(app_path)s' % context + + def get_fpm_directives(self, context, socket, app_path): + if ':' in socket: + # TCP socket + target = 'fcgi://%(socket)s%(app_path)s/$1' + else: + # UNIX socket + target = 'unix:%(socket)s|fcgi://127.0.0.1/' + context.update({ + 'app_path': os.path.normpath(app_path), + 'socket': socket, + }) + directives = textwrap.dedent(""" + + + SetHandler "proxy:unix:{socket}|fcgi://127.0.0.1" + + + """).format(socket=socket, app_path=app_path) + directives += self.get_location_filesystem_map(context) + return [ + (context['location'], directives), + ] + + def get_fcgid_directives(self, context, app_path, wrapper_path): + context.update({ + 'app_path': os.path.normpath(app_path), + 'wrapper_name': os.path.basename(wrapper_path), + }) + directives = '' + # This Action trick is used instead of FcgidWrapper because we don't want to define + # a new fcgid process class each time an app is mounted (num proc limits enforcement). + if not context['vhost_set_fcgid']: + # fcgi-bin only needs to be defined once per vhots + # We assume that all account wrapper paths will share the same dir + context['wrapper_dir'] = os.path.dirname(wrapper_path) + context['vhost_set_fcgid'] = True + directives = textwrap.dedent("""\ + Alias /fcgi-bin/ %(wrapper_dir)s/ + + SetHandler fcgid-script + Options +ExecCGI + + """) % context + directives += self.get_location_filesystem_map(context) + directives += textwrap.dedent(""" + ProxyPass %(location)s/ ! + + AddHandler php-fcgi .php + Action php-fcgi /fcgi-bin/%(wrapper_name)s + """) % context + return [ + (context['location'], directives), + ] + + def get_uwsgi_directives(self, context, socket): + # requires apache2 mod_proxy_uwsgi + context['socket'] = socket + directives = "ProxyPass / unix:%(socket)s|uwsgi://" % context + directives += self.get_location_filesystem_map(context) + return [ + (context['location'], directives), + ] + + def get_ssl(self, directives): + cert = directives.get('ssl-cert') + key = directives.get('ssl-key') + ca = directives.get('ssl-ca') + if not (cert and key): + cert = [settings.WEBSITES_DEFAULT_SSL_CERT] + key = [settings.WEBSITES_DEFAULT_SSL_KEY] + # Disabled because since the migration to LE, CA is not required here + #ca = [settings.WEBSITES_DEFAULT_SSL_CA] + if not (cert and key): + return [] + ssl_config = [ + "SSLEngine on", + "SSLCertificateFile %s" % cert[0], + "SSLCertificateKeyFile %s" % key[0], + ] + if ca: + ssl_config.append("SSLCACertificateFile %s" % ca[0]) + return [ + ('', '\n'.join(ssl_config)), + ] + + def get_security(self, directives): + rules = [] + location = '/' + for values in directives.get('sec-rule-remove', []): + for rule in values.split(): + rules.append('SecRuleRemoveById %i' % int(rule)) + for location in directives.get('sec-engine', []): + if location == '/': + rules.append('SecRuleEngine Off') + else: + rules.append(textwrap.dedent("""\ + + SecRuleEngine Off + """) % location + ) + security = [] + if rules: + rules = textwrap.dedent("""\ + + %s + """) % '\n '.join(rules) + security.append((location, rules)) + return security + + def get_redirects(self, directives): + redirects = [] + for redirect in directives.get('redirect', []): + location, target = redirect.split() + if re.match(r'^.*[\^\*\$\?\)]+.*$', redirect): + redirect = "RedirectMatch %s %s" % (location, target) + else: + redirect = "Redirect %s %s" % (location, target) + redirects.append( + (location, redirect) + ) + return redirects + + def get_proxies(self, directives): + proxies = [] + for proxy in directives.get('proxy', []): + proxy = proxy.split() + location = proxy[0] + target = proxy[1] + options = ' '.join(proxy[2:]) + location = normurlpath(location) + proxy = textwrap.dedent("""\ + ProxyPass {location}/ {target} {options} + ProxyPassReverse {location}/ {target}""".format( + location=location, target=target, options=options) + ) + proxies.append( + (location, proxy) + ) + return proxies + + def get_saas(self, directives): + saas = [] + for name, values in directives.items(): + if name.endswith('-saas'): + for value in values: + context = { + 'location': normurlpath(value), + } + directive = settings.WEBSITES_SAAS_DIRECTIVES[name] + saas += self.get_directives(directive, context) + return saas + + def get_username(self, site): + option = site.get_directives().get('user_group') + if option: + return option[0] + return site.get_username() + + def get_groupname(self, site): + option = site.get_directives().get('user_group') + if option and ' ' in option: + user, group = option.split() + return group + return site.get_groupname() + + def get_server_names(self, site): + server_name = None + server_alias = [] + for domain in site.domains.all().order_by('name'): + if not server_name and not domain.name.startswith('*'): + server_name = domain.name + else: + server_alias.append(domain.name) + return server_name, server_alias + + def get_context(self, site): + base_apache_conf = settings.WEBSITES_BASE_APACHE_CONF + sites_available = os.path.join(base_apache_conf, 'sites-available') + sites_enabled = os.path.join(base_apache_conf, 'sites-enabled') + server_name, server_alias = self.get_server_names(site) + context = { + 'site': site, + 'site_name': site.name, + 'ips': settings.WEBSITES_DEFAULT_IPS, + 'site_unique_name': site.unique_name, + 'user': self.get_username(site), + 'group': self.get_groupname(site), + 'server_name': server_name, + 'server_alias': server_alias, + 'sites_enabled': "%s.conf" % os.path.join(sites_enabled, site.unique_name), + 'sites_available': "%s.conf" % os.path.join(sites_available, site.unique_name), + 'access_log': site.get_www_access_log_path(), + 'error_log': site.get_www_error_log_path(), + 'banner': self.get_banner(), + } + if not context['ips']: + raise ValueError("WEBSITES_DEFAULT_IPS is empty.") + return context + + def set_content_context(self, content, context): + content_context = { + 'type': content.webapp.type, + 'location': normurlpath(content.path), + 'app_name': content.webapp.name, + 'app_path': content.webapp.get_path(), + } + context.update(content_context) + + +class Apache2Traffic(ServiceMonitor): + """ + Parses apache logs, + looking for the size of each request on the last word of the log line. + """ + model = 'websites.Website' + resource = ServiceMonitor.TRAFFIC + verbose_name = _("Apache 2 Traffic") + monthly_sum_old_values = True + doc_settings = (settings, + ('WEBSITES_TRAFFIC_IGNORE_HOSTS',) + ) + + def prepare(self): + super(Apache2Traffic, self).prepare() + ignore_hosts = '\\|'.join(settings.WEBSITES_TRAFFIC_IGNORE_HOSTS) + context = { + 'current_date': self.current_date.strftime("%Y-%m-%d %H:%M:%S %Z"), + 'ignore_hosts': '-v "%s"' % ignore_hosts if ignore_hosts else '', + } + self.append(textwrap.dedent("""\ + function monitor () { + OBJECT_ID=$1 + INI_DATE=$(date "+%%Y%%m%%d%%H%%M%%S" -d "$2") + END_DATE=$(date '+%%Y%%m%%d%%H%%M%%S' -d '%(current_date)s') + LOG_FILE="$3" + { + { grep %(ignore_hosts)s ${LOG_FILE} || echo -e '\\r'; } \\ + | awk -v ini="${INI_DATE}" -v end="${END_DATE}" ' + BEGIN { + sum = 0 + months["Jan"] = "01" + months["Feb"] = "02" + months["Mar"] = "03" + months["Apr"] = "04" + months["May"] = "05" + months["Jun"] = "06" + months["Jul"] = "07" + months["Aug"] = "08" + months["Sep"] = "09" + months["Oct"] = "10" + months["Nov"] = "11" + months["Dec"] = "12" + } { + # date = [11/Jul/2014:13:50:41 + date = substr($4, 2) + year = substr(date, 8, 4) + month = months[substr(date, 4, 3)]; + day = substr(date, 1, 2) + hour = substr(date, 13, 2) + minute = substr(date, 16, 2) + second = substr(date, 19, 2) + line_date = year month day hour minute second + if ( line_date > ini && line_date < end) + sum += $NF + } END { + print sum + }' || [[ $? == 1 ]] && true + } | xargs echo ${OBJECT_ID} + }""") % context) + + def monitor(self, site): + context = self.get_context(site) + self.append('monitor {object_id} "{last_date}" {log_file}'.format(**context)) + + def get_context(self, site): + return { + 'log_file': '%s{,.1}' % site.get_www_access_log_path(), + 'last_date': self.get_last_date(site.pk).strftime("%Y-%m-%d %H:%M:%S %Z"), + 'object_id': site.pk, + } diff --git a/orchestra/contrib/websites/backends/moodle.py b/orchestra/contrib/websites/backends/moodle.py new file mode 100644 index 0000000..b44ec5e --- /dev/null +++ b/orchestra/contrib/websites/backends/moodle.py @@ -0,0 +1,25 @@ +import textwrap + +from orchestra.contrib.orchestration import ServiceController + + +class MoodleWWWRootController(ServiceController): + """ + Configures Moodle site WWWRoot, without it Moodle refuses to work. + """ + verbose_name = "Moodle WWWRoot (required)" + model = 'websites.Content' + default_route_match = "content.webapp.type == 'moodle-php'" + + def save(self, content): + context = self.get_context(content) + self.append(textwrap.dedent("""\ + sed -i "s#wwwroot\s*= '.*#wwwroot = '%(url)s';#" %(app_path)s/config.php + """) % context + ) + + def get_context(self, content): + return { + 'url': content.get_absolute_url(), + 'app_path': content.webapp.get_path(), + } diff --git a/orchestra/contrib/websites/backends/webalizer.py b/orchestra/contrib/websites/backends/webalizer.py new file mode 100644 index 0000000..d08b88e --- /dev/null +++ b/orchestra/contrib/websites/backends/webalizer.py @@ -0,0 +1,130 @@ +import os +import textwrap + +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.orchestration import ServiceController +from orchestra.settings import NEW_SERVERS + +from .. import settings + + +class WebalizerController(ServiceController): + """ + Creates webalizer conf file for each time a webalizer webapp is mounted on a website. + """ + verbose_name = _("Webalizer Content") + model = 'websites.Content' + default_route_match = "content.webapp.type == 'webalizer'" + doc_settings = (settings, + ('WEBSITES_WEBALIZER_PATH',) + ) + + def save(self, content): + context = self.get_context(content) + self.append(textwrap.dedent("""\ + mkdir -p %(webalizer_path)s + if [[ ! -e %(webalizer_path)s/index.html ]]; then + echo 'Webstats are coming soon' > %(webalizer_path)s/index.html + fi + cat << 'EOF' > %(webalizer_conf_path)s + %(webalizer_conf)s + EOF + # chown %(user)s:www-data %(webalizer_path)s + chown www-data:www-data %(webalizer_path)s + chmod g+xr %(webalizer_path)s + """) % context + ) + + def delete(self, content): + context = self.get_context(content) + delete_webapp = not type(content.webapp).objects.filter(pk=content.webapp.pk).exists() + if delete_webapp: + self.append("rm -fr %(webapp_path)s" % context) + remounted = content.webapp.content_set.filter(website=content.website).exists() + if delete_webapp or not remounted: + self.append("rm -fr %(webalizer_path)s" % context) + self.append("rm -f %(webalizer_conf_path)s" % context) + + def get_context(self, content): + conf_file = "%s.conf" % content.website.unique_name + context = { + 'site_logs': content.website.get_www_access_log_path(), + 'site_name': content.website.name, + 'webapp_path': content.webapp.get_path(), + 'webalizer_path': os.path.join(content.webapp.get_path(), content.website.name), + 'webalizer_conf_path': os.path.join(settings.WEBSITES_WEBALIZER_PATH, conf_file), + 'user': content.webapp.account.username, + 'banner': self.get_banner(), + 'target_server': content.website.target_server, + } + if context.get('target_server').name in NEW_SERVERS: + context['webalizer_conf'] = textwrap.dedent("""\ + # %(banner)s + LogFile %(site_logs)s + LogType clf + OutputDir %(webalizer_path)s + HistoryName awffull.hist + Incremental yes + IncrementalName awffull.current + ReportTitle Stats of + HostName %(site_name)s + """) % context + else: + context['webalizer_conf'] = textwrap.dedent("""\ + # %(banner)s + LogFile %(site_logs)s + LogType clf + OutputDir %(webalizer_path)s + HistoryName webalizer.hist + Incremental yes + IncrementalName webalizer.current + ReportTitle Stats of + HostName %(site_name)s + """) % context + + context['webalizer_conf'] = context['webalizer_conf'] + textwrap.dedent("""\ + + PageType htm* + PageType php* + PageType shtml + PageType cgi + PageType pl + + DNSCache /var/lib/dns_cache.db + DNSChildren 15 + + HideURL *.gif + HideURL *.GIF + HideURL *.jpg + HideURL *.JPG + HideURL *.png + HideURL *.PNG + HideURL *.ra + + IncludeURL * + + SearchEngine google. q= + SearchEngine yahoo. p= + SearchEngine msn. q= + SearchEngine search.aol query= + SearchEngine altavista. q= + SearchEngine lycos. query= + SearchEngine hotbot. query= + SearchEngine alltheweb. query= + SearchEngine infoseek. qt= + SearchEngine webcrawler searchText= + SearchEngine excite search= + SearchEngine netscape. query= + SearchEngine ask.com q= + SearchEngine webwombat. ix= + SearchEngine earthlink. q= + SearchEngine search.comcast. q= + SearchEngine search.mywebsearch. searchfor= + SearchEngine reference.com q= + SearchEngine mamma.com query= + # Last attempt catch all + SearchEngine search. q= + + DumpSites yes""") % context + return context diff --git a/orchestra/contrib/websites/backends/wordpress.py b/orchestra/contrib/websites/backends/wordpress.py new file mode 100644 index 0000000..d138e9b --- /dev/null +++ b/orchestra/contrib/websites/backends/wordpress.py @@ -0,0 +1,66 @@ +import os +import textwrap + +from orchestra.contrib.orchestration import ServiceController + + +class WordPressURLController(ServiceController): + """ + Configures WordPress site URL with associated website domain. + """ + verbose_name = "WordPress URL" + model = 'websites.Content' + default_route_match = "content.webapp.type == 'wordpress-php'" + + def save(self, content): + context = self.get_context(content) + if context['url']: + self.append(textwrap.dedent("""\ + mysql %(db_name)s -e 'UPDATE wp_options + SET option_value="%(url)s" + WHERE option_id IN (1, 2) AND option_value="http:";' + """) % context + ) + + def delete(self, content): + context = self.get_context(content) + self.append(textwrap.dedent("""\ + mysql %(db_name)s -e 'UPDATE wp_options + SET option_value="http:" + WHERE option_id IN (1, 2);' + """) % context + ) + + def get_context(self, content): + return { + 'url': content.get_absolute_url(), + 'db_name': content.webapp.data.get('db_name'), + } + + +class WordPressForceSSLController(ServiceController): + """ sets FORCE_SSL_ADMIN to true when website supports HTTPS """ + verbose_name = "WordPress Force SSL" + model = 'websites.Content' + related_models = ( + ('websites.Website', 'content_set'), + ) + default_route_match = "content.webapp.type == 'wordpress-php'" + + def save(self, content): + context = self.get_context(content) + site = content.website + if site.protocol in (site.HTTP_AND_HTTPS, site.HTTPS_ONLY, site.HTTPS): + self.append(textwrap.dedent(""" + if [[ ! $(grep FORCE_SSL_ADMIN %(wp_conf_path)s) ]]; then + echo "Enabling FORCE_SSL_ADMIN for %(webapp_name)s webapp" + sed -i -E "s#^(define\('NONCE_SALT.*)#\\1\\n\\ndefine\('FORCE_SSL_ADMIN', true\);#" \\ + %(wp_conf_path)s + fi""") % context + ) + + def get_context(self, content): + return { + 'webapp_name': content.webapp.name, + 'wp_conf_path': os.path.join(content.webapp.get_path(), 'wp-config.php'), + } diff --git a/orchestra/contrib/websites/directives.py b/orchestra/contrib/websites/directives.py new file mode 100644 index 0000000..6192d16 --- /dev/null +++ b/orchestra/contrib/websites/directives.py @@ -0,0 +1,207 @@ +import re +from collections import defaultdict +from functools import lru_cache + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +from orchestra import plugins +from orchestra.utils.python import import_class + +from . import settings +from .utils import normurlpath + + +class SiteDirective(plugins.Plugin, metaclass=plugins.PluginMount): + HTTPD = 'HTTPD' + SEC = 'ModSecurity' + SSL = 'SSL' + SAAS = 'SaaS' + + help_text = "" + unique_name = False + unique_value = False + is_location = False + + @classmethod + @lru_cache() + def get_plugins(cls, all=False): + if all: + plugins = super().get_plugins() + else: + plugins = [] + for cls in settings.WEBSITES_ENABLED_DIRECTIVES: + plugins.append(import_class(cls)) + return plugins + + @classmethod + @lru_cache() + def get_option_groups(cls): + groups = {} + for opt in cls.get_plugins(): + try: + groups[opt.group].append(opt) + except KeyError: + groups[opt.group] = [opt] + return groups + + @classmethod + def get_choices(cls): + """ Generates grouped choices ready to use in Field.choices """ + # generators can not be @lru_cache() + yield (None, '-------') + options = cls.get_option_groups() + for option in options.pop(None, ()): + yield (option.name, option.verbose_name) + for group, options in options.items(): + yield (group, [(op.name, op.verbose_name) for op in options]) + + def validate_uniqueness(self, directive, values, locations): + """ Validates uniqueness location, name and value """ + errors = defaultdict(list) + value = directive.get('value', None) + # location uniqueness + location = None + if self.is_location and value is not None: + if not value and self.is_location: + value = '/' + location = normurlpath(value.split()[0]) + if location is not None and location in locations: + errors['value'].append(ValidationError( + "Location '%s' already in use by other content/directive." % location + )) + else: + locations.add(location) + + # name uniqueness + if self.unique_name and self.name in values: + errors[None].append(ValidationError( + _("Only one %s can be defined.") % self.get_verbose_name() + )) + + # value uniqueness + if value is not None: + if self.unique_value and value in values.get(self.name, []): + errors['value'].append(ValidationError( + _("This value is already used by other %s.") % force_str(self.get_verbose_name()) + )) + values[self.name].append(value) + if errors: + raise ValidationError(errors) + + def validate(self, directive): + directive.value = directive.value.strip() + if not directive.value and self.is_location: + directive.value = '/' + if self.regex and not re.match(self.regex, directive.value): + raise ValidationError({ + 'value': ValidationError(_("'%(value)s' does not match %(regex)s."), + params={ + 'value': directive.value, + 'regex': self.regex + }), + }) + + +class Redirect(SiteDirective): + name = 'redirect' + verbose_name = _("Redirection") + help_text = _("<website path> <destination URL>") + regex = r'^[^ ]*\s[^ ]+$' + group = SiteDirective.HTTPD + unique_value = True + is_location = True + + def validate(self, directive): + """ inserts default url-path if not provided """ + values = directive.value.strip().split() + if len(values) == 1: + values.insert(0, '/') + directive.value = ' '.join(values) + super(Redirect, self).validate(directive) + + +class Proxy(Redirect): + name = 'proxy' + verbose_name = _("Proxy") + help_text = _("<website path> <target URL>") + regex = r'^[^ ]+\shttp[^ ]+(timeout=[0-9]{1,3}|retry=[0-9]|\s)*$' + + +class ErrorDocument(SiteDirective): + name = 'error-document' + verbose_name = _("ErrorDocumentRoot") + help_text = _("<error code> <URL/path/message>
    " + " 500 http://foo.example.com/cgi-bin/tester
    " + " 404 /cgi-bin/bad_urls.pl
    " + " 401 /subscription_info.html
    " + " 403 \"Sorry can't allow you access today\"") + regex = r'[45]0[0-9]\s.*' + group = SiteDirective.HTTPD + unique_value = True + + +class SSLCA(SiteDirective): + name = 'ssl-ca' + verbose_name = _("SSL CA") + help_text = _("Filesystem path of the CA certificate file.") + regex = r'^/[^ ]+$' + group = SiteDirective.SSL + unique_name = True + + +class SSLCert(SSLCA): + name = 'ssl-cert' + verbose_name = _("SSL cert") + help_text = _("Filesystem path of the certificate file.") + + +class SSLKey(SSLCA): + name = 'ssl-key' + verbose_name = _("SSL key") + help_text = _("Filesystem path of the key file.") + + +class SecRuleRemove(SiteDirective): + name = 'sec-rule-remove' + verbose_name = _("SecRuleRemoveById") + help_text = _("Space separated ModSecurity rule IDs.") + regex = r'^[0-9\s]+$' + group = SiteDirective.SEC + is_location = True + + +class SecEngine(SecRuleRemove): + name = 'sec-engine' + verbose_name = _("SecRuleEngine Off") + help_text = _("URL-path with disabled modsecurity engine.") + regex = r'^/[^ ]*$' + is_location = False + + +class WordPressSaaS(SiteDirective): + name = 'wordpress-saas' + verbose_name = "WordPress SaaS" + help_text = _("URL-path for mounting WordPress multisite.") + group = SiteDirective.SAAS + regex = r'^/[^ ]*$' + unique_value = True + is_location = True + + +class DokuWikiSaaS(WordPressSaaS): + name = 'dokuwiki-saas' + verbose_name = "DokuWiki SaaS" + help_text = _("URL-path for mounting DokuWiki multisite.") + + +class DrupalSaaS(WordPressSaaS): + name = 'drupal-saas' + verbose_name = "Drupdal SaaS" + help_text = _("URL-path for mounting Drupal multisite.") + + +class MoodleSaaS(WordPressSaaS): + name = 'moodle-saas' + verbose_name = "Moodle SaaS" + help_text = _("URL-path for mounting Moodle multisite.") diff --git a/orchestra/contrib/websites/filters.py b/orchestra/contrib/websites/filters.py new file mode 100644 index 0000000..1101619 --- /dev/null +++ b/orchestra/contrib/websites/filters.py @@ -0,0 +1,34 @@ +from django.contrib.admin import SimpleListFilter +from django.utils.translation import gettext_lazy as _ + + +class HasWebAppsListFilter(SimpleListFilter): + """ Filter addresses whether they have any webapp or not """ + title = _("has webapps") + parameter_name = 'has_webapps' + + def lookups(self, request, model_admin): + return ( + ('True', _("True")), + ('False', _("False")), + ) + + def queryset(self, request, queryset): + if self.value() == 'True': + return queryset.filter(content__isnull=False) + elif self.value() == 'False': + return queryset.filter(content__isnull=True) + return queryset + + +class HasDomainsFilter(HasWebAppsListFilter): + """ Filter addresses whether they have any domains or not """ + title = _("has domains") + parameter_name = 'has_domains' + + def queryset(self, request, queryset): + if self.value() == 'True': + return queryset.filter(domains__isnull=False) + elif self.value() == 'False': + return queryset.filter(domains__isnull=True) + return queryset diff --git a/orchestra/contrib/websites/forms.py b/orchestra/contrib/websites/forms.py new file mode 100644 index 0000000..2df37fd --- /dev/null +++ b/orchestra/contrib/websites/forms.py @@ -0,0 +1,71 @@ +from collections import defaultdict + +from django import forms +from django.core.exceptions import ValidationError + +from orchestra.contrib.webapps.models import WebApp + +from .utils import normurlpath +from .validators import validate_domain_protocol, validate_server_name + + +class WebsiteAdminForm(forms.ModelForm): + def clean(self): + """ Prevent multiples domains on the same protocol """ + super(WebsiteAdminForm, self).clean() + domains = self.cleaned_data.get('domains') + if not domains: + return self.cleaned_data + protocol = self.cleaned_data.get('protocol') + domains = domains.all() + for domain in domains: + try: + validate_domain_protocol(self.instance, domain, protocol) + except ValidationError as err: + self.add_error(None, err) + try: + validate_server_name(domains) + except ValidationError as err: + self.add_error('domains', err) + return self.cleaned_data + + def clean_target_server(self): + # valida que el webapp pertenezca al server indicado + try: + server = self.cleaned_data['target_server'] + except: + server = self.instance.target_server + + diferentServer = False + for i in range(int(self.data['content_set-TOTAL_FORMS']) + 1): + if f"content_set-{i}-webapp" in self.data.keys() and f"content_set-{i}-DELETE" not in self.data.keys(): + if self.data[f"content_set-{i}-webapp"]: + idWebapp = self.data[f"content_set-{i}-webapp"] + webapp = WebApp.objects.get(id=idWebapp) + if webapp.target_server.id != server.id : + diferentServer = True + if diferentServer: + self.add_error("target_server", f"Some Webapp does not belong to the {server.name} server") + return server + + +class WebsiteDirectiveInlineFormSet(forms.models.BaseInlineFormSet): + def clean(self): + # directives formset cross-validation with contents for unique locations + locations = set() + for form in self.content_formset.forms: + location = form.cleaned_data.get('path') + delete = form.cleaned_data.get('DELETE') + if not delete and location is not None: + locations.add(normurlpath(location)) + + values = defaultdict(list) + for form in self.forms: + wdirective = form.instance + directive = form.cleaned_data + if directive.get('name') is not None: + try: + wdirective.directive_instance.validate_uniqueness(directive, values, locations) + except ValidationError as err: + for k,v in err.error_dict.items(): + form.add_error(k, v) diff --git a/orchestra/contrib/websites/migrations/0001_initial.py b/orchestra/contrib/websites/migrations/0001_initial.py new file mode 100644 index 0000000..5b45040 --- /dev/null +++ b/orchestra/contrib/websites/migrations/0001_initial.py @@ -0,0 +1,65 @@ +# Generated by Django 2.2.28 on 2023-08-17 09:45 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import orchestra.core.validators + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('orchestration', '__first__'), + ('domains', '__first__'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('webapps', '__first__'), + # ('webapps', '0004_auto_20230817_1108'), + ] + + operations = [ + migrations.CreateModel( + name='Content', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('path', models.CharField(blank=True, max_length=256, validators=[orchestra.core.validators.validate_url_path], verbose_name='path')), + ('webapp', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='webapps.WebApp', verbose_name='web application')), + ], + ), + migrations.CreateModel( + name='Website', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=128, validators=[orchestra.core.validators.validate_name], verbose_name='name')), + ('protocol', models.CharField(choices=[('http', 'HTTP'), ('https', 'HTTPS'), ('http/https', 'HTTP and HTTPS'), ('https-only', 'HTTPS only')], default='http', help_text='Select the protocol(s) for this website
    HTTPS only performs a redirection from http to https.', max_length=16, verbose_name='protocol')), + ('is_active', models.BooleanField(default=True, verbose_name='active')), + ('comments', models.TextField(blank=True, default='')), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='websites', to=settings.AUTH_USER_MODEL, verbose_name='Account')), + ('contents', models.ManyToManyField(through='websites.Content', to='webapps.WebApp')), + ('domains', models.ManyToManyField(blank=True, related_name='websites', to='domains.Domain', verbose_name='domains')), + ('target_server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='websites', to='orchestration.Server', verbose_name='Target Server')), + ], + options={ + 'unique_together': {('name', 'account')}, + }, + ), + migrations.CreateModel( + name='WebsiteDirective', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(choices=[(None, '-------'), ('HTTPD', [('redirect', 'Redirection'), ('proxy', 'Proxy'), ('error-document', 'ErrorDocumentRoot')]), ('SSL', [('ssl-ca', 'SSL CA'), ('ssl-cert', 'SSL cert'), ('ssl-key', 'SSL key')]), ('ModSecurity', [('sec-rule-remove', 'SecRuleRemoveById'), ('sec-engine', 'SecRuleEngine Off')]), ('SaaS', [('wordpress-saas', 'WordPress SaaS'), ('dokuwiki-saas', 'DokuWiki SaaS'), ('drupal-saas', 'Drupdal SaaS'), ('moodle-saas', 'Moodle SaaS')])], db_index=True, max_length=128, verbose_name='name')), + ('value', models.CharField(blank=True, max_length=256, verbose_name='value')), + ('website', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='directives', to='websites.Website', verbose_name='web site')), + ], + ), + migrations.AddField( + model_name='content', + name='website', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='websites.Website', verbose_name='web site'), + ), + migrations.AlterUniqueTogether( + name='content', + unique_together={('website', 'path')}, + ), + ] diff --git a/orchestra/contrib/websites/migrations/0002_auto_20230817_1149.py b/orchestra/contrib/websites/migrations/0002_auto_20230817_1149.py new file mode 100644 index 0000000..adfe552 --- /dev/null +++ b/orchestra/contrib/websites/migrations/0002_auto_20230817_1149.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.28 on 2023-08-17 09:49 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('orchestration', '__first__'), + ('websites', '0001_initial'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='website', + unique_together={('name', 'account', 'target_server')}, + ), + ] diff --git a/orchestra/contrib/websites/migrations/__init__.py b/orchestra/contrib/websites/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestra/contrib/websites/models.py b/orchestra/contrib/websites/models.py new file mode 100644 index 0000000..bd6db4f --- /dev/null +++ b/orchestra/contrib/websites/models.py @@ -0,0 +1,177 @@ +import os +from collections import OrderedDict + +from django.db import models +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ + +from orchestra.core import validators +from orchestra.utils.functional import cached + +from . import settings +from .directives import SiteDirective + + +class Website(models.Model): + """ Models a web site, also known as virtual host """ + HTTP = 'http' + HTTPS = 'https' + HTTP_AND_HTTPS = 'http/https' + HTTPS_ONLY = 'https-only' + + name = models.CharField(_("name"), max_length=128, + validators=[validators.validate_name]) + account = models.ForeignKey('accounts.Account', on_delete=models.CASCADE, + verbose_name=_("Account"), related_name='websites') + protocol = models.CharField(_("protocol"), max_length=16, + choices=settings.WEBSITES_PROTOCOL_CHOICES, + default=settings.WEBSITES_DEFAULT_PROTOCOL, + help_text=_("Select the protocol(s) for this website
    " + "HTTPS only performs a redirection from http to https.")) +# port = models.PositiveIntegerField(_("port"), +# choices=settings.WEBSITES_PORT_CHOICES, +# default=settings.WEBSITES_DEFAULT_PORT) + domains = models.ManyToManyField(settings.WEBSITES_DOMAIN_MODEL, blank=True, + related_name='websites', verbose_name=_("domains")) + contents = models.ManyToManyField('webapps.WebApp', through='websites.Content') + target_server = models.ForeignKey('orchestration.Server', on_delete=models.CASCADE, + verbose_name=_("Target Server"), related_name='websites') + is_active = models.BooleanField(_("active"), default=True) + comments = models.TextField(default="", blank=True) + + class Meta: + unique_together = ('name', 'account', 'target_server') + + def __str__(self): + return self.name + + @property + def unique_name(self): + context = self.get_settings_context() + return settings.WEBSITES_UNIQUE_NAME_FORMAT % context + + @cached_property + def active(self): + return self.is_active and self.account.is_active + + def disable(self): + self.is_active = False + self.save(update_fields=('is_active',)) + + def enable(self): + self.is_active = False + self.save(update_fields=('is_active',)) + + def get_settings_context(self): + """ format settings strings """ + return { + 'id': self.id, + 'pk': self.pk, + 'home': self.get_user().get_home(), + 'user': self.get_username(), + 'group': self.get_groupname(), + 'site_name': self.name, + 'protocol': self.protocol, + } + + def get_protocol(self): + if self.protocol in (self.HTTP, self.HTTP_AND_HTTPS): + return self.HTTP + return self.HTTPS + + @cached + def get_directives(self): + directives = OrderedDict() + for opt in self.directives.all().order_by('name', 'value'): + try: + directives[opt.name].append(opt.value) + except KeyError: + directives[opt.name] = [opt.value] + return directives + + def get_absolute_url(self): + try: + domain = self.domains.all()[0] + except IndexError: + return + else: + return '%s://%s' % (self.get_protocol(), domain) + + def get_user(self): + return self.account.main_systemuser + + def get_username(self): + return self.get_user().username + + def get_groupname(self): + return self.get_username() + + def get_www_access_log_path(self): + context = self.get_settings_context() + context['unique_name'] = self.unique_name + path = settings.WEBSITES_WEBSITE_WWW_ACCESS_LOG_PATH % context + return os.path.normpath(path) + + def get_www_error_log_path(self): + context = self.get_settings_context() + context['unique_name'] = self.unique_name + path = settings.WEBSITES_WEBSITE_WWW_ERROR_LOG_PATH % context + return os.path.normpath(path) + + +class WebsiteDirective(models.Model): + website = models.ForeignKey(Website, on_delete=models.CASCADE, + verbose_name=_("web site"), related_name='directives') + name = models.CharField(_("name"), max_length=128, db_index=True, + choices=SiteDirective.get_choices()) + value = models.CharField(_("value"), max_length=256, blank=True) + + def __str__(self): + return self.name + + @cached_property + def directive_class(self): + return SiteDirective.get(self.name) + + @cached_property + def directive_instance(self): + """ Per request lived directive instance """ + return self.directive_class() + + def clean(self): + self.directive_instance.validate(self) + + +class Content(models.Model): + # related_name is content_set to differentiate between website.content -> webapp + webapp = models.ForeignKey('webapps.WebApp', on_delete=models.CASCADE, + verbose_name=_("web application")) + website = models.ForeignKey('websites.Website', on_delete=models.CASCADE, + verbose_name=_("web site")) + path = models.CharField(_("path"), max_length=256, blank=True, + validators=[validators.validate_url_path]) + + class Meta: + unique_together = ('website', 'path') + + def __str__(self): + try: + return self.website.name + self.path + except Website.DoesNotExist: + return self.path + + def clean_fields(self, *args, **kwargs): + self.path = self.path.strip() + return super(Content, self).clean_fields(*args, **kwargs) + + def clean(self): + if not self.path: + self.path = '/' + + def get_absolute_url(self): + try: + domain = self.website.domains.all()[0] + except IndexError: + return + else: + return '%s://%s%s' % (self.website.get_protocol(), domain, self.path) diff --git a/orchestra/contrib/websites/serializers.py b/orchestra/contrib/websites/serializers.py new file mode 100644 index 0000000..49eb2b6 --- /dev/null +++ b/orchestra/contrib/websites/serializers.py @@ -0,0 +1,130 @@ +from django.core.exceptions import ValidationError +from rest_framework import serializers + +from orchestra.api.serializers import HyperlinkedModelSerializer, RelatedHyperlinkedModelSerializer +from orchestra.contrib.accounts.serializers import AccountSerializerMixin + +from .directives import SiteDirective +from .models import Website, Content, WebsiteDirective +from .utils import normurlpath +from .validators import validate_domain_protocol + + + +class RelatedDomainSerializer(AccountSerializerMixin, RelatedHyperlinkedModelSerializer): + class Meta: + model = Website.domains.field.related_model + fields = ('url', 'id', 'name') + + +class RelatedWebAppSerializer(AccountSerializerMixin, RelatedHyperlinkedModelSerializer): + class Meta: + model = Content.webapp.field.related_model + fields = ('url', 'id', 'name', 'type') + + +class ContentSerializer(serializers.ModelSerializer): + webapp = RelatedWebAppSerializer() + + class Meta: + model = Content + fields = ('webapp', 'path') + + def get_identity(self, data): + return '%s-%s' % (data.get('website'), data.get('path')) + + +class DirectiveSerializer(serializers.ModelSerializer): + class Meta: + model = WebsiteDirective + fields = ('name', 'value') + + def to_representation(self, instance): + return {prop.name: prop.value for prop in instance.all()} + + def to_internal_value(self, data): + return data + + +class WebsiteSerializer(AccountSerializerMixin, HyperlinkedModelSerializer): + domains = RelatedDomainSerializer(many=True, required=False) + contents = ContentSerializer(required=False, many=True, source='content_set') + directives = DirectiveSerializer(required=False) + + class Meta: + model = Website + fields = ('url', 'id', 'name', 'protocol', 'domains', 'is_active', 'contents', 'directives') + postonly_fields = ('name',) + + def validate(self, data): + """ Prevent multiples domains on the same protocol """ + # Validate location and directive uniqueness + errors = [] + directives = data.get('directives', []) + if directives: + locations = set() + for content in data.get('content_set', []): + location = content.get('path') + if location is not None: + locations.add(normurlpath(location)) + values = defaultdict(list) + for name, value in directives.items(): + directive = { + 'name': name, + 'value': value, + } + try: + SiteDirective.get(name).validate_uniqueness(directive, values, locations) + except ValidationError as err: + errors.append(err) + # Validate domain protocol uniqueness + instance = self.instance + for domain in data['domains']: + try: + validate_domain_protocol(instance, domain, data['protocol']) + except ValidationError as err: + errors.append(err) + if errors: + raise ValidationError(errors) + return data + + def create(self, validated_data): + directives_data = validated_data.pop('directives') + webapp = super(WebsiteSerializer, self).create(validated_data) + for key, value in directives_data.items(): + WebsiteDirective.objects.create(webapp=webapp, name=key, value=value) + return webap + + def update_directives(self, instance, directives_data): + existing = {} + for obj in instance.directives.all(): + existing[obj.name] = obj + posted = set() + for key, value in directives_data.items(): + posted.add(key) + try: + directive = existing[key] + except KeyError: + directive = instance.directives.create(name=key, value=value) + else: + if directive.value != value: + directive.value = value + directive.save(update_fields=('value',)) + for to_delete in set(existing.keys())-posted: + existing[to_delete].delete() + + def update_contents(self, instance, contents_data): + raise NotImplementedError + + def update_domains(self, instance, domains_data): + raise NotImplementedError + + def update(self, instance, validated_data): + directives_data = validated_data.pop('directives') + domains_data = validated_data.pop('domains') + contents_data = validated_data.pop('content_set') + instance = super(WebsiteSerializer, self).update(instance, validated_data) + self.update_directives(instance, directives_data) + self.update_contents(instance, contents_data) + self.update_domains(instance, domains_data) + return instance diff --git a/orchestra/contrib/websites/settings.py b/orchestra/contrib/websites/settings.py new file mode 100644 index 0000000..147828f --- /dev/null +++ b/orchestra/contrib/websites/settings.py @@ -0,0 +1,129 @@ +from django.utils.translation import gettext_lazy as _ + +from orchestra.contrib.settings import Setting +from orchestra.core.validators import validate_ip_address + +from .. import websites + + +_names = ('id', 'pk', 'home', 'user', 'group', 'site_name', 'protocol') +_log_names = _names + ('unique_name',) + + +WEBSITES_UNIQUE_NAME_FORMAT = Setting('WEBSITES_UNIQUE_NAME_FORMAT', + default='%(user)s-%(site_name)s', + help_text="Available fromat names: %s" % ', '.join(_names), + validators=[Setting.string_format_validator(_names)], +) + + +WEBSITES_PROTOCOL_CHOICES = Setting('WEBSITES_PROTOCOL_CHOICES', + default=( + ('http', "HTTP"), + ('https', "HTTPS"), + ('http/https', _("HTTP and HTTPS")), + ('https-only', _("HTTPS only")), + ), + validators=[Setting.validate_choices] +) + + +WEBSITES_DEFAULT_PROTOCOL = Setting('WEBSITES_DEFAULT_PROTOCOL', + default='http', + choices=WEBSITES_PROTOCOL_CHOICES +) + + +WEBSITES_DEFAULT_IPS = Setting('WEBSITES_DEFAULT_IPS', + default=('*',) +) + + +WEBSITES_DOMAIN_MODEL = Setting('WEBSITES_DOMAIN_MODEL', + 'domains.Domain', + validators=[Setting.validate_model_label] +) + + +WEBSITES_ENABLED_DIRECTIVES = Setting('WEBSITES_ENABLED_DIRECTIVES', + ( + 'orchestra.contrib.websites.directives.Redirect', + 'orchestra.contrib.websites.directives.Proxy', + 'orchestra.contrib.websites.directives.ErrorDocument', + 'orchestra.contrib.websites.directives.SSLCA', + 'orchestra.contrib.websites.directives.SSLCert', + 'orchestra.contrib.websites.directives.SSLKey', + 'orchestra.contrib.websites.directives.SecRuleRemove', + 'orchestra.contrib.websites.directives.SecEngine', + 'orchestra.contrib.websites.directives.WordPressSaaS', + 'orchestra.contrib.websites.directives.DokuWikiSaaS', + 'orchestra.contrib.websites.directives.DrupalSaaS', + 'orchestra.contrib.websites.directives.MoodleSaaS', + ), + # lazy loading + choices=lambda : ((d.get_class_path(), d.get_class_path()) for d in websites.directives.SiteDirective.get_plugins(all=True)), + multiple=True, +) + + +WEBSITES_BASE_APACHE_CONF = Setting('WEBSITES_BASE_APACHE_CONF', + '/etc/apache2/' +) + + +WEBSITES_WEBALIZER_PATH = Setting('WEBSITES_WEBALIZER_PATH', + '/home/httpd/webalizer/' +) + + +WEBSITES_WEBSITE_WWW_ACCESS_LOG_PATH = Setting('WEBSITES_WEBSITE_WWW_ACCESS_LOG_PATH', + '/var/log/apache2/virtual/%(unique_name)s.log', + help_text="Available fromat names: %s" % ', '.join(_log_names), + validators=[Setting.string_format_validator(_log_names)], +) + + +WEBSITES_WEBSITE_WWW_ERROR_LOG_PATH = Setting('WEBSITES_WEBSITE_WWW_ERROR_LOG_PATH', + '', + help_text="Available fromat names: %s" % ', '.join(_log_names), + validators=[Setting.string_format_validator(_log_names)], +) + + +WEBSITES_TRAFFIC_IGNORE_HOSTS = Setting('WEBSITES_TRAFFIC_IGNORE_HOSTS', + ('127.0.0.1',), + help_text=_("IP addresses to ignore during traffic accountability."), + validators=[lambda hosts: (validate_ip_address(host) for host in hosts)], +) + + +# TODO sane defaults +WEBSITES_SAAS_DIRECTIVES = Setting('WEBSITES_SAAS_DIRECTIVES', + { + 'wordpress-saas': ('fpm', '/var/run/fpm/pangea-5.4-fpm.sock', '/home/httpd/wordpress-mu/'), + 'drupal-saas': ('fpm', '/var/run/fpm/pangea-5.4-fpm.sock', '/home/httpd/drupal-mu/'), + 'dokuwiki-saas': ('fpm', '/var/run/fpm/pangea-5.4-fpm.sock', '/home/httpd/dokuwiki-mu/'), + 'moodle-saas': ('fpm', '/var/run/fpm/pangea-5.4-fpm.sock', '/home/httpd/moodle-mu/'), + }, +) + + +WEBSITES_DEFAULT_SSL_CERT = Setting('WEBSITES_DEFAULT_SSL_CERT', + '' +) + +WEBSITES_DEFAULT_SSL_KEY = Setting('WEBSITES_DEFAULT_SSL_KEY', + '' +) + +WEBSITES_DEFAULT_SSL_CA = Setting('WEBSITES_DEFAULT_SSL_CA', + '' +) + +WEBSITES_VHOST_EXTRA_DIRECTIVES = Setting('WEBSITES_VHOST_EXTRA_DIRECTIVES', + (), + help_text=( + "(, ),
    " + "i.e. ('/cgi-bin/', 'ScriptAlias /cgi-bin/ %(home)s/cgi-bin/')" + ) +) diff --git a/orchestra/contrib/websites/tests/__init__.py b/orchestra/contrib/websites/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestra/contrib/websites/tests/functional_tests/__init__.py b/orchestra/contrib/websites/tests/functional_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestra/contrib/websites/tests/functional_tests/tests.py b/orchestra/contrib/websites/tests/functional_tests/tests.py new file mode 100644 index 0000000..1a9bd3a --- /dev/null +++ b/orchestra/contrib/websites/tests/functional_tests/tests.py @@ -0,0 +1,140 @@ +import os +import socket + +import requests + +from orchestra.contrib.domains.models import Domain, Record +from orchestra.contrib.domains.backends import Bind9MasterDomainController +from orchestra.contrib.orchestration.models import Server, Route +from orchestra.contrib.webapps.tests.functional_tests.tests import StaticWebAppMixin, RESTWebAppMixin, WebAppMixin, PHPFPMWebAppMixin +from orchestra.utils.tests import BaseLiveServerTestCase, random_ascii, save_response_on_error + +from ... import backends + + +class WebsiteMixin(WebAppMixin): + MASTER_SERVER = os.environ.get('ORCHESTRA_MASTER_SERVER', 'localhost') + MASTER_SERVER_ADDR = socket.gethostbyname(MASTER_SERVER) + DEPENDENCIES = ( + 'orchestra.contrib.orchestration', + 'orchestra.contrib.domains', + 'orchestra.contrib.websites', + 'orchestra.contrib.webapps', + 'orchestra.contrib.systemusers', + ) + + def add_route(self): + super(WebsiteMixin, self).add_route() + server = Server.objects.get() + backend = backends.apache.Apache2Controller.get_name() + Route.objects.get_or_create(backend=backend, match=True, host=server) + backend = Bind9MasterDomainController.get_name() + Route.objects.get_or_create(backend=backend, match=True, host=server) + + def validate_add_website(self, name, domain): + url = 'http://%s/%s' % (domain.name, self.page[0]) + self.assertEqual(self.page[2], requests.get(url).content) + + def test_add(self): + # TODO domains with "_" bad name! + domain_name = '%sdomain.lan' % random_ascii(10) + domain = Domain.objects.create(name=domain_name, account=self.account) + domain.records.create(type=Record.A, value=self.MASTER_SERVER_ADDR) + self.save_domain(domain) + webapp = '%s_%s_webapp' % (random_ascii(10), self.type_value) + self.add_webapp(webapp) + self.addCleanup(self.delete_webapp, webapp) + self.upload_webapp(webapp) + website = '%s_website' % random_ascii(10) + self.add_website(website, domain, webapp) + self.addCleanup(self.delete_website, website) + self.validate_add_website(website, domain) + + +class RESTWebsiteMixin(RESTWebAppMixin): + @save_response_on_error + def save_domain(self, domain): + self.rest.domains.retrieve().get().save() + + @save_response_on_error + def add_website(self, name, domain, webapp, path='/'): + domain = self.rest.domains.retrieve(name=domain).get() + webapp = self.rest.webapps.retrieve(name=webapp).get() + contents = [{ + 'webapp': webapp, + 'path': path + }] + self.rest.websites.create(name=name, domains=[domain], contents=contents) + + @save_response_on_error + def delete_website(self, name): + self.rest.websites.retrieve(name=name).delete() + + @save_response_on_error + def add_content(self, website, webapp, path): + website = self.rest.websites.retrieve(name=website).get() + webapp = self.rest.webapps.retrieve(name=webapp).get() + website.contents.append({ + 'webapp': webapp, + 'path': path, + }) + website.save() + + # TODO test disable + # TODO test https (refactor ssl) + # TODO test php options + # TODO read php-version /fpm/fcgid + # TODO max_processes, timeouts, memory... + + +class StaticRESTWebsiteTest(RESTWebsiteMixin, StaticWebAppMixin, WebsiteMixin, BaseLiveServerTestCase): + def test_mix_webapps(self): + domain_name = '%sdomain.lan' % random_ascii(10) + domain = Domain.objects.create(name=domain_name, account=self.account) + domain.records.create(type=Record.A, value=self.MASTER_SERVER_ADDR) + self.save_domain(domain) + webapp = '%s_%s_webapp' % (random_ascii(10), self.type_value) + self.add_webapp(webapp) + self.addCleanup(self.delete_webapp, webapp) + self.upload_webapp(webapp) + website = '%s_website' % random_ascii(10) + self.add_website(website, domain, webapp) + self.addCleanup(self.delete_website, website) + self.validate_add_website(website, domain) + + self.type_value = PHPFPMWebAppMixin.type_value + self.backend = PHPFPMWebAppMixin.backend + self.page = PHPFPMWebAppMixin.page + self.add_route() + webapp = '%s_%s_webapp' % (random_ascii(10), self.type_value) + self.add_webapp(webapp) + self.addCleanup(self.delete_webapp, webapp) + self.upload_webapp(webapp) + path = '/%s' % webapp + self.add_content(website, webapp, path) + url = 'http://%s%s/%s' % (domain.name, path, self.page[0]) + self.assertEqual(self.page[2], requests.get(url).content) + + self.type_value = PHPFPMWebAppMixin.type_value + self.backend = PHPFPMWebAppMixin.backend + self.page = PHPFPMWebAppMixin.page + self.add_route() + webapp = '%s_%s_webapp' % (random_ascii(10), self.type_value) + self.add_webapp(webapp) + self.addCleanup(self.delete_webapp, webapp) + self.upload_webapp(webapp) + path = '/%s' % webapp + + self.add_content(website, webapp, path) + url = 'http://%s%s/%s' % (domain.name, path, self.page[0]) + self.assertEqual(self.page[2], requests.get(url).content) + + +class PHPFPMRESTWebsiteTest(RESTWebsiteMixin, PHPFPMWebAppMixin, WebsiteMixin, BaseLiveServerTestCase): + pass + +#class AdminWebsiteTest(AdminWebsiteMixin, BaseLiveServerTestCase): +# pass + + + diff --git a/orchestra/contrib/websites/utils.py b/orchestra/contrib/websites/utils.py new file mode 100644 index 0000000..33f8dfe --- /dev/null +++ b/orchestra/contrib/websites/utils.py @@ -0,0 +1,5 @@ +def normurlpath(path): + if not path.startswith('/'): + path = '/' + path + path = path.rstrip('/') + return path.replace('//', '/') diff --git a/orchestra/contrib/websites/validators.py b/orchestra/contrib/websites/validators.py new file mode 100644 index 0000000..348a1cf --- /dev/null +++ b/orchestra/contrib/websites/validators.py @@ -0,0 +1,38 @@ +from django.core.exceptions import ValidationError +from django.db.models import Q +from django.utils.translation import gettext_lazy as _ + +from .models import Website + + +def validate_domain_protocol(website, domain, protocol): + if protocol == Website.HTTP: + qset = Q( + Q(protocol=Website.HTTP) | + Q(protocol=Website.HTTP_AND_HTTPS) | + Q(protocol=Website.HTTPS_ONLY) + ) + elif protocol == Website.HTTPS: + qset = Q( + Q(protocol=Website.HTTPS) | + Q(protocol=Website.HTTP_AND_HTTPS) | + Q(protocol=Website.HTTPS_ONLY) + ) + elif protocol in (Website.HTTP_AND_HTTPS, Website.HTTPS_ONLY): + qset = Q() + else: + raise ValidationError({ + 'protocol': _("Unknown protocol %s") % protocol + }) + if domain.websites.filter(qset).exclude(pk=website.pk).exists(): + raise ValidationError({ + 'domains': 'A website is already defined for "%s" on protocol %s' % (domain, protocol), + }) + + +def validate_server_name(domains): + if domains: + for domain in domains: + if not domain.name.startswith('*'): + return + raise ValidationError(_("At least one non-wildcard domain should be provided.")) diff --git a/orchestra/core/__init__.py b/orchestra/core/__init__.py new file mode 100644 index 0000000..9d540ee --- /dev/null +++ b/orchestra/core/__init__.py @@ -0,0 +1,52 @@ +from django.utils.text import format_lazy + +from ..utils.python import AttrDict + + +class Register(object): + def __init__(self, verbose_name=None): + self._registry = {} + self.verbose_name = verbose_name + + def __contains__(self, key): + return key in self._registry + + def __getitem__(self, key): + return self._registry[key] + + def __iter__(self): + return iter(self._registry.values()) + + def register(self, model, **kwargs): + if model in self._registry: + raise KeyError("%s already registered" % model) + if 'verbose_name' not in kwargs: + kwargs['verbose_name'] = model._meta.verbose_name + if 'verbose_name_plural' not in kwargs: + kwargs['verbose_name_plural'] = model._meta.verbose_name_plural + defaults = { + 'menu': True, + 'search': True, + 'model': model, + } + defaults.update(kwargs) + self._registry[model] = AttrDict(**defaults) + + def register_view(self, view_name, **kwargs): + if 'verbose_name' not in kwargs: + raise KeyError("%s verbose_name is required for views" % view_name) + if 'verbose_name_plural' not in kwargs: + kwargs['verbose_name_plural'] = format_lazy('{}' * 2, *[kwargs['verbose_name'], 's']) + + self.register(view_name, **kwargs) + + def get(self, *args): + if args: + return self._registry[args[0]] + return self._registry + + +services = Register(verbose_name='Services') +# TODO rename to something else +accounts = Register(verbose_name='Accounts') +administration = Register(verbose_name='Administration') diff --git a/orchestra/core/caches.py b/orchestra/core/caches.py new file mode 100644 index 0000000..ebf5f12 --- /dev/null +++ b/orchestra/core/caches.py @@ -0,0 +1,45 @@ +from threading import currentThread + +from django.core.cache.backends.dummy import DummyCache +from django.core.cache.backends.locmem import LocMemCache +from django.utils.deprecation import MiddlewareMixin + +_request_cache = {} + + +class RequestCache(LocMemCache): + """ LocMemCache is a threadsafe local memory cache """ + def __init__(self): + name = 'locmemcache@%i' % hash(currentThread()) + super(RequestCache, self).__init__(name, {}) + + +def get_request_cache(): + """ + Returns per-request cache when running RequestCacheMiddleware otherwise a + DummyCache instance (when running periodic tasks, tests or shell) + """ + try: + return _request_cache[currentThread()] + except KeyError: + return DummyCache('dummy', {}) + + +class RequestCacheMiddleware(MiddlewareMixin): + def process_request(self, request): + current_thread = currentThread() + cache = _request_cache.get(current_thread, RequestCache()) + _request_cache[current_thread] = cache + cache.clear() + + def clear_cache(self): + current_thread = currentThread() + if currentThread() in _request_cache: + _request_cache[current_thread].clear() + + def process_exception(self, request, exception): + self.clear_cache() + + def process_response(self, request, response): + self.clear_cache() + return response diff --git a/orchestra/core/context_processors.py b/orchestra/core/context_processors.py new file mode 100644 index 0000000..52db204 --- /dev/null +++ b/orchestra/core/context_processors.py @@ -0,0 +1,9 @@ +from orchestra import settings + + +def site(request): + """ Adds site-related context variables to the context """ + return { + 'ORCHESTRA_SITE_NAME': settings.ORCHESTRA_SITE_NAME, + 'ORCHESTRA_SITE_VERBOSE_NAME': settings.ORCHESTRA_SITE_VERBOSE_NAME + } diff --git a/orchestra/core/translations.py b/orchestra/core/translations.py new file mode 100644 index 0000000..6bc7ebb --- /dev/null +++ b/orchestra/core/translations.py @@ -0,0 +1,13 @@ +class ModelTranslation(object): + """ + Collects all model fields that would be translated + + using 'makemessages --domain database' management command + """ + _registry = {} + + @classmethod + def register(cls, model, fields): + if model in cls._registry: + raise ValueError("Model %s already registered." % model.__name__) + cls._registry[model] = fields diff --git a/orchestra/core/validators.py b/orchestra/core/validators.py new file mode 100644 index 0000000..2c6b46c --- /dev/null +++ b/orchestra/core/validators.py @@ -0,0 +1,186 @@ +import logging +import re +from ipaddress import ip_address + +import phonenumbers +from django.core import validators +from django.core.exceptions import ValidationError +from django.utils.deconstruct import deconstructible +from django.utils.translation import gettext_lazy as _ + +from ..utils.python import import_class + + +logger = logging.getLogger(__name__) + + +def all_valid(*args): + """ helper function to merge multiple validators at once """ + if len(args) == 1: + # Dict + errors = {} + kwargs = args[0] + for field, validator in kwargs.items(): + try: + validator[0](*validator[1:]) + except ValidationError as error: + errors[field] = error + else: + # List + errors = [] + value, validators = args + for validator in validators: + try: + validator(value) + except ValidationError as error: + errors.append(error) + if errors: + raise ValidationError(errors) + + +@deconstructible +class OrValidator(object): + """ + Run validators with an OR logic + """ + def __init__(self, *validators): + self.validators = validators + + def __call__(self, value): + msg = [] + for validator in self.validators: + try: + validator(value) + except ValidationError as err: + msg.append(str(err)) + else: + return + raise ValidationError(' OR '.join(msg)) + + +def validate_ipv4_address(value): + msg = _("Not a valid IPv4 address") + try: + ip = ip_address(value) + except ValueError: + raise ValidationError(msg) + if ip.version != 4: + raise ValidationError(msg) + + +def validate_ipv6_address(value): + msg = _("Not a valid IPv6 address") + try: + ip = ip_address(value) + except ValueError: + raise ValidationError(msg) + if ip.version != 6: + raise ValidationError(msg) + + +def validate_ip_address(value): + msg = _("Not a valid IP address") + try: + ip_address(value) + except ValueError: + raise ValidationError(msg) + + +def validate_name(value): + """ + A single non-empty line of free-form text with no whitespace. + """ + validators.RegexValidator('^[\.\_\-0-9a-z]+$', + _("Enter a valid name (spaceless lowercase text including _.-)."), 'invalid')(value) + + +def validate_ascii(value): + try: + value.encode('ascii') + except UnicodeEncodeError: + raise ValidationError('This is not an ASCII string.') + + +def validate_hostname(hostname): + """ + Ensures that each segment + * contains at least one character and a maximum of 63 characters + * consists only of allowed characters + * doesn't begin or end with a hyphen. + http://stackoverflow.com/a/2532344 + """ + if len(hostname) > 255: + raise ValidationError(_("Too long for a hostname.")) + hostname = hostname.rstrip('.') + allowed = re.compile('(?!-)[A-Z\d-]{1,63}(? tag. + + Requires use of specific form support. (see ReadonlyForm or ReadonlyModelForm) + """ + + def __init__(self, *args, **kwargs): + kwargs['widget'] = kwargs.get('widget', SpanWidget) + super(SpanField, self).__init__(*args, **kwargs) diff --git a/orchestra/forms/options.py b/orchestra/forms/options.py new file mode 100644 index 0000000..5dfda31 --- /dev/null +++ b/orchestra/forms/options.py @@ -0,0 +1,98 @@ +from django import forms +from django.contrib.auth import forms as auth_forms +from django.utils.translation import gettext_lazy as _ + +from orchestra.utils.python import random_ascii + +from ..core.validators import validate_password + +from .fields import SpanField +from .widgets import SpanWidget + + +class UserCreationForm(forms.ModelForm): + """ + A form that creates a user, with no privileges, from the given username and + password. + """ + error_messages = { + 'password_mismatch': _("The two password fields didn't match."), + 'duplicate_username': _("A user with that username already exists."), + } + password1 = forms.CharField(label=_("Password"), + widget=forms.PasswordInput(attrs={'autocomplete': 'off'}), + validators=[validate_password]) + password2 = forms.CharField(label=_("Password confirmation"), + widget=forms.PasswordInput, + help_text=_("Enter the same password as above, for verification.")) + + def __init__(self, *args, **kwargs): + super(UserCreationForm, self).__init__(*args, **kwargs) + self.fields['password1'].help_text = _("Suggestion: %s") % random_ascii(10) + + def clean_password2(self): + password1 = self.cleaned_data.get('password1') + password2 = self.cleaned_data.get('password2') + if password1 and password2 and password1 != password2: + raise forms.ValidationError( + self.error_messages['password_mismatch'], + code='password_mismatch', + ) + return password2 + + def clean_username(self): + # Since model.clean() will check this, this is redundant, + # but it sets a nicer error message than the ORM and avoids conflicts with contrib.auth + username = self.cleaned_data["username"] + try: + self._meta.model._default_manager.get(username=username) + except self._meta.model.DoesNotExist: + return username + raise forms.ValidationError(self.error_messages['duplicate_username']) + + def save(self, commit=True): + user = super(UserCreationForm, self).save(commit=False) + user.set_password(self.cleaned_data['password1']) + if commit: + user.save() + return user + + +class UserChangeForm(forms.ModelForm): + password = auth_forms.ReadOnlyPasswordHashField(label=_("Password"), + help_text=_("Raw passwords are not stored, so there is no way to see " + "this user's password, but you can change it by " + "using this form. " + "Show hash.")) + + def clean_password(self): + # Regardless of what the user provides, return the initial value. + # This is done here, rather than on the field, because the + # field does not have access to the initial value + return self.initial["password"] + + +class NonStoredUserChangeForm(forms.ModelForm): + password = forms.CharField(label=_("Password"), required=False, + widget=SpanWidget(display='Unknown password'), + help_text=_("This service's password is not stored, so there is no way to see it, " + "but you can change it using this form.")) + + +class ReadOnlyFormMixin(object): + """ + Mixin class for ModelForm or Form that provides support for SpanField on readonly fields + Meta: + readonly_fields = (ro_field1, ro_field2) + """ + def __init__(self, *args, **kwargs): + super(ReadOnlyFormMixin, self).__init__(*args, **kwargs) + for name in self.Meta.readonly_fields: + field = self.fields[name] + if not isinstance(field, SpanField): + if not isinstance(field.widget, SpanWidget): + field.widget = SpanWidget() + original = self.initial.get(name) + if hasattr(self, 'instance'): + original = getattr(self.instance, name, original) + field.widget.original = original diff --git a/orchestra/forms/widgets.py b/orchestra/forms/widgets.py new file mode 100644 index 0000000..84e0f1e --- /dev/null +++ b/orchestra/forms/widgets.py @@ -0,0 +1,73 @@ +import re +import textwrap + +from django import forms +from django.utils.safestring import mark_safe + +from django.templatetags.static import static + + +class SpanWidget(forms.Widget): + """ + Renders a value wrapped in a tag. + Requires use of specific form support. (see ReadonlyForm or ReadonlyModelForm) + """ + def __init__(self, *args, **kwargs): + self.tag = kwargs.pop('tag', '') + self.original = kwargs.pop('original', '') + self.display = kwargs.pop('display', None) + super(SpanWidget, self).__init__(*args, **kwargs) + + def render(self, name, value, attrs=None, renderer=None): + final_attrs = self.build_attrs(attrs, extra_attrs={'name':name}) + original = self.original or value + display = original if self.display is None else self.display + # Display icon + if isinstance(original, bool): + icon = static('admin/img/icon-%s.svg' % ('yes' if original else 'no',)) + return mark_safe('%s' % (icon, display)) + tag = self.tag[:-1] + endtag = '/'.join((self.tag[0], self.tag[1:])) + return mark_safe('%s%s >%s%s' % (tag, forms.utils.flatatt(final_attrs), display, endtag)) + + def value_from_datadict(self, data, files, name): + return self.original + + def _has_changed(self, initial, data): + return False + + +class PaddingCheckboxSelectMultiple(forms.CheckboxSelectMultiple): + """ Ugly hack to render this widget nicely on Django admin """ + def __init__(self, padding, attrs=None, choices=()): + super().__init__(attrs=attrs, choices=choices) + self.padding = padding + + def render(self, *args, **kwargs): + value = super().render(*args, **kwargs) + value = re.sub(r'^