diff --git a/VERSION b/VERSION index 20dfc069d..b57acd400 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -nursix-dev-2812-g48aaea7 (2019-04-12 21:55:05) +nursix-dev-2813-g25fd3d6 (2019-04-12 23:57:22) diff --git a/controllers/br.py b/controllers/br.py index edbc257ca..06d576f2d 100644 --- a/controllers/br.py +++ b/controllers/br.py @@ -42,6 +42,15 @@ def person(): action = s3db.pr_Contacts, ) + # ID Card Export + id_card_layout = settings.get_br_id_card_layout() + id_card_export_roles = settings.get_br_id_card_export_roles() + if id_card_layout and id_card_export_roles and \ + auth.s3_has_roles(id_card_export_roles): + id_card_export = True + else: + id_card_export = False + def prep(r): # Filter to persons who have a case registered @@ -126,6 +135,21 @@ def prep(r): insertable = insertable, ) + # Configure ID Cards + if id_card_export: + if r.representation == "card": + # Configure ID card layout + resource.configure(pdf_card_layout = id_card_layout, + #pdf_card_pagesize="A4", + ) + + if not r.id and not r.component: + # Add export-icon for ID cards + export_formats = list(settings.get_ui_export_formats()) + export_formats.append(("card", "fa fa-id-card", T("Export ID Cards"))) + settings.ui.export_formats = export_formats + s3.formats["card"] = r.url(method="") + if not r.component: # Module-specific field and form configuration @@ -431,10 +455,22 @@ def postp(r, output): from s3 import S3AnonymizeWidget anonymize = S3AnonymizeWidget.widget(r, _class="action-btn anonymize-btn") - # TODO ID-Card button + # ID-Card button + if id_card_export: + card_button = A(T("ID Card"), + data = {"url": URL(c="br", f="person", + args = ["%s.card" % r.id] + ), + }, + _class = "action-btn s3-download-button", + _script = "alert('here')", + ) + else: + card_button = "" # Render in place of the delete-button - buttons["delete_btn"] = TAG[""](anonymize, + buttons["delete_btn"] = TAG[""](card_button, + anonymize, ) return output s3.postp = postp diff --git a/modules/s3/codecs/card.py b/modules/s3/codecs/card.py index e2a49e10a..a1fd6a721 100644 --- a/modules/s3/codecs/card.py +++ b/modules/s3/codecs/card.py @@ -192,7 +192,8 @@ def encode(self, resource, **attr): return output_stream # ------------------------------------------------------------------------- - def extract(self, resource, fields, orderby=None): + @staticmethod + def extract(resource, fields, orderby=None): """ Extract the data items from the given resource @@ -216,7 +217,8 @@ def extract(self, resource, fields, orderby=None): ) # ------------------------------------------------------------------------- - def get_flowables(self, layout, resource, items, labels=None, cards_per_page=1): + @staticmethod + def get_flowables(layout, resource, items, labels=None, cards_per_page=1): """ Get the Flowable-instances for the data items @@ -354,7 +356,8 @@ def __init__(self, ) # ------------------------------------------------------------------------- - def number_of_cards(self, pagesize, cardsize, margins, spacing): + @staticmethod + def number_of_cards(pagesize, cardsize, margins, spacing): """ Compute the number of cards for one page dimension @@ -674,7 +677,12 @@ def draw_qrcode(self, value, x, y, size=40, halign=None, valign=None): qr_code = qr.QrCodeWidget(value) - bounds = qr_code.getBounds() + try: + bounds = qr_code.getBounds() + except ValueError: + # Value contains invalid characters + return + w = bounds[2] - bounds[0] h = bounds[3] - bounds[1] diff --git a/modules/s3cfg.py b/modules/s3cfg.py index eaf686a84..9cbbce8ec 100644 --- a/modules/s3cfg.py +++ b/modules/s3cfg.py @@ -2751,6 +2751,18 @@ def get_br_needs_org_specific(self): """ return self.br.get("needs_org_specific", True) + def get_br_id_card_layout(self): + """ + Layout class for beneficiary ID cards + """ + return self.br.get("id_card_layout") + + def get_br_id_card_export_roles(self): + """ + User roles permitted to export beneficiary ID cards + """ + return self.br.get("id_card_export_roles") + def get_br_case_hide_default_org(self): """ Hide the organisation field in cases if only one allowed diff --git a/modules/templates/BRCMS/config.py b/modules/templates/BRCMS/config.py index 8b9dc8603..e4e09c21d 100644 --- a/modules/templates/BRCMS/config.py +++ b/modules/templates/BRCMS/config.py @@ -10,6 +10,8 @@ #from s3 import FS, IS_ONE_OF from s3dal import original_tablename +from templates.BRCMS.idcards import IDCardLayout + # ============================================================================= def config(settings): """ @@ -152,6 +154,12 @@ def config(settings): # Terminology to use when referring to measures of assistance (Counseling|Assistance) #settings.br.assistance_terminology = "Counseling" + # ID Card Layout + settings.br.id_card_layout = IDCardLayout + + # Roles with permission to generate beneficiary ID cards + settings.br.id_card_export_roles = ["CASE_MANAGEMENT"] + # ------------------------------------------------------------------------- # CMS Module Settings # diff --git a/modules/templates/BRCMS/idcards.py b/modules/templates/BRCMS/idcards.py new file mode 100644 index 000000000..69904ae35 --- /dev/null +++ b/modules/templates/BRCMS/idcards.py @@ -0,0 +1,329 @@ +# -*- coding: utf-8 -*- + +import os + +from reportlab.lib.colors import HexColor +from reportlab.platypus import Paragraph +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.lib.enums import TA_CENTER + +from gluon import current + +from s3.codecs.card import S3PDFCardLayout +from s3 import s3_format_fullname, s3_str + +# Fonts we use in this layout +NORMAL = "Helvetica" +BOLD = "Helvetica-Bold" + +# ============================================================================= +class IDCardLayout(S3PDFCardLayout): + """ + Layout for printable beneficiary ID cards + """ + + # ------------------------------------------------------------------------- + @classmethod + def fields(cls, resource): + """ + The layout-specific list of fields to look up from the resource + + @param resource: the resource + + @returns: list of field selectors + """ + + return ["id", + "pe_id", + "pe_label", + "first_name", + "middle_name", + "last_name", + "case.organisation_id$root_organisation", + ] + + # ------------------------------------------------------------------------- + @classmethod + def lookup(cls, resource, items): + """ + Look up layout-specific common data for all cards + + @param resource: the resource + @param items: the items + + @returns: a dict with common data + """ + + db = current.db + s3db = current.s3db + + defaultpath = os.path.join(current.request.folder, 'uploads') + + # Get all root organisations + root_orgs = set(item["_row"]["org_organisation.root_organisation"] + for item in items) + + # Get localized root organisation names + ctable = s3db.br_case + represent = ctable.organisation_id.represent + if represent.bulk: + root_org_names = represent.bulk(list(root_orgs), show_link=False) + else: + root_org_names = None + + # Get all PE IDs + pe_ids = set(item["_row"]["pr_person.pe_id"] for item in items) + + # Look up all profile pictures + itable = s3db.pr_image + query = (itable.pe_id.belongs(pe_ids)) & \ + (itable.profile == True) & \ + (itable.deleted == False) + rows = db(query).select(itable.pe_id, itable.image) + + field = itable.image + path = field.uploadfolder if field.uploadfolder else defaultpath + pictures = {row.pe_id: os.path.join(path, row.image) for row in rows if row.image} + + return {"pictures": pictures, + "root_org_names": root_org_names, + } + + # ------------------------------------------------------------------------- + def draw(self): + """ + Draw the card (one side) + + Instance attributes (NB draw-function should not modify them): + - self.canv...............the canvas (provides the drawing methods) + - self.resource...........the resource + - self.item...............the data item (dict) + - self.labels.............the field labels (dict) + - self.backside...........this instance should render the backside + of a card + - self.multiple...........there are multiple cards per page + - self.width..............the width of the card (in points) + - self.height.............the height of the card (in points) + + NB Canvas coordinates are relative to the lower left corner of the + card's frame, drawing must not overshoot self.width/self.height + """ + + T = current.T + + c = self.canv + w = self.width + #h = self.height + common = self.common + + blue = HexColor(0x27548F) + + item = self.item + raw = item["_row"] + + root_org = raw["org_organisation.root_organisation"] + + # Get the localized root org name + org_names = common.get("root_org_names") + if org_names: + root_org_name = org_names.get(root_org) + + #draw_field = self.draw_field + draw_value = self.draw_value + draw_label = self.draw_label + + code = raw["pr_person.pe_label"] + + if not self.backside: + + # Horizontal alignments + LEFT = w / 4 - 5 + CENTER = w / 2 - 5 + RIGHT = w * 3 / 4 - 5 + + # Vertical alignments + TOP = 200 + #LOWER = [76, 58, 40] + BOTTOM = 16 + + # Organisation name + if root_org_name: + draw_value(LEFT, TOP, root_org_name, + width = 55, + height = 55, + size = 10, + valign = "middle", + ) + + # Get the profile picture + pictures = common.get("pictures") + if pictures: + picture = pictures.get(raw["pr_person.pe_id"]) + if picture: + self.draw_image(picture, RIGHT, TOP, + width = 60, + height = 55, + valign = "middle", + halign = "center", + ) + + # Center fields in reverse order so that vertical positions + # can be adjusted for very long and hence wrapping strings + y = 98 + + # ID + ah = draw_value(CENTER, y, code, height=24, size=8) + draw_label(CENTER, y, None, T("ID Number")) + + # Name + y += ah + 12 + name = s3_format_fullname(fname = raw["pr_person.first_name"], + mname = raw["pr_person.middle_name"], + lname = raw["pr_person.last_name"], + truncate = False, + ) + draw_value(CENTER, y, name, height=24, size=10) + draw_label(CENTER, y, None, T("Name")) + + # Barcode + if code: + self.draw_barcode(s3_str(code), CENTER, BOTTOM, + height = 12, + halign = "center", + maxwidth = w - 15, + ) + + # Graphics + c.setFillColor(blue) + c.rect(0, 0, w, 12, fill=1, stroke=0) + c.rect(w - 12, 0, 12, 154, fill=1, stroke=0) + + # Add a utting line with multiple cards per page + if self.multiple: + c.setDash(1, 2) + self.draw_outline() + else: + # Horizontal alignments + CENTER = w / 2 + + # Vertical alignments + TOP = 200 + MIDDLE = 85 + BOTTOM = 16 + + # QR Code + if code: + identity = "%s//%s:%s:%s" % (code, + raw["pr_person.first_name"] or "", + raw["pr_person.middle_name"] or "", + raw["pr_person.last_name"] or "", + ) + self.draw_qrcode(identity, CENTER, MIDDLE, + size=60, halign="center", valign="center") + # Barcode + if code: + self.draw_barcode(s3_str(code), CENTER, BOTTOM, + height = 12, + halign = "center", + maxwidth = w - 15 + ) + + # Graphics + c.setFillColor(blue) + c.rect(0, 0, w, 10, fill=1, stroke=0) + + # ------------------------------------------------------------------------- + def draw_field(self, x, y, colname, size=7, bold=True): + """ + Helper function to draw a centered field value of self.item above + position (x, y) + + @param x: drawing position + @param y: drawing position + @param colname: the column name of the field to look up the value + @param size: the font size (points) + @param bold: use bold font + """ + + c = self.canv + + font = BOLD if bold else NORMAL + + value = self.item.get(colname) + if value: + c.setFont(font, size) + c.drawCentredString(x, y, s3_str(value)) + + # ------------------------------------------------------------------------- + def draw_value(self, x, y, value, width=120, height=40, size=7, bold=True, valign=None): + """ + Helper function to draw a centered text above position (x, y); + allows the text to wrap if it would otherwise exceed the given + width + + @param x: drawing position + @param y: drawing position + @param value: the text to render + @param width: the maximum available width (points) + @param height: the maximum available height (points) + @param size: the font size (points) + @param bold: use bold font + @param valign: vertical alignment ("top"|"middle"|"bottom"), + default "bottom" + + @returns: the actual height of the text element drawn + """ + + # Preserve line breaks by replacing them with
tags + value = s3_str(value).strip("\n").replace('\n','
\n') + + stylesheet = getSampleStyleSheet() + style = stylesheet["Normal"] + style.fontName = BOLD if bold else NORMAL + style.fontSize = size + style.leading = size + 2 + style.splitLongWords = False + style.alignment = TA_CENTER + + para = Paragraph(value, style) + aw, ah = para.wrap(width, height) + + while((ah > height or aw > width) and style.fontSize > 4): + # Reduce font size to make fit + style.fontSize -= 1 + style.leading = style.fontSize + 2 + para = Paragraph(value, style) + aw, ah = para.wrap(width, height) + + if valign == "top": + vshift = ah + elif valign == "middle": + vshift = ah / 2.0 + else: + vshift = 0 + + para.drawOn(self.canv, x - para.width / 2, y - vshift) + return ah + + # ------------------------------------------------------------------------- + def draw_label(self, x, y, colname, default=""): + """ + Helper function to draw a centered label below position (x, y) + + @param x: drawing position + @param y: drawing position + @param colname: the column name of the field to look up the label + @param default: the default label (if label cannot be looked up), + pass colname=None to enforce using the default + """ + + if colname: + label = self.labels.get(colname, default) + else: + label = default + + c = self.canv + c.setFont(NORMAL, 5) + c.drawCentredString(x, y - 6, s3_str(label)) + +# END ========================================================================= diff --git a/modules/templates/default/config.py b/modules/templates/default/config.py index c777163d9..34fc63404 100644 --- a/modules/templates/default/config.py +++ b/modules/templates/default/config.py @@ -504,6 +504,10 @@ def config(settings): #settings.br.case_language_details = False # Control household size tracking in case files: False, True or "auto" (=default) #settings.br.household_size = "auto" + # Layout class for beneficiary ID cards + #settings.br.id_card_layout = IDCardLayout + # User roles with permission to export beneficiary ID cards + #settings.br.id_card_export_roles = ["ORG_ADMIN", "CASE_MANAGEMENT"] # --- Case File Tabs --- # Hide the contact info tab in case files @@ -512,6 +516,8 @@ def config(settings): #settings.br.case_id_tab = True # Hide the family members tab in case files #settings.br.case_family_tab = False + # Show tab with notes journal + #settings.br.case_notes_tab = True # Show the photos-tab in case files #settings.br.case_photos_tab = True # Hide the documents-tab in case files