Add support for version history in person profile view

yay, finally
This commit is contained in:
Lance Edgar 2023-06-06 16:37:58 -05:00
parent 816e652357
commit cfdb492349
2 changed files with 448 additions and 12 deletions

View file

@ -18,11 +18,59 @@
${dynamic_content_title} ${dynamic_content_title}
</%def> </%def>
<%def name="render_instance_header_buttons()">
% if request.has_perm('people_profile.view_versions'):
<b-button v-if="!viewingHistory"
icon-pack="fas"
icon-left="history"
@click="viewHistory()">
View History
</b-button>
<div v-if="viewingHistory"
class="buttons">
<b-button icon-pack="fas"
icon-left="redo"
@click="refreshHistory()"
:disabled="gettingRevisions">
{{ gettingRevisions ? "Working, please wait..." : "Refresh History" }}
</b-button>
<b-button icon-pack="fas"
icon-left="user"
@click="viewingHistory = false">
View Profile
</b-button>
</div>
% endif
</%def>
<%def name="page_content()"> <%def name="page_content()">
<profile-info @change-content-title="changeContentTitle"> <profile-info @change-content-title="changeContentTitle"
% if request.has_perm('people_profile.view_versions'):
:viewing-history="viewingHistory"
:getting-revisions="gettingRevisions"
:revisions="revisions"
:revision-version-map="revisionVersionMap"
% endif
>
</profile-info> </profile-info>
</%def> </%def>
<%def name="render_this_page_component()">
## TODO: should override this in a cleaner way! too much duplicate code w/ parent template
<this-page @change-content-title="changeContentTitle"
% if can_edit_help:
:configure-fields-help="configureFieldsHelp"
% endif
% if request.has_perm('people_profile.view_versions'):
:viewing-history="viewingHistory"
:getting-revisions="gettingRevisions"
:revisions="revisions"
:revision-version-map="revisionVersionMap"
% endif
>
</this-page>
</%def>
<%def name="render_this_page()"> <%def name="render_this_page()">
${self.page_content()} ${self.page_content()}
</%def> </%def>
@ -551,6 +599,16 @@
{{ member._key }} {{ member._key }}
</b-field> </b-field>
<b-field horizontal label="Person">
<a v-if="member.person_uuid != person.uuid"
:href="member.view_profile_url">
{{ member.person_display_name }}
</a>
<span v-if="member.person_uuid == person.uuid">
{{ member.person_display_name }}
</span>
</b-field>
<b-field horizontal label="Membership Type"> <b-field horizontal label="Membership Type">
<a v-if="member.view_membership_type_url" <a v-if="member.view_membership_type_url"
:href="member.view_membership_type_url"> :href="member.view_membership_type_url">
@ -562,7 +620,7 @@
</b-field> </b-field>
<b-field horizontal label="Active"> <b-field horizontal label="Active">
{{ member.active }} {{ member.active ? "Yes" : "No" }}
</b-field> </b-field>
<b-field horizontal label="Joined"> <b-field horizontal label="Joined">
@ -574,16 +632,6 @@
{{ member.withdrew }} {{ member.withdrew }}
</b-field> </b-field>
<b-field horizontal label="Person">
<a v-if="member.person_uuid != person.uuid"
:href="member.view_profile_url">
{{ member.person_display_name }}
</a>
<span v-if="member.person_uuid == person.uuid">
{{ member.person_display_name }}
</span>
</b-field>
</div> </div>
<div class="buttons" style="align-items: start;"> <div class="buttons" style="align-items: start;">
${self.render_member_panel_buttons(member)} ${self.render_member_panel_buttons(member)}
@ -1019,14 +1067,112 @@
${self.render_user_tab()} ${self.render_user_tab()}
</%def> </%def>
<%def name="render_profile_info_extra_buttons()"></%def>
<%def name="render_profile_info_template()"> <%def name="render_profile_info_template()">
<script type="text/x-template" id="profile-info-template"> <script type="text/x-template" id="profile-info-template">
<div> <div>
${self.render_profile_info_extra_buttons()}
<b-tabs v-model="activeTab" <b-tabs v-model="activeTab"
% if request.has_perm('people_profile.view_versions'):
v-show="!viewingHistory"
% endif
type="is-boxed" type="is-boxed"
@input="activeTabChanged"> @input="activeTabChanged">
${self.render_profile_tabs()} ${self.render_profile_tabs()}
</b-tabs> </b-tabs>
% if request.has_perm('people_profile.view_versions'):
${revisions_grid.render_buefy_table_element(data_prop='revisions',
show_footer=True,
vshow='viewingHistory',
loading='gettingRevisions')|n}
<b-modal :active.sync="showingRevisionDialog">
<div class="card">
<div class="card-content">
<div style="display: flex; justify-content: space-between;">
<div>
<b-field horizontal label="Changed">
<div v-html="revision.changed"></div>
</b-field>
<b-field horizontal label="Changed by">
<div v-html="revision.changed_by"></div>
</b-field>
<b-field horizontal label="IP Address">
<div v-html="revision.remote_addr"></div>
</b-field>
<b-field horizontal label="Comment">
<div v-html="revision.comment"></div>
</b-field>
<b-field horizontal label="TXN ID">
<div v-html="revision.txnid"></div>
</b-field>
</div>
<div>
<div>
<b-button @click="viewPrevRevision()"
:disabled="!revision.prev_txnid">
&laquo; Prev
</b-button>
<b-button @click="viewNextRevision()"
:disabled="!revision.next_txnid">
&raquo; Next
</b-button>
</div>
<br />
<b-button @click="toggleVersionFields()">
{{ revisionShowAllFields ? "Show Diffs Only" : "Show All Fields" }}
</b-button>
</div>
</div>
<br />
<div v-for="version in revision.versions"
:key="version.key">
<p class="block has-text-weight-bold">
{{ version.model_title }}
</p>
<table class="diff monospace is-size-7"
:class="version.diff_class">
<thead>
<tr>
<th>field name</th>
<th>old value</th>
<th>new value</th>
</tr>
</thead>
<tbody>
<tr v-for="field in version.fields"
:key="field"
:class="{diff: version.values[field].after != version.values[field].before}"
v-show="revisionShowAllFields || version.values[field].after != version.values[field].before">
<td class="field">{{ field }}</td>
<td class="old-value">{{ version.values[field].before }}</td>
<td class="new-value">{{ version.values[field].after }}</td>
</tr>
</tbody>
</table>
<br />
</div>
</div>
</div>
</b-modal>
% endif
</div> </div>
</script> </script>
</%def> </%def>
@ -1611,11 +1757,28 @@
phoneTypeOptions: ${json.dumps(phone_type_options)|n}, phoneTypeOptions: ${json.dumps(phone_type_options)|n},
emailTypeOptions: ${json.dumps(email_type_options)|n}, emailTypeOptions: ${json.dumps(email_type_options)|n},
maxLengths: ${json.dumps(max_lengths)|n}, maxLengths: ${json.dumps(max_lengths)|n},
% if request.has_perm('people_profile.view_versions'):
loadingRevisions: false,
showingRevisionDialog: false,
revision: {},
revisionShowAllFields: false,
% endif
} }
let ProfileInfo = { let ProfileInfo = {
template: '#profile-info-template', template: '#profile-info-template',
mixins: [FormPosterMixin], mixins: [FormPosterMixin],
% if request.has_perm('people_profile.view_versions'):
props: {
viewingHistory: Boolean,
gettingRevisions: Boolean,
revisions: Array,
revisionVersionMap: null,
},
% endif
computed: {}, computed: {},
methods: { methods: {
@ -1641,6 +1804,29 @@
}, },
activeTabChangedExtra(value) {}, activeTabChangedExtra(value) {},
% if request.has_perm('people_profile.view_versions'):
viewRevision(row) {
this.revision = this.revisionVersionMap[row.txnid]
this.showingRevisionDialog = true
},
viewPrevRevision() {
let txnid = this.revision.prev_txnid
this.revision = this.revisionVersionMap[txnid]
},
viewNextRevision() {
let txnid = this.revision.next_txnid
this.revision = this.revisionVersionMap[txnid]
},
toggleVersionFields() {
this.revisionShowAllFields = !this.revisionShowAllFields
},
% endif
}, },
} }
@ -1662,6 +1848,13 @@
${parent.modify_this_page_vars()} ${parent.modify_this_page_vars()}
<script type="text/javascript"> <script type="text/javascript">
% if request.has_perm('people_profile.view_versions'):
ThisPage.props.viewingHistory = Boolean
ThisPage.props.gettingRevisions = Boolean
ThisPage.props.revisions = Array
ThisPage.props.revisionVersionMap = null
% endif
ThisPage.methods.changeContentTitle = function(newTitle) { ThisPage.methods.changeContentTitle = function(newTitle) {
this.$emit('change-content-title', newTitle) this.$emit('change-content-title', newTitle)
} }
@ -1717,5 +1910,49 @@
${self.make_profile_info_component()} ${self.make_profile_info_component()}
</%def> </%def>
<%def name="modify_whole_page_vars()">
${parent.modify_whole_page_vars()}
% if request.has_perm('people_profile.view_versions'):
<script type="text/javascript">
WholePageData.viewingHistory = false
WholePageData.gettingRevisions = false
WholePageData.gotRevisions = false
WholePageData.revisions = []
WholePageData.revisionVersionMap = null
WholePage.methods.viewHistory = function() {
this.viewingHistory = true
if (!this.gotRevisions && !this.gettingRevisions) {
this.getRevisions()
}
}
WholePage.methods.refreshHistory = function() {
if (!this.gettingRevisions) {
this.getRevisions()
}
}
WholePage.methods.getRevisions = function() {
this.gettingRevisions = true
let url = '${url('people.view_profile_revisions', uuid=person.uuid)}'
this.simpleGET(url, {}, response => {
this.revisions = response.data.data
this.revisionVersionMap = response.data.vmap
this.gotRevisions = true
this.gettingRevisions = false
}, response => {
this.gettingRevisions = false
})
}
</script>
% endif
</%def>
${parent.body()} ${parent.body()}

View file

@ -30,6 +30,7 @@ from collections import OrderedDict
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import orm from sqlalchemy import orm
import sqlalchemy_continuum as continuum
from rattail.db import model, api from rattail.db import model, api
from rattail.db.util import maxlen from rattail.db.util import maxlen
@ -42,6 +43,7 @@ from webhelpers2.html import HTML, tags
from tailbone import forms, grids from tailbone import forms, grids
from tailbone.views import MasterView from tailbone.views import MasterView
from tailbone.util import raw_datetime
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -429,6 +431,9 @@ class PersonView(MasterView):
'dynamic_content_title': self.get_context_content_title(person), 'dynamic_content_title': self.get_context_content_title(person),
} }
if self.request.has_perm('people_profile.view_versions'):
context['revisions_grid'] = self.profile_revisions_grid(person)
template = 'view_profile_buefy' template = 'view_profile_buefy'
return self.render_to_response(template, context) return self.render_to_response(template, context)
@ -1015,6 +1020,188 @@ class PersonView(MasterView):
'employee': self.get_context_employee(employee), 'employee': self.get_context_employee(employee),
} }
def profile_revisions_grid(self, person):
route_prefix = self.get_route_prefix()
factory = self.get_grid_factory()
g = factory(
'{}.profile.revisions'.format(route_prefix),
[], # start with empty data!
request=self.request,
columns=[
'changed',
'changed_by',
'remote_addr',
'comment',
],
labels={
'remote_addr': "IP Address",
},
linked_columns=[
'changed',
'changed_by',
'comment',
],
main_actions=[
self.make_action('view', icon='eye', url='#',
click_handler='viewRevision(props.row)'),
],
)
return g
def profile_revisions_collect(self, person, versions=None):
model = self.model
versions = versions or []
# Person
cls = continuum.version_class(model.Person)
query = self.Session.query(cls)\
.filter(cls.uuid == person.uuid)
versions.extend(query.all())
# User
cls = continuum.version_class(model.User)
query = self.Session.query(cls)\
.filter(cls.person_uuid == person.uuid)
versions.extend(query.all())
# Member
cls = continuum.version_class(model.Member)
query = self.Session.query(cls)\
.filter(cls.person_uuid == person.uuid)
versions.extend(query.all())
# Employee
cls = continuum.version_class(model.Employee)
query = self.Session.query(cls)\
.filter(cls.person_uuid == person.uuid)
versions.extend(query.all())
# EmployeeHistory
cls = continuum.version_class(model.EmployeeHistory)
query = self.Session.query(cls)\
.join(model.Employee,
model.Employee.uuid == cls.employee_uuid)\
.filter(model.Employee.person_uuid == person.uuid)
versions.extend(query.all())
# PersonPhoneNumber
cls = continuum.version_class(model.PersonPhoneNumber)
query = self.Session.query(cls)\
.filter(cls.parent_uuid == person.uuid)
versions.extend(query.all())
# PersonEmailAddress
cls = continuum.version_class(model.PersonEmailAddress)
query = self.Session.query(cls)\
.filter(cls.parent_uuid == person.uuid)
versions.extend(query.all())
# PersonMailingAddress
cls = continuum.version_class(model.PersonMailingAddress)
query = self.Session.query(cls)\
.filter(cls.parent_uuid == person.uuid)
versions.extend(query.all())
# CustomerPerson
cls = continuum.version_class(model.CustomerPerson)
query = self.Session.query(cls)\
.filter(cls.person_uuid == person.uuid)
versions.extend(query.all())
# Customer
cls = continuum.version_class(model.Customer)
query = self.Session.query(cls)\
.join(model.CustomerPerson, model.CustomerPerson.customer_uuid == cls.uuid)\
.filter(model.CustomerPerson.person_uuid == person.uuid)
versions.extend(query.all())
# PersonNote
cls = continuum.version_class(model.PersonNote)
query = self.Session.query(cls)\
.filter(cls.parent_uuid == person.uuid)
versions.extend(query.all())
return versions
def profile_revisions_data(self):
"""
View which locates and organizes all relevant "transaction"
(version) history data for a given Person. Returns JSON, for
use with the Buefy table element on the full profile view.
"""
person = self.get_instance()
versions = self.profile_revisions_collect(person)
# organize final table data
data = []
all_txns = set([v.transaction for v in versions])
for i, txn in enumerate(
sorted(all_txns, key=lambda txn: txn.issued_at, reverse=True),
1):
data.append({
'txnid': txn.id,
'changed': raw_datetime(self.rattail_config, txn.issued_at),
'changed_by': str(txn.user or '') or None,
'remote_addr': txn.remote_addr,
'comment': txn.meta.get('comment'),
})
# also stash the sequential index for this transaction, for use later
txn._sequential_index = i
# also organize final transaction/versions (diff) map
vmap = {}
for version in versions:
if version.previous and version.operation_type == continuum.Operation.DELETE:
diff_class = 'deleted'
elif version.previous:
diff_class = 'dirty'
else:
diff_class = 'new'
# collect before/after field values for version
fields = self.fields_for_version(version)
values = {}
for field in fields:
before = ''
after = ''
if diff_class != 'new':
before = repr(getattr(version.previous, field))
if diff_class != 'deleted':
after = repr(getattr(version, field))
values[field] = {'before': before, 'after': after}
if version.transaction_id not in vmap:
txn = version.transaction
prev_txnid = None
next_txnid = None
if txn._sequential_index < len(data):
prev_txnid = data[txn._sequential_index]['txnid']
if txn._sequential_index > 1:
next_txnid = data[txn._sequential_index - 2]['txnid']
vmap[txn.id] = {
'index': txn._sequential_index,
'txnid': txn.id,
'prev_txnid': prev_txnid,
'next_txnid': next_txnid,
'changed': raw_datetime(self.rattail_config, txn.issued_at,
verbose=True),
'changed_by': str(txn.user or '') or None,
'remote_addr': txn.remote_addr,
'comment': txn.meta.get('comment'),
'versions': [],
}
vmap[version.transaction_id]['versions'].append({
'key': id(version),
'model_title': self.title_for_version(version),
'diff_class': diff_class,
'fields': fields,
'values': values,
})
return {'data': data, 'vmap': vmap}
def make_note_form(self, mode, person): def make_note_form(self, mode, person):
schema = NoteSchema().bind(session=self.Session(), schema = NoteSchema().bind(session=self.Session(),
person_uuid=person.uuid) person_uuid=person.uuid)
@ -1269,6 +1456,18 @@ class PersonView(MasterView):
renderer='json', renderer='json',
permission='employees.edit') permission='employees.edit')
# profile - revisions data
config.add_tailbone_permission('people_profile',
'people_profile.view_versions',
"View full version history for a profile")
config.add_route(f'{route_prefix}.view_profile_revisions',
f'{instance_url_prefix}/profile/revisions',
request_method='GET')
config.add_view(cls, attr='profile_revisions_data',
route_name=f'{route_prefix}.view_profile_revisions',
permission='people_profile.view_versions',
renderer='json')
# manage notes from profile view # manage notes from profile view
if cls.manage_notes_from_profile_view: if cls.manage_notes_from_profile_view: