diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index f21c021e..c1799c16 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -18,11 +18,59 @@ ${dynamic_content_title} +<%def name="render_instance_header_buttons()"> + % if request.has_perm('people_profile.view_versions'): + + View History + +
+ + {{ gettingRevisions ? "Working, please wait..." : "Refresh History" }} + + + View Profile + +
+ % endif + + <%def name="page_content()"> - + +<%def name="render_this_page_component()"> + ## TODO: should override this in a cleaner way! too much duplicate code w/ parent template + + + + <%def name="render_this_page()"> ${self.page_content()} @@ -551,6 +599,16 @@ {{ member._key }} + + + {{ member.person_display_name }} + + + {{ member.person_display_name }} + + + @@ -562,7 +620,7 @@ - {{ member.active }} + {{ member.active ? "Yes" : "No" }} @@ -574,16 +632,6 @@ {{ member.withdrew }} - - - {{ member.person_display_name }} - - - {{ member.person_display_name }} - - -
${self.render_member_panel_buttons(member)} @@ -1019,14 +1067,112 @@ ${self.render_user_tab()} +<%def name="render_profile_info_extra_buttons()"> + <%def name="render_profile_info_template()"> @@ -1611,11 +1757,28 @@ phoneTypeOptions: ${json.dumps(phone_type_options)|n}, emailTypeOptions: ${json.dumps(email_type_options)|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 = { template: '#profile-info-template', mixins: [FormPosterMixin], + + % if request.has_perm('people_profile.view_versions'): + props: { + viewingHistory: Boolean, + gettingRevisions: Boolean, + revisions: Array, + revisionVersionMap: null, + }, + % endif + computed: {}, methods: { @@ -1641,6 +1804,29 @@ }, 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()} + % endif + + ${parent.body()} diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 89b857f1..ce15e48a 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -30,6 +30,7 @@ from collections import OrderedDict import sqlalchemy as sa from sqlalchemy import orm +import sqlalchemy_continuum as continuum from rattail.db import model, api from rattail.db.util import maxlen @@ -42,6 +43,7 @@ from webhelpers2.html import HTML, tags from tailbone import forms, grids from tailbone.views import MasterView +from tailbone.util import raw_datetime log = logging.getLogger(__name__) @@ -429,6 +431,9 @@ class PersonView(MasterView): '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' return self.render_to_response(template, context) @@ -1015,6 +1020,188 @@ class PersonView(MasterView): '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): schema = NoteSchema().bind(session=self.Session(), person_uuid=person.uuid) @@ -1269,6 +1456,18 @@ class PersonView(MasterView): renderer='json', 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 if cls.manage_notes_from_profile_view: