From 6c3980c8d37f69735ed8ae329b6d71346c5ee3fe Mon Sep 17 00:00:00 2001 From: Lunga Baliwe Date: Tue, 21 Nov 2023 00:03:02 +0200 Subject: [PATCH] Started with moving reports --- src/bika/reports/browser/reports/README.txt | 26 + src/bika/reports/browser/reports/__init__.py | 313 +++++++++++ .../reports/administration_arsnotinvoiced.py | 137 +++++ .../reports/administration_usershistory.py | 205 +++++++ .../reports/browser/reports/configure.zcml | 48 ++ .../productivity_analysesattachments.py | 175 ++++++ .../reports/productivity_analysesperclient.py | 183 ++++++ .../productivity_analysesperdepartment.py | 221 ++++++++ .../productivity_analysesperformedpertotal.py | 211 +++++++ .../productivity_analysespersampletype.py | 159 ++++++ .../productivity_analysesperservice.py | 174 ++++++ .../reports/productivity_analysestats.py | 330 +++++++++++ .../productivity_analysestats_overtime.py | 213 +++++++ .../productivity_dailysamplesreceived.py | 131 +++++ .../reports/productivity_dataentrydaybook.py | 200 +++++++ .../productivity_samplereceivedvsreported.py | 144 +++++ .../reports/selection_macros/__init__.py | 488 ++++++++++++++++ .../select_analysiscategory.pt | 18 + .../select_analysisservice.pt | 23 + .../select_analysisspecification.pt | 23 + .../selection_macros/select_analyst.pt | 18 + .../reports/selection_macros/select_client.pt | 19 + .../selection_macros/select_contact.pt | 21 + .../selection_macros/select_daterange.pt | 29 + .../selection_macros/select_groupingperiod.pt | 18 + .../selection_macros/select_instrument.pt | 19 + .../selection_macros/select_output_format.pt | 18 + .../reports/selection_macros/select_period.pt | 20 + .../selection_macros/select_profile.pt | 21 + .../select_reference_sample.pt | 13 + .../select_reference_service.pt | 13 + .../selection_macros/select_sampletype.pt | 19 + .../reports/selection_macros/select_state.pt | 18 + .../selection_macros/select_supplier.pt | 18 + .../reports/selection_macros/select_user.pt | 20 + .../reports/templates/administration.pt | 165 ++++++ .../templates/administration_usershistory.pt | 98 ++++ .../templates/output_resultspersampletype.pt | 170 ++++++ .../browser/reports/templates/productivity.pt | 526 ++++++++++++++++++ .../productivity_analysesperdepartment.pt | 146 +++++ .../productivity_analysesperformedpertotal.pt | 146 +++++ .../productivity_analysesperservice.pt | 156 ++++++ .../productivity_dailysamplesreceived.pt | 79 +++ .../productivity_dataentrydaybook.pt | 146 +++++ .../productivity_samplereceivedvsreported.pt | 60 ++ .../browser/reports/templates/report_frame.pt | 71 +++ .../browser/reports/templates/report_out.pt | 161 ++++++ .../browser/reports/templates/reports.pt | 26 + 48 files changed, 5656 insertions(+) create mode 100644 src/bika/reports/browser/reports/README.txt create mode 100644 src/bika/reports/browser/reports/__init__.py create mode 100644 src/bika/reports/browser/reports/administration_arsnotinvoiced.py create mode 100644 src/bika/reports/browser/reports/administration_usershistory.py create mode 100644 src/bika/reports/browser/reports/configure.zcml create mode 100644 src/bika/reports/browser/reports/productivity_analysesattachments.py create mode 100644 src/bika/reports/browser/reports/productivity_analysesperclient.py create mode 100644 src/bika/reports/browser/reports/productivity_analysesperdepartment.py create mode 100644 src/bika/reports/browser/reports/productivity_analysesperformedpertotal.py create mode 100644 src/bika/reports/browser/reports/productivity_analysespersampletype.py create mode 100644 src/bika/reports/browser/reports/productivity_analysesperservice.py create mode 100644 src/bika/reports/browser/reports/productivity_analysestats.py create mode 100644 src/bika/reports/browser/reports/productivity_analysestats_overtime.py create mode 100644 src/bika/reports/browser/reports/productivity_dailysamplesreceived.py create mode 100644 src/bika/reports/browser/reports/productivity_dataentrydaybook.py create mode 100644 src/bika/reports/browser/reports/productivity_samplereceivedvsreported.py create mode 100644 src/bika/reports/browser/reports/selection_macros/__init__.py create mode 100644 src/bika/reports/browser/reports/selection_macros/select_analysiscategory.pt create mode 100644 src/bika/reports/browser/reports/selection_macros/select_analysisservice.pt create mode 100644 src/bika/reports/browser/reports/selection_macros/select_analysisspecification.pt create mode 100644 src/bika/reports/browser/reports/selection_macros/select_analyst.pt create mode 100644 src/bika/reports/browser/reports/selection_macros/select_client.pt create mode 100644 src/bika/reports/browser/reports/selection_macros/select_contact.pt create mode 100644 src/bika/reports/browser/reports/selection_macros/select_daterange.pt create mode 100644 src/bika/reports/browser/reports/selection_macros/select_groupingperiod.pt create mode 100644 src/bika/reports/browser/reports/selection_macros/select_instrument.pt create mode 100644 src/bika/reports/browser/reports/selection_macros/select_output_format.pt create mode 100644 src/bika/reports/browser/reports/selection_macros/select_period.pt create mode 100644 src/bika/reports/browser/reports/selection_macros/select_profile.pt create mode 100644 src/bika/reports/browser/reports/selection_macros/select_reference_sample.pt create mode 100644 src/bika/reports/browser/reports/selection_macros/select_reference_service.pt create mode 100644 src/bika/reports/browser/reports/selection_macros/select_sampletype.pt create mode 100644 src/bika/reports/browser/reports/selection_macros/select_state.pt create mode 100644 src/bika/reports/browser/reports/selection_macros/select_supplier.pt create mode 100644 src/bika/reports/browser/reports/selection_macros/select_user.pt create mode 100644 src/bika/reports/browser/reports/templates/administration.pt create mode 100644 src/bika/reports/browser/reports/templates/administration_usershistory.pt create mode 100644 src/bika/reports/browser/reports/templates/output_resultspersampletype.pt create mode 100644 src/bika/reports/browser/reports/templates/productivity.pt create mode 100644 src/bika/reports/browser/reports/templates/productivity_analysesperdepartment.pt create mode 100644 src/bika/reports/browser/reports/templates/productivity_analysesperformedpertotal.pt create mode 100644 src/bika/reports/browser/reports/templates/productivity_analysesperservice.pt create mode 100644 src/bika/reports/browser/reports/templates/productivity_dailysamplesreceived.pt create mode 100644 src/bika/reports/browser/reports/templates/productivity_dataentrydaybook.pt create mode 100644 src/bika/reports/browser/reports/templates/productivity_samplereceivedvsreported.pt create mode 100644 src/bika/reports/browser/reports/templates/report_frame.pt create mode 100644 src/bika/reports/browser/reports/templates/report_out.pt create mode 100644 src/bika/reports/browser/reports/templates/reports.pt diff --git a/src/bika/reports/browser/reports/README.txt b/src/bika/reports/browser/reports/README.txt new file mode 100644 index 0000000..697a807 --- /dev/null +++ b/src/bika/reports/browser/reports/README.txt @@ -0,0 +1,26 @@ +Adding new reports +================== + +Edit one of the report selection categories to add your report, and the form +that will be displayed to select criteria. This file is currently one of: + + bika/lims/browser/reports/templates/administration.pt + bika/lims/browser/reports/templates/productivity.pt + bika/lims/browser/reports/templates/qualitycontrol.pt + +Add the following files for your report: + + bika/lims/browser/reports/templates/your_report_name.pt + bika/lims/browser/reports/your_reort_name.py + +Report names should be in the same form as the existing ones: +category_reportname. + +It is assumed that report_name.py contains a class called Report. + +The Report class should return a dictionary of 'report_title' and +'report_data' + +In case of an error, the Report class may return a string. This is assumed +to be a template output, and is rendered. + diff --git a/src/bika/reports/browser/reports/__init__.py b/src/bika/reports/browser/reports/__init__.py new file mode 100644 index 0000000..f379d13 --- /dev/null +++ b/src/bika/reports/browser/reports/__init__.py @@ -0,0 +1,313 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.CORE. +# +# SENAITE.CORE is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, version 2. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., 51 +# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Copyright 2018-2021 by it's authors. +# Some rights reserved, see README and LICENSE. + +import importlib +import os +from datetime import datetime + +from bika.lims import api +from bika.lims import bikaMessageFactory as _ +from bika.lims.browser import BrowserView +from bika.lims.browser.bika_listing import BikaListingView +from bika.reports.browser.reports.selection_macros import SelectionMacrosView +from bika.reports.interfaces import IAdministrationReport +from bika.reports.interfaces import IProductivityReport +from bika.lims.utils import createPdf +from bika.lims.utils import getUsers +from bika.lims.utils import logged_in_client +from DateTime import DateTime +from plone.app.layout.globals.interfaces import IViewView +from Products.CMFCore.utils import getToolByName +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from senaite.core.catalog import REPORT_CATALOG +from zope.component import getAdapters +from zope.interface import implements + + +class ProductivityView(BrowserView): + """ Productivity View form + """ + implements(IViewView) + template = ViewPageTemplateFile("templates/productivity.pt") + + def __init__(self, context, request): + BrowserView.__init__(self, context, request) + self.context = context + self.request = request + + def __call__(self): + self.selection_macros = SelectionMacrosView(self.context, self.request) + self.icon = self.portal_url + "/++resource++bika.lims.images/report_big.png" + self.getAnalysts = getUsers(self.context, + ['Manager', 'LabManager', 'Analyst']) + + self.additional_reports = [] + adapters = getAdapters((self.context, ), IProductivityReport) + for name, adapter in adapters: + report_dict = adapter(self.context, self.request) + report_dict['id'] = name + self.additional_reports.append(report_dict) + + return self.template() + + +class AdministrationView(BrowserView): + """ Administration View form + """ + implements(IViewView) + template = ViewPageTemplateFile("templates/administration.pt") + + def __init__(self, context, request): + BrowserView.__init__(self, context, request) + self.context = context + self.request = request + + def __call__(self): + self.selection_macros = SelectionMacrosView(self.context, self.request) + self.icon = self.portal_url + "/++resource++bika.lims.images/report_big.png" + + self.additional_reports = [] + adapters = getAdapters((self.context, ), IAdministrationReport) + for name, adapter in adapters: + report_dict = adapter(self.context, self.request) + report_dict['id'] = name + self.additional_reports.append(report_dict) + + return self.template() + + +class ReportHistoryView(BikaListingView): + """ Report history form + """ + implements(IViewView) + + def __init__(self, context, request): + super(ReportHistoryView, self).__init__(context, request) + + self.catalog = REPORT_CATALOG + + self.context_actions = {} + + self.show_select_row = False + self.show_select_column = False + self.show_column_toggles = False + self.show_workflow_action_buttons = False + self.show_select_all_checkbox = False + self.pagesize = 50 + + self.icon = self.portal_url + "/++resource++bika.lims.images/report_big.png" + self.title = self.context.translate(_("Reports")) + self.description = "" + + # this is set up in call where member is authenticated + self.columns = {} + self.review_states = [] + + def __call__(self): + self.columns = { + 'Title': { + 'title': _('Title'), + 'attr': 'Title', + 'index': 'title', }, + 'file_size': { + 'title': _("Size"), + 'attr': 'getFileSize', + 'sortable': False, }, + 'created': { + 'title': _("Created"), + 'attr': 'created', + 'index': 'created', }, + 'creator': { + 'title': _("By"), + 'attr': 'getCreatorFullName', + 'index': 'Creator', }, } + self.review_states = [ + {'id': 'default', + 'title': 'All', + 'contentFilter': {}, + 'columns': ['Title', + 'file_size', + 'created', + 'creator']}, + ] + + self.contentFilter = { + 'portal_type': 'Report', + 'sort_order': 'reverse'} + + this_client = logged_in_client(self.context) + if this_client: + self.contentFilter['getClientUID'] = this_client.UID() + else: + self.columns['client'] = { + 'title': _('Client'), + 'attr': 'getClientTitle', + 'replace_url': 'getClientURL', } + + return super(ReportHistoryView, self).__call__() + + def lookupMime(self, name): + mimetool = getToolByName(self, 'mimetypes_registry') + mimetypes = mimetool.lookup(name) + if len(mimetypes): + return mimetypes[0].name() + else: + return name + + def folderitem(self, obj, item, index): + item = BikaListingView.folderitem(self, obj, item, index) + # https://github.com/collective/uwosh.pfg.d2c/issues/20 + # https://github.com/collective/uwosh.pfg.d2c/pull/21 + item['replace']['Title'] = \ + "%s" % \ + (item['url'], item['Title']) + item['replace']['created'] = self.ulocalized_time(item['created']) + return item + + +class SubmitForm(BrowserView): + """ Redirect to specific report + """ + implements(IViewView) + frame_template = ViewPageTemplateFile("templates/report_frame.pt") + # default and errors use this template: + template = ViewPageTemplateFile("templates/productivity.pt") + + def __init__(self, context, request): + BrowserView.__init__(self, context, request) + self.context = context + self.request = request + + def __call__(self): + """Create and render selected report + """ + + # if there's an error, we return productivity.pt which requires these. + self.selection_macros = SelectionMacrosView(self.context, self.request) + self.additional_reports = [] + adapters = getAdapters((self.context, ), IProductivityReport) + for name, adapter in adapters: + report_dict = adapter(self.context, self.request) + report_dict['id'] = name + self.additional_reports.append(report_dict) + + report_id = self.request.get('report_id', '') + if not report_id: + message = _("No report specified in request") + self.logger.error(message) + self.context.plone_utils.addPortalMessage(message, 'error') + return self.template() + + self.date = DateTime() + username = self.context.portal_membership.getAuthenticatedMember().getUserName() + self.reporter = self.user_fullname(username) + self.reporter_email = self.user_email(username) + + # signature image + self.reporter_signature = "" + c = [x for x in self.senaite_catalog_setup(portal_type='LabContact') + if x.getObject().getUsername() == username] + if c: + sf = c[0].getObject().getSignature() + if sf: + self.reporter_signature = sf.absolute_url() + "/Signature" + + lab = self.context.bika_setup.laboratory + self.laboratory = lab + self.lab_title = lab.getName() + self.lab_address = lab.getPrintAddress() + self.lab_email = lab.getEmailAddress() + self.lab_url = lab.getLabURL() + + client = logged_in_client(self.context) + if client: + clientuid = client.UID() + self.client_title = client.Title() + self.client_address = client.getPrintAddress() + else: + clientuid = None + self.client_title = None + self.client_address = None + + # Render form output + + # the report can add file names to this list; they will be deleted + # once the PDF has been generated. temporary plot image files, etc. + self.request['to_remove'] = [] + + if "report_module" in self.request: + module = self.request["report_module"] + else: + module = "bika.lims.browser.reports.%s" % report_id + try: + Report = getattr(importlib.import_module(module), "Report") + # required during error redirect: the report must have a copy of + # additional_reports, because it is used as a surrogate view. + Report.additional_reports = self.additional_reports + except (ImportError, AttributeError): + message = "Report %s.Report not found (shouldn't happen)" % module + self.logger.error(message) + self.context.plone_utils.addPortalMessage(message, 'error') + return self.template() + + # Report must return dict with: + # - report_title - title string for pdf/history listing + # - report_data - rendered report + output = Report(self.context, self.request)() + + # if CSV output is chosen, report returns None + if not output: + return + + if type(output) in (str, unicode, bytes): + # remove temporary files + for f in self.request['to_remove']: + os.remove(f) + return output + + # The report output gets pulled through report_frame.pt + self.reportout = output['report_data'] + framed_output = self.frame_template() + + # this is the good part + pdf = createPdf(framed_output) + + # remove temporary files + for f in self.request['to_remove']: + os.remove(f) + + if not pdf: + return + + # Create new report object + title = output["report_title"] + data = {"title": title, "Client": clientuid, "ReportFile": pdf} + api.create(self.context, "Report", **data) + + now = datetime.now().strftime("%y%m%d%H%M%S") + fn = "{}-{}.pdf".format(now, title) + + setheader = self.request.response.setHeader + setheader("Content-Type", "application/pdf") + setheader("Content-Disposition", "attachment; filename=\"%s\"" % fn) + setheader("Content-Length", len(pdf)) + setheader("Cache-Control", "no-store") + setheader("Pragma", "no-cache") + self.request.response.write(pdf) diff --git a/src/bika/reports/browser/reports/administration_arsnotinvoiced.py b/src/bika/reports/browser/reports/administration_arsnotinvoiced.py new file mode 100644 index 0000000..1d1a034 --- /dev/null +++ b/src/bika/reports/browser/reports/administration_arsnotinvoiced.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.CORE. +# +# SENAITE.CORE is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, version 2. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., 51 +# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Copyright 2018-2021 by it's authors. +# Some rights reserved, see README and LICENSE. + +from bika.lims.workflow import getTransitionDate + +from Products.CMFCore.utils import getToolByName +from bika.lims.browser import BrowserView +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from bika.lims import bikaMessageFactory as _ +from bika.lims.utils import t +from bika.lims.utils import formatDateQuery, formatDateParms +from plone.app.layout.globals.interfaces import IViewView +from zope.interface import implements + + +class Report(BrowserView): + implements(IViewView) + template = ViewPageTemplateFile("templates/report_out.pt") + + def __init__(self, context, request, report=None): + self.report = report + BrowserView.__init__(self, context, request) + + def __call__(self): + bc = getToolByName(self.context, 'senaite_catalog') + self.report_content = {} + parms = [] + headings = {} + headings['header'] = _("Samples not invoiced") + headings['subheader'] = _( + "Published Samples which have not been invoiced") + + count_all = 0 + + query = {'portal_type': 'AnalysisRequest', + 'getInvoiced': False, + 'review_state': 'published', + 'sort_order': 'reverse'} + + date_query = formatDateQuery(self.context, 'c_DatePublished') + if date_query: + query['getDatePublished'] = date_query + pubished = formatDateParms(self.context, 'c_DatePublished') + else: + pubished = 'Undefined' + parms.append( + {'title': _('Published'), + 'value': pubished, + 'type': 'text'}) + + parms.append( + {'title': _('Active'), + 'value': 'Undefined', + 'type': 'text'}) + + + + # and now lets do the actual report lines + formats = {'columns': 6, + 'col_heads': [_('Client'), \ + _('Request'), \ + _('Sample type'), \ + _('Sample point'), \ + _('Published'), \ + _('Amount'), \ + ], + 'class': '', + } + + datalines = [] + + for ar_proxy in bc(query): + ar = ar_proxy.getObject() + + dataline = [] + + dataitem = {'value': ar.aq_parent.Title()} + dataline.append(dataitem) + + dataitem = {'value': ar.getId()} + dataline.append(dataitem) + + dataitem = {'value': ar.getSampleTypeTitle()} + dataline.append(dataitem) + + dataitem = {'value': ar.getSamplePointTitle()} + dataline.append(dataitem) + + dataitem = {'value': + self.ulocalized_time(getTransitionDate(ar, 'publish'), + long_format=True)} + dataline.append(dataitem) + + dataitem = {'value': ar.getTotalPrice()} + dataline.append(dataitem) + + datalines.append(dataline) + + count_all += 1 + + # table footer data + footlines = [] + footline = [] + footitem = {'value': _('Number of analyses retested for period'), + 'colspan': 5, + 'class': 'total_label'} + footline.append(footitem) + footitem = {'value': count_all} + footline.append(footitem) + footlines.append(footline) + + self.report_content = { + 'headings': headings, + 'parms': parms, + 'formats': formats, + 'datalines': datalines, + 'footings': footlines} + + return {'report_title': t(headings['header']), + 'report_data': self.template()} diff --git a/src/bika/reports/browser/reports/administration_usershistory.py b/src/bika/reports/browser/reports/administration_usershistory.py new file mode 100644 index 0000000..f211679 --- /dev/null +++ b/src/bika/reports/browser/reports/administration_usershistory.py @@ -0,0 +1,205 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.CORE. +# +# SENAITE.CORE is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, version 2. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., 51 +# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Copyright 2018-2021 by it's authors. +# Some rights reserved, see README and LICENSE. + +import six + +from Products.CMFCore.utils import getToolByName +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from bika.lims import api +from bika.lims import bikaMessageFactory as _ +from bika.lims import logger +from bika.lims.browser import BrowserView +from bika.lims.browser.reports.selection_macros import SelectionMacrosView +from plone.app.layout.globals.interfaces import IViewView +from zope.interface import implements + + +class Report(BrowserView): + implements(IViewView) + default_template = ViewPageTemplateFile("templates/administration.pt") + template = ViewPageTemplateFile("templates/administration_usershistory.pt") + + def __init__(self, context, request, report=None): + super(Report, self).__init__(context, request) + self.report = report + self.selection_macros = SelectionMacrosView(self.context, self.request) + + def __call__(self): + + parms = [] + titles = [] + + rt = getToolByName(self.context, 'portal_repository') + mt = getToolByName(self.context, 'portal_membership') + + # Apply filters + self.contentFilter = {'portal_type': ('Analysis', + 'AnalysisCategory', + 'AnalysisProfile', + 'AnalysisRequest', + 'AnalysisService', + 'AnalysisSpec', + 'ARTemplate', + 'Attachment', + 'Batch', + 'Calculation', + 'Client', + 'Contact', + 'Container', + 'ContainerType', + 'Department', + 'DuplicateAnalysis', + 'Instrument', + 'InstrumentCalibration', + 'InstrumentCertification', + 'InstrumentMaintenanceTask', + 'InstrumentScheduledTask', + 'InstrumentType', + 'InstrumentValidation', + 'Manufacturer' + 'Method', + 'Preservation', + 'Pricelist', + 'ReferenceAnalysis', + 'ReferenceDefinition', + 'ReferenceSample', + 'SampleMatrix', + 'SamplePoint', + 'SampleType', + 'Supplier', + 'SupplierContact', + 'Worksheet', + 'WorksheetTemplate' + )} + + val = self.selection_macros.parse_daterange(self.request, + 'getModificationDate', + _('Modification date')) + if val: + self.contentFilter['modified'] = val['contentFilter'][1] + parms.append(val['parms']) + titles.append(val['titles']) + + user = '' + userfullname = '' + titles.append(user) + if self.request.form.get('User', '') != '': + user = self.request.form['User'] + userobj = mt.getMemberById(user) + userfullname = userobj.getProperty('fullname') \ + if userobj else '' + parms.append( + {'title': _('User'), 'value': ("%s (%s)" % (userfullname, user))}) + + # Query the catalog and store results in a dictionary + entities = self.senaite_catalog_setup(self.contentFilter) + + if not entities: + message = _("No historical actions matched your query") + self.context.plone_utils.addPortalMessage(message, "error") + return self.default_template() + + datalines = [] + tmpdatalines = {} + footlines = {} + + for entity in entities: + entity = entity.getObject() + entitytype = _(entity.__class__.__name__) + + # Workflow states retrieval + for workflowid, workflow in six.iteritems(entity.workflow_history): + for action in workflow: + actiontitle = _('Created') + if not action['action'] or ( + action['action'] and action['action'] == 'create'): + actiontitle = _('Created') + else: + actiontitle = _(action['action']) + + if (user == '' or action['actor'] == user): + actorfullname = userfullname == '' and mt.getMemberById( + user) or userfullname + dataline = {'EntityNameOrId': entity.title_or_id(), + 'EntityAbsoluteUrl': entity.absolute_url(), + 'EntityCreationDate': entity.CreationDate(), + 'EntityModificationDate': entity.ModificationDate(), + 'EntityType': entitytype, + 'Workflow': _(workflowid), + 'Action': actiontitle, + 'ActionDate': action['time'], + 'ActionDateStr': self.ulocalized_time( + action['time'], 1), + 'ActionActor': action['actor'], + 'ActionActorFullName': actorfullname, + 'ActionComments': action['comments'] + } + tmpdatalines[action['time']] = dataline + + # History versioning retrieval + history = rt.getHistoryMetadata(entity) + if history: + hislen = history.getLength(countPurged=False) + for index in range(hislen): + meta = history.retrieve(index)['metadata']['sys_metadata'] + metatitle = _(meta['comment']) + if (user == '' or meta['principal'] == user): + actorfullname = userfullname == '' and \ + mt.getMemberById(user) or userfullname + action_date = api.to_date(meta['timestamp'], None) + if not action_date: + logger.warn("Cannot convert date {}").format(meta['timestamp']) + action_date = "???" + else: + action_date = self.ulocalized_time(action_date, long_format=1) + dataline = {'EntityNameOrId': entity.title_or_id(), + 'EntityAbsoluteUrl': entity.absolute_url(), + 'EntityCreationDate': entity.CreationDate(), + 'EntityModificationDate': entity.ModificationDate(), + 'EntityType': entitytype, + 'Workflow': '', + 'Action': metatitle, + 'ActionDate': meta['timestamp'], + 'ActionDateStr': action_date, + 'ActionActor': meta['principal'], + 'ActionActorFullName': actorfullname, + 'ActionComments': '' + } + tmpdatalines[meta['timestamp']] = dataline + if len(tmpdatalines) == 0: + message = _( + "No actions found for user ${user}", + mapping={"user": userfullname}) + self.context.plone_utils.addPortalMessage(message, "error") + return self.default_template() + else: + # Sort datalines + tmpkeys = tmpdatalines.keys() + tmpkeys.sort(reverse=True) + for index in range(len(tmpkeys)): + datalines.append(tmpdatalines[tmpkeys[index]]) + + self.report_data = {'parameters': parms, + 'datalines': datalines, + 'footlines': footlines} + + return {'report_title': _('Users history'), + 'report_data': self.template()} + diff --git a/src/bika/reports/browser/reports/configure.zcml b/src/bika/reports/browser/reports/configure.zcml new file mode 100644 index 0000000..ab2a320 --- /dev/null +++ b/src/bika/reports/browser/reports/configure.zcml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + diff --git a/src/bika/reports/browser/reports/productivity_analysesattachments.py b/src/bika/reports/browser/reports/productivity_analysesattachments.py new file mode 100644 index 0000000..589c5a3 --- /dev/null +++ b/src/bika/reports/browser/reports/productivity_analysesattachments.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.CORE. +# +# SENAITE.CORE is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, version 2. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., 51 +# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Copyright 2018-2021 by it's authors. +# Some rights reserved, see README and LICENSE. + +from bika.lims import api +from bika.lims import bikaMessageFactory as _ +from bika.lims.browser import BrowserView +from bika.lims.utils import formatDateParms +from bika.lims.utils import formatDateQuery +from bika.lims.utils import logged_in_client +from bika.lims.utils import t +from plone.app.layout.globals.interfaces import IViewView +from Products.CMFCore.utils import getToolByName +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from zope.interface import implements + + +class Report(BrowserView): + implements(IViewView) + template = ViewPageTemplateFile("templates/report_out.pt") + + def __init__(self, context, request, report=None): + self.report = report + BrowserView.__init__(self, context, request) + + def __call__(self): + # get all the data into datalines + + pc = getToolByName(self.context, 'portal_catalog') + rc = getToolByName(self.context, 'reference_catalog') + self.report_content = {} + parms = [] + headings = {} + headings['header'] = _("Attachments") + headings['subheader'] = _( + "The attachments linked to samples and analyses") + + count_all = 0 + query = {'portal_type': 'Attachment'} + if 'ClientUID' in self.request.form: + client_uid = self.request.form['ClientUID'] + query['getClientUID'] = client_uid + client = rc.lookupObject(client_uid) + client_title = client.Title() + else: + client = logged_in_client(self.context) + if client: + client_title = client.Title() + query['getClientUID'] = client.UID() + else: + client_title = 'All' + parms.append( + {'title': _('Client'), + 'value': client_title, + 'type': 'text'}) + + date_query = formatDateQuery(self.context, 'Loaded') + if date_query: + query['getDateLoaded'] = date_query + loaded = formatDateParms(self.context, 'Loaded') + parms.append( + {'title': _('Loaded'), + 'value': loaded, + 'type': 'text'}) + + # and now lets do the actual report lines + formats = {'columns': 6, + 'col_heads': [_('Request'), + _('File'), + _('Attachment type'), + _('Content type'), + _('Size'), + _('Loaded'), + ], + 'class': '', + } + + datalines = [] + attachments = pc(query) + for a_proxy in attachments: + attachment = a_proxy.getObject() + attachment_file = attachment.getAttachmentFile() + icon = api.get_icon(attachment, False) + filename = attachment_file.filename + filesize = attachment_file.get_size() + filesize = filesize / 1024 + sizeunit = "Kb" + if filesize > 1024: + filesize = filesize / 1024 + sizeunit = "Mb" + dateloaded = attachment.getDateLoaded() + dataline = [] + dataitem = {'value': attachment.Title()} + dataline.append(dataitem) + dataitem = {'value': filename, + 'img_before': icon} + dataline.append(dataitem) + dataitem = { + 'value': attachment.getAttachmentType().Title() if attachment.getAttachmentType() else ''} + dataline.append(dataitem) + dataitem = { + 'value': self.context.lookupMime(attachment_file.getContentType())} + dataline.append(dataitem) + dataitem = {'value': '%s%s' % (filesize, sizeunit)} + dataline.append(dataitem) + dataitem = {'value': self.ulocalized_time(dateloaded)} + dataline.append(dataitem) + + datalines.append(dataline) + + count_all += 1 + + # footer data + footlines = [] + footline = [] + footitem = {'value': _('Total'), + 'colspan': 5, + 'class': 'total_label'} + footline.append(footitem) + footitem = {'value': count_all} + footline.append(footitem) + footlines.append(footline) + + self.report_content = { + 'headings': headings, + 'parms': parms, + 'formats': formats, + 'datalines': datalines, + 'footings': footlines} + + if self.request.get('output_format', '') == 'CSV': + import csv + from six import StringIO + import datetime + + fieldnames = [ + _('Request'), + _('File'), + _('Attachment type'), + _('Content type'), + _('Size'), + _('Loaded'), + ] + output = StringIO() + dw = csv.DictWriter(output, fieldnames=fieldnames) + dw.writerow(dict((fn, fn) for fn in fieldnames)) + for row in datalines: + dw.writerow(row) + report_data = output.getvalue() + output.close() + date = datetime.datetime.now().strftime("%Y%m%d%H%M") + setheader = self.request.RESPONSE.setHeader + setheader('Content-Type', 'text/csv') + setheader("Content-Disposition", + "attachment;filename=\"analysesattachments_%s.csv\"" % date) + self.request.RESPONSE.write(report_data) + else: + return {'report_title': t(headings['header']), + 'report_data': self.template()} diff --git a/src/bika/reports/browser/reports/productivity_analysesperclient.py b/src/bika/reports/browser/reports/productivity_analysesperclient.py new file mode 100644 index 0000000..b7ae0c4 --- /dev/null +++ b/src/bika/reports/browser/reports/productivity_analysesperclient.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.CORE. +# +# SENAITE.CORE is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, version 2. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., 51 +# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Copyright 2018-2021 by it's authors. +# Some rights reserved, see README and LICENSE. + +import csv +import datetime +from collections import OrderedDict +from six import StringIO + +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from bika.lims import api +from bika.lims import bikaMessageFactory as _ +from bika.lims.browser import BrowserView +from bika.lims.catalog.analysis_catalog import CATALOG_ANALYSIS_LISTING +from bika.lims.utils import formatDateQuery, formatDateParms, logged_in_client +from bika.lims.utils import t +from plone.app.layout.globals.interfaces import IViewView +from senaite.core.workflow import ANALYSIS_WORKFLOW +from zope.interface import implements + + +class Report(BrowserView): + implements(IViewView) + template = ViewPageTemplateFile("templates/report_out.pt") + + def __init__(self, context, request, report=None): + BrowserView.__init__(self, context, request) + self.report = report + self.headings = { + 'header': _("Samples and analyses per client"), + 'subheader': _("Number of Samples and analyses per client"), + } + self.formats = { + 'columns': 3, + 'col_heads': [ + _('Client'), + _('Number of requests'), + _('Number of analyses')], + 'class': '' + } + + def __call__(self): + parms = [] + + # Base query + query = dict(portal_type="Analysis", sort_on='getClientTitle') + + # Filter by client + self.add_filter_by_client(query, parms) + + # Filter by date + self.add_filter_by_date(query, parms) + + # Filter analyses by review_state + self.add_filter_by_review_state(query, parms) + + # Fetch and fill data + data = OrderedDict() + analyses = api.search(query, CATALOG_ANALYSIS_LISTING) + total_num_analyses = len(analyses) + total_num_ars = 0 + for analysis in analyses: + client = analysis.getClientTitle + data_client = data.get(client, {}) + request = analysis.getRequestID + requests = data_client.get("requests", []) + if request not in requests: + requests += [request] + data_client["requests"] = requests + total_num_ars += 1 + data_client["analyses"] = data_client.get("analyses", 0) + 1 + data[client] = data_client + + # Generate datalines + data_lines = list() + for client, data in data.items(): + ars_count = len(data.get('requests', [])) + ans_count = data.get('analyses', 0) + data_lines.append([{"value": client}, + {"value": ars_count}, + {"value": ans_count}]) + + if self.request.get('output_format', '') == 'CSV': + return self.generate_csv(data_lines) + + self.report_content = { + 'headings': self.headings, + 'parms': parms, + 'formats': self.formats, + 'datalines': data_lines, + 'footings': [ + [{'value': _('Total'), 'class': 'total_label'}, + {'value': total_num_ars}, + {'value': total_num_analyses},],] + } + + return {'report_title': t(self.headings['header']), + 'report_data': self.template()} + + def add_filter_by_client(self, query, out_params): + """Applies the filter by client to the search query + """ + current_client = logged_in_client(self.context) + if current_client: + query['getClientUID'] = api.get_uid(current_client) + elif self.request.form.get("ClientUID", ""): + query['getClientUID'] = self.request.form['ClientUID'] + client = api.get_object_by_uid(query['getClientUID']) + out_params.append({'title': _('Client'), + 'value': client.Title(), + 'type': 'text'}) + + def add_filter_by_date(self, query, out_params): + """Applies the filter by Requested date to the search query + """ + date_query = formatDateQuery(self.context, 'Requested') + if date_query: + query['created'] = date_query + requested = formatDateParms(self.context, 'Requested') + out_params.append({'title': _('Requested'), + 'value': requested, + 'type': 'text'}) + + def add_filter_by_review_state(self, query, out_params): + """Applies the filter by review_state to the search query + """ + self.add_filter_by_wf_state(query=query, out_params=out_params, + wf_id=ANALYSIS_WORKFLOW, + index="review_state", + title=_("Status")) + + def add_filter_by_wf_state(self, query, out_params, wf_id, index, + title): + if not self.request.form.get(wf_id, ""): + return + query[index] = self.request.form[wf_id] + workflow = api.get_tool("portal_workflow") + state = workflow.getTitleForStateOnType(query[index], 'Analysis') + out_params.append({'title': title, 'value': state, 'type': 'text'}) + + def generate_csv(self, data_lines): + """Generates and writes a CSV to request's reposonse + """ + fieldnames = [ + 'Client', + 'Samples', + 'Analyses', + ] + output = StringIO() + dw = csv.DictWriter(output, extrasaction='ignore', + fieldnames=fieldnames) + dw.writerow(dict((fn, fn) for fn in fieldnames)) + for row in data_lines: + dw.writerow({ + 'Client': row[0]['value'], + 'Samples': row[1]['value'], + 'Analyses': row[2]['value'], + }) + report_data = output.getvalue() + output.close() + + date = datetime.datetime.now().strftime("%Y%m%d%H%M") + setheader = self.request.RESPONSE.setHeader + setheader('Content-Type', 'text/csv') + setheader("Content-Disposition", + "attachment;filename=\"analysesperclient_%s.csv\"" % date) + self.request.RESPONSE.write(report_data) diff --git a/src/bika/reports/browser/reports/productivity_analysesperdepartment.py b/src/bika/reports/browser/reports/productivity_analysesperdepartment.py new file mode 100644 index 0000000..fc46054 --- /dev/null +++ b/src/bika/reports/browser/reports/productivity_analysesperdepartment.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.CORE. +# +# SENAITE.CORE is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, version 2. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., 51 +# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Copyright 2018-2021 by it's authors. +# Some rights reserved, see README and LICENSE. + +from Products.CMFCore.utils import getToolByName +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from bika.lims import bikaMessageFactory as _ +from bika.lims.browser import BrowserView +from bika.lims.browser.reports.selection_macros import SelectionMacrosView +from plone.app.layout.globals.interfaces import IViewView +from senaite.core.workflow import ANALYSIS_WORKFLOW +from zope.interface import implements + + +class Report(BrowserView): + implements(IViewView) + default_template = ViewPageTemplateFile("templates/productivity.pt") + template = ViewPageTemplateFile( + "templates/productivity_analysesperdepartment.pt") + + def __init__(self, context, request, report=None): + super(Report, self).__init__(context, request) + self.report = report + self.selection_macros = SelectionMacrosView(self.context, self.request) + + def __call__(self): + + parms = [] + titles = [] + + # Apply filters + self.contentFilter = {'portal_type': 'Analysis'} + val = self.selection_macros.parse_daterange(self.request, + 'getDateRequested', + _('Date Requested')) + if val: + self.contentFilter[val['contentFilter'][0]] = val['contentFilter'][1] + parms.append(val['parms']) + titles.append(val['titles']) + + val = self.selection_macros.parse_state(self.request, + ANALYSIS_WORKFLOW, + 'getAnalysisState', + _('Analysis State')) + if val: + self.contentFilter[val['contentFilter'][0]] = val['contentFilter'][1] + parms.append(val['parms']) + titles.append(val['titles']) + + # Query the catalog and store results in a dictionary + analyses = self.senaite_catalog_analysis(self.contentFilter) + if not analyses: + message = _("No analyses matched your query") + self.context.plone_utils.addPortalMessage(message, "error") + return self.default_template() + + groupby = self.request.form.get('GroupingPeriod', '') + if (groupby != ''): + parms.append({"title": _("Grouping period"), "value": _(groupby)}) + + datalines = {} + footlines = {} + totalcount = len(analyses) + totalpublishedcount = 0 + totalperformedcount = 0 + for analysis in analyses: + analysis = analysis.getObject() + department = analysis.getDepartment() + department = department.Title() if department else '' + daterequested = analysis.created() + + group = '' + if groupby == 'Day': + group = self.ulocalized_time(daterequested) + elif groupby == 'Week': + group = daterequested.strftime( + "%Y") + ", " + daterequested.strftime("%U") + elif groupby == 'Month': + group = daterequested.strftime( + "%B") + " " + daterequested.strftime("%Y") + elif groupby == 'Year': + group = daterequested.strftime("%Y") + else: + group = '' + + dataline = {'Group': group, 'Requested': 0, 'Performed': 0, + 'Published': 0, 'Departments': {}} + deptline = {'Department': department, 'Requested': 0, 'Performed': 0, + 'Published': 0} + if (group in datalines): + dataline = datalines[group] + if (department in dataline['Departments']): + deptline = dataline['Departments'][department] + + grouptotalcount = dataline['Requested'] + 1 + groupperformedcount = dataline['Performed'] + grouppublishedcount = dataline['Published'] + + depttotalcount = deptline['Requested'] + 1 + deptperformedcount = deptline['Performed'] + deptpubishedcount = deptline['Published'] + + workflow = getToolByName(self.context, 'portal_workflow') + arstate = workflow.getInfoFor(analysis.aq_parent, 'review_state', '') + if (arstate == 'published'): + deptpubishedcount += 1 + grouppublishedcount += 1 + totalpublishedcount += 1 + + if (analysis.getResult()): + deptperformedcount += 1 + groupperformedcount += 1 + totalperformedcount += 1 + + group_performedrequested_ratio = float(groupperformedcount) / float( + grouptotalcount) + group_publishedperformed_ratio = groupperformedcount > 0 and float( + grouppublishedcount) / float(groupperformedcount) or 0 + + anl_performedrequested_ratio = float(deptperformedcount) / float( + depttotalcount) + anl_publishedperformed_ratio = deptperformedcount > 0 and float( + deptpubishedcount) / float(deptperformedcount) or 0 + + dataline['Requested'] = grouptotalcount + dataline['Performed'] = groupperformedcount + dataline['Published'] = grouppublishedcount + dataline['PerformedRequestedRatio'] = group_performedrequested_ratio + dataline['PerformedRequestedRatioPercentage'] = ('{0:.0f}'.format( + group_performedrequested_ratio * 100)) + "%" + dataline['PublishedPerformedRatio'] = group_publishedperformed_ratio + dataline['PublishedPerformedRatioPercentage'] = ('{0:.0f}'.format( + group_publishedperformed_ratio * 100)) + "%" + + deptline['Requested'] = depttotalcount + deptline['Performed'] = deptperformedcount + deptline['Published'] = deptpubishedcount + deptline['PerformedRequestedRatio'] = anl_performedrequested_ratio + deptline['PerformedRequestedRatioPercentage'] = ('{0:.0f}'.format( + anl_performedrequested_ratio * 100)) + "%" + deptline['PublishedPerformedRatio'] = anl_publishedperformed_ratio + deptline['PublishedPerformedRatioPercentage'] = ('{0:.0f}'.format( + anl_publishedperformed_ratio * 100)) + "%" + + dataline['Departments'][department] = deptline + datalines[group] = dataline + + # Footer total data + total_performedrequested_ratio = float(totalperformedcount) / float( + totalcount) + total_publishedperformed_ratio = totalperformedcount > 0 and float( + totalpublishedcount) / float(totalperformedcount) or 0 + + footline = {'Requested': totalcount, + 'Performed': totalperformedcount, + 'Published': totalpublishedcount, + 'PerformedRequestedRatio': total_performedrequested_ratio, + 'PerformedRequestedRatioPercentage': ('{0:.0f}'.format( + total_performedrequested_ratio * 100)) + "%", + 'PublishedPerformedRatio': total_publishedperformed_ratio, + 'PublishedPerformedRatioPercentage': ('{0:.0f}'.format( + total_publishedperformed_ratio * 100)) + "%"} + + footlines['Total'] = footline + + self.report_data = {'parameters': parms, + 'datalines': datalines, + 'footlines': footlines} + + if self.request.get('output_format', '') == 'CSV': + import csv + from six import StringIO + import datetime + + fieldnames = [ + 'Group', + 'Department', + 'Requested', + 'Performed', + 'Published', + ] + output = StringIO() + dw = csv.DictWriter(output, extrasaction='ignore', + fieldnames=fieldnames) + dw.writerow(dict((fn, fn) for fn in fieldnames)) + for group_name, group in datalines.items(): + for dept_name, dept in group['Departments'].items(): + dw.writerow({ + 'Group': group_name, + 'Department': dept_name, + 'Requested': dept['Requested'], + 'Performed': dept['Performed'], + 'Published': dept['Published'], + }) + report_data = output.getvalue() + output.close() + date = datetime.datetime.now().strftime("%Y%m%d%H%M") + setheader = self.request.RESPONSE.setHeader + setheader('Content-Type', 'text/csv') + setheader("Content-Disposition", + "attachment;filename=\"analysesperdepartment_%s.csv\"" % date) + self.request.RESPONSE.write(report_data) + else: + return {'report_title': _('Analyses summary per department'), + 'report_data': self.template()} diff --git a/src/bika/reports/browser/reports/productivity_analysesperformedpertotal.py b/src/bika/reports/browser/reports/productivity_analysesperformedpertotal.py new file mode 100644 index 0000000..dcd007f --- /dev/null +++ b/src/bika/reports/browser/reports/productivity_analysesperformedpertotal.py @@ -0,0 +1,211 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.CORE. +# +# SENAITE.CORE is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, version 2. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., 51 +# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Copyright 2018-2021 by it's authors. +# Some rights reserved, see README and LICENSE. + +from Products.CMFCore.utils import getToolByName +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from bika.lims import bikaMessageFactory as _ +from bika.lims.browser import BrowserView +from bika.lims.browser.reports.selection_macros import SelectionMacrosView +from plone.app.layout.globals.interfaces import IViewView +from zope.interface import implements + + +class Report(BrowserView): + implements(IViewView) + default_template = ViewPageTemplateFile("templates/productivity.pt") + template = ViewPageTemplateFile( + "templates/productivity_analysesperformedpertotal.pt") + + def __init__(self, context, request, report=None): + super(Report, self).__init__(context, request) + self.report = report + self.selection_macros = SelectionMacrosView(self.context, self.request) + + def __call__(self): + + parms = [] + titles = [] + + # Apply filters + self.contentFilter = {'portal_type': 'Analysis'} + val = self.selection_macros.parse_daterange(self.request, + 'getDateRequested', + _('Date Requested')) + if val: + self.contentFilter[val['contentFilter'][0]] = val['contentFilter'][1] + parms.append(val['parms']) + titles.append(val['titles']) + + # Query the catalog and store results in a dictionary + analyses = self.senaite_catalog_analysis(self.contentFilter) + if not analyses: + message = _("No analyses matched your query") + self.context.plone_utils.addPortalMessage(message, "error") + return self.default_template() + + groupby = self.request.form.get('GroupingPeriod', '') + if (groupby != ''): + parms.append({"title": _("Grouping period"), "value": _(groupby)}) + + datalines = {} + footlines = {} + totalcount = len(analyses) + totalpublishedcount = 0 + totalperformedcount = 0 + for analysis in analyses: + analysis = analysis.getObject() + ankeyword = analysis.getKeyword() + antitle = analysis.Title() + daterequested = analysis.created() + + group = '' + if groupby == 'Day': + group = self.ulocalized_time(daterequested) + elif groupby == 'Week': + group = daterequested.strftime( + "%Y") + ", " + daterequested.strftime("%U") + elif groupby == 'Month': + group = daterequested.strftime( + "%B") + " " + daterequested.strftime("%Y") + elif groupby == 'Year': + group = daterequested.strftime("%Y") + else: + group = '' + + dataline = {'Group': group, 'Requested': 0, 'Performed': 0, + 'Published': 0, 'Analyses': {}} + anline = {'Analysis': antitle, 'Requested': 0, 'Performed': 0, + 'Published': 0} + if (group in datalines): + dataline = datalines[group] + if (ankeyword in dataline['Analyses']): + anline = dataline['Analyses'][ankeyword] + + grouptotalcount = dataline['Requested'] + 1 + groupperformedcount = dataline['Performed'] + grouppublishedcount = dataline['Published'] + + anltotalcount = anline['Requested'] + 1 + anlperformedcount = anline['Performed'] + anlpublishedcount = anline['Published'] + + workflow = getToolByName(self.context, 'portal_workflow') + arstate = workflow.getInfoFor(analysis.aq_parent, 'review_state', '') + if (arstate == 'published'): + anlpublishedcount += 1 + grouppublishedcount += 1 + totalpublishedcount += 1 + + if (analysis.getResult()): + anlperformedcount += 1 + groupperformedcount += 1 + totalperformedcount += 1 + + group_performedrequested_ratio = float(groupperformedcount) / float( + grouptotalcount) + group_publishedperformed_ratio = groupperformedcount > 0 and float( + grouppublishedcount) / float(groupperformedcount) or 0 + + anl_performedrequested_ratio = float(anlperformedcount) / float( + anltotalcount) + anl_publishedperformed_ratio = anlperformedcount > 0 and float( + anlpublishedcount) / float(anlperformedcount) or 0 + + dataline['Requested'] = grouptotalcount + dataline['Performed'] = groupperformedcount + dataline['Published'] = grouppublishedcount + dataline['PerformedRequestedRatio'] = group_performedrequested_ratio + dataline['PerformedRequestedRatioPercentage'] = ('{0:.0f}'.format( + group_performedrequested_ratio * 100)) + "%" + dataline['PublishedPerformedRatio'] = group_publishedperformed_ratio + dataline['PublishedPerformedRatioPercentage'] = ('{0:.0f}'.format( + group_publishedperformed_ratio * 100)) + "%" + + anline['Requested'] = anltotalcount + anline['Performed'] = anlperformedcount + anline['Published'] = anlpublishedcount + anline['PerformedRequestedRatio'] = anl_performedrequested_ratio + anline['PerformedRequestedRatioPercentage'] = ('{0:.0f}'.format( + anl_performedrequested_ratio * 100)) + "%" + anline['PublishedPerformedRatio'] = anl_publishedperformed_ratio + anline['PublishedPerformedRatioPercentage'] = ('{0:.0f}'.format( + anl_publishedperformed_ratio * 100)) + "%" + + dataline['Analyses'][ankeyword] = anline + datalines[group] = dataline + + # Footer total data + total_performedrequested_ratio = float(totalperformedcount) / float( + totalcount) + total_publishedperformed_ratio = totalperformedcount > 0 and float( + totalpublishedcount) / float(totalperformedcount) or 0 + + footline = {'Requested': totalcount, + 'Performed': totalperformedcount, + 'Published': totalpublishedcount, + 'PerformedRequestedRatio': total_performedrequested_ratio, + 'PerformedRequestedRatioPercentage': ('{0:.0f}'.format( + total_performedrequested_ratio * 100)) + "%", + 'PublishedPerformedRatio': total_publishedperformed_ratio, + 'PublishedPerformedRatioPercentage': ('{0:.0f}'.format( + total_publishedperformed_ratio * 100)) + "%"} + + footlines['Total'] = footline + + self.report_data = {'parameters': parms, + 'datalines': datalines, + 'footlines': footlines} + + if self.request.get('output_format', '') == 'CSV': + import csv + import StringIO + import datetime + + fieldnames = [ + 'Group', + 'Analysis', + 'Requested', + 'Performed', + 'Published', + ] + output = StringIO() + dw = csv.DictWriter(output, extrasaction='ignore', + fieldnames=fieldnames) + dw.writerow(dict((fn, fn) for fn in fieldnames)) + for group_name, group in datalines.items(): + for service_name, service in group['Analyses'].items(): + dw.writerow({ + 'Group': group_name, + 'Analysis': service_name, + 'Requested': service['Requested'], + 'Performed': service['Performed'], + 'Published': service['Published'], + }) + report_data = output.getvalue() + output.close() + date = datetime.datetime.now().strftime("%Y%m%d%H%M") + setheader = self.request.RESPONSE.setHeader + setheader('Content-Type', 'text/csv') + setheader("Content-Disposition", + "attachment;filename=\"analysesperformedpertotal_%s.csv\"" % date) + self.request.RESPONSE.write(report_data) + else: + return {'report_title': _('Analyses performed as % of total'), + 'report_data': self.template()} diff --git a/src/bika/reports/browser/reports/productivity_analysespersampletype.py b/src/bika/reports/browser/reports/productivity_analysespersampletype.py new file mode 100644 index 0000000..e24ae45 --- /dev/null +++ b/src/bika/reports/browser/reports/productivity_analysespersampletype.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.CORE. +# +# SENAITE.CORE is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, version 2. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., 51 +# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Copyright 2018-2021 by it's authors. +# Some rights reserved, see README and LICENSE. + +from Products.CMFCore.utils import getToolByName +from bika.lims.browser import BrowserView +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from bika.lims import bikaMessageFactory as _ +from bika.lims.utils import t +from bika.lims.utils import formatDateQuery, formatDateParms, logged_in_client +from plone.app.layout.globals.interfaces import IViewView +from senaite.core.workflow import ANALYSIS_WORKFLOW +from zope.interface import implements + + +class Report(BrowserView): + implements(IViewView) + template = ViewPageTemplateFile("templates/report_out.pt") + + def __init__(self, context, request, report=None): + self.report = report + BrowserView.__init__(self, context, request) + + def __call__(self): + + # get all the data into datalines + sc = getToolByName(self.context, 'senaite_catalog_setup') + bac = getToolByName(self.context, 'senaite_catalog_analysis') + rc = getToolByName(self.context, 'reference_catalog') + self.report_content = {} + parms = [] + headings = {} + headings['header'] = _("Analyses per sample type") + headings['subheader'] = _("Number of analyses requested per sample type") + + count_all = 0 + query = {'portal_type': 'Analysis'} + client_title = None + if 'ClientUID' in self.request.form: + client_uid = self.request.form['ClientUID'] + query['getClientUID'] = client_uid + client = rc.lookupObject(client_uid) + client_title = client.Title() + else: + client = logged_in_client(self.context) + if client: + client_title = client.Title() + query['getClientUID'] = client.UID() + if client_title: + parms.append( + {'title': _('Client'), + 'value': client_title, + 'type': 'text'}) + + date_query = formatDateQuery(self.context, 'Requested') + if date_query: + query['created'] = date_query + requested = formatDateParms(self.context, 'Requested') + parms.append( + {'title': _('Requested'), + 'value': requested, + 'type': 'text'}) + + workflow = getToolByName(self.context, 'portal_workflow') + if ANALYSIS_WORKFLOW in self.request.form: + query['review_state'] = self.request.form[ANALYSIS_WORKFLOW] + review_state = workflow.getTitleForStateOnType( + self.request.form[ANALYSIS_WORKFLOW], 'Analysis') + parms.append( + {'title': _('Status'), + 'value': review_state, + 'type': 'text'}) + + # and now lets do the actual report lines + formats = {'columns': 2, + 'col_heads': [_('Sample type'), _('Number of analyses')], + 'class': '', + } + + datalines = [] + for sampletype in sc(portal_type="SampleType", + sort_on='sortable_title'): + query['getSampleTypeUID'] = sampletype.UID + analyses = bac(query) + count_analyses = len(analyses) + + dataline = [] + dataitem = {'value': sampletype.Title} + dataline.append(dataitem) + dataitem = {'value': count_analyses} + + dataline.append(dataitem) + + datalines.append(dataline) + + count_all += count_analyses + + # footer data + footlines = [] + footline = [] + footitem = {'value': _('Total'), + 'class': 'total_label'} + footline.append(footitem) + footitem = {'value': count_all} + footline.append(footitem) + footlines.append(footline) + + self.report_content = { + 'headings': headings, + 'parms': parms, + 'formats': formats, + 'datalines': datalines, + 'footings': footlines} + + if self.request.get('output_format', '') == 'CSV': + import csv + from six import StringIO + import datetime + + fieldnames = [ + 'Sample Type', + 'Analyses', + ] + output = StringIO() + dw = csv.DictWriter(output, extrasaction='ignore', + fieldnames=fieldnames) + dw.writerow(dict((fn, fn) for fn in fieldnames)) + for row in datalines: + dw.writerow({ + 'Sample Type': row[0]['value'], + 'Analyses': row[1]['value'], + }) + report_data = output.getvalue() + output.close() + date = datetime.datetime.now().strftime("%Y%m%d%H%M") + setheader = self.request.RESPONSE.setHeader + setheader('Content-Type', 'text/csv') + setheader("Content-Disposition", + "attachment;filename=\"analysespersampletype_%s.csv\"" % date) + self.request.RESPONSE.write(report_data) + else: + return {'report_title': t(headings['header']), + 'report_data': self.template()} diff --git a/src/bika/reports/browser/reports/productivity_analysesperservice.py b/src/bika/reports/browser/reports/productivity_analysesperservice.py new file mode 100644 index 0000000..d48451b --- /dev/null +++ b/src/bika/reports/browser/reports/productivity_analysesperservice.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.CORE. +# +# SENAITE.CORE is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, version 2. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., 51 +# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Copyright 2018-2021 by it's authors. +# Some rights reserved, see README and LICENSE. + +from Products.CMFCore.utils import getToolByName +from bika.lims.browser import BrowserView +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from bika.lims import bikaMessageFactory as _ +from bika.lims.utils import t +from bika.lims.utils import formatDateQuery, formatDateParms, logged_in_client +from plone.app.layout.globals.interfaces import IViewView +from senaite.core.workflow import ANALYSIS_WORKFLOW +from zope.interface import implements + + +class Report(BrowserView): + implements(IViewView) + template = ViewPageTemplateFile( + "templates/productivity_analysesperservice.pt") + + def __init__(self, context, request, report=None): + self.report = report + BrowserView.__init__(self, context, request) + + def __call__(self): + # get all the data into datalines + + sc = getToolByName(self.context, 'senaite_catalog_setup') + bc = getToolByName(self.context, 'senaite_catalog_analysis') + rc = getToolByName(self.context, 'reference_catalog') + self.report_content = {} + parms = [] + headings = {} + headings['header'] = _("Analyses per analysis service") + headings['subheader'] = _( + "Number of analyses requested per analysis service") + + query = {'portal_type': 'Analysis'} + client_title = None + if 'ClientUID' in self.request.form: + client_uid = self.request.form['ClientUID'] + query['getClientUID'] = client_uid + client = rc.lookupObject(client_uid) + client_title = client.Title() + else: + client = logged_in_client(self.context) + if client: + client_title = client.Title() + query['getClientUID'] = client.UID() + if client_title: + parms.append( + {'title': _('Client'), 'value': client_title, 'type': 'text'}) + + date_query = formatDateQuery(self.context, 'Requested') + if date_query: + query['created'] = date_query + requested = formatDateParms(self.context, 'Requested') + parms.append( + {'title': _('Requested'), 'value': requested, 'type': 'text'}) + + date_query = formatDateQuery(self.context, 'Published') + if date_query: + query['getDatePublished'] = date_query + published = formatDateParms(self.context, 'Published') + parms.append( + {'title': _('Published'), 'value': published, 'type': 'text'}) + + workflow = getToolByName(self.context, 'portal_workflow') + if ANALYSIS_WORKFLOW in self.request.form: + query['review_state'] = self.request.form[ANALYSIS_WORKFLOW] + review_state = workflow.getTitleForStateOnType( + self.request.form[ANALYSIS_WORKFLOW], 'Analysis') + parms.append( + {'title': _('Status'), 'value': review_state, 'type': 'text'}) + + # and now lets do the actual report lines + formats = {'columns': 2, + 'col_heads': [_('Analysis service'), _('Number of analyses')], + 'class': '', + } + + datalines = [] + count_all = 0 + for cat in sc(portal_type="AnalysisCategory", + sort_on='sortable_title'): + dataline = [{'value': cat.Title, + 'class': 'category_heading', + 'colspan': 2}, ] + datalines.append(dataline) + for service in sc(portal_type="AnalysisService", + category_uid=cat.UID, + sort_on='sortable_title'): + query['getServiceUID'] = service.UID + analyses = bc(query) + count_analyses = len(analyses) + + dataline = [] + dataitem = {'value': service.Title} + dataline.append(dataitem) + dataitem = {'value': count_analyses} + + dataline.append(dataitem) + + datalines.append(dataline) + + count_all += count_analyses + + # footer data + footlines = [] + footline = [] + footitem = {'value': _('Total'), + 'class': 'total_label'} + footline.append(footitem) + footitem = {'value': count_all} + footline.append(footitem) + footlines.append(footline) + + self.report_content = { + 'headings': headings, + 'parms': parms, + 'formats': formats, + 'datalines': datalines, + 'footings': footlines} + + title = t(headings['header']) + + if self.request.get('output_format', '') == 'CSV': + import csv + from six import StringIO + import datetime + + fieldnames = [ + 'Analysis Service', + 'Analyses', + ] + output = StringIO() + dw = csv.DictWriter(output, extrasaction='ignore', + fieldnames=fieldnames) + dw.writerow(dict((fn, fn) for fn in fieldnames)) + for row in datalines: + if len(row) == 1: + # category heading thingy + continue + dw.writerow({ + 'Analysis Service': row[0]['value'], + 'Analyses': row[1]['value'], + }) + report_data = output.getvalue() + output.close() + date = datetime.datetime.now().strftime("%Y%m%d%H%M") + setheader = self.request.RESPONSE.setHeader + setheader('Content-Type', 'text/csv') + setheader("Content-Disposition", + "attachment;filename=\"analysesperservice_%s.csv\"" % date) + self.request.RESPONSE.write(report_data) + else: + return {'report_title': title, + 'report_data': self.template()} diff --git a/src/bika/reports/browser/reports/productivity_analysestats.py b/src/bika/reports/browser/reports/productivity_analysestats.py new file mode 100644 index 0000000..e50d8ac --- /dev/null +++ b/src/bika/reports/browser/reports/productivity_analysestats.py @@ -0,0 +1,330 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.CORE. +# +# SENAITE.CORE is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, version 2. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., 51 +# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Copyright 2018-2021 by it's authors. +# Some rights reserved, see README and LICENSE. + +from Products.CMFCore.utils import getToolByName +from bika.lims.browser import BrowserView +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from bika.lims import api +from bika.lims import bikaMessageFactory as _ +from bika.lims.utils import t +from bika.lims.utils import formatDateQuery, formatDateParms, \ + logged_in_client +from plone.app.layout.globals.interfaces import IViewView +from zope.interface import implements + + +class Report(BrowserView): + implements(IViewView) + template = ViewPageTemplateFile("templates/report_out.pt") + + def __init__(self, context, request, report=None): + self.report = report + BrowserView.__init__(self, context, request) + + def __call__(self): + # get all the data into datalines + + sc = getToolByName(self.context, 'senaite_catalog_setup') + bc = getToolByName(self.context, 'senaite_catalog_analysis') + rc = getToolByName(self.context, 'reference_catalog') + self.report_content = {} + parms = [] + headings = {} + headings['header'] = _("Analysis turnaround times") + headings['subheader'] = _("The turnaround time of analyses") + + query = {'portal_type': 'Analysis'} + client_title = None + if 'ClientUID' in self.request.form: + client_uid = self.request.form['ClientUID'] + query['getClientUID'] = client_uid + client = rc.lookupObject(client_uid) + client_title = client.Title() + else: + client = logged_in_client(self.context) + if client: + client_title = client.Title() + query['getClientUID'] = client.UID() + if client_title: + parms.append( + {'title': _('Client'), + 'value': client_title, + 'type': 'text'}) + + date_query = formatDateQuery(self.context, 'Received') + if date_query: + query['created'] = date_query + received = formatDateParms(self.context, 'Received') + parms.append( + {'title': _('Received'), + 'value': received, + 'type': 'text'}) + + query['review_state'] = 'published' + + services = {} + analyses = bc(query) + for a in analyses: + analysis = a.getObject() + service_uid = analysis.getServiceUID() + if service_uid not in services: + services[service_uid] = {'count_early': 0, + 'count_late': 0, + 'mins_early': 0, + 'mins_late': 0, + 'count_undefined': 0, + } + earliness = analysis.getEarliness() + if earliness < 0: + count_late = services[service_uid]['count_late'] + mins_late = services[service_uid]['mins_late'] + count_late += 1 + mins_late -= earliness + services[service_uid]['count_late'] = count_late + services[service_uid]['mins_late'] = mins_late + if earliness > 0: + count_early = services[service_uid]['count_early'] + mins_early = services[service_uid]['mins_early'] + count_early += 1 + mins_early += earliness + services[service_uid]['count_early'] = count_early + services[service_uid]['mins_early'] = mins_early + if earliness == 0: + count_undefined = services[service_uid]['count_undefined'] + count_undefined += 1 + services[service_uid]['count_undefined'] = count_undefined + + # calculate averages + for service_uid in services.keys(): + count_early = services[service_uid]['count_early'] + mins_early = services[service_uid]['mins_early'] + if count_early == 0: + services[service_uid]['ave_early'] = '' + else: + avemins = (mins_early) / count_early + services[service_uid]['ave_early'] = \ + api.to_dhm_format(minutes=avemins) + count_late = services[service_uid]['count_late'] + mins_late = services[service_uid]['mins_late'] + if count_late == 0: + services[service_uid]['ave_late'] = '' + else: + avemins = mins_late / count_late + services[service_uid]['ave_late'] = api.to_dhm_format(avemins) + + # and now lets do the actual report lines + formats = {'columns': 7, + 'col_heads': [_('Analysis'), + _('Count'), + _('Undefined'), + _('Late'), + _('Average late'), + _('Early'), + _('Average early'), + ], + 'class': '', + } + + total_count_early = 0 + total_count_late = 0 + total_mins_early = 0 + total_mins_late = 0 + total_count_undefined = 0 + datalines = [] + + for cat in sc(portal_type='AnalysisCategory', + sort_on='sortable_title'): + catline = [{'value': cat.Title, + 'class': 'category_heading', + 'colspan': 7}, ] + first_time = True + cat_count_early = 0 + cat_count_late = 0 + cat_count_undefined = 0 + cat_mins_early = 0 + cat_mins_late = 0 + for service in sc(portal_type="AnalysisService", + getCategoryUID=cat.UID, + sort_on='sortable_title'): + + dataline = [{'value': service.Title, + 'class': 'testgreen'}, ] + if service.UID not in services: + continue + + if first_time: + datalines.append(catline) + first_time = False + + # analyses found + cat_count_early += services[service.UID]['count_early'] + cat_count_late += services[service.UID]['count_late'] + cat_count_undefined += services[service.UID]['count_undefined'] + cat_mins_early += services[service.UID]['mins_early'] + cat_mins_late += services[service.UID]['mins_late'] + + count = services[service.UID]['count_early'] + \ + services[service.UID]['count_late'] + \ + services[service.UID]['count_undefined'] + + dataline.append({'value': count, + 'class': 'number'}) + dataline.append( + {'value': services[service.UID]['count_undefined'], + 'class': 'number'}) + dataline.append({'value': services[service.UID]['count_late'], + 'class': 'number'}) + dataline.append({'value': services[service.UID]['ave_late'], + 'class': 'number'}) + dataline.append({'value': services[service.UID]['count_early'], + 'class': 'number'}) + dataline.append({'value': services[service.UID]['ave_early'], + 'class': 'number'}) + + datalines.append(dataline) + + # category totals + dataline = [{'value': '%s - total' % (cat.Title), + 'class': 'subtotal_label'}, ] + + dataline.append({'value': cat_count_early + + cat_count_late + + cat_count_undefined, + 'class': 'subtotal_number'}) + + dataline.append({'value': cat_count_undefined, + 'class': 'subtotal_number'}) + + dataline.append({'value': cat_count_late, + 'class': 'subtotal_number'}) + + if cat_count_late: + dataitem = {'value': cat_mins_late / cat_count_late, + 'class': 'subtotal_number'} + else: + dataitem = {'value': 0, + 'class': 'subtotal_number'} + + dataline.append(dataitem) + + dataline.append({'value': cat_count_early, + 'class': 'subtotal_number'}) + + if cat_count_early: + dataitem = {'value': cat_mins_early / cat_count_early, + 'class': 'subtotal_number'} + else: + dataitem = {'value': 0, + 'class': 'subtotal_number'} + + dataline.append(dataitem) + + total_count_early += cat_count_early + total_count_late += cat_count_late + total_count_undefined += cat_count_undefined + total_mins_early += cat_mins_early + total_mins_late += cat_mins_late + + # footer data + footlines = [] + footline = [] + footline = [{'value': _('Total'), + 'class': 'total'}, ] + + footline.append({'value': total_count_early + + total_count_late + + total_count_undefined, + 'class': 'total number'}) + + footline.append({'value': total_count_undefined, + 'class': 'total number'}) + + footline.append({'value': total_count_late, + 'class': 'total number'}) + + if total_count_late: + ave_mins = total_mins_late / total_count_late + footline.append({'value': api.to_dhm_format(minutes=ave_mins), + 'class': 'total number'}) + else: + footline.append({'value': ''}) + + footline.append({'value': total_count_early, + 'class': 'total number'}) + + if total_count_early: + ave_mins = total_mins_early / total_count_early + footline.append({'value': api.to_dhm_format(minutes=ave_mins), + 'class': 'total number'}) + else: + footline.append({'value': '', + 'class': 'total number'}) + + footlines.append(footline) + + self.report_content = { + 'headings': headings, + 'parms': parms, + 'formats': formats, + 'datalines': datalines, + 'footings': footlines} + + if self.request.get('output_format', '') == 'CSV': + import csv + from six import StringIO + import datetime + + fieldnames = [ + 'Analysis', + 'Count', + 'Undefined', + 'Late', + 'Average late', + 'Early', + 'Average early', + ] + output = StringIO() + dw = csv.DictWriter(output, extrasaction='ignore', + fieldnames=fieldnames) + dw.writerow(dict((fn, fn) for fn in fieldnames)) + for row in datalines: + if len(row) == 1: + # category heading thingy + continue + dw.writerow({ + 'Analysis': row[0]['value'], + 'Count': row[1]['value'], + 'Undefined': row[2]['value'], + 'Late': row[3]['value'], + 'Average late': row[4]['value'], + 'Early': row[5]['value'], + 'Average early': row[6]['value'], + }) + report_data = output.getvalue() + output.close() + date = datetime.datetime.now().strftime("%Y%m%d%H%M") + setheader = self.request.RESPONSE.setHeader + setheader('Content-Type', 'text/csv') + setheader("Content-Disposition", + "attachment;filename=\"analysestats_%s.csv\"" % date) + self.request.RESPONSE.write(report_data) + else: + return {'report_title': t(headings['header']), + 'report_data': self.template()} diff --git a/src/bika/reports/browser/reports/productivity_analysestats_overtime.py b/src/bika/reports/browser/reports/productivity_analysestats_overtime.py new file mode 100644 index 0000000..7b99824 --- /dev/null +++ b/src/bika/reports/browser/reports/productivity_analysestats_overtime.py @@ -0,0 +1,213 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.CORE. +# +# SENAITE.CORE is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, version 2. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., 51 +# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Copyright 2018-2021 by it's authors. +# Some rights reserved, see README and LICENSE. + +import csv +import datetime + +from six import StringIO +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from bika.lims import api +from bika.lims import bikaMessageFactory as _ +from bika.lims.browser import BrowserView +from bika.lims.catalog.analysis_catalog import CATALOG_ANALYSIS_LISTING +from bika.lims.utils import formatDateQuery, formatDateParms +from bika.lims.utils import t +from plone.app.layout.globals.interfaces import IViewView +from zope.interface import implements + + +class Report(BrowserView): + implements(IViewView) + template = ViewPageTemplateFile("templates/report_out.pt") + + def __init__(self, context, request, report=None): + BrowserView.__init__(self, context, request) + self.report = report + self.headings = { + 'header': _("Analysis turnaround times over time"), + 'subheader': _("The turnaround time of analyses plotted over time") + } + self.formats = { + 'columns': 2, + 'col_heads': [ + _('Date'), + _('Turnaround time (h)')], + 'class': '' + } + + def __call__(self): + parms = [] + query = dict(portal_type="Analysis", sort_on="getDateReceived", + sort_order="ascending") + + # Filter by Service UID + self.add_filter_by_service(query=query, out_params=parms) + + # Filter by Analyst + self.add_filter_by_analyst(query=query, out_params=parms) + + # Filter by date range + self.add_filter_by_date_range(query=query, out_params=parms) + + # Period + period = self.request.form.get("Period", "Day") + parms.append( + {"title": _("Period"), + "value": period, + "type": "text"} + ) + + # Fetch the data + data_lines = [] + prev_date_key = None + count = 0 + duration = 0 + total_count = 0 + total_duration = 0 + analyses = api.search(query, CATALOG_ANALYSIS_LISTING) + for analysis in analyses: + analysis = api.get_object(analysis) + date_key = self.get_period_key(analysis.getDateReceived(), + self.date_format_short) + if date_key and date_key != prev_date_key: + if prev_date_key: + # Calculate averages + data_lines.append( + [{"value": prev_date_key, 'class': ''}, + {"value": api.to_dhm_format(minutes=(duration//count)), + "class": "number"}] + ) + count = 0 + duration = 0 + + analysis_duration = analysis.getDuration() + count += 1 + total_count += 1 + duration += analysis_duration + total_duration += analysis_duration + prev_date_key = date_key + + if prev_date_key: + # Calculate averages + data_lines.append( + [{"value": prev_date_key, 'class': ''}, + {"value": api.to_dhm_format(minutes=(duration//count)), + "class": "number"}] + ) + + # Totals + total_duration = total_count and total_duration / total_count or 0 + total_duration = api.to_dhm_format(minutes=total_duration) + + if self.request.get("output_format", "") == "CSV": + return self.generate_csv(data_lines) + + self.report_content = { + 'headings': self.headings, + 'parms': parms, + 'formats': self.formats, + 'datalines': data_lines, + 'footings': [ + [{'value': _('Total data points'), 'class': 'total'}, + {'value': total_count, 'class': 'total number'}], + + [{'value': _('Average TAT'), 'class': 'total'}, + {'value': total_duration, 'class': 'total number'}] + ]} + return {'report_title': t(self.headings['header']), + 'report_data': self.template()} + + def add_filter_by_service(self, query, out_params): + if not self.request.form.get("ServiceUID", ""): + return + query["getServiceUID"] = self.request.form["ServiceUID"] + service = api.get_object_by_uid(query["getServiceUID"]) + out_params.append({ + "title": _("Analysis Service"), + "value": service.Title(), + "type": "text"}) + + def add_filter_by_analyst(self, query, out_params): + if not self.request.form.get("Analyst", ""): + return + query["getAnalyst"] = self.request.form["Analyst"] + out_params.append({ + "title": _("Analyst"), + "value": self.user_fullname(query["getAnalyst"]), + "type": "text"}) + + def add_filter_by_instrument(self, query, out_params): + if not self.request.form.get("getInstrumentUID", ""): + return + query["getInstrumentUID"] = self.request.form["getInstrumentUID"] + instrument = api.get_object_by_uid(query["getInstrumentUID"]) + out_params.append({ + "title": _("Instrument"), + "value": instrument.Title(), + "type": "text"}) + + def add_filter_by_date_range(self, query, out_params): + date_query = formatDateQuery(self.context, "tats_DateReceived") + if not date_query: + return + query["getDateReceived"] = date_query + out_params.append( + {"title": _("Received"), + "value": formatDateParms(self.context, "Tats_DateReceived"), + "type": "text"} + ) + + def get_period_key(self, date_time, date_format): + period = self.request.form.get("Period", "Day") + if period == "Day": + return date_time.strftime('%d %b %Y') + elif period == "Week": + day_of_week = date_time.strftime("%w") + first_day = date_time - (int(day_of_week) - 1) + # Sunday = 0 + if not day_of_week: + first_day = date_time - 6 + return first_day.strftime(date_format) + elif period == "Month": + return date_time.strftime("%b %Y") + return "unknown" + + def generate_csv(self, data_lines): + fieldnames = [ + 'Date', + 'Turnaround time (h)', + ] + output = StringIO() + dw = csv.DictWriter(output, extrasaction='ignore', + fieldnames=fieldnames) + dw.writerow(dict((fn, fn) for fn in fieldnames)) + for row in data_lines: + dw.writerow({ + 'Date': row[0]['value'], + 'Turnaround time (h)': row[1]['value'], + }) + report_data = output.getvalue() + output.close() + date = datetime.datetime.now().strftime("%Y%m%d%H%M") + setheader = self.request.RESPONSE.setHeader + setheader('Content-Type', 'text/csv') + setheader("Content-Disposition", + "attachment;filename=\"analysesperservice_%s.csv\"" % date) + self.request.RESPONSE.write(report_data) diff --git a/src/bika/reports/browser/reports/productivity_dailysamplesreceived.py b/src/bika/reports/browser/reports/productivity_dailysamplesreceived.py new file mode 100644 index 0000000..44f94f8 --- /dev/null +++ b/src/bika/reports/browser/reports/productivity_dailysamplesreceived.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.CORE. +# +# SENAITE.CORE is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, version 2. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., 51 +# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Copyright 2018-2021 by it's authors. +# Some rights reserved, see README and LICENSE. + +from bika.lims import api +from bika.lims import bikaMessageFactory as _ +from bika.lims.browser import BrowserView +from bika.lims.browser.reports.selection_macros import SelectionMacrosView +from plone.app.layout.globals.interfaces import IViewView +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from senaite.core.catalog import SAMPLE_CATALOG +from zope.interface import implements + + +class Report(BrowserView): + implements(IViewView) + default_template = ViewPageTemplateFile("templates/productivity.pt") + template = ViewPageTemplateFile( + "templates/productivity_dailysamplesreceived.pt") + + def __init__(self, context, request, report=None): + super(Report, self).__init__(context, request) + self.report = report + self.selection_macros = SelectionMacrosView(self.context, self.request) + + def __call__(self): + + parms = [] + titles = [] + + self.contentFilter = dict(portal_type="AnalysisRequest", + is_active=True, + sort_on="getDateReceived") + + val = self.selection_macros.parse_daterange(self.request, + 'getDateReceived', + _('Date Received')) + if val: + self.contentFilter[val['contentFilter'][0]] = val['contentFilter'][1] + parms.append(val['parms']) + titles.append(val['titles']) + + # Query the catalog and store results in a dictionary + ars = api.search(self.contentFilter, SAMPLE_CATALOG) + if not ars: + message = _("No Samples matched your query") + self.context.plone_utils.addPortalMessage(message, "error") + return self.default_template() + + datalines = [] + analyses_count = 0 + for ar in ars: + ar = api.get_object(ar) + # For each sample, retrieve the analyses and generate + # a data line for each one + for analysis in ar.getAnalyses(): + analysis = analysis.getObject() + ds = ar.getDateSampled() + sd = ar.getSamplingDate() + dataline = {'AnalysisKeyword': analysis.getKeyword(), + 'AnalysisTitle': analysis.Title(), + 'SampleID': ar.getId(), + 'SampleType': ar.getSampleType().Title(), + 'DateReceived': self.ulocalized_time( + ar.getDateReceived(), long_format=1), + 'DateSampled': self.ulocalized_time( + ds, long_format=1), + } + if self.context.bika_setup.getSamplingWorkflowEnabled(): + dataline['SamplingDate']= self.ulocalized_time( + sd, long_format=1) + datalines.append(dataline) + analyses_count += 1 + + # Footer total data + footlines = [] + footline = {'TotalCount': analyses_count} + footlines.append(footline) + + self.report_data = { + 'parameters': parms, + 'datalines': datalines, + 'footlines': footlines} + + if self.request.get('output_format', '') == 'CSV': + import csv + from six import StringIO + import datetime + + fieldnames = [ + 'SampleID', + 'SampleType', + 'DateSampled', + 'DateReceived', + 'AnalysisTitle', + 'AnalysisKeyword', + ] + if self.context.bika_setup.getSamplingWorkflowEnabled(): + fieldnames.append('SamplingDate') + output = StringIO() + dw = csv.DictWriter(output, fieldnames=fieldnames) + dw.writerow(dict((fn, fn) for fn in fieldnames)) + for row in datalines: + dw.writerow(row) + report_data = output.getvalue() + output.close() + date = datetime.datetime.now().strftime("%Y%m%d%H%M") + setheader = self.request.RESPONSE.setHeader + setheader('Content-Type', 'text/csv') + setheader("Content-Disposition", + "attachment;filename=\"dailysamplesreceived_%s.csv\"" % date) + self.request.RESPONSE.write(report_data) + else: + return {'report_title': _('Daily samples received'), + 'report_data': self.template()} diff --git a/src/bika/reports/browser/reports/productivity_dataentrydaybook.py b/src/bika/reports/browser/reports/productivity_dataentrydaybook.py new file mode 100644 index 0000000..1960593 --- /dev/null +++ b/src/bika/reports/browser/reports/productivity_dataentrydaybook.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.CORE. +# +# SENAITE.CORE is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, version 2. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., 51 +# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Copyright 2018-2021 by it's authors. +# Some rights reserved, see README and LICENSE. + +from bika.lims import bikaMessageFactory as _ +from bika.lims import logger +from bika.lims.browser import BrowserView +from bika.lims.browser.reports.selection_macros import SelectionMacrosView +from bika.lims.workflow import getTransitionDate +from plone.app.layout.globals.interfaces import IViewView +from Products.CMFCore.utils import getToolByName +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from senaite.core.catalog import SAMPLE_CATALOG +from zope.interface import implements + + +class Report(BrowserView): + implements(IViewView) + default_template = ViewPageTemplateFile("templates/productivity.pt") + template = ViewPageTemplateFile("templates/productivity_dataentrydaybook.pt") + + def __init__(self, context, request, report=None): + super(Report, self).__init__(context, request) + self.report = report + self.selection_macros = SelectionMacrosView(self.context, self.request) + + def __call__(self): + + parms = [] + titles = [] + + # Apply filters + self.contentFilter = {'portal_type': 'AnalysisRequest'} + val = self.selection_macros.parse_daterange(self.request, + 'getDateCreated', + _('Date Created')) + if val: + self.contentFilter["created"] = val['contentFilter'][1] + parms.append(val['parms']) + titles.append(val['titles']) + + # Query the catalog and store results in a dictionary + catalog = getToolByName(self.context, SAMPLE_CATALOG) + ars = catalog(self.contentFilter) + + logger.info("Catalog Query '{}' returned {} results".format( + self.contentFilter, len(ars))) + + if not ars: + message = _("No Samples matched your query") + self.context.plone_utils.addPortalMessage(message, "error") + return self.default_template() + + datalines = {} + footlines = {} + totalcreatedcount = len(ars) + totalreceivedcount = 0 + totalpublishedcount = 0 + totalanlcount = 0 + totalreceptionlag = 0 + totalpublicationlag = 0 + + for ar in ars: + ar = ar.getObject() + datecreated = ar.created() + datereceived = ar.getDateReceived() + datepublished = getTransitionDate(ar, 'publish') + receptionlag = 0 + publicationlag = 0 + anlcount = len(ar.getAnalyses()) + + dataline = { + "AnalysisRequestID": ar.getId(), + "DateCreated": self.ulocalized_time(datecreated), + "DateReceived": self.ulocalized_time(datereceived), + "DatePublished": self.ulocalized_time(datepublished), + "ReceptionLag": receptionlag, + "PublicationLag": publicationlag, + "TotalLag": receptionlag + publicationlag, + "BatchID": ar.getBatch().getId() if ar.getBatch() else '', + "SampleID": ar.getId(), + "SampleType": ar.getSampleTypeTitle(), + "NumAnalyses": anlcount, + "ClientID": ar.aq_parent.id, + "Creator": ar.Creator(), + "Remarks": ar.getRemarks() + } + + datalines[ar.getId()] = dataline + + totalreceivedcount += ar.getDateReceived() and 1 or 0 + totalpublishedcount += 1 if datepublished else 0 + totalanlcount += anlcount + totalreceptionlag += receptionlag + totalpublicationlag += publicationlag + + # Footer total data + totalreceivedcreated_ratio = float(totalreceivedcount) / float( + totalcreatedcount) + totalpublishedcreated_ratio = float(totalpublishedcount) / float( + totalcreatedcount) + totalpublishedreceived_ratio = totalreceivedcount and float( + totalpublishedcount) / float(totalreceivedcount) or 0 + + footline = {'Created': totalcreatedcount, + 'Received': totalreceivedcount, + 'Published': totalpublishedcount, + 'ReceivedCreatedRatio': totalreceivedcreated_ratio, + 'ReceivedCreatedRatioPercentage': ('{0:.0f}'.format( + totalreceivedcreated_ratio * 100)) + "%", + 'PublishedCreatedRatio': totalpublishedcreated_ratio, + 'PublishedCreatedRatioPercentage': ('{0:.0f}'.format( + totalpublishedcreated_ratio * 100)) + "%", + 'PublishedReceivedRatio': totalpublishedreceived_ratio, + 'PublishedReceivedRatioPercentage': ('{0:.0f}'.format( + totalpublishedreceived_ratio * 100)) + "%", + 'AvgReceptionLag': ( + '{0:.1f}'.format(totalreceptionlag / totalcreatedcount)), + 'AvgPublicationLag': ( + '{0:.1f}'.format(totalpublicationlag / totalcreatedcount)), + 'AvgTotalLag': ('{0:.1f}'.format(( + totalreceptionlag + totalpublicationlag) / totalcreatedcount)), + 'NumAnalyses': totalanlcount + } + + footlines['Total'] = footline + + self.report_data = {'parameters': parms, + 'datalines': datalines, + 'footlines': footlines} + + if self.request.get('output_format', '') == 'CSV': + import csv + from six import StringIO + import datetime + + fieldnames = [ + "AnalysisRequestID", + "DateCreated", + "DateReceived", + "DatePublished", + "ReceptionLag", + "PublicationLag", + "TotalLag", + "BatchID", + "SampleID", + "SampleType", + "NumAnalyses", + "ClientID", + "Creator", + "Remarks", + ] + output = StringIO() + dw = csv.DictWriter(output, extrasaction='ignore', + fieldnames=fieldnames) + dw.writerow(dict((fn, fn) for fn in fieldnames)) + for ar_id, row in datalines.items(): + dw.writerow({ + "AnalysisRequestID": row["AnalysisRequestID"], + "DateCreated": row["DateCreated"], + "DateReceived": row["DateReceived"], + "DatePublished": row["DatePublished"], + "ReceptionLag": row["ReceptionLag"], + "PublicationLag": row["PublicationLag"], + "TotalLag": row["TotalLag"], + "BatchID": row["BatchID"], + "SampleID": row["SampleID"], + "SampleType": row["SampleType"], + "NumAnalyses": row["NumAnalyses"], + "ClientID": row["ClientID"], + "Creator": row["Creator"], + "Remarks": row["Remarks"], + }) + report_data = output.getvalue() + output.close() + date = datetime.datetime.now().strftime("%Y%m%d%H%M") + setheader = self.request.RESPONSE.setHeader + setheader('Content-Type', 'text/csv') + setheader("Content-Disposition", + "attachment;filename=\"dataentrydaybook_%s.csv\"" % date) + self.request.RESPONSE.write(report_data) + else: + return {'report_title': _('Data entry day book'), + 'report_data': self.template()} diff --git a/src/bika/reports/browser/reports/productivity_samplereceivedvsreported.py b/src/bika/reports/browser/reports/productivity_samplereceivedvsreported.py new file mode 100644 index 0000000..cf188ff --- /dev/null +++ b/src/bika/reports/browser/reports/productivity_samplereceivedvsreported.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.CORE. +# +# SENAITE.CORE is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, version 2. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., 51 +# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Copyright 2018-2021 by it's authors. +# Some rights reserved, see README and LICENSE. + +from bika.lims import api +from bika.lims import bikaMessageFactory as _ +from bika.lims.browser import BrowserView +from bika.lims.browser.reports.selection_macros import SelectionMacrosView +from plone.app.layout.globals.interfaces import IViewView +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from senaite.core.catalog import SAMPLE_CATALOG +from zope.interface import implements + + +def percentage(part, whole): + return + + +class Report(BrowserView): + implements(IViewView) + default_template = ViewPageTemplateFile("templates/productivity.pt") + template = ViewPageTemplateFile( + "templates/productivity_samplereceivedvsreported.pt") + + def __init__(self, context, request, report=None): + super(Report, self).__init__(context, request) + self.report = report + self.selection_macros = SelectionMacrosView(self.context, self.request) + + def __call__(self): + + parms = [] + titles = [] + + self.contentFilter = dict(portal_type="AnalysisRequest", + is_active=True, + sort_on="getDateReceived") + + val = self.selection_macros.parse_daterange(self.request, + 'getDateReceived', + _('Date Received')) + if val: + self.contentFilter[val['contentFilter'][0]] = val['contentFilter'][1] + parms.append(val['parms']) + titles.append(val['titles']) + + # Query the catalog and store results in a dictionary + ars = api.search(self.contentFilter, SAMPLE_CATALOG) + if not ars: + message = _("No samples matched your query") + self.context.plone_utils.addPortalMessage(message, "error") + return self.default_template() + + datalines = {} + footlines = {} + total_received_count = 0 + total_published_count = 0 + for ar in ars: + published = api.get_workflow_status_of(ar) == "published" + ar = api.get_object(ar) + datereceived = ar.getDateReceived() + monthyear = datereceived.strftime("%B") + " " + datereceived.strftime( + "%Y") + received = 1 + publishedcnt = published and 1 or 0 + if (monthyear in datalines): + received = datalines[monthyear]['ReceivedCount'] + 1 + publishedcnt = published and datalines[monthyear][ + 'PublishedCount'] + 1 or \ + datalines[monthyear]['PublishedCount'] + ratio = publishedcnt / received + dataline = {'MonthYear': monthyear, + 'ReceivedCount': received, + 'PublishedCount': publishedcnt, + 'UnpublishedCount': received - publishedcnt, + 'Ratio': ratio, + 'RatioPercentage': '%02d' % ( + 100 * (float(publishedcnt) / float(received))) + '%'} + datalines[monthyear] = dataline + + total_received_count += 1 + total_published_count = published and total_published_count + 1 or total_published_count + + # Footer total data + ratio = total_published_count / total_received_count + footline = {'ReceivedCount': total_received_count, + 'PublishedCount': total_published_count, + 'UnpublishedCount': total_received_count - total_published_count, + 'Ratio': ratio, + 'RatioPercentage': '%02d' % (100 * ( + float(total_published_count) / float( + total_received_count))) + '%' + } + footlines['Total'] = footline + + self.report_data = { + 'parameters': parms, + 'datalines': datalines, + 'footlines': footlines} + + if self.request.get('output_format', '') == 'CSV': + import csv + from six import StringIO + import datetime + + fieldnames = [ + 'MonthYear', + 'ReceivedCount', + 'PublishedCount', + 'RatioPercentage', + ] + output = StringIO() + dw = csv.DictWriter(output, extrasaction='ignore', + fieldnames=fieldnames) + dw.writerow(dict((fn, fn) for fn in fieldnames)) + for row in datalines.values(): + dw.writerow(row) + report_data = output.getvalue() + output.close() + date = datetime.datetime.now().strftime("%Y%m%d%H%M") + setheader = self.request.RESPONSE.setHeader + setheader('Content-Type', 'text/csv') + setheader("Content-Disposition", + "attachment;filename=\"receivedvspublished_%s.csv\"" % date) + self.request.RESPONSE.write(report_data) + else: + return {'report_title': _('Samples received vs. reported'), + 'report_data': self.template()} diff --git a/src/bika/reports/browser/reports/selection_macros/__init__.py b/src/bika/reports/browser/reports/selection_macros/__init__.py new file mode 100644 index 0000000..5307cb1 --- /dev/null +++ b/src/bika/reports/browser/reports/selection_macros/__init__.py @@ -0,0 +1,488 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.CORE. +# +# SENAITE.CORE is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, version 2. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., 51 +# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Copyright 2018-2021 by it's authors. +# Some rights reserved, see README and LICENSE. + +from Products.CMFCore.utils import getToolByName +from zope.i18n import translate +from bika.lims.browser import BrowserView +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from bika.lims.utils import getUsers +from bika.lims import bikaMessageFactory as _ +from plone.memoize import ram +from time import time + + +def update_timer(): + """ + This function sets the time between updates of cached values + """ + return time() // (24 *60 * 60) + + +def _cache_key_select_state(method, self, workflow_id, field_id, field_title): + """ + This function returns the key used to decide if select_state has to be recomputed + """ + key = update_timer(), workflow_id, field_id, field_title + return key + + +def _cache_key_select_analysiscategory(method, self, style=None): + """ + This function returns the key used to decide if method select_analysiscategory has to be recomputed + """ + key = update_timer(), style + return key + + +def _cache_key_select_analysisservice(method, self, allow_blank, + multiselect, style=None): + """ + This function returns the key used to decide if method select_analysisservice has to be recomputed + """ + key = update_timer(), allow_blank, multiselect, style + return key + + +def _cache_key_select_analysisspecification(method, self, style=None): + """ + This function returns the key used to decide if method select_analysisspecification has to be recomputed + """ + key = update_timer(), style + return key + + +def _cache_key_select_analyst(method, self, allow_blank=False, style=None): + """ + This function returns the key used to decide if method select_analyst has to be recomputed + """ + key = update_timer(),allow_blank, style + return key + + +def _cache_key_select_user(method, self, allow_blank=True, style=None): + """ + This function returns the key used to decide if method select_user has to be recomputed + """ + key = update_timer(), allow_blank, style + return key + + +def _cache_key_select_client(method, self, style=None): + """ + This function returns the key used to decide if method select_client has to be recomputed + """ + key = update_timer(), style + return key + + +def _cache_key_select_contact(method, self, style=None): + """ + This function returns the key used to decide if method select_contact has to be recomputed + """ + key = update_timer(), style + return key + + +def _cache_key_select_daterange(method, self, field_id, field_title, style=None): + """ + This function returns the key used to decide if method select_daterange has to be recomputed + """ + key = update_timer(), field_id, field_title, style + return key + + +def _cache_key_select_instrument(method, self, style=None): + """ + This function returns the key used to decide if method select_instrument has to be recomputed + """ + key = update_timer(), style + return key + + +def _cache_key_select_period(method, self, style=None): + """ + This function returns the key used to decide if method select_period has to be recomputed + """ + key = update_timer(), style + return key + + +def _cache_key_select_profile(method, self, style=None): + """ + This function returns the key used to decide if method select_profile has to be recomputed + """ + key = update_timer(), style + return key + + +def _cache_key_select_supplier(method, self, style=None): + """ + This function returns the key used to decide if method select_supplier has to be recomputed + """ + key = update_timer(), style + return key + +def _cache_key_select_reference_sample(method, self, style=None): + """ + This function returns the key used to decide if method select_reference_sample has to be recomputed + """ + key = update_timer(), style + return key + + +def _cache_key_select_reference_service(method, self, style=None): + """ + This function returns the key used to decide if method select_reference_service has to be recomputed + """ + key = update_timer(), style + return key + + +def _cache_key_select_sample_type(method, self, allow_blank=True, multiselect=False, style=None): + """ + This function returns the key used to decide if method select_sample_type has to be recomputed + """ + key = update_timer(), allow_blank, multiselect, style + return key + + +def _cache_key_select_groupingperiod(method, self, allow_blank=True, multiselect=False, style=None): + """ + This function returns the key used to decide if method select_groupingperiod has to be recomputed + """ + key = update_timer(), allow_blank, multiselect, style + return key + + +def _cache_key_select_output_format(method, self, style=None): + """ + This function returns the key used to decide if method select_output_format has to be recomputed + """ + key = update_timer(), style + return key + + +class SelectionMacrosView(BrowserView): + """ Display snippets for the query form, and + parse their results to contentFilter + + These methods are called directlly from tal: + + context/@@selection_macros/analysts + + To parse form values in reports: + + python:view.selection_macros.parse_analysisservice(allow_blank=False) + + The parse_ functions return {'contentFilter': (k,v), + 'parms': (k,v), + 'title': string + } + + """ + + def __init__(self, context, request): + super(SelectionMacrosView, self).__init__(context, request) + self.bc = self.senaite_catalog + self.bac = self.senaite_catalog_analysis + self.bsc = self.senaite_catalog_setup + self.pc = self.portal_catalog + self.rc = self.reference_catalog + + select_analysiscategory_pt = ViewPageTemplateFile( + "select_analysiscategory.pt") + + @ram.cache(_cache_key_select_analysiscategory) + def select_analysiscategory(self, style=None): + self.style = style + self.analysiscategories = self.bsc(portal_type='AnalysisCategory', + sort_on='sortable_title') + return self.select_analysiscategory_pt() + + select_analysisservice_pt = ViewPageTemplateFile("select_analysisservice.pt") + + @ram.cache(_cache_key_select_analysisservice) + def select_analysisservice(self, allow_blank=True, multiselect=False, + style=None): + self.style = style + self.allow_blank = allow_blank + self.multiselect = multiselect + self.analysisservices = self.bsc(portal_type='AnalysisService', + sort_on='sortable_title') + return self.select_analysisservice_pt() + + def parse_analysisservice(self, request): + val = request.form.get("ServiceUID", "") + if val: + if not type(val) in (list, tuple): + val = (val,) # Single service + val = [self.rc.lookupObject(s) for s in val] + uids = [o.UID() for o in val] + titles = [o.Title() for o in val] + res = {} + res['contentFilter'] = ('getServiceUID', uids) + res['parms'] = {'title': _("Services"), 'value': ','.join(titles)} + res['titles'] = ','.join(titles) + return res + + select_analysisspecification_pt = ViewPageTemplateFile( + "select_analysisspecification.pt") + + @ram.cache(_cache_key_select_analysisspecification) + def select_analysisspecification(self, style=None): + self.style = style + res = [] + bsc = getToolByName(self.context, "senaite_catalog_setup") + for s in bsc(portal_type='AnalysisSpec'): + res.append({'uid': s.UID, 'title': s.Title}) + self.specs = res + return self.select_analysisspecification_pt() + + select_analyst_pt = ViewPageTemplateFile("select_analyst.pt") + + @ram.cache(_cache_key_select_analyst) + def select_analyst(self, allow_blank=False, style=None): + self.style = style + self.analysts = getUsers(self.context, + ['Manager', 'Analyst', 'LabManager'], + allow_blank) + return self.select_analyst_pt() + + select_user_pt = ViewPageTemplateFile("select_user.pt") + + @ram.cache(_cache_key_select_user) + def select_user(self, allow_blank=True, style=None): + self.style = style + self.allow_blank = allow_blank + self.users = getUsers(self.context, None, allow_blank) + return self.select_user_pt() + + select_client_pt = ViewPageTemplateFile("select_client.pt") + + @ram.cache(_cache_key_select_client) + def select_client(self, style=None): + self.style = style + self.clients = self.pc(portal_type='Client', + sort_on='sortable_title') + return self.select_client_pt() + + def parse_client(self, request): + val = request.form.get("ClientUID", "") + if val: + obj = val and self.rc.lookupObject(val) + title = obj.Title() + res = {} + res['contentFilter'] = ('getClientUID', val) + res['parms'] = {'title': _("Client"), 'value': title} + res['titles'] = title + return res + + select_contact_pt = ViewPageTemplateFile("select_contact.pt") + + @ram.cache(_cache_key_select_contact) + def select_contact(self, style=None): + self.style = style + self.contacts = self.pc(portal_type='Contact', is_active=True, + sort_on='sortable_title') + return self.select_contact_pt() + + select_daterange_pt = ViewPageTemplateFile("select_daterange.pt") + + def _select_daterange(self, field_id, field_title, style=None): + self.style = style + self.field_id = field_id + self.field_title = _(field_title) + return self.select_daterange_pt() + + @ram.cache(_cache_key_select_daterange) + def select_daterange(self, field_id, field_title, style=None): + return self._select_daterange(field_id, field_title, style) + + @ram.cache(_cache_key_select_daterange) + def select_daterange_requested(self, field_id, field_title, style=None): + return self._select_daterange(field_id, field_title, style) + + @ram.cache(_cache_key_select_daterange) + def select_daterange_created(self, field_id, field_title, style=None): + return self._select_daterange(field_id, field_title, style) + + @ram.cache(_cache_key_select_daterange) + def select_daterange_received(self, field_id, field_title, style=None): + return self._select_daterange(field_id, field_title, style) + + @ram.cache(_cache_key_select_daterange) + def select_daterange_published(self, field_id, field_title, style=None): + return self._select_daterange(field_id, field_title, style) + + @ram.cache(_cache_key_select_daterange) + def select_daterange_loaded(self, field_id, field_title, style=None): + return self._select_daterange(field_id, field_title, style) + + def parse_daterange(self, request, field_id, field_title): + from_date = request.get('%s_fromdate' % field_id, None) + from_date = from_date and from_date + ' 00:00' or None + to_date = request.get('%s_todate' % field_id, None) + to_date = to_date and to_date + ' 23:59' or None + if from_date and to_date: + query = {'query': [from_date, to_date], 'range': 'min:max'} + elif from_date or to_date: + query = {'query': from_date or to_date, + 'range': from_date and 'min' or 'max'} + else: + return None + + if from_date and to_date: + parms = translate(_("From ${start_date} to ${end_date}", + mapping={"start_date":from_date, "end_date":to_date})) + elif from_date: + parms = translate(_("Before ${start_date}", + mapping={"start_date":from_date})) + elif to_date: + parms = translate(_("After ${end_date}", + mapping={"end_date":to_date})) + + res = {} + res['contentFilter'] = (field_id, query) + res['parms'] = {'title': field_title, 'value': parms} + res['titles'] = parms + return res + + select_instrument_pt = ViewPageTemplateFile("select_instrument.pt") + + @ram.cache(_cache_key_select_instrument) + def select_instrument(self, style=None): + self.style = style + self.instruments = self.bsc(portal_type='Instrument', + is_active=True, + sort_on='sortable_title') + return self.select_instrument_pt() + + select_period_pt = ViewPageTemplateFile("select_period.pt") + + @ram.cache(_cache_key_select_period) + def select_period(self, style=None): + self.style = style + return self.select_period_pt() + + select_profile_pt = ViewPageTemplateFile("select_profile.pt") + + @ram.cache(_cache_key_select_profile) + def select_profile(self, style=None): + self.style = style + self.analysisprofiles = self.bsc(portal_type='AnalysisProfile', + is_active=True, + sort_on='sortable_title') + return self.select_profile_pt() + + select_supplier_pt = ViewPageTemplateFile("select_supplier.pt") + + @ram.cache(_cache_key_select_supplier) + def select_supplier(self, style=None): + self.style = style + self.suppliers = self.bsc(portal_type='Supplier', is_active=True, + sort_on='sortable_title') + return self.select_supplier_pt() + + select_reference_sample_pt = ViewPageTemplateFile( + "select_reference_sample.pt") + + @ram.cache(_cache_key_select_reference_sample) + def select_reference_sample(self, style=None): + self.style = style + return self.select_reference_sample_pt() + + select_reference_service_pt = ViewPageTemplateFile( + "select_reference_service.pt") + + @ram.cache(_cache_key_select_reference_service) + def select_reference_service(self, style=None): + self.style = style + return self.select_reference_service_pt() + + select_state_pt = ViewPageTemplateFile("select_state.pt") + + def _select_state(self, workflow_id, field_id, field_title, style=None): + self.style = style + self.field_id = field_id + self.field_title = field_title + states = self.portal_workflow[workflow_id].states + self.states = [] + for state_id in states: + state = states[state_id] + self.states.append({'id': state.getId(), 'title': state.title}) + return self.select_state_pt() + + @ram.cache(_cache_key_select_state) + def select_state(self, workflow_id, field_id, field_title, style=None): + return self._select_state(workflow_id, field_title, field_title, style) + + @ram.cache(_cache_key_select_state) + def select_state_analysis(self, workflow_id, field_id, field_title, style=None): + return self._select_state(workflow_id, field_id, field_title, style) + + def parse_state(self, request, workflow_id, field_id, field_title): + val = request.form.get(field_id, "") + states = self.portal_workflow[workflow_id].states + if val in states: + state_title = states[val].title + res = {} + res['contentFilter'] = (field_id, val) + res['parms'] = {'title': _('State'), 'value': state_title} + res['titles'] = state_title + return res + + select_sampletype_pt = ViewPageTemplateFile("select_sampletype.pt") + + @ram.cache(_cache_key_select_sample_type) + def select_sampletype(self, allow_blank=True, multiselect=False, style=None): + self.style = style + self.allow_blank = allow_blank + self.multiselect = multiselect + self.sampletypes = self.bsc(portal_type='SampleType', + is_active=True, + sort_on='sortable_title') + return self.select_sampletype_pt() + + def parse_sampletype(self, request): + val = request.form.get("SampleTypeUID", "") + if val: + obj = val and self.rc.lookupObject(val) + title = obj.Title() + res = {} + res['contentFilter'] = ('getSampleTypeUID', val) + res['parms'] = {'title': _("Sample Type"), 'value': title} + res['titles'] = title + return res + + select_groupingperiod_pt = ViewPageTemplateFile("select_groupingperiod.pt") + + @ram.cache(_cache_key_select_groupingperiod) + def select_groupingperiod(self, allow_blank=True, multiselect=False, + style=None): + self.style = style + self.allow_blank = allow_blank + return self.select_groupingperiod_pt() + + select_output_format_pt = ViewPageTemplateFile("select_output_format.pt") + + @ram.cache(_cache_key_select_output_format) + def select_output_format(self, style=None): + self.style = style + return self.select_output_format_pt() diff --git a/src/bika/reports/browser/reports/selection_macros/select_analysiscategory.pt b/src/bika/reports/browser/reports/selection_macros/select_analysiscategory.pt new file mode 100644 index 0000000..d96494e --- /dev/null +++ b/src/bika/reports/browser/reports/selection_macros/select_analysiscategory.pt @@ -0,0 +1,18 @@ +
+ + + +
diff --git a/src/bika/reports/browser/reports/selection_macros/select_analysisservice.pt b/src/bika/reports/browser/reports/selection_macros/select_analysisservice.pt new file mode 100644 index 0000000..a72a096 --- /dev/null +++ b/src/bika/reports/browser/reports/selection_macros/select_analysisservice.pt @@ -0,0 +1,23 @@ +
+ + + + + +
diff --git a/src/bika/reports/browser/reports/selection_macros/select_analysisspecification.pt b/src/bika/reports/browser/reports/selection_macros/select_analysisspecification.pt new file mode 100644 index 0000000..9459289 --- /dev/null +++ b/src/bika/reports/browser/reports/selection_macros/select_analysisspecification.pt @@ -0,0 +1,23 @@ +
+ + + + + + + +
diff --git a/src/bika/reports/browser/reports/selection_macros/select_analyst.pt b/src/bika/reports/browser/reports/selection_macros/select_analyst.pt new file mode 100644 index 0000000..21dde97 --- /dev/null +++ b/src/bika/reports/browser/reports/selection_macros/select_analyst.pt @@ -0,0 +1,18 @@ +
+ + + + + +
diff --git a/src/bika/reports/browser/reports/selection_macros/select_client.pt b/src/bika/reports/browser/reports/selection_macros/select_client.pt new file mode 100644 index 0000000..80cf54b --- /dev/null +++ b/src/bika/reports/browser/reports/selection_macros/select_client.pt @@ -0,0 +1,19 @@ +
+ + + + + +
diff --git a/src/bika/reports/browser/reports/selection_macros/select_contact.pt b/src/bika/reports/browser/reports/selection_macros/select_contact.pt new file mode 100644 index 0000000..cd6be04 --- /dev/null +++ b/src/bika/reports/browser/reports/selection_macros/select_contact.pt @@ -0,0 +1,21 @@ +
+ + + + + +
diff --git a/src/bika/reports/browser/reports/selection_macros/select_daterange.pt b/src/bika/reports/browser/reports/selection_macros/select_daterange.pt new file mode 100644 index 0000000..f12e910 --- /dev/null +++ b/src/bika/reports/browser/reports/selection_macros/select_daterange.pt @@ -0,0 +1,29 @@ +
+ + + + + + + + + + + + + +
+ From + + + + +
+ to + + + + +
+
diff --git a/src/bika/reports/browser/reports/selection_macros/select_groupingperiod.pt b/src/bika/reports/browser/reports/selection_macros/select_groupingperiod.pt new file mode 100644 index 0000000..b20e917 --- /dev/null +++ b/src/bika/reports/browser/reports/selection_macros/select_groupingperiod.pt @@ -0,0 +1,18 @@ +
+ + + + + +
diff --git a/src/bika/reports/browser/reports/selection_macros/select_instrument.pt b/src/bika/reports/browser/reports/selection_macros/select_instrument.pt new file mode 100644 index 0000000..aa7c16f --- /dev/null +++ b/src/bika/reports/browser/reports/selection_macros/select_instrument.pt @@ -0,0 +1,19 @@ +
+ + + + + +
diff --git a/src/bika/reports/browser/reports/selection_macros/select_output_format.pt b/src/bika/reports/browser/reports/selection_macros/select_output_format.pt new file mode 100644 index 0000000..3ad21d6 --- /dev/null +++ b/src/bika/reports/browser/reports/selection_macros/select_output_format.pt @@ -0,0 +1,18 @@ +
+ + + + + +
diff --git a/src/bika/reports/browser/reports/selection_macros/select_period.pt b/src/bika/reports/browser/reports/selection_macros/select_period.pt new file mode 100644 index 0000000..be1b800 --- /dev/null +++ b/src/bika/reports/browser/reports/selection_macros/select_period.pt @@ -0,0 +1,20 @@ +
+ + + + + +
diff --git a/src/bika/reports/browser/reports/selection_macros/select_profile.pt b/src/bika/reports/browser/reports/selection_macros/select_profile.pt new file mode 100644 index 0000000..5c96b1a --- /dev/null +++ b/src/bika/reports/browser/reports/selection_macros/select_profile.pt @@ -0,0 +1,21 @@ +
+ + + + + +
diff --git a/src/bika/reports/browser/reports/selection_macros/select_reference_sample.pt b/src/bika/reports/browser/reports/selection_macros/select_reference_sample.pt new file mode 100644 index 0000000..646cf17 --- /dev/null +++ b/src/bika/reports/browser/reports/selection_macros/select_reference_sample.pt @@ -0,0 +1,13 @@ + + +
+ + + + + +
diff --git a/src/bika/reports/browser/reports/selection_macros/select_reference_service.pt b/src/bika/reports/browser/reports/selection_macros/select_reference_service.pt new file mode 100644 index 0000000..f76fa9e --- /dev/null +++ b/src/bika/reports/browser/reports/selection_macros/select_reference_service.pt @@ -0,0 +1,13 @@ + + +
+ + + + + +
diff --git a/src/bika/reports/browser/reports/selection_macros/select_sampletype.pt b/src/bika/reports/browser/reports/selection_macros/select_sampletype.pt new file mode 100644 index 0000000..5f324bf --- /dev/null +++ b/src/bika/reports/browser/reports/selection_macros/select_sampletype.pt @@ -0,0 +1,19 @@ +
+ + + + + +
diff --git a/src/bika/reports/browser/reports/selection_macros/select_state.pt b/src/bika/reports/browser/reports/selection_macros/select_state.pt new file mode 100644 index 0000000..ab1133a --- /dev/null +++ b/src/bika/reports/browser/reports/selection_macros/select_state.pt @@ -0,0 +1,18 @@ +
+ + + + + +
diff --git a/src/bika/reports/browser/reports/selection_macros/select_supplier.pt b/src/bika/reports/browser/reports/selection_macros/select_supplier.pt new file mode 100644 index 0000000..bbcc39e --- /dev/null +++ b/src/bika/reports/browser/reports/selection_macros/select_supplier.pt @@ -0,0 +1,18 @@ +
+ + + + + +
diff --git a/src/bika/reports/browser/reports/selection_macros/select_user.pt b/src/bika/reports/browser/reports/selection_macros/select_user.pt new file mode 100644 index 0000000..bdaf7a1 --- /dev/null +++ b/src/bika/reports/browser/reports/selection_macros/select_user.pt @@ -0,0 +1,20 @@ +
+ + + + + +
diff --git a/src/bika/reports/browser/reports/templates/administration.pt b/src/bika/reports/browser/reports/templates/administration.pt new file mode 100644 index 0000000..e03612a --- /dev/null +++ b/src/bika/reports/browser/reports/templates/administration.pt @@ -0,0 +1,165 @@ + + + + + + + + + + + +

Administrative Reports

+
+ + + + + + + +
+
+

Analyses related reports

+
    +
  • + Samples not + invoiced
    + + Report of published samples which have not been invoiced + + + +
  • +
+ +

Traceability

+
    +
  • + User history
    + + Actions performed by users (or specific user) between a period of time + + + +
  • +
+
+
+
+ + diff --git a/src/bika/reports/browser/reports/templates/administration_usershistory.pt b/src/bika/reports/browser/reports/templates/administration_usershistory.pt new file mode 100644 index 0000000..219bd69 --- /dev/null +++ b/src/bika/reports/browser/reports/templates/administration_usershistory.pt @@ -0,0 +1,98 @@ + + + + + + + + + + +

Users history

+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
DateUserWorkflowActionTypeEntityComments
+ +
+ + diff --git a/src/bika/reports/browser/reports/templates/output_resultspersampletype.pt b/src/bika/reports/browser/reports/templates/output_resultspersampletype.pt new file mode 100644 index 0000000..02c8ad4 --- /dev/null +++ b/src/bika/reports/browser/reports/templates/output_resultspersampletype.pt @@ -0,0 +1,170 @@ + + + + + + + + + + +

+ +

+ + +
+ + + + + + +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +   + + + + +   + +
+ +
+ + + + +

+ + +   + + + + +   + +

+
+
+ + + diff --git a/src/bika/reports/browser/reports/templates/productivity.pt b/src/bika/reports/browser/reports/templates/productivity.pt new file mode 100644 index 0000000..2eca38f --- /dev/null +++ b/src/bika/reports/browser/reports/templates/productivity.pt @@ -0,0 +1,526 @@ + + + + + + + + + + + +

Productivity Reports

+
+ + + + + +
+
+

Sample related reports

+
    + +
  • + Daily samples received +
    + + Lists all samples received for a date range + + + +
  • + + +
  • + Samples received vs. samples reported +
    + + Report tables between a period of time the number of + samples received and results reported for them with + differences between the two + + + +
  • +
+ +

Analyses related reports

+ + +

Other productivity + reports

+ +
+
+
+ + + diff --git a/src/bika/reports/browser/reports/templates/productivity_analysesperdepartment.pt b/src/bika/reports/browser/reports/templates/productivity_analysesperdepartment.pt new file mode 100644 index 0000000..09009fc --- /dev/null +++ b/src/bika/reports/browser/reports/templates/productivity_analysesperdepartment.pt @@ -0,0 +1,146 @@ + + + + + + + + + + +

Analyses summary per department

+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Period + DepartmentRequestedPerformed% PerformedPublished% Published
 
+ Subtotal +
 Total
+ + diff --git a/src/bika/reports/browser/reports/templates/productivity_analysesperformedpertotal.pt b/src/bika/reports/browser/reports/templates/productivity_analysesperformedpertotal.pt new file mode 100644 index 0000000..88ddaed --- /dev/null +++ b/src/bika/reports/browser/reports/templates/productivity_analysesperformedpertotal.pt @@ -0,0 +1,146 @@ + + + + + + + + + + +

Analyses performed and published as % of total

+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Period + AnalysisRequestedPerformed% PerformedPublished% Published
 
+ Subtotal +
 Total
+ + diff --git a/src/bika/reports/browser/reports/templates/productivity_analysesperservice.pt b/src/bika/reports/browser/reports/templates/productivity_analysesperservice.pt new file mode 100644 index 0000000..1e90777 --- /dev/null +++ b/src/bika/reports/browser/reports/templates/productivity_analysesperservice.pt @@ -0,0 +1,156 @@ + + + + + + + + +

+ +

+ +
+ + + + + +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +   + + + + +   + +
+ +
+
+ + + +

+ + +   + + + + +   + +

+
+
+ + + diff --git a/src/bika/reports/browser/reports/templates/productivity_dailysamplesreceived.pt b/src/bika/reports/browser/reports/templates/productivity_dailysamplesreceived.pt new file mode 100644 index 0000000..5f24b9c --- /dev/null +++ b/src/bika/reports/browser/reports/templates/productivity_dailysamplesreceived.pt @@ -0,0 +1,79 @@ + + + + + + + + + + +

Daily samples received

+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AnalysisSample IDSample TypeDate received
Total:
+ + diff --git a/src/bika/reports/browser/reports/templates/productivity_dataentrydaybook.pt b/src/bika/reports/browser/reports/templates/productivity_dataentrydaybook.pt new file mode 100644 index 0000000..d9eb67d --- /dev/null +++ b/src/bika/reports/browser/reports/templates/productivity_dataentrydaybook.pt @@ -0,0 +1,146 @@ + + + + + + + + + + +

Data entry day book

+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDCreatedReceivedPublishedRecept. LagPublic. LagTotal LagBatchSampleSample TypeAnalysesClientCreator
 
Total +   + () + +   + () +   
+ + diff --git a/src/bika/reports/browser/reports/templates/productivity_samplereceivedvsreported.pt b/src/bika/reports/browser/reports/templates/productivity_samplereceivedvsreported.pt new file mode 100644 index 0000000..bd567d8 --- /dev/null +++ b/src/bika/reports/browser/reports/templates/productivity_samplereceivedvsreported.pt @@ -0,0 +1,60 @@ + + + + + + + + +

Samples received vs. reported

+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PeriodReceivedPublishedUnpublished%
Total
+ + diff --git a/src/bika/reports/browser/reports/templates/report_frame.pt b/src/bika/reports/browser/reports/templates/report_frame.pt new file mode 100644 index 0000000..9c982c6 --- /dev/null +++ b/src/bika/reports/browser/reports/templates/report_frame.pt @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + +
+ + + +
+
+
+   +
+ Print date: + +
+ Created by: + + + () + + +
+ +
+
+ +
+ + +
+
+ + + + +
+
+ +
+ +
+ + + + diff --git a/src/bika/reports/browser/reports/templates/report_out.pt b/src/bika/reports/browser/reports/templates/report_out.pt new file mode 100644 index 0000000..8b22182 --- /dev/null +++ b/src/bika/reports/browser/reports/templates/report_out.pt @@ -0,0 +1,161 @@ + + + + + + + + +

+ +

+ + +
+ + + + + +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +   + + + + +   + +
+ +
+
+ + + +

+ + +   + + + + +   + +

+
+
+ + + diff --git a/src/bika/reports/browser/reports/templates/reports.pt b/src/bika/reports/browser/reports/templates/reports.pt new file mode 100644 index 0000000..881aa29 --- /dev/null +++ b/src/bika/reports/browser/reports/templates/reports.pt @@ -0,0 +1,26 @@ + + + + +

+ + +

+
+ + +
+ + + + + + + +