diff --git a/CHANGELOG b/CHANGELOG index 98bf908c44..61c35c7172 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Add header grouping on report * Protect trusted devices against brute force attack * Make ModelView.parse_view public * Add help for each value to selection and multiselection diff --git a/doc/topics/reports/index.rst b/doc/topics/reports/index.rst index e854b4527f..ab594ff673 100644 --- a/doc/topics/reports/index.rst +++ b/doc/topics/reports/index.rst @@ -228,11 +228,11 @@ employee object. __name__ = 'account.invoice' @classmethod - def get_context(cls, records, data): + def get_context(cls, records, header, data): pool = Pool() Employee = pool.get('company.employee') - context = super(InvoiceReport, cls).get_context(records, data) + context = super().get_context(records, header, data) employee_id = Transaction().context.get('employee') employee = Employee(employee_id) if employee_id else None context['employee'] = employee diff --git a/trytond/report/report.py b/trytond/report/report.py index 27b4de4e00..95f0f5a23d 100644 --- a/trytond/report/report.py +++ b/trytond/report/report.py @@ -5,6 +5,7 @@ import dateutil.tz import os import inspect import logging +import math import subprocess import tempfile import time @@ -14,6 +15,7 @@ import operator from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from io import BytesIO +from itertools import groupby try: import html2text @@ -120,6 +122,10 @@ class Report(URLMixin, PoolBase): if report_groups and not groups & report_groups: raise UserError('Calling report %s is not allowed!' % cls.__name__) + @classmethod + def header_key(cls, record): + return () + @classmethod def execute(cls, ids, data): ''' @@ -146,38 +152,58 @@ class Report(URLMixin, PoolBase): else: action_report = ActionReport(action_id) + def report_name(records): + name = '-'.join(r.rec_name for r in records[:5]) + if len(records) > 5: + name += '__' + str(len(records[5:])) + return name + records = [] model = action_report.model or data.get('model') if model: records = cls._get_records(ids, model, data) - if action_report.single and len(records) > 1: + + if action_report.single: + groups = [[r] for r in records] + headers = [dict(cls.header_key(r)) for r in records] + else: + groups = [] + headers = [] + for key, group in groupby(records, key=cls.header_key): + groups.append(list(group)) + headers.append(dict(key)) + + n = len(groups) + if n > 1: + padding = math.ceil(math.log10(n)) content = BytesIO() with zipfile.ZipFile(content, 'w') as content_zip: - for record in records: + for i, (header, group_records) in enumerate( + zip(headers, groups), 1): oext, rcontent = cls._execute( - [record], data, action_report) - filename = slugify('%s-%s' % (record.id, record.rec_name)) + group_records, header, data, action_report) + filename = report_name(group_records) + number = str(i).zfill(padding) + filename = slugify('%s-%s' % (number, filename)) rfilename = '%s.%s' % (filename, oext) content_zip.writestr(rfilename, rcontent) content = content.getvalue() oext = 'zip' else: - oext, content = cls._execute(records, data, action_report) + oext, content = cls._execute( + groups[0], headers[0], data, action_report) if not isinstance(content, str): content = bytearray(content) if bytes == str else bytes(content) - - suffix = '-'.join(r.rec_name for r in records[:5]) - if len(records) > 5: - suffix += '__' + str(len(records[5:])) - report_name = '-'.join(filter(None, [action_report.name, suffix])) - return (oext, content, action_report.direct_print, report_name) + filename = '-'.join( + filter(None, [action_report.name, report_name(records)])) + return (oext, content, action_report.direct_print, filename) @classmethod - def _execute(cls, records, data, action): + def _execute(cls, records, header, data, action): # Ensure to restore original context # set_lang may modify it with Transaction().set_context(Transaction().context): - report_context = cls.get_context(records, data) + report_context = cls.get_context(records, header, data) return cls.convert(action, cls.render(action, report_context)) @classmethod @@ -223,12 +249,13 @@ class Report(URLMixin, PoolBase): return [TranslateModel(id) for id in ids] @classmethod - def get_context(cls, records, data): + def get_context(cls, records, header, data): pool = Pool() User = pool.get('res.user') Lang = pool.get('ir.lang') report_context = {} + report_context['header'] = header report_context['data'] = data report_context['context'] = Transaction().context report_context['user'] = User(Transaction().user)