From fd50e444cd12972716ed17c3be90ca4c0eea568e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20K=C3=B6nig?= Date: Mon, 18 Jul 2022 14:17:19 +0200 Subject: [PATCH 1/3] RLPPTM: guard against post-approval facility data changes --- VERSION | 2 +- modules/core/msg/base.py | 4 +- modules/templates/RLPPTM/customise/org.py | 223 ++++++++++++++++++---- 3 files changed, 195 insertions(+), 34 deletions(-) diff --git a/VERSION b/VERSION index 94e0025c0..6d444fc3e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -nursix-dev-5446-g7bdf29081 (2022-07-13 23:25:37) +nursix-dev-5447-gdfa43e3d6 (2022-07-18 14:17:19) diff --git a/modules/core/msg/base.py b/modules/core/msg/base.py index 6b0e4ff39..7a5306db0 100644 --- a/modules/core/msg/base.py +++ b/modules/core/msg/base.py @@ -986,7 +986,9 @@ def send_email(self, attachments = [attachments] from email.header import Header for attachment in attachments: - filename = attachment.my_filename.decode("utf-8") + filename = attachment.my_filename + if isinstance(filename, bytes): + filename = filename.decode("utf-8") header = Header('attachment; filename="%s"' % Header(filename, "utf-8").encode()) attachment.replace_header("Content-Disposition", header) diff --git a/modules/templates/RLPPTM/customise/org.py b/modules/templates/RLPPTM/customise/org.py index de4e25df4..11dead641 100644 --- a/modules/templates/RLPPTM/customise/org.py +++ b/modules/templates/RLPPTM/customise/org.py @@ -13,7 +13,7 @@ from ..helpers import workflow_tag_represent -SITE_WORKFLOW = ("MPAV", "HYGIENE", "LAYOUT", "STATUS", "PUBLIC") +SITE_WORKFLOW = ("MPAV", "HYGIENE", "LAYOUT", "STATUS", "PUBLIC", "DHASH") SITE_REVIEW = ("MPAV", "HYGIENE", "LAYOUT") # ------------------------------------------------------------------------- @@ -44,6 +44,36 @@ def add_org_tags(): ), ) +# ------------------------------------------------------------------------- +# Helper functions for approval workflows +# +def get_dhash(*values): + """ + Produce a data verification hash from the values + + Args: + values: an (ordered) iterable of values + Returns: + the verification hash as string + """ + + import hashlib + dstr = "#".join([str(v) if v else "***" for v in values]) + + return hashlib.sha256(dstr.encode("utf-8")).hexdigest().lower() + +def reset_all(tags, value="N/A"): + """ + Set all given workflow tags to initial status + + Args: + tags: the tag Rows + value: the initial value + """ + + for tag in tags: + tag.update_record(value=value) + # ------------------------------------------------------------------------- def mgrinfo_opts(): """ @@ -122,6 +152,7 @@ def update_mgrinfo(organisation_id): reg_tag = httable.with_alias("reg_tag") crc_tag = httable.with_alias("crc_tag") scp_tag = httable.with_alias("scp_tag") + dsh_tag = httable.with_alias("dsh_tag") join = ptable.on(ptable.id == htable.person_id) left = [reg_tag.on((reg_tag.human_resource_id == htable.id) & \ @@ -133,6 +164,9 @@ def update_mgrinfo(organisation_id): scp_tag.on((scp_tag.human_resource_id == htable.id) & \ (scp_tag.tag == "SCP") & \ (scp_tag.deleted == False)), + dsh_tag.on((dsh_tag.human_resource_id == htable.id) & \ + (dsh_tag.tag == "DHASH") & \ + (dsh_tag.deleted == False)), ] query = (htable.organisation_id == organisation_id) & \ @@ -140,10 +174,18 @@ def update_mgrinfo(organisation_id): (htable.status == 1) & \ (htable.deleted == False) - rows = db(query).select(ptable.pe_id, + rows = db(query).select(htable.id, + ptable.pe_id, + ptable.first_name, + ptable.last_name, ptable.date_of_birth, + dsh_tag.id, + dsh_tag.value, + reg_tag.id, reg_tag.value, + crc_tag.id, crc_tag.value, + scp_tag.id, scp_tag.value, join = join, left = left, @@ -152,39 +194,73 @@ def update_mgrinfo(organisation_id): # No managers selected status = "N/A" else: - # Managers selected => check completeness of data/documentation + # Managers selected => check data/documentation status = "REVISE" ctable = s3db.pr_contact for row in rows: - # Check that all documentation tags are set as approved - doc_tags = True - for t in (reg_tag, crc_tag, scp_tag): - if row[t.value] != "APPROVED": - doc_tags = False - break - if not doc_tags: - continue - - # Check DoB - if not row.pr_person.date_of_birth: - continue - - # Check that there is at least one contact details - # of phone/email type - query = (ctable.pe_id == row.pr_person.pe_id) & \ - (ctable.contact_method in ("SMS", "HOME_PHONE", "WORK_PHONE", "EMAIL")) & \ - (ctable.value != None) & \ - (ctable.deleted == False) - contact = db(query).select(ctable.id, limitby=(0, 1)).first() - if not contact: - continue - - # All that given, the manager-data status of the organisation - # can be set as complete - status = "COMPLETE" - break + person = row.pr_person + dob = person.date_of_birth + vhash = get_dhash(person.first_name, + person.last_name, + dob.isoformat() if dob else None, + ) + doc_tags = [row[t._tablename] for t in (reg_tag, crc_tag, scp_tag)] + + # Do we have a verification hash (after previous approval)? + dhash = row.dsh_tag + verified = bool(dhash.id) + accepted = True + + # Check completeness/integrity of data + + # Must have DoB + if accepted and not dob: + # No documentation can be approved without DoB + reset_all(doc_tags) + accepted = False + + # Must have at least one contact detail of the email/phone type + if accepted: + query = (ctable.pe_id == row.pr_person.pe_id) & \ + (ctable.contact_method in ("SMS", "HOME_PHONE", "WORK_PHONE", "EMAIL")) & \ + (ctable.value != None) & \ + (ctable.deleted == False) + contact = db(query).select(ctable.id, limitby=(0, 1)).first() + if not contact: + accepted = False + + # Do the data (still) match the verification hash? + if accepted and verified: + if dhash.value != vhash: + if current.auth.s3_has_role("ORG_GROUP_ADMIN"): + # Data changed by OrgGroupAdmin => update hash + # (authorized change has no influence on approval) + dhash.update_record(value=vhash) + else: + # Data changed by someone else => previous + # approval of documentation no longer valid + reset_all(doc_tags) + accepted = False + + # Check approval status for documentation + if accepted and all(tag.value == "APPROVED" for tag in doc_tags): + if not verified: + # Set the verification hash + dsh_tag.insert(human_resource_id = row[htable.id], + tag = "DHASH", + value = vhash, + ) + + # If at least one record is acceptable, the manager-data + # status of the organisation can be set as complete + status = "COMPLETE" + else: + # Remove the verification hash, if any (unapproved records + # do not need to be integrity-checked) + if verified: + dhash.delete_record() # Update or add MGRINFO-tag with status ottable = s3db.org_organisation_tag @@ -828,6 +904,8 @@ def add_facility_default_tags(facility_id, approve=False): default = "REVIEW" else: default = "APPROVED" if public else "REVIEW" + elif tag == "DHASH": + default = None else: default = "APPROVED" if public else "REVISE" ttable.insert(site_id = site_id, @@ -878,6 +956,72 @@ def set_facility_code(facility_id): return code +# ----------------------------------------------------------------------------- +def facility_approval_hash(tags, site_id, location_id): + """ + Compute and check the verification hash for facility details + + Args: + tags: the current facility workflow tags (including existing hash) + site_id: the facility site ID + location_id: the facility location ID + + Returns: + tuple (update, vhash), where + - update is a dict with workflow tag updates + - vhash is the computed verification hash + + Notes: + - the verification hash encodes certain facility details, so + if those details are changed after approval, then the hash + becomes invalid and any previous approval is overturned + (=reduced to review-status) + - if the user is OrgGroupAdmin or Admin, the approval workflow + status is kept as-is (i.e. Admins can change details without + that impacting the current workflow status) + """ + + db = current.db + s3db = current.s3db + + dhash = tags.get("DHASH") + approved = tags.get("STATUS") == "APPROVED" + + # Extract the location, and compute the hash + ltable = s3db.gis_location + query = (ltable.id == location_id) & \ + (ltable.deleted == False) + location = db(query).select(ltable.id, + ltable.parent, + ltable.addr_street, + ltable.addr_postcode, + limitby = (0, 1), + ).first() + if location: + vhash = get_dhash(location.id, + location.parent, + location.addr_street, + location.addr_postcode, + ) + else: + vhash = get_dhash(None, None, None, None) + + if approved and dhash and dhash != vhash and \ + not current.auth.s3_has_role("ORG_GROUP_ADMIN"): + update = {"PUBLIC": "N"} + status = "REVIEW" + for t in SITE_REVIEW: + value = tags.get(t) + if value == "APPROVED": + update[t] = "REVIEW" + elif value == "REVISE": + status = "REVISE" + update["STATUS"] = status + else: + update = None + + return update, vhash + # ----------------------------------------------------------------------------- def facility_approval_status(tags, mgrinfo): """ @@ -966,6 +1110,7 @@ def facility_approval_workflow(site_id): query = (ftable.site_id == site_id) facility = db(query).select(ftable.id, ftable.organisation_id, + ftable.location_id, ottable.value, left = left, limitby = (0, 1), @@ -1001,8 +1146,22 @@ def facility_approval_workflow(site_id): facility_approval_workflow(site_id) return - # Update tags - update, notify = facility_approval_status(tags, mgrinfo) + # Verify record integrity and compute the verification hash + update, vhash = facility_approval_hash(tags, + site_id, + facility.org_facility.location_id, + ) + notify = False + if not update: + # Integrity check okay => proceed to workflow status + update, notify = facility_approval_status(tags, mgrinfo) + + # If the record would be approved, add the verification hash to the + # update, otherwise reset it to None (=>unapproved records do not need + # to be integrity-checked) + status = update["STATUS"] if "STATUS" in update else tags.get("STATUS") + update["DHASH"] = vhash if status == "APPROVED" else None + for row in rows: key = row.tag if key in update: From dea16f46c5a4cd2cb46c0433edc12ac728ed1217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20K=C3=B6nig?= Date: Mon, 18 Jul 2022 15:07:17 +0200 Subject: [PATCH 2/3] Bug fix --- VERSION | 2 +- modules/core/aaa/auth.py | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/VERSION b/VERSION index 6d444fc3e..7fe79f6bd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -nursix-dev-5447-gdfa43e3d6 (2022-07-18 14:17:19) +nursix-dev-5448-gfd50e444c (2022-07-18 15:07:17) diff --git a/modules/core/aaa/auth.py b/modules/core/aaa/auth.py index 36f355e37..f6c67ad05 100644 --- a/modules/core/aaa/auth.py +++ b/modules/core/aaa/auth.py @@ -4355,14 +4355,16 @@ def s3_remove_role(self, user_id, group_id, for_pe=DEFAULT): # Archive the memberships for m in memberships: deleted_fk = {"user_id": m.user_id, - "group_id": m.group_id} - if for_pe: - deleted_fk["pe_id"] = for_pe + "group_id": m.group_id, + } + if m.pe_id: + deleted_fk["pe_id"] = m.pe_id deleted_fk = json.dumps(deleted_fk) m.update_record(deleted = True, deleted_fk = deleted_fk, user_id = None, - group_id = None) + group_id = None, + ) # Update roles for current user if required if self.user and str(user_id) == str(self.user.id): From 958f1c4820dd31e1c9991cee59c26c22c7ec445c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20K=C3=B6nig?= Date: Mon, 18 Jul 2022 21:35:54 +0200 Subject: [PATCH 3/3] Bug fix --- VERSION | 2 +- static/scripts/S3/s3.ui.datatable.js | 2 +- static/scripts/S3/s3.ui.datatable.min.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/VERSION b/VERSION index 7fe79f6bd..fd695df1a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -nursix-dev-5448-gfd50e444c (2022-07-18 15:07:17) +nursix-dev-5449-gdea16f46c (2022-07-18 21:35:54) diff --git a/static/scripts/S3/s3.ui.datatable.js b/static/scripts/S3/s3.ui.datatable.js index b53e80685..adca0ce99 100644 --- a/static/scripts/S3/s3.ui.datatable.js +++ b/static/scripts/S3/s3.ui.datatable.js @@ -1742,7 +1742,7 @@ if (oSetting) { - var args = 'id=' + self.tableid, + var args = 'id=' + self.tableID, sSearch = oSetting.oPreviousSearch.sSearch, aaSort = oSetting.aaSorting, aaSortFixed = oSetting.aaSortingFixed, diff --git a/static/scripts/S3/s3.ui.datatable.min.js b/static/scripts/S3/s3.ui.datatable.min.js index 2facd84e6..028565763 100644 --- a/static/scripts/S3/s3.ui.datatable.min.js +++ b/static/scripts/S3/s3.ui.datatable.min.js @@ -244,7 +244,7 @@ u=x('').data({level:""+r,group:""+u}).addClass("level_"+r);var " ("+A[p]+")":(B+=p,null!=A[B]&&(S=" ("+A[B]+")"));w.append(p+S);F&&D&&(p=K.groupIcon|0,p=p.length>=r?p[r-1]:"icon",A=x('').appendTo(w),D=x('').hide().appendTo(w),"text"==p?(A.text("\u2192"),D.text("\u2193")):"icon"==p&&(A.addClass("ui-icon ui-icon-arrowthick-1-e"),D.addClass("ui-icon ui-icon-arrowthick-1-s")));J?u.insertAfter(m):u.insertBefore(m);K.groupSpacing&&(m=u.prevAll("tr.group").first(),m.length&&m.data("level")==r&&(m=m.data("group"), z=x("").attr("colspan",z),z=x('').append(z),F&&z.addClass("collapsable"),z.addClass("xgroup_"+r+"_"+m).insertBefore(u)))},_toggleGroup:function(m,p){switch(this.tableConfig.shrinkGroupedRows){case "individual":p?this._expandGroup(m):this._collapseGroup(m);break;case "accordion":if(p){this._expandGroup(m);p=".level_"+m.data("level");var r=m.data("parentGroup");r&&(p+=".xgroup_"+m.data("parentLevel")+"_"+r);var u=this;m.siblings("tr.group"+p).each(function(){u._collapseGroup(x(this))})}else this._collapseGroup(m)}}, _expandGroup:function(m){var p=m.data("level"),r=m.data("group");m.siblings("tr.xgroup_"+p+"_"+r).show();x(".group-expand, .group-closed",m).hide();x(".group-collapse, .group-opened",m).show()},_collapseGroup:function(m){var p=m.data("level"),r=m.data("group"),u=this;m.siblings("tr.xgroup_"+p+"_"+r).each(function(){var w=x(this);w.hasClass("group")&&u._collapseGroup(w);w.hide()});x(".group-expand, .group-closed",m).show();x(".group-collapse, .group-opened",m).hide()},_exportFormat:function(){var m= -x(this.element),p=this;return function(){var r=m.dataTable().fnSettings(),u=x(this).data("url"),w=x(this).data("extension");if(r){var z="id="+p.tableid,A=r.oPreviousSearch.sSearch,B=r.aaSorting,D=r.aaSortingFixed;r=r.aoColumns;A&&(z+="&sSearch="+A+"&iColumns="+r.length);null!==D&&(B=D.concat(B));r.forEach(function(J,K){J.bSortable||(z+="&bSortable_"+K+"=false")});z+="&iSortingCols="+B.length;B.forEach(function(J,K){z+="&iSortCol_"+K+"="+B[K][0]+"&sSortDir_"+K+"="+B[K][1]});u=M(u,w,z)}else u=M(u,w); +x(this.element),p=this;return function(){var r=m.dataTable().fnSettings(),u=x(this).data("url"),w=x(this).data("extension");if(r){var z="id="+p.tableID,A=r.oPreviousSearch.sSearch,B=r.aaSorting,D=r.aaSortingFixed;r=r.aoColumns;A&&(z+="&sSearch="+A+"&iColumns="+r.length);null!==D&&(B=D.concat(B));r.forEach(function(J,K){J.bSortable||(z+="&bSortable_"+K+"=false")});z+="&iSortingCols="+B.length;B.forEach(function(J,K){z+="&iSortCol_"+K+"="+B[K][0]+"&sSortDir_"+K+"="+B[K][1]});u=M(u,w,z)}else u=M(u,w); x.searchDownloadS3!==L?x.searchDownloadS3(u,"_blank"):window.open(u)}},_initExportFormats:function(){var m=this._parseConfig();if(m!==L&&(m=m.ajaxUrl)&&S3.search!==L){var p=document.createElement("a");p.href=m;if(p.search){var r=p.search.slice(1).split("&").map(function(u){u=u.split("=");return[decodeURIComponent(u[0]),decodeURIComponent(u[1])]}).filter(function(u){return-1!=u[0].indexOf(".")});x(this.element).closest(".dt-wrapper").find(".dt-export").each(function(){var u=x(this),w=u.data("url"); w&&u.data("url",S3.search.filterURL(w,r))})}}},_bindEvents:function(){var m=x(this.element),p=this.eventNamespace,r=this;m.on("click"+p,".dt-truncate .ui-icon-zoomin, .dt-truncate .ui-icon-zoomout",function(){x(this).parent().toggle().siblings(".dt-truncate").toggle();return!1});this._initExportFormats();m.closest(".dt-wrapper").find(".dt-export").on("click"+p,this._exportFormat());m.on("click"+p,".dt-ajax-delete",this.ajaxAction(i18n.delete_confirmation));m.on("click"+p,".group-collapse, .group-expand", function(){var u=x(this),w=u.closest("tr.group"),z=!0;u.hasClass("group-collapse")&&(z=!1);r._toggleGroup(w,z)});if(this.tableConfig.bulkActions){if(this.tableConfig.bulkSingle)x(".bulk-select-options",m).hide();else m.on("click"+p,".bulk-select-all",this._bulkSelectAll());m.on("click"+p,".bulkcheckbox",this._bulkSelectRow())}return!0},_unbindEvents:function(){var m=x(this.element),p=this.eventNamespace;m.off(p);m.closest(".dt-wrapper").find(".dt-export").off(p);return!0}});x.fn.dataTable.Api.register("clearPipeline()",