OMG a ridiculous commit to overhaul import handler config etc.

- add `MasterView.configurable` concept, `/configure.mako` template
- add new master view for DataSync Threads (needs content)
- tweak view config for DataSync Changes accordingly
- update the Configure DataSync page per `configurable` concept
- add new Configure Import/Export page, per `configurable`
- add basic views for Raw Permissions
This commit is contained in:
Lance Edgar 2021-12-06 20:04:34 -06:00
parent 282185c5af
commit cc4b2278e7
10 changed files with 735 additions and 238 deletions

View file

@ -0,0 +1,175 @@
## -*- coding: utf-8; -*-
<%inherit file="/page.mako" />
<%def name="title()">Configure ${config_title}</%def>
<%def name="save_undo_buttons()">
<div class="buttons"
v-if="settingsNeedSaved">
<b-button type="is-primary"
@click="saveSettings"
:disabled="savingSettings"
icon-pack="fas"
icon-left="save">
{{ savingSettings ? "Working, please wait..." : "Save All Settings" }}
</b-button>
<once-button tag="a" href="${request.current_route_url()}"
@click="undoChanges = true"
icon-left="undo"
text="Undo All Changes">
</once-button>
</div>
</%def>
<%def name="purge_button()">
<b-button type="is-danger"
@click="purgeSettingsInit()"
icon-pack="fas"
icon-left="trash">
Remove All Settings
</b-button>
</%def>
<%def name="buttons_row()">
<div class="level">
<div class="level-left">
<div class="level-item">
<p class="block">
This tool lets you modify the ${config_title} configuration.
</p>
</div>
<div class="level-item">
${self.save_undo_buttons()}
</div>
</div>
<div class="level-right">
<div class="level-item">
${self.purge_button()}
</div>
</div>
</div>
</%def>
<%def name="page_content()">
${parent.page_content()}
<br />
${self.buttons_row()}
<b-modal has-modal-card
:active.sync="purgeSettingsShowDialog">
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Remove All Settings</p>
</header>
<section class="modal-card-body">
<p class="block">
If you like we can remove all ${config_title}
settings from the DB.
</p>
<p class="block">
Note that the tool normally removes all settings first,
every time you click "Save Settings" - here though you can
"just remove and not save" the settings.
</p>
<p class="block">
Note also that this will of course
<span class="is-italic">not</span> remove any settings from
your config files, so after removing from DB,
<span class="is-italic">only</span> your config file
settings should be in effect.
</p>
</section>
<footer class="modal-card-foot">
<b-button @click="purgeSettingsShowDialog = false">
Cancel
</b-button>
${h.form(request.current_route_url())}
${h.csrf_token(request)}
${h.hidden('remove_settings', 'true')}
<b-button type="is-danger"
native-type="submit"
:disabled="purgingSettings"
icon-pack="fas"
icon-left="trash"
@click="purgingSettings = true">
{{ purgingSettings ? "Working, please wait..." : "Remove All Settings" }}
</b-button>
${h.end_form()}
</footer>
</div>
</b-modal>
</%def>
<%def name="modify_this_page_vars()">
${parent.modify_this_page_vars()}
<script type="text/javascript">
ThisPageData.purgeSettingsShowDialog = false
ThisPageData.purgingSettings = false
ThisPageData.settingsNeedSaved = false
ThisPageData.undoChanges = false
ThisPageData.savingSettings = false
ThisPage.methods.purgeSettingsInit = function() {
this.purgeSettingsShowDialog = true
}
ThisPage.methods.settingsCollectParams = function() {
return {}
}
ThisPage.methods.saveSettings = function() {
this.savingSettings = true
let url = ${json.dumps(request.current_route_url())|n}
let params = this.settingsCollectParams()
let headers = {
'X-CSRF-TOKEN': this.csrftoken,
}
this.$http.post(url, params, {headers: headers}).then((response) => {
if (response.data.success) {
this.settingsNeedSaved = false
location.href = url // reload page
} else {
this.$buefy.toast.open({
message: "Save failed: " + (response.data.error || "(unknown error)"),
type: 'is-danger',
duration: 4000, // 4 seconds
})
}
}).catch((error) => {
this.$buefy.toast.open({
message: "Save failed: (unknown error)",
type: 'is-danger',
duration: 4000, // 4 seconds
})
})
}
// cf. https://stackoverflow.com/a/56551646
ThisPage.methods.beforeWindowUnload = function(e) {
if (this.settingsNeedSaved && !this.undoChanges) {
e.preventDefault()
e.returnValue = ''
}
}
ThisPage.created = function() {
window.addEventListener('beforeunload', this.beforeWindowUnload)
}
</script>
</%def>
${parent.body()}

View file

@ -3,8 +3,8 @@
<%def name="context_menu_items()"> <%def name="context_menu_items()">
${parent.context_menu_items()} ${parent.context_menu_items()}
% if master.has_perm('configure'): % if request.has_perm('datasync.list'):
${h.link_to("Configure DataSync", url('datasync.configure'))} <li>${h.link_to("View DataSync Threads", url('datasync'))}</li>
% endif % endif
</%def> </%def>

View file

@ -1,13 +1,10 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%inherit file="/page.mako" /> <%inherit file="/configure.mako" />
<%def name="title()">Configure DataSync</%def>
<%def name="page_content()">
<br />
<%def name="buttons_row()">
<div class="level"> <div class="level">
<div class="level-left"> <div class="level-left">
<div class="level-item"> <div class="level-item">
<p class="block"> <p class="block">
This tool lets you modify the DataSync configuration.&nbsp; This tool lets you modify the DataSync configuration.&nbsp;
@ -19,24 +16,13 @@
</p> </p>
</div> </div>
<div class="level-item buttons" <div class="level-item">
v-if="settingsNeedSaved"> ${self.save_undo_buttons()}
<b-button type="is-primary"
@click="saveSettings"
:disabled="savingSettings"
icon-pack="fas"
icon-left="save">
{{ saveSettingsButtonText }}
</b-button>
<once-button tag="a" href="${request.current_route_url()}"
@click="undoChanges = true"
icon-left="undo"
text="Undo All Changes">
</once-button>
</div> </div>
</div> </div>
<div class="level-right"> <div class="level-right">
<div class="level-item"> <div class="level-item">
${h.form(url('datasync.restart'), **{'@submit': 'submitRestartDatasyncForm'})} ${h.form(url('datasync.restart'), **{'@submit': 'submitRestartDatasyncForm'})}
${h.csrf_token(request)} ${h.csrf_token(request)}
@ -50,56 +36,16 @@
</b-button> </b-button>
${h.end_form()} ${h.end_form()}
</div> </div>
<div class="level-item"> <div class="level-item">
<b-button type="is-danger" ${self.purge_button()}
@click="purgeSettingsInit()"
icon-pack="fas"
icon-left="trash">
Remove All Settings
</b-button>
</div> </div>
</div> </div>
</div> </div>
</%def>
<b-modal has-modal-card <%def name="page_content()">
:active.sync="purgeSettingsShowDialog"> ${parent.page_content()}
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Remove All Settings</p>
</header>
<section class="modal-card-body">
<p class="block">
If you like we can remove all DataSync settings from the DB.
</p>
<p class="block">
Note that this tool normally removes all settings first,
every time you click "Save Settings".&nbsp; Here though you
can "just remove" and <span class="is-italic">not</span>
save the current settings.
</p>
</section>
<footer class="modal-card-foot">
<b-button @click="purgeSettingsShowDialog = false">
Cancel
</b-button>
${h.form(request.current_route_url())}
${h.csrf_token(request)}
${h.hidden('purge_settings', 'true')}
<b-button type="is-danger"
native-type="submit"
:disabled="purgingSettings"
icon-pack="fas"
icon-left="trash"
@click="purgingSettings = true">
{{ purgingSettings ? "Working, please wait..." : "Remove All Settings" }}
</b-button>
${h.end_form()}
</footer>
</div>
</b-modal>
<b-notification type="is-warning" <b-notification type="is-warning"
:active.sync="showConfigFilesNote"> :active.sync="showConfigFilesNote">
@ -496,13 +442,6 @@
ThisPageData.restartCommand = ${json.dumps(restart_command)|n} ThisPageData.restartCommand = ${json.dumps(restart_command)|n}
ThisPageData.purgeSettingsShowDialog = false
ThisPageData.purgingSettings = false
ThisPageData.settingsNeedSaved = false
ThisPageData.undoChanges = false
ThisPageData.savingSettings = false
ThisPage.computed.filteredProfilesData = function() { ThisPage.computed.filteredProfilesData = function() {
if (this.showDisabledProfiles) { if (this.showDisabledProfiles) {
return this.profilesData return this.profilesData
@ -539,13 +478,6 @@
return false return false
} }
ThisPage.computed.saveSettingsButtonText = function() {
if (this.savingSettings) {
return "Working, please wait..."
}
return "Save All Settings"
}
ThisPage.methods.toggleDisabledProfiles = function() { ThisPage.methods.toggleDisabledProfiles = function() {
this.showDisabledProfiles = !this.showDisabledProfiles this.showDisabledProfiles = !this.showDisabledProfiles
} }
@ -743,53 +675,11 @@
} }
} }
ThisPage.methods.purgeSettingsInit = function() { ThisPage.methods.settingsCollectParams = function() {
this.purgeSettingsShowDialog = true return {
}
ThisPage.methods.saveSettings = function() {
this.savingSettings = true
let url = ${json.dumps(request.current_route_url())|n}
let params = {
profiles: this.profilesData, profiles: this.profilesData,
restart_command: this.restartCommand, restart_command: this.restartCommand,
} }
let headers = {
'X-CSRF-TOKEN': this.csrftoken,
}
this.$http.post(url, params, {headers: headers}).then((response) => {
if (response.data.success) {
this.settingsNeedSaved = false
location.href = url // reload page
} else {
this.$buefy.toast.open({
message: "Save failed: " + (response.data.error || "(unknown error)"),
type: 'is-danger',
duration: 4000, // 4 seconds
})
}
}).catch((error) => {
this.$buefy.toast.open({
message: "Save failed: (unknown error)",
type: 'is-danger',
duration: 4000, // 4 seconds
})
})
}
// cf. https://stackoverflow.com/a/56551646
ThisPage.methods.beforeWindowUnload = function(e) {
if (this.settingsNeedSaved && !this.undoChanges) {
e.preventDefault()
e.returnValue = ''
}
}
ThisPage.created = function() {
window.addEventListener('beforeunload', this.beforeWindowUnload)
} }
% if request.has_perm('datasync.restart'): % if request.has_perm('datasync.restart'):

View file

@ -0,0 +1,19 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/index.mako" />
<%def name="context_menu_items()">
${parent.context_menu_items()}
% if request.has_perm('datasync_changes.list'):
<li>${h.link_to("View DataSync Changes", url('datasyncchanges'))}</li>
% endif
</%def>
<%def name="render_grid_component()">
<b-notification :closable="false">
TODO: this page coming soon...
</b-notification>
${parent.render_grid_component()}
</%def>
${parent.body()}

View file

@ -0,0 +1,197 @@
## -*- coding: utf-8; -*-
<%inherit file="/configure.mako" />
<%def name="page_content()">
${parent.page_content()}
<h3 class="is-size-3">Designated Handlers</h3>
<b-table :data="handlersData"
narrowed
icon-pack="fas"
:default-sort="['host_title', 'asc']">
<template slot-scope="props">
<b-table-column field="host_title" label="Data Source" sortable>
{{ props.row.host_title }}
</b-table-column>
<b-table-column field="local_title" label="Data Target" sortable>
{{ props.row.local_title }}
</b-table-column>
<b-table-column field="direction" label="Direction" sortable>
{{ props.row.direction_display }}
</b-table-column>
<b-table-column field="handler_spec" label="Handler Spec" sortable>
{{ props.row.handler_spec }}
</b-table-column>
<b-table-column field="cmd" label="Command" sortable>
{{ props.row.command }} {{ props.row.subcommand }}
</b-table-column>
<b-table-column field="runas" label="Default Runas" sortable>
{{ props.row.default_runas }}
</b-table-column>
<b-table-column label="Actions">
<a href="#" class="grid-action"
@click.prevent="editHandler(props.row)">
<i class="fas fa-edit"></i>
Edit
</a>
</b-table-column>
</template>
<template slot="empty">
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>
<b-icon
pack="fas"
icon="fas fa-sad-tear"
size="is-large">
</b-icon>
</p>
<p>Nothing here.</p>
</div>
</section>
</template>
</b-table>
<b-modal :active.sync="editHandlerShowDialog">
<div class="card">
<div class="card-content">
<b-field :label="editingHandlerDirection" horizontal expanded>
{{ editingHandlerHostTitle }} -> {{ editingHandlerLocalTitle }}
</b-field>
<b-field label="Handler Spec"
:type="editingHandlerSpec ? null : 'is-danger'">
<b-select v-model="editingHandlerSpec">
<option v-for="option in editingHandlerSpecOptions"
:key="option"
:value="option">
{{ option }}
</option>
</b-select>
</b-field>
<b-field grouped>
<b-field label="Command"
:type="editingHandlerCommand ? null : 'is-danger'">
<div class="level">
<div class="level-left">
<div class="level-item" style="margin-right: 0;">
bin/
</div>
<div class="level-item" style="margin-left: 0;">
<b-input v-model="editingHandlerCommand">
</b-input>
</div>
</div>
</div>
</b-field>
<b-field label="Subcommand"
:type="editingHandlerSubcommand ? null : 'is-danger'">
<b-input v-model="editingHandlerSubcommand">
</b-input>
</b-field>
<b-field label="Default Runas">
<b-input v-model="editingHandlerRunas">
</b-input>
</b-field>
</b-field>
<b-field grouped>
<b-button @click="editHandlerShowDialog = false"
class="control">
Cancel
</b-button>
<b-button type="is-primary"
class="control"
@click="updateHandler()"
:disabled="updateHandlerDisabled">
Update Handler
</b-button>
</b-field>
</div>
</div>
</b-modal>
</%def>
<%def name="modify_this_page_vars()">
${parent.modify_this_page_vars()}
<script type="text/javascript">
ThisPageData.handlersData = ${json.dumps(handlers_data)|n}
ThisPageData.editHandlerShowDialog = false
ThisPageData.editingHandler = null
ThisPageData.editingHandlerHostTitle = null
ThisPageData.editingHandlerLocalTitle = null
ThisPageData.editingHandlerDirection = 'import'
ThisPageData.editingHandlerSpec = null
ThisPageData.editingHandlerSpecOptions = []
ThisPageData.editingHandlerCommand = null
ThisPageData.editingHandlerSubcommand = null
ThisPageData.editingHandlerRunas = null
ThisPageData.settingsNeedSaved = false
ThisPageData.undoChanges = false
ThisPageData.savingSettings = false
ThisPage.computed.updateHandlerDisabled = function() {
if (!this.editingHandlerSpec) {
return true
}
if (!this.editingHandlerCommand) {
return true
}
if (!this.editingHandlerSubcommand) {
return true
}
return false
}
ThisPage.methods.editHandler = function(row) {
this.editingHandler = row
this.editingHandlerHostTitle = row.host_title
this.editingHandlerLocalTitle = row.local_title
this.editingHandlerDirection = row.direction_display
this.editingHandlerSpec = row.handler_spec
this.editingHandlerSpecOptions = row.spec_options
this.editingHandlerCommand = row.command
this.editingHandlerSubcommand = row.subcommand
this.editingHandlerRunas = row.default_runas
this.editHandlerShowDialog = true
}
ThisPage.methods.updateHandler = function() {
let row = this.editingHandler
row.handler_spec = this.editingHandlerSpec
row.command = this.editingHandlerCommand
row.subcommand = this.editingHandlerSubcommand
row.default_runas = this.editingHandlerRunas
this.settingsNeedSaved = true
this.editHandlerShowDialog = false
}
ThisPage.methods.settingsCollectParams = function() {
return {
handlers: this.handlersData,
}
}
</script>
</%def>
${parent.body()}

View file

@ -162,6 +162,9 @@
<li>${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}</li> <li>${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}</li>
% endif % endif
% endif % endif
% if master.configurable and master.has_perm('configure'):
<li>${h.link_to("Configure {}".format(config_title), url('{}.configure'.format(route_prefix)))}</li>
% endif
</%def> </%def>
<%def name="grid_tools()"> <%def name="grid_tools()">

View file

@ -32,54 +32,44 @@ import logging
from rattail.db import model from rattail.db import model
from rattail.datasync.config import load_profiles from rattail.datasync.config import load_profiles
from rattail.datasync.util import get_lastrun, purge_datasync_settings from rattail.datasync.util import purge_datasync_settings
from tailbone.views import MasterView from tailbone.views import MasterView
from tailbone.util import csrf_token
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class DataSyncChangeView(MasterView): class DataSyncThreadView(MasterView):
""" """
Master view for the DataSyncChange model. Master view for DataSync itself.
This should (eventually) show all running threads in the main
index view, with status for each, sort of akin to "dashboard".
For now it only serves the config view.
""" """
model_class = model.DataSyncChange normalized_model_name = 'datasyncthread'
url_prefix = '/datasync/changes' model_title = "DataSync Thread"
permission_prefix = 'datasync' model_key = 'key'
route_prefix = 'datasync'
url_prefix = '/datasync'
viewable = False
creatable = False creatable = False
editable = False editable = False
bulk_deletable = True deletable = False
filterable = False
pageable = False
labels = { configurable = True
'batch_id': "Batch ID", config_title = "DataSync"
}
grid_columns = [ grid_columns = [
'source', 'key',
'batch_id',
'batch_sequence',
'payload_type',
'payload_key',
'deletion',
'obtained',
'consumer',
] ]
def configure_grid(self, g): def get_data(self, session=None):
super(DataSyncChangeView, self).configure_grid(g) data = []
return data
# batch_sequence
g.set_label('batch_sequence', "Batch Seq.")
g.filters['batch_sequence'].label = "Batch Sequence"
g.set_sort_defaults('obtained')
g.set_type('obtained', 'datetime')
def template_kwargs_index(self, **kwargs):
kwargs['allow_filemon_restart'] = bool(self.rattail_config.get('tailbone', 'filemon.restart'))
return kwargs
def restart(self): def restart(self):
cmd = self.rattail_config.getlist('tailbone', 'datasync.restart', cmd = self.rattail_config.getlist('tailbone', 'datasync.restart',
@ -93,23 +83,7 @@ class DataSyncChangeView(MasterView):
self.request.session.flash("DataSync daemon could not be restarted; result was: {}".format(result), 'error') self.request.session.flash("DataSync daemon could not be restarted; result was: {}".format(result), 'error')
return self.redirect(self.request.get_referrer(default=self.request.route_url('datasyncchanges'))) return self.redirect(self.request.get_referrer(default=self.request.route_url('datasyncchanges')))
def configure(self): def configure_get_context(self):
"""
View for configuring the DataSync daemon.
"""
if self.request.method == 'POST':
# if self.request.is_xhr and not self.request.POST:
if self.request.POST.get('purge_settings'):
self.delete_settings()
self.request.session.flash("Settings have been removed.")
return self.redirect(self.request.current_route_url())
else:
data = self.request.json_body
self.save_settings(data)
self.request.session.flash("Settings have been saved. "
"You should probably restart DataSync now.")
return self.json_response({'success': True})
profiles = load_profiles(self.rattail_config, profiles = load_profiles(self.rattail_config,
include_disabled=True, include_disabled=True,
ignore_problems=True) ignore_problems=True)
@ -148,27 +122,21 @@ class DataSyncChangeView(MasterView):
profiles_data.append(data) profiles_data.append(data)
return { return {
'master': self,
# TODO: really only buefy themes are supported here
'use_buefy': self.get_use_buefy(),
'index_title': "DataSync Changes",
'index_url': self.get_index_url(),
'profiles': profiles, 'profiles': profiles,
'profiles_data': profiles_data, 'profiles_data': profiles_data,
'restart_command': self.rattail_config.get('tailbone', 'datasync.restart'), 'restart_command': self.rattail_config.get('tailbone', 'datasync.restart'),
'system_user': getpass.getuser(), 'system_user': getpass.getuser(),
} }
def save_settings(self, data): def configure_gather_settings(self, data):
model = self.model
# collect new settings
settings = [] settings = []
watch = [] watch = []
for profile in data['profiles']: for profile in data['profiles']:
pkey = profile['key'] pkey = profile['key']
if profile['enabled']: if profile['enabled']:
watch.append(pkey) watch.append(pkey)
settings.extend([ settings.extend([
{'name': 'rattail.datasync.{}.watcher'.format(pkey), {'name': 'rattail.datasync.{}.watcher'.format(pkey),
'value': profile['watcher_spec']}, 'value': profile['watcher_spec']},
@ -183,10 +151,12 @@ class DataSyncChangeView(MasterView):
{'name': 'rattail.datasync.{}.consumers.runas'.format(pkey), {'name': 'rattail.datasync.{}.consumers.runas'.format(pkey),
'value': profile['watcher_default_runas']}, 'value': profile['watcher_default_runas']},
]) ])
consumers = [] consumers = []
if profile['watcher_consumes_self']: if profile['watcher_consumes_self']:
consumers = ['self'] consumers = ['self']
else: else:
for consumer in profile['consumers_data']: for consumer in profile['consumers_data']:
ckey = consumer['key'] ckey = consumer['key']
if consumer['enabled']: if consumer['enabled']:
@ -205,10 +175,12 @@ class DataSyncChangeView(MasterView):
{'name': 'rattail.datasync.{}.consumer.{}.runas'.format(pkey, ckey), {'name': 'rattail.datasync.{}.consumer.{}.runas'.format(pkey, ckey),
'value': consumer['consumer_runas']}, 'value': consumer['consumer_runas']},
]) ])
settings.extend([ settings.extend([
{'name': 'rattail.datasync.{}.consumers'.format(pkey), {'name': 'rattail.datasync.{}.consumers'.format(pkey),
'value': ', '.join(consumers)}, 'value': ', '.join(consumers)},
]) ])
settings.extend([ settings.extend([
{'name': 'rattail.datasync.watch', {'name': 'rattail.datasync.watch',
'value': ', '.join(watch)}, 'value': ', '.join(watch)},
@ -216,15 +188,9 @@ class DataSyncChangeView(MasterView):
'value': data['restart_command']}, 'value': data['restart_command']},
]) ])
# delete all current settings return settings
self.delete_settings()
# create all new settings def configure_remove_settings(self):
for setting in settings:
self.Session.add(model.Setting(name=setting['name'],
value=setting['value']))
def delete_settings(self):
purge_datasync_settings(self.rattail_config, self.Session()) purge_datasync_settings(self.rattail_config, self.Session())
@classmethod @classmethod
@ -235,33 +201,65 @@ class DataSyncChangeView(MasterView):
@classmethod @classmethod
def _datasync_defaults(cls, config): def _datasync_defaults(cls, config):
permission_prefix = cls.get_permission_prefix() permission_prefix = cls.get_permission_prefix()
route_prefix = cls.get_route_prefix()
url_prefix = cls.get_url_prefix()
# fix permission group title # restart
config.add_tailbone_permission_group(permission_prefix, label="DataSync")
# restart datasync
config.add_tailbone_permission(permission_prefix, config.add_tailbone_permission(permission_prefix,
'{}.restart'.format(permission_prefix), '{}.restart'.format(permission_prefix),
label="Restart the DataSync daemon") label="Restart the DataSync daemon")
config.add_route('datasync.restart', '/datasync/restart', config.add_route('{}.restart'.format(route_prefix),
'{}/restart'.format(url_prefix),
request_method='POST') request_method='POST')
config.add_view(cls, attr='restart', config.add_view(cls, attr='restart',
route_name='datasync.restart', route_name='{}.restart'.format(route_prefix),
permission='{}.restart'.format(permission_prefix)) permission='{}.restart'.format(permission_prefix))
# configure datasync
config.add_tailbone_permission(permission_prefix, class DataSyncChangeView(MasterView):
'{}.configure'.format(permission_prefix), """
label="Configure the DataSync daemon") Master view for the DataSyncChange model.
config.add_route('datasync.configure', '/datasync/configure') """
config.add_view(cls, attr='configure', model_class = model.DataSyncChange
route_name='datasync.configure', url_prefix = '/datasync/changes'
permission='{}.configure'.format(permission_prefix), permission_prefix = 'datasync_changes'
renderer='/datasync/configure.mako') creatable = False
editable = False
bulk_deletable = True
labels = {
'batch_id': "Batch ID",
}
grid_columns = [
'source',
'batch_id',
'batch_sequence',
'payload_type',
'payload_key',
'deletion',
'obtained',
'consumer',
]
def configure_grid(self, g):
super(DataSyncChangeView, self).configure_grid(g)
# batch_sequence
g.set_label('batch_sequence', "Batch Seq.")
g.filters['batch_sequence'].label = "Batch Sequence"
g.set_sort_defaults('obtained')
g.set_type('obtained', 'datetime')
def template_kwargs_index(self, **kwargs):
kwargs['allow_filemon_restart'] = bool(self.rattail_config.get('tailbone', 'filemon.restart'))
return kwargs
# TODO: deprecate / remove this # TODO: deprecate / remove this
DataSyncChangesView = DataSyncChangeView DataSyncChangesView = DataSyncChangeView
def includeme(config): def includeme(config):
DataSyncThreadView.defaults(config)
DataSyncChangeView.defaults(config) DataSyncChangeView.defaults(config)

View file

@ -35,6 +35,7 @@ import time
import json import json
import six import six
import sqlalchemy as sa
from rattail.exceptions import ConfigurationError from rattail.exceptions import ConfigurationError
from rattail.threads import Thread from rattail.threads import Thread
@ -66,14 +67,19 @@ class ImportingView(MasterView):
filterable = False filterable = False
pageable = False pageable = False
configurable = True
config_title = "Import / Export"
labels = { labels = {
'host_title': "Data Source", 'host_title': "Data Source",
'local_title': "Data Target", 'local_title': "Data Target",
'direction_display': "Direction",
} }
grid_columns = [ grid_columns = [
'host_title', 'host_title',
'local_title', 'local_title',
'direction_display',
'handler_spec', 'handler_spec',
] ]
@ -84,6 +90,7 @@ class ImportingView(MasterView):
'handler_spec', 'handler_spec',
'host_title', 'host_title',
'local_title', 'local_title',
'direction_display',
'models', 'models',
] ]
@ -105,18 +112,14 @@ class ImportingView(MasterView):
app = self.get_rattail_app() app = self.get_rattail_app()
data = [] data = []
for Handler in app.all_import_handlers(): for handler in app.get_designated_import_handlers(
handler = Handler(self.rattail_config) ignore_errors=True, sort=True):
data.append(self.normalize(handler)) data.append(self.normalize(handler))
data.sort(key=lambda handler: (handler['host_title'],
handler['local_title']))
return data return data
def normalize(self, handler): def normalize(self, handler, keep_handler=True):
Handler = handler.__class__ data = {
return {
'_handler': handler,
'key': handler.get_key(), 'key': handler.get_key(),
'generic_title': handler.get_generic_title(), 'generic_title': handler.get_generic_title(),
'host_key': handler.host_key, 'host_key': handler.host_key,
@ -124,8 +127,32 @@ class ImportingView(MasterView):
'local_key': handler.local_key, 'local_key': handler.local_key,
'local_title': handler.get_generic_local_title(), 'local_title': handler.get_generic_local_title(),
'handler_spec': handler.get_spec(), 'handler_spec': handler.get_spec(),
'direction': handler.direction,
'direction_display': handler.direction.capitalize(),
} }
if keep_handler:
data['_handler'] = handler
alternates = getattr(handler, 'alternate_handlers', None)
if alternates:
data['alternates'] = []
for alternate in alternates:
data['alternates'].append(self.normalize(
alternate, keep_handler=keep_handler))
cmd = self.get_cmd_for_handler(handler, ignore_errors=True)
if cmd:
data['cmd'] = ' '.join(cmd)
data['command'] = cmd[0]
data['subcommand'] = cmd[1]
runas = self.get_runas_for_handler(handler)
if runas:
data['default_runas'] = runas
return data
def configure_grid(self, g): def configure_grid(self, g):
super(ImportingView, self).configure_grid(g) super(ImportingView, self).configure_grid(g)
@ -139,9 +166,9 @@ class ImportingView(MasterView):
""" """
key = self.request.matchdict['key'] key = self.request.matchdict['key']
app = self.get_rattail_app() app = self.get_rattail_app()
for Handler in app.all_import_handlers(): handler = app.get_designated_import_handler(key, ignore_errors=True)
if Handler.get_key() == key: if handler:
return self.normalize(Handler(self.rattail_config)) return self.normalize(handler)
raise self.notfound() raise self.notfound()
def get_instance_title(self, handler_info): def get_instance_title(self, handler_info):
@ -206,8 +233,8 @@ class ImportingView(MasterView):
def cache_runjob_form_values(self, handler, form): def cache_runjob_form_values(self, handler, form):
handler_key = handler.get_key() handler_key = handler.get_key()
def make_key(key): def make_key(field):
return 'rattail.importing.{}.{}'.format(handler_key, key) return 'rattail.importing.{}.{}'.format(handler_key, field)
for field in form.fields: for field in form.fields:
key = make_key(field) key = make_key(field)
@ -216,8 +243,8 @@ class ImportingView(MasterView):
def read_cached_runjob_values(self, handler, form): def read_cached_runjob_values(self, handler, form):
handler_key = handler.get_key() handler_key = handler.get_key()
def make_key(key): def make_key(field):
return 'rattail.importing.{}.{}'.format(handler_key, key) return 'rattail.importing.{}.{}'.format(handler_key, field)
for field in form.fields: for field in form.fields:
key = make_key(field) key = make_key(field)
@ -331,8 +358,10 @@ class ImportingView(MasterView):
# invoke handler command via subprocess # invoke handler command via subprocess
try: try:
result = subprocess.run(cmd, check=True, capture_output=True) result = subprocess.run(cmd, check=True,
output = result.stderr.decode('utf_8').strip() stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
output = result.stdout.decode('utf_8').strip()
except Exception as error: except Exception as error:
log.warning("failed to invoke handler cmd: %s", cmd, exc_info=True) log.warning("failed to invoke handler cmd: %s", cmd, exc_info=True)
@ -346,14 +375,14 @@ class ImportingView(MasterView):
{} {}
``` ```
And here is the STDERR output: And here is the output:
``` ```
{} {}
``` ```
""".format(handler.direction.capitalize(), """.format(handler.direction.capitalize(),
' '.join(cmd), ' '.join(cmd),
error.stderr.decode('utf_8').strip()) error.stdout.decode('utf_8').strip())
msg = markdown.markdown(msg, extensions=['fenced_code']) msg = markdown.markdown(msg, extensions=['fenced_code'])
msg = HTML.literal(msg) msg = HTML.literal(msg)
msg = HTML.tag('div', class_='tailbone-markdown', c=[msg]) msg = HTML.tag('div', class_='tailbone-markdown', c=[msg])
@ -394,21 +423,35 @@ And here is the STDERR output:
notes = HTML.literal(notes) notes = HTML.literal(notes)
return HTML.tag('div', class_='tailbone-markdown', c=[notes]) return HTML.tag('div', class_='tailbone-markdown', c=[notes])
def make_runjob_cmd(self, handler, form, typ, port=None): def get_cmd_for_handler(self, handler, ignore_errors=False):
handler_key = handler.get_key() handler_key = handler.get_key()
option = '{}.cmd'.format(handler_key) cmd = self.rattail_config.getlist('rattail.importing',
cmd = self.rattail_config.getlist('rattail.importing', option) '{}.cmd'.format(handler_key))
if not cmd or len(cmd) != 2: if not cmd or len(cmd) != 2:
msg = ("Missing or invalid config; please set '{}' in the " cmd = self.rattail_config.getlist('rattail.importing',
"[rattail.importing] section of your config file".format(option)) '{}.default_cmd'.format(handler_key))
raise ConfigurationError(msg)
command, subcommand = cmd if not cmd or len(cmd) != 2:
msg = ("Missing or invalid config; please set '{}.default_cmd' in the "
"[rattail.importing] section of your config file".format(handler_key))
if ignore_errors:
return
raise ConfigurationError(msg)
option = '{}.runas'.format(handler_key) return cmd
runas = self.rattail_config.require('rattail.importing', option)
def get_runas_for_handler(self, handler):
handler_key = handler.get_key()
runas = self.rattail_config.get('rattail.importing',
'{}.runas'.format(handler_key))
if runas:
return runas
return self.rattail_config.get('rattail', 'runas.default')
def make_runjob_cmd(self, handler, form, typ, port=None):
command, subcommand = self.get_cmd_for_handler(handler)
runas = self.get_runas_for_handler(handler)
data = form.validated data = form.validated
if typ == 'true': if typ == 'true':
@ -460,7 +503,10 @@ And here is the STDERR output:
cmd.append('--dry-run') cmd.append('--dry-run')
if data['warnings']: if data['warnings']:
cmd.append('--warnings') if typ == 'true':
cmd.append('--warnings')
else:
cmd.append('-W')
return cmd return cmd
@ -479,6 +525,54 @@ cd {prefix}
self.request.session['rattail.importing.runjob.notes'] = markdown.markdown( self.request.session['rattail.importing.runjob.notes'] = markdown.markdown(
notes, extensions=['fenced_code', 'codehilite']) notes, extensions=['fenced_code', 'codehilite'])
def configure_get_context(self):
app = self.get_rattail_app()
handlers_data = []
for handler in app.get_designated_import_handlers(
with_alternates=True,
ignore_errors=True, sort=True):
data = self.normalize(handler, keep_handler=False)
data['spec_options'] = [handler.get_spec()]
for alternate in handler.alternate_handlers:
data['spec_options'].append(alternate.get_spec())
data['spec_options'].sort()
handlers_data.append(data)
return {
'handlers_data': handlers_data,
}
def configure_gather_settings(self, data):
settings = []
for handler in data['handlers']:
key = handler['key']
settings.extend([
{'name': 'rattail.importing.{}.handler'.format(key),
'value': handler['handler_spec']},
{'name': 'rattail.importing.{}.cmd'.format(key),
'value': '{} {}'.format(handler['command'],
handler['subcommand'])},
{'name': 'rattail.importing.{}.runas'.format(key),
'value': handler['default_runas']},
])
return settings
def configure_remove_settings(self):
model = self.model
self.Session.query(model.Setting)\
.filter(sa.or_(
model.Setting.name.like('rattail.importing.%.handler'),
model.Setting.name.like('rattail.importing.%.cmd'),
model.Setting.name.like('rattail.importing.%.runas')))\
.delete(synchronize_session=False)
@classmethod @classmethod
def defaults(cls, config): def defaults(cls, config):
cls._defaults(config) cls._defaults(config)
@ -493,7 +587,7 @@ cd {prefix}
# run job # run job
config.add_tailbone_permission(permission_prefix, config.add_tailbone_permission(permission_prefix,
'{}.runjob'.format(permission_prefix), '{}.runjob'.format(permission_prefix),
"Run an arbitrary import / export job") "Run an arbitrary Import / Export Job")
config.add_route('{}.runjob'.format(route_prefix), config.add_route('{}.runjob'.format(route_prefix),
'{}/runjob'.format(instance_url_prefix)) '{}/runjob'.format(instance_url_prefix))
config.add_view(cls, attr='runjob', config.add_view(cls, attr='runjob',

View file

@ -114,6 +114,7 @@ class MasterView(View):
execute_progress_initial_msg = None execute_progress_initial_msg = None
supports_prev_next = False supports_prev_next = False
supports_import_batch_from_file = False supports_import_batch_from_file = False
configurable = False
# set to True to add "View *global* Objects" permission, and # set to True to add "View *global* Objects" permission, and
# expose / leverage the ``local_only`` object flag # expose / leverage the ``local_only`` object flag
@ -2032,6 +2033,16 @@ class MasterView(View):
""" """
return getattr(cls, 'index_title', cls.get_model_title_plural()) return getattr(cls, 'index_title', cls.get_model_title_plural())
@classmethod
def get_config_title(cls):
"""
Returns the view's "config title".
"""
if hasattr(cls, 'config_title'):
return cls.config_title
return cls.get_model_title_plural()
def get_action_url(self, action, instance, **kwargs): def get_action_url(self, action, instance, **kwargs):
""" """
Generate a URL for the given action on the given instance Generate a URL for the given action on the given instance
@ -2075,6 +2086,7 @@ class MasterView(View):
'permission_prefix': self.get_permission_prefix(), 'permission_prefix': self.get_permission_prefix(),
'index_title': self.get_index_title(), 'index_title': self.get_index_title(),
'index_url': self.get_index_url(), 'index_url': self.get_index_url(),
'config_title': self.get_config_title(),
'action_url': self.get_action_url, 'action_url': self.get_action_url,
'grid_index': self.grid_index, 'grid_index': self.grid_index,
'help_url': self.get_help_url(), 'help_url': self.get_help_url(),
@ -3982,7 +3994,46 @@ class MasterView(View):
return diffs.Diff(old_data, new_data, **kwargs) return diffs.Diff(old_data, new_data, **kwargs)
############################## ##############################
# Config Stuff # Configuration Views
##############################
def configure(self):
"""
Generic view for configuring some aspect of the software.
"""
if self.request.method == 'POST':
if self.request.POST.get('remove_settings'):
self.configure_remove_settings()
self.request.session.flash("Settings have been removed.")
return self.redirect(self.request.current_route_url())
else:
data = self.request.json_body
settings = self.configure_gather_settings(data)
self.configure_remove_settings()
self.configure_save_settings(settings)
self.request.session.flash("Settings have been saved.")
return self.json_response({'success': True})
context = self.configure_get_context()
return self.render_to_response('configure', context)
def configure_get_context(self):
return {}
def configure_gather_settings(self, data):
return []
def configure_remove_settings(self):
pass
def configure_save_settings(self, settings):
model = self.model
for setting in settings:
self.Session.add(model.Setting(name=setting['name'],
value=setting['value']))
##############################
# Pyramid View Config
############################## ##############################
@classmethod @classmethod
@ -4025,6 +4076,7 @@ class MasterView(View):
model_key = cls.get_model_key() model_key = cls.get_model_key()
model_title = cls.get_model_title() model_title = cls.get_model_title()
model_title_plural = cls.get_model_title_plural() model_title_plural = cls.get_model_title_plural()
config_title = cls.get_config_title()
if cls.has_rows: if cls.has_rows:
row_model_title = cls.get_row_model_title() row_model_title = cls.get_row_model_title()
@ -4087,6 +4139,17 @@ class MasterView(View):
config.add_view(cls, attr='download_results_rows', route_name='{}.download_results_rows'.format(route_prefix), config.add_view(cls, attr='download_results_rows', route_name='{}.download_results_rows'.format(route_prefix),
permission='{}.download_results_rows'.format(permission_prefix)) permission='{}.download_results_rows'.format(permission_prefix))
# configure
if cls.configurable:
config.add_tailbone_permission(permission_prefix,
'{}.configure'.format(permission_prefix),
label="Configure {}".format(config_title))
config.add_route('{}.configure'.format(route_prefix),
'{}/configure'.format(url_prefix))
config.add_view(cls, attr='configure',
route_name='{}.configure'.format(route_prefix),
permission='{}.configure'.format(permission_prefix))
# quickie (search) # quickie (search)
if cls.supports_quickie_search: if cls.supports_quickie_search:
config.add_tailbone_permission(permission_prefix, '{}.quickie'.format(permission_prefix), config.add_tailbone_permission(permission_prefix, '{}.quickie'.format(permission_prefix),

View file

@ -0,0 +1,58 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2021 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Raw Permission Views
"""
from __future__ import unicode_literals, absolute_import
from sqlalchemy import orm
from rattail.db import model
from tailbone.views import MasterView
class PermissionView(MasterView):
"""
Master view for the permissions model.
"""
model_class = model.Permission
model_title = "Raw Permission"
editable = False
bulk_deletable = True
grid_columns = [
'role',
'permission',
]
def query(self, session):
model = self.model
query = super(PermissionView, self).query(session)
query = query.options(orm.joinedload(model.Permission.role))
return query
def includeme(config):
PermissionView.defaults(config)