inicio 2023/07/09
This commit is contained in:
commit
0f26b5a8e0
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
*.log
|
||||
*.pot
|
||||
*.pyc
|
||||
*~
|
||||
*.save
|
||||
.svn
|
||||
local_settings.py
|
||||
build
|
||||
*.egg-info
|
42
.idea/inspectionProfiles/Project_Default.xml
Normal file
42
.idea/inspectionProfiles/Project_Default.xml
Normal file
|
@ -0,0 +1,42 @@
|
|||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="ignoredPackages">
|
||||
<value>
|
||||
<list size="24">
|
||||
<item index="0" class="java.lang.String" itemvalue="paramiko" />
|
||||
<item index="1" class="java.lang.String" itemvalue="fabric" />
|
||||
<item index="2" class="java.lang.String" itemvalue="django-admin-tools" />
|
||||
<item index="3" class="java.lang.String" itemvalue="django-fluent-dashboard" />
|
||||
<item index="4" class="java.lang.String" itemvalue="phonenumbers" />
|
||||
<item index="5" class="java.lang.String" itemvalue="jsonfield" />
|
||||
<item index="6" class="java.lang.String" itemvalue="amqp" />
|
||||
<item index="7" class="java.lang.String" itemvalue="python-dateutil" />
|
||||
<item index="8" class="java.lang.String" itemvalue="django-localflavor" />
|
||||
<item index="9" class="java.lang.String" itemvalue="ecdsa" />
|
||||
<item index="10" class="java.lang.String" itemvalue="kombu" />
|
||||
<item index="11" class="java.lang.String" itemvalue="django-extensions" />
|
||||
<item index="12" class="java.lang.String" itemvalue="celery" />
|
||||
<item index="13" class="java.lang.String" itemvalue="django-countries" />
|
||||
<item index="14" class="java.lang.String" itemvalue="Pygments" />
|
||||
<item index="15" class="java.lang.String" itemvalue="django-filter" />
|
||||
<item index="16" class="java.lang.String" itemvalue="Django" />
|
||||
<item index="17" class="java.lang.String" itemvalue="anyjson" />
|
||||
<item index="18" class="java.lang.String" itemvalue="django-celery" />
|
||||
<item index="19" class="java.lang.String" itemvalue="djangorestframework" />
|
||||
<item index="20" class="java.lang.String" itemvalue="Markdown" />
|
||||
<item index="21" class="java.lang.String" itemvalue="django-iban" />
|
||||
<item index="22" class="java.lang.String" itemvalue="billiard" />
|
||||
<item index="23" class="java.lang.String" itemvalue="passlib" />
|
||||
</list>
|
||||
</value>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
|
||||
<option name="processCode" value="true" />
|
||||
<option name="processLiterals" value="true" />
|
||||
<option name="processComments" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
7
.idea/misc.xml
Normal file
7
.idea/misc.xml
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="JavaScriptSettings">
|
||||
<option name="languageLevel" value="ES6" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.7 (zfs-blues)" project-jdk-type="Python SDK" />
|
||||
</project>
|
8
.idea/modules.xml
Normal file
8
.idea/modules.xml
Normal file
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/orchestra.iml" filepath="$PROJECT_DIR$/.idea/orchestra.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
32
.idea/orchestra.iml
Normal file
32
.idea/orchestra.iml
Normal file
|
@ -0,0 +1,32 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="FacetManager">
|
||||
<facet type="django" name="Django">
|
||||
<configuration>
|
||||
<option name="rootFolder" value="$MODULE_DIR$/orchestra/conf/project_template" />
|
||||
<option name="settingsModule" value="orchestra/settings.py" />
|
||||
<option name="manageScript" value="manage.py" />
|
||||
<option name="environment" value="<map/>" />
|
||||
<option name="doNotUseTestRunner" value="false" />
|
||||
<option name="trackFilePattern" value="migrations" />
|
||||
</configuration>
|
||||
</facet>
|
||||
</component>
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
<component name="TemplatesService">
|
||||
<option name="TEMPLATE_CONFIGURATION" value="Django" />
|
||||
<option name="TEMPLATE_FOLDERS">
|
||||
<list>
|
||||
<option value="$MODULE_DIR$/orchestra/contrib/bills/templates" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="TestRunnerService">
|
||||
<option name="projectConfiguration" value="pytest" />
|
||||
<option name="PROJECT_TEST_RUNNER" value="pytest" />
|
||||
</component>
|
||||
</module>
|
6
.idea/vcs.xml
Normal file
6
.idea/vcs.xml
Normal file
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
261
.idea/workspace.xml
Normal file
261
.idea/workspace.xml
Normal file
|
@ -0,0 +1,261 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="573278bf-2cdd-4afd-973a-1660f3364e9d" name="Default Changelist" comment="">
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/api/actions.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/api/actions.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/api/serializers.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/api/serializers.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/accounts/models.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/accounts/models.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/accounts/serializers.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/accounts/serializers.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/bills/admin.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/bills/admin.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/bills/locale/ca/LC_MESSAGES/django.mo" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/bills/locale/ca/LC_MESSAGES/django.mo" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/bills/locale/ca/LC_MESSAGES/django.po" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/bills/locale/ca/LC_MESSAGES/django.po" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/bills/locale/es/LC_MESSAGES/django.mo" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/bills/locale/es/LC_MESSAGES/django.mo" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/bills/locale/es/LC_MESSAGES/django.po" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/bills/locale/es/LC_MESSAGES/django.po" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/bills/models.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/bills/models.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/bills/settings.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/bills/settings.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/bills/templates/bills/microspective.css" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/bills/templates/bills/microspective.css" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/bills/templates/bills/microspective.html" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/bills/templates/bills/microspective.html" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/databases/admin.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/databases/admin.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/databases/models.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/databases/models.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/domains/admin.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/domains/admin.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/domains/backends.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/domains/backends.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/domains/forms.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/domains/forms.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/domains/models.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/domains/models.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/domains/settings.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/domains/settings.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/domains/validators.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/domains/validators.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/lists/api.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/lists/api.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/lists/backends.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/lists/backends.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/lists/serializers.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/lists/serializers.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/mailboxes/backends.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/mailboxes/backends.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/orchestration/backends.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/orchestration/backends.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/payments/actions.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/payments/actions.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/payments/methods/sepadirectdebit.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/payments/methods/sepadirectdebit.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/saas/models.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/saas/models.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/saas/services/helpers.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/saas/services/helpers.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/systemusers/backends.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/systemusers/backends.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/systemusers/models.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/systemusers/models.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/webapps/backends/php.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/webapps/backends/php.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/webapps/models.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/webapps/models.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/webapps/options.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/webapps/options.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/webapps/serializers.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/webapps/serializers.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/webapps/signals.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/webapps/signals.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/websites/admin.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/websites/admin.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/websites/backends/apache.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/websites/backends/apache.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/orchestra/contrib/websites/models.py" beforeDir="false" afterPath="$PROJECT_DIR$/orchestra/contrib/websites/models.py" afterDir="false" />
|
||||
</list>
|
||||
<option name="EXCLUDED_CONVERTED_TO_IGNORED" value="true" />
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||
</component>
|
||||
<component name="FileEditorManager">
|
||||
<leaf SIDE_TABS_SIZE_LIMIT_KEY="300">
|
||||
<file pinned="false" current-in-tab="true">
|
||||
<entry file="file://$PROJECT_DIR$/orchestra/contrib/lists/backends.py">
|
||||
<provider selected="true" editor-type-id="text-editor">
|
||||
<state relative-caret-position="589">
|
||||
<caret line="124" column="4" lean-forward="true" selection-start-line="124" selection-start-column="4" selection-end-line="124" selection-end-column="4" />
|
||||
</state>
|
||||
</provider>
|
||||
</entry>
|
||||
</file>
|
||||
<file pinned="false" current-in-tab="false">
|
||||
<entry file="file://$PROJECT_DIR$/orchestra/contrib/orchestration/backends.py">
|
||||
<provider selected="true" editor-type-id="text-editor">
|
||||
<state relative-caret-position="4256">
|
||||
<caret line="224" column="8" selection-start-line="224" selection-start-column="8" selection-end-line="224" selection-end-column="8" />
|
||||
</state>
|
||||
</provider>
|
||||
</entry>
|
||||
</file>
|
||||
</leaf>
|
||||
</component>
|
||||
<component name="FindInProjectRecents">
|
||||
<findStrings>
|
||||
<find>check_origin</find>
|
||||
<find>updated_</find>
|
||||
<find>UPDATED_CHECK_ORIGIN</find>
|
||||
</findStrings>
|
||||
<dirStrings>
|
||||
<dir>$PROJECT_DIR$/orchestra/contrib/lists</dir>
|
||||
</dirStrings>
|
||||
</component>
|
||||
<component name="Git.Settings">
|
||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||
</component>
|
||||
<component name="IdeDocumentHistory">
|
||||
<option name="CHANGED_PATHS">
|
||||
<list>
|
||||
<option value="$PROJECT_DIR$/orchestra/contrib/lists/backends.py" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="ProjectConfigurationFiles">
|
||||
<option name="files">
|
||||
<list>
|
||||
<option value="$PROJECT_DIR$/.idea/orchestra.iml" />
|
||||
<option value="$PROJECT_DIR$/.idea/misc.xml" />
|
||||
<option value="$PROJECT_DIR$/.idea/modules.xml" />
|
||||
<option value="$PROJECT_DIR$/.idea/vcs.xml" />
|
||||
<option value="$PROJECT_DIR$/.idea/inspectionProfiles/Project_Default.xml" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="ProjectFrameBounds" extendedState="6">
|
||||
<option name="x" value="959" />
|
||||
<option name="width" value="961" />
|
||||
<option name="height" value="1056" />
|
||||
</component>
|
||||
<component name="ProjectView">
|
||||
<navigator proportions="" version="1">
|
||||
<foldersAlwaysOnTop value="true" />
|
||||
</navigator>
|
||||
<panes>
|
||||
<pane id="ProjectPane">
|
||||
<subPane>
|
||||
<expand>
|
||||
<path>
|
||||
<item name="orchestra" type="b2602c69:ProjectViewProjectNode" />
|
||||
<item name="orchestra" type="462c0819:PsiDirectoryNode" />
|
||||
</path>
|
||||
<path>
|
||||
<item name="orchestra" type="b2602c69:ProjectViewProjectNode" />
|
||||
<item name="orchestra" type="462c0819:PsiDirectoryNode" />
|
||||
<item name="orchestra" type="462c0819:PsiDirectoryNode" />
|
||||
</path>
|
||||
<path>
|
||||
<item name="orchestra" type="b2602c69:ProjectViewProjectNode" />
|
||||
<item name="orchestra" type="462c0819:PsiDirectoryNode" />
|
||||
<item name="orchestra" type="462c0819:PsiDirectoryNode" />
|
||||
<item name="contrib" type="462c0819:PsiDirectoryNode" />
|
||||
</path>
|
||||
<path>
|
||||
<item name="orchestra" type="b2602c69:ProjectViewProjectNode" />
|
||||
<item name="orchestra" type="462c0819:PsiDirectoryNode" />
|
||||
<item name="orchestra" type="462c0819:PsiDirectoryNode" />
|
||||
<item name="contrib" type="462c0819:PsiDirectoryNode" />
|
||||
<item name="lists" type="462c0819:PsiDirectoryNode" />
|
||||
</path>
|
||||
</expand>
|
||||
<select />
|
||||
</subPane>
|
||||
</pane>
|
||||
<pane id="Scope" />
|
||||
</panes>
|
||||
</component>
|
||||
<component name="PropertiesComponent">
|
||||
<property name="WebServerToolWindowFactoryState" value="false" />
|
||||
<property name="last_opened_file_path" value="$PROJECT_DIR$" />
|
||||
<property name="node.js.detected.package.eslint" value="true" />
|
||||
<property name="node.js.detected.package.tslint" value="true" />
|
||||
<property name="node.js.path.for.package.eslint" value="project" />
|
||||
<property name="node.js.path.for.package.tslint" value="project" />
|
||||
<property name="node.js.selected.package.eslint" value="(autodetect)" />
|
||||
<property name="node.js.selected.package.tslint" value="(autodetect)" />
|
||||
<property name="nodejs_interpreter_path.stuck_in_default_project" value="undefined stuck path" />
|
||||
<property name="nodejs_npm_path_reset_for_default_project" value="true" />
|
||||
</component>
|
||||
<component name="RunDashboard">
|
||||
<option name="ruleStates">
|
||||
<list>
|
||||
<RuleState>
|
||||
<option name="name" value="ConfigurationTypeDashboardGroupingRule" />
|
||||
</RuleState>
|
||||
<RuleState>
|
||||
<option name="name" value="StatusDashboardGroupingRule" />
|
||||
</RuleState>
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="RunManager">
|
||||
<configuration name="orchestra" type="Python.DjangoServer" factoryName="Django server">
|
||||
<module name="orchestra" />
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
<option name="PARENT_ENVS" value="true" />
|
||||
<envs>
|
||||
<env name="PYTHONUNBUFFERED" value="1" />
|
||||
</envs>
|
||||
<option name="SDK_HOME" value="" />
|
||||
<option name="WORKING_DIRECTORY" value="" />
|
||||
<option name="IS_MODULE_SDK" value="false" />
|
||||
<option name="ADD_CONTENT_ROOTS" value="true" />
|
||||
<option name="ADD_SOURCE_ROOTS" value="true" />
|
||||
<option name="launchJavascriptDebuger" value="false" />
|
||||
<option name="port" value="8000" />
|
||||
<option name="host" value="" />
|
||||
<option name="additionalOptions" value="" />
|
||||
<option name="browserUrl" value="" />
|
||||
<option name="runTestServer" value="false" />
|
||||
<option name="runNoReload" value="false" />
|
||||
<option name="useCustomRunCommand" value="false" />
|
||||
<option name="customRunCommand" value="" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
<component name="SvnConfiguration">
|
||||
<configuration />
|
||||
</component>
|
||||
<component name="TaskManager">
|
||||
<task active="true" id="Default" summary="Default task">
|
||||
<changelist id="573278bf-2cdd-4afd-973a-1660f3364e9d" name="Default Changelist" comment="" />
|
||||
<created>1582799032840</created>
|
||||
<option name="number" value="Default" />
|
||||
<option name="presentableId" value="Default" />
|
||||
<updated>1582799032840</updated>
|
||||
<workItem from="1582799053215" duration="2986000" />
|
||||
<workItem from="1582882260836" duration="4157000" />
|
||||
<workItem from="1582890671958" duration="815000" />
|
||||
</task>
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TimeTrackingManager">
|
||||
<option name="totallyTimeSpent" value="7958000" />
|
||||
</component>
|
||||
<component name="ToolWindowManager">
|
||||
<frame x="-1" y="0" width="1922" height="1057" extended-state="6" />
|
||||
<editor active="true" />
|
||||
<layout>
|
||||
<window_info active="true" content_ui="combo" id="Project" order="0" visible="true" weight="0.124068156" />
|
||||
<window_info id="Structure" order="1" side_tool="true" weight="0.25" />
|
||||
<window_info id="Favorites" order="2" side_tool="true" />
|
||||
<window_info anchor="bottom" id="Message" order="0" />
|
||||
<window_info anchor="bottom" id="Find" order="1" />
|
||||
<window_info anchor="bottom" id="Run" order="2" />
|
||||
<window_info anchor="bottom" id="Debug" order="3" weight="0.4" />
|
||||
<window_info anchor="bottom" id="Cvs" order="4" weight="0.25" />
|
||||
<window_info anchor="bottom" id="Inspection" order="5" weight="0.4" />
|
||||
<window_info anchor="bottom" id="TODO" order="6" />
|
||||
<window_info anchor="bottom" id="Docker" order="7" show_stripe_button="false" />
|
||||
<window_info anchor="bottom" id="Version Control" order="8" />
|
||||
<window_info anchor="bottom" id="Database Changes" order="9" />
|
||||
<window_info anchor="bottom" id="Event Log" order="10" side_tool="true" />
|
||||
<window_info anchor="bottom" id="Terminal" order="11" />
|
||||
<window_info anchor="bottom" id="Python Console" order="12" />
|
||||
<window_info anchor="right" id="Commander" internal_type="SLIDING" order="0" type="SLIDING" weight="0.4" />
|
||||
<window_info anchor="right" id="Ant Build" order="1" weight="0.25" />
|
||||
<window_info anchor="right" content_ui="combo" id="Hierarchy" order="2" weight="0.25" />
|
||||
<window_info anchor="right" id="SciView" order="3" />
|
||||
<window_info anchor="right" id="Database" order="4" />
|
||||
</layout>
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
<option name="version" value="1" />
|
||||
</component>
|
||||
<component name="editorHistoryManager">
|
||||
<entry file="file://$PROJECT_DIR$/orchestra/contrib/lists/backends.py">
|
||||
<provider selected="true" editor-type-id="text-editor">
|
||||
<state relative-caret-position="589">
|
||||
<caret line="124" column="4" lean-forward="true" selection-start-line="124" selection-start-column="4" selection-end-line="124" selection-end-column="4" />
|
||||
</state>
|
||||
</provider>
|
||||
</entry>
|
||||
<entry file="file://$PROJECT_DIR$/orchestra/contrib/orchestration/backends.py">
|
||||
<provider selected="true" editor-type-id="text-editor">
|
||||
<state relative-caret-position="4256">
|
||||
<caret line="224" column="8" selection-start-line="224" selection-start-column="8" selection-end-line="224" selection-end-column="8" />
|
||||
</state>
|
||||
</provider>
|
||||
</entry>
|
||||
</component>
|
||||
</project>
|
106
INSTALL.md
Normal file
106
INSTALL.md
Normal file
|
@ -0,0 +1,106 @@
|
|||
Installation
|
||||
============
|
||||
|
||||
Django-orchestra ships with a set of management commands for automating some of the installation steps.
|
||||
|
||||
These commands are meant to be run within a **clean** Debian-like distribution, you should be specially careful while following this guide on a customized system.
|
||||
|
||||
Django-orchestra can be manually installed on any Linux system, however it is **strongly recommended** to chose the reference platform for your deployment (Debian 8.0 Jessie and Python 3.4).
|
||||
|
||||
|
||||
1. Create a system user for running Orchestra
|
||||
```bash
|
||||
adduser orchestra
|
||||
# not required but it will be very handy
|
||||
sudo adduser orchestra sudo
|
||||
su - orchestra
|
||||
```
|
||||
|
||||
2. Install django-orchestra's source code
|
||||
```bash
|
||||
sudo apt-get install python3-pip
|
||||
sudo pip3 install http://git.io/django-orchestra-dev
|
||||
```
|
||||
|
||||
3. Install requirements
|
||||
```bash
|
||||
sudo orchestra-admin install_requirements
|
||||
```
|
||||
|
||||
4. Create a new project
|
||||
```bash
|
||||
cd ~orchestra
|
||||
orchestra-admin startproject <project_name> # e.g. panel
|
||||
cd <project_name>
|
||||
```
|
||||
|
||||
5. Create and configure a Postgres database
|
||||
```bash
|
||||
sudo apt-get install python3-psycopg2 postgresql
|
||||
sudo python3 manage.py setuppostgres --db_password <password>
|
||||
python3 manage.py migrate
|
||||
```
|
||||
|
||||
6. Configure periodic execution of tasks (choose one)
|
||||
1. Use cron (recommended)
|
||||
```bash
|
||||
python3 manage.py setupcronbeat
|
||||
python3 manage.py syncperiodictasks
|
||||
```
|
||||
|
||||
2. Use celeryd
|
||||
```bash
|
||||
sudo apt-get install rabbitmq-server
|
||||
sudo python3 manage.py setupcelery --username orchestra
|
||||
```
|
||||
|
||||
7. (Optional) Configure logging
|
||||
```bash
|
||||
sudo python3 manage.py setuplog
|
||||
```
|
||||
|
||||
8. Configure the web server:
|
||||
```bash
|
||||
python3 manage.py collectstatic --noinput
|
||||
sudo apt-get install nginx-full uwsgi uwsgi-plugin-python3
|
||||
sudo python3 manage.py setupnginx --user orchestra
|
||||
```
|
||||
|
||||
6. See the Django deployment checklist
|
||||
```bash
|
||||
python3 manage.py check --deploy
|
||||
```
|
||||
|
||||
9. Start all services:
|
||||
```bash
|
||||
sudo python3 manage.py startservices
|
||||
```
|
||||
|
||||
|
||||
Upgrade
|
||||
=======
|
||||
To upgrade your Orchestra installation to the last release you can use `upgradeorchestra` management command. Before rolling the upgrade it is strongly recommended to check the [release notes](http://django-orchestra.readthedocs.org/en/latest/).
|
||||
```bash
|
||||
sudo python3 manage.py upgradeorchestra
|
||||
```
|
||||
|
||||
Current in *development* version (master branch) can be installed by
|
||||
```bash
|
||||
sudo python3 manage.py upgradeorchestra dev
|
||||
```
|
||||
|
||||
Additionally the following command can be used in order to determine the currently installed version:
|
||||
```bash
|
||||
python3 manage.py orchestraversion
|
||||
```
|
||||
|
||||
|
||||
Extra
|
||||
=====
|
||||
|
||||
1. Generate a passwordless ssh key for orchestra user
|
||||
ssh-keygen
|
||||
|
||||
2. Copy this key to all servers orchestra will manage, including itself is neccessary
|
||||
ssh-copy-id root@<server-address>
|
||||
|
39
INSTALLDEV.md
Normal file
39
INSTALLDEV.md
Normal file
|
@ -0,0 +1,39 @@
|
|||
Development and Testing Setup
|
||||
-----------------------------
|
||||
If you are planing to do some development you may want to consider doing it under the following setup
|
||||
|
||||
|
||||
1. Install Docker
|
||||
```sh
|
||||
curl https://get.docker.com/ | sh
|
||||
```
|
||||
|
||||
|
||||
2. Build a new image, create and start a container
|
||||
```bash
|
||||
curl -L https://raw.githubusercontent.com/ribaguifi/django-orchestra/master/scripts/containers/Dockerfile > /tmp/Dockerfile
|
||||
docker build -t orchestra /tmp/
|
||||
docker create --name orchestra -i -t -u orchestra -w /home/orchestra orchestra bash
|
||||
docker start orchestra
|
||||
docker attach orchestra
|
||||
```
|
||||
|
||||
|
||||
3. Deploy django-orchestra development environment, inside the container
|
||||
```bash
|
||||
bash <( curl -L https://raw.githubusercontent.com/ribaguifi/django-orchestra/master/scripts/containers/deploy.sh ) --dev
|
||||
```
|
||||
|
||||
3. Nginx should be serving on port 80, but Django's development server can be used as well:
|
||||
```bash
|
||||
cd panel
|
||||
python3 manage.py migrate
|
||||
python3 manage.py runserver 0.0.0.0:8888
|
||||
```
|
||||
|
||||
|
||||
5. To upgrade to current master just re-run the deploy script
|
||||
```bash
|
||||
git pull origin master
|
||||
bash <( curl -L https://raw.githubusercontent.com/ribaguifi/django-orchestra/master/scripts/containers/deploy.sh ) --dev
|
||||
```
|
35
LICENSE
Normal file
35
LICENSE
Normal file
|
@ -0,0 +1,35 @@
|
|||
Copyright (c) 2014 Marc Aymerich and individual contributors
|
||||
All Rights Reserved.
|
||||
|
||||
Django-orchestra is licensed under The BSD License (3 Clause, also known as
|
||||
the new BSD license). The license is an OSI approved Open Source
|
||||
license and is GPL-compatible(1).
|
||||
|
||||
The license text can also be found here:
|
||||
http://www.opensource.org/licenses/BSD-3-Clause
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name of Ask Solem, nor the
|
||||
names of its contributors may be used to endorse or promote products
|
||||
derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL Ask Solem OR CONTRIBUTORS
|
||||
BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
8
MANIFEST.in
Normal file
8
MANIFEST.in
Normal file
|
@ -0,0 +1,8 @@
|
|||
recursive-include orchestra *
|
||||
|
||||
recursive-exclude * __pycache__
|
||||
recursive-exclude * *.py[co]
|
||||
recursive-exclude * *~
|
||||
recursive-exclude * *.save
|
||||
recursive-exclude * *.svg
|
||||
|
121
README.md
Normal file
121
README.md
Normal file
|
@ -0,0 +1,121 @@
|
|||
![](orchestra/static/orchestra/icons/Emblem-important.png) **This project is in early development stage**
|
||||
|
||||
Django Orchestra
|
||||
================
|
||||
|
||||
Orchestra is a Django-based framework for building web hosting control panels.
|
||||
|
||||
* [Installation](#fast-deployment-setup)
|
||||
* [Roadmap](ROADMAP.md)
|
||||
|
||||
|
||||
Motivation
|
||||
----------
|
||||
|
||||
There are a lot of widely used open source hosting control panels, however, none of them seems apropiate when you already have an existing service infrastructure or simply you want your services to run on a particular architecture.
|
||||
|
||||
The goal of this project is to provide the tools for easily build a fully featured control panel that is not tied to any particular service architecture.
|
||||
|
||||
Overview
|
||||
--------
|
||||
|
||||
* The **admin interface** is based on [Django Admin](https://docs.djangoproject.com/en/dev/ref/contrib/admin/). The resulting interface is very model-centric with a limited workflow pattern: change lists, add and change forms. The advantage is that only little declarative code is required.
|
||||
* It does **not** provide a **customer-facing interface**, but provides a REST API that allows you to build one.
|
||||
* Service [orchestration](orchestra/contrib/orchestration), [resource management](orchestra/contrib/resources), [billing](orchestra/contrib/bills), [accountancy](orchestra/contrib/orders) is provided in a decoupled way, meaning:
|
||||
* You can [develop new services](docs/create-services.md) without worring about those parts
|
||||
* You can replace any of these parts by your own implementation without carring about the others
|
||||
* You can reuse any of those modules on your Django projects
|
||||
* Be advised, because its flexibility Orchestra may be more tedious to deploy than traditional web hosting control panels.
|
||||
|
||||
|
||||
![](docs/images/index-screenshot.png)
|
||||
|
||||
|
||||
Fast Deployment Setup
|
||||
---------------------
|
||||
|
||||
This deployment is **not suitable for production** but more than enough for checking out this project. For other deployments checkout these links:
|
||||
* [Development](INSTALLDEV.md)
|
||||
* [Production](INSTALL.md)
|
||||
|
||||
```bash
|
||||
# Create and activate a Python virtualenv
|
||||
# Make sure python3.x-venv package is installed on your system
|
||||
python3 -mvenv env-django-orchestra
|
||||
source env-django-orchestra/bin/activate
|
||||
|
||||
# Install Orchestra and its dependencies
|
||||
pip3 install http://git.io/django-orchestra-dev
|
||||
# The only non-pip required dependency for runing pip3 install is python3-dev
|
||||
sudo apt-get install python3-dev
|
||||
pip3 install -r http://git.io/orchestra-requirements.txt
|
||||
|
||||
# Create a new Orchestra site
|
||||
orchestra-admin startproject panel
|
||||
python3 panel/manage.py migrate
|
||||
python3 panel/manage.py runserver
|
||||
```
|
||||
|
||||
Now you can see the web interface on `http://localhost:8000/admin/`
|
||||
|
||||
|
||||
|
||||
Quick Start
|
||||
-----------
|
||||
0. Install django-orchestra following any of these methods:
|
||||
1. [PIP-only, Fast deployment setup (demo)](#fast-deployment-setup)
|
||||
2. [Docker container (development)](INSTALLDEV.md)
|
||||
3. [Install on current system (production)](INSTALL.md)
|
||||
|
||||
1. Generate a password-less SSH key for user `orchestra` and transfer it to your servers:
|
||||
```bash
|
||||
orchestra@panel:~ ssh-keygen
|
||||
orchestra@panel:~ ssh-copy-id root@server.address
|
||||
```
|
||||
Now add the servers using the web interface `/admin/orchestration/servers`, check that the SSH connection is working and Orchestra is able to report servers uptimes.
|
||||
|
||||
2. Configure your services, one at a time, staring with domains, databases, webapps, websites, ...
|
||||
1. Add related [routes](orchestra/contrib/orchestration) via `/admin/orchestration/route/`
|
||||
2. Configure related settings on `/admin/settings/setting/`
|
||||
3. If required, configure related [resources](orchestra/contrib/resources) like *account disk limit*, *VPS traffic*, etc `/resources/resource/`
|
||||
3. Test if create and delete service instances works as expected
|
||||
4. Do the same for the remaining services. You can disable services that you don't want by editing `INSTALLED_APPS` setting
|
||||
|
||||
3. Configure billing by adding [services](orchestra/contrib/services) `/admin/services/service/add/` and [plans](orchestra/contrib/plans) `/admin/plans/plan/`. Once a service is created hit the *Update orders* button to create orders for existing service instances, orders for new instances will be automatically created.
|
||||
|
||||
|
||||
|
||||
License
|
||||
-------
|
||||
Copyright (c) 2014 - Marc Aymerich and individual contributors.
|
||||
All Rights Reserved.
|
||||
|
||||
Django-orchestra is licensed under The BSD License (3 Clause, also known as
|
||||
the new BSD license). The license is an OSI approved Open Source
|
||||
license and is GPL-compatible(1).
|
||||
|
||||
The license text can also be found here:
|
||||
http://www.opensource.org/licenses/BSD-3-Clause
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
* Neither the name of Marc Aymerich, nor the
|
||||
names of its contributors may be used to endorse or promote products
|
||||
derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL Ask Solem OR CONTRIBUTORS
|
||||
BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
69
ROADMAP.md
Normal file
69
ROADMAP.md
Normal file
|
@ -0,0 +1,69 @@
|
|||
# Roadmap
|
||||
|
||||
Note `*` _for sustancial progress_
|
||||
|
||||
|
||||
### 1.0a1 Milestone (first alpha release on ~~Oct '14~~ Apr '15)
|
||||
|
||||
1. [x] Automated deployment of the development environment
|
||||
2. [x] Automated installation and upgrading
|
||||
2. ~~[ ] Testing framework for running unittests and functional tests with LXC containers~~
|
||||
2. [ ] Continuous integration with Jenkins
|
||||
2. [x] Admin interface based on django.contrib.admin
|
||||
3. [x] REST API for users
|
||||
2. [x] [Orchestra-orm](https://github.com/glic3rinu/orchestra-orm) a Python library for easily interacting with Orchestra REST API
|
||||
3. [x] Service orchestration framework
|
||||
4. [x] Data model, crazy input validation, admin and REST interfaces, permissions, unit and functional tests, service management, migration scripts and documentation of:
|
||||
1. [x] PHP/static Web applications
|
||||
1. [x] Websites with Apache
|
||||
2. [x] FTP/rsync/scp/shell system accounts
|
||||
2. [x] Databases and database users with MySQL
|
||||
1. [x] Mail accounts, aliases, forwards with Postfix and Dovecot
|
||||
1. [x] DNS with Bind
|
||||
1. [x] Mailing lists with Mailman
|
||||
1. [x] Contact management and service contraction
|
||||
1. [ ] *Unittests of the bussines logic
|
||||
2. [x] Functional tests of Admin UI and REST interations
|
||||
1. [ ] Initial documentation
|
||||
|
||||
|
||||
### 1.0b1 Milestone (first beta release on ~~Dec '14~~ Jun '15)
|
||||
|
||||
1. [x] Resource allocation and monitoring
|
||||
1. [x] Order tracking
|
||||
2. [x] Service definition framework, service plans and pricing
|
||||
3. [ ] *Billing
|
||||
3. [x] Invoice
|
||||
3. [x] Membership fee
|
||||
3. [x] Amendment invoice
|
||||
3. [x] Amendment fee
|
||||
3. [x] Pro Forma
|
||||
3. [ ] *Advanced bill handling (move lines, undo billing, ...)
|
||||
1. [x] Payment methods
|
||||
1. [x] SEPA Direct Debit
|
||||
2. [x] SEPA Credit Transfer
|
||||
2. [ ] Additional services
|
||||
2. [ ] *VPS with Proxmox/OpenVZ
|
||||
2. [x] SaaS (Software as a Service) Gitlab/phpList/BSCW/Wordpress/Moodle/Drupal
|
||||
2. [x] Wordpress webapps
|
||||
3. [ ] uwsgi-emperor Python webapps
|
||||
2. [x] Miscellaneous services
|
||||
2. [x] Issue tracking system
|
||||
|
||||
|
||||
### 1.0 Milestone (first stable release on Sep '15)
|
||||
|
||||
1. [ ] Stabilize data model, internal APIs and REST API
|
||||
3. [ ] Spanish and Catalan translations
|
||||
1. [ ] Complete documentation for developers
|
||||
|
||||
|
||||
### 2.0 Milestone (unscheduled)
|
||||
|
||||
1. [ ] Integration with third-party service providers, e.g. Gandi
|
||||
2. [ ] Scheduling of service cancellations and deactivations
|
||||
1. [ ] Object-level permission system
|
||||
2. [ ] REST API functionality for superusers
|
||||
3. [ ] Responsive user interface, based on a JS framework.
|
||||
4. [ ] Full development documentation
|
||||
5. [ ] [Ansible](http://www.ansible.com/home) orchestration method, which synchronizes the whole service config everytime instead of incremental changes.
|
472
TODO.md
Normal file
472
TODO.md
Normal file
|
@ -0,0 +1,472 @@
|
|||
==== TODO ====
|
||||
* use format_html_join for orchestration email alerts
|
||||
|
||||
* enforce an emergency email contact and account to contact contacts about problems when mailserver is down
|
||||
|
||||
* add `BackendLog` retry action
|
||||
|
||||
* webmail identities and addresses
|
||||
|
||||
* Permissions .filter_queryset()
|
||||
|
||||
* env vars instead of multiple settings files: https://devcenter.heroku.com/articles/config-vars ?
|
||||
|
||||
* backend logs with hal logo
|
||||
|
||||
* help_text on readonly_fields specialy Bill.state. (eg. A bill is in OPEN state when bla bla )
|
||||
|
||||
* order.register_at
|
||||
@property
|
||||
def register_on(self):
|
||||
return order.register_at.date()
|
||||
|
||||
* mail backend related_models = ('resources__content_type') ??
|
||||
|
||||
* Maildir billing tests/ webdisk billing tests (avg metric)
|
||||
|
||||
* when using modeladmin to store shit like self.account, make sure to have a cleanslate in each request? no, better reuse the last one
|
||||
|
||||
* jabber with mailbox accounts (dovecot mail notification)
|
||||
|
||||
* rename accounts register to "account", and reated api and admin references
|
||||
|
||||
* AccountAdminMixin auto adds 'account__name' on searchfields
|
||||
|
||||
* What fields we really need on contacts? name email phone and what more?
|
||||
|
||||
* DOC: Complitely decouples scripts execution, billing, service definition
|
||||
|
||||
* init.d celery scripts
|
||||
-# Required-Start: $network $local_fs $remote_fs postgresql celeryd
|
||||
-# Required-Stop: $network $local_fs $remote_fs postgresql celeryd
|
||||
|
||||
* regenerate virtual_domains every time (configure a separate file for orchestra on postfix)
|
||||
|
||||
* Backend optimization
|
||||
* fields = ()
|
||||
* ignore_fields = ()
|
||||
* based on a merge set of save(update_fields)
|
||||
|
||||
* proforma without billing contact?
|
||||
|
||||
* print open invoices as proforma?
|
||||
|
||||
* env ORCHESTRA_MASTER_SERVER='test1.orchestra.lan' ORCHESTRA_SECOND_SERVER='test2.orchestra.lan' ORCHESTRA_SLAVE_SERVER='test3.orchestra.lan' python3 manage.py test orchestra.contrib.domains.tests.functional_tests.tests:AdminBind9BackendDomainTest --nologcapture --keepdb
|
||||
|
||||
* ForeignKey.swappable
|
||||
|
||||
* REST PERMISSIONS
|
||||
|
||||
* Databases.User add reverse M2M databases widget (like mailbox.addresses)
|
||||
|
||||
* Make one dedicated CGI user for each account only for CGI execution (fpm/fcgid). Different from the files owner, and without W permissions, so attackers can not inject backdors and malware.
|
||||
|
||||
* resource min max allocation with validation
|
||||
|
||||
* domain validation parse named-checzone output to assign errors to fields
|
||||
|
||||
* Directory Protection on webapp and use webapp path as base path (validate)
|
||||
|
||||
* webapp backend option compatibility check? raise exception, missconfigured error
|
||||
|
||||
* Resource used_list_display=True, allocated_list_displat=True, allow resources to show up on list_display
|
||||
|
||||
* BackendLog.updated_at (tasks that run over several minutes when finished they do not appear first on the changelist) (like celery tasks.when)
|
||||
|
||||
* Create an admin service_view with icons (like SaaS app)
|
||||
|
||||
* prevent @pangea.org email addresses on contacts, enforce at least one email without @pangea.org
|
||||
|
||||
ln -s /proc/self/fd /dev/fd
|
||||
|
||||
|
||||
POST INSTALL
|
||||
------------
|
||||
|
||||
* Generate a password-less ssh key, and copy it to the servers you want to orchestrate.
|
||||
ssh-keygen
|
||||
ssh-copy-id root@<server-address>
|
||||
|
||||
Php binaries should have this format: /usr/bin/php5.2-cgi
|
||||
|
||||
|
||||
* logs on panel/logs/ ? mkdir ~webapps, backend post save signal?
|
||||
* <IfModule security2_module> and other IfModule on backend SecRule
|
||||
|
||||
# Orchestra global search box on the page head, based https://github.com/django/django/blob/master/django/contrib/admin/options.py#L866 and iterating over all registered services and inspectin its admin.search_fields
|
||||
|
||||
* contain error on plugin missing key (plugin dissabled): NOP, fail hard is better than silently, perhaps fail at starttime? apploading machinary
|
||||
|
||||
* contact.alternative_phone on a phone.tooltip, email:to
|
||||
|
||||
* make sure that you understand the risks
|
||||
|
||||
* full support for deactivation of services/accounts
|
||||
* Display admin.is_active (disabled account special icon and order by support)
|
||||
|
||||
* lock resource monitoring
|
||||
* -EXecCGI in common CMS upload locations /wp-upload/upload/uploads
|
||||
* cgi user / pervent shell access
|
||||
|
||||
* prevent stderr when users exists on backend i.e. mysql user create
|
||||
|
||||
* disable anonymized list options (mailman)
|
||||
|
||||
* tags = GenericRelation(TaggedItem, related_query_name='bookmarks')
|
||||
|
||||
* user provided crons
|
||||
|
||||
* ```<?php
|
||||
$moodle_host = $SERVER[‘HTTP_HOST’];
|
||||
require_once(‘/etc/moodles/’.$moodle_host.‘config.php’);``` moodle/drupla/php-list multi-tenancy
|
||||
|
||||
* make account available on all admin forms
|
||||
|
||||
* more robust backend error handling, continue executing but exit code > 0 if failure: failing_cmd || exit_code=1 and don't forget to call super.commit()!!
|
||||
|
||||
* website directives uniquenes validation on serializers
|
||||
|
||||
+ is_Active custom filter with support for instance.account.is_Active annotate with F() needed (django 1.8)
|
||||
|
||||
* document service help things: discount/refound/compensation effect and metric table
|
||||
* Document metric interpretation help_text
|
||||
* document plugin serialization, data_serializer?
|
||||
* Document strong input validation
|
||||
|
||||
# bill line managemente, remove, undo (only when possible), move, copy, paste
|
||||
* budgets: no undo feature
|
||||
|
||||
* Autocomplete admin fields like <site_name>.phplist... with js
|
||||
|
||||
* allow empty metric pack for default rates? changes on rating algo
|
||||
|
||||
* payment methods icons
|
||||
* use server.name | server.address on python backends, like gitlab instead of settings?
|
||||
|
||||
* TODO raise404, here and everywhere
|
||||
* update service orders on a celery task? because it take alot
|
||||
|
||||
# FIXME do more test, make sure billed until doesn't get uodated whhen services are billed with les metric, and don't upgrade billed_until when undoing under this circumstances
|
||||
# * line 513: change threshold and one time service metric change should update last value if not billed, only record for recurring invoicing. postpay services should store the last metric for pricing period.
|
||||
# * add ini, end dates on bill lines and breakup quanity into size(defaut:1) and metric
|
||||
# * threshold for significative metric accountancy on services.handler
|
||||
# * http://orchestra.pangea.org/admin/orders/order/6418/
|
||||
|
||||
* move normurlpath to orchestra.utils from websites.utils
|
||||
|
||||
* write down insights
|
||||
|
||||
* websites directives get_location() and use it on last change view validation stage to compare with contents.location and also on the backend ?
|
||||
|
||||
* modeladmin Default filter + search isn't working, prepend filter when searching
|
||||
|
||||
* create service help templates based on urlqwargs with the most basic services.
|
||||
|
||||
Translation
|
||||
-----------
|
||||
mkdir locale
|
||||
django-admin.py makemessages -l ca
|
||||
django-admin.py compilemessages -l ca
|
||||
|
||||
https://docs.djangoproject.com/en/1.7/topics/i18n/translation/#joining-strings-string-concat
|
||||
|
||||
from django.utils.translation import gettext
|
||||
from django.utils import translation
|
||||
translation.activate('ca')
|
||||
gettext("Description")
|
||||
|
||||
* saas validate_creation generic approach, for all backends. standard output
|
||||
|
||||
# create orchestrate databases.Database pk=1 -n --dry-run | --noinput --action save (default)|delete --backend name (limit to this backend) --help
|
||||
|
||||
* postupgradeorchestra send signals in order to hook custom stuff
|
||||
|
||||
* gevent is not ported to python3 :'(
|
||||
|
||||
# FIXME account deletion generates an integrity error
|
||||
https://code.djangoproject.com/ticket/24576
|
||||
# FIXME what to do when deleting accounts? set fk null and fill a username charfield? issues, invoices.. we whant all this to go away?
|
||||
* implement delete All related services
|
||||
|
||||
* read https://docs.djangoproject.com/en/dev/releases/1.8/ and fix deprecation warnings
|
||||
|
||||
* create nice fieldsets for SaaS, WebApp types and services, and helptexts too!
|
||||
|
||||
* replace make_option in management commands
|
||||
|
||||
# FIXME model contact info and account info (email, name, etc) correctly/unredundant/dry
|
||||
|
||||
* Use the new django.contrib.admin.RelatedOnlyFieldListFilter in ModelAdmin.list_filter to limit the list_filter choices to foreign objects which are attached to those from the ModelAdmin.
|
||||
+ Query Expressions, Conditional Expressions, and Database Functions¶
|
||||
* forms: You can now pass a callable that returns an iterable of choices when instantiating a ChoiceField.
|
||||
|
||||
* move all tests to django-orchestra/tests
|
||||
* *natural keys: those fields that uniquely identify a service, list.name, website.name, webapp.name+account, make sure rest api can not edit thos things
|
||||
|
||||
* MultiCHoiceField proper serialization
|
||||
|
||||
* replace unique_name by natural_key?
|
||||
* do not require contact or create default
|
||||
* abstract model classes that enabling overriding, and ORCHESTRA_DATABASE_MODEL settings + orchestra.get_database_model() instead of explicitly importing from orchestra.contrib.databases.models import Database.. (Admin and REST API are fucked then?)
|
||||
|
||||
# billing order list filter detect metrics that are greater from those of billing_date
|
||||
# Ignore superusers & co on billing: list filter doesn't work nor ignore detection
|
||||
# bill.totals make it 100% computed?
|
||||
* joomla: wget https://github.com/joomla/joomla-cms/releases/download/3.4.1/Joomla_3.4.1-Stable-Full_Package.tar.gz -O - | tar xvfz -
|
||||
|
||||
# Amend lines???
|
||||
# orders currency setting
|
||||
|
||||
# Determine the difference between data serializer used for validation and used for the rest API!
|
||||
# Make PluginApiView that fills metadata and other stuff like modeladmin plugin support
|
||||
|
||||
# reset setting button
|
||||
|
||||
# admin edit relevant djanog settings
|
||||
# django SITE_NAME vs ORCHESTRA_SITE_NAME ?
|
||||
|
||||
|
||||
# TASKS_ENABLE_UWSGI_CRON_BEAT (default) for production + system check --deploy
|
||||
if 'wsgi' in sys.argv and settings.TASKS_ENABLE_UWSGI_CRON_BEAT:
|
||||
import uwsgi
|
||||
def uwsgi_beat(signum):
|
||||
print "It's 5 o'clock of the first day of the month."
|
||||
uwsgi.register_signal(99, '', uwsgi_beat)
|
||||
uwsgi.add_timer(99, 60)
|
||||
# TASK_BEAT_BACKEND = ('cron', 'celerybeat', 'uwsgi')
|
||||
# Ship orchestra production-ready (no DEBUG etc)
|
||||
|
||||
# reload generic admin view ?redirect=http...
|
||||
# inspecting django db connection for asserting db readines? or performing a query
|
||||
* wake up django mailer on send_mail
|
||||
|
||||
from orchestra.contrib.tasks import task
|
||||
import time, sys
|
||||
@task(name='rata')
|
||||
def counter(num, log):
|
||||
for i in range(1, num):
|
||||
with open(log, 'a') as handler:
|
||||
handler.write(str(i))
|
||||
sys.stderr.write('hola\n')
|
||||
time.sleep(1)
|
||||
counter.apply_async(10, '/tmp/kakas')
|
||||
|
||||
* Provide some fixtures with mocked data
|
||||
|
||||
|
||||
TODO http://wiki2.dovecot.org/HowTo/SimpleVirtualInstall
|
||||
TODO http://wiki2.dovecot.org/HowTo/VirtualUserFlatFilesPostfix
|
||||
TODO mount the filesystem with "nosuid" option
|
||||
|
||||
* uwse uwsgi cron: decorator or config cron = 59 2 -1 -1 -1 %(virtualenv)/bin/python manage.py runmyfunnytask
|
||||
|
||||
# mailboxes.address settings multiple local domains, not only one?
|
||||
# backend.context = self.get_context() or save(obj, context=None) ?? more like form.cleaned_data
|
||||
|
||||
# smtplib.SMTPConnectError: (421, b'4.7.0 mail.pangea.org Error: too many connections from 77.246.181.209')
|
||||
|
||||
# rename virtual_maps to virtual_alias_maps and remove virtual_alias_domains ?
|
||||
# virtdomains file is not ideal, prevent user provided fake/error domains there! and make sure to chekc if this file is required!
|
||||
|
||||
# Deprecate restart/start/stop services (do touch wsgi.py and fuck celery)
|
||||
orchestra-beat support for uwsgi cron
|
||||
|
||||
make django admin taskstate uncollapse fucking traceback, ( if exists ?)
|
||||
|
||||
# form for custom message on admin save "comment & save"?
|
||||
|
||||
# backend.context and backned.instance provided when an action is called? like forms.cleaned_data: do it on manager.generation(backend.context = backend.get_context()) or in backend.__getattr__ ? also backend.head,tail,content switching on manager.generate()?
|
||||
|
||||
resorce monitoring more efficient, less mem an better queries for calc current data
|
||||
|
||||
# bill this https://orchestra.pangea.org/admin/orders/order/8236/ should be already billed, <= vs <
|
||||
# Convert rating method from function to PluginClass
|
||||
|
||||
# autoresponses on mailboxes, not addresses or remove them
|
||||
|
||||
# force save and continue on routes (and others?)
|
||||
# gevent for python3
|
||||
apt-get install cython3
|
||||
export CYTHON='cython3'
|
||||
pip3 install https://github.com/fantix/gevent/archive/master.zip
|
||||
|
||||
|
||||
# SIgnal handler for notify workers to reload stuff, like resource sync: https://docs.python.org/2/library/signal.html
|
||||
|
||||
# BUG Delete related services also deletes account!
|
||||
|
||||
# get_related service__rates__isnull=TRue is that correct?
|
||||
|
||||
# uwsgi hot reload? http://uwsgi-docs.readthedocs.org/en/latest/articles/TheArtOfGracefulReloading.html
|
||||
|
||||
# change mailer.message.priority by, queue/sent inmediatelly or rename critical to noq
|
||||
|
||||
|
||||
method(
|
||||
arg, arg, arg)
|
||||
|
||||
|
||||
Bash/Python/PHPController
|
||||
|
||||
# services.handler as generator in order to save memory? not swell like a balloon
|
||||
|
||||
import uwsgi
|
||||
from uwsgidecorators import timer
|
||||
from django.utils import autoreload
|
||||
|
||||
@timer(3)
|
||||
def change_code_gracefull_reload(sig):
|
||||
if autoreload.code_changed():
|
||||
uwsgi.reload()
|
||||
# using kill to send the signal
|
||||
kill -HUP `cat /tmp/project-master.pid`
|
||||
# or the convenience option --reload
|
||||
uwsgi --reload /tmp/project-master.pid
|
||||
# or if uwsgi was started with touch-reload=/tmp/somefile
|
||||
touch /tmp/somefile
|
||||
|
||||
# Serializers.validation migration to DRF3: grep -r 'attrs, source' *|grep -v '~'
|
||||
serailzer self.instance on create.
|
||||
|
||||
* check certificate: websites directive ssl + domains search on miscellaneous
|
||||
|
||||
# billing invoice link on related invoices not overflow nginx GET vars
|
||||
|
||||
* backendLog store method and language... and use it for display_script with correct lexer
|
||||
|
||||
@register.filter
|
||||
def comma(value):
|
||||
value = str(value)
|
||||
if '.' in value:
|
||||
left, right = str(value).split('.')
|
||||
return ','.join((left, right))
|
||||
return value
|
||||
|
||||
|
||||
# payment/bill report allow to change template using a setting variable
|
||||
# Payment transaction stats, graphs over time
|
||||
|
||||
reporter.stories_filed = F('stories_filed') + 1
|
||||
reporter.save()
|
||||
In order to access the new value that has been saved in this way, the object will need to be reloaded:
|
||||
https://docs.djangoproject.com/en/dev/ref/models/conditional-expressions/
|
||||
Greatest
|
||||
Colaesce('total', 'computed_total')
|
||||
Case
|
||||
|
||||
# SQL case on payment transaction state ? case when trans.amount >
|
||||
|
||||
# Resource inline links point to custom changelist view that preserve state (breadcrumbs, title, etc) rather than generic changeview with queryarg filtering
|
||||
|
||||
# ORDER diff Pending vs ALL
|
||||
|
||||
# DELETING RESOURCE RELATED OBJECT SHOULD NOT delete related monitor data for traffic accountancy
|
||||
|
||||
# round decimals on every billing operation
|
||||
|
||||
# use "su $user --shell /bin/bash" on backends for security : MKDIR -p...
|
||||
|
||||
# model.field.flatchoices
|
||||
|
||||
* This is beta software, please test thoroughly before putting into production and report back any issues.
|
||||
|
||||
# messages SMTP errors: temporary->deferre else Failed
|
||||
|
||||
# Don't enforce one contact per account? remove account.email in favour of contacts?
|
||||
|
||||
# Mailer: mark as sent
|
||||
# Mailer: download attachments
|
||||
|
||||
# Enable/disable ignore period orders list filter
|
||||
|
||||
|
||||
# Modsecurity rules template by cms (wordpress, joomla, dokuwiki (973337 973338 973347 958057), ...
|
||||
|
||||
|
||||
|
||||
deploy --dev
|
||||
deploy.sh and deploy-dev.sh autoupgrade
|
||||
|
||||
short URLS: https://github.com/rsvp/gitio
|
||||
|
||||
link backend help text variables to settings/#var_name
|
||||
|
||||
mkhomedir_helper or create ssh homes with bash.rc and such
|
||||
|
||||
# warnings if some plugins are disabled, like make routes red
|
||||
# replace show emails by https://docs.python.org/3/library/email.contentmanager.html#module-email.contentmanager
|
||||
|
||||
|
||||
|
||||
# setupforbiddendomains --url alexa -n 5000
|
||||
|
||||
|
||||
* remove welcome box on dashboard?
|
||||
|
||||
# account contacts inline, show provided fields and ignore the rest?
|
||||
# email usage -webkit-column-count:3;-moz-column-count:3;column-count:3;
|
||||
|
||||
|
||||
# validate_user on saas.wordpress to detect if username already exists before attempting to create a blog
|
||||
|
||||
|
||||
# webapps don't override owner and permissions on every save(), just on create
|
||||
# webapps php fpm allow pool config to be overriden. template + pool inheriting template?
|
||||
# get_context signal to overridaconfiguration? best practice: all context on get_context, ever use other context. template rendering as backend generator: proof of concept
|
||||
|
||||
|
||||
# if not database_ready(): schedule a retry in 60 seconds, otherwise resources and other dynamic content gets fucked, maybe attach some 'signal' when first query goes trough
|
||||
with database_ready:
|
||||
shit_happend, otherwise schedule for first query
|
||||
# Entry.objects.filter()[:1].first() (LIMIT 1)
|
||||
|
||||
|
||||
# Reverse lOgHistory order by date (lastest first)
|
||||
|
||||
* setuppostgres use porject_name for db name and user instead of orchestra
|
||||
|
||||
# POSTFIX web traffic monitor '": uid=" from=<%(user)s>'
|
||||
|
||||
# Automatically re-run backends until success? only timedout executions?
|
||||
# TODO save serialized versions ob backendoperation.instance in order to allow backend reexecution of deleted objects
|
||||
|
||||
# lets encrypt: DNS vs HTTP challange
|
||||
# lets enctypt: autorenew
|
||||
|
||||
# Warning websites with ssl options without https protocol
|
||||
|
||||
# Schedule cancellation
|
||||
|
||||
# Multiple domains wordpress
|
||||
|
||||
# Reversion
|
||||
# Disable/enable SaaS and VPS
|
||||
|
||||
# Don't show lines with size 0?
|
||||
# pending orders with recharge do not show up
|
||||
# Traffic of disabled accounts doesn't get disabled
|
||||
|
||||
# URL encode "Order description" on clone
|
||||
# Service CLONE METRIC doesn't work
|
||||
|
||||
# Show warning when saving order and metricstorage date is inconistent with registered date!
|
||||
# exclude from change list action, support for multiple exclusion
|
||||
|
||||
# breadcrumbs https://orchestra.pangea.org/admin/domains/domain/?account_id=930
|
||||
|
||||
with open(file) as handler:
|
||||
os.unlink(file)
|
||||
|
||||
|
||||
# Mark transaction process as executed should not override higher transaction states
|
||||
# Bill amend and related transaction, what to do? allow edit transaction ammount of amends when their are pending execution
|
||||
|
||||
# DASHBOARD: Show owned tickets, scheduled actions, maintenance operations (diff domains)
|
||||
|
||||
# Add confirmation step on transaction actions like process transaction
|
||||
|
||||
# SAVE INISTIAL PASSWORD from all services, and just use it to create the service, never update it
|
||||
|
||||
# Don't use system groups for unixmailbackends
|
||||
|
||||
# trigger a reload_relations on updates on monitors on all processes, not just current one. Alt. restart service
|
222
docs/API.rst
Normal file
222
docs/API.rst
Normal file
|
@ -0,0 +1,222 @@
|
|||
=================================
|
||||
Orchestra REST API Specification
|
||||
=================================
|
||||
|
||||
:Version: 0.1
|
||||
|
||||
Resources
|
||||
---------
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
|
||||
Panel [application/vnd.orchestra.Panel+json]
|
||||
============================================
|
||||
|
||||
A Panel represents a user's view of all accessible resources.
|
||||
A "Panel" resource model contains the following fields:
|
||||
|
||||
========================== ============ ========== ===========================
|
||||
**Field name** **Type** **Occurs** **Description**
|
||||
========================== ============ ========== ===========================
|
||||
uri URI 1 A GET against this URI refreshes the client representation of the resources accessible to this user.
|
||||
services Object[] 0..1 {'DNS': {'names': "names_URI", 'zones': "zones_URI}, {'Mail': {'Virtual_user': "virtual_user_URI" ....
|
||||
accountancy Object[] 0..1
|
||||
administration Object[] 0..1
|
||||
========================== ============ ========== ===========================
|
||||
|
||||
|
||||
Contact [application/vnd.orchestra.Contact+json]
|
||||
================================================
|
||||
|
||||
A Contact represents
|
||||
|
||||
========================== ============ ========== ===========================
|
||||
**Field name** **Type** **Occurs** **Description**
|
||||
========================== ============ ========== ===========================
|
||||
uri URI 1
|
||||
name String 1
|
||||
surname String 0..1
|
||||
second_surname String 0..1
|
||||
national_id String 1
|
||||
type String 1
|
||||
language String 1
|
||||
address String 1
|
||||
city String 1
|
||||
zipcode Number 1
|
||||
province String 1
|
||||
country String 1
|
||||
fax String 0..1
|
||||
emails String[] 1
|
||||
phones String[] 1
|
||||
billing_contact Contact 0..1
|
||||
technical_contact Contact 0..1
|
||||
administrative_contact Contact 0..1
|
||||
========================== ============ ========== ===========================
|
||||
|
||||
TODO: phone and emails for this contacts this contacts should be equal to Contact on Django models
|
||||
|
||||
|
||||
User [application/vnd.orchestra.User+json]
|
||||
==========================================
|
||||
|
||||
A User represents
|
||||
|
||||
========================== ============ ========== ===========================
|
||||
**Field name** **Type** **Occurs** **Description**
|
||||
========================== ============ ========== ===========================
|
||||
username String
|
||||
uri URI 1
|
||||
contact Contact
|
||||
password String
|
||||
first_name String
|
||||
last_name String
|
||||
email_address String
|
||||
active Boolean
|
||||
staff_status Boolean
|
||||
superuser_status Boolean
|
||||
groups Group
|
||||
user_permissions Permission[]
|
||||
last_login String
|
||||
date_joined String
|
||||
system_user SystemUser
|
||||
virtual_user VirtualUser
|
||||
========================== ============ ========== ===========================
|
||||
|
||||
|
||||
SystemUser [application/vnd.orchestra.SystemUser+json]
|
||||
======================================================
|
||||
|
||||
========================== =========== ========== ===========================
|
||||
**Field name** **Type** **Occurs** **Description**
|
||||
========================== =========== ========== ===========================
|
||||
user User
|
||||
uri URI 1
|
||||
user_shell String
|
||||
user_uid Number
|
||||
primary_group Group
|
||||
homedir String
|
||||
only_ftp Boolean
|
||||
========================== =========== ========== ===========================
|
||||
|
||||
|
||||
VirtualUser [application/vnd.orchestra.VirtualUser+json]
|
||||
========================================================
|
||||
|
||||
========================== ============ ========== ===========================
|
||||
**Field name** **Type** **Occurs** **Description**
|
||||
========================== ============ ========== ===========================
|
||||
user User
|
||||
uri URI 1
|
||||
emailname String
|
||||
domain Name <VirtualDomain?>
|
||||
home String
|
||||
========================== ============ ========== ===========================
|
||||
|
||||
Zone [application/vnd.orchestra.Zone+json]
|
||||
==========================================
|
||||
|
||||
========================== ============ ========== ===========================
|
||||
**Field name** **Type** **Occurs** **Description**
|
||||
========================== ============ ========== ===========================
|
||||
origin String
|
||||
uri URI 1
|
||||
contact Contact
|
||||
primary_ns String
|
||||
hostmaster_email String
|
||||
serial Number
|
||||
slave_refresh Number
|
||||
slave_retry Number
|
||||
slave_expiration Number
|
||||
min_caching_time Number
|
||||
records Object[] Domain record i.e. {'name': ('type', 'value') }
|
||||
========================== ============ ========== ===========================
|
||||
|
||||
Name [application/vnd.orchestra.Name+json]
|
||||
==========================================
|
||||
========================== ============ ========== ===========================
|
||||
**Field name** **Type** **Occurs** **Description**
|
||||
========================== ============ ========== ===========================
|
||||
name String
|
||||
extension String
|
||||
uri URI 1
|
||||
contact Contact
|
||||
register_provider String
|
||||
name_server Object[] Name server key/value i.e. {'ns1.pangea.org': '1.1.1.1'}
|
||||
virtual_domain Boolean <TODO: is redundant with virtual domain type?>
|
||||
virtual_domain_type String
|
||||
zone Zone
|
||||
========================== ============ ========== ===========================
|
||||
|
||||
VirtualHost [application/vnd.orchestra.VirtualHost+json]
|
||||
========================================================
|
||||
<TODO: REST and dynamic attributes (resources, contacts)>
|
||||
A VirtualHost represents an Apache-like virtualhost configuration, which is useful for generating all the configuration files on the web server.
|
||||
A VirtualHost resource model contains the following fields:
|
||||
|
||||
========================== ============ ========== ===========================
|
||||
**Field name** **Type** **Occurs** **Description**
|
||||
========================== ============ ========== ===========================
|
||||
server_name String
|
||||
uri URI
|
||||
contact Contact
|
||||
ip String
|
||||
port Number
|
||||
domains Name[]
|
||||
document_root String
|
||||
custom_directives String[]
|
||||
fcgid_user String
|
||||
fcgid_group string String
|
||||
fcgid_directives Object Fcgid custom directives represented on a key/value pairs i.e. {'FcgidildeTimeout': 1202}
|
||||
php_version String
|
||||
php_directives Object PHP custom directives represented on key/value pairs i.e. {'display errors': 'True'}
|
||||
resource_swap_current Number PHP custom directives represented on key/value pairs i.e. {'display errors': 'True'}
|
||||
resource_swap_limit Number PHP custom directives represented on key/value pairs i.e. {'display errors': 'True'}
|
||||
resource_cpu_current Number
|
||||
resource_cpu_limit Number
|
||||
========================== ============ ========== ===========================
|
||||
|
||||
Daemon [application/vnd.orchestra.Daemon+json]
|
||||
==============================================
|
||||
|
||||
========================== ============ ========== ===========================
|
||||
**Field name** **Type** **Occurs** **Description**
|
||||
========================== ============ ========== ===========================
|
||||
name String
|
||||
uri URI 1
|
||||
content_type String
|
||||
active Boolean
|
||||
save_template String
|
||||
save_method String
|
||||
delete_template String
|
||||
delete_method String
|
||||
daemon_instances Object[] {'host': 'expression'}
|
||||
========================== ============ ========== ===========================
|
||||
|
||||
Monitor [application/vnd.orchestra.Monitor+json]
|
||||
================================================
|
||||
|
||||
========================== ============ ========== ===========================
|
||||
**Field name** **Type** **Occurs** **Description**
|
||||
========================== ============ ========== ===========================
|
||||
uri URI 1
|
||||
daemon Daemon
|
||||
resource String
|
||||
monitoring_template String
|
||||
monitoring method String
|
||||
exceed_template String <TODO: rename on monitor django model>
|
||||
exceed_method String
|
||||
recover_template String
|
||||
recover_method String
|
||||
allow_limit Boolean
|
||||
allow_unlimit Boolean
|
||||
default_initial Number
|
||||
block_size Number
|
||||
algorithm String
|
||||
period String
|
||||
interval String 0..1
|
||||
crontab String 0..1
|
||||
========================== ============ ========== ===========================
|
||||
|
||||
|
||||
#Layout inspired from http://kenai.com/projects/suncloudapis/pages/CloudAPISpecificationResourceModels
|
153
docs/Makefile
Normal file
153
docs/Makefile
Normal file
|
@ -0,0 +1,153 @@
|
|||
# Makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line.
|
||||
SPHINXOPTS =
|
||||
SPHINXBUILD = sphinx-build
|
||||
PAPER =
|
||||
BUILDDIR = _build
|
||||
|
||||
# Internal variables.
|
||||
PAPEROPT_a4 = -D latex_paper_size=a4
|
||||
PAPEROPT_letter = -D latex_paper_size=letter
|
||||
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
|
||||
# the i18n builder cannot share the environment and doctrees with the others
|
||||
I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
|
||||
|
||||
.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
|
||||
|
||||
help:
|
||||
@echo "Please use \`make <target>' where <target> is one of"
|
||||
@echo " html to make standalone HTML files"
|
||||
@echo " dirhtml to make HTML files named index.html in directories"
|
||||
@echo " singlehtml to make a single large HTML file"
|
||||
@echo " pickle to make pickle files"
|
||||
@echo " json to make JSON files"
|
||||
@echo " htmlhelp to make HTML files and a HTML help project"
|
||||
@echo " qthelp to make HTML files and a qthelp project"
|
||||
@echo " devhelp to make HTML files and a Devhelp project"
|
||||
@echo " epub to make an epub"
|
||||
@echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
|
||||
@echo " latexpdf to make LaTeX files and run them through pdflatex"
|
||||
@echo " text to make text files"
|
||||
@echo " man to make manual pages"
|
||||
@echo " texinfo to make Texinfo files"
|
||||
@echo " info to make Texinfo files and run them through makeinfo"
|
||||
@echo " gettext to make PO message catalogs"
|
||||
@echo " changes to make an overview of all changed/added/deprecated items"
|
||||
@echo " linkcheck to check all external links for integrity"
|
||||
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
|
||||
|
||||
clean:
|
||||
-rm -rf $(BUILDDIR)/*
|
||||
|
||||
html:
|
||||
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
|
||||
@echo
|
||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
||||
|
||||
dirhtml:
|
||||
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
|
||||
@echo
|
||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
|
||||
|
||||
singlehtml:
|
||||
$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
|
||||
@echo
|
||||
@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
|
||||
|
||||
pickle:
|
||||
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
|
||||
@echo
|
||||
@echo "Build finished; now you can process the pickle files."
|
||||
|
||||
json:
|
||||
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
|
||||
@echo
|
||||
@echo "Build finished; now you can process the JSON files."
|
||||
|
||||
htmlhelp:
|
||||
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
|
||||
@echo
|
||||
@echo "Build finished; now you can run HTML Help Workshop with the" \
|
||||
".hhp project file in $(BUILDDIR)/htmlhelp."
|
||||
|
||||
qthelp:
|
||||
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
|
||||
@echo
|
||||
@echo "Build finished; now you can run "qcollectiongenerator" with the" \
|
||||
".qhcp project file in $(BUILDDIR)/qthelp, like this:"
|
||||
@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-orchestra.qhcp"
|
||||
@echo "To view the help file:"
|
||||
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-orchestra.qhc"
|
||||
|
||||
devhelp:
|
||||
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
|
||||
@echo
|
||||
@echo "Build finished."
|
||||
@echo "To view the help file:"
|
||||
@echo "# mkdir -p $$HOME/.local/share/devhelp/django-orchestra"
|
||||
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-orchestra"
|
||||
@echo "# devhelp"
|
||||
|
||||
epub:
|
||||
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
|
||||
@echo
|
||||
@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
|
||||
|
||||
latex:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo
|
||||
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
|
||||
@echo "Run \`make' in that directory to run these through (pdf)latex" \
|
||||
"(use \`make latexpdf' here to do that automatically)."
|
||||
|
||||
latexpdf:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo "Running LaTeX files through pdflatex..."
|
||||
$(MAKE) -C $(BUILDDIR)/latex all-pdf
|
||||
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
|
||||
|
||||
text:
|
||||
$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
|
||||
@echo
|
||||
@echo "Build finished. The text files are in $(BUILDDIR)/text."
|
||||
|
||||
man:
|
||||
$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
|
||||
@echo
|
||||
@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
|
||||
|
||||
texinfo:
|
||||
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
|
||||
@echo
|
||||
@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
|
||||
@echo "Run \`make' in that directory to run these through makeinfo" \
|
||||
"(use \`make info' here to do that automatically)."
|
||||
|
||||
info:
|
||||
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
|
||||
@echo "Running Texinfo files through makeinfo..."
|
||||
make -C $(BUILDDIR)/texinfo info
|
||||
@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
|
||||
|
||||
gettext:
|
||||
$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
|
||||
@echo
|
||||
@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
|
||||
|
||||
changes:
|
||||
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
|
||||
@echo
|
||||
@echo "The overview file is in $(BUILDDIR)/changes."
|
||||
|
||||
linkcheck:
|
||||
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
|
||||
@echo
|
||||
@echo "Link check complete; look for any errors in the above output " \
|
||||
"or in $(BUILDDIR)/linkcheck/output.txt."
|
||||
|
||||
doctest:
|
||||
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
|
||||
@echo "Testing of doctests in the sources finished, look at the " \
|
||||
"results in $(BUILDDIR)/doctest/output.txt."
|
10
docs/README.md
Normal file
10
docs/README.md
Normal file
|
@ -0,0 +1,10 @@
|
|||
# Documentation
|
||||
|
||||
### Architecture
|
||||
* [Orchestration](../orchestra/contrib/orchestration)
|
||||
* [Orders](../orchestra/contrib/orders)
|
||||
* [Resources](../orchestra/contrib/resources)
|
||||
|
||||
|
||||
|
||||
|
244
docs/conf.py
Normal file
244
docs/conf.py
Normal file
|
@ -0,0 +1,244 @@
|
|||
from __future__ import unicode_literals
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# django-orchestra documentation build configuration file, created by
|
||||
# sphinx-quickstart on Wed Aug 8 11:07:40 2012.
|
||||
#
|
||||
# This file is execfile()d with the current directory set to its containing dir.
|
||||
#
|
||||
# Note that not all possible configuration values are present in this
|
||||
# autogenerated file.
|
||||
#
|
||||
# All configuration values have a default; values that are commented out
|
||||
# serve to show the default.
|
||||
|
||||
import sys, os
|
||||
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
#sys.path.insert(0, os.path.abspath('.'))
|
||||
|
||||
# -- General configuration -----------------------------------------------------
|
||||
|
||||
# If your documentation needs a minimal Sphinx version, state it here.
|
||||
#needs_sphinx = '1.0'
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be extensions
|
||||
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
|
||||
extensions = ['sphinx.ext.autodoc']
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
|
||||
# The suffix of source filenames.
|
||||
source_suffix = '.rst'
|
||||
|
||||
# The encoding of source files.
|
||||
#source_encoding = 'utf-8-sig'
|
||||
|
||||
# The master toctree document.
|
||||
master_doc = 'index'
|
||||
|
||||
# General information about the project.
|
||||
project = u'django-orchestra'
|
||||
copyright = u'2012, Marc Aymerich'
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
# |version| and |release|, also used in various other places throughout the
|
||||
# built documents.
|
||||
#
|
||||
# The short X.Y version.
|
||||
version = '0.1'
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = '0.1'
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
#language = None
|
||||
|
||||
# There are two options for replacing |today|: either, you set today to some
|
||||
# non-false value, then it is used:
|
||||
#today = ''
|
||||
# Else, today_fmt is used as the format for a strftime call.
|
||||
#today_fmt = '%B %d, %Y'
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
exclude_patterns = ['_build']
|
||||
|
||||
# The reST default role (used for this markup: `text`) to use for all documents.
|
||||
#default_role = None
|
||||
|
||||
# If true, '()' will be appended to :func: etc. cross-reference text.
|
||||
#add_function_parentheses = True
|
||||
|
||||
# If true, the current module name will be prepended to all description
|
||||
# unit titles (such as .. function::).
|
||||
#add_module_names = True
|
||||
|
||||
# If true, sectionauthor and moduleauthor directives will be shown in the
|
||||
# output. They are ignored by default.
|
||||
#show_authors = False
|
||||
|
||||
# The name of the Pygments (syntax highlighting) style to use.
|
||||
pygments_style = 'sphinx'
|
||||
|
||||
# A list of ignored prefixes for module index sorting.
|
||||
#modindex_common_prefix = []
|
||||
|
||||
|
||||
# -- Options for HTML output ---------------------------------------------------
|
||||
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
html_theme = 'default'
|
||||
|
||||
# Theme options are theme-specific and customize the look and feel of a theme
|
||||
# further. For a list of options available for each theme, see the
|
||||
# documentation.
|
||||
#html_theme_options = {}
|
||||
|
||||
# Add any paths that contain custom themes here, relative to this directory.
|
||||
#html_theme_path = []
|
||||
|
||||
# The name for this set of Sphinx documents. If None, it defaults to
|
||||
# "<project> v<release> documentation".
|
||||
#html_title = None
|
||||
|
||||
# A shorter title for the navigation bar. Default is the same as html_title.
|
||||
#html_short_title = None
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top
|
||||
# of the sidebar.
|
||||
#html_logo = None
|
||||
|
||||
# The name of an image file (within the static path) to use as favicon of the
|
||||
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
|
||||
# pixels large.
|
||||
#html_favicon = None
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ['_static']
|
||||
|
||||
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
|
||||
# using the given strftime format.
|
||||
#html_last_updated_fmt = '%b %d, %Y'
|
||||
|
||||
# If true, SmartyPants will be used to convert quotes and dashes to
|
||||
# typographically correct entities.
|
||||
#html_use_smartypants = True
|
||||
|
||||
# Custom sidebar templates, maps document names to template names.
|
||||
#html_sidebars = {}
|
||||
|
||||
# Additional templates that should be rendered to pages, maps page names to
|
||||
# template names.
|
||||
#html_additional_pages = {}
|
||||
|
||||
# If false, no module index is generated.
|
||||
#html_domain_indices = True
|
||||
|
||||
# If false, no index is generated.
|
||||
#html_use_index = True
|
||||
|
||||
# If true, the index is split into individual pages for each letter.
|
||||
#html_split_index = False
|
||||
|
||||
# If true, links to the reST sources are added to the pages.
|
||||
#html_show_sourcelink = True
|
||||
|
||||
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
|
||||
#html_show_sphinx = True
|
||||
|
||||
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
|
||||
#html_show_copyright = True
|
||||
|
||||
# If true, an OpenSearch description file will be output, and all pages will
|
||||
# contain a <link> tag referring to it. The value of this option must be the
|
||||
# base URL from which the finished HTML is served.
|
||||
#html_use_opensearch = ''
|
||||
|
||||
# This is the file name suffix for HTML files (e.g. ".xhtml").
|
||||
#html_file_suffix = None
|
||||
|
||||
# Output file base name for HTML help builder.
|
||||
htmlhelp_basename = 'django-orchestradoc'
|
||||
|
||||
|
||||
# -- Options for LaTeX output --------------------------------------------------
|
||||
|
||||
latex_elements = {
|
||||
# The paper size ('letterpaper' or 'a4paper').
|
||||
#'papersize': 'letterpaper',
|
||||
|
||||
# The font size ('10pt', '11pt' or '12pt').
|
||||
#'pointsize': '10pt',
|
||||
|
||||
# Additional stuff for the LaTeX preamble.
|
||||
#'preamble': '',
|
||||
}
|
||||
|
||||
# Grouping the document tree into LaTeX files. List of tuples
|
||||
# (source start file, target name, title, author, documentclass [howto/manual]).
|
||||
latex_documents = [
|
||||
('index', 'django-orchestra.tex', u'django-orchestra Documentation',
|
||||
u'Marc Aymerich', 'manual'),
|
||||
]
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top of
|
||||
# the title page.
|
||||
#latex_logo = None
|
||||
|
||||
# For "manual" documents, if this is true, then toplevel headings are parts,
|
||||
# not chapters.
|
||||
#latex_use_parts = False
|
||||
|
||||
# If true, show page references after internal links.
|
||||
#latex_show_pagerefs = False
|
||||
|
||||
# If true, show URL addresses after external links.
|
||||
#latex_show_urls = False
|
||||
|
||||
# Documents to append as an appendix to all manuals.
|
||||
#latex_appendices = []
|
||||
|
||||
# If false, no module index is generated.
|
||||
#latex_domain_indices = True
|
||||
|
||||
|
||||
# -- Options for manual page output --------------------------------------------
|
||||
|
||||
# One entry per manual page. List of tuples
|
||||
# (source start file, name, description, authors, manual section).
|
||||
man_pages = [
|
||||
('index', 'django-orchestra', u'django-orchestra Documentation',
|
||||
[u'Marc Aymerich'], 1)
|
||||
]
|
||||
|
||||
# If true, show URL addresses after external links.
|
||||
#man_show_urls = False
|
||||
|
||||
|
||||
# -- Options for Texinfo output ------------------------------------------------
|
||||
|
||||
# Grouping the document tree into Texinfo files. List of tuples
|
||||
# (source start file, target name, title, author,
|
||||
# dir menu entry, description, category)
|
||||
texinfo_documents = [
|
||||
('index', 'django-orchestra', u'django-orchestra Documentation',
|
||||
u'Marc Aymerich', 'django-orchestra', 'One line description of project.',
|
||||
'Miscellaneous'),
|
||||
]
|
||||
|
||||
# Documents to append as an appendix to all manuals.
|
||||
#texinfo_appendices = []
|
||||
|
||||
# If false, no module index is generated.
|
||||
#texinfo_domain_indices = True
|
||||
|
||||
# How to display URL addresses: 'footnote', 'no', or 'inline'.
|
||||
#texinfo_show_urls = 'footnote'
|
94
docs/create-services.md
Normal file
94
docs/create-services.md
Normal file
|
@ -0,0 +1,94 @@
|
|||
# Creating New Services
|
||||
|
||||
1. Think about if the service can fit into one of the existing service models like: SaaS or WebApps, refere to the related documentation if that is the case.
|
||||
2. Create a new django app using `startapp` management command. For ilustrational purposes we will create a crontab services that will allow orchestra to manage user-based crontabs.
|
||||
```bash
|
||||
python3 manage.py startapp crontabs
|
||||
```
|
||||
3. Add the new *crontabs* app to the `INSTALLED_APPS` in your project's `settings.py`
|
||||
3. Create a `models.py` file with the data your service needs to keep in order to be managed by orchestra
|
||||
```python
|
||||
from django.db import models
|
||||
|
||||
class CrontabSchedule(models.Model):
|
||||
account = models.ForeignKey('accounts.Account', verbose_name=_("account"))
|
||||
minute = models.CharField(_("minute"), max_length=64, default='*')
|
||||
hour = models.CharField(_("hour"), max_length=64, default='*')
|
||||
day_of_week = models.CharField(_("day of week"), max_length=64, default='*')
|
||||
day_of_month = models.CharField(_("day of month"), max_length=64, default='*')
|
||||
month_of_year = models.CharField(_("month of year"), max_length=64, default='*')
|
||||
|
||||
class Meta:
|
||||
ordering = ('month_of_year', 'day_of_month', 'day_of_week', 'hour', 'minute')
|
||||
|
||||
def __str__(self):
|
||||
rfield = lambda f: f and str(f).replace(' ', '') or '*'
|
||||
return "{0} {1} {2} {3} {4} (m/h/d/dM/MY)".format(
|
||||
rfield(self.minute), rfield(self.hour), rfield(self.day_of_week),
|
||||
rfield(self.day_of_month), rfield(self.month_of_year),
|
||||
)
|
||||
|
||||
class Crontab(models.Model):
|
||||
account = models.ForeignKey('accounts.Account', verbose_name=_("account"))
|
||||
schedule = models.ForeignKey(CrontabSchedule, verbose_name=_("schedule"))
|
||||
description = models.CharField(_("description"), max_length=256, blank=True)
|
||||
command = models.TextField(_("content"))
|
||||
|
||||
def __str__(self):
|
||||
return (self.description or self.command)[:32]
|
||||
```
|
||||
|
||||
4. Create a `admin.py` to enable the admin interface, refere to [Django Admin documentation](https://docs.djangoproject.com/en/1.9/ref/contrib/admin/) for further customization.
|
||||
```python
|
||||
from django.contrib import admin
|
||||
from .models import CrontabSchedule, Crontab
|
||||
|
||||
class CrontabScheduleAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
class CrontabAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
admin.site.register(CrontabSchedule, CrontabScheduleAdmin)
|
||||
admin.site.register(Crontab, CrontabAdmin)
|
||||
```
|
||||
|
||||
5. Create a `api.py` to enable the REST API.
|
||||
|
||||
6. Create a `backends.py` fiel with the needed backends for service orchestration and monitoring.
|
||||
```python
|
||||
import os
|
||||
import textwrap
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from orchestra.contrib.orchestration import ServiceController, replace
|
||||
from orchestra.contrib.resources import ServiceMonitor
|
||||
|
||||
class UNIXCronBackend(ServiceController):
|
||||
"""
|
||||
Basic UNIX cron support.
|
||||
"""
|
||||
verbose_name = _("UNIX cron")
|
||||
model = 'crons.CronTab'
|
||||
|
||||
def prepare(self):
|
||||
super(UNIXCronBackend, self).prepare()
|
||||
self.accounts = set()
|
||||
|
||||
def save(self, crontab):
|
||||
self.accounts.add(crontab.account)
|
||||
|
||||
def delete(self, crontab):
|
||||
self.accounts.add(crontab.account)
|
||||
|
||||
def commit(self):
|
||||
for account in self.accounts:
|
||||
crontab = None
|
||||
self.append("echo '' > %(crontab_path)s" % context)
|
||||
for crontab in account.crontabs.all():
|
||||
self.append("
|
||||
```
|
||||
7. Configure the routing
|
||||
|
||||
|
||||
|
||||
|
BIN
docs/images/index-screenshot.png
Normal file
BIN
docs/images/index-screenshot.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 193 KiB |
3628
docs/images/orchestration.svg
Normal file
3628
docs/images/orchestration.svg
Normal file
File diff suppressed because it is too large
Load diff
After Width: | Height: | Size: 103 KiB |
482
docs/images/services.svg
Normal file
482
docs/images/services.svg
Normal file
|
@ -0,0 +1,482 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="1052.3622"
|
||||
height="744.09448"
|
||||
id="svg2"
|
||||
version="1.1"
|
||||
inkscape:version="0.48.3.1 r9886"
|
||||
sodipodi:docname="services.svg">
|
||||
<defs
|
||||
id="defs4" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="0.70710678"
|
||||
inkscape:cx="559.86324"
|
||||
inkscape:cy="278.12745"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
showguides="true"
|
||||
inkscape:guide-bbox="true"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1024"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="27"
|
||||
inkscape:window-maximized="1" />
|
||||
<metadata
|
||||
id="metadata7">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(0,-308.2677)">
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:FreeMono Bold"
|
||||
x="132.85733"
|
||||
y="526.45612"
|
||||
id="text2985"
|
||||
sodipodi:linespacing="94.999999%"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan2987"
|
||||
x="132.85733"
|
||||
y="526.45612">Orders</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
|
||||
x="133.94112"
|
||||
y="853.0473"
|
||||
id="text2989"
|
||||
sodipodi:linespacing="94.999999%"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan2991"
|
||||
x="133.94112"
|
||||
y="853.0473"
|
||||
style="font-size:22px;text-align:center;line-height:94.99999880999999391%;text-anchor:middle">Metric</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
|
||||
x="294.6925"
|
||||
y="431.67795"
|
||||
id="text2993"
|
||||
sodipodi:linespacing="94.999999%"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan2995"
|
||||
x="294.6925"
|
||||
y="431.67795">Periodic</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="294.6925"
|
||||
y="453.67804"
|
||||
id="tspan2997">billing</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
|
||||
x="294.77344"
|
||||
y="597.10419"
|
||||
id="text2999"
|
||||
sodipodi:linespacing="94.999999%"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3001"
|
||||
x="294.77344"
|
||||
y="597.10419">One-time</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="294.77344"
|
||||
y="619.10431"
|
||||
id="tspan3003">service</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
|
||||
x="488.67383"
|
||||
y="472.50183"
|
||||
id="text3005"
|
||||
sodipodi:linespacing="94.999999%"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3007"
|
||||
x="488.67383"
|
||||
y="472.50183">Pricing</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="488.67383"
|
||||
y="494.50192"
|
||||
id="tspan3009">period</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
|
||||
x="488.91711"
|
||||
y="390.854"
|
||||
id="text3011"
|
||||
sodipodi:linespacing="94.999999%"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3013"
|
||||
x="488.91711"
|
||||
y="390.854">No pricing</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="488.91711"
|
||||
y="412.8541"
|
||||
id="tspan3015">period</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
|
||||
x="294.6925"
|
||||
y="758.26892"
|
||||
id="text2993-8"
|
||||
sodipodi:linespacing="94.999999%"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan2995-2"
|
||||
x="294.6925"
|
||||
y="758.26892">Periodic</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="294.6925"
|
||||
y="780.26904"
|
||||
id="tspan2997-4">billing</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
|
||||
x="294.77344"
|
||||
y="923.69543"
|
||||
id="text2999-4"
|
||||
sodipodi:linespacing="94.999999%"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3001-2"
|
||||
x="294.77344"
|
||||
y="923.69543">One-time</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="294.77344"
|
||||
y="945.69556"
|
||||
id="tspan3003-9">service</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
|
||||
x="488.67383"
|
||||
y="554.14972"
|
||||
id="text3005-7"
|
||||
sodipodi:linespacing="94.999999%"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3007-9"
|
||||
x="488.67383"
|
||||
y="554.14972">Pricing</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="488.67383"
|
||||
y="576.14984"
|
||||
id="tspan3009-3">period</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
|
||||
x="488.91711"
|
||||
y="635.79749"
|
||||
id="text3011-6"
|
||||
sodipodi:linespacing="94.999999%"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3013-2"
|
||||
x="488.91711"
|
||||
y="635.79749">No pricing</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="488.91711"
|
||||
y="657.79761"
|
||||
id="tspan3015-0">period</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
|
||||
x="488.67383"
|
||||
y="799.09296"
|
||||
id="text3005-6"
|
||||
sodipodi:linespacing="94.999999%"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3007-8"
|
||||
x="488.67383"
|
||||
y="799.09296">Pricing</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="488.67383"
|
||||
y="821.09308"
|
||||
id="tspan3009-2">period</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
|
||||
x="488.91711"
|
||||
y="717.44519"
|
||||
id="text3011-1"
|
||||
sodipodi:linespacing="94.999999%"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3013-6"
|
||||
x="488.91711"
|
||||
y="717.44519">No pricing</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="488.91711"
|
||||
y="739.44531"
|
||||
id="tspan3015-3">period</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
|
||||
x="488.67383"
|
||||
y="962.38898"
|
||||
id="text3005-1"
|
||||
sodipodi:linespacing="94.999999%"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3007-3"
|
||||
x="488.67383"
|
||||
y="962.38898">Pricing</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="488.67383"
|
||||
y="984.3891"
|
||||
id="tspan3009-7">period</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:center;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans Bold"
|
||||
x="488.91711"
|
||||
y="880.74097"
|
||||
id="text3011-64"
|
||||
sodipodi:linespacing="94.999999%"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3013-1"
|
||||
x="488.91711"
|
||||
y="880.74097">No pricing</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="488.91711"
|
||||
y="902.74109"
|
||||
id="tspan3015-08">period</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans"
|
||||
x="583.38361"
|
||||
y="379.86563"
|
||||
id="text3898"
|
||||
sodipodi:linespacing="94.999999%"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3900"
|
||||
x="583.38361"
|
||||
y="379.86563"
|
||||
style="font-weight:bold;font-size:22px">Mail accounts</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="583.38361"
|
||||
y="401.86572"
|
||||
id="tspan3912">Concurrent (changes)</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="583.38361"
|
||||
y="423.86581"
|
||||
id="tspan3902">Compensate on <tspan
|
||||
style="font-weight:bold;line-height:94.99999880999999391%;-inkscape-font-specification:FreeMono Bold;font-size:22px"
|
||||
id="tspan3904">prepay</tspan></tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans"
|
||||
x="586.6123"
|
||||
y="461.51324"
|
||||
id="text3906"
|
||||
sodipodi:linespacing="94.999999%"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3908"
|
||||
x="586.6123"
|
||||
y="461.51324"
|
||||
style="font-weight:bold;font-size:22px">Domains</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="586.6123"
|
||||
y="483.51334"
|
||||
id="tspan3914">Register or renew events</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="586.6123"
|
||||
y="505.51343"
|
||||
id="tspan3910">Compensate on <tspan
|
||||
style="font-weight:bold;line-height:94.99999880999999391%;font-size:22px"
|
||||
id="tspan3993">prepay</tspan></tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans"
|
||||
x="590.04663"
|
||||
y="554.09149"
|
||||
id="text3916"
|
||||
sodipodi:linespacing="94.999999%"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3918"
|
||||
x="590.04663"
|
||||
y="554.09149"
|
||||
style="font-weight:bold;font-size:22px">Plans</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="590.04663"
|
||||
y="576.09161"
|
||||
id="tspan3920">Always one order</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans"
|
||||
x="590.58252"
|
||||
y="635.97125"
|
||||
id="text3922"
|
||||
sodipodi:linespacing="94.999999%"><tspan
|
||||
sodipodi:role="line"
|
||||
x="590.58252"
|
||||
y="635.97125"
|
||||
id="tspan3926"
|
||||
style="font-weight:bold;font-size:22px">CMS installation</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="590.58252"
|
||||
y="657.97137"
|
||||
id="tspan3930">Register or renew events</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="590.58252"
|
||||
y="679.97144"
|
||||
id="tspan3932" /></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans"
|
||||
x="591.32349"
|
||||
y="777.26685"
|
||||
id="text3934"
|
||||
sodipodi:linespacing="94.999999%"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3936"
|
||||
x="591.32349"
|
||||
y="777.26685"
|
||||
style="font-weight:bold;font-size:22px">Traffic consumption</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="591.32349"
|
||||
y="799.26697"
|
||||
id="tspan3938">Metric period lookup</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="591.32349"
|
||||
y="821.26703"
|
||||
id="tspan3940">Prepay and != billing_period</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="591.32349"
|
||||
y="843.26715"
|
||||
id="tspan3995"
|
||||
style="font-style:italic;-inkscape-font-specification:FreeMono Italic;font-size:22px"> NotImplemented</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans"
|
||||
x="591.32349"
|
||||
y="717.61884"
|
||||
id="text3942"
|
||||
sodipodi:linespacing="94.999999%"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3944"
|
||||
x="591.32349"
|
||||
y="717.61884"
|
||||
style="font-weight:bold;font-size:22px">Mailbox size</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="591.32349"
|
||||
y="739.61896"
|
||||
id="tspan3946">Concurrent (changes)</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans"
|
||||
x="590.1192"
|
||||
y="882.65179"
|
||||
id="text3942-8"
|
||||
sodipodi:linespacing="94.999999%"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3944-2"
|
||||
x="590.1192"
|
||||
y="882.65179"
|
||||
style="font-weight:bold;font-size:22px">Jobs</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="590.1192"
|
||||
y="904.65192"
|
||||
id="tspan3946-6">Last known metric</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:22px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:94.99999880999999391%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:FreeMono;-inkscape-font-specification:Sans"
|
||||
x="591.06866"
|
||||
y="973.33081"
|
||||
id="text3972"
|
||||
sodipodi:linespacing="94.999999%"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3974"
|
||||
x="591.06866"
|
||||
y="973.33081"
|
||||
style="font-style:italic;-inkscape-font-specification:FreeMono Italic;font-size:22px">NotImplement</tspan></text>
|
||||
<path
|
||||
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 228.73934,436.50836 -23.61913,0 0,164.75267 23.53877,0"
|
||||
id="path4013"
|
||||
inkscape:connector-curvature="0"
|
||||
sodipodi:nodetypes="cccc" />
|
||||
<path
|
||||
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 205.55487,521.14184 -23.98356,0"
|
||||
id="path4019"
|
||||
inkscape:connector-curvature="0"
|
||||
sodipodi:nodetypes="cc" />
|
||||
<path
|
||||
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 228.73934,764.42561 -23.61913,0 0,164.7526 23.53877,0"
|
||||
id="path4013-2"
|
||||
inkscape:connector-curvature="0"
|
||||
sodipodi:nodetypes="cccc" />
|
||||
<path
|
||||
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 205.55487,849.05914 -23.98356,0"
|
||||
id="path4019-1"
|
||||
inkscape:connector-curvature="0"
|
||||
sodipodi:nodetypes="cc" />
|
||||
<path
|
||||
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 407.81779,398.68928 -23.61908,0 0,80.25672 23.5387,0"
|
||||
id="path4013-6"
|
||||
inkscape:connector-curvature="0"
|
||||
sodipodi:nodetypes="cccc" />
|
||||
<path
|
||||
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 384.6333,438.12728 -23.98362,0"
|
||||
id="path4019-18"
|
||||
inkscape:connector-curvature="0"
|
||||
sodipodi:nodetypes="cc" />
|
||||
<path
|
||||
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 407.81779,561.72165 -23.61908,0 0,80.25664 23.5387,0"
|
||||
id="path4013-6-63"
|
||||
inkscape:connector-curvature="0"
|
||||
sodipodi:nodetypes="cccc" />
|
||||
<path
|
||||
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 384.6333,601.15965 -23.98362,0"
|
||||
id="path4019-18-4"
|
||||
inkscape:connector-curvature="0"
|
||||
sodipodi:nodetypes="cc" />
|
||||
<path
|
||||
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 407.81779,726.60658 -23.61908,0 0,80.25672 23.5387,0"
|
||||
id="path4013-6-8"
|
||||
inkscape:connector-curvature="0"
|
||||
sodipodi:nodetypes="cccc" />
|
||||
<path
|
||||
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 384.6333,766.04458 -23.98362,0"
|
||||
id="path4019-18-3"
|
||||
inkscape:connector-curvature="0"
|
||||
sodipodi:nodetypes="cc" />
|
||||
<path
|
||||
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 407.81779,889.63886 -23.61908,0 0,80.25667 23.5387,0"
|
||||
id="path4013-6-0"
|
||||
inkscape:connector-curvature="0"
|
||||
sodipodi:nodetypes="cccc" />
|
||||
<path
|
||||
style="fill:none;stroke:#000000;stroke-width:0.92632013999999996px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 384.6333,929.07689 -23.98362,0"
|
||||
id="path4019-18-8"
|
||||
inkscape:connector-curvature="0"
|
||||
sodipodi:nodetypes="cc" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 21 KiB |
21
docs/index.rst
Normal file
21
docs/index.rst
Normal file
|
@ -0,0 +1,21 @@
|
|||
.. django-orchestra documentation master file, created by
|
||||
sphinx-quickstart on Wed Aug 8 11:07:40 2012.
|
||||
You can adapt this file completely to your liking, but it should at least
|
||||
contain the root `toctree` directive.
|
||||
|
||||
Welcome to django-orchestra's documentation!
|
||||
============================================
|
||||
|
||||
Contents:
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
|
190
docs/make.bat
Normal file
190
docs/make.bat
Normal file
|
@ -0,0 +1,190 @@
|
|||
@ECHO OFF
|
||||
|
||||
REM Command file for Sphinx documentation
|
||||
|
||||
if "%SPHINXBUILD%" == "" (
|
||||
set SPHINXBUILD=sphinx-build
|
||||
)
|
||||
set BUILDDIR=_build
|
||||
set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% .
|
||||
set I18NSPHINXOPTS=%SPHINXOPTS% .
|
||||
if NOT "%PAPER%" == "" (
|
||||
set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
|
||||
set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
|
||||
)
|
||||
|
||||
if "%1" == "" goto help
|
||||
|
||||
if "%1" == "help" (
|
||||
:help
|
||||
echo.Please use `make ^<target^>` where ^<target^> is one of
|
||||
echo. html to make standalone HTML files
|
||||
echo. dirhtml to make HTML files named index.html in directories
|
||||
echo. singlehtml to make a single large HTML file
|
||||
echo. pickle to make pickle files
|
||||
echo. json to make JSON files
|
||||
echo. htmlhelp to make HTML files and a HTML help project
|
||||
echo. qthelp to make HTML files and a qthelp project
|
||||
echo. devhelp to make HTML files and a Devhelp project
|
||||
echo. epub to make an epub
|
||||
echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
|
||||
echo. text to make text files
|
||||
echo. man to make manual pages
|
||||
echo. texinfo to make Texinfo files
|
||||
echo. gettext to make PO message catalogs
|
||||
echo. changes to make an overview over all changed/added/deprecated items
|
||||
echo. linkcheck to check all external links for integrity
|
||||
echo. doctest to run all doctests embedded in the documentation if enabled
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "clean" (
|
||||
for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
|
||||
del /q /s %BUILDDIR%\*
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "html" (
|
||||
%SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The HTML pages are in %BUILDDIR%/html.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "dirhtml" (
|
||||
%SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "singlehtml" (
|
||||
%SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "pickle" (
|
||||
%SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished; now you can process the pickle files.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "json" (
|
||||
%SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished; now you can process the JSON files.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "htmlhelp" (
|
||||
%SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished; now you can run HTML Help Workshop with the ^
|
||||
.hhp project file in %BUILDDIR%/htmlhelp.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "qthelp" (
|
||||
%SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished; now you can run "qcollectiongenerator" with the ^
|
||||
.qhcp project file in %BUILDDIR%/qthelp, like this:
|
||||
echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-orchestra.qhcp
|
||||
echo.To view the help file:
|
||||
echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-orchestra.ghc
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "devhelp" (
|
||||
%SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "epub" (
|
||||
%SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The epub file is in %BUILDDIR%/epub.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "latex" (
|
||||
%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "text" (
|
||||
%SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The text files are in %BUILDDIR%/text.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "man" (
|
||||
%SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The manual pages are in %BUILDDIR%/man.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "texinfo" (
|
||||
%SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "gettext" (
|
||||
%SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "changes" (
|
||||
%SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.The overview file is in %BUILDDIR%/changes.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "linkcheck" (
|
||||
%SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Link check complete; look for any errors in the above output ^
|
||||
or in %BUILDDIR%/linkcheck/output.txt.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "doctest" (
|
||||
%SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Testing of doctests in the sources finished, look at the ^
|
||||
results in %BUILDDIR%/doctest/output.txt.
|
||||
goto end
|
||||
)
|
||||
|
||||
:end
|
132
install_manually.md
Normal file
132
install_manually.md
Normal file
|
@ -0,0 +1,132 @@
|
|||
# System requirements:
|
||||
The most important requirement is use python3.6
|
||||
we need install this packages:
|
||||
```
|
||||
bind9utils
|
||||
ca-certificates
|
||||
gettext
|
||||
libcrack2-dev
|
||||
libxml2-dev
|
||||
libxslt1-dev
|
||||
python3
|
||||
python3-pip
|
||||
python3-dev
|
||||
ssh-client
|
||||
wget
|
||||
xvfb
|
||||
zlib1g-dev
|
||||
git
|
||||
iceweasel
|
||||
dnsutils
|
||||
```
|
||||
We need install too a *wkhtmltopdf* package
|
||||
You can use one of your OS or get it from original.
|
||||
This it is in https://wkhtmltopdf.org/downloads.html
|
||||
|
||||
# pip installations
|
||||
We need install this packages:
|
||||
```
|
||||
Django==1.10.5
|
||||
django-fluent-dashboard==0.6.1
|
||||
django-admin-tools==0.8.0
|
||||
django-extensions==1.7.4
|
||||
django-celery==3.1.17
|
||||
celery==3.1.23
|
||||
kombu==3.0.35
|
||||
billiard==3.3.0.23
|
||||
Markdown==2.4
|
||||
djangorestframework==3.4.7
|
||||
ecdsa==0.11
|
||||
Pygments==1.6
|
||||
django-filter==0.15.2
|
||||
jsonfield==0.9.22
|
||||
python-dateutil==2.2
|
||||
https://github.com/glic3rinu/passlib/archive/master.zip
|
||||
django-iban==0.3.0
|
||||
requests
|
||||
phonenumbers
|
||||
django-countries
|
||||
django-localflavor
|
||||
amqp
|
||||
anyjson
|
||||
pytz
|
||||
cracklib
|
||||
lxml==3.3.5
|
||||
selenium
|
||||
xvfbwrapper
|
||||
freezegun
|
||||
coverage
|
||||
flake8
|
||||
django-debug-toolbar==1.3.0
|
||||
django-nose==1.4.4
|
||||
sqlparse
|
||||
pyinotify
|
||||
PyMySQL
|
||||
```
|
||||
|
||||
If you want to use Orchestra you need to install from pip like this:
|
||||
```
|
||||
pip3 install http://git.io/django-orchestra-dev
|
||||
```
|
||||
|
||||
But if you want develop orquestra you need to do this:
|
||||
```
|
||||
git clone https://github.com/ribaguifi/django-orchestra
|
||||
pip install -e django-orchestra
|
||||
```
|
||||
|
||||
# Database
|
||||
For default use sqlite3 if you want to use postgresql you need install this packages:
|
||||
|
||||
```
|
||||
psycopg2 postgresql
|
||||
```
|
||||
|
||||
You can use it for debian or ubuntu:
|
||||
|
||||
```
|
||||
sudo apt-get install python3-psycopg2 postgresql-contrib
|
||||
```
|
||||
|
||||
Remember create a database for your project and give permitions for the correct user like this:
|
||||
|
||||
```
|
||||
psql -U postgres
|
||||
psql (12.4)
|
||||
Digite «help» para obtener ayuda.
|
||||
|
||||
postgres=# CREATE database orchesta;
|
||||
postgres=# CREATE USER orchesta WITH PASSWORD 'orquesta';
|
||||
postgres=# GRANT ALL PRIVILEGES ON DATABASE orchesta TO orchesta;
|
||||
```
|
||||
|
||||
# Create new project
|
||||
You can use orchestra-admin for create your new project
|
||||
```
|
||||
orchestra-admin startproject <project_name> # e.g. panel
|
||||
cd <project_name>
|
||||
```
|
||||
|
||||
Next we need change the settings.py for configure the correct database
|
||||
|
||||
In settings.py we need change the DATABASE section like this:
|
||||
|
||||
```
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql_psycopg2',
|
||||
'NAME': 'orchestra'
|
||||
'USER': 'orchestra',
|
||||
'PASSWORD': 'orchestra',
|
||||
'HOST': 'localhost',
|
||||
'PORT': '5432',
|
||||
'CONN_MAX_AGE': 60*10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For end you need to do the migrations:
|
||||
|
||||
```
|
||||
python3 manage.py migrate
|
||||
```
|
25
orchestra/__init__.py
Normal file
25
orchestra/__init__.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
default_app_config = 'orchestra.apps.OrchestraConfig'
|
||||
|
||||
VERSION = (0, 0, 1, 'alpha', 1)
|
||||
|
||||
|
||||
def get_version():
|
||||
"Returns a PEP 386-compliant version number from VERSION."
|
||||
assert len(VERSION) == 5
|
||||
assert VERSION[3] in ('alpha', 'beta', 'rc', 'final')
|
||||
|
||||
# Now build the two parts of the version number:
|
||||
# main = X.Y[.Z]
|
||||
# sub = .devN - for pre-alpha releases
|
||||
# | {a|b|c}N - for alpha, beta and rc releases
|
||||
|
||||
parts = 2 if VERSION[2] == 0 else 3
|
||||
main = '.'.join(str(x) for x in VERSION[:parts])
|
||||
|
||||
sub = ''
|
||||
|
||||
if VERSION[3] != 'final':
|
||||
mapping = {'alpha': 'a', 'beta': 'b', 'rc': 'c'}
|
||||
sub = mapping[VERSION[3]] + str(VERSION[4])
|
||||
|
||||
return str(main + sub)
|
121
orchestra/admin/__init__.py
Normal file
121
orchestra/admin/__init__.py
Normal file
|
@ -0,0 +1,121 @@
|
|||
import itertools
|
||||
from collections import OrderedDict
|
||||
from functools import update_wrapper
|
||||
|
||||
from django.contrib import admin
|
||||
from django.urls import reverse
|
||||
from django.shortcuts import render, redirect
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .dashboard import *
|
||||
from .options import *
|
||||
from ..core import accounts, services
|
||||
|
||||
|
||||
# monkey-patch admin.site in order to porvide some extra admin urls
|
||||
|
||||
urls = []
|
||||
def register_url(pattern, view, name=""):
|
||||
global urls
|
||||
urls.append((pattern, view, name))
|
||||
admin.site.register_url = register_url
|
||||
|
||||
|
||||
site_get_urls = admin.site.get_urls
|
||||
def get_urls():
|
||||
def wrap(view, cacheable=False):
|
||||
def wrapper(*args, **kwargs):
|
||||
return admin.site.admin_view(view, cacheable)(*args, **kwargs)
|
||||
wrapper.admin_site = admin.site
|
||||
return update_wrapper(wrapper, view)
|
||||
global urls
|
||||
extra_patterns = []
|
||||
for pattern, view, name in urls:
|
||||
extra_patterns.append(
|
||||
url(pattern, wrap(view), name=name)
|
||||
)
|
||||
return site_get_urls() + extra_patterns
|
||||
admin.site.get_urls = get_urls
|
||||
|
||||
|
||||
def get_model(model_name, model_name_map):
|
||||
try:
|
||||
return model_name_map[model_name.lower()]
|
||||
except KeyError:
|
||||
return
|
||||
|
||||
|
||||
def search(request):
|
||||
query = request.GET.get('q', '')
|
||||
search_term = query
|
||||
models = set()
|
||||
selected_models = set()
|
||||
model_name_map = {}
|
||||
for service in itertools.chain(services, accounts):
|
||||
if service.search:
|
||||
models.add(service.model)
|
||||
model_name_map[service.model._meta.model_name] = service.model
|
||||
|
||||
# Account direct access
|
||||
if search_term.endswith('!'):
|
||||
from ..contrib.accounts.models import Account
|
||||
search_term = search_term.replace('!', '')
|
||||
try:
|
||||
account = Account.objects.get(username=search_term)
|
||||
except Account.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
account_url = reverse('admin:accounts_account_change', args=(account.pk,))
|
||||
return redirect(account_url)
|
||||
# Search for specific model
|
||||
elif ':' in search_term:
|
||||
new_search_term = []
|
||||
for part in search_term.split():
|
||||
if ':' in part:
|
||||
model_name, term = part.split(':')
|
||||
model = get_model(model_name, model_name_map)
|
||||
# Retry with singular version
|
||||
if model is None and model_name.endswith('s'):
|
||||
model = get_model(model_name[:-1], model_name_map)
|
||||
if model is None:
|
||||
new_search_term.append(':'.join((model_name, term)))
|
||||
else:
|
||||
selected_models.add(model)
|
||||
new_search_term.append(term)
|
||||
else:
|
||||
new_search_term.append(part)
|
||||
search_term = ' '.join(new_search_term)
|
||||
if selected_models:
|
||||
models = selected_models
|
||||
results = OrderedDict()
|
||||
models = sorted(models, key=lambda m: m._meta.verbose_name_plural.lower())
|
||||
total = 0
|
||||
for model in models:
|
||||
try:
|
||||
modeladmin = admin.site._registry[model]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
qs = modeladmin.get_queryset(request)
|
||||
qs, search_use_distinct = modeladmin.get_search_results(request, qs, search_term)
|
||||
if search_use_distinct:
|
||||
qs = qs.distinct()
|
||||
num = len(qs)
|
||||
if num:
|
||||
total += num
|
||||
results[model._meta] = qs
|
||||
title = _("{total} search results for '<tt>{query}</tt>'").format(total=total, query=query)
|
||||
context = {
|
||||
'title': mark_safe(title),
|
||||
'total': total,
|
||||
'columns': min(int(total/17), 3),
|
||||
'query': query,
|
||||
'search_term': search_term,
|
||||
'results': results,
|
||||
'search_autofocus': True,
|
||||
}
|
||||
return render(request, 'admin/orchestra/search.html', context)
|
||||
|
||||
|
||||
admin.site.register_url(r'^search/$', search, 'orchestra_search_view')
|
145
orchestra/admin/actions.py
Normal file
145
orchestra/admin/actions.py
Normal file
|
@ -0,0 +1,145 @@
|
|||
from functools import partial
|
||||
|
||||
from django.contrib import admin
|
||||
from django.core.mail import send_mass_mail
|
||||
from django.shortcuts import render
|
||||
from django.utils.translation import ngettext, gettext_lazy as _
|
||||
|
||||
from .. import settings
|
||||
|
||||
from .decorators import action_with_confirmation
|
||||
from .forms import SendEmailForm
|
||||
|
||||
|
||||
class SendEmail(object):
|
||||
""" Form wizard for billing orders admin action """
|
||||
short_description = _("Send email")
|
||||
form = SendEmailForm
|
||||
template = 'admin/orchestra/generic_confirmation.html'
|
||||
default_from = settings.ORCHESTRA_DEFAULT_SUPPORT_FROM_EMAIL
|
||||
__name__ = 'semd_email'
|
||||
|
||||
def __call__(self, modeladmin, request, queryset):
|
||||
""" make this monster behave like a function """
|
||||
self.modeladmin = modeladmin
|
||||
self.queryset = queryset
|
||||
self.opts = modeladmin.model._meta
|
||||
app_label = self.opts.app_label
|
||||
self.context = {
|
||||
'action_name': _("Send email"),
|
||||
'action_value': self.__name__,
|
||||
'opts': self.opts,
|
||||
'app_label': app_label,
|
||||
'queryset': queryset,
|
||||
'action_checkbox_name': admin.helpers.ACTION_CHECKBOX_NAME,
|
||||
}
|
||||
return self.write_email(request)
|
||||
|
||||
def write_email(self, request):
|
||||
if not request.user.is_superuser:
|
||||
raise PermissionDenied
|
||||
initial={
|
||||
'email_from': self.default_from,
|
||||
'to': ' '.join(self.get_email_addresses())
|
||||
}
|
||||
form = self.form(initial=initial)
|
||||
if request.POST.get('post'):
|
||||
form = self.form(request.POST, initial=initial)
|
||||
if form.is_valid():
|
||||
options = {
|
||||
'email_from': form.cleaned_data['email_from'],
|
||||
'extra_to': form.cleaned_data['extra_to'],
|
||||
'subject': form.cleaned_data['subject'],
|
||||
'message': form.cleaned_data['message'],
|
||||
|
||||
}
|
||||
return self.confirm_email(request, **options)
|
||||
self.context.update({
|
||||
'title': _("Send e-mail to %s") % self.opts.verbose_name_plural,
|
||||
'content_title': "",
|
||||
'form': form,
|
||||
'submit_value': _("Continue"),
|
||||
})
|
||||
# Display confirmation page
|
||||
return render(request, self.template, self.context)
|
||||
|
||||
def get_email_addresses(self):
|
||||
return self.queryset.values_list('email', flat=True)
|
||||
|
||||
def confirm_email(self, request, **options):
|
||||
email_from = options['email_from']
|
||||
extra_to = options['extra_to']
|
||||
subject = options['subject']
|
||||
message = options['message']
|
||||
# The user has already confirmed
|
||||
if request.POST.get('post') == 'email_confirmation':
|
||||
emails = []
|
||||
num = 0
|
||||
for email in self.get_email_addresses():
|
||||
emails.append((subject, message, email_from, [email]))
|
||||
num += 1
|
||||
if extra_to:
|
||||
emails.append((subject, message, email_from, extra_to))
|
||||
send_mass_mail(emails, fail_silently=False)
|
||||
msg = ngettext(
|
||||
_("Message has been sent to one %s.") % self.opts.verbose_name_plural,
|
||||
_("Message has been sent to %i %s.") % (num, self.opts.verbose_name_plural),
|
||||
num
|
||||
)
|
||||
self.modeladmin.message_user(request, msg)
|
||||
return None
|
||||
|
||||
form = self.form(initial={
|
||||
'email_from': email_from,
|
||||
'extra_to': ', '.join(extra_to),
|
||||
'subject': subject,
|
||||
'message': message
|
||||
})
|
||||
self.context.update({
|
||||
'title': _("Are you sure?"),
|
||||
'content_message': _(
|
||||
"Are you sure you want to send the following message to the following %s?"
|
||||
) % self.opts.verbose_name_plural,
|
||||
'display_objects': ["%s (%s)" % (contact, email) for contact, email in zip(self.queryset, self.get_email_addresses())],
|
||||
'form': form,
|
||||
'subject': subject,
|
||||
'message': message,
|
||||
'post_value': 'email_confirmation',
|
||||
})
|
||||
# Display the confirmation page
|
||||
return render(request, self.template, self.context)
|
||||
|
||||
|
||||
def base_disable(modeladmin, request, queryset, disable=True):
|
||||
num = 0
|
||||
action_name = _("disabled") if disable else _("enabled")
|
||||
for obj in queryset:
|
||||
obj.disable() if disable else obj.enable()
|
||||
modeladmin.log_change(request, obj, action_name.capitalize())
|
||||
num += 1
|
||||
opts = modeladmin.model._meta
|
||||
context = {
|
||||
'action_name': action_name,
|
||||
'verbose_name': opts.verbose_name,
|
||||
'verbose_name_plural': opts.verbose_name_plural,
|
||||
'num': num
|
||||
}
|
||||
msg = ngettext(
|
||||
_("Selected %(verbose_name)s and related services has been %(action_name)s.") % context,
|
||||
_("%(num)s selected %(verbose_name_plural)s and related services have been %(action_name)s.") % context,
|
||||
num)
|
||||
modeladmin.message_user(request, msg)
|
||||
|
||||
|
||||
@action_with_confirmation()
|
||||
def disable(modeladmin, request, queryset):
|
||||
return base_disable(modeladmin, request, queryset)
|
||||
disable.url_name = 'disable'
|
||||
disable.short_description = _("Disable")
|
||||
|
||||
|
||||
@action_with_confirmation()
|
||||
def enable(modeladmin, request, queryset):
|
||||
return base_disable(modeladmin, request, queryset, disable=False)
|
||||
enable.url_name = 'enable'
|
||||
enable.short_description = _("Enable")
|
74
orchestra/admin/dashboard.py
Normal file
74
orchestra/admin/dashboard.py
Normal file
|
@ -0,0 +1,74 @@
|
|||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from fluent_dashboard import dashboard, appsettings
|
||||
from fluent_dashboard.modules import CmsAppIconList
|
||||
|
||||
from orchestra.core import services, accounts, administration
|
||||
|
||||
|
||||
class AppDefaultIconList(CmsAppIconList):
|
||||
""" Provides support for custom default icons """
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.icons = kwargs.pop('icons')
|
||||
super(AppDefaultIconList, self).__init__(*args, **kwargs)
|
||||
|
||||
def get_icon_for_model(self, app_name, model_name, default=None):
|
||||
icon = self.icons.get('.'.join((app_name, model_name)))
|
||||
return super(AppDefaultIconList, self).get_icon_for_model(app_name, model_name, default=icon)
|
||||
|
||||
|
||||
class OrchestraIndexDashboard(dashboard.FluentIndexDashboard):
|
||||
""" Gets application modules from services, accounts and administration registries """
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super(dashboard.FluentIndexDashboard, self).__init__(**kwargs)
|
||||
self.children.append(self.get_personal_module())
|
||||
self.children.extend(self.get_application_modules())
|
||||
recent_actions = self.get_recent_actions_module()
|
||||
recent_actions.enabled = True
|
||||
self.children.append(recent_actions)
|
||||
|
||||
def process_registered_view(self, module, view_name, options):
|
||||
app_name, name = view_name.split('_')[:-1]
|
||||
module.icons['.'.join((app_name, name))] = options.get('icon')
|
||||
url = reverse('admin:' + view_name)
|
||||
add_url = '/'.join(url.split('/')[:-2])
|
||||
module.children.append({
|
||||
'models': [
|
||||
{
|
||||
'add_url': add_url,
|
||||
'app_name': app_name,
|
||||
'change_url': url,
|
||||
'name': name,
|
||||
'title': options.get('verbose_name_plural')
|
||||
}
|
||||
],
|
||||
'name': app_name,
|
||||
'title': options.get('verbose_name_plural'),
|
||||
'url': add_url,
|
||||
})
|
||||
|
||||
def get_application_modules(self):
|
||||
modules = []
|
||||
# Honor settings override, hacky. I Know
|
||||
if appsettings.FLUENT_DASHBOARD_APP_GROUPS[0][0] != _('CMS'):
|
||||
modules = super(OrchestraIndexDashboard, self).get_application_modules()
|
||||
for register in (accounts, services, administration):
|
||||
title = register.verbose_name
|
||||
models = []
|
||||
icons = {}
|
||||
views = []
|
||||
for model, options in register.get().items():
|
||||
if isinstance(model, str):
|
||||
views.append((model, options))
|
||||
elif options.get('dashboard', True):
|
||||
opts = model._meta
|
||||
label = "%s.%s" % (model.__module__, opts.object_name)
|
||||
models.append(label)
|
||||
label = '.'.join((opts.app_label, opts.model_name))
|
||||
icons[label] = options.get('icon')
|
||||
module = AppDefaultIconList(title, models=models, icons=icons, collapsible=True)
|
||||
for view_name, options in views:
|
||||
self.process_registered_view(module, view_name, options)
|
||||
modules.append(module)
|
||||
return modules
|
101
orchestra/admin/decorators.py
Normal file
101
orchestra/admin/decorators.py
Normal file
|
@ -0,0 +1,101 @@
|
|||
from functools import wraps, partial, update_wrapper
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.admin import helpers
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.template.response import TemplateResponse
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.html import format_html
|
||||
from django.utils.text import capfirst
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def admin_field(method):
|
||||
""" Wraps a function to be used as a ModelAdmin method field """
|
||||
def admin_field_wrapper(*args, **kwargs):
|
||||
""" utility function for creating admin links """
|
||||
kwargs['field'] = args[0] if args else '__str__'
|
||||
kwargs['order'] = kwargs.get('order', kwargs['field'])
|
||||
kwargs['popup'] = kwargs.get('popup', False)
|
||||
# TODO get field verbose name
|
||||
kwargs['short_description'] = kwargs.get('short_description',
|
||||
kwargs['field'].split('__')[-1].replace('_', ' ').capitalize())
|
||||
admin_method = partial(method, **kwargs)
|
||||
admin_method = update_wrapper(admin_method, method)
|
||||
admin_method.short_description = kwargs['short_description']
|
||||
admin_method.allow_tags = True
|
||||
admin_method.admin_order_field = kwargs['order']
|
||||
return admin_method
|
||||
return admin_field_wrapper
|
||||
|
||||
|
||||
def format_display_objects(modeladmin, request, queryset):
|
||||
from .utils import change_url
|
||||
opts = modeladmin.model._meta
|
||||
objects = []
|
||||
for obj in queryset:
|
||||
objects.append(format_html('{0}: <a href="{1}">{2}</a>',
|
||||
capfirst(opts.verbose_name), change_url(obj), obj)
|
||||
)
|
||||
return objects
|
||||
|
||||
|
||||
def action_with_confirmation(action_name=None, extra_context=None, validator=None,
|
||||
template='admin/orchestra/generic_confirmation.html'):
|
||||
"""
|
||||
Generic pattern for actions that needs confirmation step
|
||||
If custom template is provided the form must contain:
|
||||
<input type="hidden" name="post" value="generic_confirmation" />
|
||||
"""
|
||||
|
||||
def decorator(func, extra_context=extra_context, template=template, action_name=action_name, validatior=validator):
|
||||
@wraps(func)
|
||||
def inner(modeladmin, request, queryset, action_name=action_name, extra_context=extra_context, validator=validator):
|
||||
if validator is not None:
|
||||
try:
|
||||
validator(queryset)
|
||||
except ValidationError as e:
|
||||
messages.error(request, '<br>'.join(e))
|
||||
return
|
||||
# The user has already confirmed the action.
|
||||
if request.POST.get('post') == 'generic_confirmation':
|
||||
stay = func(modeladmin, request, queryset)
|
||||
if not stay:
|
||||
return
|
||||
|
||||
opts = modeladmin.model._meta
|
||||
app_label = opts.app_label
|
||||
action_value = func.__name__
|
||||
|
||||
if len(queryset) == 1:
|
||||
objects_name = force_str(opts.verbose_name)
|
||||
obj = queryset.get()
|
||||
else:
|
||||
objects_name = force_str(opts.verbose_name_plural)
|
||||
obj = None
|
||||
if not action_name:
|
||||
action_name = func.__name__
|
||||
context = {
|
||||
'title': _("Are you sure?"),
|
||||
'content_message': _("Are you sure you want to {action} the selected {item}?").format(
|
||||
action=action_name, item=objects_name),
|
||||
'action_name': action_name.capitalize(),
|
||||
'action_value': action_value,
|
||||
'queryset': queryset,
|
||||
'opts': opts,
|
||||
'obj': obj,
|
||||
'app_label': app_label,
|
||||
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
|
||||
}
|
||||
|
||||
if callable(extra_context):
|
||||
extra_context = extra_context(modeladmin, request, queryset)
|
||||
context.update(extra_context or {})
|
||||
if 'display_objects' not in context:
|
||||
# Compute it only when necessary
|
||||
context['display_objects'] = format_display_objects(modeladmin, request, queryset)
|
||||
|
||||
# Display the confirmation page
|
||||
return TemplateResponse(request, template, context)
|
||||
return inner
|
||||
return decorator
|
228
orchestra/admin/forms.py
Normal file
228
orchestra/admin/forms.py
Normal file
|
@ -0,0 +1,228 @@
|
|||
import textwrap
|
||||
from functools import partial
|
||||
|
||||
from django import forms
|
||||
from django.contrib.admin import helpers
|
||||
from django.core import validators
|
||||
from django.forms.models import modelformset_factory, BaseModelFormSet
|
||||
from django.template import Template, Context
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from orchestra.forms.widgets import SpanWidget
|
||||
|
||||
from ..core.validators import validate_password
|
||||
|
||||
|
||||
class AdminFormMixin(object):
|
||||
""" Provides a method for rendering a form just like in Django Admin """
|
||||
def as_admin(self):
|
||||
prepopulated_fields = {}
|
||||
fieldsets = [
|
||||
(None, {
|
||||
'fields': list(self.fields.keys())
|
||||
}),
|
||||
]
|
||||
adminform = helpers.AdminForm(self, fieldsets, prepopulated_fields)
|
||||
template = Template(
|
||||
'{% for fieldset in adminform %}'
|
||||
' {% include "admin/includes/fieldset.html" %}'
|
||||
'{% endfor %}'
|
||||
)
|
||||
context = {
|
||||
'adminform': adminform
|
||||
}
|
||||
return template.render(Context(context))
|
||||
|
||||
|
||||
class AdminFormSet(BaseModelFormSet):
|
||||
def as_admin(self):
|
||||
template = Template(textwrap.dedent("""\
|
||||
<div class="inline-group">
|
||||
<div class="tabular inline-related last-related">
|
||||
{{ formset.management_form }}
|
||||
<fieldset class="module">
|
||||
{{ formset.non_form_errors.as_ul }}
|
||||
<table id="formset" class="form">
|
||||
{% for form in formset.forms %}
|
||||
{% if forloop.first %}
|
||||
<thead><tr>
|
||||
{% for field in form.visible_fields %}
|
||||
<th>{{ field.label|capfirst }}</th>
|
||||
{% endfor %}
|
||||
</tr></thead>
|
||||
{% endif %}
|
||||
<tr class="{% cycle 'row1' 'row2' %}">
|
||||
{% for field in form.visible_fields %}
|
||||
<td>
|
||||
{# Include the hidden fields in the form #}
|
||||
{% if forloop.first %}
|
||||
{% for hidden in form.hidden_fields %}
|
||||
{{ hidden }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{{ field.errors.as_ul }}
|
||||
{{ field }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>""")
|
||||
)
|
||||
context = {
|
||||
'formset': self
|
||||
}
|
||||
return template.render(Context(context))
|
||||
|
||||
|
||||
class AdminPasswordChangeForm(forms.Form):
|
||||
"""
|
||||
A form used to change the password of a user in the admin interface.
|
||||
"""
|
||||
error_messages = {
|
||||
'password_mismatch': _("The two password fields didn't match."),
|
||||
'password_missing': _("No password has been provided."),
|
||||
'bad_hash': _("Invalid password format or unknown hashing algorithm."),
|
||||
}
|
||||
required_css_class = 'required'
|
||||
password = forms.CharField(label=_("Password"), required=False,
|
||||
widget=forms.TextInput(attrs={'size':'120'}))
|
||||
password1 = forms.CharField(label=_("Password"), widget=forms.PasswordInput,
|
||||
required=False, validators=[validate_password])
|
||||
password2 = forms.CharField(label=_("Password (again)"), widget=forms.PasswordInput,
|
||||
required=False)
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
self.related = kwargs.pop('related', [])
|
||||
self.raw = kwargs.pop('raw', False)
|
||||
self.user = user
|
||||
super().__init__(*args, **kwargs)
|
||||
self.password_provided = False
|
||||
for ix, rel in enumerate(self.related):
|
||||
self.fields['password_%i' % ix] = forms.CharField(label=_("Password"), required=False,
|
||||
widget=forms.TextInput(attrs={'size':'120'}))
|
||||
setattr(self, 'clean_password_%i' % ix, partial(self.clean_password, ix=ix))
|
||||
self.fields['password1_%i' % ix] = forms.CharField(label=_("Password"),
|
||||
widget=forms.PasswordInput, required=False)
|
||||
self.fields['password2_%i' % ix] = forms.CharField(label=_("Password (again)"),
|
||||
widget=forms.PasswordInput, required=False)
|
||||
setattr(self, 'clean_password2_%i' % ix, partial(self.clean_password2, ix=ix))
|
||||
|
||||
def clean_password2(self, ix=''):
|
||||
if ix != '':
|
||||
ix = '_%i' % ix
|
||||
password1 = self.cleaned_data.get('password1%s' % ix)
|
||||
password2 = self.cleaned_data.get('password2%s' % ix)
|
||||
if password1 and password2:
|
||||
self.password_provided = True
|
||||
if password1 != password2:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['password_mismatch'],
|
||||
code='password_mismatch',
|
||||
)
|
||||
elif password1 or password2:
|
||||
self.password_provided = True
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['password_mismatch'],
|
||||
code='password_mismatch',
|
||||
)
|
||||
return password2
|
||||
|
||||
def clean_password(self, ix=''):
|
||||
if ix != '':
|
||||
ix = '_%i' % ix
|
||||
password = self.cleaned_data.get('password%s' % ix)
|
||||
if password:
|
||||
# lazy loading because of passlib
|
||||
from django.contrib.auth.hashers import identify_hasher
|
||||
self.password_provided = True
|
||||
try:
|
||||
identify_hasher(password)
|
||||
except ValueError:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['bad_hash'],
|
||||
code='bad_hash',
|
||||
)
|
||||
return password
|
||||
|
||||
def clean(self):
|
||||
if not self.password_provided:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['password_missing'],
|
||||
code='password_missing',
|
||||
)
|
||||
|
||||
def save(self, commit=True):
|
||||
"""
|
||||
Saves the new password.
|
||||
"""
|
||||
field_name = 'password' if self.raw else 'password1'
|
||||
password = self.cleaned_data[field_name]
|
||||
if password:
|
||||
if self.raw:
|
||||
self.user.password = password
|
||||
else:
|
||||
self.user.set_password(password)
|
||||
if commit:
|
||||
try:
|
||||
self.user.save(update_fields=['password'])
|
||||
except ValueError:
|
||||
# password is not a field but an attribute
|
||||
self.user.save() # Trigger the backend
|
||||
for ix, rel in enumerate(self.related):
|
||||
password = self.cleaned_data['%s_%s' % (field_name, ix)]
|
||||
if password:
|
||||
if self.raw:
|
||||
rel.password = password
|
||||
else:
|
||||
set_password = getattr(rel, 'set_password')
|
||||
set_password(password)
|
||||
if commit:
|
||||
rel.save(update_fields=['password'])
|
||||
return self.user
|
||||
|
||||
def _get_changed_data(self):
|
||||
data = super().changed_data
|
||||
for name in self.fields.keys():
|
||||
if name not in data:
|
||||
return []
|
||||
return ['password']
|
||||
changed_data = property(_get_changed_data)
|
||||
|
||||
|
||||
class SendEmailForm(forms.Form):
|
||||
email_from = forms.EmailField(label=_("From"),
|
||||
widget=forms.TextInput(attrs={'size': '118'}))
|
||||
to = forms.CharField(label="To", required=False)
|
||||
extra_to = forms.CharField(label="To (extra)", required=False,
|
||||
widget=forms.TextInput(attrs={'size': '118'}))
|
||||
subject = forms.CharField(label=_("Subject"),
|
||||
widget=forms.TextInput(attrs={'size': '118'}))
|
||||
message = forms.CharField(label=_("Message"),
|
||||
widget=forms.Textarea(attrs={'cols': 118, 'rows': 15}))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
initial = kwargs.get('initial')
|
||||
if 'to' in initial:
|
||||
self.fields['to'].widget = SpanWidget(original=initial['to'])
|
||||
else:
|
||||
self.fields.pop('to')
|
||||
|
||||
def clean_comma_separated_emails(self, value):
|
||||
clean_value = []
|
||||
for email in value.split(','):
|
||||
email = email.strip()
|
||||
if email:
|
||||
try:
|
||||
validators.validate_email(email)
|
||||
except validators.ValidationError:
|
||||
raise validators.ValidationError("Comma separated email addresses.")
|
||||
clean_value.append(email)
|
||||
return clean_value
|
||||
|
||||
def clean_extra_to(self):
|
||||
extra_to = self.cleaned_data['extra_to']
|
||||
return self.clean_comma_separated_emails(extra_to)
|
20
orchestra/admin/html.py
Normal file
20
orchestra/admin/html.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
from django.utils.safestring import mark_safe
|
||||
|
||||
|
||||
MONOSPACE_FONTS = ('Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,'
|
||||
'Bitstream Vera Sans Mono,Courier New,monospace')
|
||||
|
||||
|
||||
def monospace_format(text):
|
||||
style="font-family:%s;padding-left:110px;white-space:pre-wrap;" % MONOSPACE_FONTS
|
||||
return mark_safe('<pre style="%s">%s</pre>' % (style, text))
|
||||
|
||||
|
||||
def code_format(text, language='bash'):
|
||||
from pygments import highlight
|
||||
from pygments.lexers import get_lexer_by_name
|
||||
from pygments.formatters import HtmlFormatter
|
||||
lexer = get_lexer_by_name(language, stripall=True)
|
||||
formatter = HtmlFormatter(linenos=True)
|
||||
code = highlight(text, lexer, formatter)
|
||||
return mark_safe('<div style="padding-left:110px;">%s</div>' % code)
|
100
orchestra/admin/menu.py
Normal file
100
orchestra/admin/menu.py
Normal file
|
@ -0,0 +1,100 @@
|
|||
from copy import deepcopy
|
||||
|
||||
from admin_tools.menu import items, Menu
|
||||
from django.urls import reverse
|
||||
from django.utils.text import capfirst
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from orchestra.core import services, accounts, administration
|
||||
|
||||
|
||||
def api_link(context):
|
||||
""" Dynamically generates API related URL """
|
||||
if 'opts' in context:
|
||||
opts = context['opts']
|
||||
elif 'cl' in context:
|
||||
opts = context['cl'].opts
|
||||
else:
|
||||
return reverse('api-root')
|
||||
if 'object_id' in context:
|
||||
object_id = context['object_id']
|
||||
try:
|
||||
return reverse('%s-detail' % opts.model_name, args=[object_id])
|
||||
except:
|
||||
return reverse('api-root')
|
||||
try:
|
||||
return reverse('%s-list' % opts.model_name)
|
||||
except:
|
||||
return reverse('api-root')
|
||||
|
||||
|
||||
def process_registry(register):
|
||||
def get_item(model, options, name=None):
|
||||
if name is None:
|
||||
name = capfirst(options.get('verbose_name_plural'))
|
||||
if isinstance(model, str):
|
||||
url = reverse('admin:'+model)
|
||||
else:
|
||||
opts = model._meta
|
||||
url = reverse('admin:{}_{}_changelist'.format(
|
||||
opts.app_label, opts.model_name)
|
||||
)
|
||||
item = items.MenuItem(name, url)
|
||||
item.options = options
|
||||
return item
|
||||
|
||||
childrens = {}
|
||||
for model, options in register.get().items():
|
||||
if options.get('menu', True):
|
||||
parent = options.get('parent')
|
||||
if parent:
|
||||
name = capfirst(model._meta.app_label)
|
||||
parent_item = childrens.get(parent)
|
||||
if parent_item:
|
||||
if not parent_item.children:
|
||||
parent_item.children.append(deepcopy(parent_item))
|
||||
parent_item.title = name
|
||||
else:
|
||||
parent_item = get_item(parent, register[parent], name=name)
|
||||
parent_item.children = []
|
||||
parent_item.children.append(get_item(model, options))
|
||||
childrens[parent] = parent_item
|
||||
elif model not in childrens:
|
||||
childrens[model] = get_item(model, options)
|
||||
else:
|
||||
childrens[model].children.insert(0, get_item(model, options))
|
||||
return sorted(childrens.values(), key=lambda i: i.title)
|
||||
|
||||
|
||||
class OrchestraMenu(Menu):
|
||||
template = 'admin/orchestra/menu.html'
|
||||
|
||||
def init_with_context(self, context):
|
||||
self.children = [
|
||||
# items.MenuItem(
|
||||
# mark_safe('{site_name} <span style="{version_style}">v{version}</span>'.format(
|
||||
# site_name=force_str(settings.SITE_VERBOSE_NAME),
|
||||
# version_style="text-transform:none; float:none; font-size:smaller; background:none;",
|
||||
# version=get_version())),
|
||||
# reverse('admin:index')
|
||||
# ),
|
||||
# items.MenuItem(
|
||||
# _('Dashboard'),
|
||||
# reverse('admin:index')
|
||||
# ),
|
||||
# items.Bookmarks(),
|
||||
items.MenuItem(
|
||||
_("Services"),
|
||||
children=process_registry(services)
|
||||
),
|
||||
items.MenuItem(
|
||||
_("Accounts"),
|
||||
reverse('admin:accounts_account_changelist'),
|
||||
children=process_registry(accounts)
|
||||
),
|
||||
items.MenuItem(
|
||||
_("Administration"),
|
||||
children=process_registry(administration)
|
||||
),
|
||||
items.MenuItem("API", api_link(context)),
|
||||
]
|
339
orchestra/admin/options.py
Normal file
339
orchestra/admin/options.py
Normal file
|
@ -0,0 +1,339 @@
|
|||
from urllib import parse
|
||||
|
||||
from django import forms
|
||||
from django.urls import re_path as url
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.admin.options import IS_POPUP_VAR
|
||||
from django.contrib.admin.utils import unquote
|
||||
from django.contrib.auth import update_session_auth_hash
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpResponseRedirect, Http404, HttpResponse
|
||||
from django.forms.models import BaseInlineFormSet
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.template.response import TemplateResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.html import escape
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.debug import sensitive_post_parameters
|
||||
|
||||
from orchestra.models.utils import has_db_field
|
||||
|
||||
from ..utils.python import random_ascii, pairwise
|
||||
|
||||
from .forms import AdminPasswordChangeForm
|
||||
#, AdminRawPasswordChangeForm
|
||||
#from django.contrib.auth.forms import AdminPasswordChangeForm
|
||||
from .utils import action_to_view
|
||||
|
||||
|
||||
sensitive_post_parameters_m = method_decorator(sensitive_post_parameters())
|
||||
|
||||
|
||||
class ChangeListDefaultFilter(object):
|
||||
"""
|
||||
Enables support for default filtering on admin change list pages
|
||||
Your model admin class should define an default_changelist_filters attribute
|
||||
default_changelist_filters = (('my_nodes', 'True'),)
|
||||
"""
|
||||
default_changelist_filters = ()
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
# defaults = []
|
||||
# for key, value in self.default_changelist_filters:
|
||||
# set_url_query(request, key, value)
|
||||
# defaults.append(key)
|
||||
# # hack response cl context in order to hook default filter awaearness
|
||||
# # into search_form.html template
|
||||
# response = super(ChangeListDefaultFilter, self).changelist_view(request, extra_context)
|
||||
# if hasattr(response, 'context_data') and 'cl' in response.context_data:
|
||||
# response.context_data['cl'].default_changelist_filters = defaults
|
||||
# return response
|
||||
querystring = request.META['QUERY_STRING']
|
||||
querydict = parse.parse_qs(querystring)
|
||||
redirect = False
|
||||
for field, value in self.default_changelist_filters:
|
||||
if field not in querydict:
|
||||
redirect = True
|
||||
querydict[field] = value
|
||||
if redirect:
|
||||
querystring = parse.urlencode(querydict, doseq=True)
|
||||
return HttpResponseRedirect(request.path + '?%s' % querystring)
|
||||
return super(ChangeListDefaultFilter, self).changelist_view(request, extra_context=extra_context)
|
||||
|
||||
|
||||
class AtLeastOneRequiredInlineFormSet(BaseInlineFormSet):
|
||||
def clean(self):
|
||||
"""Check that at least one service has been entered."""
|
||||
super(AtLeastOneRequiredInlineFormSet, self).clean()
|
||||
if any(self.errors):
|
||||
return
|
||||
if not any(cleaned_data and not cleaned_data.get('DELETE', False)
|
||||
for cleaned_data in self.cleaned_data):
|
||||
raise forms.ValidationError('At least one item required.')
|
||||
|
||||
|
||||
class EnhaceSearchMixin(object):
|
||||
def lookup_allowed(self, lookup, value):
|
||||
""" allows any lookup """
|
||||
if 'password' in lookup:
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_search_results(self, request, queryset, search_term):
|
||||
""" allows to specify field <field_name>:<search_term> """
|
||||
search_fields = self.get_search_fields(request)
|
||||
if '=' in search_term:
|
||||
fields = {field.split('__')[0]: field for field in search_fields}
|
||||
new_search_term = []
|
||||
for part in search_term.split():
|
||||
field = None
|
||||
if '=' in part:
|
||||
field, term = part.split('=')
|
||||
kwarg = '%s__icontains'
|
||||
c_term = term
|
||||
if term.startswith(('"', "'")) and term.endswith(('"', "'")):
|
||||
c_term = term[1:-1]
|
||||
kwarg = '%s__iexact'
|
||||
if field in fields:
|
||||
queryset = queryset.filter(**{kwarg % fields[field]: c_term})
|
||||
else:
|
||||
new_search_term.append('='.join((field, term)))
|
||||
else:
|
||||
new_search_term.append(part)
|
||||
search_term = ' '.join(new_search_term)
|
||||
return super(EnhaceSearchMixin, self).get_search_results(request, queryset, search_term)
|
||||
|
||||
|
||||
class ChangeViewActionsMixin(object):
|
||||
""" Makes actions visible on the admin change view page. """
|
||||
change_view_actions = ()
|
||||
change_form_template = 'orchestra/admin/change_form.html'
|
||||
|
||||
def get_urls(self):
|
||||
"""Returns the additional urls for the change view links"""
|
||||
urls = super(ChangeViewActionsMixin, self).get_urls()
|
||||
admin_site = self.admin_site
|
||||
opts = self.model._meta
|
||||
new_urls = []
|
||||
for action in self.get_change_view_actions():
|
||||
new_urls.append(
|
||||
url('^(\d+)/%s/$' % action.url_name,
|
||||
admin_site.admin_view(action),
|
||||
name='%s_%s_%s' % (opts.app_label, opts.model_name, action.url_name)
|
||||
)
|
||||
)
|
||||
return new_urls + urls
|
||||
|
||||
def get_change_view_actions(self, obj=None):
|
||||
""" allow customization on modelamdin """
|
||||
views = []
|
||||
for action in self.change_view_actions:
|
||||
if isinstance(action, str):
|
||||
action = getattr(self, action)
|
||||
view = action_to_view(action, self)
|
||||
view.url_name = getattr(action, 'url_name', action.__name__)
|
||||
tool_description = getattr(action, 'tool_description', '')
|
||||
if not tool_description:
|
||||
tool_description = getattr(action, 'short_description',
|
||||
view.url_name.capitalize().replace('_', ' '))
|
||||
if hasattr(tool_description, '__call__'):
|
||||
tool_description = tool_description(obj)
|
||||
view.tool_description = tool_description
|
||||
view.css_class = getattr(action, 'css_class', 'historylink')
|
||||
view.help_text = getattr(action, 'help_text', '')
|
||||
view.hidden = getattr(action, 'hidden', False)
|
||||
views.append(view)
|
||||
return views
|
||||
|
||||
def change_view(self, request, object_id, **kwargs):
|
||||
if kwargs.get('extra_context', None) is None:
|
||||
kwargs['extra_context'] = {}
|
||||
obj = self.get_object(request, unquote(object_id))
|
||||
kwargs['extra_context']['object_tools_items'] = [
|
||||
action.__dict__ for action in self.get_change_view_actions(obj) if not action.hidden
|
||||
]
|
||||
return super().change_view(request, object_id, **kwargs)
|
||||
|
||||
|
||||
class ChangeAddFieldsMixin(object):
|
||||
""" Enables to specify different set of fields for change and add views """
|
||||
add_fields = ()
|
||||
add_fieldsets = ()
|
||||
add_form = None
|
||||
add_prepopulated_fields = {}
|
||||
change_readonly_fields = ()
|
||||
change_form = None
|
||||
add_inlines = None
|
||||
|
||||
def get_prepopulated_fields(self, request, obj=None):
|
||||
if not obj:
|
||||
return super(ChangeAddFieldsMixin, self).get_prepopulated_fields(request, obj)
|
||||
return {}
|
||||
|
||||
def get_change_readonly_fields(self, request, obj=None):
|
||||
return self.change_readonly_fields
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
fields = super(ChangeAddFieldsMixin, self).get_readonly_fields(request, obj)
|
||||
if obj:
|
||||
return fields + self.get_change_readonly_fields(request, obj)
|
||||
return fields
|
||||
|
||||
def get_fieldsets(self, request, obj=None):
|
||||
if not obj:
|
||||
if self.add_fieldsets:
|
||||
return self.add_fieldsets
|
||||
elif self.add_fields:
|
||||
return [(None, {'fields': self.add_fields})]
|
||||
return super(ChangeAddFieldsMixin, self).get_fieldsets(request, obj)
|
||||
|
||||
def get_inline_instances(self, request, obj=None):
|
||||
""" add_inlines and inline.parent_object """
|
||||
if obj:
|
||||
self.inlines = type(self).inlines
|
||||
else:
|
||||
self.inlines = self.inlines if self.add_inlines is None else self.add_inlines
|
||||
inlines = super(ChangeAddFieldsMixin, self).get_inline_instances(request, obj)
|
||||
for inline in inlines:
|
||||
inline.parent_object = obj
|
||||
return inlines
|
||||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
""" Use special form during user creation """
|
||||
defaults = {}
|
||||
if obj is None:
|
||||
if self.add_form:
|
||||
defaults['form'] = self.add_form
|
||||
else:
|
||||
if self.change_form:
|
||||
defaults['form'] = self.change_form
|
||||
defaults.update(kwargs)
|
||||
return super(ChangeAddFieldsMixin, self).get_form(request, obj, **defaults)
|
||||
|
||||
|
||||
class ExtendedModelAdmin(ChangeViewActionsMixin,
|
||||
ChangeAddFieldsMixin,
|
||||
ChangeListDefaultFilter,
|
||||
EnhaceSearchMixin,
|
||||
admin.ModelAdmin):
|
||||
list_prefetch_related = None
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super(ExtendedModelAdmin, self).get_queryset(request)
|
||||
if self.list_prefetch_related:
|
||||
qs = qs.prefetch_related(*self.list_prefetch_related)
|
||||
return qs
|
||||
|
||||
def get_object(self, request, object_id, from_field=None):
|
||||
obj = super(ExtendedModelAdmin, self).get_object(request, object_id, from_field)
|
||||
if obj is None:
|
||||
opts = self.model._meta
|
||||
raise Http404(_('%(name)s object with primary key %(key)r does not exist.') % {
|
||||
'name': force_str(opts.verbose_name), 'key': escape(object_id)})
|
||||
return obj
|
||||
|
||||
|
||||
class ChangePasswordAdminMixin(object):
|
||||
change_password_form = AdminPasswordChangeForm
|
||||
change_user_password_template = 'admin/orchestra/change_password.html'
|
||||
|
||||
def get_urls(self):
|
||||
opts = self.model._meta
|
||||
info = opts.app_label, opts.model_name
|
||||
return [
|
||||
url(r'^(\d+)/password/$',
|
||||
self.admin_site.admin_view(self.change_password),
|
||||
name='%s_%s_change_password' % info),
|
||||
url(r'^(\d+)/hash/$',
|
||||
self.admin_site.admin_view(self.show_hash),
|
||||
name='%s_%s_show_hash' % info)
|
||||
] + super().get_urls()
|
||||
|
||||
def get_change_password_username(self, obj):
|
||||
return str(obj)
|
||||
|
||||
@sensitive_post_parameters_m
|
||||
def change_password(self, request, id, form_url=''):
|
||||
if not self.has_change_permission(request):
|
||||
raise PermissionDenied
|
||||
# TODO use this insetad of self.get_object(), in other places
|
||||
obj = get_object_or_404(self.get_queryset(request), pk=id)
|
||||
raw = request.GET.get('raw', '0') == '1'
|
||||
can_raw = has_db_field(obj, 'password')
|
||||
if raw and not can_raw:
|
||||
raise TypeError("%s has no password db field for raw password edditing." % obj)
|
||||
related = []
|
||||
for obj_name_attr in ('username', 'name', 'hostname'):
|
||||
try:
|
||||
obj_name = getattr(obj, obj_name_attr)
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
break
|
||||
if hasattr(obj, 'account'):
|
||||
account = obj.account
|
||||
if obj.account.username == obj_name:
|
||||
related.append(obj.account)
|
||||
else:
|
||||
account = obj
|
||||
if account.username == obj_name:
|
||||
for rel in account.get_related_passwords(db_field=raw):
|
||||
if not isinstance(obj, type(rel)):
|
||||
related.append(rel)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = self.change_password_form(obj, request.POST, related=related, raw=raw)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
self.log_change(request, obj, _("Password changed."))
|
||||
msg = _('Password changed successfully.')
|
||||
messages.success(request, msg)
|
||||
update_session_auth_hash(request, form.user) # This is safe
|
||||
return HttpResponseRedirect('..')
|
||||
else:
|
||||
form = self.change_password_form(obj, related=related, raw=raw)
|
||||
|
||||
fieldsets = [
|
||||
(obj._meta.verbose_name.capitalize(), {
|
||||
'classes': ('wide',),
|
||||
'fields': ('password',) if raw else ('password1', 'password2'),
|
||||
}),
|
||||
]
|
||||
for ix, rel in enumerate(related):
|
||||
fieldsets.append((rel._meta.verbose_name.capitalize(), {
|
||||
'classes': ('wide',),
|
||||
'fields': ('password_%i' % ix,) if raw else ('password1_%i' % ix, 'password2_%i' % ix)
|
||||
}))
|
||||
|
||||
obj_username = self.get_change_password_username(obj)
|
||||
adminForm = admin.helpers.AdminForm(form, fieldsets, {})
|
||||
context = {
|
||||
'title': _('Change password: %s') % obj_username,
|
||||
'adminform': adminForm,
|
||||
'raw': raw,
|
||||
'can_raw': can_raw,
|
||||
'errors': admin.helpers.AdminErrorList(form, []),
|
||||
'form_url': form_url,
|
||||
'is_popup': (IS_POPUP_VAR in request.POST or
|
||||
IS_POPUP_VAR in request.GET),
|
||||
'add': True,
|
||||
'change': False,
|
||||
'has_delete_permission': False,
|
||||
'has_change_permission': True,
|
||||
'has_absolute_url': False,
|
||||
'opts': self.model._meta,
|
||||
'original': obj,
|
||||
'obj_username': obj_username,
|
||||
'save_as': False,
|
||||
'show_save': True,
|
||||
'password': random_ascii(10),
|
||||
}
|
||||
context.update(admin.site.each_context(request))
|
||||
return TemplateResponse(request, self.change_user_password_template, context)
|
||||
|
||||
def show_hash(self, request, id):
|
||||
if not request.user.is_superuser:
|
||||
raise PermissionDenied
|
||||
obj = get_object_or_404(self.get_queryset(request), pk=id)
|
||||
return HttpResponse(obj.password)
|
185
orchestra/admin/utils.py
Normal file
185
orchestra/admin/utils.py
Normal file
|
@ -0,0 +1,185 @@
|
|||
import datetime
|
||||
import importlib
|
||||
import inspect
|
||||
from functools import wraps
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.urls import reverse, NoReverseMatch
|
||||
from django.db import models
|
||||
from django.shortcuts import redirect
|
||||
from django.utils import timezone
|
||||
from django.utils.html import escape, format_html
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from orchestra.models.utils import get_field_value
|
||||
from orchestra.utils import humanize
|
||||
|
||||
from .decorators import admin_field
|
||||
from .html import monospace_format, code_format
|
||||
|
||||
|
||||
def get_modeladmin(model, import_module=True):
|
||||
""" returns the modeladmin registred for model """
|
||||
for k,v in admin.site._registry.items():
|
||||
if k is model:
|
||||
return v
|
||||
if import_module:
|
||||
# Sometimes the admin module is not yet imported
|
||||
app_label = model._meta.app_label
|
||||
for app in settings.INSTALLED_APPS:
|
||||
if app.endswith(app_label):
|
||||
app_label = app
|
||||
importlib.import_module('%s.%s' % (app_label, 'admin'))
|
||||
return get_modeladmin(model, import_module=False)
|
||||
|
||||
|
||||
def insertattr(model, name, value):
|
||||
""" Inserts attribute to a modeladmin """
|
||||
modeladmin = None
|
||||
if issubclass(model, models.Model):
|
||||
modeladmin = get_modeladmin(model)
|
||||
modeladmin_class = type(modeladmin)
|
||||
elif not inspect.isclass(model):
|
||||
modeladmin = model
|
||||
modeladmin_class = type(modeladmin)
|
||||
else:
|
||||
modeladmin_class = model
|
||||
# Avoid inlines defined on parent class be shared between subclasses
|
||||
# Seems that if we use tuples they are lost in some conditions like changing
|
||||
# the tuple in modeladmin.__init__
|
||||
if not getattr(modeladmin_class, name):
|
||||
setattr(modeladmin_class, name, [])
|
||||
setattr(modeladmin_class, name, list(getattr(modeladmin_class, name))+[value])
|
||||
if modeladmin:
|
||||
# make sure class and object share the same attribute, to avoid wierd bugs
|
||||
setattr(modeladmin, name, getattr(modeladmin_class, name))
|
||||
|
||||
|
||||
def wrap_admin_view(modeladmin, view):
|
||||
""" Add admin authentication to view """
|
||||
@wraps(view)
|
||||
def wrapper(*args, **kwargs):
|
||||
return modeladmin.admin_site.admin_view(view)(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
def set_url_query(request, key, value):
|
||||
""" set default filters for changelist_view """
|
||||
if key not in request.GET:
|
||||
request_copy = request.GET.copy()
|
||||
if callable(value):
|
||||
value = value(request)
|
||||
request_copy[key] = value
|
||||
request.GET = request_copy
|
||||
request.META['QUERY_STRING'] = request.GET.urlencode()
|
||||
|
||||
|
||||
def action_to_view(action, modeladmin):
|
||||
""" Converts modeladmin action to view function """
|
||||
@wraps(action)
|
||||
def action_view(request, object_id=1, modeladmin=modeladmin, action=action):
|
||||
queryset = modeladmin.model.objects.filter(pk=object_id)
|
||||
response = action(modeladmin, request, queryset)
|
||||
if not response:
|
||||
opts = modeladmin.model._meta
|
||||
url = 'admin:%s_%s_change' % (opts.app_label, opts.model_name)
|
||||
return redirect(url, object_id)
|
||||
return response
|
||||
return action_view
|
||||
|
||||
|
||||
def change_url(obj):
|
||||
if obj is not None:
|
||||
cls = type(obj)
|
||||
opts = obj._meta
|
||||
if cls is models.DEFERRED:
|
||||
opts = cls.__base__._meta
|
||||
view_name = 'admin:%s_%s_change' % (opts.app_label, opts.model_name)
|
||||
return reverse(view_name, args=(obj.pk,))
|
||||
raise NoReverseMatch
|
||||
|
||||
|
||||
@admin_field
|
||||
def admin_link(*args, **kwargs):
|
||||
instance = args[-1]
|
||||
if kwargs['field'] in ('id', 'pk', '__str__'):
|
||||
obj = instance
|
||||
else:
|
||||
try:
|
||||
obj = get_field_value(instance, kwargs['field'])
|
||||
except ObjectDoesNotExist:
|
||||
return '---'
|
||||
if not getattr(obj, 'pk', None):
|
||||
return '---'
|
||||
display_ = kwargs.get('display')
|
||||
if display_:
|
||||
display_ = getattr(obj, display_, display_)
|
||||
else:
|
||||
display_ = obj
|
||||
try:
|
||||
url = change_url(obj)
|
||||
except NoReverseMatch:
|
||||
# Does not has admin
|
||||
return str(display_)
|
||||
extra = ''
|
||||
if kwargs['popup']:
|
||||
extra = mark_safe('onclick="return showAddAnotherPopup(this);"')
|
||||
title = "Change %s" % obj._meta.verbose_name
|
||||
return format_html('<a href="{}" title="{}" {}>{}</a>', url, title, extra, display_)
|
||||
|
||||
|
||||
@admin_field
|
||||
def admin_colored(*args, **kwargs):
|
||||
instance = args[-1]
|
||||
field = kwargs['field']
|
||||
value = escape(get_field_value(instance, field))
|
||||
color = kwargs.get('colors', {}).get(value, 'black')
|
||||
value = getattr(instance, 'get_%s_display' % field)().upper()
|
||||
colored_value = '<span style="color: %s;">%s</span>' % (color, value)
|
||||
if kwargs.get('bold', True):
|
||||
colored_value = '<b>%s</b>' % colored_value
|
||||
return mark_safe(colored_value)
|
||||
|
||||
|
||||
@admin_field
|
||||
def admin_date(*args, **kwargs):
|
||||
instance = args[-1]
|
||||
date = get_field_value(instance, kwargs['field'])
|
||||
if not date:
|
||||
return kwargs.get('default', '')
|
||||
if isinstance(date, datetime.datetime):
|
||||
natural = humanize.naturaldatetime(date)
|
||||
else:
|
||||
natural = humanize.naturaldate(date)
|
||||
if hasattr(date, 'hour'):
|
||||
date = timezone.localtime(date)
|
||||
date = date.strftime("%Y-%m-%d %H:%M:%S %Z")
|
||||
else:
|
||||
date = date.strftime("%Y-%m-%d")
|
||||
return format_html('<span title="{0}">{1}</span>', date, natural)
|
||||
|
||||
|
||||
def get_object_from_url(modeladmin, request):
|
||||
try:
|
||||
object_id = int(request.path.split('/')[-3])
|
||||
except ValueError:
|
||||
return None
|
||||
else:
|
||||
return modeladmin.model.objects.get(pk=object_id)
|
||||
|
||||
|
||||
def display_mono(field):
|
||||
def display(self, log):
|
||||
content = getattr(log, field)
|
||||
return monospace_format(escape(content))
|
||||
display.short_description = field
|
||||
return display
|
||||
|
||||
|
||||
def display_code(field):
|
||||
def display(self, log):
|
||||
return code_format(getattr(log, field))
|
||||
display.short_description = field
|
||||
return display
|
2
orchestra/api/__init__.py
Normal file
2
orchestra/api/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
from .options import *
|
||||
from .actions import *
|
30
orchestra/api/actions.py
Normal file
30
orchestra/api/actions.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .serializers import SetPasswordSerializer
|
||||
|
||||
|
||||
class SetPasswordApiMixin(object):
|
||||
@action(detail=True, methods=['post'], serializer_class=SetPasswordSerializer)
|
||||
def set_password(self, request, pk):
|
||||
obj = self.get_object()
|
||||
data = request.data
|
||||
if isinstance(data, str):
|
||||
data = {
|
||||
'password': data
|
||||
}
|
||||
serializer = SetPasswordSerializer(data=data)
|
||||
if serializer.is_valid():
|
||||
obj.set_password(serializer.data['password'])
|
||||
try:
|
||||
obj.save(update_fields=['password'])
|
||||
except ValueError:
|
||||
# Some services don't store the password on the database
|
||||
# update_fields=[] doesn't trigger post save!
|
||||
obj.save()
|
||||
return Response({
|
||||
'status': 'password changed'
|
||||
})
|
||||
else:
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
45
orchestra/api/helpers.py
Normal file
45
orchestra/api/helpers.py
Normal file
|
@ -0,0 +1,45 @@
|
|||
from django.urls import NoReverseMatch
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
|
||||
def link_wrap(view, view_names):
|
||||
def wrapper(self, request, *args, **kwargs):
|
||||
""" wrapper function that inserts HTTP links on view """
|
||||
links = []
|
||||
for name in view_names:
|
||||
try:
|
||||
url = reverse(name, request=self.request)
|
||||
except NoReverseMatch:
|
||||
url = reverse(name, args, kwargs, request=request)
|
||||
links.append('<%s>; rel="%s"' % (url, name))
|
||||
response = view(self, request, *args, **kwargs)
|
||||
response['Link'] = ', '.join(links)
|
||||
return response
|
||||
for attr in dir(view):
|
||||
try:
|
||||
setattr(wrapper, attr, getattr(view, attr))
|
||||
except:
|
||||
pass
|
||||
return wrapper
|
||||
|
||||
|
||||
def insert_links(viewset, basename):
|
||||
collection_links = ['api-root', '%s-list' % basename]
|
||||
object_links = ['api-root', '%s-list' % basename, '%s-detail' % basename]
|
||||
exception_links = ['api-root']
|
||||
list_links = ['api-root']
|
||||
retrieve_links = ['api-root', '%s-list' % basename]
|
||||
# Determine any `@action` or `@link` decorated methods on the viewset
|
||||
for methodname in dir(viewset):
|
||||
method = getattr(viewset, methodname)
|
||||
view_name = '%s-%s' % (basename, methodname.replace('_', '-'))
|
||||
if hasattr(method, 'collection_bind_to_methods'):
|
||||
list_links.append(view_name)
|
||||
retrieve_links.append(view_name)
|
||||
setattr(viewset, methodname, link_wrap(method, collection_links))
|
||||
elif hasattr(method, 'bind_to_methods'):
|
||||
retrieve_links.append(view_name)
|
||||
setattr(viewset, methodname, link_wrap(method, object_links))
|
||||
viewset.handle_exception = link_wrap(viewset.handle_exception, exception_links)
|
||||
viewset.list = link_wrap(viewset.list, list_links)
|
||||
viewset.retrieve = link_wrap(viewset.retrieve, retrieve_links)
|
94
orchestra/api/options.py
Normal file
94
orchestra/api/options.py
Normal file
|
@ -0,0 +1,94 @@
|
|||
from django.contrib.admin.options import get_content_type_for_model
|
||||
from django.conf import settings as django_settings
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.module_loading import autodiscover_modules
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from orchestra import settings
|
||||
from orchestra.utils.python import import_class
|
||||
|
||||
from .helpers import insert_links
|
||||
|
||||
|
||||
class LogApiMixin(object):
|
||||
def create(self, request, *args, **kwargs):
|
||||
from django.contrib.admin.models import ADDITION
|
||||
response = super(LogApiMixin, self).create(request, *args, **kwargs)
|
||||
message = _('Added.')
|
||||
self.log(request, message, ADDITION, instance=self.serializer.instance)
|
||||
return response
|
||||
|
||||
def perform_create(self, serializer):
|
||||
""" stores serializer for accessing instance on create() """
|
||||
super(LogApiMixin, self).perform_create(serializer)
|
||||
self.serializer = serializer
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
from django.contrib.admin.models import CHANGE
|
||||
response = super(LogApiMixin, self).update(request, *args, **kwargs)
|
||||
message = _('Changed data')
|
||||
self.log(request, message, CHANGE)
|
||||
return response
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
from django.contrib.admin.models import CHANGE
|
||||
response = super(LogApiMixin, self).partial_update(request, *args, **kwargs)
|
||||
message = _('Changed %s') % response.data
|
||||
self.log(request, message, CHANGE)
|
||||
return response
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
from django.contrib.admin.models import DELETION
|
||||
message = _('Deleted')
|
||||
self.log(request, message, DELETION)
|
||||
response = super(LogApiMixin, self).destroy(request, *args, **kwargs)
|
||||
return response
|
||||
|
||||
def log(self, request, message, action, instance=None):
|
||||
from django.contrib.admin.models import LogEntry
|
||||
instance = instance or self.get_object()
|
||||
LogEntry.objects.log_action(
|
||||
user_id=request.user.pk,
|
||||
content_type_id=get_content_type_for_model(instance).pk,
|
||||
object_id=instance.pk,
|
||||
object_repr=force_str(instance),
|
||||
action_flag=action,
|
||||
change_message=message,
|
||||
)
|
||||
|
||||
|
||||
class LinkHeaderRouter(DefaultRouter):
|
||||
def get_api_root_view(self, api_urls=None):
|
||||
""" returns the root view, with all the linked collections """
|
||||
APIRoot = import_class(settings.ORCHESTRA_API_ROOT_VIEW)
|
||||
APIRoot.router = self
|
||||
return APIRoot.as_view()
|
||||
|
||||
def register(self, prefix, viewset, basename=None):
|
||||
""" inserts link headers on every viewset """
|
||||
if basename is None:
|
||||
basename = self.get_default_basename(viewset)
|
||||
insert_links(viewset, basename)
|
||||
self.registry.append((prefix, viewset, basename))
|
||||
|
||||
def get_viewset(self, prefix_or_model):
|
||||
for _prefix, viewset, __ in self.registry:
|
||||
if _prefix == prefix_or_model or viewset.queryset.model == prefix_or_model:
|
||||
return viewset
|
||||
msg = "%s does not have a regiestered viewset" % prefix_or_model
|
||||
raise KeyError(msg)
|
||||
|
||||
def insert(self, prefix_or_model, name, field, **kwargs):
|
||||
""" Dynamically add new fields to an existing serializer """
|
||||
viewset = self.get_viewset(prefix_or_model)
|
||||
if viewset.serializer_class is None:
|
||||
viewset.serializer_class = viewset().get_serializer_class()
|
||||
viewset.serializer_class._declared_fields.update({name: field(**kwargs)})
|
||||
viewset.serializer_class.Meta.fields += (name,)
|
||||
|
||||
|
||||
# Create a router and register our viewsets with it.
|
||||
router = LinkHeaderRouter(trailing_slash=django_settings.APPEND_SLASH)
|
||||
|
||||
autodiscover = lambda: (autodiscover_modules('api'), autodiscover_modules('serializers'))
|
70
orchestra/api/root.py
Normal file
70
orchestra/api/root.py
Normal file
|
@ -0,0 +1,70 @@
|
|||
from rest_framework import views
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
from .. import settings
|
||||
from ..core import services, accounts
|
||||
|
||||
|
||||
class APIRoot(views.APIView):
|
||||
names = (
|
||||
'ORCHESTRA_SITE_NAME',
|
||||
'ORCHESTRA_SITE_VERBOSE_NAME'
|
||||
)
|
||||
|
||||
def get(self, request, format=None):
|
||||
root_url = reverse('api-root', request=request, format=format)
|
||||
token_url = reverse('api-token-auth', request=request, format=format)
|
||||
links = [
|
||||
'<%s>; rel="%s"' % (root_url, 'api-root'),
|
||||
'<%s>; rel="%s"' % (token_url, 'api-get-auth-token'),
|
||||
]
|
||||
body = {
|
||||
'accountancy': {},
|
||||
'services': {},
|
||||
}
|
||||
if not request.user.is_anonymous:
|
||||
list_name = '{basename}-list'
|
||||
detail_name = '{basename}-detail'
|
||||
for prefix, viewset, basename in self.router.registry:
|
||||
singleton_pk = getattr(viewset, 'singleton_pk', False)
|
||||
if singleton_pk:
|
||||
url_name = detail_name.format(basename=basename)
|
||||
kwargs = {
|
||||
'pk': singleton_pk(viewset(), request)
|
||||
}
|
||||
else:
|
||||
url_name = list_name.format(basename=basename)
|
||||
kwargs = {}
|
||||
url = reverse(url_name, request=request, format=format, kwargs=kwargs)
|
||||
links.append('<%s>; rel="%s"' % (url, url_name))
|
||||
model = viewset.queryset.model
|
||||
group = None
|
||||
if model in services:
|
||||
group = 'services'
|
||||
menu = services[model].menu
|
||||
if model in accounts:
|
||||
group = 'accountancy'
|
||||
menu = accounts[model].menu
|
||||
if group and menu:
|
||||
body[group][basename] = {
|
||||
'url': url,
|
||||
'verbose_name': model._meta.verbose_name,
|
||||
'verbose_name_plural': model._meta.verbose_name_plural,
|
||||
}
|
||||
headers = {
|
||||
'Link': ', '.join(links)
|
||||
}
|
||||
body.update({
|
||||
name.lower(): getattr(settings, name, None)
|
||||
for name in self.names
|
||||
})
|
||||
return Response(body, headers=headers)
|
||||
|
||||
def options(self, request):
|
||||
metadata = super(APIRoot, self).options(request)
|
||||
metadata.data['settings'] = {
|
||||
name.lower(): getattr(settings, name, None)
|
||||
for name in self.names
|
||||
}
|
||||
return metadata
|
114
orchestra/api/serializers.py
Normal file
114
orchestra/api/serializers.py
Normal file
|
@ -0,0 +1,114 @@
|
|||
import copy
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.forms import widgets
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
from rest_framework.utils import model_meta
|
||||
|
||||
from ..core.validators import validate_password
|
||||
|
||||
|
||||
class SetPasswordSerializer(serializers.Serializer):
|
||||
password = serializers.CharField(max_length=128, label=_('Password'),
|
||||
style={'widget': widgets.PasswordInput}, validators=[validate_password])
|
||||
|
||||
|
||||
class HyperlinkedModelSerializer(serializers.HyperlinkedModelSerializer):
|
||||
""" support for postonly_fields, fields whose value can only be set on post """
|
||||
|
||||
def validate(self, attrs):
|
||||
""" calls model.clean() """
|
||||
attrs = super(HyperlinkedModelSerializer, self).validate(attrs)
|
||||
if isinstance(attrs, models.Model):
|
||||
return attrs
|
||||
validated_data = dict(attrs)
|
||||
ModelClass = self.Meta.model
|
||||
# Remove many-to-many relationships from validated_data.
|
||||
info = model_meta.get_field_info(ModelClass)
|
||||
for field_name, relation_info in info.relations.items():
|
||||
if relation_info.to_many and (field_name in validated_data):
|
||||
validated_data.pop(field_name)
|
||||
if self.instance:
|
||||
# on update: Merge provided fields with instance field
|
||||
instance = copy.deepcopy(self.instance)
|
||||
for key, value in validated_data.items():
|
||||
setattr(instance, key, value)
|
||||
else:
|
||||
instance = ModelClass(**validated_data)
|
||||
instance.clean()
|
||||
return attrs
|
||||
|
||||
def post_only_cleanning(self, instance, validated_data):
|
||||
""" removes postonly_fields from attrs """
|
||||
model_attrs = dict(**validated_data)
|
||||
post_only_fields = getattr(self, 'post_only_fields', None)
|
||||
if instance is not None and post_only_fields:
|
||||
for attr, value in validated_data.items():
|
||||
if attr in post_only_fields:
|
||||
model_attrs.pop(attr)
|
||||
return model_attrs
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
""" removes postonly_fields from attrs when not posting """
|
||||
model_attrs = self.post_only_cleanning(instance, validated_data)
|
||||
return super(HyperlinkedModelSerializer, self).update(instance, model_attrs)
|
||||
|
||||
def partial_update(self, instance, validated_data):
|
||||
""" removes postonly_fields from attrs when not posting """
|
||||
model_attrs = self.post_only_cleanning(instance, validated_data)
|
||||
return super(HyperlinkedModelSerializer, self).partial_update(instance, model_attrs)
|
||||
|
||||
|
||||
class RelatedHyperlinkedModelSerializer(HyperlinkedModelSerializer):
|
||||
""" returns object on to_internal_value based on URL """
|
||||
def to_internal_value(self, data):
|
||||
try:
|
||||
url = data.get('url')
|
||||
except AttributeError:
|
||||
url = None
|
||||
if not url:
|
||||
raise ValidationError({
|
||||
'url': "URL is required."
|
||||
})
|
||||
account = self.get_account()
|
||||
queryset = self.Meta.model.objects.filter(account=account)
|
||||
self.fields['url'].queryset = queryset
|
||||
obj = self.fields['url'].to_internal_value(url)
|
||||
return obj
|
||||
|
||||
|
||||
class SetPasswordHyperlinkedSerializer(HyperlinkedModelSerializer):
|
||||
password = serializers.CharField(max_length=128, label=_('Password'),
|
||||
validators=[validate_password], write_only=True, required=False,
|
||||
style={'widget': widgets.PasswordInput})
|
||||
|
||||
def validate_password(self, value):
|
||||
""" POST only password """
|
||||
if self.instance:
|
||||
if value:
|
||||
raise serializers.ValidationError(_("Can not set password"))
|
||||
elif not value:
|
||||
raise serializers.ValidationError(_("Password required"))
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
""" remove password in case is not a real model field """
|
||||
try:
|
||||
self.Meta.model._meta.get_field('password')
|
||||
except models.FieldDoesNotExist:
|
||||
pass
|
||||
else:
|
||||
password = attrs.pop('password', None)
|
||||
attrs = super().validate(attrs)
|
||||
if password is not None:
|
||||
attrs['password'] = password
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
password = validated_data.pop('password')
|
||||
instance = self.Meta.model(**validated_data)
|
||||
instance.set_password(password)
|
||||
instance.save()
|
||||
return instance
|
6
orchestra/apps.py
Normal file
6
orchestra/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class OrchestraConfig(AppConfig):
|
||||
name = 'orchestra'
|
||||
verbose_name = 'Orchestra'
|
285
orchestra/bin/celerybeat
Executable file
285
orchestra/bin/celerybeat
Executable file
|
@ -0,0 +1,285 @@
|
|||
#!/bin/bash
|
||||
# =========================================================
|
||||
# celerybeat - Starts the Celery periodic task scheduler.
|
||||
# =========================================================
|
||||
#
|
||||
# :Usage: /etc/init.d/celerybeat {start|stop|force-reload|restart|try-restart|status}
|
||||
# :Configuration file: /etc/default/celerybeat or /etc/default/celeryd
|
||||
#
|
||||
# See http://docs.celeryproject.org/en/latest/tutorials/daemonizing.html#generic-init-scripts
|
||||
|
||||
### BEGIN INIT INFO
|
||||
# Provides: celerybeat
|
||||
# Required-Start: $network $local_fs $remote_fs
|
||||
# Required-Stop: $network $local_fs $remote_fs
|
||||
# Default-Start: 2 3 4 5
|
||||
# Default-Stop: 0 1 6
|
||||
# Short-Description: celery periodic task scheduler
|
||||
### END INIT INFO
|
||||
|
||||
# Cannot use set -e/bash -e since the kill -0 command will abort
|
||||
# abnormally in the absence of a valid process ID.
|
||||
#set -e
|
||||
VERSION=10.0
|
||||
echo "celery init v${VERSION}."
|
||||
|
||||
if [ $(id -u) -ne 0 ]; then
|
||||
echo "Error: This program can only be used by the root user."
|
||||
echo " Unpriviliged users must use 'celery beat --detach'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# May be a runlevel symlink (e.g. S02celeryd)
|
||||
if [ -L "$0" ]; then
|
||||
SCRIPT_FILE=$(readlink "$0")
|
||||
else
|
||||
SCRIPT_FILE="$0"
|
||||
fi
|
||||
SCRIPT_NAME="$(basename "$SCRIPT_FILE")"
|
||||
|
||||
# /etc/init.d/celerybeat: start and stop the celery periodic task scheduler daemon.
|
||||
|
||||
# Make sure executable configuration script is owned by root
|
||||
_config_sanity() {
|
||||
local path="$1"
|
||||
local owner=$(ls -ld "$path" | awk '{print $3}')
|
||||
local iwgrp=$(ls -ld "$path" | cut -b 6)
|
||||
local iwoth=$(ls -ld "$path" | cut -b 9)
|
||||
|
||||
if [ "$(id -u $owner)" != "0" ]; then
|
||||
echo "Error: Config script '$path' must be owned by root!"
|
||||
echo
|
||||
echo "Resolution:"
|
||||
echo "Review the file carefully and make sure it has not been "
|
||||
echo "modified with mailicious intent. When sure the "
|
||||
echo "script is safe to execute with superuser privileges "
|
||||
echo "you can change ownership of the script:"
|
||||
echo " $ sudo chown root '$path'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$iwoth" != "-" ]; then # S_IWOTH
|
||||
echo "Error: Config script '$path' cannot be writable by others!"
|
||||
echo
|
||||
echo "Resolution:"
|
||||
echo "Review the file carefully and make sure it has not been "
|
||||
echo "modified with malicious intent. When sure the "
|
||||
echo "script is safe to execute with superuser privileges "
|
||||
echo "you can change the scripts permissions:"
|
||||
echo " $ sudo chmod 640 '$path'"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$iwgrp" != "-" ]; then # S_IWGRP
|
||||
echo "Error: Config script '$path' cannot be writable by group!"
|
||||
echo
|
||||
echo "Resolution:"
|
||||
echo "Review the file carefully and make sure it has not been "
|
||||
echo "modified with malicious intent. When sure the "
|
||||
echo "script is safe to execute with superuser privileges "
|
||||
echo "you can change the scripts permissions:"
|
||||
echo " $ sudo chmod 640 '$path'"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
scripts=""
|
||||
|
||||
if test -f /etc/default/celeryd; then
|
||||
scripts="/etc/default/celeryd"
|
||||
_config_sanity /etc/default/celeryd
|
||||
. /etc/default/celeryd
|
||||
fi
|
||||
|
||||
EXTRA_CONFIG="/etc/default/${SCRIPT_NAME}"
|
||||
if test -f "$EXTRA_CONFIG"; then
|
||||
scripts="$scripts, $EXTRA_CONFIG"
|
||||
_config_sanity "$EXTRA_CONFIG"
|
||||
. "$EXTRA_CONFIG"
|
||||
fi
|
||||
|
||||
echo "Using configuration: $scripts"
|
||||
|
||||
CELERY_BIN=${CELERY_BIN:-"celery"}
|
||||
DEFAULT_USER="celery"
|
||||
DEFAULT_PID_FILE="/var/run/celery/beat.pid"
|
||||
DEFAULT_LOG_FILE="/var/log/celery/beat.log"
|
||||
DEFAULT_LOG_LEVEL="INFO"
|
||||
DEFAULT_CELERYBEAT="$CELERY_BIN beat"
|
||||
|
||||
CELERYBEAT=${CELERYBEAT:-$DEFAULT_CELERYBEAT}
|
||||
CELERYBEAT_LOG_LEVEL=${CELERYBEAT_LOG_LEVEL:-${CELERYBEAT_LOGLEVEL:-$DEFAULT_LOG_LEVEL}}
|
||||
|
||||
# Sets --app argument for CELERY_BIN
|
||||
CELERY_APP_ARG=""
|
||||
if [ ! -z "$CELERY_APP" ]; then
|
||||
CELERY_APP_ARG="--app=$CELERY_APP"
|
||||
fi
|
||||
|
||||
CELERYBEAT_USER=${CELERYBEAT_USER:-${CELERYD_USER:-$DEFAULT_USER}}
|
||||
|
||||
# Set CELERY_CREATE_DIRS to always create log/pid dirs.
|
||||
CELERY_CREATE_DIRS=${CELERY_CREATE_DIRS:-0}
|
||||
CELERY_CREATE_RUNDIR=$CELERY_CREATE_DIRS
|
||||
CELERY_CREATE_LOGDIR=$CELERY_CREATE_DIRS
|
||||
if [ -z "$CELERYBEAT_PID_FILE" ]; then
|
||||
CELERYBEAT_PID_FILE="$DEFAULT_PID_FILE"
|
||||
CELERY_CREATE_RUNDIR=1
|
||||
fi
|
||||
if [ -z "$CELERYBEAT_LOG_FILE" ]; then
|
||||
CELERYBEAT_LOG_FILE="$DEFAULT_LOG_FILE"
|
||||
CELERY_CREATE_LOGDIR=1
|
||||
fi
|
||||
|
||||
export CELERY_LOADER
|
||||
|
||||
CELERYBEAT_OPTS="$CELERYBEAT_OPTS -f $CELERYBEAT_LOG_FILE -l $CELERYBEAT_LOG_LEVEL"
|
||||
|
||||
if [ -n "$2" ]; then
|
||||
CELERYBEAT_OPTS="$CELERYBEAT_OPTS $2"
|
||||
fi
|
||||
|
||||
CELERYBEAT_LOG_DIR=`dirname $CELERYBEAT_LOG_FILE`
|
||||
CELERYBEAT_PID_DIR=`dirname $CELERYBEAT_PID_FILE`
|
||||
|
||||
# Extra start-stop-daemon options, like user/group.
|
||||
|
||||
CELERYBEAT_CHDIR=${CELERYBEAT_CHDIR:-$CELERYD_CHDIR}
|
||||
if [ -n "$CELERYBEAT_CHDIR" ]; then
|
||||
DAEMON_OPTS="$DAEMON_OPTS --workdir=$CELERYBEAT_CHDIR"
|
||||
fi
|
||||
|
||||
|
||||
export PATH="${PATH:+$PATH:}/usr/sbin:/sbin"
|
||||
|
||||
check_dev_null() {
|
||||
if [ ! -c /dev/null ]; then
|
||||
echo "/dev/null is not a character device!"
|
||||
exit 75 # EX_TEMPFAIL
|
||||
fi
|
||||
}
|
||||
|
||||
maybe_die() {
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Exiting: $*"
|
||||
exit 77 # EX_NOPERM
|
||||
fi
|
||||
}
|
||||
|
||||
create_default_dir() {
|
||||
if [ ! -d "$1" ]; then
|
||||
echo "- Creating default directory: '$1'"
|
||||
mkdir -p "$1"
|
||||
maybe_die "Couldn't create directory $1"
|
||||
echo "- Changing permissions of '$1' to 02755"
|
||||
chmod 02755 "$1"
|
||||
maybe_die "Couldn't change permissions for $1"
|
||||
if [ -n "$CELERYBEAT_USER" ]; then
|
||||
echo "- Changing owner of '$1' to '$CELERYBEAT_USER'"
|
||||
chown "$CELERYBEAT_USER" "$1"
|
||||
maybe_die "Couldn't change owner of $1"
|
||||
fi
|
||||
if [ -n "$CELERYBEAT_GROUP" ]; then
|
||||
echo "- Changing group of '$1' to '$CELERYBEAT_GROUP'"
|
||||
chgrp "$CELERYBEAT_GROUP" "$1"
|
||||
maybe_die "Couldn't change group of $1"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
check_paths() {
|
||||
if [ $CELERY_CREATE_LOGDIR -eq 1 ]; then
|
||||
create_default_dir "$CELERYBEAT_LOG_DIR"
|
||||
fi
|
||||
if [ $CELERY_CREATE_RUNDIR -eq 1 ]; then
|
||||
create_default_dir "$CELERYBEAT_PID_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
create_paths () {
|
||||
create_default_dir "$CELERYBEAT_LOG_DIR"
|
||||
create_default_dir "$CELERYBEAT_PID_DIR"
|
||||
}
|
||||
|
||||
|
||||
wait_pid () {
|
||||
pid=$1
|
||||
forever=1
|
||||
i=0
|
||||
while [ $forever -gt 0 ]; do
|
||||
kill -0 $pid 1>/dev/null 2>&1
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "OK"
|
||||
forever=0
|
||||
else
|
||||
kill -TERM "$pid"
|
||||
i=$((i + 1))
|
||||
if [ $i -gt 60 ]; then
|
||||
echo "ERROR"
|
||||
echo "Timed out while stopping (30s)"
|
||||
forever=0
|
||||
else
|
||||
sleep 0.5
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
stop_beat () {
|
||||
echo -n "Stopping ${SCRIPT_NAME}... "
|
||||
if [ -f "$CELERYBEAT_PID_FILE" ]; then
|
||||
wait_pid $(cat "$CELERYBEAT_PID_FILE")
|
||||
else
|
||||
echo "NOT RUNNING"
|
||||
fi
|
||||
}
|
||||
|
||||
_chuid () {
|
||||
su "$CELERYBEAT_USER" -c "$CELERYBEAT $*"
|
||||
}
|
||||
|
||||
start_beat () {
|
||||
echo "Starting ${SCRIPT_NAME}..."
|
||||
_chuid $CELERY_APP_ARG $CELERYBEAT_OPTS $DAEMON_OPTS --detach \
|
||||
--pidfile="$CELERYBEAT_PID_FILE"
|
||||
}
|
||||
|
||||
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
check_dev_null
|
||||
check_paths
|
||||
start_beat
|
||||
;;
|
||||
stop)
|
||||
check_paths
|
||||
stop_beat
|
||||
;;
|
||||
reload|force-reload)
|
||||
echo "Use start+stop"
|
||||
;;
|
||||
restart)
|
||||
echo "Restarting celery periodic task scheduler"
|
||||
check_paths
|
||||
stop_beat
|
||||
check_dev_null
|
||||
start_beat
|
||||
;;
|
||||
create-paths)
|
||||
check_dev_null
|
||||
create_paths
|
||||
;;
|
||||
check-paths)
|
||||
check_dev_null
|
||||
check_paths
|
||||
;;
|
||||
*)
|
||||
echo "Usage: /etc/init.d/${SCRIPT_NAME} {start|stop|restart|create-paths}"
|
||||
exit 64 # EX_USAGE
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
387
orchestra/bin/celeryd
Executable file
387
orchestra/bin/celeryd
Executable file
|
@ -0,0 +1,387 @@
|
|||
#!/bin/sh -e
|
||||
# ============================================
|
||||
# celeryd - Starts the Celery worker daemon.
|
||||
# ============================================
|
||||
#
|
||||
# :Usage: /etc/init.d/celeryd {start|stop|force-reload|restart|try-restart|status}
|
||||
# :Configuration file: /etc/default/celeryd
|
||||
#
|
||||
# See http://docs.celeryproject.org/en/latest/tutorials/daemonizing.html#generic-init-scripts
|
||||
|
||||
|
||||
### BEGIN INIT INFO
|
||||
# Provides: celeryd
|
||||
# Required-Start: $network $local_fs $remote_fs
|
||||
# Required-Stop: $network $local_fs $remote_fs
|
||||
# Default-Start: 2 3 4 5
|
||||
# Default-Stop: 0 1 6
|
||||
# Short-Description: celery task worker daemon
|
||||
### END INIT INFO
|
||||
#
|
||||
#
|
||||
# To implement separate init scripts, copy this script and give it a different
|
||||
# name:
|
||||
# I.e., if my new application, "little-worker" needs an init, I
|
||||
# should just use:
|
||||
#
|
||||
# cp /etc/init.d/celeryd /etc/init.d/little-worker
|
||||
#
|
||||
# You can then configure this by manipulating /etc/default/little-worker.
|
||||
#
|
||||
VERSION=10.0
|
||||
echo "celery init v${VERSION}."
|
||||
if [ $(id -u) -ne 0 ]; then
|
||||
echo "Error: This program can only be used by the root user."
|
||||
echo " Unprivileged users must use the 'celery multi' utility, "
|
||||
echo " or 'celery worker --detach'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Can be a runlevel symlink (e.g. S02celeryd)
|
||||
if [ -L "$0" ]; then
|
||||
SCRIPT_FILE=$(readlink "$0")
|
||||
else
|
||||
SCRIPT_FILE="$0"
|
||||
fi
|
||||
SCRIPT_NAME="$(basename "$SCRIPT_FILE")"
|
||||
|
||||
DEFAULT_USER="celery"
|
||||
DEFAULT_PID_FILE="/var/run/celery/%n.pid"
|
||||
DEFAULT_LOG_FILE="/var/log/celery/%n%I.log"
|
||||
DEFAULT_LOG_LEVEL="INFO"
|
||||
DEFAULT_NODES="celery"
|
||||
DEFAULT_CELERYD="-m celery worker --detach"
|
||||
|
||||
CELERY_DEFAULTS=${CELERY_DEFAULTS:-"/etc/default/${SCRIPT_NAME}"}
|
||||
|
||||
# Make sure executable configuration script is owned by root
|
||||
_config_sanity() {
|
||||
local path="$1"
|
||||
local owner=$(ls -ld "$path" | awk '{print $3}')
|
||||
local iwgrp=$(ls -ld "$path" | cut -b 6)
|
||||
local iwoth=$(ls -ld "$path" | cut -b 9)
|
||||
|
||||
if [ "$(id -u $owner)" != "0" ]; then
|
||||
echo "Error: Config script '$path' must be owned by root!"
|
||||
echo
|
||||
echo "Resolution:"
|
||||
echo "Review the file carefully and make sure it has not been "
|
||||
echo "modified with mailicious intent. When sure the "
|
||||
echo "script is safe to execute with superuser privileges "
|
||||
echo "you can change ownership of the script:"
|
||||
echo " $ sudo chown root '$path'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$iwoth" != "-" ]; then # S_IWOTH
|
||||
echo "Error: Config script '$path' cannot be writable by others!"
|
||||
echo
|
||||
echo "Resolution:"
|
||||
echo "Review the file carefully and make sure it has not been "
|
||||
echo "modified with malicious intent. When sure the "
|
||||
echo "script is safe to execute with superuser privileges "
|
||||
echo "you can change the scripts permissions:"
|
||||
echo " $ sudo chmod 640 '$path'"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$iwgrp" != "-" ]; then # S_IWGRP
|
||||
echo "Error: Config script '$path' cannot be writable by group!"
|
||||
echo
|
||||
echo "Resolution:"
|
||||
echo "Review the file carefully and make sure it has not been "
|
||||
echo "modified with malicious intent. When sure the "
|
||||
echo "script is safe to execute with superuser privileges "
|
||||
echo "you can change the scripts permissions:"
|
||||
echo " $ sudo chmod 640 '$path'"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -f "$CELERY_DEFAULTS" ]; then
|
||||
_config_sanity "$CELERY_DEFAULTS"
|
||||
echo "Using config script: $CELERY_DEFAULTS"
|
||||
. "$CELERY_DEFAULTS"
|
||||
fi
|
||||
|
||||
# Sets --app argument for CELERY_BIN
|
||||
CELERY_APP_ARG=""
|
||||
if [ ! -z "$CELERY_APP" ]; then
|
||||
CELERY_APP_ARG="--app=$CELERY_APP"
|
||||
fi
|
||||
|
||||
CELERYD_USER=${CELERYD_USER:-$DEFAULT_USER}
|
||||
|
||||
# Set CELERY_CREATE_DIRS to always create log/pid dirs.
|
||||
CELERY_CREATE_DIRS=${CELERY_CREATE_DIRS:-0}
|
||||
CELERY_CREATE_RUNDIR=$CELERY_CREATE_DIRS
|
||||
CELERY_CREATE_LOGDIR=$CELERY_CREATE_DIRS
|
||||
if [ -z "$CELERYD_PID_FILE" ]; then
|
||||
CELERYD_PID_FILE="$DEFAULT_PID_FILE"
|
||||
CELERY_CREATE_RUNDIR=1
|
||||
fi
|
||||
if [ -z "$CELERYD_LOG_FILE" ]; then
|
||||
CELERYD_LOG_FILE="$DEFAULT_LOG_FILE"
|
||||
CELERY_CREATE_LOGDIR=1
|
||||
fi
|
||||
|
||||
CELERYD_LOG_LEVEL=${CELERYD_LOG_LEVEL:-${CELERYD_LOGLEVEL:-$DEFAULT_LOG_LEVEL}}
|
||||
CELERY_BIN=${CELERY_BIN:-"celery"}
|
||||
CELERYD_MULTI=${CELERYD_MULTI:-"$CELERY_BIN multi"}
|
||||
CELERYD_NODES=${CELERYD_NODES:-$DEFAULT_NODES}
|
||||
|
||||
export CELERY_LOADER
|
||||
|
||||
if [ -n "$2" ]; then
|
||||
CELERYD_OPTS="$CELERYD_OPTS $2"
|
||||
fi
|
||||
|
||||
CELERYD_LOG_DIR=`dirname $CELERYD_LOG_FILE`
|
||||
CELERYD_PID_DIR=`dirname $CELERYD_PID_FILE`
|
||||
|
||||
# Extra start-stop-daemon options, like user/group.
|
||||
if [ -n "$CELERYD_CHDIR" ]; then
|
||||
DAEMON_OPTS="$DAEMON_OPTS --workdir=$CELERYD_CHDIR"
|
||||
fi
|
||||
|
||||
|
||||
check_dev_null() {
|
||||
if [ ! -c /dev/null ]; then
|
||||
echo "/dev/null is not a character device!"
|
||||
exit 75 # EX_TEMPFAIL
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
maybe_die() {
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Exiting: $* (errno $?)"
|
||||
exit 77 # EX_NOPERM
|
||||
fi
|
||||
}
|
||||
|
||||
create_default_dir() {
|
||||
if [ ! -d "$1" ]; then
|
||||
echo "- Creating default directory: '$1'"
|
||||
mkdir -p "$1"
|
||||
maybe_die "Couldn't create directory $1"
|
||||
echo "- Changing permissions of '$1' to 02755"
|
||||
chmod 02755 "$1"
|
||||
maybe_die "Couldn't change permissions for $1"
|
||||
if [ -n "$CELERYD_USER" ]; then
|
||||
echo "- Changing owner of '$1' to '$CELERYD_USER'"
|
||||
chown "$CELERYD_USER" "$1"
|
||||
maybe_die "Couldn't change owner of $1"
|
||||
fi
|
||||
if [ -n "$CELERYD_GROUP" ]; then
|
||||
echo "- Changing group of '$1' to '$CELERYD_GROUP'"
|
||||
chgrp "$CELERYD_GROUP" "$1"
|
||||
maybe_die "Couldn't change group of $1"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
check_paths() {
|
||||
if [ $CELERY_CREATE_LOGDIR -eq 1 ]; then
|
||||
create_default_dir "$CELERYD_LOG_DIR"
|
||||
fi
|
||||
if [ $CELERY_CREATE_RUNDIR -eq 1 ]; then
|
||||
create_default_dir "$CELERYD_PID_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
create_paths() {
|
||||
create_default_dir "$CELERYD_LOG_DIR"
|
||||
create_default_dir "$CELERYD_PID_DIR"
|
||||
}
|
||||
|
||||
export PATH="${PATH:+$PATH:}/usr/sbin:/sbin"
|
||||
|
||||
|
||||
_get_pids() {
|
||||
found_pids=0
|
||||
my_exitcode=0
|
||||
|
||||
for pid_file in "$CELERYD_PID_DIR"/*.pid; do
|
||||
local pid=`cat "$pid_file"`
|
||||
local cleaned_pid=`echo "$pid" | sed -e 's/[^0-9]//g'`
|
||||
if [ -z "$pid" ] || [ "$cleaned_pid" != "$pid" ]; then
|
||||
echo "bad pid file ($pid_file)"
|
||||
one_failed=true
|
||||
my_exitcode=1
|
||||
else
|
||||
found_pids=1
|
||||
echo "$pid"
|
||||
fi
|
||||
|
||||
if [ $found_pids -eq 0 ]; then
|
||||
echo "${SCRIPT_NAME}: All nodes down"
|
||||
exit $my_exitcode
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
_chuid () {
|
||||
su "$CELERYD_USER" -c "$CELERYD_MULTI $*"
|
||||
}
|
||||
|
||||
|
||||
start_workers () {
|
||||
if [ ! -z "$CELERYD_ULIMIT" ]; then
|
||||
ulimit $CELERYD_ULIMIT
|
||||
fi
|
||||
_chuid $* start $CELERYD_NODES $DAEMON_OPTS \
|
||||
--pidfile="$CELERYD_PID_FILE" \
|
||||
--logfile="$CELERYD_LOG_FILE" \
|
||||
--loglevel="$CELERYD_LOG_LEVEL" \
|
||||
$CELERY_APP_ARG \
|
||||
$CELERYD_OPTS
|
||||
}
|
||||
|
||||
|
||||
dryrun () {
|
||||
(C_FAKEFORK=1 start_workers --verbose)
|
||||
}
|
||||
|
||||
|
||||
stop_workers () {
|
||||
_chuid stopwait $CELERYD_NODES --pidfile="$CELERYD_PID_FILE"
|
||||
}
|
||||
|
||||
|
||||
restart_workers () {
|
||||
_chuid restart $CELERYD_NODES $DAEMON_OPTS \
|
||||
--pidfile="$CELERYD_PID_FILE" \
|
||||
--logfile="$CELERYD_LOG_FILE" \
|
||||
--loglevel="$CELERYD_LOG_LEVEL" \
|
||||
$CELERY_APP_ARG \
|
||||
$CELERYD_OPTS
|
||||
}
|
||||
|
||||
|
||||
kill_workers() {
|
||||
_chuid kill $CELERYD_NODES --pidfile="$CELERYD_PID_FILE"
|
||||
}
|
||||
|
||||
|
||||
restart_workers_graceful () {
|
||||
local worker_pids=
|
||||
worker_pids=`_get_pids`
|
||||
[ "$one_failed" ] && exit 1
|
||||
|
||||
for worker_pid in $worker_pids; do
|
||||
local failed=
|
||||
kill -HUP $worker_pid 2> /dev/null || failed=true
|
||||
if [ "$failed" ]; then
|
||||
echo "${SCRIPT_NAME} worker (pid $worker_pid) could not be restarted"
|
||||
one_failed=true
|
||||
else
|
||||
echo "${SCRIPT_NAME} worker (pid $worker_pid) received SIGHUP"
|
||||
fi
|
||||
done
|
||||
|
||||
[ "$one_failed" ] && exit 1 || exit 0
|
||||
}
|
||||
|
||||
|
||||
check_status () {
|
||||
my_exitcode=0
|
||||
found_pids=0
|
||||
|
||||
local one_failed=
|
||||
for pid_file in "$CELERYD_PID_DIR"/*.pid; do
|
||||
if [ ! -r $pid_file ]; then
|
||||
echo "${SCRIPT_NAME} is stopped: no pids were found"
|
||||
one_failed=true
|
||||
break
|
||||
fi
|
||||
|
||||
local node=`basename "$pid_file" .pid`
|
||||
local pid=`cat "$pid_file"`
|
||||
local cleaned_pid=`echo "$pid" | sed -e 's/[^0-9]//g'`
|
||||
if [ -z "$pid" ] || [ "$cleaned_pid" != "$pid" ]; then
|
||||
echo "bad pid file ($pid_file)"
|
||||
one_failed=true
|
||||
else
|
||||
local failed=
|
||||
kill -0 $pid 2> /dev/null || failed=true
|
||||
if [ "$failed" ]; then
|
||||
echo "${SCRIPT_NAME} (node $node) (pid $pid) is stopped, but pid file exists!"
|
||||
one_failed=true
|
||||
else
|
||||
echo "${SCRIPT_NAME} (node $node) (pid $pid) is running..."
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
[ "$one_failed" ] && exit 1 || exit 0
|
||||
}
|
||||
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
check_dev_null
|
||||
check_paths
|
||||
start_workers
|
||||
;;
|
||||
|
||||
stop)
|
||||
check_dev_null
|
||||
check_paths
|
||||
stop_workers
|
||||
;;
|
||||
|
||||
reload|force-reload)
|
||||
echo "Use restart"
|
||||
;;
|
||||
|
||||
status)
|
||||
check_status
|
||||
;;
|
||||
|
||||
restart)
|
||||
check_dev_null
|
||||
check_paths
|
||||
restart_workers
|
||||
;;
|
||||
|
||||
graceful)
|
||||
check_dev_null
|
||||
restart_workers_graceful
|
||||
;;
|
||||
|
||||
kill)
|
||||
check_dev_null
|
||||
kill_workers
|
||||
;;
|
||||
|
||||
dryrun)
|
||||
check_dev_null
|
||||
dryrun
|
||||
;;
|
||||
|
||||
try-restart)
|
||||
check_dev_null
|
||||
check_paths
|
||||
restart_workers
|
||||
;;
|
||||
|
||||
create-paths)
|
||||
check_dev_null
|
||||
create_paths
|
||||
;;
|
||||
|
||||
check-paths)
|
||||
check_dev_null
|
||||
check_paths
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Usage: /etc/init.d/${SCRIPT_NAME} {start|stop|restart|graceful|kill|dryrun|create-paths}"
|
||||
exit 64 # EX_USAGE
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
226
orchestra/bin/celeryevcam
Executable file
226
orchestra/bin/celeryevcam
Executable file
|
@ -0,0 +1,226 @@
|
|||
#!/bin/bash
|
||||
# ============================================
|
||||
# celeryd - Starts the Celery worker daemon.
|
||||
# ============================================
|
||||
#
|
||||
# :Usage: /etc/init.d/celeryd {start|stop|force-reload|restart|try-restart|status}
|
||||
#
|
||||
# :Configuration file: /etc/default/celeryev | /etc/default/celeryd
|
||||
#
|
||||
# To configure celeryd you probably need to tell it where to chdir.
|
||||
#
|
||||
# EXAMPLE CONFIGURATION
|
||||
# =====================
|
||||
#
|
||||
# this is an example configuration for a Python project:
|
||||
#
|
||||
# /etc/default/celeryd:
|
||||
#
|
||||
# # Where to chdir at start.
|
||||
# CELERYD_CHDIR="/opt/Myproject/"
|
||||
#
|
||||
# # Extra arguments to celeryev
|
||||
# CELERYEV_OPTS="-x"
|
||||
#
|
||||
# # Name of the celery config module.#
|
||||
# CELERY_CONFIG_MODULE="celeryconfig"
|
||||
#
|
||||
# # Camera class to use (required)
|
||||
# CELERYEV_CAM = "myapp.Camera"
|
||||
#
|
||||
# EXAMPLE DJANGO CONFIGURATION
|
||||
# ============================
|
||||
#
|
||||
# # Where the Django project is.
|
||||
# CELERYD_CHDIR="/opt/Project/"
|
||||
#
|
||||
# # Name of the projects settings module.
|
||||
# export DJANGO_SETTINGS_MODULE="MyProject.settings"
|
||||
#
|
||||
# # Path to celeryd
|
||||
# CELERYEV="/opt/Project/manage.py"
|
||||
#
|
||||
# # Extra arguments to manage.py
|
||||
# CELERYEV_OPTS="celeryev"
|
||||
#
|
||||
# # Camera class to use (required)
|
||||
# CELERYEV_CAM="djcelery.snapshot.Camera"
|
||||
#
|
||||
# AVAILABLE OPTIONS
|
||||
# =================
|
||||
#
|
||||
# * CELERYEV_OPTS
|
||||
# Additional arguments to celeryd, see `celeryd --help` for a list.
|
||||
#
|
||||
# * CELERYD_CHDIR
|
||||
# Path to chdir at start. Default is to stay in the current directory.
|
||||
#
|
||||
# * CELERYEV_PID_FILE
|
||||
# Full path to the pidfile. Default is /var/run/celeryd.pid.
|
||||
#
|
||||
# * CELERYEV_LOG_FILE
|
||||
# Full path to the celeryd logfile. Default is /var/log/celeryd.log
|
||||
#
|
||||
# * CELERYEV_LOG_LEVEL
|
||||
# Log level to use for celeryd. Default is INFO.
|
||||
#
|
||||
# * CELERYEV
|
||||
# Path to the celeryev program. Default is `celeryev`.
|
||||
# You can point this to an virtualenv, or even use manage.py for django.
|
||||
#
|
||||
# * CELERYEV_USER
|
||||
# User to run celeryev as. Default is current user.
|
||||
#
|
||||
# * CELERYEV_GROUP
|
||||
# Group to run celeryev as. Default is current user.
|
||||
#
|
||||
# * VIRTUALENV
|
||||
# Full path to the virtualenv environment to activate. Default is none.
|
||||
|
||||
### BEGIN INIT INFO
|
||||
# Provides: celeryev
|
||||
# Required-Start: $network $local_fs $remote_fs postgresql rabbitmq-server
|
||||
# Required-Stop: $network $local_fs $remote_fs postgresql rabbitmq-server
|
||||
# Default-Start: 2 3 4 5
|
||||
# Default-Stop: 0 1 6
|
||||
# Short-Description: celery event snapshots
|
||||
### END INIT INFO
|
||||
|
||||
# Cannot use set -e/bash -e since the kill -0 command will abort
|
||||
# abnormally in the absence of a valid process ID.
|
||||
#set -e
|
||||
|
||||
DEFAULT_PID_FILE="/var/run/celeryev.pid"
|
||||
DEFAULT_LOG_FILE="/var/log/celeryev.log"
|
||||
DEFAULT_LOG_LEVEL="INFO"
|
||||
DEFAULT_CELERYEV="/usr/bin/celeryev"
|
||||
|
||||
if test -f /etc/default/celeryd; then
|
||||
. /etc/default/celeryd
|
||||
fi
|
||||
|
||||
if test -f /etc/default/celeryev; then
|
||||
. /etc/default/celeryev
|
||||
fi
|
||||
|
||||
CELERYEV=${CELERYEV:-$DEFAULT_CELERYEV}
|
||||
CELERYEV_PID_FILE=${CELERYEV_PID_FILE:-${CELERYEV_PIDFILE:-$DEFAULT_PID_FILE}}
|
||||
CELERYEV_LOG_FILE=${CELERYEV_LOG_FILE:-${CELERYEV_LOGFILE:-$DEFAULT_LOG_FILE}}
|
||||
CELERYEV_LOG_LEVEL=${CELERYEV_LOG_LEVEL:-${CELERYEV_LOG_LEVEL:-$DEFAULT_LOG_LEVEL}}
|
||||
|
||||
export CELERY_LOADER
|
||||
|
||||
if [ -z "$CELERYEV_CAM" ]; then
|
||||
echo "Missing CELERYEV_CAM variable" 1>&2
|
||||
exit
|
||||
fi
|
||||
|
||||
CELERYEV_OPTS="$CELERYEV_OPTS -f $CELERYEV_LOG_FILE -l $CELERYEV_LOG_LEVEL -c $CELERYEV_CAM"
|
||||
|
||||
if [ -n "$2" ]; then
|
||||
CELERYEV_OPTS="$CELERYEV_OPTS $2"
|
||||
fi
|
||||
|
||||
CELERYEV_LOG_DIR=`dirname $CELERYEV_LOG_FILE`
|
||||
CELERYEV_PID_DIR=`dirname $CELERYEV_PID_FILE`
|
||||
if [ ! -d "$CELERYEV_LOG_DIR" ]; then
|
||||
mkdir -p $CELERYEV_LOG_DIR
|
||||
fi
|
||||
if [ ! -d "$CELERYEV_PID_DIR" ]; then
|
||||
mkdir -p $CELERYEV_PID_DIR
|
||||
fi
|
||||
|
||||
# Extra start-stop-daemon options, like user/group.
|
||||
if [ -n "$CELERYEV_USER" ]; then
|
||||
DAEMON_OPTS="$DAEMON_OPTS --uid $CELERYEV_USER"
|
||||
chown "$CELERYEV_USER" $CELERYEV_LOG_DIR $CELERYEV_PID_DIR
|
||||
fi
|
||||
if [ -n "$CELERYEV_GROUP" ]; then
|
||||
DAEMON_OPTS="$DAEMON_OPTS --gid $CELERYEV_GROUP"
|
||||
chgrp "$CELERYEV_GROUP" $CELERYEV_LOG_DIR $CELERYEV_PID_DIR
|
||||
fi
|
||||
|
||||
CELERYEV_CHDIR=${CELERYEV_CHDIR:-$CELERYD_CHDIR}
|
||||
if [ -n "$CELERYEV_CHDIR" ]; then
|
||||
DAEMON_OPTS="$DAEMON_OPTS --workdir $CELERYEV_CHDIR"
|
||||
fi
|
||||
|
||||
|
||||
export PATH="${PATH:+$PATH:}/usr/sbin:/sbin"
|
||||
|
||||
check_dev_null() {
|
||||
if [ ! -c /dev/null ]; then
|
||||
echo "/dev/null is not a character device!"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
wait_pid () {
|
||||
pid=$1
|
||||
forever=1
|
||||
i=0
|
||||
while [ $forever -gt 0 ]; do
|
||||
kill -0 $pid 1>/dev/null 2>&1
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "OK"
|
||||
forever=0
|
||||
else
|
||||
kill -TERM "$pid"
|
||||
i=$((i + 1))
|
||||
if [ $i -gt 60 ]; then
|
||||
echo "ERROR"
|
||||
echo "Timed out while stopping (30s)"
|
||||
forever=0
|
||||
else
|
||||
sleep 0.5
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
stop_evcam () {
|
||||
echo -n "Stopping celeryev..."
|
||||
if [ -f "$CELERYEV_PID_FILE" ]; then
|
||||
wait_pid $(cat "$CELERYEV_PID_FILE")
|
||||
else
|
||||
echo "NOT RUNNING"
|
||||
fi
|
||||
}
|
||||
|
||||
start_evcam () {
|
||||
echo "Starting celeryev..."
|
||||
if [ -n "$VIRTUALENV" ]; then
|
||||
source $VIRTUALENV/bin/activate
|
||||
fi
|
||||
$CELERYEV $CELERYEV_OPTS $DAEMON_OPTS --detach \
|
||||
--pidfile="$CELERYEV_PID_FILE"
|
||||
}
|
||||
|
||||
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
check_dev_null
|
||||
start_evcam
|
||||
;;
|
||||
stop)
|
||||
stop_evcam
|
||||
;;
|
||||
reload|force-reload)
|
||||
echo "Use start+stop"
|
||||
;;
|
||||
restart)
|
||||
echo "Restarting celery event snapshots" "celeryev"
|
||||
stop_evcam
|
||||
check_dev_null
|
||||
start_evcam
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Usage: /etc/init.d/celeryev {start|stop|restart}"
|
||||
exit 1
|
||||
esac
|
||||
|
||||
exit 0
|
||||
|
72
orchestra/bin/django_bash_completion.sh
Executable file
72
orchestra/bin/django_bash_completion.sh
Executable file
|
@ -0,0 +1,72 @@
|
|||
# #########################################################################
|
||||
# This bash script adds tab-completion feature to django-admin.py and
|
||||
# manage.py.
|
||||
#
|
||||
# Testing it out without installing
|
||||
# =================================
|
||||
#
|
||||
# To test out the completion without "installing" this, just run this file
|
||||
# directly, like so:
|
||||
#
|
||||
# . ~/path/to/django_bash_completion
|
||||
#
|
||||
# Note: There's a dot ('.') at the beginning of that command.
|
||||
#
|
||||
# After you do that, tab completion will immediately be made available in your
|
||||
# current Bash shell. But it won't be available next time you log in.
|
||||
#
|
||||
# Installing
|
||||
# ==========
|
||||
#
|
||||
# To install this, point to this file from your .bash_profile, like so:
|
||||
#
|
||||
# . ~/path/to/django_bash_completion
|
||||
#
|
||||
# Do the same in your .bashrc if .bashrc doesn't invoke .bash_profile.
|
||||
#
|
||||
# Settings will take effect the next time you log in.
|
||||
#
|
||||
# Uninstalling
|
||||
# ============
|
||||
#
|
||||
# To uninstall, just remove the line from your .bash_profile and .bashrc.
|
||||
|
||||
_django_completion()
|
||||
{
|
||||
COMPREPLY=( $( COMP_WORDS="${COMP_WORDS[*]}" \
|
||||
COMP_CWORD=$COMP_CWORD \
|
||||
DJANGO_AUTO_COMPLETE=1 $1 ) )
|
||||
}
|
||||
complete -F _django_completion -o default django-admin.py manage.py django-admin
|
||||
|
||||
_python_django_completion()
|
||||
{
|
||||
if [[ ${COMP_CWORD} -ge 2 ]]; then
|
||||
PYTHON_EXE=${COMP_WORDS[0]##*/}
|
||||
echo $PYTHON_EXE | egrep "python([2-9]\.[0-9])?" >/dev/null 2>&1
|
||||
if [[ $? == 0 ]]; then
|
||||
PYTHON_SCRIPT=${COMP_WORDS[1]##*/}
|
||||
echo $PYTHON_SCRIPT | egrep "manage\.py|django-admin(\.py)?" >/dev/null 2>&1
|
||||
if [[ $? == 0 ]]; then
|
||||
COMPREPLY=( $( COMP_WORDS="${COMP_WORDS[*]:1}" \
|
||||
COMP_CWORD=$(( COMP_CWORD-1 )) \
|
||||
DJANGO_AUTO_COMPLETE=1 ${COMP_WORDS[*]} ) )
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Support for multiple interpreters.
|
||||
unset pythons
|
||||
if command -v whereis &>/dev/null; then
|
||||
python_interpreters=$(whereis python | cut -d " " -f 2-)
|
||||
for python in $python_interpreters; do
|
||||
pythons="${pythons} ${python##*/}"
|
||||
done
|
||||
pythons=$(echo $pythons | tr " " "\n" | sort -u | tr "\n" " ")
|
||||
else
|
||||
pythons=python
|
||||
fi
|
||||
|
||||
complete -F _python_django_completion -o default $pythons
|
||||
|
246
orchestra/bin/orchestra-admin
Executable file
246
orchestra/bin/orchestra-admin
Executable file
|
@ -0,0 +1,246 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -u
|
||||
set -e
|
||||
|
||||
bold=$(tput -T ${TERM:-xterm} bold)
|
||||
normal=$(tput -T ${TERM:-xterm} sgr0)
|
||||
|
||||
|
||||
PYTHON_BIN='python3'
|
||||
|
||||
function help () {
|
||||
if [[ $# -gt 1 ]]; then
|
||||
CMD="print_${2}_help"
|
||||
$CMD
|
||||
else
|
||||
print_help
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
function print_help () {
|
||||
cat <<- EOF
|
||||
|
||||
${bold}NAME${normal}
|
||||
${bold}orchestra-admin${normal} - Orchetsra administration script
|
||||
|
||||
${bold}OPTIONS${normal}
|
||||
${bold}install_requirements${normal}
|
||||
Installs Orchestra requirements using apt-get and pip
|
||||
|
||||
${bold}startproject${normal}
|
||||
Creates a new Django-orchestra instance
|
||||
|
||||
${bold}help${normal}
|
||||
Displays this help text or related help page as argument
|
||||
for example:
|
||||
${bold}orchestra-admin help startproject${normal}
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
|
||||
show () {
|
||||
echo " ${bold}\$ ${@}${normal}"
|
||||
}
|
||||
export -f show
|
||||
|
||||
|
||||
run () {
|
||||
show "${@}"
|
||||
"${@}"
|
||||
}
|
||||
export -f run
|
||||
|
||||
|
||||
check_root () {
|
||||
[ $(whoami) != 'root' ] && { echo -e "\nErr. This should be run as root\n" >&2; exit 1; }
|
||||
}
|
||||
export -f check_root
|
||||
|
||||
|
||||
get_orchestra_dir () {
|
||||
if ! $(echo "import orchestra" | $PYTHON_BIN 2> /dev/null); then
|
||||
echo -e "\norchestra not installed.\n" >&2
|
||||
exit 1
|
||||
fi
|
||||
PATH=$(echo "import orchestra, os; print(os.path.dirname(os.path.realpath(orchestra.__file__)))" | $PYTHON_BIN)
|
||||
echo $PATH
|
||||
}
|
||||
export -f get_orchestra_dir
|
||||
|
||||
|
||||
function print_install_requirements_help () {
|
||||
cat <<- EOF
|
||||
|
||||
${bold}NAME${normal}
|
||||
${bold}orchetsra-admin install_requirements${normal} - Installs all Orchestra requirements using apt-get and pip
|
||||
|
||||
${bold}OPTIONS${normal}
|
||||
${bold}-t, --testing${normal}
|
||||
Install Orchestra normal requirements plus those needed for running functional tests
|
||||
|
||||
${bold}-h, --help${normal}
|
||||
Displays this help text
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
|
||||
function install_requirements () {
|
||||
opts=$(getopt -o h,t -l help,testing -- "$@") || exit 1
|
||||
set -- $opts
|
||||
testing=false
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
-h|--help) print_deploy_help; exit 0 ;;
|
||||
-t|--testing) testing=true; shift ;;
|
||||
(--) shift; break;;
|
||||
(-*) echo "$0: Err. - unrecognized option $1" 1>&2; exit 1;;
|
||||
(*) break;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
unset OPTIND
|
||||
unset opt
|
||||
|
||||
check_root || true
|
||||
ORCHESTRA_PATH=$(get_orchestra_dir) || true
|
||||
|
||||
# Make sure locales are in place before installing postgres
|
||||
if [[ $({ perl --help > /dev/null; } 2>&1|grep 'locale failed') ]]; then
|
||||
run sed -i "s/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/" /etc/locale.gen
|
||||
run locale-gen
|
||||
update-locale LANG=en_US.UTF-8
|
||||
fi
|
||||
|
||||
# lxml: libxml2-dev, libxslt1-dev, zlib1g-dev
|
||||
APT="bind9utils \
|
||||
ca-certificates \
|
||||
gettext \
|
||||
libcrack2-dev \
|
||||
libxml2-dev \
|
||||
libxslt1-dev \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-dev \
|
||||
ssh-client \
|
||||
wget \
|
||||
xvfb \
|
||||
zlib1g-dev"
|
||||
if $testing; then
|
||||
APT="${APT} \
|
||||
git \
|
||||
iceweasel \
|
||||
dnsutils"
|
||||
fi
|
||||
|
||||
run apt-get update
|
||||
run apt-get install -y $APT
|
||||
|
||||
# Install ca certificates before executing pip install
|
||||
if [[ ! -e /usr/local/share/ca-certificates/cacert.org ]]; then
|
||||
mkdir -p /usr/local/share/ca-certificates/cacert.org
|
||||
wget -P /usr/local/share/ca-certificates/cacert.org \
|
||||
http://www.cacert.org/certs/root.crt \
|
||||
http://www.cacert.org/certs/class3.crt
|
||||
update-ca-certificates
|
||||
fi
|
||||
|
||||
# cracklib and lxml are excluded on the requirements.txt because they need unconvinient system dependencies
|
||||
PIP="$(wget http://git.io/orchestra-requirements.txt -O - | tr '\n' ' ') \
|
||||
cracklib \
|
||||
lxml==3.3.5"
|
||||
if $testing; then
|
||||
PIP="${PIP} \
|
||||
selenium \
|
||||
xvfbwrapper \
|
||||
freezegun==0.3.14 \
|
||||
coverage \
|
||||
flake8 \
|
||||
django-debug-toolbar==1.3.0 \
|
||||
django-nose==1.4.4 \
|
||||
sqlparse \
|
||||
pyinotify \
|
||||
PyMySQL"
|
||||
fi
|
||||
|
||||
run pip3 install $PIP
|
||||
|
||||
# Install a more recent version of wkhtmltopdf (0.12.2) (PDF page number support)
|
||||
wkhtmltox_version=$(dpkg --list | grep wkhtmltox | awk {'print $3'})
|
||||
minor=$(echo -e "$wkhtmltox_version\n0.12.2.1" | sort -V | head -n 1)
|
||||
if [[ ! $wkhtmltox_version ]] || [[ $wkhtmltox_version != 0.12.2.1 && $minor == ${wkhtmltox_version} ]]; then
|
||||
wkhtmltox=$(mktemp)
|
||||
wget https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.buster_amd64.deb -O ${wkhtmltox}
|
||||
dpkg -i ${wkhtmltox} || { echo "Installing missing dependencies for wkhtmltox..." && apt-get -f -y install; }
|
||||
fi
|
||||
}
|
||||
export -f install_requirements
|
||||
|
||||
|
||||
print_startproject_help () {
|
||||
cat <<- EOF
|
||||
|
||||
${bold}NAME${normal}
|
||||
${bold}orchestra-admin startproject${normal} - Create a new Django-Orchestra instance
|
||||
|
||||
${bold}SYNOPSIS${normal}
|
||||
Options: [ -h ]
|
||||
|
||||
${bold}OPTIONS${normal}
|
||||
${bold}-h, --help${normal}
|
||||
This help message
|
||||
|
||||
${bold}EXAMPLES${normal}
|
||||
orchestra-admin startproject controlpanel
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
|
||||
function startproject () {
|
||||
local PROJECT_NAME="$2"; shift
|
||||
|
||||
opts=$(getopt -o h -l help -- "$@") || exit 1
|
||||
set -- $opts
|
||||
|
||||
set -- $opts
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
-h|--help) print_startproject_help; exit 0 ;;
|
||||
(--) shift; break;;
|
||||
(-*) echo "$0: Err. - unrecognized option $1" 1>&2; exit 1;;
|
||||
(*) break;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
unset OPTIND
|
||||
unset opt
|
||||
|
||||
[ $(whoami) == 'root' ] && { echo -e "\nYou don't want to run this as root\n" >&2; exit 1; }
|
||||
ORCHESTRA_PATH=$(get_orchestra_dir) || { echo "Error getting orchestra dir"; exit 1; }
|
||||
if [[ ! -e $PROJECT_NAME/manage.py ]]; then
|
||||
run django-admin.py startproject $PROJECT_NAME --template="${ORCHESTRA_PATH}/conf/project_template"
|
||||
# This is a workaround for this issue https://github.com/pypa/pip/issues/317
|
||||
run chmod +x $PROJECT_NAME/manage.py
|
||||
# End of workaround ###
|
||||
else
|
||||
echo "Not cloning: $PROJECT_NAME already exists."
|
||||
fi
|
||||
# Install bash autocompletition for django commands
|
||||
if [[ ! $(grep 'source $HOME/.django_bash_completion.sh' ~/.bashrc &> /dev/null) ]]; then
|
||||
# run wget https://raw.github.com/django/django/master/extras/django_bash_completion \
|
||||
# --no-check-certificate -O ~/.django_bash_completion.sh
|
||||
cp ${ORCHESTRA_PATH}/bin/django_bash_completion.sh ~/.django_bash_completion.sh
|
||||
echo 'source $HOME/.django_bash_completion.sh' >> ~/.bashrc
|
||||
fi
|
||||
}
|
||||
export -f startproject
|
||||
|
||||
|
||||
[ $# -lt 1 ] && { print_help; exit 1; }
|
||||
$1 "${@}"
|
226
orchestra/bin/orchestra-beat
Executable file
226
orchestra/bin/orchestra-beat
Executable file
|
@ -0,0 +1,226 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# High performance alternative to beat management command
|
||||
# Looks for pending work before firing up all the Django machinery on separate processes
|
||||
#
|
||||
# Handles orchestra.contrib.tasks periodic_tasks and orchestra.contrib.mailer queued mails
|
||||
#
|
||||
# USAGE: beat /path/to/project/manage.py
|
||||
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from orchestra.utils.sys import run, join, LockFile
|
||||
|
||||
|
||||
class crontab_parser(object):
|
||||
"""
|
||||
from celery.schedules import crontab_parser
|
||||
Too expensive to import celery
|
||||
"""
|
||||
ParseException = ValueError
|
||||
|
||||
_range = r'(\w+?)-(\w+)'
|
||||
_steps = r'/(\w+)?'
|
||||
_star = r'\*'
|
||||
|
||||
def __init__(self, max_=60, min_=0):
|
||||
self.max_ = max_
|
||||
self.min_ = min_
|
||||
self.pats = (
|
||||
(re.compile(self._range + self._steps), self._range_steps),
|
||||
(re.compile(self._range), self._expand_range),
|
||||
(re.compile(self._star + self._steps), self._star_steps),
|
||||
(re.compile('^' + self._star + '$'), self._expand_star),
|
||||
)
|
||||
|
||||
def parse(self, spec):
|
||||
acc = set()
|
||||
for part in spec.split(','):
|
||||
if not part:
|
||||
raise self.ParseException('empty part')
|
||||
acc |= set(self._parse_part(part))
|
||||
return acc
|
||||
|
||||
def _parse_part(self, part):
|
||||
for regex, handler in self.pats:
|
||||
m = regex.match(part)
|
||||
if m:
|
||||
return handler(m.groups())
|
||||
return self._expand_range((part, ))
|
||||
|
||||
def _expand_range(self, toks):
|
||||
fr = self._expand_number(toks[0])
|
||||
if len(toks) > 1:
|
||||
to = self._expand_number(toks[1])
|
||||
if to < fr: # Wrap around max_ if necessary
|
||||
return (list(range(fr, self.min_ + self.max_)) +
|
||||
list(range(self.min_, to + 1)))
|
||||
return list(range(fr, to + 1))
|
||||
return [fr]
|
||||
|
||||
def _range_steps(self, toks):
|
||||
if len(toks) != 3 or not toks[2]:
|
||||
raise self.ParseException('empty filter')
|
||||
return self._expand_range(toks[:2])[::int(toks[2])]
|
||||
|
||||
def _star_steps(self, toks):
|
||||
if not toks or not toks[0]:
|
||||
raise self.ParseException('empty filter')
|
||||
return self._expand_star()[::int(toks[0])]
|
||||
def _expand_star(self, *args):
|
||||
return list(range(self.min_, self.max_ + self.min_))
|
||||
|
||||
def _expand_number(self, s):
|
||||
if isinstance(s, str) and s[0] == '-':
|
||||
raise self.ParseException('negative numbers not supported')
|
||||
try:
|
||||
i = int(s)
|
||||
except ValueError:
|
||||
try:
|
||||
i = weekday(s)
|
||||
except KeyError:
|
||||
raise ValueError('Invalid weekday literal {0!r}.'.format(s))
|
||||
max_val = self.min_ + self.max_ - 1
|
||||
if i > max_val:
|
||||
raise ValueError(
|
||||
'Invalid end range: {0} > {1}.'.format(i, max_val))
|
||||
if i < self.min_:
|
||||
raise ValueError(
|
||||
'Invalid beginning range: {0} < {1}.'.format(i, self.min_))
|
||||
return i
|
||||
|
||||
|
||||
class Setting(object):
|
||||
def __init__(self, manage):
|
||||
self.manage = manage
|
||||
self.settings_file = self.get_settings_file(manage)
|
||||
|
||||
def get_settings(self):
|
||||
""" get db settings from settings.py file without importing """
|
||||
settings = {'__file__': self.settings_file}
|
||||
with open(self.settings_file) as f:
|
||||
content = ''
|
||||
for line in f.readlines():
|
||||
# This is very costly, skip
|
||||
if not line.startswith(('import djcelery', 'djcelery.setup_loader()')):
|
||||
content += line
|
||||
exec(content, settings)
|
||||
return settings
|
||||
|
||||
def get_settings_file(self, manage):
|
||||
with open(manage, 'r') as handler:
|
||||
regex = re.compile(r'"DJANGO_SETTINGS_MODULE"\s*,\s*"([^"]+)"')
|
||||
for line in handler.readlines():
|
||||
match = regex.search(line)
|
||||
if match:
|
||||
settings_module = match.groups()[0]
|
||||
settings_file = os.path.join(*settings_module.split('.')) + '.py'
|
||||
settings_file = os.path.join(os.path.dirname(manage), settings_file)
|
||||
return settings_file
|
||||
raise ValueError("settings module not found in %s" % manage)
|
||||
|
||||
|
||||
class DB(object):
|
||||
def __init__(self, settings):
|
||||
self.settings = settings['DATABASES']['default']
|
||||
|
||||
def connect(self):
|
||||
if self.settings['ENGINE'] == 'django.db.backends.sqlite3':
|
||||
import sqlite3
|
||||
self.conn = sqlite3.connect(self.settings['NAME'])
|
||||
elif self.settings['ENGINE'] == 'django.db.backends.postgresql_psycopg2':
|
||||
import psycopg2
|
||||
self.conn = psycopg2.connect("dbname='{NAME}' user='{USER}' host='{HOST}' password='{PASSWORD}'".format(**self.settings))
|
||||
else:
|
||||
raise ValueError("%s engine not supported." % self.settings['ENGINE'])
|
||||
|
||||
def query(self, query):
|
||||
cur = self.conn.cursor()
|
||||
try:
|
||||
cur.execute(query)
|
||||
result = cur.fetchall()
|
||||
finally:
|
||||
cur.close()
|
||||
return result
|
||||
|
||||
def close(self):
|
||||
self.conn.close()
|
||||
|
||||
|
||||
def fire_pending_tasks(manage, db):
|
||||
def get_tasks(db):
|
||||
enabled = 1 if 'sqlite' in db.settings['ENGINE'] else True
|
||||
query = (
|
||||
"SELECT c.minute, c.hour, c.day_of_week, c.day_of_month, c.month_of_year, p.id "
|
||||
"FROM djcelery_periodictask as p, djcelery_crontabschedule as c "
|
||||
"WHERE p.crontab_id = c.id AND p.enabled = {}"
|
||||
).format(enabled)
|
||||
return db.query(query)
|
||||
|
||||
def is_due(now, minute, hour, day_of_week, day_of_month, month_of_year):
|
||||
n_minute, n_hour, n_day_of_week, n_day_of_month, n_month_of_year = now
|
||||
return (
|
||||
n_minute in crontab_parser(60).parse(minute) and
|
||||
n_hour in crontab_parser(24).parse(hour) and
|
||||
n_day_of_week in crontab_parser(7).parse(day_of_week) and
|
||||
n_day_of_month in crontab_parser(31, 1).parse(day_of_month) and
|
||||
n_month_of_year in crontab_parser(12, 1).parse(month_of_year)
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = tuple(map(int, now.strftime("%M %H %w %d %m").split()))
|
||||
for minute, hour, day_of_week, day_of_month, month_of_year, task_id in get_tasks(db):
|
||||
if is_due(now, minute, hour, day_of_week, day_of_month, month_of_year):
|
||||
command = 'python3 -W ignore::DeprecationWarning {manage} runtask {task_id}'.format(
|
||||
manage=manage, task_id=task_id)
|
||||
proc = run(command, run_async=True)
|
||||
yield proc
|
||||
|
||||
|
||||
def fire_pending_messages(settings, db):
|
||||
def has_pending_messages(settings, db):
|
||||
MAILER_DEFERE_SECONDS = settings.get('MAILER_DEFERE_SECONDS', (300, 600, 60*60, 60*60*24))
|
||||
now = datetime.utcnow()
|
||||
query_or = []
|
||||
|
||||
for num, seconds in enumerate(MAILER_DEFERE_SECONDS):
|
||||
delta = timedelta(seconds=seconds)
|
||||
epoch = now-delta
|
||||
query_or.append("""(mailer_message.retries = %i AND mailer_message.last_try <= '%s')"""
|
||||
% (num, epoch.isoformat().replace('T', ' ')))
|
||||
query = """\
|
||||
SELECT 1 FROM mailer_message
|
||||
WHERE (mailer_message.state = 'QUEUED'
|
||||
OR (mailer_message.state = 'DEFERRED' AND (%s))) LIMIT 1""" % ' OR '.join(query_or)
|
||||
return bool(db.query(query))
|
||||
|
||||
if has_pending_messages(settings, db):
|
||||
command = 'python3 -W ignore::DeprecationWarning {manage} sendpendingmessages'.format(manage=manage)
|
||||
proc = run(command, run_async=True)
|
||||
yield proc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with LockFile('/dev/shm/beat.lock', expire=20):
|
||||
manage = sys.argv[1]
|
||||
procs = []
|
||||
settings = Setting(manage).get_settings()
|
||||
db = DB(settings)
|
||||
db.connect()
|
||||
try:
|
||||
# Non-blocking loop, we need to finish this in time for the next minute.
|
||||
if 'orchestra.contrib.tasks' in settings['INSTALLED_APPS']:
|
||||
if settings.get('TASKS_BACKEND', 'thread') in ('thread', 'process'):
|
||||
for proc in fire_pending_tasks(manage, db):
|
||||
procs.append(proc)
|
||||
if 'orchestra.contrib.mailer' in settings['INSTALLED_APPS']:
|
||||
for proc in fire_pending_messages(settings, db):
|
||||
procs.append(proc)
|
||||
finally:
|
||||
db.close()
|
||||
sys.exit(0)
|
BIN
orchestra/bin/sieve-test
Executable file
BIN
orchestra/bin/sieve-test
Executable file
Binary file not shown.
0
orchestra/conf/__init__.py
Normal file
0
orchestra/conf/__init__.py
Normal file
0
orchestra/conf/project_template/locale/.gitignore
vendored
Normal file
0
orchestra/conf/project_template/locale/.gitignore
vendored
Normal file
13
orchestra/conf/project_template/manage.py
Executable file
13
orchestra/conf/project_template/manage.py
Executable file
|
@ -0,0 +1,13 @@
|
|||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.version_info < (3, 3):
|
||||
cmd = ' '.join(sys.argv)
|
||||
sys.stderr.write("Sorry, Orchestra requires at least Python 3.3, try with:\n$ python3 %s\n" % cmd)
|
||||
sys.exit(1)
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{{ project_name }}.settings")
|
||||
from django.core.management import execute_from_command_line
|
||||
execute_from_command_line(sys.argv)
|
0
orchestra/conf/project_template/media/.gitignore
vendored
Normal file
0
orchestra/conf/project_template/media/.gitignore
vendored
Normal file
275
orchestra/conf/project_template/project_name/settings.py
Normal file
275
orchestra/conf/project_template/project_name/settings.py
Normal file
|
@ -0,0 +1,275 @@
|
|||
"""
|
||||
Django settings for {{ project_name }} project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django {{ django_version }}.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/{{ docs_version }}/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/{{ docs_version }}/ref/settings/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/{{ docs_version }}/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = '{{ secret_key }}'
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = []
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
# django-orchestra apps
|
||||
'orchestra',
|
||||
'orchestra.contrib.accounts',
|
||||
'orchestra.contrib.systemusers',
|
||||
'orchestra.contrib.contacts',
|
||||
'orchestra.contrib.orchestration',
|
||||
'orchestra.contrib.bills',
|
||||
'orchestra.contrib.payments',
|
||||
'orchestra.contrib.tasks',
|
||||
'orchestra.contrib.mailer',
|
||||
'orchestra.contrib.history',
|
||||
'orchestra.contrib.issues',
|
||||
'orchestra.contrib.services',
|
||||
'orchestra.contrib.plans',
|
||||
'orchestra.contrib.orders',
|
||||
'orchestra.contrib.domains',
|
||||
'orchestra.contrib.mailboxes',
|
||||
'orchestra.contrib.lists',
|
||||
'orchestra.contrib.webapps',
|
||||
'orchestra.contrib.websites',
|
||||
'orchestra.contrib.letsencrypt',
|
||||
'orchestra.contrib.databases',
|
||||
'orchestra.contrib.vps',
|
||||
'orchestra.contrib.saas',
|
||||
'orchestra.contrib.miscellaneous',
|
||||
|
||||
# Third-party apps
|
||||
'django_extensions',
|
||||
'djcelery',
|
||||
'fluent_dashboard',
|
||||
'admin_tools',
|
||||
'admin_tools.theming',
|
||||
'admin_tools.menu',
|
||||
'admin_tools.dashboard',
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
'django_filters',
|
||||
'passlib.ext.django',
|
||||
'django_countries',
|
||||
# 'debug_toolbar',
|
||||
|
||||
# Django.contrib
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.admin.apps.SimpleAdminConfig',
|
||||
|
||||
# Last to load
|
||||
'orchestra.contrib.resources',
|
||||
'orchestra.contrib.settings',
|
||||
# 'django_nose',
|
||||
]
|
||||
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
|
||||
'orchestra.core.caches.RequestCacheMiddleware',
|
||||
# also handles transations, ATOMIC_REQUESTS does not wrap middlewares
|
||||
'orchestra.contrib.orchestration.middlewares.OperationsMiddleware',
|
||||
]
|
||||
|
||||
|
||||
ROOT_URLCONF = '{{ project_name }}.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'orchestra.core.context_processors.site',
|
||||
],
|
||||
'loaders': [
|
||||
'admin_tools.template_loaders.Loader',
|
||||
'django.template.loaders.filesystem.Loader',
|
||||
'django.template.loaders.app_directories.Loader',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
WSGI_APPLICATION = '{{ project_name }}.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/{{ docs_version }}/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
|
||||
'USER': '', # Not used with sqlite3.
|
||||
'PASSWORD': '', # Not used with sqlite3.
|
||||
'HOST': '', # Set to empty string for localhost. Not used with sqlite3.
|
||||
'PORT': '', # Set to empty string for default. Not used with sqlite3.
|
||||
'CONN_MAX_AGE': 60*10 # Enable persistent connections
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/{{ docs_version }}/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/{{ docs_version }}/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
|
||||
try:
|
||||
TIME_ZONE = open('/etc/timezone', 'r').read().strip()
|
||||
except IOError:
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_L10N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/{{ docs_version }}/howto/static-files/
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
|
||||
|
||||
# Absolute path to the directory static files should be collected to.
|
||||
# Don't put anything in this directory yourself; store your static files
|
||||
# in apps' "static/" subdirectories and in STATICFILES_DIRS.
|
||||
# Example: "/home/media/media.lawrence.com/static/"
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
|
||||
|
||||
# Absolute filesystem path to the directory that will hold user-uploaded files.
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
|
||||
|
||||
|
||||
# Path used for database translations files
|
||||
LOCALE_PATHS = (
|
||||
os.path.join(BASE_DIR, 'locale'),
|
||||
)
|
||||
|
||||
ORCHESTRA_SITE_NAME = '{{ project_name }}'
|
||||
|
||||
|
||||
AUTH_USER_MODEL = 'accounts.Account'
|
||||
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
'orchestra.permissions.auth.OrchestraPermissionBackend',
|
||||
'django.contrib.auth.backends.ModelBackend',
|
||||
]
|
||||
|
||||
|
||||
EMAIL_BACKEND = 'orchestra.contrib.mailer.backends.EmailBackend'
|
||||
|
||||
|
||||
# Needed for Bulk operations
|
||||
DATA_UPLOAD_MAX_NUMBER_FIELDS = None
|
||||
|
||||
|
||||
#################################
|
||||
## 3RD PARTY APPS CONIGURATION ##
|
||||
#################################
|
||||
|
||||
# Admin Tools
|
||||
ADMIN_TOOLS_MENU = 'orchestra.admin.menu.OrchestraMenu'
|
||||
|
||||
# Fluent dashboard
|
||||
ADMIN_TOOLS_INDEX_DASHBOARD = 'orchestra.admin.dashboard.OrchestraIndexDashboard'
|
||||
FLUENT_DASHBOARD_ICON_THEME = '../orchestra/icons'
|
||||
|
||||
|
||||
# Django-celery
|
||||
import djcelery
|
||||
djcelery.setup_loader()
|
||||
CELERYBEAT_SCHEDULER = 'djcelery.schedulers.DatabaseScheduler'
|
||||
|
||||
|
||||
# rest_framework
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'orchestra.permissions.api.OrchestraPermissionBackend',
|
||||
),
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
'rest_framework.authentication.TokenAuthentication',
|
||||
),
|
||||
'DEFAULT_FILTER_BACKENDS': (
|
||||
('django_filters.rest_framework.DjangoFilterBackend',)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# Use a UNIX compatible hash
|
||||
PASSLIB_CONFIG = (
|
||||
"[passlib]\n"
|
||||
"schemes = sha512_crypt, django_pbkdf2_sha256, django_pbkdf2_sha1, "
|
||||
" django_bcrypt, django_bcrypt_sha256, django_salted_sha1, des_crypt, "
|
||||
" django_salted_md5, django_des_crypt, hex_md5, bcrypt, phpass\n"
|
||||
"default = sha512_crypt\n"
|
||||
"deprecated = django_pbkdf2_sha1, django_salted_sha1, django_salted_md5, "
|
||||
" django_des_crypt, des_crypt, hex_md5\n"
|
||||
"django_pbkdf2_sha256__min_rounds = 10000\n"
|
||||
"sha512_crypt__min_rounds = 80000\n"
|
||||
"staff__django_pbkdf2_sha256__default_rounds = 12500\n"
|
||||
"staff__sha512_crypt__default_rounds = 100000\n"
|
||||
"superuser__django_pbkdf2_sha256__default_rounds = 15000\n"
|
||||
"superuser__sha512_crypt__default_rounds = 120000\n"
|
||||
)
|
||||
|
||||
|
||||
SHELL_PLUS_PRE_IMPORTS = (
|
||||
('orchestra.contrib.orchestration.managers', ('orchestrate',)),
|
||||
)
|
6
orchestra/conf/project_template/project_name/urls.py
Normal file
6
orchestra/conf/project_template/project_name/urls.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.conf.urls import include, url
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
url(r'', include('orchestra.urls')),
|
||||
]
|
14
orchestra/conf/project_template/project_name/wsgi.py
Normal file
14
orchestra/conf/project_template/project_name/wsgi.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
"""
|
||||
WSGI config for {{ project_name }} project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/{{ docs_version }}/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{{ project_name }}.settings")
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
application = get_wsgi_application()
|
0
orchestra/contrib/__init__.py
Normal file
0
orchestra/contrib/__init__.py
Normal file
1
orchestra/contrib/accounts/__init__.py
Normal file
1
orchestra/contrib/accounts/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
default_app_config = 'orchestra.contrib.accounts.apps.AccountConfig'
|
287
orchestra/contrib/accounts/actions.py
Normal file
287
orchestra/contrib/accounts/actions.py
Normal file
|
@ -0,0 +1,287 @@
|
|||
from functools import partial, wraps
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.admin import helpers
|
||||
from django.contrib.admin.utils import NestedObjects, quote
|
||||
from django.contrib.auth import get_permission_codename
|
||||
from django.urls import reverse, NoReverseMatch
|
||||
from django.db import router
|
||||
from django.shortcuts import redirect, render
|
||||
from django.template.response import TemplateResponse
|
||||
from django.utils import timezone
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.html import format_html
|
||||
from django.utils.text import capfirst
|
||||
from django.utils.translation import ngettext, gettext_lazy as _
|
||||
|
||||
from orchestra.core import services
|
||||
|
||||
from . import settings
|
||||
|
||||
|
||||
def list_contacts(modeladmin, request, queryset):
|
||||
ids = queryset.order_by().values_list('id', flat=True).distinct()
|
||||
if not ids:
|
||||
messages.warning(request, "Select at least one account.")
|
||||
return
|
||||
url = reverse('admin:contacts_contact_changelist')
|
||||
url += '?account__in=%s' % ','.join(map(str, ids))
|
||||
return redirect(url)
|
||||
list_contacts.short_description = _("List contacts")
|
||||
|
||||
|
||||
def list_accounts(modeladmin, request, queryset):
|
||||
accounts = queryset.order_by().values_list('account_id', flat=True).distinct()
|
||||
if not accounts:
|
||||
messages.warning(request, "Select at least one instance.")
|
||||
return
|
||||
url = reverse('admin:contacts_contact_changelist')
|
||||
url += '?id__in=%s' % ','.join(map(str, accounts))
|
||||
return redirect(url)
|
||||
list_accounts.short_description = _("List accounts")
|
||||
|
||||
|
||||
def service_report(modeladmin, request, queryset):
|
||||
# TODO resources
|
||||
accounts = []
|
||||
fields = []
|
||||
registered_services = services.get()
|
||||
# First we get related manager names to fire a prefetch related
|
||||
for name, field in queryset.model._meta.fields_map.items():
|
||||
model = field.related_model
|
||||
if model in registered_services and model != queryset.model:
|
||||
fields.append((model, name))
|
||||
fields = sorted(fields, key=lambda f: f[0]._meta.verbose_name_plural.lower())
|
||||
fields = [field for model, field in fields]
|
||||
|
||||
for account in queryset.prefetch_related(*fields):
|
||||
items = []
|
||||
for field in fields:
|
||||
related_manager = getattr(account, field)
|
||||
items.append((related_manager.model._meta, related_manager.all()))
|
||||
accounts.append((account, items))
|
||||
|
||||
context = {
|
||||
'accounts': accounts,
|
||||
'date': timezone.now().today()
|
||||
}
|
||||
return render(request, settings.ACCOUNTS_SERVICE_REPORT_TEMPLATE, context)
|
||||
|
||||
|
||||
def delete_related_services(modeladmin, request, queryset):
|
||||
opts = modeladmin.model._meta
|
||||
app_label = opts.app_label
|
||||
|
||||
using = router.db_for_write(modeladmin.model)
|
||||
collector = NestedObjects(using=using)
|
||||
collector.collect(queryset)
|
||||
registered_services = services.get()
|
||||
related_services = []
|
||||
to_delete = []
|
||||
|
||||
admin_site = modeladmin.admin_site
|
||||
|
||||
def format(obj, account=False):
|
||||
has_admin = obj.__class__ in admin_site._registry
|
||||
opts = obj._meta
|
||||
no_edit_link = '%s: %s' % (capfirst(opts.verbose_name), force_str(obj))
|
||||
|
||||
if has_admin:
|
||||
try:
|
||||
admin_url = reverse(
|
||||
'admin:%s_%s_change' % (opts.app_label, opts.model_name),
|
||||
None, (quote(obj._get_pk_val()),)
|
||||
)
|
||||
except NoReverseMatch:
|
||||
# Change url doesn't exist -- don't display link to edit
|
||||
return no_edit_link
|
||||
|
||||
# Display a link to the admin page.
|
||||
context = (capfirst(opts.verbose_name), admin_url, obj)
|
||||
if account:
|
||||
context += (_("services to delete:"),)
|
||||
return format_html('{} <a href="{}">{}</a> {}', *context)
|
||||
return format_html('{}: <a href="{}">{}</a>', *context)
|
||||
else:
|
||||
# Don't display link to edit, because it either has no
|
||||
# admin or is edited inline.
|
||||
return no_edit_link
|
||||
|
||||
def format_nested(objs, result):
|
||||
if isinstance(objs, list):
|
||||
current = []
|
||||
for obj in objs:
|
||||
format_nested(obj, current)
|
||||
result.append(current)
|
||||
else:
|
||||
result.append(format(objs))
|
||||
|
||||
for nested in collector.nested():
|
||||
if isinstance(nested, list):
|
||||
# Is lists of objects
|
||||
current = []
|
||||
is_service = False
|
||||
for service in nested:
|
||||
if type(service) in registered_services:
|
||||
if service == main_systemuser:
|
||||
continue
|
||||
current.append(format(service))
|
||||
to_delete.append(service)
|
||||
is_service = True
|
||||
elif is_service and isinstance(service, list):
|
||||
nested = []
|
||||
format_nested(service, nested)
|
||||
current.append(nested[0])
|
||||
is_service = False
|
||||
else:
|
||||
is_service = False
|
||||
related_services.append(current)
|
||||
elif isinstance(nested, modeladmin.model):
|
||||
# Is account
|
||||
# Prevent the deletion of the main system user, which will delete the account
|
||||
main_systemuser = nested.main_systemuser
|
||||
related_services.append(format(nested, account=True))
|
||||
|
||||
# The user has already confirmed the deletion.
|
||||
# Do the deletion and return a None to display the change list view again.
|
||||
if request.POST.get('post'):
|
||||
accounts = len(queryset)
|
||||
msg = _("Related services deleted and account disabled.")
|
||||
for account in queryset:
|
||||
account.is_active = False
|
||||
account.save(update_fields=('is_active',))
|
||||
modeladmin.log_change(request, account, msg)
|
||||
if accounts:
|
||||
relateds = len(to_delete)
|
||||
for obj in to_delete:
|
||||
obj_display = force_str(obj)
|
||||
modeladmin.log_deletion(request, obj, obj_display)
|
||||
obj.delete()
|
||||
context = {
|
||||
'accounts': accounts,
|
||||
'relateds': relateds,
|
||||
}
|
||||
msg = _("Successfully disabled %(accounts)d account and deleted %(relateds)d related services.") % context
|
||||
modeladmin.message_user(request, msg, messages.SUCCESS)
|
||||
# Return None to display the change list page again.
|
||||
return None
|
||||
|
||||
if len(queryset) == 1:
|
||||
objects_name = force_str(opts.verbose_name)
|
||||
else:
|
||||
objects_name = force_str(opts.verbose_name_plural)
|
||||
|
||||
model_count = {}
|
||||
for model, objs in collector.model_objs.items():
|
||||
count = 0
|
||||
# discount main systemuser
|
||||
if model is modeladmin.model.main_systemuser.field.related_model:
|
||||
count = len(objs) - 1
|
||||
# Discount account
|
||||
elif model is not modeladmin.model and model in registered_services:
|
||||
count = len(objs)
|
||||
if count:
|
||||
model_count[model._meta.verbose_name_plural] = count
|
||||
if not model_count:
|
||||
modeladmin.message_user(request, _("Nothing to delete"), messages.WARNING)
|
||||
return None
|
||||
context = dict(
|
||||
admin_site.each_context(request),
|
||||
title=_("Are you sure?"),
|
||||
objects_name=objects_name,
|
||||
deletable_objects=[related_services],
|
||||
model_count=dict(model_count).items(),
|
||||
queryset=queryset,
|
||||
opts=opts,
|
||||
action_checkbox_name=helpers.ACTION_CHECKBOX_NAME,
|
||||
)
|
||||
request.current_app = admin_site.name
|
||||
# Display the confirmation page
|
||||
template = 'admin/%s/%s/delete_related_services_confirmation.html' % (app_label, opts.model_name)
|
||||
return TemplateResponse(request, template, context)
|
||||
delete_related_services.short_description = _("Delete related services")
|
||||
|
||||
|
||||
def disable_selected(modeladmin, request, queryset, disable=True):
|
||||
opts = modeladmin.model._meta
|
||||
app_label = opts.app_label
|
||||
verbose_action_name = _("disabled") if disable else _("enabled")
|
||||
# The user has already confirmed the deletion.
|
||||
# Do the disable and return a None to display the change list view again.
|
||||
if request.POST.get('post'):
|
||||
n = 0
|
||||
for account in queryset:
|
||||
account.disable() if disable else account.enable()
|
||||
modeladmin.log_change(request, account, verbose_action_name.capitalize())
|
||||
n += 1
|
||||
modeladmin.message_user(request, ngettext(
|
||||
_("One account has been successfully %s.") % verbose_action_name,
|
||||
_("%i accounts have been successfully %s.") % (n, verbose_action_name),
|
||||
n)
|
||||
)
|
||||
return None
|
||||
|
||||
user = request.user
|
||||
admin_site = modeladmin.admin_site
|
||||
|
||||
def format(obj):
|
||||
has_admin = obj.__class__ in admin_site._registry
|
||||
opts = obj._meta
|
||||
no_edit_link = '%s: %s' % (capfirst(opts.verbose_name), force_str(obj))
|
||||
if has_admin:
|
||||
try:
|
||||
admin_url = reverse(
|
||||
'admin:%s_%s_change' % (opts.app_label, opts.model_name),
|
||||
None,
|
||||
(quote(obj._get_pk_val()),)
|
||||
)
|
||||
except NoReverseMatch:
|
||||
# Change url doesn't exist -- don't display link to edit
|
||||
return no_edit_link
|
||||
|
||||
p = '%s.%s' % (opts.app_label, get_permission_codename('delete', opts))
|
||||
if not user.has_perm(p):
|
||||
perms_needed.add(opts.verbose_name)
|
||||
# Display a link to the admin page.
|
||||
context = (capfirst(opts.verbose_name), admin_url, obj)
|
||||
return format_html('{}: <a href="{}">{}</a>', *context)
|
||||
else:
|
||||
# Don't display link to edit, because it either has no
|
||||
# admin or is edited inline.
|
||||
return no_edit_link
|
||||
|
||||
display = []
|
||||
for account in queryset:
|
||||
current = []
|
||||
for related in account.get_services_to_disable():
|
||||
current.append(format(related))
|
||||
display.append([format(account), current])
|
||||
|
||||
if len(queryset) == 1:
|
||||
objects_name = force_str(opts.verbose_name)
|
||||
else:
|
||||
objects_name = force_str(opts.verbose_name_plural)
|
||||
|
||||
context = dict(
|
||||
admin_site.each_context(request),
|
||||
action_name='disable_selected' if disable else 'enable_selected',
|
||||
disable=disable,
|
||||
title=_("Are you sure?"),
|
||||
objects_name=objects_name,
|
||||
deletable_objects=display,
|
||||
queryset=queryset,
|
||||
opts=opts,
|
||||
action_checkbox_name=helpers.ACTION_CHECKBOX_NAME,
|
||||
)
|
||||
request.current_app = admin_site.name
|
||||
template = 'admin/%s/%s/disable_selected_confirmation.html' % (app_label, opts.model_name)
|
||||
return TemplateResponse(request, template, context)
|
||||
disable_selected.short_description = _("Disable selected accounts")
|
||||
disable_selected.url_name = 'disable'
|
||||
disable_selected.tool_description = _("Disable")
|
||||
|
||||
|
||||
enable_selected = partial(disable_selected, disable=False)
|
||||
enable_selected.__name__ = 'enable_selected'
|
||||
enable_selected.url_name = 'enable'
|
||||
enable_selected.tool_description = _("Enable")
|
415
orchestra/contrib/accounts/admin.py
Normal file
415
orchestra/contrib/accounts/admin.py
Normal file
|
@ -0,0 +1,415 @@
|
|||
import copy
|
||||
import re
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
from django import forms
|
||||
from django.apps import apps
|
||||
from django.urls import re_path as url
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.admin.utils import unquote
|
||||
from django.contrib.auth import admin as auth
|
||||
from django.urls import reverse
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.templatetags.static import static
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from orchestra.admin import ExtendedModelAdmin, ChangePasswordAdminMixin
|
||||
from orchestra.admin.actions import SendEmail
|
||||
from orchestra.admin.utils import wrap_admin_view, admin_link, set_url_query
|
||||
from orchestra.contrib.services.settings import SERVICES_IGNORE_ACCOUNT_TYPE
|
||||
from orchestra.core import services, accounts
|
||||
from orchestra.forms import UserChangeForm
|
||||
from orchestra.utils.apps import isinstalled
|
||||
|
||||
from .actions import (list_contacts, service_report, delete_related_services, disable_selected,
|
||||
enable_selected)
|
||||
from .forms import AccountCreationForm
|
||||
from .models import Account
|
||||
|
||||
|
||||
class AccountAdmin(ChangePasswordAdminMixin, auth.UserAdmin, ExtendedModelAdmin):
|
||||
list_display = ('username', 'full_name', 'type', 'is_active')
|
||||
list_filter = (
|
||||
'type', 'is_active',
|
||||
)
|
||||
add_fieldsets = (
|
||||
(_("User"), {
|
||||
'fields': ('username', 'password1', 'password2',),
|
||||
}),
|
||||
(_("Personal info"), {
|
||||
'fields': ('short_name', 'full_name', 'email', ('type', 'language'), 'comments'),
|
||||
}),
|
||||
(_("Permissions"), {
|
||||
'fields': ('is_superuser',)
|
||||
}),
|
||||
)
|
||||
fieldsets = (
|
||||
(_("User"), {
|
||||
'fields': ('username', 'password', 'main_systemuser_link')
|
||||
}),
|
||||
(_("Personal info"), {
|
||||
'fields': ('short_name', 'full_name', 'email', ('type', 'language'), 'comments'),
|
||||
}),
|
||||
(_("Permissions"), {
|
||||
'fields': ('is_superuser', 'is_active')
|
||||
}),
|
||||
(_("Important dates"), {
|
||||
'classes': ('collapse',),
|
||||
'fields': ('last_login', 'date_joined')
|
||||
}),
|
||||
)
|
||||
search_fields = ('username', 'short_name', 'full_name')
|
||||
add_form = AccountCreationForm
|
||||
form = UserChangeForm
|
||||
filter_horizontal = ()
|
||||
change_readonly_fields = ('username', 'main_systemuser_link', 'is_active')
|
||||
change_form_template = 'admin/accounts/account/change_form.html'
|
||||
actions = (
|
||||
disable_selected, enable_selected, delete_related_services, list_contacts, service_report,
|
||||
SendEmail()
|
||||
)
|
||||
change_view_actions = (disable_selected, service_report, enable_selected)
|
||||
ordering = ()
|
||||
|
||||
main_systemuser_link = admin_link('main_systemuser')
|
||||
|
||||
def formfield_for_dbfield(self, db_field, **kwargs):
|
||||
""" Make value input widget bigger """
|
||||
if db_field.name == 'comments':
|
||||
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 4})
|
||||
return super(AccountAdmin, self).formfield_for_dbfield(db_field, **kwargs)
|
||||
|
||||
def render_change_form(self, request, context, add=False, change=False, form_url='', obj=None):
|
||||
if not add:
|
||||
if request.method == 'GET' and not obj.is_active:
|
||||
messages.warning(request, 'This account is disabled.')
|
||||
context.update({
|
||||
'services': sorted(
|
||||
[model._meta for model in services.get() if model is not Account],
|
||||
key=lambda i: i.verbose_name_plural.lower()
|
||||
),
|
||||
'accounts': sorted(
|
||||
[model._meta for model in accounts.get() if model is not Account],
|
||||
key=lambda i: i.verbose_name_plural.lower()
|
||||
)
|
||||
})
|
||||
return super(AccountAdmin, self).render_change_form(
|
||||
request, context, add, change, form_url, obj)
|
||||
|
||||
def get_fieldsets(self, request, obj=None):
|
||||
fieldsets = super(AccountAdmin, self).get_fieldsets(request, obj)
|
||||
if not obj:
|
||||
fields = AccountCreationForm.create_related_fields
|
||||
if fields:
|
||||
fieldsets = copy.deepcopy(fieldsets)
|
||||
fieldsets = list(fieldsets)
|
||||
fieldsets.insert(1, (_("Related services"), {'fields': fields}))
|
||||
return fieldsets
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not change:
|
||||
form.save_model(obj)
|
||||
form.save_related(obj)
|
||||
else:
|
||||
if isinstalled('orchestra.contrib.orders') and isinstalled('orchestra.contrib.services'):
|
||||
if 'type' in form.changed_data:
|
||||
old_type = Account.objects.get(pk=obj.pk).type
|
||||
new_type = form.cleaned_data['type']
|
||||
context = {
|
||||
'from': old_type.lower(),
|
||||
'to': new_type.lower(),
|
||||
'url': reverse('admin:orders_order_changelist'),
|
||||
}
|
||||
msg = ''
|
||||
if old_type in SERVICES_IGNORE_ACCOUNT_TYPE and new_type not in SERVICES_IGNORE_ACCOUNT_TYPE:
|
||||
context['url'] += '?account=%i&ignore=1' % obj.pk
|
||||
msg = _("Account type has been changed from <i>%(from)s</i> to <i>%(to)s</i>. "
|
||||
"You may want to mark <a href='%(url)s'>existing ignored orders</a> as not ignored.")
|
||||
elif old_type not in SERVICES_IGNORE_ACCOUNT_TYPE and new_type in SERVICES_IGNORE_ACCOUNT_TYPE:
|
||||
context['url'] += '?account=%i&ignore=0' % obj.pk
|
||||
msg = _("Account type has been changed from <i>%(from)s</i> to <i>%(to)s</i>. "
|
||||
"You may want to ignore <a href='%(url)s'>existing not ignored orders</a>.")
|
||||
if msg:
|
||||
messages.warning(request, mark_safe(msg % context))
|
||||
super(AccountAdmin, self).save_model(request, obj, form, change)
|
||||
|
||||
def get_change_view_actions(self, obj=None):
|
||||
views = super().get_change_view_actions(obj=obj)
|
||||
if obj is not None:
|
||||
if obj.is_active:
|
||||
return [view for view in views if view.url_name != 'enable']
|
||||
return [view for view in views if view.url_name != 'disable']
|
||||
return views
|
||||
|
||||
def get_actions(self, request):
|
||||
actions = super().get_actions(request)
|
||||
if 'delete_selected' in actions:
|
||||
del actions['delete_selected']
|
||||
return actions
|
||||
|
||||
|
||||
admin.site.register(Account, AccountAdmin)
|
||||
|
||||
|
||||
class AccountListAdmin(AccountAdmin):
|
||||
""" Account list to allow account selection when creating new services """
|
||||
list_display = ('select_account', 'username', 'type', 'username')
|
||||
actions = None
|
||||
change_list_template = 'admin/accounts/account/select_account_list.html'
|
||||
|
||||
@mark_safe
|
||||
def select_account(self, instance):
|
||||
# TODO get query string from request.META['QUERY_STRING'] to preserve filters
|
||||
context = {
|
||||
'url': '../?account=' + str(instance.pk),
|
||||
'name': instance.username,
|
||||
'plus': '<strong style="color:green; font-size:12px">+</strong>',
|
||||
}
|
||||
return _('<a href="%(url)s">%(plus)s Add to %(name)s</a>') % context
|
||||
select_account.short_description = _("account")
|
||||
select_account.admin_order_field = 'username'
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
app_label = request.META['PATH_INFO'].split('/')[-5]
|
||||
model = request.META['PATH_INFO'].split('/')[-4]
|
||||
model = apps.get_model(app_label, model)
|
||||
opts = model._meta
|
||||
context = {
|
||||
'title': _("Select account for adding a new %s") % (opts.verbose_name),
|
||||
'original_opts': opts,
|
||||
}
|
||||
context.update(extra_context or {})
|
||||
response = super(AccountListAdmin, self).changelist_view(request, extra_context=context)
|
||||
if hasattr(response, 'context_data'):
|
||||
# user has submitted a change list change, we redirect directly to the add view
|
||||
# if there is only one result
|
||||
query = request.GET.get('q', '')
|
||||
if query:
|
||||
try:
|
||||
account = Account.objects.get(username=query)
|
||||
except Account.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
return HttpResponseRedirect('../?account=%i' % account.pk)
|
||||
queryset = response.context_data['cl'].queryset
|
||||
if len(queryset) == 1:
|
||||
return HttpResponseRedirect('../?account=%i' % queryset[0].pk)
|
||||
return response
|
||||
|
||||
|
||||
class AccountAdminMixin(object):
|
||||
""" Provide basic account support to ModelAdmin and AdminInline classes """
|
||||
readonly_fields = ('account_link',)
|
||||
filter_by_account_fields = []
|
||||
change_list_template = 'admin/accounts/account/change_list.html'
|
||||
change_form_template = 'admin/accounts/account/change_form.html'
|
||||
account = None
|
||||
list_select_related = ('account',)
|
||||
|
||||
@mark_safe
|
||||
def display_active(self, instance):
|
||||
if not instance.is_active:
|
||||
return '<img src="%s" alt="False">' % static('admin/img/icon-no.svg')
|
||||
elif not instance.account.is_active:
|
||||
msg = _("Account disabled")
|
||||
return '<img style="width:13px" src="%s" alt="False" title="%s">' % (static('admin/img/inline-delete.svg'), msg)
|
||||
return '<img src="%s" alt="False">' % static('admin/img/icon-yes.svg')
|
||||
display_active.short_description = _("active")
|
||||
display_active.admin_order_field = 'is_active'
|
||||
|
||||
def account_link(self, instance):
|
||||
account = instance.account if instance.pk else self.account
|
||||
return admin_link()(account)
|
||||
account_link.short_description = _("account")
|
||||
account_link.admin_order_field = 'account__username'
|
||||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
""" Warns user when object's account is disabled """
|
||||
form = super(AccountAdminMixin, self).get_form(request, obj, **kwargs)
|
||||
try:
|
||||
field = form.base_fields['is_active']
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
opts = self.model._meta
|
||||
help_text = _(
|
||||
"Designates whether this %(name)s should be treated as active. "
|
||||
"Unselect this instead of deleting %(plural_name)s."
|
||||
) % {
|
||||
'name': opts.verbose_name,
|
||||
'plural_name': opts.verbose_name_plural,
|
||||
}
|
||||
if obj and not obj.account.is_active:
|
||||
help_text += "<br><b style='color:red;'>This user's account is dissabled</b>"
|
||||
field.help_text = _(help_text)
|
||||
# Not available in POST
|
||||
form.initial_account = self.get_changeform_initial_data(request).get('account')
|
||||
return form
|
||||
|
||||
def get_fields(self, request, obj=None):
|
||||
""" remove account or account_link depending on the case """
|
||||
fields = super(AccountAdminMixin, self).get_fields(request, obj)
|
||||
fields = list(fields)
|
||||
if obj is not None or getattr(self, 'account_id', None):
|
||||
try:
|
||||
fields.remove('account')
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
fields.remove('account_link')
|
||||
except ValueError:
|
||||
pass
|
||||
return fields
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
""" provide account for filter_by_account_fields """
|
||||
if obj:
|
||||
self.account = obj.account
|
||||
return super(AccountAdminMixin, self).get_readonly_fields(request, obj)
|
||||
|
||||
def formfield_for_dbfield(self, db_field, **kwargs):
|
||||
""" Filter by account """
|
||||
formfield = super(AccountAdminMixin, self).formfield_for_dbfield(db_field, **kwargs)
|
||||
if db_field.name in self.filter_by_account_fields:
|
||||
if self.account:
|
||||
# Hack widget render in order to append ?account=id to the add url
|
||||
old_render = formfield.widget.render
|
||||
|
||||
def render(*args, **kwargs):
|
||||
output = old_render(*args, **kwargs)
|
||||
output = output.replace('/add/"', '/add/?account=%s"' % self.account.pk)
|
||||
with_qargs = r'/add/?\1&account=%s"' % self.account.pk
|
||||
output = re.sub(r'/add/\?([^".]*)"', with_qargs, output)
|
||||
return mark_safe(output)
|
||||
|
||||
formfield.widget.render = render
|
||||
# Filter related object by account
|
||||
formfield.queryset = formfield.queryset.filter(account=self.account)
|
||||
# Apply heuristic order by
|
||||
if not formfield.queryset.query.order_by:
|
||||
related_fields = [f.name for f in db_field.related_model._meta.get_fields()]
|
||||
if 'name' in related_fields:
|
||||
formfield.queryset = formfield.queryset.order_by('name')
|
||||
elif 'username' in related_fields:
|
||||
formfield.queryset = formfield.queryset.order_by('username')
|
||||
elif db_field.name == 'account':
|
||||
if self.account:
|
||||
formfield.initial = self.account.pk
|
||||
elif Account.objects.count() == 1:
|
||||
formfield.initial = 1
|
||||
formfield.queryset = formfield.queryset.order_by('username')
|
||||
return formfield
|
||||
|
||||
def get_formset(self, request, obj=None, **kwargs):
|
||||
""" provides form.account for convinience """
|
||||
formset = super(AccountAdminMixin, self).get_formset(request, obj, **kwargs)
|
||||
formset.form.account = self.account
|
||||
formset.account = self.account
|
||||
return formset
|
||||
|
||||
def get_account_from_preserve_filters(self, request):
|
||||
preserved_filters = self.get_preserved_filters(request)
|
||||
preserved_filters = dict(parse_qsl(preserved_filters))
|
||||
cl_filters = preserved_filters.get('_changelist_filters')
|
||||
if cl_filters:
|
||||
return dict(parse_qsl(cl_filters)).get('account')
|
||||
|
||||
def changeform_view(self, request, object_id=None, form_url='', extra_context=None):
|
||||
account_id = self.get_account_from_preserve_filters(request)
|
||||
if not object_id:
|
||||
if account_id:
|
||||
# Preselect account
|
||||
set_url_query(request, 'account', account_id)
|
||||
context = {
|
||||
'from_account': bool(account_id),
|
||||
'account': not account_id or Account.objects.get(pk=account_id),
|
||||
'account_opts': Account._meta,
|
||||
}
|
||||
context.update(extra_context or {})
|
||||
return super(AccountAdminMixin, self).changeform_view(
|
||||
request, object_id, form_url=form_url, extra_context=context)
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
account_id = request.GET.get('account')
|
||||
context = {}
|
||||
if account_id:
|
||||
opts = self.model._meta
|
||||
account = Account.objects.get(pk=account_id)
|
||||
context = {
|
||||
'account': not account_id or Account.objects.get(pk=account_id),
|
||||
'account_opts': Account._meta,
|
||||
'all_selected': True,
|
||||
}
|
||||
if not request.GET.get('all'):
|
||||
context.update({
|
||||
'all_selected': False,
|
||||
'title': _("Select %s to change for %s") % (
|
||||
opts.verbose_name, account.username),
|
||||
})
|
||||
else:
|
||||
request_copy = request.GET.copy()
|
||||
request_copy.pop('account')
|
||||
request.GET = request_copy
|
||||
context.update(extra_context or {})
|
||||
return super(AccountAdminMixin, self).changelist_view(request, extra_context=context)
|
||||
|
||||
|
||||
class SelectAccountAdminMixin(AccountAdminMixin):
|
||||
""" Provides support for accounts on ModelAdmin """
|
||||
def get_inline_instances(self, request, obj=None):
|
||||
inlines = super(AccountAdminMixin, self).get_inline_instances(request, obj)
|
||||
if self.account:
|
||||
account = self.account
|
||||
else:
|
||||
account = Account.objects.get(pk=request.GET['account'])
|
||||
[setattr(inline, 'account', account) for inline in inlines]
|
||||
return inlines
|
||||
|
||||
def get_urls(self):
|
||||
""" Hooks select account url """
|
||||
urls = super(AccountAdminMixin, self).get_urls()
|
||||
admin_site = self.admin_site
|
||||
opts = self.model._meta
|
||||
info = opts.app_label, opts.model_name
|
||||
account_list = AccountListAdmin(Account, admin_site).changelist_view
|
||||
select_urls = [
|
||||
url("add/select-account/$",
|
||||
wrap_admin_view(self, account_list),
|
||||
name='%s_%s_select_account' % info),
|
||||
]
|
||||
return select_urls + urls
|
||||
|
||||
def add_view(self, request, form_url='', extra_context=None):
|
||||
""" Redirects to select account view if required """
|
||||
if request.user.is_superuser:
|
||||
from_account_id = self.get_account_from_preserve_filters(request)
|
||||
if from_account_id:
|
||||
set_url_query(request, 'account', from_account_id)
|
||||
account_id = request.GET.get('account')
|
||||
if account_id or Account.objects.count() == 1:
|
||||
kwargs = {}
|
||||
if account_id:
|
||||
kwargs = dict(pk=account_id)
|
||||
self.account = Account.objects.get(**kwargs)
|
||||
opts = self.model._meta
|
||||
context = {
|
||||
'title': _("Add %s for %s") % (opts.verbose_name, self.account.username),
|
||||
'from_account': bool(from_account_id),
|
||||
'from_select': True,
|
||||
'account': self.account,
|
||||
'account_opts': Account._meta,
|
||||
}
|
||||
context.update(extra_context or {})
|
||||
return super(AccountAdminMixin, self).add_view(
|
||||
request, form_url=form_url, extra_context=context)
|
||||
return HttpResponseRedirect('./select-account/?%s' % request.META['QUERY_STRING'])
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""
|
||||
Given a model instance save it to the database.
|
||||
"""
|
||||
if not change:
|
||||
obj.account_id = self.account.pk
|
||||
obj.save()
|
32
orchestra/contrib/accounts/api.py
Normal file
32
orchestra/contrib/accounts/api.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import viewsets, exceptions
|
||||
|
||||
from orchestra.api import router, SetPasswordApiMixin, LogApiMixin
|
||||
|
||||
from .models import Account
|
||||
from .serializers import AccountSerializer
|
||||
|
||||
|
||||
class AccountApiMixin(object):
|
||||
def get_queryset(self):
|
||||
qs = super(AccountApiMixin, self).get_queryset()
|
||||
return qs.filter(account=self.request.user.pk)
|
||||
|
||||
|
||||
class AccountViewSet(LogApiMixin, SetPasswordApiMixin, viewsets.ModelViewSet):
|
||||
queryset = Account.objects.all()
|
||||
serializer_class = AccountSerializer
|
||||
singleton_pk = lambda _,request: request.user.pk
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super(AccountViewSet, self).get_queryset()
|
||||
return qs.filter(id=self.request.user.pk)
|
||||
|
||||
def destroy(self, request, pk=None):
|
||||
# TODO reimplement in permissions
|
||||
if not request.user.is_superuser:
|
||||
raise exceptions.PermissionDenied(_("Accounts can not be deleted."))
|
||||
return super(AccountViewSet, self).destroy(request, pk=pk)
|
||||
|
||||
|
||||
router.register(r'accounts', AccountViewSet)
|
18
orchestra/contrib/accounts/apps.py
Normal file
18
orchestra/contrib/accounts/apps.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
from django.apps import AppConfig
|
||||
from django.db.models.signals import post_migrate
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from orchestra.core import services, accounts
|
||||
|
||||
|
||||
class AccountConfig(AppConfig):
|
||||
name = 'orchestra.contrib.accounts'
|
||||
verbose_name = _("Accounts")
|
||||
|
||||
def ready(self):
|
||||
from .management import create_initial_superuser
|
||||
from .models import Account
|
||||
services.register(Account, menu=False, dashboard=False)
|
||||
accounts.register(Account, icon='Face-monkey.png')
|
||||
post_migrate.connect(create_initial_superuser,
|
||||
dispatch_uid="orchestra.contrib.accounts.management.createsuperuser")
|
27
orchestra/contrib/accounts/filters.py
Normal file
27
orchestra/contrib/accounts/filters.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
from django.contrib.admin import SimpleListFilter
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class IsActiveListFilter(SimpleListFilter):
|
||||
title = _("is active")
|
||||
parameter_name = 'active'
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (
|
||||
('True', _("Yes")),
|
||||
('False', _("No")),
|
||||
('account', _("Account disabled")),
|
||||
('object', _("Object disabled")),
|
||||
)
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if self.value() == 'True':
|
||||
return queryset.filter(is_active=True, account__is_active=True)
|
||||
elif self.value() == 'False':
|
||||
return queryset.filter(Q(is_active=False) | Q(account__is_active=False))
|
||||
elif self.value() == 'account':
|
||||
return queryset.filter(account__is_active=False)
|
||||
elif self.value() == 'object':
|
||||
return queryset.filter(is_active=False)
|
||||
return queryset
|
90
orchestra/contrib/accounts/forms.py
Normal file
90
orchestra/contrib/accounts/forms.py
Normal file
|
@ -0,0 +1,90 @@
|
|||
import logging
|
||||
from collections import OrderedDict
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.apps import apps
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from orchestra.forms import UserCreationForm
|
||||
|
||||
from . import settings
|
||||
from .models import Account
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_account_creation_form():
|
||||
fields = OrderedDict(**{
|
||||
'enable_systemuser': forms.BooleanField(initial=True, required=False,
|
||||
label=_("Enable systemuser"),
|
||||
help_text=_("Designates whether to creates an enabled or disabled related system user. "
|
||||
"Notice that a related system user will be always created."))
|
||||
})
|
||||
create_related = []
|
||||
for model, key, kwargs, help_text in settings.ACCOUNTS_CREATE_RELATED:
|
||||
try:
|
||||
model = apps.get_model(model)
|
||||
except LookupError:
|
||||
logger.error("%s not installed." % model)
|
||||
else:
|
||||
field_name = 'create_%s' % model._meta.model_name
|
||||
label = _("Create %s") % model._meta.verbose_name
|
||||
fields[field_name] = forms.BooleanField(
|
||||
initial=True, required=False, label=label, help_text=help_text)
|
||||
create_related.append((model, key, kwargs, help_text))
|
||||
|
||||
def clean(self, create_related=create_related):
|
||||
""" unique usernames between accounts and system users """
|
||||
cleaned_data = UserCreationForm.clean(self)
|
||||
try:
|
||||
account = Account(
|
||||
username=cleaned_data['username'],
|
||||
password=cleaned_data['password1']
|
||||
)
|
||||
except KeyError:
|
||||
# Previous validation error
|
||||
return
|
||||
errors = {}
|
||||
systemuser_model = Account.main_systemuser.field.related_model
|
||||
if systemuser_model.objects.filter(username=account.username).exists():
|
||||
errors['username'] = _("A system user with this name already exists.")
|
||||
for model, key, related_kwargs, __ in create_related:
|
||||
kwargs = {
|
||||
key: eval(related_kwargs[key], {'account': account})
|
||||
}
|
||||
if model.objects.filter(**kwargs).exists():
|
||||
verbose_name = model._meta.verbose_name
|
||||
field_name = 'create_%s' % model._meta.model_name
|
||||
errors[field_name] = ValidationError(
|
||||
_("A %(type)s with this name already exists."),
|
||||
params={'type': verbose_name})
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
def save_model(self, account):
|
||||
enable_systemuser=self.cleaned_data['enable_systemuser']
|
||||
account.save(active_systemuser=enable_systemuser)
|
||||
|
||||
def save_related(self, account):
|
||||
for model, key, related_kwargs, __ in settings.ACCOUNTS_CREATE_RELATED:
|
||||
model = apps.get_model(model)
|
||||
field_name = 'create_%s' % model._meta.model_name
|
||||
if self.cleaned_data[field_name]:
|
||||
kwargs = {
|
||||
key: eval(value, {'account': account}) for key, value in related_kwargs.items()
|
||||
}
|
||||
model.objects.create(account=account, **kwargs)
|
||||
|
||||
fields.update({
|
||||
'create_related_fields': list(fields.keys()),
|
||||
'clean': clean,
|
||||
'save_model': save_model,
|
||||
'save_related': save_related,
|
||||
})
|
||||
|
||||
return type('AccountCreationForm', (UserCreationForm,), fields)
|
||||
|
||||
|
||||
AccountCreationForm = create_account_creation_form()
|
32
orchestra/contrib/accounts/management/__init__.py
Normal file
32
orchestra/contrib/accounts/management/__init__.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
import sys
|
||||
import textwrap
|
||||
|
||||
from django.contrib.auth import get_user_model, base_user
|
||||
from django.core.exceptions import FieldError
|
||||
from django.core.management import execute_from_command_line
|
||||
from django.db import models
|
||||
|
||||
|
||||
def create_initial_superuser(**kwargs):
|
||||
if '--noinput' not in sys.argv and '--fake' not in sys.argv and '--fake-initial' not in sys.argv:
|
||||
model = get_user_model()
|
||||
if not model.objects.filter(is_superuser=True).exists():
|
||||
sys.stdout.write(textwrap.dedent("""
|
||||
It appears that you just installed Accounts application.
|
||||
You can now create a superuser:
|
||||
|
||||
""")
|
||||
)
|
||||
from ..models import Account
|
||||
try:
|
||||
Account.systemusers.field.model.objects.filter(account_id=1).exists()
|
||||
except FieldError:
|
||||
# avoid creating a systemuser when systemuser table is not ready
|
||||
Account.save = models.Model.save
|
||||
old_init = base_user.AbstractBaseUser.__init__
|
||||
def remove_is_staff(*args, **kwargs):
|
||||
kwargs.pop('is_staff', None)
|
||||
old_init(*args, **kwargs)
|
||||
base_user.AbstractBaseUser.__init__ = remove_is_staff
|
||||
manager = sys.argv[0]
|
||||
execute_from_command_line(argv=[manager, 'createsuperuser'])
|
207
orchestra/contrib/accounts/models.py
Normal file
207
orchestra/contrib/accounts/models.py
Normal file
|
@ -0,0 +1,207 @@
|
|||
from django.contrib.auth import models as auth
|
||||
from django.conf import settings as djsettings
|
||||
from django.core import validators
|
||||
from django.db import models
|
||||
from django.db.models import signals
|
||||
from django.apps import apps
|
||||
from django.utils import timezone, translation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
#from orchestra.contrib.orchestration.middlewares import OperationsMiddleware
|
||||
#from orchestra.contrib.orchestration import Operation
|
||||
from orchestra import core
|
||||
from orchestra.models.utils import has_db_field
|
||||
from orchestra.utils.mail import send_email_template
|
||||
|
||||
from . import settings
|
||||
|
||||
|
||||
class AccountManager(auth.UserManager):
|
||||
def get_main(self):
|
||||
return self.get(pk=settings.ACCOUNTS_MAIN_PK)
|
||||
|
||||
|
||||
class Account(auth.AbstractBaseUser):
|
||||
# Username max_length determined by LINUX system user/group lentgh: 32
|
||||
username = models.CharField(_("username"), max_length=32, unique=True,
|
||||
help_text=_("Required. 32 characters or fewer. Letters, digits and ./-/_ only."),
|
||||
validators=[
|
||||
validators.RegexValidator(r'^[\w.-]+$', _("Enter a valid username."), 'invalid')
|
||||
])
|
||||
main_systemuser = models.ForeignKey(settings.ACCOUNTS_SYSTEMUSER_MODEL, null=True,
|
||||
related_name='accounts_main', editable=False, on_delete=models.SET_NULL)
|
||||
short_name = models.CharField(_("short name"), max_length=64, blank=True)
|
||||
full_name = models.CharField(_("full name"), max_length=256)
|
||||
email = models.EmailField(_('email address'), help_text=_("Used for password recovery"))
|
||||
type = models.CharField(_("type"), choices=settings.ACCOUNTS_TYPES,
|
||||
max_length=32, default=settings.ACCOUNTS_DEFAULT_TYPE)
|
||||
language = models.CharField(_("language"), max_length=2,
|
||||
choices=settings.ACCOUNTS_LANGUAGES,
|
||||
default=settings.ACCOUNTS_DEFAULT_LANGUAGE)
|
||||
comments = models.TextField(_("comments"), max_length=256, blank=True)
|
||||
is_superuser = models.BooleanField(_("superuser status"), default=False,
|
||||
help_text=_("Designates that this user has all permissions without "
|
||||
"explicitly assigning them."))
|
||||
is_active = models.BooleanField(_("active"), default=True,
|
||||
help_text=_("Designates whether this account should be treated as active. "
|
||||
"Unselect this instead of deleting accounts."))
|
||||
date_joined = models.DateTimeField(_("date joined"), default=timezone.now)
|
||||
|
||||
objects = AccountManager()
|
||||
|
||||
USERNAME_FIELD = 'username'
|
||||
REQUIRED_FIELDS = ['email']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# ignore `is_staff` kwarg because is handled with `is_superuser`
|
||||
kwargs.pop('is_staff', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.username
|
||||
|
||||
@property
|
||||
def is_staff(self):
|
||||
return self.is_superuser
|
||||
|
||||
def save(self, active_systemuser=False, *args, **kwargs):
|
||||
created = not self.pk
|
||||
if not created:
|
||||
was_active = Account.objects.filter(pk=self.pk).values_list('is_active', flat=True)[0]
|
||||
super(Account, self).save(*args, **kwargs)
|
||||
if created:
|
||||
self.main_systemuser = self.systemusers.create(
|
||||
account=self, username=self.username, password=self.password,
|
||||
is_active=active_systemuser)
|
||||
self.save(update_fields=('main_systemuser',))
|
||||
elif was_active != self.is_active:
|
||||
self.notify_related()
|
||||
|
||||
def clean(self):
|
||||
self.short_name = self.short_name.strip()
|
||||
self.full_name = self.full_name.strip()
|
||||
|
||||
def disable(self):
|
||||
self.is_active = False
|
||||
self.save(update_fields=('is_active',))
|
||||
self.notify_related()
|
||||
|
||||
def enable(self):
|
||||
self.is_active = True
|
||||
self.save(update_fields=('is_active',))
|
||||
self.notify_related()
|
||||
|
||||
def get_services_to_disable(self):
|
||||
related_fields = [
|
||||
f for f in self._meta.get_fields()
|
||||
if (f.one_to_many or f.one_to_one)
|
||||
and f.auto_created and not f.concrete
|
||||
]
|
||||
for rel in related_fields:
|
||||
source = getattr(rel, 'related_model', rel.model)
|
||||
if source in core.services and hasattr(source, 'active'):
|
||||
for obj in getattr(self, rel.get_accessor_name()).all():
|
||||
yield obj
|
||||
|
||||
def notify_related(self):
|
||||
""" Trigger save() on related objects that depend on this account """
|
||||
for obj in self.get_services_to_disable():
|
||||
signals.pre_save.send(sender=type(obj), instance=obj)
|
||||
signals.post_save.send(sender=type(obj), instance=obj)
|
||||
# OperationsMiddleware.collect(Operation.SAVE, instance=obj, update_fields=())
|
||||
|
||||
def get_contacts_emails(self, usages=None):
|
||||
contacts = self.contacts.all()
|
||||
if usages is not None:
|
||||
contactes = contacts.filter(email_usages=usages)
|
||||
return contacts.values_list('email', flat=True)
|
||||
|
||||
def send_email(self, template, context, email_from=None, usages=None, attachments=[], html=None):
|
||||
contacts = self.contacts.filter(email_usages=usages)
|
||||
email_to = self.get_contacts_emails(usages)
|
||||
extra_context = {
|
||||
'account': self,
|
||||
'email_from': email_from or djsettings.SERVER_EMAIL,
|
||||
}
|
||||
extra_context.update(context)
|
||||
with translation.override(self.language):
|
||||
send_email_template(template, extra_context, email_to, email_from=email_from,
|
||||
html=html, attachments=attachments)
|
||||
|
||||
def get_full_name(self):
|
||||
return self.full_name or self.short_name or self.username
|
||||
|
||||
def get_short_name(self):
|
||||
""" Returns the short name for the user """
|
||||
return self.short_name or self.username or self.full_name
|
||||
|
||||
def has_perm(self, perm, obj=None):
|
||||
"""
|
||||
Returns True if the user has the specified permission. This method
|
||||
queries all available auth backends, but returns immediately if any
|
||||
backend returns True. Thus, a user who has permission from a single
|
||||
auth backend is assumed to have permission in general. If an object is
|
||||
provided, permissions for this specific object are checked.
|
||||
applabel.action_modelname
|
||||
"""
|
||||
if not self.is_active:
|
||||
return False
|
||||
# Active superusers have all permissions.
|
||||
if self.is_superuser:
|
||||
return True
|
||||
app, action_model = perm.split('.')
|
||||
action, model = action_model.split('_', 1)
|
||||
service_apps = set(model._meta.app_label for model in core.services.get().keys())
|
||||
accounting_apps = set(model._meta.app_label for model in core.accounts.get().keys())
|
||||
import inspect
|
||||
if ((app in service_apps or (action == 'view' and app in accounting_apps))):
|
||||
# class-level permissions
|
||||
if inspect.isclass(obj):
|
||||
return True
|
||||
elif obj and getattr(obj, 'account', None) == self:
|
||||
return True
|
||||
|
||||
def has_perms(self, perm_list, obj=None):
|
||||
"""
|
||||
Returns True if the user has each of the specified permissions. If
|
||||
object is passed, it checks if the user has all required perms for this
|
||||
object.
|
||||
"""
|
||||
for perm in perm_list:
|
||||
if not self.has_perm(perm, obj):
|
||||
return False
|
||||
return True
|
||||
|
||||
def has_module_perms(self, app_label):
|
||||
"""
|
||||
Returns True if the user has any permissions in the given app label.
|
||||
Uses pretty much the same logic as has_perm, above.
|
||||
"""
|
||||
# Active superusers have all permissions.
|
||||
if self.is_active and self.is_superuser:
|
||||
return True
|
||||
|
||||
def get_related_passwords(self, db_field=False):
|
||||
related = [
|
||||
self.main_systemuser,
|
||||
]
|
||||
for model, key, related_kwargs, __ in settings.ACCOUNTS_CREATE_RELATED:
|
||||
if 'password' not in related_kwargs:
|
||||
continue
|
||||
model = apps.get_model(model)
|
||||
kwargs = {
|
||||
key: eval(related_kwargs[key], {'account': self})
|
||||
}
|
||||
try:
|
||||
rel = model.objects.get(account=self, **kwargs)
|
||||
except model.DoesNotExist:
|
||||
continue
|
||||
if db_field:
|
||||
if not has_db_field(rel, 'password'):
|
||||
continue
|
||||
related.append(rel)
|
||||
return related
|
27
orchestra/contrib/accounts/serializers.py
Normal file
27
orchestra/contrib/accounts/serializers.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from .models import Account
|
||||
|
||||
|
||||
class AccountSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = Account
|
||||
fields = (
|
||||
'url', 'id', 'username', 'type', 'language', 'short_name', 'full_name', 'date_joined', 'last_login',
|
||||
'is_active'
|
||||
)
|
||||
|
||||
|
||||
class AccountSerializerMixin(object):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(AccountSerializerMixin, self).__init__(*args, **kwargs)
|
||||
self.account = self.get_account()
|
||||
|
||||
def get_account(self):
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.user
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['account'] = self.get_account()
|
||||
return super(AccountSerializerMixin, self).create(validated_data)
|
74
orchestra/contrib/accounts/settings.py
Normal file
74
orchestra/contrib/accounts/settings.py
Normal file
|
@ -0,0 +1,74 @@
|
|||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from orchestra.contrib.settings import Setting
|
||||
from orchestra.settings import ORCHESTRA_BASE_DOMAIN
|
||||
|
||||
|
||||
ACCOUNTS_TYPES = Setting('ACCOUNTS_TYPES',
|
||||
(
|
||||
('INDIVIDUAL', _("Individual")),
|
||||
('ASSOCIATION', _("Association")),
|
||||
('CUSTOMER', _("Customer")),
|
||||
('COMPANY', _("Company")),
|
||||
('PUBLICBODY', _("Public body")),
|
||||
('STAFF', _("Staff")),
|
||||
('FRIEND', _("Friend")),
|
||||
),
|
||||
validators=[Setting.validate_choices]
|
||||
)
|
||||
|
||||
|
||||
ACCOUNTS_DEFAULT_TYPE = Setting('ACCOUNTS_DEFAULT_TYPE',
|
||||
'INDIVIDUAL', choices=ACCOUNTS_TYPES)
|
||||
|
||||
|
||||
ACCOUNTS_LANGUAGES = Setting('ACCOUNTS_LANGUAGES',
|
||||
(
|
||||
('EN', _('English')),
|
||||
),
|
||||
validators=[Setting.validate_choices]
|
||||
)
|
||||
|
||||
|
||||
ACCOUNTS_DEFAULT_LANGUAGE = Setting('ACCOUNTS_DEFAULT_LANGUAGE',
|
||||
'EN',
|
||||
choices=ACCOUNTS_LANGUAGES
|
||||
)
|
||||
|
||||
|
||||
ACCOUNTS_SYSTEMUSER_MODEL = Setting('ACCOUNTS_SYSTEMUSER_MODEL',
|
||||
'systemusers.SystemUser',
|
||||
validators=[Setting.validate_model_label],
|
||||
)
|
||||
|
||||
|
||||
ACCOUNTS_MAIN_PK = Setting('ACCOUNTS_MAIN_PK',
|
||||
1
|
||||
)
|
||||
|
||||
|
||||
ACCOUNTS_CREATE_RELATED = Setting('ACCOUNTS_CREATE_RELATED',
|
||||
(
|
||||
# <model>, <key field>, <kwargs>, <help_text>
|
||||
('mailboxes.Mailbox',
|
||||
'name',
|
||||
{
|
||||
'name': 'account.username',
|
||||
'password': 'account.password',
|
||||
},
|
||||
_("Designates whether to creates a related mailbox with the same name and password or not."),
|
||||
),
|
||||
('domains.Domain',
|
||||
'name',
|
||||
{
|
||||
'name': '"%s.{}" % account.username.replace("_", "-")'.format(ORCHESTRA_BASE_DOMAIN),
|
||||
},
|
||||
_("Designates whether to creates a related subdomain <username>.{} or not.".format(ORCHESTRA_BASE_DOMAIN)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
ACCOUNTS_SERVICE_REPORT_TEMPLATE = Setting('ACCOUNTS_SERVICE_REPORT_TEMPLATE',
|
||||
'admin/accounts/account/service_report.html'
|
||||
)
|
|
@ -0,0 +1,42 @@
|
|||
{% extends "orchestra/admin/change_form.html" %}
|
||||
{% load i18n admin_urls static admin_modify %}
|
||||
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
|
||||
{% if from_account %}
|
||||
› <a href="{% url 'admin:app_list' app_label=account_opts.app_label %}">{{ account_opts.app_config.verbose_name }}</a>
|
||||
› <a href="{% url account_opts|admin_urlname:'changelist' %}">{{ account_opts.verbose_name_plural|capfirst }}</a>
|
||||
› <a href="{% url account_opts|admin_urlname:'change' account.pk|admin_urlquote %}">{{ account|truncatewords:"18" }}</a>
|
||||
{% else %}
|
||||
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||
› {% if has_change_permission %}<a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>{% else %}{{ opts.verbose_name_plural|capfirst }}{% endif %}
|
||||
{% endif %}
|
||||
{% if from_select %}
|
||||
› <a href="{% url opts|admin_urlname:'select_account' %}">{% blocktrans with name=original_opts.verbose_name %}Select {{ name }} account{% endblocktrans %}</a>
|
||||
{% endif %}
|
||||
› {% if add %}{% trans 'Add' %} {{ opts.verbose_name }}{% else %}{{ original|truncatewords:"18" }}{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block object-tools-items %}
|
||||
{% if services %}
|
||||
<li><select name="forma" onchange="location = this.options[this.selectedIndex].value;" style="margin: -3px 0 0 0;">
|
||||
<option selected disabled>{% trans "Services" %}</option>
|
||||
{% for service in services %}
|
||||
<option value="{% url service|admin_urlname:'changelist' %}?account={{ original.pk }}">{{ service.verbose_name_plural|capfirst }}</option>
|
||||
{% endfor %}
|
||||
</select></li>
|
||||
{% endif %}
|
||||
{% if accounts %}
|
||||
<li><select name="forma" onchange="location = this.options[this.selectedIndex].value;" style="margin: -3px 4px 0px 4px;">
|
||||
<option selected disabled>{% trans "Accounts" %}</option>
|
||||
{% for account in accounts %}
|
||||
<option value="{% url account|admin_urlname:'changelist' %}?account={{ original.pk }}">{{ account.verbose_name_plural|capfirst }}</option>
|
||||
{% endfor %}
|
||||
</select></li>
|
||||
{% endif %}
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
|
@ -0,0 +1,49 @@
|
|||
{% extends "admin/change_list.html" %}
|
||||
{% load i18n admin_urls admin_list %}
|
||||
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
|
||||
{% if account %}
|
||||
› <a href="{% url 'admin:app_list' app_label=account_opts.app_label %}">{{ account_opts.app_config.verbose_name }}</a>
|
||||
› <a href="{% url account_opts|admin_urlname:'changelist' %}">{{ account_opts.verbose_name_plural|capfirst }}</a>
|
||||
› <a href="{% url account_opts|admin_urlname:'change' account.pk|admin_urlquote %}">{{ account|truncatewords:"18" }}</a>
|
||||
{% else %}
|
||||
› <a href="{% url 'admin:app_list' app_label=cl.opts.app_label %}">{{ cl.opts.app_config.verbose_name }}</a>
|
||||
{% endif %}
|
||||
› {{ cl.opts.verbose_name_plural|capfirst }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block object-tools-items %}
|
||||
<li>
|
||||
{% url cl.opts|admin_urlname:'add' as add_url %}
|
||||
<a href="{% add_preserved_filters add_url is_popup to_field %}" class="addlink">
|
||||
{% if all_selected %}
|
||||
{% blocktrans with cl.opts.verbose_name as name %}Add {{ name }}{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans with cl.opts.verbose_name as name and account|truncatewords:"18" as account %}Add {{ account }} {{ name }}{% endblocktrans %}
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block filters %}
|
||||
{% if cl.has_filters %}
|
||||
<div id="changelist-filter">
|
||||
<h2>{% trans 'Filter' %}</h2>
|
||||
{% if account %}
|
||||
<h3>{% trans 'By account' %}</h3>
|
||||
<ul>
|
||||
<li {% if not all_selected %}class="selected"{% endif %}><a href="?account={{ account.pk }}">{{ account|truncatewords:"18" }}</a></li>
|
||||
<li {% if all_selected %}class="selected"{% endif %}><a href="?account={{ account.pk }}&all=True">All</a></li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% for spec in cl.filter_specs %}{% admin_list_filter cl spec %}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
{% extends "admin/delete_selected_confirmation.html" %}
|
||||
{% load i18n l10n admin_urls %}
|
||||
|
||||
{% block content %}
|
||||
{% if perms_lacking %}
|
||||
<p>{% blocktrans %}Deleting the selected {{ objects_name }} would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktrans %}</p>
|
||||
<ul>
|
||||
{% for obj in perms_lacking %}
|
||||
<li>{{ obj }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% elif protected %}
|
||||
<p>{% blocktrans %}Deleting the selected {{ objects_name }} would require deleting the following protected related objects:{% endblocktrans %}</p>
|
||||
<ul>
|
||||
{% for obj in protected %}
|
||||
<li>{{ obj }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>{% blocktrans %}Are you sure you want to delete the selected {{ objects_name }}? All of the following objects and their related items will be deleted:{% endblocktrans %}</p>
|
||||
{% include "admin/includes/object_delete_summary.html" %}
|
||||
<h2>{% trans "Objects" %}</h2>
|
||||
{% for deletable_object in deletable_objects %}
|
||||
<ul>{{ deletable_object|unordered_list }}</ul>
|
||||
{% endfor %}
|
||||
<form action="" method="post">{% csrf_token %}
|
||||
<div>
|
||||
{% for obj in queryset %}
|
||||
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk|unlocalize }}" />
|
||||
{% endfor %}
|
||||
<input type="hidden" name="action" value="delete_related_services" />
|
||||
<input type="hidden" name="post" value="yes" />
|
||||
<input type="submit" value="{% trans "Yes, I'm sure" %}" />
|
||||
<a href="#" onclick="window.history.back(); return false;" class="button cancel-link">{% trans "No, take me back" %}</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
{% extends "admin/base_site.html" %}
|
||||
{% load i18n l10n admin_urls %}
|
||||
|
||||
{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} delete-confirmation delete-selected-confirmation{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
|
||||
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
||||
› {% if disable%}{% blocktrans %}Disable {{ objects_name }}{% endblocktrans %}{% else %}{% blocktrans %}Enable {{ objects_name }}{% endblocktrans %}{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if disable%}<p>{% blocktrans %}Are you sure you want to disable selected {{ objects_name }}?{% endblocktrans %}</p>
|
||||
{% else %}<p>{% blocktrans %}Are you sure you want to enable selected {{ objects_name }}?{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
<h2>{% trans "Objects" %}</h2>
|
||||
{% for deletable_object in deletable_objects %}
|
||||
<ul>{{ deletable_object|unordered_list }}</ul>
|
||||
{% endfor %}
|
||||
<form action="" method="post">{% csrf_token %}
|
||||
<div>
|
||||
{% for obj in queryset %}
|
||||
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk|unlocalize }}" />
|
||||
{% endfor %}
|
||||
<input type="hidden" name="action" value="{{ action_name }}" />
|
||||
<input type="hidden" name="post" value="yes" />
|
||||
<input type="submit" value="{% trans "Yes, I'm sure" %}" />
|
||||
<a href="#" onclick="window.history.back(); return false;" class="button cancel-link">{% trans "No, take me back" %}</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
{% extends 'admin/change_list.html' %}
|
||||
{% load i18n admin_urls %}
|
||||
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
|
||||
› <a href="{% url 'admin:app_list' app_label=cl.opts.app_label %}">{{ original_opts.app_config.verbose_name }}</a>
|
||||
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ original_opts.verbose_name_plural|capfirst }}</a>
|
||||
› {% blocktrans with name=original_opts.verbose_name %}Select {{ name }} account{% endblocktrans %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
{% load i18n admin_urls utils %}
|
||||
<html>
|
||||
<head>
|
||||
<title>{% block title %}Account service report{% endblock %}</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
|
||||
{% block head %}{% endblock %}
|
||||
<style type="text/css">
|
||||
body {
|
||||
max-width: 670px;
|
||||
margin: 20 auto !important;
|
||||
float: none !important;
|
||||
font-family: Arial, 'Liberation Sans', 'DejaVu Sans', sans-serif;
|
||||
font-size: 12px;
|
||||
color: #444;
|
||||
}
|
||||
#date {
|
||||
float: right;
|
||||
color: rgb(102, 102, 102);
|
||||
}
|
||||
.account-content {
|
||||
margin: 0px 0px 40px 20px;
|
||||
}
|
||||
.item-title {
|
||||
list-style-type: none;
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
}
|
||||
.items-ul {
|
||||
padding: 0px;
|
||||
margin: 5px 0px 10px 20px;
|
||||
}
|
||||
.related {
|
||||
list-style: disc;
|
||||
}
|
||||
hr {
|
||||
margin-top: -9px;
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: rgb(91, 128, 178);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="date">{% trans "Service report generated on" %} {{ date | date }}</div>
|
||||
{% for account, items in accounts %}
|
||||
<h3>{{ account.get_full_name }} - <a href="{{ account|admin_url }}">{{ account.username }}</a></h3>
|
||||
<hr>
|
||||
<div class="account-content">
|
||||
{{ account.get_type_display }} {% trans "account registered on" %} {{ account.date_joined | date }}<br>
|
||||
<ul class="items-ul">
|
||||
<li class="item-title">{% trans 'Resources' %}</li>
|
||||
{% if account.resources %}
|
||||
<ul>
|
||||
{% for resource in account.resources %}
|
||||
<li><a href="{{ resource|admin_url }}">{{ resource.verbose_name }} {% if resource.used != None %}<span title="{% trans 'Used' %}">{{ resource.used }}</span>{% endif %}{% if resource.allocated != None %}{% if resource.used != None %} / {% endif %}<span title="{% trans 'Allocated' %}">{{ resource.allocated }}</span>{% endif %}</a> {{ resource.unit }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% for opts, related in items %}
|
||||
<li class="item-title"><a href="{% url opts|admin_urlname:'changelist' %}?account_id={{ account.pk }}">{{ opts.verbose_name_plural|capfirst }}</a></li>
|
||||
<ul>
|
||||
{% for obj in related %}
|
||||
<li class="related"><a href="{{ obj|admin_url }}">{{ obj }}</a>
|
||||
{% if not obj|isactive %} ({% trans "disabled" %}){% endif %}
|
||||
{{ obj.get_description|capfirst }}
|
||||
{% if obj.resources %}
|
||||
<ul>
|
||||
{% for resource in obj.resources %}
|
||||
<li><a href="{{ resource|admin_url }}">{{ resource.verbose_name }} {% if resource.used != None %}<span title="{% trans 'Used' %}">{{ resource.used }}</span>{% endif %}{% if resource.allocated != None %}{% if resource.used != None %} / {% endif %}<span title="{% trans 'Allocated' %}">{{ resource.allocated }}</span>{% endif %}</a> {{ resource.unit }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</body>
|
||||
</html>
|
1
orchestra/contrib/bills/__init__.py
Normal file
1
orchestra/contrib/bills/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
default_app_config = 'orchestra.contrib.bills.apps.BillsConfig'
|
377
orchestra/contrib/bills/actions.py
Normal file
377
orchestra/contrib/bills/actions.py
Normal file
|
@ -0,0 +1,377 @@
|
|||
import io
|
||||
import zipfile
|
||||
from datetime import date
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.admin import helpers
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.urls import reverse
|
||||
from django.db import transaction
|
||||
from django.forms.models import modelformset_factory
|
||||
from django.http import HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import render, redirect
|
||||
from django.utils import translation, timezone
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import ngettext, gettext_lazy as _
|
||||
|
||||
from orchestra.admin.decorators import action_with_confirmation
|
||||
from orchestra.admin.forms import AdminFormSet
|
||||
from orchestra.admin.utils import get_object_from_url, change_url
|
||||
|
||||
from . import settings
|
||||
from .forms import SelectSourceForm
|
||||
from .helpers import validate_contact, set_context_emails
|
||||
from .models import Bill, BillLine
|
||||
|
||||
|
||||
def view_bill(modeladmin, request, queryset):
|
||||
bill = queryset.get()
|
||||
if not validate_contact(request, bill):
|
||||
return
|
||||
html = bill.html or bill.render()
|
||||
return HttpResponse(html)
|
||||
view_bill.tool_description = _("View")
|
||||
view_bill.url_name = 'view'
|
||||
view_bill.hidden = True
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def close_bills(modeladmin, request, queryset, action='close_bills'):
|
||||
# Validate bills
|
||||
for bill in queryset:
|
||||
if not validate_contact(request, bill):
|
||||
return False
|
||||
if not bill.is_open:
|
||||
messages.warning(request, _("Selected bills should be in open state"))
|
||||
return False
|
||||
SelectSourceFormSet = modelformset_factory(modeladmin.model, form=SelectSourceForm, formset=AdminFormSet, extra=0)
|
||||
formset = SelectSourceFormSet(queryset=queryset)
|
||||
if request.POST.get('post') == 'generic_confirmation':
|
||||
formset = SelectSourceFormSet(request.POST, request.FILES, queryset=queryset)
|
||||
if formset.is_valid():
|
||||
transactions = []
|
||||
for form in formset.forms:
|
||||
source = form.cleaned_data['source']
|
||||
transaction = form.instance.close(payment=source)
|
||||
if transaction:
|
||||
transactions.append(transaction)
|
||||
for bill in queryset:
|
||||
modeladmin.log_change(request, bill, 'Closed')
|
||||
messages.success(request, _("Selected bills have been closed"))
|
||||
if transactions:
|
||||
num = len(transactions)
|
||||
if num == 1:
|
||||
url = change_url(transactions[0])
|
||||
else:
|
||||
url = reverse('admin:payments_transaction_changelist')
|
||||
url += 'id__in=%s' % ','.join([str(t.id) for t in transactions])
|
||||
context = {
|
||||
'url': url,
|
||||
'num': num,
|
||||
}
|
||||
message = ngettext(
|
||||
_('<a href="%(url)s">One related transaction</a> has been created') % context,
|
||||
_('<a href="%(url)s">%(num)i related transactions</a> have been created') % context,
|
||||
num)
|
||||
messages.success(request, mark_safe(message))
|
||||
return
|
||||
opts = modeladmin.model._meta
|
||||
context = {
|
||||
'title': _("Are you sure about closing the following bills?"),
|
||||
'content_message': _("Once a bill is closed it can not be further modified.</p>"
|
||||
"<p>Please select a payment source for the selected bills"),
|
||||
'action_name': 'Close bills',
|
||||
'action_value': action,
|
||||
'display_objects': [],
|
||||
'queryset': queryset,
|
||||
'opts': opts,
|
||||
'app_label': opts.app_label,
|
||||
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
|
||||
'formset': formset,
|
||||
'obj': get_object_from_url(modeladmin, request),
|
||||
}
|
||||
template = 'admin/orchestra/generic_confirmation.html'
|
||||
if action == 'close_send_download_bills':
|
||||
template = 'admin/bills/bill/close_send_download_bills.html'
|
||||
return render(request, template, context)
|
||||
close_bills.tool_description = _("Close")
|
||||
close_bills.url_name = 'close'
|
||||
|
||||
|
||||
def send_bills_action(modeladmin, request, queryset):
|
||||
"""
|
||||
raw function without confirmation
|
||||
enables reuse on close_send_download_bills because of generic_confirmation.action_view
|
||||
"""
|
||||
for bill in queryset:
|
||||
if not validate_contact(request, bill):
|
||||
return False
|
||||
num = 0
|
||||
for bill in queryset:
|
||||
bill.send()
|
||||
modeladmin.log_change(request, bill, 'Sent')
|
||||
num += 1
|
||||
messages.success(request, ngettext(
|
||||
_("One bill has been sent."),
|
||||
_("%i bills have been sent.") % num,
|
||||
num))
|
||||
|
||||
|
||||
@action_with_confirmation(extra_context=set_context_emails)
|
||||
def send_bills(modeladmin, request, queryset):
|
||||
return send_bills_action(modeladmin, request, queryset)
|
||||
send_bills.verbose_name = lambda bill: _("Resend" if getattr(bill, 'is_sent', False) else "Send")
|
||||
send_bills.url_name = 'send'
|
||||
|
||||
|
||||
def download_bills(modeladmin, request, queryset):
|
||||
for bill in queryset:
|
||||
if not validate_contact(request, bill):
|
||||
return False
|
||||
if len(queryset) > 1:
|
||||
bytesio = io.BytesIO()
|
||||
archive = zipfile.ZipFile(bytesio, 'w')
|
||||
for bill in queryset:
|
||||
pdf = bill.as_pdf()
|
||||
archive.writestr('%s.pdf' % bill.number, pdf)
|
||||
archive.close()
|
||||
response = HttpResponse(bytesio.getvalue(), content_type='application/zip')
|
||||
response['Content-Disposition'] = 'attachment; filename="orchestra-bills.zip"'
|
||||
return response
|
||||
bill = queryset[0]
|
||||
pdf = bill.as_pdf()
|
||||
response = HttpResponse(pdf, content_type='application/pdf')
|
||||
response['Content-Disposition'] = 'attachment; filename="%s.pdf"' % bill.number
|
||||
return response
|
||||
download_bills.tool_description = _("Download")
|
||||
download_bills.url_name = 'download'
|
||||
|
||||
|
||||
def close_send_download_bills(modeladmin, request, queryset):
|
||||
response = close_bills(modeladmin, request, queryset, action='close_send_download_bills')
|
||||
if response is False:
|
||||
# Not a valid contact or closed bill
|
||||
return
|
||||
if request.POST.get('post') == 'generic_confirmation':
|
||||
response = send_bills_action(modeladmin, request, queryset)
|
||||
if response is False:
|
||||
# Not a valid contact
|
||||
return
|
||||
return download_bills(modeladmin, request, queryset)
|
||||
return response
|
||||
close_send_download_bills.tool_description = _("C.S.D.")
|
||||
close_send_download_bills.url_name = 'close-send-download'
|
||||
close_send_download_bills.help_text = _("Close, send and download bills in one shot.")
|
||||
|
||||
|
||||
def manage_lines(modeladmin, request, queryset):
|
||||
url = reverse('admin:bills_bill_manage_lines')
|
||||
url += '?ids=%s' % ','.join(map(str, queryset.values_list('id', flat=True)))
|
||||
return redirect(url)
|
||||
|
||||
|
||||
@action_with_confirmation()
|
||||
def undo_billing(modeladmin, request, queryset):
|
||||
group = {}
|
||||
for line in queryset.select_related('order'):
|
||||
if line.order_id:
|
||||
try:
|
||||
group[line.order].append(line)
|
||||
except KeyError:
|
||||
group[line.order] = [line]
|
||||
|
||||
# Validate
|
||||
for order, lines in group.items():
|
||||
prev = None
|
||||
billed_on = date.max
|
||||
billed_until = date.max
|
||||
for line in sorted(lines, key=lambda l: l.start_on):
|
||||
if billed_on is not None:
|
||||
if line.order_billed_on is None:
|
||||
billed_on = line.order_billed_on
|
||||
else:
|
||||
billed_on = min(billed_on, line.order_billed_on)
|
||||
if billed_until is not None:
|
||||
if line.order_billed_until is None:
|
||||
billed_until = line.order_billed_until
|
||||
else:
|
||||
billed_until = min(billed_until, line.order_billed_until)
|
||||
if prev:
|
||||
if line.start_on != prev:
|
||||
messages.error(request, "Line dates doesn't match.")
|
||||
return
|
||||
else:
|
||||
# First iteration
|
||||
if order.billed_on < line.start_on:
|
||||
messages.error(request, "Billed on is smaller than first line start_on.")
|
||||
return
|
||||
prev = line.end_on
|
||||
nlines += 1
|
||||
if not prev:
|
||||
messages.error(request, "Order does not have lines!.")
|
||||
order.billed_until = billed_until
|
||||
order.billed_on = billed_on
|
||||
|
||||
# Commit changes
|
||||
norders, nlines = 0, 0
|
||||
for order, lines in group.items():
|
||||
for line in lines:
|
||||
nlines += 1
|
||||
line.delete()
|
||||
# TODO update order history undo billing
|
||||
order.save(update_fields=('billed_until', 'billed_on'))
|
||||
norders += 1
|
||||
|
||||
messages.success(request, _("%(norders)s orders and %(nlines)s lines undoed.") % {
|
||||
'nlines': nlines,
|
||||
'norders': norders
|
||||
})
|
||||
|
||||
|
||||
def move_lines(modeladmin, request, queryset, action=None):
|
||||
# Validate
|
||||
target = request.GET.get('target')
|
||||
if not target:
|
||||
# select target
|
||||
context = {}
|
||||
return render(request, 'admin/orchestra/generic_confirmation.html', context)
|
||||
target = Bill.objects.get(pk=int(pk))
|
||||
if request.POST.get('post') == 'generic_confirmation':
|
||||
for line in queryset:
|
||||
line.bill = target
|
||||
line.save(update_fields=['bill'])
|
||||
# TODO bill history update
|
||||
messages.success(request, _("Lines moved"))
|
||||
# Final confirmation
|
||||
return render(request, 'admin/orchestra/generic_confirmation.html', context)
|
||||
|
||||
|
||||
def copy_lines(modeladmin, request, queryset):
|
||||
# same as move, but changing action behaviour
|
||||
return move_lines(modeladmin, request, queryset)
|
||||
|
||||
|
||||
def validate_amend_bills(bills):
|
||||
for bill in bills:
|
||||
if bill.is_open:
|
||||
raise ValidationError(_("Selected bills should be in closed state"))
|
||||
if bill.type not in bill.AMEND_MAP:
|
||||
raise ValidationError(_("%s can not be amended.") % bill.get_type_display())
|
||||
|
||||
|
||||
@action_with_confirmation(validator=validate_amend_bills)
|
||||
def amend_bills(modeladmin, request, queryset):
|
||||
amend_ids = []
|
||||
for bill in queryset:
|
||||
with translation.override(bill.account.language):
|
||||
amend_type = bill.get_amend_type()
|
||||
context = {
|
||||
'related_type': _(bill.get_type_display()),
|
||||
'number': bill.number,
|
||||
'date': bill.created_on,
|
||||
}
|
||||
amend = Bill.objects.create(
|
||||
account=bill.account,
|
||||
type=amend_type,
|
||||
amend_of=bill,
|
||||
)
|
||||
context['type'] = _(amend.get_type_display())
|
||||
amend.comments = _("%(type)s of %(related_type)s %(number)s and creation date %(date)s") % context
|
||||
amend.save(update_fields=('comments',))
|
||||
for tax, subtotals in bill.compute_subtotals().items():
|
||||
context['tax'] = tax
|
||||
line = BillLine.objects.create(
|
||||
bill=amend,
|
||||
start_on=bill.created_on,
|
||||
description=_("%(related_type)s %(number)s subtotal for tax %(tax)s%%") % context,
|
||||
subtotal=subtotals[0],
|
||||
tax=tax
|
||||
)
|
||||
amend_ids.append(amend.pk)
|
||||
modeladmin.log_change(request, bill, 'Amended, amend id is %i' % amend.id)
|
||||
num = len(amend_ids)
|
||||
if num == 1:
|
||||
amend_url = reverse('admin:bills_bill_change', args=amend_ids)
|
||||
else:
|
||||
amend_url = reverse('admin:bills_bill_changelist')
|
||||
amend_url += '?id=%s' % ','.join(map(str, amend_ids))
|
||||
context = {
|
||||
'url': amend_url,
|
||||
'num': num,
|
||||
}
|
||||
messages.success(request, mark_safe(ngettext(
|
||||
_('<a href="%(url)s">One amendment bill</a> have been generated.') % context,
|
||||
_('<a href="%(url)s">%(num)i amendment bills</a> have been generated.') % context,
|
||||
num
|
||||
)))
|
||||
amend_bills.tool_description = _("Amend")
|
||||
amend_bills.url_name = 'amend'
|
||||
|
||||
|
||||
def bill_report(modeladmin, request, queryset):
|
||||
subtotals = {}
|
||||
total = 0
|
||||
for bill in queryset:
|
||||
for tax, subtotal in bill.compute_subtotals().items():
|
||||
try:
|
||||
subtotals[tax][0] += subtotal[0]
|
||||
except KeyError:
|
||||
subtotals[tax] = subtotal
|
||||
else:
|
||||
subtotals[tax][1] += subtotal[1]
|
||||
total += bill.compute_total()
|
||||
context = {
|
||||
'subtotals': subtotals,
|
||||
'total': total,
|
||||
'bills': queryset,
|
||||
'currency': settings.BILLS_CURRENCY,
|
||||
}
|
||||
return render(request, 'admin/bills/bill/report.html', context)
|
||||
|
||||
|
||||
def service_report(modeladmin, request, queryset):
|
||||
services = {}
|
||||
totals = [0, 0, 0, 0, 0]
|
||||
now = timezone.now().date()
|
||||
if queryset.model == Bill:
|
||||
queryset = BillLine.objects.filter(bill_id__in=queryset.values_list('id', flat=True))
|
||||
# Filter amends
|
||||
queryset = queryset.filter(bill__amend_of__isnull=True)
|
||||
for line in queryset.select_related('order__service').prefetch_related('sublines'):
|
||||
order, service = None, None
|
||||
if line.order_id:
|
||||
order = line.order
|
||||
service = order.service
|
||||
name = service.description
|
||||
active, cancelled = (1, 0) if not order.cancelled_on or order.cancelled_on > now else (0, 1)
|
||||
nominal_price = order.service.nominal_price
|
||||
else:
|
||||
name = '*%s' % line.description
|
||||
active = 1
|
||||
cancelled = 0
|
||||
nominal_price = 0
|
||||
try:
|
||||
info = services[name]
|
||||
except KeyError:
|
||||
info = [active, cancelled, nominal_price, line.quantity or 1, line.compute_total()]
|
||||
services[name] = info
|
||||
else:
|
||||
info[0] += active
|
||||
info[1] += cancelled
|
||||
info[3] += line.quantity or 1
|
||||
info[4] += line.compute_total()
|
||||
totals[0] += active
|
||||
totals[1] += cancelled
|
||||
totals[2] += nominal_price
|
||||
totals[3] += line.quantity or 1
|
||||
totals[4] += line.compute_total()
|
||||
context = {
|
||||
'services': sorted(services.items(), key=lambda n: -n[1][4]),
|
||||
'totals': totals,
|
||||
}
|
||||
return render(request, 'admin/bills/billline/report.html', context)
|
||||
|
||||
|
||||
def list_bills(modeladmin, request, queryset):
|
||||
ids = ','.join(map(str, queryset.values_list('bill_id', flat=True).distinct()))
|
||||
return HttpResponseRedirect('../bill/?id__in=%s' % ids)
|
493
orchestra/contrib/bills/admin.py
Normal file
493
orchestra/contrib/bills/admin.py
Normal file
|
@ -0,0 +1,493 @@
|
|||
from django import forms
|
||||
from django.urls import re_path as url
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.admin.utils import unquote
|
||||
from django.urls import reverse
|
||||
from django.db import models
|
||||
from django.db.models import F, Sum, Prefetch
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.templatetags.static import static
|
||||
from django.utils.html import format_html
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.shortcuts import redirect
|
||||
|
||||
from orchestra.admin import ExtendedModelAdmin
|
||||
from orchestra.admin.utils import admin_date, insertattr, admin_link, change_url
|
||||
from orchestra.contrib.accounts.actions import list_accounts
|
||||
from orchestra.contrib.accounts.admin import AccountAdminMixin, AccountAdmin
|
||||
from orchestra.forms.widgets import PaddingCheckboxSelectMultiple
|
||||
|
||||
from . import settings, actions
|
||||
from .filters import (BillTypeListFilter, HasBillContactListFilter, TotalListFilter,
|
||||
PaymentStateListFilter, AmendedListFilter)
|
||||
from .models import (Bill, Invoice, AmendmentInvoice, AbonoInvoice, Fee, AmendmentFee, ProForma, BillLine,
|
||||
BillSubline, BillContact)
|
||||
|
||||
|
||||
PAYMENT_STATE_COLORS = {
|
||||
Bill.OPEN: 'grey',
|
||||
Bill.CREATED: 'magenta',
|
||||
Bill.PROCESSED: 'darkorange',
|
||||
Bill.AMENDED: 'blue',
|
||||
Bill.PAID: 'green',
|
||||
Bill.EXECUTED: 'olive',
|
||||
Bill.BAD_DEBT: 'red',
|
||||
Bill.INCOMPLETE: 'red',
|
||||
}
|
||||
|
||||
|
||||
class BillSublineInline(admin.TabularInline):
|
||||
model = BillSubline
|
||||
fields = ('description', 'total', 'type')
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
fields = super().get_readonly_fields(request, obj)
|
||||
if obj and not obj.bill.is_open:
|
||||
return self.get_fields(request)
|
||||
return fields
|
||||
|
||||
def get_max_num(self, request, obj=None):
|
||||
if obj and not obj.bill.is_open:
|
||||
return 0
|
||||
return super().get_max_num(request, obj)
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
if obj and not obj.bill.is_open:
|
||||
return False
|
||||
return super().has_delete_permission(request, obj)
|
||||
|
||||
|
||||
class BillLineInline(admin.TabularInline):
|
||||
model = BillLine
|
||||
fields = (
|
||||
'description', 'order_link', 'start_on', 'end_on', 'rate', 'quantity', 'tax',
|
||||
'subtotal', 'display_total',
|
||||
)
|
||||
readonly_fields = ('display_total', 'order_link')
|
||||
|
||||
order_link = admin_link('order', display='pk')
|
||||
|
||||
@mark_safe
|
||||
def display_total(self, line):
|
||||
if line.pk:
|
||||
total = line.compute_total()
|
||||
sublines = line.sublines.all()
|
||||
url = change_url(line)
|
||||
if sublines:
|
||||
content = '\n'.join(['%s: %s' % (sub.description, sub.total) for sub in sublines])
|
||||
img = static('admin/img/icon-alert.svg')
|
||||
return '<a href="%s" title="%s">%s <img src="%s"></img></a>' % (url, content, total, img)
|
||||
return '<a href="%s">%s</a>' % (url, total)
|
||||
display_total.short_description = _("Total")
|
||||
|
||||
def formfield_for_dbfield(self, db_field, **kwargs):
|
||||
""" Make value input widget bigger """
|
||||
if db_field.name == 'description':
|
||||
kwargs['widget'] = forms.TextInput(attrs={'size':'50'})
|
||||
elif db_field.name not in ('start_on', 'end_on'):
|
||||
kwargs['widget'] = forms.TextInput(attrs={'size':'6'})
|
||||
return super().formfield_for_dbfield(db_field, **kwargs)
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
return qs.prefetch_related('sublines').select_related('order')
|
||||
|
||||
|
||||
class ClosedBillLineInline(BillLineInline):
|
||||
# TODO reimplement as nested inlines when upstream
|
||||
# https://code.djangoproject.com/ticket/9025
|
||||
|
||||
fields = (
|
||||
'display_description', 'order_link', 'start_on', 'end_on', 'rate', 'quantity', 'tax',
|
||||
'display_subtotal', 'display_total'
|
||||
)
|
||||
readonly_fields = fields
|
||||
can_delete = False
|
||||
|
||||
@mark_safe
|
||||
def display_description(self, line):
|
||||
descriptions = [line.description]
|
||||
for subline in line.sublines.all():
|
||||
descriptions.append(' ' * 4 + subline.description)
|
||||
return '<br>'.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 '<br>'.join(subtotals)
|
||||
display_subtotal.short_description = _("Subtotal")
|
||||
|
||||
def display_total(self, line):
|
||||
if line.pk:
|
||||
return line.compute_total()
|
||||
display_total.short_description = _("Total")
|
||||
|
||||
def has_add_permission(self, request, obj):
|
||||
return False
|
||||
|
||||
|
||||
class BillLineAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
'description', 'bill_link', 'display_is_open', 'account_link', 'rate', 'quantity',
|
||||
'tax', 'subtotal', 'display_sublinetotal', 'display_total'
|
||||
)
|
||||
actions = (
|
||||
actions.undo_billing, actions.move_lines, actions.copy_lines, actions.service_report,
|
||||
actions.list_bills,
|
||||
)
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('bill_link', 'description', 'tax', 'start_on', 'end_on', 'amended_line_link')
|
||||
}),
|
||||
(_("Totals"), {
|
||||
'fields': ('rate', ('quantity', 'verbose_quantity'), 'subtotal', 'display_sublinetotal',
|
||||
'display_total'),
|
||||
}),
|
||||
(_("Order"), {
|
||||
'fields': ('order_link', 'order_billed_on', 'order_billed_until',)
|
||||
}),
|
||||
)
|
||||
readonly_fields = (
|
||||
'bill_link', 'order_link', 'amended_line_link', 'display_sublinetotal', 'display_total'
|
||||
)
|
||||
list_filter = ('tax', 'bill__is_open', 'order__service')
|
||||
list_select_related = ('bill', 'bill__account')
|
||||
search_fields = ('description', 'bill__number')
|
||||
inlines = (BillSublineInline,)
|
||||
|
||||
account_link = admin_link('bill__account')
|
||||
bill_link = admin_link('bill')
|
||||
order_link = admin_link('order')
|
||||
amended_line_link = admin_link('amended_line')
|
||||
|
||||
def display_is_open(self, instance):
|
||||
return instance.bill.is_open
|
||||
display_is_open.short_description = _("Is open")
|
||||
display_is_open.boolean = True
|
||||
|
||||
def display_sublinetotal(self, instance):
|
||||
total = instance.subline_total
|
||||
return total if total is not None else '---'
|
||||
display_sublinetotal.short_description = _("Sublines")
|
||||
display_sublinetotal.admin_order_field = 'subline_total'
|
||||
|
||||
def display_total(self, instance):
|
||||
return round(instance.computed_total or 0, 2)
|
||||
display_total.short_description = _("Total")
|
||||
display_total.admin_order_field = 'computed_total'
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
fields = super().get_readonly_fields(request, obj)
|
||||
if obj and not obj.bill.is_open:
|
||||
return list(fields) + [
|
||||
'description', 'tax', 'start_on', 'end_on', 'rate', 'quantity', 'verbose_quantity',
|
||||
'subtotal', 'order_billed_on', 'order_billed_until'
|
||||
]
|
||||
return fields
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
qs = qs.annotate(
|
||||
subline_total=Sum('sublines__total'),
|
||||
computed_total=(F('subtotal') + Sum(Coalesce('sublines__total', 0))) * (1+F('tax')/100),
|
||||
)
|
||||
return qs
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
if obj and not obj.bill.is_open:
|
||||
return False
|
||||
return super().has_delete_permission(request, obj)
|
||||
|
||||
|
||||
class BillLineManagerAdmin(BillLineAdmin):
|
||||
def get_queryset(self, request):
|
||||
qset = super().get_queryset(request)
|
||||
if self.bill_ids:
|
||||
return qset.filter(bill_id__in=self.bill_ids)
|
||||
return qset
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
GET_copy = request.GET.copy()
|
||||
bill_ids = GET_copy.pop('ids', None)
|
||||
if bill_ids:
|
||||
bill_ids = bill_ids[0]
|
||||
request.GET = GET_copy
|
||||
bill_ids = list(map(int, bill_ids.split(',')))
|
||||
else:
|
||||
messages.error(request, _("No bills selected."))
|
||||
return redirect('..')
|
||||
self.bill_ids = bill_ids
|
||||
bill = None
|
||||
if len(bill_ids) == 1:
|
||||
bill_url = reverse('admin:bills_bill_change', args=(bill_ids[0],))
|
||||
bill = Bill.objects.get(pk=bill_ids[0])
|
||||
bill_link = '<a href="%s">%s</a>' % (bill_url, bill.number)
|
||||
title = mark_safe(_("Manage %s bill lines") % bill_link)
|
||||
if not bill.is_open:
|
||||
messages.warning(request, _("Bill not in open state."))
|
||||
else:
|
||||
if Bill.objects.filter(id__in=bill_ids, is_open=False).exists():
|
||||
messages.warning(request, _("Not all bills are in open state."))
|
||||
title = _("Manage bill lines of multiple bills")
|
||||
context = {
|
||||
'title': title,
|
||||
'bill': bill,
|
||||
}
|
||||
context.update(extra_context or {})
|
||||
return super().changelist_view(request, context)
|
||||
|
||||
|
||||
class BillAdminMixin(AccountAdminMixin):
|
||||
@mark_safe
|
||||
def display_total_with_subtotals(self, bill):
|
||||
if bill.pk:
|
||||
currency = settings.BILLS_CURRENCY.lower()
|
||||
subtotals = []
|
||||
for tax, subtotal in bill.compute_subtotals().items():
|
||||
subtotals.append(_("Subtotal %s%% VAT %s &%s;") % (tax, subtotal[0], currency))
|
||||
subtotals.append(_("Taxes %s%% VAT %s &%s;") % (tax, subtotal[1], currency))
|
||||
subtotals = '\n'.join(subtotals)
|
||||
return '<span title="%s">%s &%s;</span>' % (subtotals, bill.compute_total(), currency)
|
||||
display_total_with_subtotals.short_description = _("total")
|
||||
display_total_with_subtotals.admin_order_field = 'approx_total'
|
||||
|
||||
@mark_safe
|
||||
def display_payment_state(self, bill):
|
||||
if bill.pk:
|
||||
t_opts = bill.transactions.model._meta
|
||||
if bill.get_type() == bill.PROFORMA:
|
||||
return '<span title="Pro forma">---</span>'
|
||||
transactions = bill.transactions.all()
|
||||
if len(transactions) == 1:
|
||||
args = (transactions[0].pk,)
|
||||
view = 'admin:%s_%s_change' % (t_opts.app_label, t_opts.model_name)
|
||||
url = reverse(view, args=args)
|
||||
else:
|
||||
url = reverse('admin:%s_%s_changelist' % (t_opts.app_label, t_opts.model_name))
|
||||
url += '?bill=%i' % bill.pk
|
||||
state = bill.get_payment_state_display().upper()
|
||||
title = ''
|
||||
if bill.closed_amends:
|
||||
state = '<strike>%s*</strike>' % state
|
||||
title = _("This bill has been amended, this value may not be valid.")
|
||||
color = PAYMENT_STATE_COLORS.get(bill.payment_state, 'grey')
|
||||
return '<a href="{url}" style="color:{color}" title="{title}">{name}</a>'.format(
|
||||
url=url, color=color, name=state, title=title)
|
||||
display_payment_state.short_description = _("Payment")
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
qs = qs.annotate(
|
||||
models.Count('lines'),
|
||||
# FIXME https://code.djangoproject.com/ticket/10060
|
||||
approx_total=Coalesce(Sum(
|
||||
(F('lines__subtotal') + Coalesce('lines__sublines__total', 0)) * (1+F('lines__tax')/100),
|
||||
), 0),
|
||||
)
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch('amends', queryset=Bill.objects.filter(is_open=False), to_attr='closed_amends')
|
||||
)
|
||||
return qs.defer('html')
|
||||
|
||||
|
||||
class AmendInline(BillAdminMixin, admin.TabularInline):
|
||||
model = Bill
|
||||
fields = (
|
||||
'self_link', 'type', 'display_total_with_subtotals', 'display_payment_state', 'is_open',
|
||||
'is_sent'
|
||||
)
|
||||
readonly_fields = fields
|
||||
verbose_name_plural = _("Amends")
|
||||
can_delete = False
|
||||
extra = 0
|
||||
|
||||
self_link = admin_link('__str__')
|
||||
|
||||
def has_add_permission(self, *args, **kwargs):
|
||||
return False
|
||||
|
||||
|
||||
class BillAdmin(BillAdminMixin, ExtendedModelAdmin):
|
||||
list_display = (
|
||||
'number', 'type_link', 'account_link', 'closed_on_display', 'updated_on_display',
|
||||
'num_lines', 'display_total', 'display_payment_state', 'is_sent'
|
||||
)
|
||||
list_filter = (
|
||||
BillTypeListFilter, 'is_open', 'is_sent', TotalListFilter, PaymentStateListFilter,
|
||||
AmendedListFilter, 'account__is_active',
|
||||
)
|
||||
add_fields = ('account', 'type', 'amend_of', 'is_open', 'due_on', 'comments')
|
||||
change_list_template = 'admin/bills/bill/change_list.html'
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ['number', 'type', (), 'account_link', 'display_total_with_subtotals',
|
||||
'display_payment_state', 'is_sent', 'comments'],
|
||||
}),
|
||||
(_("Dates"), {
|
||||
'classes': ('collapse',),
|
||||
'fields': ('created_on_display', 'closed_on_display', 'updated_on_display',
|
||||
'due_on'),
|
||||
}),
|
||||
(_("Raw"), {
|
||||
'classes': ('collapse',),
|
||||
'fields': ('html',),
|
||||
}),
|
||||
)
|
||||
list_prefetch_related = ('transactions', 'lines__sublines')
|
||||
search_fields = ('number', 'account__username', 'comments')
|
||||
change_view_actions = [
|
||||
actions.manage_lines, actions.view_bill, actions.download_bills, actions.send_bills,
|
||||
actions.close_bills, actions.amend_bills, actions.close_send_download_bills,
|
||||
]
|
||||
actions = [
|
||||
actions.manage_lines, actions.download_bills, actions.close_bills, actions.send_bills,
|
||||
actions.amend_bills, actions.bill_report, actions.service_report,
|
||||
actions.close_send_download_bills, list_accounts,
|
||||
]
|
||||
change_readonly_fields = ('account_link', 'type', 'is_open', 'amend_of_link')
|
||||
readonly_fields = (
|
||||
'number', 'display_total', 'is_sent', 'display_payment_state', 'created_on_display',
|
||||
'closed_on_display', 'updated_on_display', 'display_total_with_subtotals',
|
||||
)
|
||||
date_hierarchy = 'closed_on'
|
||||
|
||||
created_on_display = admin_date('created_on', short_description=_("Created"))
|
||||
closed_on_display = admin_date('closed_on', short_description=_("Closed"))
|
||||
updated_on_display = admin_date('updated_on', short_description=_("Updated"))
|
||||
amend_of_link = admin_link('amend_of')
|
||||
|
||||
# def amend_links(self, bill):
|
||||
# links = []
|
||||
# for amend in bill.amends.all():
|
||||
# url = reverse('admin:bills_bill_change', args=(amend.id,))
|
||||
# links.append('<a href="{url}">{num}</a>'.format(url=url, num=amend.number))
|
||||
# return '<br>'.join(links)
|
||||
# amend_links.short_description = _("Amends")
|
||||
# amend_links.allow_tags = True
|
||||
|
||||
def num_lines(self, bill):
|
||||
return bill.lines__count
|
||||
num_lines.admin_order_field = 'lines__count'
|
||||
num_lines.short_description = _("lines")
|
||||
|
||||
def display_total(self, bill):
|
||||
currency = settings.BILLS_CURRENCY.lower()
|
||||
return format_html('{} &{};', bill.compute_total(), currency)
|
||||
display_total.short_description = _("total")
|
||||
display_total.admin_order_field = 'approx_total'
|
||||
|
||||
def type_link(self, bill):
|
||||
bill_type = bill.type.lower()
|
||||
url = reverse('admin:bills_%s_changelist' % bill_type)
|
||||
return format_html('<a href="{}">{}</a>', url, bill.get_type_display())
|
||||
type_link.short_description = _("type")
|
||||
type_link.admin_order_field = 'type'
|
||||
|
||||
def get_urls(self):
|
||||
""" Hook bill lines management URLs on bill admin """
|
||||
urls = super().get_urls()
|
||||
admin_site = self.admin_site
|
||||
extra_urls = [
|
||||
url("^manage-lines/$",
|
||||
admin_site.admin_view(BillLineManagerAdmin(BillLine, admin_site).changelist_view),
|
||||
name='bills_bill_manage_lines'),
|
||||
]
|
||||
return extra_urls + urls
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
fields = super().get_readonly_fields(request, obj)
|
||||
if obj and not obj.is_open:
|
||||
fields += self.add_fields
|
||||
return fields
|
||||
|
||||
def get_fieldsets(self, request, obj=None):
|
||||
fieldsets = super().get_fieldsets(request, obj)
|
||||
if obj:
|
||||
# Switches between amend_of_link and amend_links fields
|
||||
fields = fieldsets[0][1]['fields']
|
||||
if obj.amend_of_id:
|
||||
fields[2] = 'amend_of_link'
|
||||
else:
|
||||
fields[2] = ()
|
||||
if obj.is_open:
|
||||
fieldsets = fieldsets[0:-1]
|
||||
return fieldsets
|
||||
|
||||
def get_change_view_actions(self, obj=None):
|
||||
actions = super().get_change_view_actions(obj)
|
||||
exclude = []
|
||||
if obj:
|
||||
if not obj.is_open:
|
||||
exclude += ['close_bills', 'close_send_download_bills']
|
||||
if obj.type not in obj.AMEND_MAP:
|
||||
exclude += ['amend_bills']
|
||||
return [action for action in actions if action.__name__ not in exclude]
|
||||
|
||||
def get_inline_instances(self, request, obj=None):
|
||||
cls = type(self)
|
||||
if obj and not obj.is_open:
|
||||
if obj.amends.all():
|
||||
cls.inlines = [AmendInline, ClosedBillLineInline]
|
||||
else:
|
||||
cls.inlines = [ClosedBillLineInline]
|
||||
else:
|
||||
cls.inlines = [BillLineInline]
|
||||
return super().get_inline_instances(request, obj)
|
||||
|
||||
def formfield_for_dbfield(self, db_field, **kwargs):
|
||||
""" Make value input widget bigger """
|
||||
if db_field.name == 'comments':
|
||||
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 4})
|
||||
elif db_field.name == 'html':
|
||||
kwargs['widget'] = forms.Textarea(attrs={'cols': 150, 'rows': 20})
|
||||
formfield = super().formfield_for_dbfield(db_field, **kwargs)
|
||||
if db_field.name == 'amend_of':
|
||||
formfield.queryset = formfield.queryset.filter(is_open=False)
|
||||
return formfield
|
||||
|
||||
def change_view(self, request, object_id, **kwargs):
|
||||
# TODO raise404, here and everywhere
|
||||
bill = self.get_object(request, unquote(object_id))
|
||||
actions.validate_contact(request, bill, error=False)
|
||||
return super().change_view(request, object_id, **kwargs)
|
||||
|
||||
|
||||
admin.site.register(Bill, BillAdmin)
|
||||
admin.site.register(Invoice, BillAdmin)
|
||||
admin.site.register(AmendmentInvoice, BillAdmin)
|
||||
admin.site.register(AbonoInvoice, BillAdmin)
|
||||
admin.site.register(Fee, BillAdmin)
|
||||
admin.site.register(AmendmentFee, BillAdmin)
|
||||
admin.site.register(ProForma, BillAdmin)
|
||||
admin.site.register(BillLine, BillLineAdmin)
|
||||
|
||||
|
||||
class BillContactInline(admin.StackedInline):
|
||||
model = BillContact
|
||||
fields = ('name', 'address', ('city', 'zipcode'), 'country', 'vat')
|
||||
|
||||
def formfield_for_dbfield(self, db_field, **kwargs):
|
||||
""" Make value input widget bigger """
|
||||
if db_field.name == 'name':
|
||||
kwargs['widget'] = forms.TextInput(attrs={'size':'90'})
|
||||
if db_field.name == 'address':
|
||||
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 2})
|
||||
if db_field.name == 'email_usage':
|
||||
kwargs['widget'] = PaddingCheckboxSelectMultiple(45)
|
||||
return super().formfield_for_dbfield(db_field, **kwargs)
|
||||
|
||||
|
||||
def has_bill_contact(account):
|
||||
return hasattr(account, 'billcontact')
|
||||
has_bill_contact.boolean = True
|
||||
has_bill_contact.admin_order_field = 'billcontact'
|
||||
|
||||
|
||||
insertattr(AccountAdmin, 'inlines', BillContactInline)
|
||||
insertattr(AccountAdmin, 'list_display', has_bill_contact)
|
||||
insertattr(AccountAdmin, 'list_filter', HasBillContactListFilter)
|
||||
insertattr(AccountAdmin, 'list_select_related', 'billcontact')
|
29
orchestra/contrib/bills/api.py
Normal file
29
orchestra/contrib/bills/api.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
from django.http import HttpResponse
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
|
||||
from orchestra.api import router, LogApiMixin
|
||||
from orchestra.contrib.accounts.api import AccountApiMixin
|
||||
from orchestra.utils.html import html_to_pdf
|
||||
|
||||
from .models import Bill
|
||||
from .serializers import BillSerializer
|
||||
|
||||
|
||||
|
||||
class BillViewSet(LogApiMixin, AccountApiMixin, viewsets.ModelViewSet):
|
||||
queryset = Bill.objects.all()
|
||||
serializer_class = BillSerializer
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def document(self, request, pk):
|
||||
bill = self.get_object()
|
||||
content_type = request.META.get('HTTP_ACCEPT')
|
||||
if content_type == 'application/pdf':
|
||||
pdf = html_to_pdf(bill.html or bill.render())
|
||||
return HttpResponse(pdf, content_type='application/pdf')
|
||||
else:
|
||||
return HttpResponse(bill.html or bill.render())
|
||||
|
||||
|
||||
router.register('bills', BillViewSet)
|
12
orchestra/contrib/bills/apps.py
Normal file
12
orchestra/contrib/bills/apps.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
from orchestra.core import accounts
|
||||
|
||||
|
||||
class BillsConfig(AppConfig):
|
||||
name = 'orchestra.contrib.bills'
|
||||
verbose_name = 'Bills'
|
||||
|
||||
def ready(self):
|
||||
from .models import Bill
|
||||
accounts.register(Bill, icon='invoice.png')
|
160
orchestra/contrib/bills/filters.py
Normal file
160
orchestra/contrib/bills/filters.py
Normal file
|
@ -0,0 +1,160 @@
|
|||
from django.contrib.admin import SimpleListFilter
|
||||
from django.urls import reverse
|
||||
from django.db.models import Q
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from . models import Bill
|
||||
|
||||
|
||||
class BillTypeListFilter(SimpleListFilter):
|
||||
""" Filter tickets by created_by according to request.user """
|
||||
title = 'Type'
|
||||
parameter_name = ''
|
||||
|
||||
def __init__(self, request, *args, **kwargs):
|
||||
super(BillTypeListFilter, self).__init__(request, *args, **kwargs)
|
||||
self.request = request
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (
|
||||
('bill', _("All")),
|
||||
('invoice', _("Invoice")),
|
||||
('fee', _("Fee")),
|
||||
('proforma', _("Pro-forma")),
|
||||
('amendmentfee', _("Amendment fee")),
|
||||
('amendmentinvoice', _("Amendment invoice")),
|
||||
)
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
return queryset
|
||||
|
||||
def value(self):
|
||||
return self.request.path.split('/')[-2]
|
||||
|
||||
def choices(self, cl):
|
||||
query = self.request.GET.urlencode()
|
||||
for lookup, title in self.lookup_choices:
|
||||
yield {
|
||||
'selected': self.value() == lookup,
|
||||
'query_string': reverse('admin:bills_%s_changelist' % lookup) + '?%s' % query,
|
||||
'display': title,
|
||||
}
|
||||
|
||||
|
||||
class TotalListFilter(SimpleListFilter):
|
||||
title = _("total")
|
||||
parameter_name = 'total'
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (
|
||||
('gt', mark_safe("total > 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()
|
49
orchestra/contrib/bills/forms.py
Normal file
49
orchestra/contrib/bills/forms.py
Normal file
|
@ -0,0 +1,49 @@
|
|||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from orchestra.admin.utils import admin_link
|
||||
from orchestra.forms import SpanWidget
|
||||
|
||||
|
||||
class SelectSourceForm(forms.ModelForm):
|
||||
bill_link = forms.CharField(label=_("Number"), required=False, widget=SpanWidget)
|
||||
account_link = forms.CharField(label=_("Account"), required=False, widget=SpanWidget)
|
||||
show_total = forms.CharField(label=_("Total"), required=False, widget=SpanWidget)
|
||||
display_type = forms.CharField(label=_("Type"), required=False, widget=SpanWidget)
|
||||
source = forms.ChoiceField(label=_("Source"), required=False)
|
||||
|
||||
class Meta:
|
||||
fields = (
|
||||
'bill_link', 'display_type', 'account_link', 'show_total', 'source'
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(SelectSourceForm, self).__init__(*args, **kwargs)
|
||||
bill = kwargs.get('instance')
|
||||
if bill:
|
||||
total = bill.compute_total()
|
||||
sources = bill.account.paymentsources.filter(is_active=True)
|
||||
recharge = bool(total < 0)
|
||||
choices = [(None, '-----------')]
|
||||
for source in sources:
|
||||
if not recharge or source.method_class().allow_recharge:
|
||||
choices.append((source.pk, str(source)))
|
||||
self.fields['source'].choices = choices
|
||||
self.fields['source'].initial = choices[-1][0]
|
||||
self.fields['show_total'].widget.display = total
|
||||
self.fields['bill_link'].widget.display = admin_link('__str__')(bill)
|
||||
self.fields['display_type'].widget.display = bill.get_type_display()
|
||||
self.fields['account_link'].widget.display = admin_link('account')(bill)
|
||||
|
||||
def clean_source(self):
|
||||
source_id = self.cleaned_data['source']
|
||||
if not source_id:
|
||||
return None
|
||||
source_model = self.instance.account.paymentsources.model
|
||||
return source_model.objects.get(id=source_id)
|
||||
|
||||
def has_changed(self):
|
||||
return False
|
||||
|
||||
def save(self, commit=True):
|
||||
pass
|
44
orchestra/contrib/bills/helpers.py
Normal file
44
orchestra/contrib/bills/helpers.py
Normal file
|
@ -0,0 +1,44 @@
|
|||
from django.contrib import messages
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.html import format_html
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.text import capfirst
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from orchestra.admin.utils import change_url
|
||||
|
||||
|
||||
def validate_contact(request, bill, error=True):
|
||||
""" checks if all the preconditions for bill generation are met """
|
||||
msg = _('{relation} account "{account}" does not have a declared invoice contact. '
|
||||
'You should <a href="{url}#invoicecontact-group">provide one</a>')
|
||||
valid = True
|
||||
send = messages.error if error else messages.warning
|
||||
if not hasattr(bill.account, 'billcontact'):
|
||||
account = force_str(bill.account)
|
||||
url = reverse('admin:accounts_account_change', args=(bill.account_id,))
|
||||
message = msg.format(relation=_("Related"), account=account, url=url)
|
||||
send(request, mark_safe(message))
|
||||
valid = False
|
||||
main = type(bill).account.field.related_model.objects.get_main()
|
||||
if not hasattr(main, 'billcontact'):
|
||||
account = force_str(main)
|
||||
url = reverse('admin:accounts_account_change', args=(main.id,))
|
||||
message = msg.format(relation=_("Main"), account=account, url=url)
|
||||
send(request, mark_safe(message))
|
||||
valid = False
|
||||
return valid
|
||||
|
||||
|
||||
def set_context_emails(modeladmin, request, queryset):
|
||||
opts = modeladmin.model._meta
|
||||
bills = []
|
||||
for bill in queryset:
|
||||
emails = ', '.join(bill.get_billing_contact_emails())
|
||||
bills.append(format_html('{0}: <a href="{1}">{2}</a> <i>{3}</i>',
|
||||
capfirst(opts.verbose_name), change_url(bill), bill, emails)
|
||||
)
|
||||
return {
|
||||
'display_objects': bills
|
||||
}
|
BIN
orchestra/contrib/bills/locale/ca/LC_MESSAGES/django.mo
Normal file
BIN
orchestra/contrib/bills/locale/ca/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
749
orchestra/contrib/bills/locale/ca/LC_MESSAGES/django.po
Normal file
749
orchestra/contrib/bills/locale/ca/LC_MESSAGES/django.po
Normal file
|
@ -0,0 +1,749 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-12-20 11:56+0100\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"Language: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#: actions.py:33
|
||||
msgid "View"
|
||||
msgstr "Vista"
|
||||
|
||||
#: actions.py:45
|
||||
msgid "Selected bills should be in open state"
|
||||
msgstr "Les factures seleccionades han d'estar en estat obert"
|
||||
|
||||
#: actions.py:60
|
||||
msgid "Selected bills have been closed"
|
||||
msgstr "Les factures seleccionades han estat tancades"
|
||||
|
||||
#: actions.py:73
|
||||
#, python-format
|
||||
msgid "<a href=\"%(url)s\">One related transaction</a> has been created"
|
||||
msgstr "S'ha creat una <a href=\"%(url)s\">transacció</a>"
|
||||
|
||||
#: actions.py:74
|
||||
#, python-format
|
||||
msgid "<a href=\"%(url)s\">%(num)i related transactions</a> have been created"
|
||||
msgstr "S'han creat les <a href=\"%(url)s\">%(num)i següents transaccions</a>"
|
||||
|
||||
#: actions.py:80
|
||||
msgid "Are you sure about closing the following bills?"
|
||||
msgstr "Estàs a punt de tancar les següents factures, estàs segur?"
|
||||
|
||||
#: actions.py:81
|
||||
msgid ""
|
||||
"Once a bill is closed it can not be further modified.</p><p>Please select a "
|
||||
"payment source for the selected bills"
|
||||
msgstr ""
|
||||
"Una vegada la factura estigui tancada no podrà ser modificada.</p><p>Si us "
|
||||
"plau selecciona un mètode de pagament per les factures seleccionades"
|
||||
|
||||
#: actions.py:97
|
||||
msgid "Close"
|
||||
msgstr "Tanca"
|
||||
|
||||
#: actions.py:115
|
||||
msgid "One bill has been sent."
|
||||
msgstr "S'ha creat una factura"
|
||||
|
||||
#: actions.py:116
|
||||
#, python-format
|
||||
msgid "%i bills have been sent."
|
||||
msgstr "S'han enviat %i factures."
|
||||
|
||||
#: actions.py:123
|
||||
msgid "Resend"
|
||||
msgstr "Reenviat"
|
||||
|
||||
#: actions.py:146
|
||||
msgid "Download"
|
||||
msgstr "Descarrega"
|
||||
|
||||
#: actions.py:162
|
||||
msgid "C.S.D."
|
||||
msgstr ""
|
||||
|
||||
#: actions.py:164
|
||||
msgid "Close, send and download bills in one shot."
|
||||
msgstr ""
|
||||
|
||||
#: actions.py:225
|
||||
#, python-format
|
||||
msgid "%(norders)s orders and %(nlines)s lines undoed."
|
||||
msgstr "%(norders)s ordres i %(nlines)s línies desfetes."
|
||||
|
||||
#: actions.py:244
|
||||
msgid "Lines moved"
|
||||
msgstr "Línies mogudes"
|
||||
|
||||
#: actions.py:257
|
||||
msgid "Selected bills should be in closed state"
|
||||
msgstr "Les factures seleccionades han d'estar en estat obert"
|
||||
|
||||
#: actions.py:259
|
||||
#, python-format
|
||||
msgid "%s can not be amended."
|
||||
msgstr ""
|
||||
|
||||
#: actions.py:279
|
||||
#, python-format
|
||||
msgid "%(type)s of %(related_type)s %(number)s and creation date %(date)s"
|
||||
msgstr "%(type)s de %(related_type)s %(number)s amb data de creació %(date)s"
|
||||
|
||||
#: actions.py:286
|
||||
#, python-format
|
||||
msgid "%(related_type)s %(number)s subtotal for tax %(tax)s%%"
|
||||
msgstr "%(related_type)s %(number)s subtotal %(tax)s%%"
|
||||
|
||||
#: actions.py:303
|
||||
#, python-format
|
||||
msgid "<a href=\"%(url)s\">One amendment bill</a> have been generated."
|
||||
msgstr "S'ha creat una <a href=\"%(url)s\">transacció</a>"
|
||||
|
||||
#: actions.py:304
|
||||
#, python-format
|
||||
msgid "<a href=\"%(url)s\">%(num)i amendment bills</a> have been generated."
|
||||
msgstr "S'han creat les <a href=\"%(url)s\">%(num)i següents transaccions</a>"
|
||||
|
||||
#: actions.py:307
|
||||
msgid "Amend"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:80 admin.py:126 admin.py:180 forms.py:11
|
||||
#: templates/admin/bills/bill/report.html:43
|
||||
#: templates/admin/bills/bill/report.html:70
|
||||
msgid "Total"
|
||||
msgstr "Total"
|
||||
|
||||
#: admin.py:112
|
||||
msgid "Description"
|
||||
msgstr "Descripció"
|
||||
|
||||
#: admin.py:120
|
||||
msgid "Subtotal"
|
||||
msgstr "Subtotal"
|
||||
|
||||
#: admin.py:146
|
||||
#, fuzzy
|
||||
#| msgid "Total"
|
||||
msgid "Totals"
|
||||
msgstr "Total"
|
||||
|
||||
#: admin.py:150
|
||||
msgid "Order"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:169
|
||||
msgid "Is open"
|
||||
msgstr "És oberta"
|
||||
|
||||
#: admin.py:175
|
||||
#, fuzzy
|
||||
#| msgid "Subline"
|
||||
msgid "Sublines"
|
||||
msgstr "Sublínia"
|
||||
|
||||
#: admin.py:221
|
||||
msgid "No bills selected."
|
||||
msgstr "No hi ha factures seleccionades"
|
||||
|
||||
#: admin.py:229
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Manage %s bill lines."
|
||||
msgid "Manage %s bill lines"
|
||||
msgstr "Gestiona %s línies de factura."
|
||||
|
||||
#: admin.py:231
|
||||
msgid "Bill not in open state."
|
||||
msgstr "La factura no està en estat obert"
|
||||
|
||||
#: admin.py:234
|
||||
msgid "Not all bills are in open state."
|
||||
msgstr "No totes les factures estan en estat obert"
|
||||
|
||||
#: admin.py:235
|
||||
#, fuzzy
|
||||
#| msgid "Manage bill lines of multiple bills."
|
||||
msgid "Manage bill lines of multiple bills"
|
||||
msgstr "Gestiona línies de factura de multiples factures."
|
||||
|
||||
#: admin.py:250
|
||||
#, python-format
|
||||
msgid "Subtotal %s%% VAT %s &%s;"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:251
|
||||
#, python-format
|
||||
msgid "Taxes %s%% VAT %s &%s;"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:255 admin.py:381 filters.py:46
|
||||
#: templates/bills/microspective.html:123
|
||||
msgid "total"
|
||||
msgstr "total"
|
||||
|
||||
#: admin.py:275
|
||||
msgid "This bill has been amended, this value may not be valid."
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:280
|
||||
msgid "Payment"
|
||||
msgstr "Pagament"
|
||||
|
||||
#: admin.py:304
|
||||
#, fuzzy
|
||||
#| msgid "amended line"
|
||||
msgid "Amends"
|
||||
msgstr "línia rectificada"
|
||||
|
||||
#: admin.py:330
|
||||
msgid "Dates"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:335
|
||||
msgid "Raw"
|
||||
msgstr "Raw"
|
||||
|
||||
#: admin.py:358 models.py:75
|
||||
msgid "Created"
|
||||
msgstr "Creada"
|
||||
|
||||
#: admin.py:359
|
||||
#, fuzzy
|
||||
#| msgid "Close"
|
||||
msgid "Closed"
|
||||
msgstr "Tanca"
|
||||
|
||||
#: admin.py:360
|
||||
#, fuzzy
|
||||
#| msgid "updated on"
|
||||
msgid "Updated"
|
||||
msgstr "actualitzada el"
|
||||
|
||||
#: admin.py:375
|
||||
msgid "lines"
|
||||
msgstr "línies"
|
||||
|
||||
#: admin.py:389 models.py:108 models.py:501
|
||||
msgid "type"
|
||||
msgstr "tipus"
|
||||
|
||||
#: filters.py:21
|
||||
msgid "All"
|
||||
msgstr "Tot"
|
||||
|
||||
#: filters.py:22 models.py:91
|
||||
msgid "Invoice"
|
||||
msgstr "Factura"
|
||||
|
||||
#: filters.py:23 models.py:93
|
||||
msgid "Fee"
|
||||
msgstr "Quota de soci"
|
||||
|
||||
#: filters.py:24
|
||||
msgid "Pro-forma"
|
||||
msgstr "Pro-forma"
|
||||
|
||||
#: filters.py:25
|
||||
msgid "Amendment fee"
|
||||
msgstr "Rectificació de quota de soci"
|
||||
|
||||
#: filters.py:26 models.py:92
|
||||
msgid "Amendment invoice"
|
||||
msgstr "Factura rectificativa"
|
||||
|
||||
#: filters.py:71
|
||||
msgid "has bill contact"
|
||||
msgstr "té contacte de facturació"
|
||||
|
||||
#: filters.py:76
|
||||
msgid "Yes"
|
||||
msgstr "Si"
|
||||
|
||||
#: filters.py:77
|
||||
msgid "No"
|
||||
msgstr "No"
|
||||
|
||||
#: filters.py:88
|
||||
msgid "payment state"
|
||||
msgstr "Pagament"
|
||||
|
||||
#: filters.py:93 models.py:74
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: filters.py:94 models.py:78
|
||||
msgid "Paid"
|
||||
msgstr "Pagat"
|
||||
|
||||
#: filters.py:95
|
||||
msgid "Pending"
|
||||
msgstr "Pendent"
|
||||
|
||||
#: filters.py:96 models.py:81
|
||||
msgid "Bad debt"
|
||||
msgstr "Incobrable"
|
||||
|
||||
#: filters.py:138
|
||||
#, fuzzy
|
||||
#| msgid "amended line"
|
||||
msgid "amended"
|
||||
msgstr "línia rectificada"
|
||||
|
||||
#: filters.py:143
|
||||
#, fuzzy
|
||||
#| msgid "Due date"
|
||||
msgid "Closed amends"
|
||||
msgstr "Data de pagament"
|
||||
|
||||
#: filters.py:144
|
||||
#, fuzzy
|
||||
#| msgid "Due date"
|
||||
msgid "Open amends"
|
||||
msgstr "Data de pagament"
|
||||
|
||||
#: filters.py:145
|
||||
#, fuzzy
|
||||
#| msgid "amended line"
|
||||
msgid "Any amends"
|
||||
msgstr "línia rectificada"
|
||||
|
||||
#: filters.py:146
|
||||
msgid "No amends"
|
||||
msgstr ""
|
||||
|
||||
#: forms.py:9 templates/admin/bills/bill/report.html:64
|
||||
msgid "Number"
|
||||
msgstr "Número"
|
||||
|
||||
#: forms.py:10
|
||||
msgid "Account"
|
||||
msgstr "Compte"
|
||||
|
||||
#: forms.py:12
|
||||
msgid "Type"
|
||||
msgstr "Tipus"
|
||||
|
||||
#: forms.py:13
|
||||
msgid "Source"
|
||||
msgstr "Font"
|
||||
|
||||
#: helpers.py:14
|
||||
msgid ""
|
||||
"{relation} account \"{account}\" does not have a declared invoice contact. "
|
||||
"You should <a href=\"{url}#invoicecontact-group\">provide one</a>"
|
||||
msgstr ""
|
||||
"{relation} compte \"{account}\" no te un contacte de facturació. Hauries de "
|
||||
"<a href=\"{url}#invoicecontact-group\">proporcionar un</a>"
|
||||
|
||||
#: helpers.py:21
|
||||
msgid "Related"
|
||||
msgstr "Relacionat"
|
||||
|
||||
#: helpers.py:28
|
||||
msgid "Main"
|
||||
msgstr "Principal"
|
||||
|
||||
#: models.py:26 models.py:104
|
||||
msgid "account"
|
||||
msgstr "compte"
|
||||
|
||||
#: models.py:28
|
||||
msgid "name"
|
||||
msgstr "nom"
|
||||
|
||||
#: models.py:29
|
||||
msgid "Account full name will be used when left blank."
|
||||
msgstr "S'emprarà el nom complet del compte quan es deixi en blanc."
|
||||
|
||||
#: models.py:30
|
||||
msgid "address"
|
||||
msgstr "adreça"
|
||||
|
||||
#: models.py:31
|
||||
msgid "city"
|
||||
msgstr "ciutat"
|
||||
|
||||
#: models.py:33
|
||||
msgid "zip code"
|
||||
msgstr "codi postal"
|
||||
|
||||
#: models.py:34
|
||||
msgid "Enter a valid zipcode."
|
||||
msgstr "Introdueix un codi postal vàlid."
|
||||
|
||||
#: models.py:35
|
||||
msgid "country"
|
||||
msgstr "país"
|
||||
|
||||
#: models.py:38 templates/admin/bills/bill/report.html:65
|
||||
msgid "VAT number"
|
||||
msgstr "NIF"
|
||||
|
||||
#: models.py:76
|
||||
msgid "Processed"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:77
|
||||
#, fuzzy
|
||||
#| msgid "amended line"
|
||||
msgid "Amended"
|
||||
msgstr "línia rectificada"
|
||||
|
||||
#: models.py:79
|
||||
msgid "Incomplete"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:80
|
||||
msgid "Executed"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:94
|
||||
msgid "Amendment Fee"
|
||||
msgstr "Rectificació de quota de soci"
|
||||
|
||||
#: models.py:95
|
||||
#, fuzzy
|
||||
#| msgid "Invoice"
|
||||
msgid "Abono Invoice"
|
||||
msgstr "Abonament"
|
||||
|
||||
#: models.py:96
|
||||
msgid "Pro forma"
|
||||
msgstr "Pro forma"
|
||||
|
||||
#: models.py:103
|
||||
msgid "number"
|
||||
msgstr "número"
|
||||
|
||||
#: models.py:106
|
||||
#, fuzzy
|
||||
#| msgid "amended line"
|
||||
msgid "amend of"
|
||||
msgstr "línia rectificada"
|
||||
|
||||
#: models.py:109
|
||||
msgid "created on"
|
||||
msgstr "creat el"
|
||||
|
||||
#: models.py:110
|
||||
msgid "closed on"
|
||||
msgstr "tancat el"
|
||||
|
||||
#: models.py:111
|
||||
msgid "open"
|
||||
msgstr "obert"
|
||||
|
||||
#: models.py:112
|
||||
msgid "sent"
|
||||
msgstr "enviat"
|
||||
|
||||
#: models.py:113
|
||||
msgid "due on"
|
||||
msgstr "es deu"
|
||||
|
||||
#: models.py:114
|
||||
msgid "updated on"
|
||||
msgstr "actualitzada el"
|
||||
|
||||
#: models.py:116
|
||||
msgid "comments"
|
||||
msgstr "comentaris"
|
||||
|
||||
#: models.py:117
|
||||
msgid "HTML"
|
||||
msgstr "HTML"
|
||||
|
||||
#: models.py:200
|
||||
#, python-format
|
||||
msgid "Type %s is not an amendment."
|
||||
msgstr ""
|
||||
|
||||
#: models.py:202
|
||||
msgid "Amend of related account doesn't match bill account."
|
||||
msgstr ""
|
||||
|
||||
#: models.py:204
|
||||
#, fuzzy
|
||||
#| msgid "Bill not in open state."
|
||||
msgid "Related invoice is in open state."
|
||||
msgstr "La factura no està en estat obert"
|
||||
|
||||
#: models.py:206
|
||||
msgid "Related invoice is an amendment."
|
||||
msgstr ""
|
||||
|
||||
#: models.py:419
|
||||
msgid "bill"
|
||||
msgstr "factura"
|
||||
|
||||
#: models.py:420 models.py:499 templates/bills/microspective.html:75
|
||||
msgid "description"
|
||||
msgstr "descripció"
|
||||
|
||||
#: models.py:421
|
||||
msgid "rate"
|
||||
msgstr "tarifa"
|
||||
|
||||
#: models.py:422
|
||||
msgid "quantity"
|
||||
msgstr "quantitat"
|
||||
|
||||
#: models.py:424
|
||||
#, fuzzy
|
||||
#| msgid "quantity"
|
||||
msgid "Verbose quantity"
|
||||
msgstr "quantitat"
|
||||
|
||||
#: models.py:425 templates/admin/bills/bill/report.html:47
|
||||
#: templates/bills/microspective.html:79
|
||||
#: templates/bills/microspective.html:116
|
||||
msgid "subtotal"
|
||||
msgstr "subtotal"
|
||||
|
||||
#: models.py:426
|
||||
msgid "tax"
|
||||
msgstr "impostos"
|
||||
|
||||
#: models.py:427
|
||||
msgid "start"
|
||||
msgstr "iniciar"
|
||||
|
||||
#: models.py:428
|
||||
msgid "end"
|
||||
msgstr "finalitzar"
|
||||
|
||||
#: models.py:431
|
||||
msgid "Informative link back to the order"
|
||||
msgstr "Enllaç informatiu de l'ordre"
|
||||
|
||||
#: models.py:432
|
||||
msgid "order billed"
|
||||
msgstr "ordre facturada"
|
||||
|
||||
#: models.py:433
|
||||
msgid "order billed until"
|
||||
msgstr "ordre facturada fins a"
|
||||
|
||||
#: models.py:434
|
||||
msgid "created"
|
||||
msgstr "creada"
|
||||
|
||||
#: models.py:436
|
||||
msgid "amended line"
|
||||
msgstr "línia rectificada"
|
||||
|
||||
#: models.py:492
|
||||
msgid "Volume"
|
||||
msgstr "Volum"
|
||||
|
||||
#: models.py:493
|
||||
msgid "Compensation"
|
||||
msgstr "Compensació"
|
||||
|
||||
#: models.py:494
|
||||
msgid "Other"
|
||||
msgstr "Altre"
|
||||
|
||||
#: models.py:498
|
||||
msgid "bill line"
|
||||
msgstr "línia de factura"
|
||||
|
||||
#: templates/admin/bills/bill/change_list.html:9
|
||||
#, fuzzy
|
||||
#| msgid "lines"
|
||||
msgid "Lines"
|
||||
msgstr "línies"
|
||||
|
||||
#: templates/admin/bills/bill/change_list.html:15
|
||||
#, fuzzy
|
||||
#| msgid "bill"
|
||||
msgid "Add bill"
|
||||
msgstr "factura"
|
||||
|
||||
#: templates/admin/bills/bill/close_send_download_bills.html:57
|
||||
msgid "Yes, I'm sure"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/bill/report.html:42
|
||||
msgid "Summary"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/bill/report.html:47
|
||||
#: templates/admin/bills/bill/report.html:51
|
||||
#: templates/admin/bills/bill/report.html:69
|
||||
#: templates/bills/microspective.html:116
|
||||
#: templates/bills/microspective.html:119
|
||||
msgid "VAT"
|
||||
msgstr "IVA"
|
||||
|
||||
#: templates/admin/bills/bill/report.html:51
|
||||
#: templates/bills/microspective.html:119
|
||||
msgid "taxes"
|
||||
msgstr "impostos"
|
||||
|
||||
#: templates/admin/bills/bill/report.html:56
|
||||
#: templates/admin/bills/billline/report.html:60
|
||||
#: templates/bills/microspective.html:54
|
||||
msgid "TOTAL"
|
||||
msgstr "TOTAL"
|
||||
|
||||
#: templates/admin/bills/bill/report.html:66
|
||||
msgid "Contact"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/bill/report.html:67
|
||||
#, fuzzy
|
||||
#| msgid "Due date"
|
||||
msgid "Close date"
|
||||
msgstr "Data de pagament"
|
||||
|
||||
#: templates/admin/bills/bill/report.html:68
|
||||
msgid "Base"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/change_list.html:6
|
||||
msgid "Home"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/change_list.html:8
|
||||
msgid "Bills"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/change_list.html:9
|
||||
msgid "Multiple bills"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/report.html:42
|
||||
msgid "Service"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/report.html:43
|
||||
msgid "Active"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/report.html:44
|
||||
msgid "Cancelled"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/report.html:45
|
||||
msgid "Nominal price"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/report.html:46
|
||||
#, fuzzy
|
||||
#| msgid "quantity"
|
||||
msgid "Quantity"
|
||||
msgstr "quantitat"
|
||||
|
||||
#: templates/admin/bills/billline/report.html:47
|
||||
msgid "Profit"
|
||||
msgstr ""
|
||||
|
||||
#: templates/bills/microspective-fee.html:115
|
||||
msgid "Due date"
|
||||
msgstr "Data de pagament"
|
||||
|
||||
#: templates/bills/microspective-fee.html:116
|
||||
#, python-format
|
||||
msgid "On %(bank_account)s"
|
||||
msgstr "Al %(bank_account)s"
|
||||
|
||||
#: templates/bills/microspective-fee.html:122
|
||||
#, python-format
|
||||
msgid "From %(ini)s to %(end)s"
|
||||
msgstr "De %(ini)s a %(end)s"
|
||||
|
||||
#: templates/bills/microspective-fee.html:144
|
||||
msgid ""
|
||||
"\n"
|
||||
"<strong>With your membership</strong> you are supporting ...\n"
|
||||
msgstr ""
|
||||
"\n"
|
||||
"<strong>Amb la teva quota de soci</strong> estàs donant suport ...\n"
|
||||
|
||||
#: templates/bills/microspective.html:50
|
||||
msgid "DUE DATE"
|
||||
msgstr "VENCIMENT"
|
||||
|
||||
#: templates/bills/microspective.html:58
|
||||
#, python-format
|
||||
msgid "%(bill_type)s DATE"
|
||||
msgstr "DATA %(bill_type)s"
|
||||
|
||||
#: templates/bills/microspective.html:76
|
||||
msgid "period"
|
||||
msgstr "període"
|
||||
|
||||
#: templates/bills/microspective.html:77
|
||||
msgid "hrs/qty"
|
||||
msgstr "hrs/qnt"
|
||||
|
||||
#: templates/bills/microspective.html:78
|
||||
msgid "rate/price"
|
||||
msgstr "tarifa/preu"
|
||||
|
||||
#: templates/bills/microspective.html:137
|
||||
msgid "COMMENTS"
|
||||
msgstr "COMENTARIS"
|
||||
|
||||
#: templates/bills/microspective.html:145
|
||||
msgid "PAYMENT"
|
||||
msgstr "PAGAMENT"
|
||||
|
||||
#: templates/bills/microspective.html:149
|
||||
#, python-format
|
||||
msgid ""
|
||||
"\n"
|
||||
" You can pay our <i>%(type)s</i> by bank transfer.<br>\n"
|
||||
" Please make sure to state your name and the <i>%(type)s</"
|
||||
"i> number.\n"
|
||||
" Our bank account number is <br>\n"
|
||||
" "
|
||||
msgstr ""
|
||||
"\n"
|
||||
"Pots pagar aquesta <i>%(type)s</i> per transferència bancaria.<br>Inclou el "
|
||||
"teu nom i el número de <i>%(type)s</i>. El nostre compte bancari és"
|
||||
|
||||
#: templates/bills/microspective.html:160
|
||||
msgid "QUESTIONS"
|
||||
msgstr "PREGUNTES"
|
||||
|
||||
#: templates/bills/microspective.html:161
|
||||
#, python-format
|
||||
msgid ""
|
||||
"\n"
|
||||
" If you have any question about your <i>%(type)s</i>, please\n"
|
||||
" feel free to write us at %(email)s. We will reply as soon as we "
|
||||
"get\n"
|
||||
" your message.\n"
|
||||
" "
|
||||
msgstr ""
|
||||
"\n"
|
||||
" Si tens algun dubte o pregunta sobre la teva <i>%(type)s</i>, si "
|
||||
"us plau\n"
|
||||
" contacta amb nosaltres a %(email)s. Et respondrem el més "
|
||||
"ràpidament possible.\n"
|
||||
" "
|
||||
|
||||
#, fuzzy
|
||||
#~| msgid "closed on"
|
||||
#~ msgid "No closed amends"
|
||||
#~ msgstr "tancat el"
|
||||
|
||||
#~ msgid "positive price"
|
||||
#~ msgstr "preu positiu"
|
BIN
orchestra/contrib/bills/locale/es/LC_MESSAGES/django.mo
Normal file
BIN
orchestra/contrib/bills/locale/es/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
728
orchestra/contrib/bills/locale/es/LC_MESSAGES/django.po
Normal file
728
orchestra/contrib/bills/locale/es/LC_MESSAGES/django.po
Normal file
|
@ -0,0 +1,728 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-12-20 11:56+0100\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"Language: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#: actions.py:33
|
||||
msgid "View"
|
||||
msgstr "Vista"
|
||||
|
||||
#: actions.py:45
|
||||
msgid "Selected bills should be in open state"
|
||||
msgstr "Las facturas seleccionadas están en estado abierto"
|
||||
|
||||
#: actions.py:60
|
||||
msgid "Selected bills have been closed"
|
||||
msgstr "Las facturas seleccionadas han sido cerradas"
|
||||
|
||||
#: actions.py:73
|
||||
#, python-format
|
||||
msgid "<a href=\"%(url)s\">One related transaction</a> has been created"
|
||||
msgstr "Se ha creado una <a href=\"%(url)s\">transacción</a>"
|
||||
|
||||
#: actions.py:74
|
||||
#, python-format
|
||||
msgid "<a href=\"%(url)s\">%(num)i related transactions</a> have been created"
|
||||
msgstr "Se han creado <a href=\"%(url)s\">%(num)i transacciones</a>"
|
||||
|
||||
#: actions.py:80
|
||||
msgid "Are you sure about closing the following bills?"
|
||||
msgstr "Estás a punto de cerrar las sigüientes facturas. ¿Estás seguro?"
|
||||
|
||||
#: actions.py:81
|
||||
msgid ""
|
||||
"Once a bill is closed it can not be further modified.</p><p>Please select a "
|
||||
"payment source for the selected bills"
|
||||
msgstr ""
|
||||
"Una vez cerrada la factura ya no se podrá modificar.</p><p>Por favor "
|
||||
"seleciona un metodo de pago para las facturas seleccionadas"
|
||||
|
||||
#: actions.py:97
|
||||
msgid "Close"
|
||||
msgstr "Cerrar"
|
||||
|
||||
#: actions.py:115
|
||||
msgid "One bill has been sent."
|
||||
msgstr "Se ha enviado una factura"
|
||||
|
||||
#: actions.py:116
|
||||
#, python-format
|
||||
msgid "%i bills have been sent."
|
||||
msgstr ""
|
||||
|
||||
#: actions.py:123
|
||||
msgid "Resend"
|
||||
msgstr ""
|
||||
|
||||
#: actions.py:146
|
||||
msgid "Download"
|
||||
msgstr "Descarga"
|
||||
|
||||
#: actions.py:162
|
||||
msgid "C.S.D."
|
||||
msgstr ""
|
||||
|
||||
#: actions.py:164
|
||||
msgid "Close, send and download bills in one shot."
|
||||
msgstr ""
|
||||
|
||||
#: actions.py:225
|
||||
#, python-format
|
||||
msgid "%(norders)s orders and %(nlines)s lines undoed."
|
||||
msgstr ""
|
||||
|
||||
#: actions.py:244
|
||||
msgid "Lines moved"
|
||||
msgstr ""
|
||||
|
||||
#: actions.py:257
|
||||
msgid "Selected bills should be in closed state"
|
||||
msgstr "Las facturas seleccionadas están en estado abierto"
|
||||
|
||||
#: actions.py:259
|
||||
#, python-format
|
||||
msgid "%s can not be amended."
|
||||
msgstr ""
|
||||
|
||||
#: actions.py:279
|
||||
#, python-format
|
||||
msgid "%(type)s of %(related_type)s %(number)s and creation date %(date)s"
|
||||
msgstr "%(type)s de %(related_type)s %(number)s con fecha de creación %(date)s"
|
||||
|
||||
#: actions.py:286
|
||||
#, python-format
|
||||
msgid "%(related_type)s %(number)s subtotal for tax %(tax)s%%"
|
||||
msgstr "%(related_type)s %(number)s subtotal %(tax)s%%"
|
||||
|
||||
#: actions.py:303
|
||||
#, python-format
|
||||
msgid "<a href=\"%(url)s\">One amendment bill</a> have been generated."
|
||||
msgstr "Se ha creado una <a href=\"%(url)s\">transacción</a>"
|
||||
|
||||
#: actions.py:304
|
||||
#, python-format
|
||||
msgid "<a href=\"%(url)s\">%(num)i amendment bills</a> have been generated."
|
||||
msgstr "Se han creado <a href=\"%(url)s\">%(num)i transacciones</a>"
|
||||
|
||||
#: actions.py:307
|
||||
msgid "Amend"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:80 admin.py:126 admin.py:180 forms.py:11
|
||||
#: templates/admin/bills/bill/report.html:43
|
||||
#: templates/admin/bills/bill/report.html:70
|
||||
msgid "Total"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:112
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:120
|
||||
msgid "Subtotal"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:146
|
||||
msgid "Totals"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:150
|
||||
msgid "Order"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:169
|
||||
msgid "Is open"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:175
|
||||
msgid "Sublines"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:221
|
||||
msgid "No bills selected."
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:229
|
||||
#, fuzzy, python-format
|
||||
#| msgid "bill line"
|
||||
msgid "Manage %s bill lines"
|
||||
msgstr "linea de factura"
|
||||
|
||||
#: admin.py:231
|
||||
msgid "Bill not in open state."
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:234
|
||||
msgid "Not all bills are in open state."
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:235
|
||||
msgid "Manage bill lines of multiple bills"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:250
|
||||
#, python-format
|
||||
msgid "Subtotal %s%% VAT %s &%s;"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:251
|
||||
#, python-format
|
||||
msgid "Taxes %s%% VAT %s &%s;"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:255 admin.py:381 filters.py:46
|
||||
#: templates/bills/microspective.html:123
|
||||
msgid "total"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:275
|
||||
msgid "This bill has been amended, this value may not be valid."
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:280
|
||||
msgid "Payment"
|
||||
msgstr "Pago"
|
||||
|
||||
#: admin.py:304
|
||||
#, fuzzy
|
||||
#| msgid "Amended"
|
||||
msgid "Amends"
|
||||
msgstr "Quota rectificativa"
|
||||
|
||||
#: admin.py:330
|
||||
msgid "Dates"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:335
|
||||
msgid "Raw"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:358 models.py:75
|
||||
msgid "Created"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:359
|
||||
#, fuzzy
|
||||
#| msgid "Close"
|
||||
msgid "Closed"
|
||||
msgstr "Cerrar"
|
||||
|
||||
#: admin.py:360
|
||||
#, fuzzy
|
||||
#| msgid "updated on"
|
||||
msgid "Updated"
|
||||
msgstr "actualizada en"
|
||||
|
||||
#: admin.py:375
|
||||
msgid "lines"
|
||||
msgstr ""
|
||||
|
||||
#: admin.py:389 models.py:108 models.py:501
|
||||
msgid "type"
|
||||
msgstr ""
|
||||
|
||||
#: filters.py:21
|
||||
msgid "All"
|
||||
msgstr ""
|
||||
|
||||
#: filters.py:22 models.py:91
|
||||
msgid "Invoice"
|
||||
msgstr "Factura"
|
||||
|
||||
#: filters.py:23 models.py:93
|
||||
msgid "Fee"
|
||||
msgstr "Cuota de socio"
|
||||
|
||||
#: filters.py:24
|
||||
msgid "Pro-forma"
|
||||
msgstr ""
|
||||
|
||||
#: filters.py:25
|
||||
msgid "Amendment fee"
|
||||
msgstr "Cuota rectificativa"
|
||||
|
||||
#: filters.py:26 models.py:92
|
||||
msgid "Amendment invoice"
|
||||
msgstr "Factura rectificativa"
|
||||
|
||||
#: filters.py:71
|
||||
msgid "has bill contact"
|
||||
msgstr ""
|
||||
|
||||
#: filters.py:76
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
#: filters.py:77
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
|
||||
#: filters.py:88
|
||||
msgid "payment state"
|
||||
msgstr "Pago"
|
||||
|
||||
#: filters.py:93 models.py:74
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: filters.py:94 models.py:78
|
||||
msgid "Paid"
|
||||
msgstr ""
|
||||
|
||||
#: filters.py:95
|
||||
msgid "Pending"
|
||||
msgstr ""
|
||||
|
||||
#: filters.py:96 models.py:81
|
||||
msgid "Bad debt"
|
||||
msgstr ""
|
||||
|
||||
#: filters.py:138
|
||||
#, fuzzy
|
||||
#| msgid "Amended"
|
||||
msgid "amended"
|
||||
msgstr "Quota rectificativa"
|
||||
|
||||
#: filters.py:143
|
||||
#, fuzzy
|
||||
#| msgid "Due date"
|
||||
msgid "Closed amends"
|
||||
msgstr "Fecha de pago"
|
||||
|
||||
#: filters.py:144
|
||||
#, fuzzy
|
||||
#| msgid "Due date"
|
||||
msgid "Open amends"
|
||||
msgstr "Fecha de pago"
|
||||
|
||||
#: filters.py:145
|
||||
#, fuzzy
|
||||
#| msgid "Amended"
|
||||
msgid "Any amends"
|
||||
msgstr "Quota rectificativa"
|
||||
|
||||
#: filters.py:146
|
||||
msgid "No amends"
|
||||
msgstr ""
|
||||
|
||||
#: forms.py:9 templates/admin/bills/bill/report.html:64
|
||||
msgid "Number"
|
||||
msgstr ""
|
||||
|
||||
#: forms.py:10
|
||||
msgid "Account"
|
||||
msgstr ""
|
||||
|
||||
#: forms.py:12
|
||||
msgid "Type"
|
||||
msgstr ""
|
||||
|
||||
#: forms.py:13
|
||||
msgid "Source"
|
||||
msgstr ""
|
||||
|
||||
#: helpers.py:14
|
||||
msgid ""
|
||||
"{relation} account \"{account}\" does not have a declared invoice contact. "
|
||||
"You should <a href=\"{url}#invoicecontact-group\">provide one</a>"
|
||||
msgstr ""
|
||||
|
||||
#: helpers.py:21
|
||||
msgid "Related"
|
||||
msgstr ""
|
||||
|
||||
#: helpers.py:28
|
||||
msgid "Main"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:26 models.py:104
|
||||
msgid "account"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:28
|
||||
msgid "name"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:29
|
||||
msgid "Account full name will be used when left blank."
|
||||
msgstr ""
|
||||
|
||||
#: models.py:30
|
||||
msgid "address"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:31
|
||||
msgid "city"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:33
|
||||
msgid "zip code"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:34
|
||||
msgid "Enter a valid zipcode."
|
||||
msgstr ""
|
||||
|
||||
#: models.py:35
|
||||
msgid "country"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:38 templates/admin/bills/bill/report.html:65
|
||||
msgid "VAT number"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:76
|
||||
msgid "Processed"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:77
|
||||
msgid "Amended"
|
||||
msgstr "Quota rectificativa"
|
||||
|
||||
#: models.py:79
|
||||
msgid "Incomplete"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:80
|
||||
msgid "Executed"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:94
|
||||
msgid "Amendment Fee"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:95
|
||||
#, fuzzy
|
||||
#| msgid "Invoice"
|
||||
msgid "Abono Invoice"
|
||||
msgstr "Abono"
|
||||
|
||||
#: models.py:96
|
||||
msgid "Pro forma"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:103
|
||||
msgid "number"
|
||||
msgstr "número"
|
||||
|
||||
#: models.py:106
|
||||
msgid "amend of"
|
||||
msgstr "rectificación de"
|
||||
|
||||
#: models.py:109
|
||||
msgid "created on"
|
||||
msgstr "creado en"
|
||||
|
||||
#: models.py:110
|
||||
msgid "closed on"
|
||||
msgstr "cerrada en"
|
||||
|
||||
#: models.py:111
|
||||
msgid "open"
|
||||
msgstr "abierta"
|
||||
|
||||
#: models.py:112
|
||||
msgid "sent"
|
||||
msgstr "enviada"
|
||||
|
||||
#: models.py:113
|
||||
msgid "due on"
|
||||
msgstr "vencimiento"
|
||||
|
||||
#: models.py:114
|
||||
msgid "updated on"
|
||||
msgstr "actualizada en"
|
||||
|
||||
#: models.py:116
|
||||
msgid "comments"
|
||||
msgstr "comentarios"
|
||||
|
||||
#: models.py:117
|
||||
msgid "HTML"
|
||||
msgstr "HTML"
|
||||
|
||||
#: models.py:200
|
||||
#, python-format
|
||||
msgid "Type %s is not an amendment."
|
||||
msgstr ""
|
||||
|
||||
#: models.py:202
|
||||
msgid "Amend of related account doesn't match bill account."
|
||||
msgstr ""
|
||||
|
||||
#: models.py:204
|
||||
#, fuzzy
|
||||
#| msgid "Selected bills should be in open state"
|
||||
msgid "Related invoice is in open state."
|
||||
msgstr "Las facturas seleccionadas están en estado abierto"
|
||||
|
||||
#: models.py:206
|
||||
msgid "Related invoice is an amendment."
|
||||
msgstr ""
|
||||
|
||||
#: models.py:419
|
||||
msgid "bill"
|
||||
msgstr "factura"
|
||||
|
||||
#: models.py:420 models.py:499 templates/bills/microspective.html:75
|
||||
msgid "description"
|
||||
msgstr "descripción"
|
||||
|
||||
#: models.py:421
|
||||
msgid "rate"
|
||||
msgstr "tarifa"
|
||||
|
||||
#: models.py:422
|
||||
msgid "quantity"
|
||||
msgstr "cantidad"
|
||||
|
||||
#: models.py:424
|
||||
msgid "Verbose quantity"
|
||||
msgstr "Cantidad"
|
||||
|
||||
#: models.py:425 templates/admin/bills/bill/report.html:47
|
||||
#: templates/bills/microspective.html:79
|
||||
#: templates/bills/microspective.html:116
|
||||
msgid "subtotal"
|
||||
msgstr "subtotal"
|
||||
|
||||
#: models.py:426
|
||||
msgid "tax"
|
||||
msgstr "impuesto"
|
||||
|
||||
#: models.py:427
|
||||
msgid "start"
|
||||
msgstr "inicio"
|
||||
|
||||
#: models.py:428
|
||||
msgid "end"
|
||||
msgstr "fín"
|
||||
|
||||
#: models.py:431
|
||||
msgid "Informative link back to the order"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:432
|
||||
msgid "order billed"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:433
|
||||
msgid "order billed until"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:434
|
||||
msgid "created"
|
||||
msgstr "creado"
|
||||
|
||||
#: models.py:436
|
||||
msgid "amended line"
|
||||
msgstr "linea rectificativa"
|
||||
|
||||
#: models.py:492
|
||||
msgid "Volume"
|
||||
msgstr "Volumen"
|
||||
|
||||
#: models.py:493
|
||||
msgid "Compensation"
|
||||
msgstr "Compensación"
|
||||
|
||||
#: models.py:494
|
||||
msgid "Other"
|
||||
msgstr "Otro"
|
||||
|
||||
#: models.py:498
|
||||
msgid "bill line"
|
||||
msgstr "linea de factura"
|
||||
|
||||
#: templates/admin/bills/bill/change_list.html:9
|
||||
msgid "Lines"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/bill/change_list.html:15
|
||||
#, fuzzy
|
||||
#| msgid "bill"
|
||||
msgid "Add bill"
|
||||
msgstr "factura"
|
||||
|
||||
#: templates/admin/bills/bill/close_send_download_bills.html:57
|
||||
msgid "Yes, I'm sure"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/bill/report.html:42
|
||||
msgid "Summary"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/bill/report.html:47
|
||||
#: templates/admin/bills/bill/report.html:51
|
||||
#: templates/admin/bills/bill/report.html:69
|
||||
#: templates/bills/microspective.html:116
|
||||
#: templates/bills/microspective.html:119
|
||||
msgid "VAT"
|
||||
msgstr "IVA"
|
||||
|
||||
#: templates/admin/bills/bill/report.html:51
|
||||
#: templates/bills/microspective.html:119
|
||||
msgid "taxes"
|
||||
msgstr "impuestos"
|
||||
|
||||
#: templates/admin/bills/bill/report.html:56
|
||||
#: templates/admin/bills/billline/report.html:60
|
||||
#: templates/bills/microspective.html:54
|
||||
msgid "TOTAL"
|
||||
msgstr "TOTAL"
|
||||
|
||||
#: templates/admin/bills/bill/report.html:66
|
||||
msgid "Contact"
|
||||
msgstr "Contacto"
|
||||
|
||||
#: templates/admin/bills/bill/report.html:67
|
||||
#, fuzzy
|
||||
#| msgid "Due date"
|
||||
msgid "Close date"
|
||||
msgstr "Fecha de pago"
|
||||
|
||||
#: templates/admin/bills/bill/report.html:68
|
||||
msgid "Base"
|
||||
msgstr "Base"
|
||||
|
||||
#: templates/admin/bills/billline/change_list.html:6
|
||||
msgid "Home"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/change_list.html:8
|
||||
msgid "Bills"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/change_list.html:9
|
||||
msgid "Multiple bills"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/report.html:42
|
||||
msgid "Service"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/report.html:43
|
||||
msgid "Active"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/report.html:44
|
||||
msgid "Cancelled"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/report.html:45
|
||||
msgid "Nominal price"
|
||||
msgstr ""
|
||||
|
||||
#: templates/admin/bills/billline/report.html:46
|
||||
#, fuzzy
|
||||
#| msgid "quantity"
|
||||
msgid "Quantity"
|
||||
msgstr "cantidad"
|
||||
|
||||
#: templates/admin/bills/billline/report.html:47
|
||||
msgid "Profit"
|
||||
msgstr ""
|
||||
|
||||
#: templates/bills/microspective-fee.html:115
|
||||
msgid "Due date"
|
||||
msgstr "Fecha de pago"
|
||||
|
||||
#: templates/bills/microspective-fee.html:116
|
||||
#, python-format
|
||||
msgid "On %(bank_account)s"
|
||||
msgstr "En %(bank_account)s"
|
||||
|
||||
#: templates/bills/microspective-fee.html:122
|
||||
#, python-format
|
||||
msgid "From %(ini)s to %(end)s"
|
||||
msgstr "Desde %(ini)s hasta %(end)s"
|
||||
|
||||
#: templates/bills/microspective-fee.html:144
|
||||
msgid ""
|
||||
"\n"
|
||||
"<strong>With your membership</strong> you are supporting ...\n"
|
||||
msgstr ""
|
||||
|
||||
#: templates/bills/microspective.html:50
|
||||
msgid "DUE DATE"
|
||||
msgstr "VENCIMIENTO"
|
||||
|
||||
#: templates/bills/microspective.html:58
|
||||
#, python-format
|
||||
msgid "%(bill_type)s DATE"
|
||||
msgstr "FECHA %(bill_type)s"
|
||||
|
||||
#: templates/bills/microspective.html:76
|
||||
msgid "period"
|
||||
msgstr "periodo"
|
||||
|
||||
#: templates/bills/microspective.html:77
|
||||
msgid "hrs/qty"
|
||||
msgstr "hrs/cant"
|
||||
|
||||
#: templates/bills/microspective.html:78
|
||||
msgid "rate/price"
|
||||
msgstr "tarifa/precio"
|
||||
|
||||
#: templates/bills/microspective.html:137
|
||||
msgid "COMMENTS"
|
||||
msgstr "COMENTARIOS"
|
||||
|
||||
#: templates/bills/microspective.html:145
|
||||
msgid "PAYMENT"
|
||||
msgstr "PAGO"
|
||||
|
||||
#: templates/bills/microspective.html:149
|
||||
#, python-format
|
||||
msgid ""
|
||||
"\n"
|
||||
" You can pay our <i>%(type)s</i> by bank transfer.<br>\n"
|
||||
" Please make sure to state your name and the <i>%(type)s</"
|
||||
"i> number.\n"
|
||||
" Our bank account number is <br>\n"
|
||||
" "
|
||||
msgstr ""
|
||||
"\n"
|
||||
"Puedes pagar esta <i>%(type)s</i> por transferencia bancaria.<br>Incluye tu "
|
||||
"nombre y el número de <i>%(type)s</i>. Nuestra cuenta bancaria es"
|
||||
|
||||
#: templates/bills/microspective.html:160
|
||||
msgid "QUESTIONS"
|
||||
msgstr "PREGUNTAS"
|
||||
|
||||
#: templates/bills/microspective.html:161
|
||||
#, python-format
|
||||
msgid ""
|
||||
"\n"
|
||||
" If you have any question about your <i>%(type)s</i>, please\n"
|
||||
" feel free to write us at %(email)s. We will reply as soon as we "
|
||||
"get\n"
|
||||
" your message.\n"
|
||||
" "
|
||||
msgstr ""
|
||||
"\n"
|
||||
" Si tienes alguna duda o pregunta sobre tu <i>%(type)s</i>, por "
|
||||
"favor\n"
|
||||
" contacta con nosotros en %(email)s. Te responderemos lo más "
|
||||
"rapidamente posible.\n"
|
||||
" "
|
||||
|
||||
#, fuzzy
|
||||
#~| msgid "closed on"
|
||||
#~ msgid "No closed amends"
|
||||
#~ msgstr "cerrada en"
|
504
orchestra/contrib/bills/models.py
Normal file
504
orchestra/contrib/bills/models.py
Normal file
|
@ -0,0 +1,504 @@
|
|||
import datetime
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from django.urls import reverse
|
||||
from django.core.validators import ValidationError, RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models import F, Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.template import loader
|
||||
from django.utils import timezone, translation
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from orchestra.admin.utils import change_url
|
||||
from orchestra.contrib.accounts.models import Account
|
||||
from orchestra.contrib.contacts.models import Contact
|
||||
from orchestra.core import validators
|
||||
from orchestra.utils.functional import cached
|
||||
from orchestra.utils.html import html_to_pdf
|
||||
|
||||
from . import settings
|
||||
|
||||
|
||||
class BillContact(models.Model):
|
||||
account = models.OneToOneField('accounts.Account', verbose_name=_("account"),
|
||||
related_name='billcontact', on_delete=models.CASCADE)
|
||||
name = models.CharField(_("name"), max_length=256, blank=True,
|
||||
help_text=_("Account full name will be used when left blank."))
|
||||
address = models.TextField(_("address"))
|
||||
city = models.CharField(_("city"), max_length=128,
|
||||
default=settings.BILLS_CONTACT_DEFAULT_CITY)
|
||||
zipcode = models.CharField(_("zip code"), max_length=10,
|
||||
validators=[RegexValidator(r'^[0-9A-Z]{3,10}$', _("Enter a valid zipcode."))])
|
||||
country = models.CharField(_("country"), max_length=20,
|
||||
choices=settings.BILLS_CONTACT_COUNTRIES,
|
||||
default=settings.BILLS_CONTACT_DEFAULT_COUNTRY)
|
||||
vat = models.CharField(_("VAT number"), max_length=64)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_name(self):
|
||||
return self.name or self.account.get_full_name()
|
||||
|
||||
def clean(self):
|
||||
self.vat = self.vat.strip()
|
||||
self.city = self.city.strip()
|
||||
validators.all_valid({
|
||||
'vat': (validators.validate_vat, self.vat, self.country),
|
||||
'zipcode': (validators.validate_zipcode, self.zipcode, self.country)
|
||||
})
|
||||
|
||||
|
||||
class BillManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
queryset = super(BillManager, self).get_queryset()
|
||||
if self.model != Bill:
|
||||
bill_type = self.model.get_class_type()
|
||||
queryset = queryset.filter(type=bill_type)
|
||||
return queryset
|
||||
|
||||
|
||||
class Bill(models.Model):
|
||||
OPEN = ''
|
||||
CREATED = 'CREATED'
|
||||
PROCESSED = 'PROCESSED'
|
||||
AMENDED = 'AMENDED'
|
||||
PAID = 'PAID'
|
||||
EXECUTED = 'EXECUTED'
|
||||
BAD_DEBT = 'BAD_DEBT'
|
||||
INCOMPLETE = 'INCOMPLETE'
|
||||
PAYMENT_STATES = (
|
||||
(OPEN, _("Open")),
|
||||
(CREATED, _("Created")),
|
||||
(PROCESSED, _("Processed")),
|
||||
(AMENDED, _("Amended")),
|
||||
(PAID, _("Paid")),
|
||||
(INCOMPLETE, _('Incomplete')),
|
||||
(EXECUTED, _("Executed")),
|
||||
(BAD_DEBT, _("Bad debt")),
|
||||
)
|
||||
BILL = 'BILL'
|
||||
INVOICE = 'INVOICE'
|
||||
AMENDMENTINVOICE = 'AMENDMENTINVOICE'
|
||||
FEE = 'FEE'
|
||||
AMENDMENTFEE = 'AMENDMENTFEE'
|
||||
PROFORMA = 'PROFORMA'
|
||||
ABONOINVOICE = 'ABONOINVOICE'
|
||||
TYPES = (
|
||||
(INVOICE, _("Invoice")),
|
||||
(AMENDMENTINVOICE, _("Amendment invoice")),
|
||||
(FEE, _("Fee")),
|
||||
(AMENDMENTFEE, _("Amendment Fee")),
|
||||
(ABONOINVOICE, _("Abono Invoice")),
|
||||
(PROFORMA, _("Pro forma")),
|
||||
)
|
||||
AMEND_MAP = {
|
||||
INVOICE: AMENDMENTINVOICE,
|
||||
FEE: AMENDMENTFEE,
|
||||
}
|
||||
|
||||
number = models.CharField(_("number"), max_length=16, unique=True, blank=True)
|
||||
account = models.ForeignKey('accounts.Account', verbose_name=_("account"),
|
||||
related_name='%(class)s', on_delete=models.CASCADE)
|
||||
amend_of = models.ForeignKey('self', null=True, blank=True, verbose_name=_("amend of"),
|
||||
related_name='amends', on_delete=models.SET_NULL)
|
||||
type = models.CharField(_("type"), max_length=16, choices=TYPES)
|
||||
created_on = models.DateField(_("created on"), auto_now_add=True)
|
||||
closed_on = models.DateField(_("closed on"), blank=True, null=True, db_index=True)
|
||||
is_open = models.BooleanField(_("open"), default=True)
|
||||
is_sent = models.BooleanField(_("sent"), default=False)
|
||||
due_on = models.DateField(_("due on"), null=True, blank=True)
|
||||
updated_on = models.DateField(_("updated on"), auto_now=True)
|
||||
# total = models.DecimalField(max_digits=12, decimal_places=2, null=True)
|
||||
comments = models.TextField(_("comments"), blank=True)
|
||||
html = models.TextField(_("HTML"), blank=True)
|
||||
|
||||
objects = BillManager()
|
||||
|
||||
class Meta:
|
||||
get_latest_by = 'id'
|
||||
|
||||
def __str__(self):
|
||||
return self.number
|
||||
|
||||
@classmethod
|
||||
def get_class_type(cls):
|
||||
if cls is models.DEFERRED:
|
||||
cls = cls.__base__
|
||||
return cls.__name__.upper()
|
||||
|
||||
@cached_property
|
||||
def total(self):
|
||||
return self.compute_total()
|
||||
|
||||
@cached_property
|
||||
def seller(self):
|
||||
return Account.objects.get_main().billcontact
|
||||
|
||||
@cached_property
|
||||
def buyer(self):
|
||||
return self.account.billcontact
|
||||
|
||||
@property
|
||||
def has_multiple_pages(self):
|
||||
return self.type != self.FEE
|
||||
|
||||
@cached_property
|
||||
def payment_state(self):
|
||||
if self.is_open or self.get_type() == self.PROFORMA:
|
||||
return self.OPEN
|
||||
secured = 0
|
||||
pending = 0
|
||||
created = False
|
||||
processed = False
|
||||
executed = False
|
||||
rejected = False
|
||||
for transaction in self.transactions.all():
|
||||
if transaction.state == transaction.SECURED:
|
||||
secured += transaction.amount
|
||||
pending += transaction.amount
|
||||
elif transaction.state == transaction.WAITTING_PROCESSING:
|
||||
pending += transaction.amount
|
||||
created = True
|
||||
elif transaction.state == transaction.WAITTING_EXECUTION:
|
||||
pending += transaction.amount
|
||||
processed = True
|
||||
elif transaction.state == transaction.EXECUTED:
|
||||
pending += transaction.amount
|
||||
executed = True
|
||||
elif transaction.state == transaction.REJECTED:
|
||||
rejected = True
|
||||
else:
|
||||
raise TypeError("Unknown state")
|
||||
ongoing = bool(secured != 0 or created or processed or executed)
|
||||
total = self.compute_total()
|
||||
if total >= 0:
|
||||
if secured >= total:
|
||||
return self.PAID
|
||||
elif ongoing and pending < total:
|
||||
return self.INCOMPLETE
|
||||
else:
|
||||
if secured <= total:
|
||||
return self.PAID
|
||||
elif ongoing and pending > total:
|
||||
return self.INCOMPLETE
|
||||
if created:
|
||||
return self.CREATED
|
||||
elif processed:
|
||||
return self.PROCESSED
|
||||
elif executed:
|
||||
return self.EXECUTED
|
||||
return self.BAD_DEBT
|
||||
|
||||
def clean(self):
|
||||
if self.amend_of_id:
|
||||
errors = {}
|
||||
if self.type not in self.AMEND_MAP.values():
|
||||
errors['amend_of'] = _("Type %s is not an amendment.") % self.get_type_display()
|
||||
if self.amend_of.account_id != self.account_id:
|
||||
errors['account'] = _("Amend of related account doesn't match bill account.")
|
||||
if self.amend_of.is_open:
|
||||
errors['amend_of'] = _("Related invoice is in open state.")
|
||||
if self.amend_of.type in self.AMEND_MAP.values():
|
||||
errors['amend_of'] = _("Related invoice is an amendment.")
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
def get_payment_state_display(self):
|
||||
value = self.payment_state
|
||||
return force_str(dict(self.PAYMENT_STATES).get(value, value))
|
||||
|
||||
def get_current_transaction(self):
|
||||
return self.transactions.exclude_rejected().first()
|
||||
|
||||
def get_type(self):
|
||||
return self.type or self.get_class_type()
|
||||
|
||||
@property
|
||||
def is_amend(self):
|
||||
return self.type in self.AMEND_MAP.values()
|
||||
|
||||
def get_amend_type(self):
|
||||
amend_type = self.AMEND_MAP.get(self.type)
|
||||
if amend_type is None:
|
||||
raise TypeError("%s has no associated amend type." % self.type)
|
||||
return amend_type
|
||||
|
||||
def get_number(self):
|
||||
cls = type(self)
|
||||
if cls is models.DEFERRED:
|
||||
cls = cls.__base__
|
||||
bill_type = self.get_type()
|
||||
if bill_type == self.BILL:
|
||||
raise TypeError('This method can not be used on BILL instances')
|
||||
bill_type = bill_type.replace('AMENDMENT', 'AMENDMENT_')
|
||||
prefix = getattr(settings, 'BILLS_%s_NUMBER_PREFIX' % bill_type)
|
||||
if self.is_open:
|
||||
prefix = 'O{}'.format(prefix)
|
||||
year = timezone.now().strftime("%Y")
|
||||
bills = cls.objects.filter(number__regex=r'^%s%s[0-9]+' % (prefix, year))
|
||||
last_number = bills.order_by('-number').values_list('number', flat=True).first()
|
||||
if last_number is None:
|
||||
last_number = 0
|
||||
else:
|
||||
last_number = int(last_number[len(prefix)+4:])
|
||||
number = last_number + 1
|
||||
number_length = settings.BILLS_NUMBER_LENGTH
|
||||
zeros = (number_length - len(str(number))) * '0'
|
||||
number = zeros + str(number)
|
||||
return '{prefix}{year}{number}'.format(prefix=prefix, year=year, number=number)
|
||||
|
||||
def get_due_date(self, payment=None):
|
||||
now = timezone.now()
|
||||
if payment:
|
||||
return now + payment.get_due_delta()
|
||||
return now + relativedelta(months=1)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('admin:bills_bill_view', args=(self.pk,))
|
||||
|
||||
def close(self, payment=False):
|
||||
if not self.is_open:
|
||||
raise TypeError("Bill not in Open state.")
|
||||
if payment is False:
|
||||
payment = self.account.paymentsources.get_default()
|
||||
if not self.due_on:
|
||||
self.due_on = self.get_due_date(payment=payment)
|
||||
total = self.compute_total()
|
||||
transaction = None
|
||||
if self.get_type() != self.PROFORMA:
|
||||
transaction = self.transactions.create(bill=self, source=payment, amount=total)
|
||||
self.closed_on = timezone.now()
|
||||
self.is_open = False
|
||||
self.is_sent = False
|
||||
self.number = self.get_number()
|
||||
self.html = self.render(payment=payment)
|
||||
self.save()
|
||||
return transaction
|
||||
|
||||
def get_billing_contact_emails(self):
|
||||
return self.account.get_contacts_emails(usages=(Contact.BILLING,))
|
||||
|
||||
def send(self):
|
||||
pdf = self.as_pdf()
|
||||
self.account.send_email(
|
||||
template=settings.BILLS_EMAIL_NOTIFICATION_TEMPLATE,
|
||||
context={
|
||||
'bill': self,
|
||||
'settings': settings,
|
||||
},
|
||||
email_from=settings.BILLS_SELLER_EMAIL,
|
||||
usages=(Contact.BILLING,),
|
||||
attachments=[
|
||||
('%s.pdf' % self.number, pdf, 'application/pdf')
|
||||
]
|
||||
)
|
||||
self.is_sent = True
|
||||
self.save(update_fields=['is_sent'])
|
||||
|
||||
def render(self, payment=False, language=None):
|
||||
with translation.override(language or self.account.language):
|
||||
if payment is False:
|
||||
payment = self.account.paymentsources.get_default()
|
||||
context = {
|
||||
'bill': self,
|
||||
'lines': self.lines.all().prefetch_related('sublines'),
|
||||
'seller': self.seller,
|
||||
'buyer': self.buyer,
|
||||
'seller_info': {
|
||||
'phone': settings.BILLS_SELLER_PHONE,
|
||||
'website': settings.BILLS_SELLER_WEBSITE,
|
||||
'email': settings.BILLS_SELLER_EMAIL,
|
||||
'bank_account': settings.BILLS_SELLER_BANK_ACCOUNT,
|
||||
},
|
||||
'currency': settings.BILLS_CURRENCY,
|
||||
'payment': payment and payment.get_bill_context(),
|
||||
'default_due_date': self.get_due_date(payment=payment),
|
||||
'now': timezone.now(),
|
||||
}
|
||||
template_name = 'BILLS_%s_TEMPLATE' % self.get_type()
|
||||
template = getattr(settings, template_name, settings.BILLS_DEFAULT_TEMPLATE)
|
||||
bill_template = loader.get_template(template)
|
||||
html = bill_template.render(context)
|
||||
html = html.replace('-pageskip-', '<pdf:nextpage />')
|
||||
return html
|
||||
|
||||
def as_pdf(self):
|
||||
html = self.html or self.render()
|
||||
return html_to_pdf(html, pagination=self.has_multiple_pages)
|
||||
|
||||
def updated(self):
|
||||
self.updated_on = timezone.now()
|
||||
self.save(update_fields=('updated_on',))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.type:
|
||||
self.type = self.get_type()
|
||||
if not self.number:
|
||||
self.number = self.get_number()
|
||||
super(Bill, self).save(*args, **kwargs)
|
||||
|
||||
@cached
|
||||
def compute_subtotals(self):
|
||||
subtotals = {}
|
||||
lines = self.lines.annotate(totals=F('subtotal') + Sum(Coalesce('sublines__total', 0)))
|
||||
for tax, total in lines.values_list('tax', 'totals'):
|
||||
try:
|
||||
subtotals[tax] += total
|
||||
except KeyError:
|
||||
subtotals[tax] = total
|
||||
result = {}
|
||||
for tax, subtotal in subtotals.items():
|
||||
result[tax] = [subtotal, round(tax/100*subtotal, 2)]
|
||||
return result
|
||||
|
||||
@cached
|
||||
def compute_base(self):
|
||||
bases = self.lines.annotate(
|
||||
bases=F('subtotal') + Sum(Coalesce('sublines__total', 0))
|
||||
)
|
||||
return round(bases.aggregate(Sum('bases'))['bases__sum'] or 0, 2)
|
||||
|
||||
@cached
|
||||
def compute_tax(self):
|
||||
taxes = self.lines.annotate(
|
||||
taxes=(F('subtotal') + Coalesce(Sum('sublines__total'), 0)) * (F('tax')/100)
|
||||
)
|
||||
return round(taxes.aggregate(Sum('taxes'))['taxes__sum'] or 0, 2)
|
||||
|
||||
@cached
|
||||
def compute_total(self):
|
||||
if 'lines' in getattr(self, '_prefetched_objects_cache', ()):
|
||||
total = 0
|
||||
for line in self.lines.all():
|
||||
line_total = line.compute_total()
|
||||
total += line_total * (1+line.tax/100)
|
||||
return round(total, 2)
|
||||
else:
|
||||
totals = self.lines.annotate(
|
||||
totals=(F('subtotal') + Sum(Coalesce('sublines__total', 0))) * (1+F('tax')/100)
|
||||
)
|
||||
return round(totals.aggregate(Sum('totals'))['totals__sum'] or 0, 2)
|
||||
|
||||
|
||||
class Invoice(Bill):
|
||||
class Meta:
|
||||
proxy = True
|
||||
|
||||
|
||||
class AmendmentInvoice(Bill):
|
||||
class Meta:
|
||||
proxy = True
|
||||
|
||||
|
||||
class AbonoInvoice(Bill):
|
||||
class Meta:
|
||||
proxy = True
|
||||
|
||||
|
||||
class Fee(Bill):
|
||||
class Meta:
|
||||
proxy = True
|
||||
|
||||
|
||||
class AmendmentFee(Bill):
|
||||
class Meta:
|
||||
proxy = True
|
||||
|
||||
|
||||
class ProForma(Bill):
|
||||
class Meta:
|
||||
proxy = True
|
||||
|
||||
|
||||
class BillLine(models.Model):
|
||||
""" Base model for bill item representation """
|
||||
bill = models.ForeignKey(Bill, verbose_name=_("bill"), related_name='lines', on_delete=models.CASCADE)
|
||||
description = models.CharField(_("description"), max_length=256)
|
||||
rate = models.DecimalField(_("rate"), blank=True, null=True, max_digits=12, decimal_places=2)
|
||||
quantity = models.DecimalField(_("quantity"), blank=True, null=True, max_digits=12,
|
||||
decimal_places=2)
|
||||
verbose_quantity = models.CharField(_("Verbose quantity"), max_length=16, blank=True)
|
||||
subtotal = models.DecimalField(_("subtotal"), max_digits=12, decimal_places=2)
|
||||
tax = models.DecimalField(_("tax"), max_digits=4, decimal_places=2)
|
||||
start_on = models.DateField(_("start"))
|
||||
end_on = models.DateField(_("end"), null=True, blank=True)
|
||||
order = models.ForeignKey(settings.BILLS_ORDER_MODEL, null=True, blank=True,
|
||||
related_name='lines', on_delete=models.SET_NULL,
|
||||
help_text=_("Informative link back to the order"))
|
||||
order_billed_on = models.DateField(_("order billed"), null=True, blank=True)
|
||||
order_billed_until = models.DateField(_("order billed until"), null=True, blank=True)
|
||||
created_on = models.DateField(_("created"), auto_now_add=True)
|
||||
# Amendment
|
||||
amended_line = models.ForeignKey('self', verbose_name=_("amended line"),
|
||||
related_name='amendment_lines', null=True, blank=True, on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
get_latest_by = 'id'
|
||||
|
||||
def __str__(self):
|
||||
return "#%i" % self.pk if self.pk else self.description
|
||||
|
||||
def get_verbose_quantity(self):
|
||||
return self.verbose_quantity or self.quantity
|
||||
|
||||
def clean(self):
|
||||
if not self.verbose_quantity:
|
||||
quantity = str(self.quantity)
|
||||
# Strip trailing zeros
|
||||
if quantity.endswith('0'):
|
||||
self.verbose_quantity = quantity.strip('0').strip('.')
|
||||
|
||||
def get_verbose_period(self):
|
||||
from django.template.defaultfilters import date
|
||||
date_format = "N 'y"
|
||||
if self.start_on.day != 1 or (self.end_on and self.end_on.day != 1):
|
||||
date_format = "N j, 'y"
|
||||
end = date(self.end_on, date_format)
|
||||
elif self.end_on:
|
||||
end = date((self.end_on - datetime.timedelta(days=1)), date_format)
|
||||
ini = date(self.start_on, date_format).capitalize()
|
||||
if not self.end_on:
|
||||
return ini
|
||||
end = end.capitalize()
|
||||
if ini == end:
|
||||
return ini
|
||||
return "{ini} / {end}".format(ini=ini, end=end)
|
||||
|
||||
@cached
|
||||
def compute_total(self):
|
||||
total = self.subtotal or 0
|
||||
if hasattr(self, 'subline_total'):
|
||||
total += self.subline_total or 0
|
||||
elif 'sublines' in getattr(self, '_prefetched_objects_cache', ()):
|
||||
total += sum(subline.total for subline in self.sublines.all())
|
||||
else:
|
||||
total += self.sublines.aggregate(sub_total=Sum('total'))['sub_total'] or 0
|
||||
return round(total, 2)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return change_url(self)
|
||||
|
||||
|
||||
class BillSubline(models.Model):
|
||||
""" Subline used for describing an item discount """
|
||||
VOLUME = 'VOLUME'
|
||||
COMPENSATION = 'COMPENSATION'
|
||||
OTHER = 'OTHER'
|
||||
TYPES = (
|
||||
(VOLUME, _("Volume")),
|
||||
(COMPENSATION, _("Compensation")),
|
||||
(OTHER, _("Other")),
|
||||
)
|
||||
|
||||
# TODO: order info for undoing
|
||||
line = models.ForeignKey(BillLine, verbose_name=_("bill line"), related_name='sublines', on_delete=models.CASCADE)
|
||||
description = models.CharField(_("description"), max_length=256)
|
||||
total = models.DecimalField(max_digits=12, decimal_places=2)
|
||||
type = models.CharField(_("type"), max_length=16, choices=TYPES, default=OTHER)
|
||||
|
||||
def __str__(self):
|
||||
return "%s %i" % (self.description, self.total)
|
34
orchestra/contrib/bills/serializers.py
Normal file
34
orchestra/contrib/bills/serializers.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from orchestra.api import router
|
||||
from orchestra.contrib.accounts.models import Account
|
||||
from orchestra.contrib.accounts.serializers import AccountSerializerMixin
|
||||
|
||||
from .models import Bill, BillLine, BillContact
|
||||
|
||||
|
||||
class BillLineSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = BillLine
|
||||
|
||||
|
||||
|
||||
class BillSerializer(AccountSerializerMixin, serializers.HyperlinkedModelSerializer):
|
||||
# lines = BillLineSerializer(source='lines')
|
||||
|
||||
class Meta:
|
||||
model = Bill
|
||||
fields = (
|
||||
'url', 'id', 'number', 'type', 'total', 'is_sent', 'created_on', 'due_on',
|
||||
'comments',
|
||||
# 'lines'
|
||||
)
|
||||
|
||||
|
||||
class BillContactSerializer(AccountSerializerMixin, serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = BillContact
|
||||
fields = ('name', 'address', 'city', 'zipcode', 'country', 'vat')
|
||||
|
||||
|
||||
router.insert(Account, 'billcontact', BillContactSerializer, required=False)
|
106
orchestra/contrib/bills/settings.py
Normal file
106
orchestra/contrib/bills/settings.py
Normal file
|
@ -0,0 +1,106 @@
|
|||
from django_countries import data
|
||||
|
||||
from orchestra.contrib.settings import Setting
|
||||
from orchestra.settings import ORCHESTRA_BASE_DOMAIN
|
||||
|
||||
|
||||
BILLS_NUMBER_LENGTH = Setting('BILLS_NUMBER_LENGTH',
|
||||
4
|
||||
)
|
||||
|
||||
|
||||
BILLS_INVOICE_NUMBER_PREFIX = Setting('BILLS_INVOICE_NUMBER_PREFIX',
|
||||
'I'
|
||||
)
|
||||
|
||||
|
||||
BILLS_AMENDMENT_INVOICE_NUMBER_PREFIX = Setting('BILLS_AMENDMENT_INVOICE_NUMBER_PREFIX',
|
||||
'A'
|
||||
)
|
||||
|
||||
BILLS_ABONOINVOICE_NUMBER_PREFIX = Setting('BILLS_ABONOINVOICE_NUMBER_PREFIX',
|
||||
'AB'
|
||||
)
|
||||
|
||||
BILLS_FEE_NUMBER_PREFIX = Setting('BILLS_FEE_NUMBER_PREFIX',
|
||||
'F'
|
||||
)
|
||||
|
||||
BILLS_AMENDMENT_FEE_NUMBER_PREFIX = Setting('BILLS_AMENDMENT_FEE_NUMBER_PREFIX',
|
||||
'B'
|
||||
)
|
||||
|
||||
|
||||
BILLS_PROFORMA_NUMBER_PREFIX = Setting('BILLS_PROFORMA_NUMBER_PREFIX',
|
||||
'P'
|
||||
)
|
||||
|
||||
|
||||
BILLS_DEFAULT_TEMPLATE = Setting('BILLS_DEFAULT_TEMPLATE',
|
||||
'bills/microspective.html'
|
||||
)
|
||||
|
||||
|
||||
BILLS_FEE_TEMPLATE = Setting('BILLS_FEE_TEMPLATE',
|
||||
'bills/microspective-fee.html'
|
||||
)
|
||||
|
||||
|
||||
BILLS_PROFORMA_TEMPLATE = Setting('BILLS_PROFORMA_TEMPLATE',
|
||||
'bills/microspective-proforma.html'
|
||||
)
|
||||
|
||||
|
||||
BILLS_CURRENCY = Setting('BILLS_CURRENCY',
|
||||
'euro'
|
||||
)
|
||||
|
||||
|
||||
BILLS_SELLER_PHONE = Setting('BILLS_SELLER_PHONE',
|
||||
'111-112-11-222'
|
||||
)
|
||||
|
||||
|
||||
BILLS_SELLER_EMAIL = Setting('BILLS_SELLER_EMAIL',
|
||||
'sales@{}'.format(ORCHESTRA_BASE_DOMAIN),
|
||||
help_text="Uses ORCHESTRA_BASE_DOMAIN by default.",
|
||||
)
|
||||
|
||||
|
||||
BILLS_SELLER_WEBSITE = Setting('BILLS_SELLER_WEBSITE',
|
||||
'www.{}'.format(ORCHESTRA_BASE_DOMAIN),
|
||||
help_text="Uses ORCHESTRA_BASE_DOMAIN by default.",
|
||||
)
|
||||
|
||||
|
||||
BILLS_SELLER_BANK_ACCOUNT = Setting('BILLS_SELLER_BANK_ACCOUNT',
|
||||
'0000 0000 00 00000000 (Orchestra Bank)'
|
||||
)
|
||||
|
||||
|
||||
BILLS_EMAIL_NOTIFICATION_TEMPLATE = Setting('BILLS_EMAIL_NOTIFICATION_TEMPLATE',
|
||||
'bills/bill-notification.email'
|
||||
)
|
||||
|
||||
|
||||
BILLS_ORDER_MODEL = Setting('BILLS_ORDER_MODEL',
|
||||
'orders.Order',
|
||||
validators=[Setting.validate_model_label]
|
||||
)
|
||||
|
||||
|
||||
BILLS_CONTACT_DEFAULT_CITY = Setting('BILLS_CONTACT_DEFAULT_CITY',
|
||||
'Barcelona'
|
||||
)
|
||||
|
||||
|
||||
BILLS_CONTACT_COUNTRIES = Setting('BILLS_CONTACT_COUNTRIES',
|
||||
tuple((k,v) for k,v in data.COUNTRIES.items()),
|
||||
serializable=False
|
||||
)
|
||||
|
||||
|
||||
BILLS_CONTACT_DEFAULT_COUNTRY = Setting('BILLS_CONTACT_DEFAULT_COUNTRY',
|
||||
'ES',
|
||||
choices=BILLS_CONTACT_COUNTRIES
|
||||
)
|
|
@ -0,0 +1,18 @@
|
|||
{% extends "admin/change_list.html" %}
|
||||
{% load i18n admin_urls %}
|
||||
|
||||
|
||||
{% block object-tools-items %}
|
||||
<li>
|
||||
{% url 'admin:bills_billline_changelist' as list_url %}
|
||||
<a href="{% add_preserved_filters list_url is_popup to_field %}" class="historylink">
|
||||
{% trans "Lines" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
{% url 'admin:bills_bill_add' as add_url %}
|
||||
<a href="{% add_preserved_filters add_url is_popup to_field %}" class="addlink">
|
||||
{% trans "Add bill" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endblock %}
|
|
@ -0,0 +1,60 @@
|
|||
{% extends "admin/orchestra/generic_confirmation.html" %}
|
||||
{% load i18n l10n %}
|
||||
{% load admin_urls static utils %}
|
||||
|
||||
{% block extrastyle %}
|
||||
{{ block.super }}
|
||||
<script type="text/javascript">
|
||||
function DoSubmit() {
|
||||
// document.form.button.type = 'hidden';
|
||||
document.getElementsByName("message")[0].innerHTML = "Bills are being generated and download will start shortly...";
|
||||
document.getElementsByTagName("fieldset")[0].style.display = 'none';
|
||||
document.form.button.value = 'Go back';
|
||||
return true;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div name="content">
|
||||
<div style="margin:20px;">
|
||||
<div name="message">
|
||||
<p>{{ content_message | safe }}</p>
|
||||
<ul>{{ display_objects | unordered_list }}</ul>
|
||||
</div>
|
||||
<form name="form" action="" method="post" onsubmit="DoSubmit();">{% csrf_token %}
|
||||
{% block form %}
|
||||
{% if form %}
|
||||
<fieldset class="module aligned">
|
||||
{{ form.non_field_errors }}
|
||||
{% for field in form %}
|
||||
<div class="form-row ">
|
||||
<div >
|
||||
{{ field.errors }}
|
||||
{% if field|is_checkbox %}
|
||||
{{ field }} <label for="{{ field.id_for_label }}" class="vCheckboxLabel">{{ field.label }}</label>
|
||||
{% else %}
|
||||
{{ field.label_tag }} {{ field }}
|
||||
{% endif %}
|
||||
<p class="help">{{ field.help_text|safe }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block formset %}
|
||||
{% if formset %}
|
||||
{{ formset.as_admin }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
<div>
|
||||
{% for obj in queryset %}
|
||||
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk|unlocalize }}" />
|
||||
{% endfor %}
|
||||
<input type="hidden" name="action" value="{{ action_value }}" />
|
||||
<input type="hidden" name="post" value="{{ post_value|default:'generic_confirmation' }}" />
|
||||
<input name="button" type="submit" value="{{ submit_value|default:_("Yes, I'm sure") }}" />
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
|
@ -0,0 +1,87 @@
|
|||
{% load i18n utils %}
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>Bill Report</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
|
||||
<style type="text/css">
|
||||
@page {
|
||||
size: 11.69in 8.27in;
|
||||
}
|
||||
table {
|
||||
font-family: sans;
|
||||
font-size: 10px;
|
||||
max-width: 10in;
|
||||
margin: 4px;
|
||||
}
|
||||
.item.column-name {
|
||||
text-align: right;
|
||||
}
|
||||
table tr:nth-child(even) {
|
||||
background-color: #eee;
|
||||
}
|
||||
table tr:nth-child(odd) {
|
||||
background-color: #fff;
|
||||
}
|
||||
table th {
|
||||
color: white;
|
||||
background-color: grey;
|
||||
}
|
||||
|
||||
.item.column-base, .item.column-vat, .item.column-total, .item.column-number {
|
||||
text-align: right;
|
||||
}
|
||||
.column-vat-number {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table id="summary">
|
||||
<tr class="header">
|
||||
<th class="title column-name">{% trans "Summary" %}</th>
|
||||
<th class="title column-total">{% trans "Total" %}</th>
|
||||
</tr>
|
||||
{% for tax, subtotal in subtotals.items %}
|
||||
<tr>
|
||||
<td class="item column-name">{% trans "subtotal" %} {{ tax }}% {% trans "VAT" %}</td>
|
||||
<td class="item column-total">{{ subtotal|first}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="item column-name">{% trans "taxes" %} {{ tax }}% {% trans "VAT" %}</td>
|
||||
<td class="item column-total">{{ subtotal|last}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<td class="item column-name"><b>{% trans "TOTAL" %}</b></td>
|
||||
<td class="item column-total"><b>{{ total }}</b></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<table id="main">
|
||||
<tr class="header">
|
||||
<th class="title column-number">{% trans "Number" %}</th>
|
||||
<th class="title column-vat-number">{% trans "VAT number" %}</th>
|
||||
<th class="title column-billcontant">{% trans "Contact" %}</th>
|
||||
<th class="title column-date">{% trans "Close date" %}</th>
|
||||
<th class="title column-base">{% trans "Base" %}</th>
|
||||
<th class="title column-vat">{% trans "VAT" %}</th>
|
||||
<th class="title column-total">{% trans "Total" %}</th>
|
||||
</tr>
|
||||
{% for bill in bills %}
|
||||
<tr>
|
||||
<td class="item column-number">{{ bill.number }}</td>
|
||||
<td class="item column-vat-number">{{ bill.buyer.vat }}</td>
|
||||
<td class="item column-billcontant">{{ bill.buyer.get_name }}</td>
|
||||
<td class="item column-date">{{ bill.closed_on|date }}</td>
|
||||
{% with base=bill.compute_base total=bill.compute_total %}
|
||||
<td class="item column-base">{{ base }}</td>
|
||||
<td class="item column-vat">{{ total|sub:base }}</td>
|
||||
<td class="item column-total">{{ total }}</td>
|
||||
{% endwith %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,12 @@
|
|||
{% extends "admin/change_list.html" %}
|
||||
{% load i18n admin_urls %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
|
||||
› <a href="{% url 'admin:app_list' app_label=cl.opts.app_label %}">{{ cl.opts.app_config.verbose_name }}</a>
|
||||
› <a href="{% url 'admin:bills_bill_changelist' %}">{% trans "Bills" %}</a>
|
||||
› {% if bill %}<a href="{% url 'admin:bills_bill_change' bill.pk %}">{{ bill }}</a>{% else %}{% trans 'Multiple bills' %}{% endif %}
|
||||
› {{ cl.opts.verbose_name_plural|capfirst }}
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,72 @@
|
|||
{% load i18n utils %}
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>Transaction Report</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
|
||||
<style type="text/css">
|
||||
@page {
|
||||
size: 11.69in 8.27in;
|
||||
}
|
||||
table {
|
||||
max-width: 10in;
|
||||
font-family: sans;
|
||||
font-size: 10px;
|
||||
}
|
||||
table tr:nth-child(even) {
|
||||
background-color: #eee;
|
||||
}
|
||||
table tr:nth-child(odd) {
|
||||
background-color: #fff;
|
||||
}
|
||||
table th {
|
||||
color: white;
|
||||
background-color: grey;
|
||||
}
|
||||
.item.column-created, .item.column-updated {
|
||||
text-align: center;
|
||||
}
|
||||
.item.column-amount {
|
||||
text-align: right;
|
||||
}
|
||||
.footnote {
|
||||
font-family: sans;
|
||||
font-size: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<table id="summary">
|
||||
<tr class="header">
|
||||
<th class="title column-name">{% trans "Service" %}</th>
|
||||
<th class="title column-active">{% trans "Active" %}</th>
|
||||
<th class="title column-cancelled">{% trans "Cancelled" %}</th>
|
||||
<th class="title column-nominal-price">{% trans "Nominal price" %}</th>
|
||||
<th class="title column-number">{% trans "Quantity" %}</th>
|
||||
<th class="title column-number">{% trans "Profit" %}</th>
|
||||
</tr>
|
||||
{% for service, info in services %}
|
||||
<tr>
|
||||
<td class="item column-name">{{ service }}</td>
|
||||
<td class="item column-amount">{{ info.0 }}</td>
|
||||
<td class="item column-amount">{{ info.1 }}</td>
|
||||
<td class="item column-amount">{{ info.2 }}</td>
|
||||
<td class="item column-amount">{{ info.3 }}</td>
|
||||
<td class="item column-amount">{{ info.4 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<td class="item column-name"><b>{% trans "TOTAL" %}</b></td>
|
||||
<td class="item column-amount"><b>{{ totals.0 }}</b></td>
|
||||
<td class="item column-amount"><b>{{ totals.1 }}<b></td>
|
||||
<td class="item column-amount"><b>{{ totals.2 }}<b></td>
|
||||
<td class="item column-amount"><b>{{ totals.3 }}<b></td>
|
||||
<td class="item column-amount"><b>{{ totals.4 }}<b></td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="footnote">
|
||||
* Custom lines
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
10
orchestra/contrib/bills/templates/bills/base.html
Normal file
10
orchestra/contrib/bills/templates/bills/base.html
Normal file
|
@ -0,0 +1,10 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>{% block title %}{{ bill.get_type_display }} - {{ bill.number }}{% endblock %}</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
|
@ -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 %}
|
217
orchestra/contrib/bills/templates/bills/invoice.html
Normal file
217
orchestra/contrib/bills/templates/bills/invoice.html
Normal file
|
@ -0,0 +1,217 @@
|
|||
<html>
|
||||
<style>
|
||||
@page {
|
||||
margin: 1cm;
|
||||
margin-bottom: 0cm;
|
||||
margin-top: 3cm;
|
||||
size: a4 portrait;
|
||||
background-image: url('img/letter_head.png');
|
||||
@frame footer {
|
||||
-pdf-frame-content: footerContent;
|
||||
bottom: 0cm;
|
||||
margin-left: 1cm;
|
||||
margin-right: 1cm;
|
||||
height: 2cm;
|
||||
}
|
||||
@frame simple {
|
||||
-pdf-frame-content: simple;
|
||||
bottom: 2.0cm;
|
||||
height: 2.5cm;
|
||||
margin-left: 1cm;
|
||||
margin-right: 1cm;
|
||||
}
|
||||
}
|
||||
|
||||
div#buyer-details{
|
||||
font-size: 120%;
|
||||
}
|
||||
|
||||
div#specification{
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
div#specification td{
|
||||
vertical-align: middle;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
|
||||
table td {
|
||||
vertical-align: top;
|
||||
padding: 2px 0;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
table td.amount{
|
||||
text-align: right;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
table td.total{
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
table th {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #000;
|
||||
}
|
||||
|
||||
tr.uneven {
|
||||
background-color: #efefef;
|
||||
}
|
||||
|
||||
div#footerContent {
|
||||
color: #777777;
|
||||
}
|
||||
|
||||
div#footerContent a {
|
||||
color: #790000;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 90%;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
div#totals {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
div#simple td {
|
||||
margin-left: 10px;
|
||||
background-color: #efefef;
|
||||
}
|
||||
|
||||
div#simple tr {
|
||||
border-right: 1px solid #333;
|
||||
}
|
||||
|
||||
div#simple table{
|
||||
text-align: center;
|
||||
border-left: 1px solid #999;
|
||||
}
|
||||
</style>
|
||||
|
||||
<body>
|
||||
<h1>{{ bill_type }}</h1>
|
||||
<div id="buyer-details">
|
||||
<table>
|
||||
<tr>
|
||||
<td width="60%">
|
||||
<strong>{{ buyer.name }}</strong><br>
|
||||
{{ buyer.address }}<br>
|
||||
{{ buyer.zipcode }} {{ buyer.city }}<br>
|
||||
{{ buyer.country }}<br>
|
||||
{{ buyer.vat_number }}<br>
|
||||
</td>
|
||||
<td width="20%">
|
||||
<strong>Invoice number</strong><br />
|
||||
<strong>Date</strong><br />
|
||||
<strong>Due date</strong>
|
||||
</td>
|
||||
<td width="20%">
|
||||
: {{ bill.ident }}<br />
|
||||
: {{ bill.date|date:"d F, Y" }}<br />
|
||||
: {{ bill.due_on|date:"d F, Y" }}<br />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div id="specification">
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<th width="5%">ID</th>
|
||||
<th width="65%">{% trans Description %}</th>
|
||||
<th width="20%">Amount</th>
|
||||
<th width="10%">Price</th>
|
||||
</tr>
|
||||
{% for line in lines %}
|
||||
<tr class="{% cycle 'even' 'uneven' %}"{% if forloop.last %} style="border-bottom: 1px solid #000;"{% endif %}>
|
||||
<td class="ID">{{ line.order_id }}</td>
|
||||
<td style="padding-left: 2px;">{{ line.description }}
|
||||
<span class="date">({{ line.initial_date|date:"d-m-Y" }}{% if line.initial_date != line.final_date %} - {{ line.final_date|date:"d-m-Y" }}{% endif %})</span></td>
|
||||
<td class="quantity">{{ line.amount }}</td>
|
||||
<td class="amount total">&{{ currency }}; {{ line.price }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
<div id="totals">
|
||||
<table width="100%">
|
||||
<tr>
|
||||
{% for tax, base in bases.items %}
|
||||
<td width="60%"> </td>
|
||||
<td width="20%">Subtotal{% if bases.items|length > 1 %} (for {{ tax }}% taxes){% endif %}</td>
|
||||
<td width="20%" class="amount">&{{ currency }}; {{ base }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<tr>
|
||||
{% for tax, value in taxes.items %}
|
||||
<td width="60%"> </td>
|
||||
<td width="20%">Total {{ tax }}%</td>
|
||||
<td width="20%" class="amount" style="border-bottom: 1px solid #333;">&{{ currency }}; {{ value }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="60%"> </td>
|
||||
<td width="20%" class="total"><strong>Total</strong></td>
|
||||
<td width="20%" class="amount total">&{{ currency }}; {{ total }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div id="simple">
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" style="padding-top: 5px;">IBAN</th>
|
||||
<td width="34%" style="padding-top: 5px;">Invoice ID</th>
|
||||
<td width="33%" style="padding-top: 5px;">Amount {{ currency.upper }}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>NL28INGB0004954664</strong></td>
|
||||
<td><strong>{{ bill.ident }}</strong></td>
|
||||
<td><strong>{{ total }}</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="text-align:center;">The invoice is to be paid before <strong>{{ invoice.exp_date|date:"F jS, Y" }}</strong> with the mention of the invoice id.</p>
|
||||
</div>
|
||||
<div id="footerContent">
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%">
|
||||
{{ seller.name }}<br />
|
||||
{{ seller.address }}<br />
|
||||
{{ seller.city }}<br />
|
||||
{{ seller.country }}<br />
|
||||
</td>
|
||||
<td width="5%">
|
||||
Tel<br />
|
||||
Web<br />
|
||||
Email<br />
|
||||
</td>
|
||||
<td width="29%">
|
||||
{{ seller_info.phone }}<br />
|
||||
<a href="http://{{ seller_info.website }}">{{ seller_info.website }}</a><br />
|
||||
{{ seller_info.email }}
|
||||
</td>
|
||||
<td width="8%">
|
||||
Bank ING<br />
|
||||
IBAN<br />
|
||||
BTW<br />
|
||||
KvK<br />
|
||||
</td>
|
||||
<td width="25%">
|
||||
4954664<br />
|
||||
NL28INGB0004954664<br />
|
||||
NL 8207.29.449.B01<br />
|
||||
27343027
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
Payment info
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
155
orchestra/contrib/bills/templates/bills/microspective-fee.html
Normal file
155
orchestra/contrib/bills/templates/bills/microspective-fee.html
Normal file
|
@ -0,0 +1,155 @@
|
|||
{% extends 'bills/microspective.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block head %}
|
||||
<style type="text/css">
|
||||
{% with color="#809708" %}
|
||||
{% include 'bills/microspective.css' %}
|
||||
{% endwith %}
|
||||
|
||||
#buyer-details {
|
||||
clear: left;
|
||||
margin-top: 40px;
|
||||
margin-left: 54%;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.column-1 {
|
||||
float: left;
|
||||
font-size: 30;
|
||||
font-weight: bold;
|
||||
text-align: right;
|
||||
color: #666;
|
||||
width: 40%;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
#extralines {
|
||||
clear: left;
|
||||
clear: right;
|
||||
text-align: right;
|
||||
color: #A40000;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#number-date {
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
#number-value {
|
||||
font-size: 30;
|
||||
color: #809708;
|
||||
}
|
||||
|
||||
.column-2 {
|
||||
float: right;
|
||||
padding: 15px;
|
||||
margin: 10px;
|
||||
margin-top: 0px;
|
||||
width: 44%;
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
#amount {
|
||||
color: white;
|
||||
background-color: #809708;
|
||||
}
|
||||
|
||||
#amount-value {
|
||||
font-size: 30;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#date {
|
||||
clear: left;
|
||||
clear: right;
|
||||
margin-top: 0px;
|
||||
padding-top: 0px;
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
#text {
|
||||
clear: left;
|
||||
clear: right;
|
||||
text-align: right;
|
||||
margin: 40px 10px 50px 10px;
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
#text strong {
|
||||
color: #809708;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin-top: 20px;
|
||||
border: 2px solid #809708;
|
||||
clear: left;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block summary %}
|
||||
<div style="position: relative; margin-top: 140px;">
|
||||
<hr>
|
||||
</div>
|
||||
|
||||
<div id="buyer-details">
|
||||
<span class="name">{{ buyer.get_name }}</span><br>
|
||||
{{ buyer.vat }}<br>
|
||||
{{ buyer.address }}<br>
|
||||
{{ buyer.zipcode }} - {{ buyer.city }}<br>
|
||||
{% trans buyer.get_country_display %}<br>
|
||||
</div>
|
||||
|
||||
<div id="number" class="column-1">
|
||||
<span id="number-title">{% filter title %}{% trans bill.get_type_display %}{% endfilter %}</span><br>
|
||||
<span id="number-value">{{ bill.number }}</span><br>
|
||||
<span id="number-date">{{ bill.closed_on | default:now | date:"F j, Y" | capfirst }}</span><br>
|
||||
</div>
|
||||
|
||||
<div id="amount" class="column-2">
|
||||
<span id="amount-value">{{ bill.compute_total }} &{{ currency.lower }};</span><br>
|
||||
<span id="amount-note">{% trans "Due date" %} {{ payment.due_date| default:default_due_date | date:"F j, Y" }}<br>
|
||||
{% if not payment.message %}{% blocktrans with bank_account=seller_info.bank_account %}On {{ bank_account }}{% endblocktrans %}{% endif %}<br>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div id="date" class="column-2">
|
||||
{% with line=bill.lines.first %}
|
||||
{% blocktrans with ini=line.start_on|date:"F j, Y" end=line.end_on|date:"F j, Y" %}From {{ ini }} to {{ end }}{% endblocktrans %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% block lines %}
|
||||
<div id="extralines">
|
||||
{% for line in bill.lines.all %}
|
||||
<ul>
|
||||
{% if not forloop.first %}
|
||||
<li>{{ line.description }}</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block text %}
|
||||
<div id="text">
|
||||
{% blocktrans %}
|
||||
<strong>With your membership</strong> you are supporting ...
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block footer %}
|
||||
<hr>
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
|
@ -0,0 +1,13 @@
|
|||
{% extends 'bills/microspective.html' %}
|
||||
|
||||
{% block head %}
|
||||
<style type="text/css">
|
||||
{% with color="#2C5899" %}
|
||||
{% include 'bills/microspective.css' %}
|
||||
{% endwith %}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block payment %}
|
||||
{% endblock %}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue