From 8f69b07ee2085287697d61e87bfe4a9fb2aabae2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 4 Feb 2021 16:44:40 -0600 Subject: [PATCH 0001/1378] Fix bug when editing a Person --- tailbone/views/people.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index de970119..6e72fe1f 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -180,7 +180,7 @@ class PersonView(MasterView): names['middle'] = data['middle_name'] if 'last_name' in form: names['last'] = data['last_name'] - if 'display_name' in form: + if 'display_name' in form and 'display_name' not in form.readonly_fields: names['full'] = data['display_name'] # TODO: why do we find colander.null values in data at this point? From 85403dfa5ea8a7afd42f1bc3d4f6d60baf2d1caa Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 4 Feb 2021 16:45:24 -0600 Subject: [PATCH 0002/1378] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index eb919210..77fc38cf 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.124 (2021-02-04) +-------------------- + +* Fix bug when editing a Person. + + 0.8.123 (2021-02-04) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 5e5bd121..c5d2b24f 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.123' +__version__ = '0.8.124' From cc2308c3992e064441fdcf6040ba20f532c0403d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 9 Feb 2021 12:19:26 -0600 Subject: [PATCH 0003/1378] Fix some permission bugs when showing batch tools etc. --- tailbone/templates/batch/index.mako | 7 ++++++- tailbone/templates/master/index.mako | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako index 8d54facc..89358567 100644 --- a/tailbone/templates/batch/index.mako +++ b/tailbone/templates/batch/index.mako @@ -150,7 +150,7 @@ <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} - % if master.results_executable and master.has_perm('execute_multiple'): + % if master.results_refreshable and master.has_perm('refresh'): + % endif + % if master.results_executable and master.has_perm('execute_multiple'): + + + +${parent.body()} diff --git a/tailbone/templates/people/merge-requests/view.mako b/tailbone/templates/people/merge-requests/view.mako new file mode 100644 index 00000000..5dcbea03 --- /dev/null +++ b/tailbone/templates/people/merge-requests/view.mako @@ -0,0 +1,40 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="page_content()"> + ${parent.page_content()} + % if not instance.merged and request.has_perm('people.merge'): + % if use_buefy: + ${h.form(url('people.merge'), **{'@submit': 'submitMergeForm'})} + ${h.csrf_token(request)} + ${h.hidden('uuids', value=','.join([instance.removing_uuid, instance.keeping_uuid]))} + + {{ mergeFormButtonText }} + + ${h.end_form()} + % endif + % endif + + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + % if not instance.merged and request.has_perm('people.merge'): + + % endif + + +${parent.body()} diff --git a/tailbone/views/master.py b/tailbone/views/master.py index b21051cb..c6f0253d 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -378,7 +378,7 @@ class MasterView(View): Return a dictionary of kwargs to be passed to the factory when creating new grid instances. """ - checkboxes = self.checkboxes + checkboxes = kwargs.get('checkboxes', self.checkboxes) if not checkboxes and self.mergeable and self.has_perm('merge'): checkboxes = True if not checkboxes and self.supports_set_enabled_toggle and self.has_perm('enable_disable_set'): diff --git a/tailbone/views/people.py b/tailbone/views/people.py index b8e06ced..5b4064b3 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -33,7 +33,7 @@ import sqlalchemy as sa from sqlalchemy import orm from rattail.db import model, api -from rattail.time import localtime +from rattail.time import localtime, make_utc from rattail.util import OrderedDict import colander @@ -68,6 +68,7 @@ class PersonView(MasterView): 'last_name', 'phone', 'email', + 'merge_requested', ] form_fields = [ @@ -93,6 +94,15 @@ class PersonView(MasterView): app = self.get_rattail_app() self.handler = app.get_people_handler() + def make_grid_kwargs(self, **kwargs): + kwargs = super(PersonView, self).make_grid_kwargs(**kwargs) + + # turn on checkboxes if user can create a merge reqeust + if self.mergeable and self.has_perm('request_merge'): + kwargs['checkboxes'] = True + + return kwargs + def configure_grid(self, g): super(PersonView, self).configure_grid(g) @@ -123,6 +133,9 @@ class PersonView(MasterView): g.sorters['email'] = lambda q, d: q.order_by(getattr(model.PersonEmailAddress.address, d)()) g.sorters['phone'] = lambda q, d: q.order_by(getattr(model.PersonPhoneNumber.number, d)()) + g.set_label('merge_requested', "MR") + g.set_renderer('merge_requested', self.render_merge_requested) + g.set_sort_defaults('display_name') g.set_label('display_name', "Full Name") @@ -134,6 +147,23 @@ class PersonView(MasterView): g.set_link('first_name') g.set_link('last_name') + def render_merge_requested(self, person, field): + model = self.model + merge_request = self.Session.query(model.MergePeopleRequest)\ + .filter(sa.or_( + model.MergePeopleRequest.removing_uuid == person.uuid, + model.MergePeopleRequest.keeping_uuid == person.uuid))\ + .filter(model.MergePeopleRequest.merged == None)\ + .first() + if merge_request: + use_buefy = self.get_use_buefy() + if use_buefy: + return HTML.tag('span', + class_='has-text-danger has-text-weight-bold', + title="A merge has been requested for this person.", + c="MR") + return "MR" + def get_instance(self): # TODO: I don't recall why this fallback check for a vendor contact # exists here, but leaving it intact for now. @@ -383,7 +413,7 @@ class PersonView(MasterView): raise Exception(reason) def merge_objects(self, removing, keeping): - self.handler.perform_merge(removing, keeping) + self.handler.perform_merge(removing, keeping, user=self.request.user) def view_profile(self): """ @@ -696,6 +726,18 @@ class PersonView(MasterView): self.request.session.flash("User has been created: {}".format(user.username)) return self.redirect(self.request.route_url('users.view', uuid=user.uuid)) + def request_merge(self): + """ + Create a new merge request for the given 2 people. + """ + merge = self.model.MergePeopleRequest() + merge.removing_uuid = self.request.POST['removing_uuid'] + merge.keeping_uuid = self.request.POST['keeping_uuid'] + merge.requested_by = self.request.user + merge.requested = make_utc() + self.Session.add(merge) + return self.redirect(self.get_index_url()) + @classmethod def defaults(cls, config): cls._people_defaults(config) @@ -709,6 +751,7 @@ class PersonView(MasterView): instance_url_prefix = cls.get_instance_url_prefix() model_key = cls.get_model_key() model_title = cls.get_model_title() + model_title_plural = cls.get_model_title_plural() # "profile" perms # TODO: should let view class (or config) determine which of these are available @@ -777,6 +820,15 @@ class PersonView(MasterView): config.add_view(cls, attr='make_user', route_name='{}.make_user'.format(route_prefix), permission='users.create') + # merge requests + if cls.mergeable: + config.add_tailbone_permission(permission_prefix, '{}.request_merge'.format(permission_prefix), + "Request merge for 2 {}".format(model_title_plural)) + config.add_route('{}.request_merge'.format(route_prefix), '{}/request-merge'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='request_merge', route_name='{}.request_merge'.format(route_prefix), + permission='{}.request_merge'.format(permission_prefix)) + # TODO: deprecate / remove this PeopleView = PersonView @@ -888,6 +940,84 @@ class NoteSchema(colander.Schema): note_text = colander.SchemaNode(colander.String(), missing='') +class MergePeopleRequestView(MasterView): + """ + Master view for the MergePeopleRequest class. + """ + model_class = model.MergePeopleRequest + route_prefix = 'people_merge_requests' + url_prefix = '/people/merge-requests' + creatable = False + editable = False + + labels = { + 'removing_uuid': "Removing", + 'keeping_uuid': "Keeping", + } + + grid_columns = [ + 'removing_uuid', + 'keeping_uuid', + 'requested', + 'requested_by', + 'merged', + 'merged_by', + ] + + form_fields = [ + 'removing_uuid', + 'keeping_uuid', + 'requested', + 'requested_by', + 'merged', + 'merged_by', + ] + + def configure_grid(self, g): + super(MergePeopleRequestView, self).configure_grid(g) + + g.set_renderer('removing_uuid', self.render_referenced_person_name) + g.set_renderer('keeping_uuid', self.render_referenced_person_name) + + g.filters['merged'].default_active = True + g.filters['merged'].default_verb = 'is_null' + + g.set_sort_defaults('requested', 'desc') + + g.set_link('removing_uuid') + g.set_link('keeping_uuid') + + def render_referenced_person_name(self, merge_request, field): + uuid = getattr(merge_request, field) + person = self.Session.query(self.model.Person).get(uuid) + if person: + return six.text_type(person) + return "(person not found)" + + def get_instance_title(self, merge_request): + model = self.model + removing = self.Session.query(model.Person).get(merge_request.removing_uuid) + keeping = self.Session.query(model.Person).get(merge_request.keeping_uuid) + return "{} -> {}".format( + removing or "(not found)", + keeping or "(not found)") + + def configure_form(self, f): + super(MergePeopleRequestView, self).configure_form(f) + + f.set_renderer('removing_uuid', self.render_referenced_person) + f.set_renderer('keeping_uuid', self.render_referenced_person) + + def render_referenced_person(self, merge_request, field): + uuid = getattr(merge_request, field) + person = self.Session.query(self.model.Person).get(uuid) + if person: + text = six.text_type(person) + url = self.request.route_url('people.view', uuid=person.uuid) + return tags.link_to(text, url) + return "(person not found)" + + def includeme(config): # autocomplete @@ -900,3 +1030,4 @@ def includeme(config): PersonView.defaults(config) PersonNoteView.defaults(config) + MergePeopleRequestView.defaults(config) From a881b310bc96610598a91e5ea8e0e187ca4e4ada Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 23 Aug 2021 14:25:08 -0500 Subject: [PATCH 0060/1378] Allow customization of row 'view' action url --- tailbone/views/master.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index c6f0253d..6d5a3d96 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -490,9 +490,8 @@ class MasterView(View): # view action if self.rows_viewable: - view = lambda r, i: self.get_row_action_url('view', r) icon = 'eye' if use_buefy else 'zoomin' - actions.append(self.make_action('view', icon=icon, url=view)) + actions.append(self.make_action('view', icon=icon, url=self.row_view_action_url)) # edit action if self.rows_editable and self.has_perm('edit_row'): @@ -1344,6 +1343,9 @@ class MasterView(View): """ return True + def row_view_action_url(self, row, i): + return self.get_row_action_url('view', row) + def row_edit_action_url(self, row, i): if self.row_editable(row): return self.get_row_action_url('edit', row) From 3cf4c0f8e40d89cae2ec2a998cf029630006bd63 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 23 Aug 2021 19:26:50 -0500 Subject: [PATCH 0061/1378] Require explicit opt-in for "clicking grid row checks box" feature sometimes it makes sense *not* to enable that, in which case disabled probably should be the default --- tailbone/grids/core.py | 2 ++ tailbone/templates/grids/buefy.mako | 2 ++ tailbone/views/master.py | 5 +++++ 3 files changed, 9 insertions(+) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 5c8e1c87..f6df3375 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -75,6 +75,7 @@ class Grid(object): sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc', pageable=False, default_pagesize=20, default_page=1, checkboxes=False, checked=None, check_handler=None, check_all_handler=None, + clicking_row_checks_box=False, main_actions=[], more_actions=[], delete_speedbump=False, ajax_data_url=None, component='tailbone-grid', **kwargs): @@ -128,6 +129,7 @@ class Grid(object): self.checked = lambda item: False self.check_handler = check_handler self.check_all_handler = check_all_handler + self.clicking_row_checks_box = clicking_row_checks_box self.main_actions = main_actions or [] self.more_actions = more_actions or [] diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 00b9ce9e..6eaff11a 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -143,8 +143,10 @@ :checkable="checkable" % if grid.checkboxes: :checked-rows.sync="checkedRows" + % if grid.clicking_row_checks_box: @click="rowClick" % endif + % endif % if grid.check_handler: @check="${grid.check_handler}" % endif diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 6d5a3d96..a1d6da79 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -73,6 +73,10 @@ class MasterView(View): pageable = True checkboxes = False + # set to True to allow user to click "anywhere" in a row in order + # to toggle its checkbox + clicking_row_checks_box = False + # set to True in order to encode search values as utf-8 use_byte_string_filters = False @@ -399,6 +403,7 @@ class MasterView(View): 'url': lambda obj: self.get_action_url('view', obj), 'checkboxes': checkboxes, 'checked': self.checked, + 'clicking_row_checks_box': self.clicking_row_checks_box, 'assume_local_times': self.has_local_times, } if 'main_actions' not in kwargs and 'more_actions' not in kwargs: From c3079fe899a5c54b77042fe7a0b7742204dd0ab8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 24 Aug 2021 09:39:45 -0500 Subject: [PATCH 0062/1378] Add `before_render_index()` customization hook for MasterView --- tailbone/views/master.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index a1d6da79..f2634328 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -335,8 +335,17 @@ class MasterView(View): context['download_results_rows_fields_available'] = available context['download_results_rows_fields_default'] = self.download_results_rows_fields_default(available) + self.before_render_index() return self.render_to_response('index', context) + def before_render_index(self): + """ + Perform any needed logic just prior to rendering the index + response. Note that this logic is invoked only when rendering + the main index page, but *not* invoked when refreshing partial + grid contents etc. + """ + def make_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): """ Creates a new grid instance From 445862d48d5c07306c314faa27159c1e59f5761c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Aug 2021 11:55:09 -0500 Subject: [PATCH 0063/1378] Update changelog --- CHANGES.rst | 16 ++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 7976b45d..069c6c94 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.8.139 (2021-08-26) +-------------------- + +* Tweak how email preview is sent, and attempt "to" is displayed. + +* Move "merge 2 people" logic into People Handler. + +* Expose "merge request tracking" feature for People data. + +* Allow customization of row 'view' action url. + +* Require explicit opt-in for "clicking grid row checks box" feature. + +* Add ``before_render_index()`` customization hook for MasterView. + + 0.8.138 (2021-08-04) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 10c12322..565c21e5 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.138' +__version__ = '0.8.139' From 897bb177bc649966283fda0c79dad1cd244cb32f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Aug 2021 14:24:56 -0500 Subject: [PATCH 0064/1378] Make it easier to override rendering grid component in master/index was needed so i could pass extra event handlers to it --- tailbone/templates/master/index.mako | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index d1389a47..8e855422 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -466,17 +466,22 @@ % endif + ${self.render_grid_component()} + + % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': + ${h.form('#', ref='deleteObjectForm')} + ${h.csrf_token(request)} + ${h.end_form()} + % endif + + +<%def name="render_grid_component()"> <${grid.component} :csrftoken="csrftoken" % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': @deleteActionClicked="deleteObject" % endif > - % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': - ${h.form('#', ref='deleteObjectForm')} - ${h.csrf_token(request)} - ${h.end_form()} - % endif <%def name="make_this_page_component()"> From fe584f193fca3fdcfaeef35623e034ec998ac5ea Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 28 Aug 2021 18:45:31 -0500 Subject: [PATCH 0065/1378] Always show all grid actions...for now we don't have a great way to accommodate too many actions; ideally could hide some in a drawer, but for now we just show them all for simplicity... --- tailbone/templates/grids/buefy.mako | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 6eaff11a..90e8121b 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -187,7 +187,9 @@ % if grid.main_actions or grid.more_actions: - % for action in grid.main_actions: + ## TODO: we do not currently differentiate for "main vs. more" + ## here, but ideally we would tuck "more" away in a drawer etc. + % for action in grid.main_actions + grid.more_actions: Date: Sun, 29 Aug 2021 10:28:36 -0500 Subject: [PATCH 0066/1378] Allow grid columns to be *invisible* (but still present in grid) this can be useful when you need contextual data for a given row, for sake of front-end UI features, but do not want to actually show the extra data column(s) --- tailbone/grids/core.py | 29 ++++++++++++++++++++++++++++- tailbone/templates/grids/buefy.mako | 5 ++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index f6df3375..c476c426 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -69,7 +69,7 @@ class Grid(object): def __init__(self, key, data, columns=None, width='auto', request=None, model_class=None, model_title=None, model_title_plural=None, - enums={}, labels={}, assume_local_times=False, renderers={}, + enums={}, labels={}, assume_local_times=False, renderers={}, invisible=[], extra_row_class=None, linked_columns=[], url='#', joiners={}, filterable=False, filters={}, use_byte_string_filters=False, sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc', @@ -105,6 +105,7 @@ class Grid(object): self.labels = labels or {} self.assume_local_times = assume_local_times self.renderers = self.make_default_renderers(renderers or {}) + self.invisible = invisible or [] self.extra_row_class = extra_row_class self.linked_columns = linked_columns or [] self.url = url @@ -161,13 +162,38 @@ class Grid(object): return [prop.key for prop in mapper.iterate_properties] def hide_column(self, key): + """ + This *removes* a column from the grid, altogether. + + This method should really be renamed to ``remove_column()`` + instead. + """ if key in self.columns: self.columns.remove(key) def hide_columns(self, *keys): + """ + This *removes* columns from the grid, altogether. + + This method should really be renamed to ``remove_columns()`` + instead. + """ for key in keys: self.hide_column(key) + def set_invisible(self, key, invisible=True): + """ + Mark the given column as "invisible" (but do not remove it). + + Use :meth:`hide_column()` if you actually want to remove it. + """ + if invisible: + if key not in self.invisible: + self.invisible.append(key) + else: + if key in self.invisible: + self.invisible.remove(key) + def append(self, field): self.columns.append(field) @@ -1185,6 +1211,7 @@ class Grid(object): 'field': name, 'label': self.get_label(name), 'sortable': self.sortable and name in self.sorters, + 'visible': name not in self.invisible, }) return columns diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako index 90e8121b..b55ff30e 100644 --- a/tailbone/templates/grids/buefy.mako +++ b/tailbone/templates/grids/buefy.mako @@ -176,7 +176,10 @@