[FEATURE] Reporting framework

Reports are based on the new layouts engine. They consist of multiple
nested sections. Each individual section (and the report itself)
can have an optional header and footer (which are themselves layouts,
and can consist of multiple pages!).

Two different types of sections are implemented so far:
- a standard section, which has a single, static body layout. This
can be used to embed static layouts mid way through a report
- a "field group" section, which repeats its body layout for
every feature in a layer. The features are sorted by the selected
grouping feature (with an option for ascending/descending sort).
If a field group section has child sections (e.g. another field
group section with a different field, then only features
with unique values for the group feature are iterated over.
This allows nested reports, e.g.

Report
- Country: Australia
    - State: NSW
        - Town: Sydney
        - Town: Woolongong
    - State: QLD
        - Town: Beerburrum
        - Town: Brisbane
        - Town: Emerald
- Country: NZ
    - State: ... etc

In this example country, state or town groups can have their
own headers and footers which will be inserted in the report.

Reports are configured through a new panel in the layout designer
dialog, which is shown when editing a report (created through
the Layout Manager Dialog). The organizer allows for adding
(and removing) sections to the report, and for selecting which
layout (e.g. headers, footers, bodies) to edit within the
layout designer.
This commit is contained in:
Nyall Dawson 2017-12-29 11:48:57 +10:00
parent 811145eb96
commit 1ea5a5fb98
7 changed files with 870 additions and 0 deletions

View File

@ -162,6 +162,7 @@
%Include composer/qgscomposertexttable.sip
%Include composer/qgspaperitem.sip
%Include layout/qgsabstractlayoutiterator.sip
%Include layout/qgsabstractreportsection.sip
%Include layout/qgslayoutaligner.sip
%Include layout/qgslayoutexporter.sip
%Include layout/qgslayoutgridsettings.sip

View File

@ -0,0 +1,270 @@
/************************************************************************
* This file has been generated automatically from *
* *
* src/core/layout/qgsabstractreportsection.h *
* *
* Do not edit manually ! Edit header and run scripts/sipify.pl again *
************************************************************************/
class QgsAbstractReportSection : QgsAbstractLayoutIterator
{
%Docstring
An abstract base class for QgsReport subsections.
.. versionadded:: 3.0
%End
%TypeHeaderCode
#include "qgsabstractreportsection.h"
%End
public:
QgsAbstractReportSection();
%Docstring
Constructor for QgsAbstractReportSection
%End
~QgsAbstractReportSection();
virtual QgsAbstractReportSection *clone() const = 0 /Factory/;
%Docstring
Clones the report section. Ownership of the returned section is
transferred to the caller.
Subclasses should call copyCommonProperties() in their clone()
implementations.
%End
virtual QgsLayout *layout();
virtual bool beginRender();
virtual bool next();
virtual bool endRender();
bool headerEnabled() const;
%Docstring
Returns true if the header for the section is enabled.
.. seealso:: :py:func:`setHeaderEnabled()`
.. seealso:: :py:func:`header()`
.. seealso:: :py:func:`setHeader()`
%End
void setHeaderEnabled( bool enabled );
%Docstring
Sets whether the header for the section is ``enabled``.
.. seealso:: :py:func:`headerEnabled()`
.. seealso:: :py:func:`header()`
.. seealso:: :py:func:`setHeader()`
%End
QgsLayout *header();
%Docstring
Returns the header for the section. Note that the header is only
included if headerEnabled() is true.
.. seealso:: :py:func:`setHeaderEnabled()`
.. seealso:: :py:func:`headerEnabled()`
.. seealso:: :py:func:`setHeader()`
%End
void setHeader( QgsLayout *header /Transfer/ );
%Docstring
Sets the ``header`` for the section. Note that the header is only
included if headerEnabled() is true. Ownership of ``header``
is transferred to the report section.
.. seealso:: :py:func:`setHeaderEnabled()`
.. seealso:: :py:func:`headerEnabled()`
.. seealso:: :py:func:`header()`
%End
bool footerEnabled() const;
%Docstring
Returns true if the footer for the section is enabled.
.. seealso:: :py:func:`setFooterEnabled()`
.. seealso:: :py:func:`footer()`
.. seealso:: :py:func:`setFooter()`
%End
void setFooterEnabled( bool enabled );
%Docstring
Sets whether the footer for the section is ``enabled``.
.. seealso:: :py:func:`footerEnabled()`
.. seealso:: :py:func:`footer()`
.. seealso:: :py:func:`setFooter()`
%End
QgsLayout *footer();
%Docstring
Returns the footer for the section. Note that the footer is only
included if footerEnabled() is true.
.. seealso:: :py:func:`setFooterEnabled()`
.. seealso:: :py:func:`footerEnabled()`
.. seealso:: :py:func:`setFooter()`
%End
void setFooter( QgsLayout *footer /Transfer/ );
%Docstring
Sets the ``footer`` for the section. Note that the footer is only
included if footerEnabled() is true. Ownership of ``footer``
is transferred to the report section.
.. seealso:: :py:func:`setFooterEnabled()`
.. seealso:: :py:func:`footerEnabled()`
.. seealso:: :py:func:`footer()`
%End
int childCount() const;
%Docstring
Return the number of child sections for this report section. The child
sections form the body of the report section.
.. seealso:: :py:func:`children()`
%End
QList< QgsAbstractReportSection * > children();
%Docstring
Return all child sections for this report section. The child
sections form the body of the report section.
.. seealso:: :py:func:`childCount()`
.. seealso:: :py:func:`child()`
.. seealso:: :py:func:`appendChild()`
.. seealso:: :py:func:`insertChild()`
.. seealso:: :py:func:`removeChild()`
%End
QgsAbstractReportSection *child( int index );
%Docstring
Returns the child section at the specified ``index``.
.. seealso:: :py:func:`children()`
%End
void appendChild( QgsAbstractReportSection *section /Transfer/ );
%Docstring
Adds a child ``section``, transferring ownership of the section to this section.
.. seealso:: :py:func:`children()`
.. seealso:: :py:func:`insertChild()`
%End
void insertChild( int index, QgsAbstractReportSection *section /Transfer/ );
%Docstring
Inserts a child ``section`` at the specified ``index``, transferring ownership of the section to this section.
.. seealso:: :py:func:`children()`
.. seealso:: :py:func:`appendChild()`
%End
void removeChild( QgsAbstractReportSection *section );
%Docstring
Removes a child ``section``, deleting it.
.. seealso:: :py:func:`children()`
%End
void removeChildAt( int index );
%Docstring
Removes the child section at the specified ``index``, deleting it.
.. seealso:: :py:func:`children()`
%End
protected:
enum SubSection
{
Header,
Body,
Footer,
End,
};
void copyCommonProperties( QgsAbstractReportSection *destination ) const;
%Docstring
Copies the common properties of a report section to a ``destination`` section.
This method should be called from clone() implementations.
%End
private:
QgsAbstractReportSection( const QgsAbstractReportSection &other );
};
class QgsReport : QgsAbstractReportSection
{
%Docstring
Represents a report for use with the QgsLayout engine.
Reports consist of multiple sections, represented by QgsAbstractReportSection
subclasses.
.. versionadded:: 3.0
%End
%TypeHeaderCode
#include "qgsabstractreportsection.h"
%End
public:
QgsReport();
%Docstring
Constructor for QgsReport.
%End
virtual QgsReport *clone() const;
virtual int count();
virtual bool beginRender();
virtual bool next();
virtual QString filePath( const QString &baseFilePath, const QString &extension );
};
/************************************************************************
* This file has been generated automatically from *
* *
* src/core/layout/qgsabstractreportsection.h *
* *
* Do not edit manually ! Edit header and run scripts/sipify.pl again *
************************************************************************/

View File

@ -364,6 +364,7 @@ SET(QGIS_CORE_SRCS
dxf/qgsdxfpaintengine.cpp
dxf/qgsdxfpallabeling.cpp
layout/qgsabstractreportsection.cpp
layout/qgslayout.cpp
layout/qgslayoutaligner.cpp
layout/qgslayoutatlas.cpp
@ -1029,6 +1030,7 @@ SET(QGIS_CORE_HDRS
composer/qgspaperitem.h
layout/qgsabstractlayoutiterator.h
layout/qgsabstractreportsection.h
layout/qgslayoutaligner.h
layout/qgslayoutexporter.h
layout/qgslayoutgridsettings.h

View File

@ -0,0 +1,215 @@
/***************************************************************************
qgsabstractreportsection.cpp
--------------------
begin : December 2017
copyright : (C) 2017 by Nyall Dawson
email : nyall dot dawson at gmail dot com
***************************************************************************/
/***************************************************************************
* *
* This program 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; either version 2 of the License, or *
* (at your option) any later version. *
* *
***************************************************************************/
#include "qgsabstractreportsection.h"
#include "qgslayout.h"
QgsAbstractReportSection::~QgsAbstractReportSection()
{
qDeleteAll( mChildren );
}
QgsLayout *QgsAbstractReportSection::layout()
{
return mCurrentLayout;
}
bool QgsAbstractReportSection::beginRender()
{
// reset this section
mCurrentLayout = nullptr;
mNextChild = 0;
mNextSection = Header;
// and all children too
bool result = true;
for ( QgsAbstractReportSection *child : qgis::as_const( mChildren ) )
{
result = result && child->beginRender();
}
return result;
}
bool QgsAbstractReportSection::next()
{
switch ( mNextSection )
{
case Header:
{
// regardless of whether we have a header or not, the next section will be the body
mNextSection = Body;
// if we have a header, then the current section will be the header
if ( mHeaderEnabled && mHeader )
{
mCurrentLayout = mHeader.get();
return true;
}
// but if not, then the current section is the body
FALLTHROUGH;
}
case Body:
{
// we iterate through all the section's children...
while ( mNextChild < mChildren.count() )
{
// ... staying on the current child only while it still has content for us
if ( mChildren.at( mNextChild )->next() )
{
mCurrentLayout = mChildren.at( mNextChild )->layout();
return true;
}
else
{
// no more content for this child, so move to next child
mNextChild++;
}
}
// all children have spent their content, so move to the footer
mNextSection = Footer;
FALLTHROUGH;
}
case Footer:
{
// regardless of whether we have a footer or not, this is the last section
mNextSection = End;
// if we have a footer, then the current section will be the footer
if ( mFooterEnabled && mFooter )
{
mCurrentLayout = mFooter.get();
return true;
}
// if not, then we're all done
FALLTHROUGH;
}
case End:
break;
}
mCurrentLayout = nullptr;
return false;
}
bool QgsAbstractReportSection::endRender()
{
// reset this section
mCurrentLayout = nullptr;
mNextChild = 0;
mNextSection = Header;
// and all children too
bool result = true;
for ( QgsAbstractReportSection *child : qgis::as_const( mChildren ) )
{
result = result && child->endRender();
}
return result;
}
QgsAbstractReportSection *QgsAbstractReportSection::child( int index )
{
return mChildren.value( index );
}
void QgsAbstractReportSection::appendChild( QgsAbstractReportSection *section )
{
mChildren.append( section );
}
void QgsAbstractReportSection::insertChild( int index, QgsAbstractReportSection *section )
{
index = std::max( 0, index );
index = std::min( index, mChildren.count() );
mChildren.insert( index, section );
}
void QgsAbstractReportSection::removeChild( QgsAbstractReportSection *section )
{
mChildren.removeAll( section );
delete section;
}
void QgsAbstractReportSection::removeChildAt( int index )
{
if ( index < 0 || index >= mChildren.count() )
return;
QgsAbstractReportSection *section = mChildren.at( index );
removeChild( section );
}
void QgsAbstractReportSection::copyCommonProperties( QgsAbstractReportSection *destination ) const
{
destination->mHeaderEnabled = mHeaderEnabled;
if ( mHeader )
destination->mHeader.reset( mHeader->clone() );
else
destination->mHeader.reset();
destination->mFooterEnabled = mFooterEnabled;
if ( mFooter )
destination->mFooter.reset( mFooter->clone() );
else
destination->mFooter.reset();
qDeleteAll( destination->mChildren );
destination->mChildren.clear();
for ( QgsAbstractReportSection *child : qgis::as_const( mChildren ) )
{
destination->mChildren.append( child->clone() );
}
}
// QgsReport
QgsReport *QgsReport::clone() const
{
std::unique_ptr< QgsReport > copy = qgis::make_unique< QgsReport >();
copyCommonProperties( copy.get() );
return copy.release();
}
bool QgsReport::beginRender()
{
mSectionNumber = 0;
return QgsAbstractReportSection::beginRender();
}
bool QgsReport::next()
{
mSectionNumber++;
return QgsAbstractReportSection::next();
}
QString QgsReport::filePath( const QString &baseFilePath, const QString &extension )
{
QString base = QDir( baseFilePath ).filePath( "report_" ) + QString::number( mSectionNumber );
if ( !extension.startsWith( '.' ) )
base += '.';
base += extension;
return base;
}

View File

@ -0,0 +1,253 @@
/***************************************************************************
qgsabstractreportsection.h
---------------------------
begin : December 2017
copyright : (C) 2017 by Nyall Dawson
email : nyall dot dawson at gmail dot com
***************************************************************************/
/***************************************************************************
* *
* This program 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; either version 2 of the License, or *
* (at your option) any later version. *
* *
***************************************************************************/
#ifndef QGSABSTRACTREPORTSECTION_H
#define QGSABSTRACTREPORTSECTION_H
#include "qgis_core.h"
#include "qgsabstractlayoutiterator.h"
#include "qgslayoutreportcontext.h"
/**
* \ingroup core
* \class QgsAbstractReportSection
* \brief An abstract base class for QgsReport subsections.
* \since QGIS 3.0
*/
class CORE_EXPORT QgsAbstractReportSection : public QgsAbstractLayoutIterator
{
public:
//! Constructor for QgsAbstractReportSection
QgsAbstractReportSection() = default;
~QgsAbstractReportSection() override;
//! QgsAbstractReportSection cannot be copied
QgsAbstractReportSection( const QgsAbstractReportSection &other ) = delete;
//! QgsAbstractReportSection cannot be copied
QgsAbstractReportSection &operator=( const QgsAbstractReportSection &other ) = delete;
/**
* Clones the report section. Ownership of the returned section is
* transferred to the caller.
*
* Subclasses should call copyCommonProperties() in their clone()
* implementations.
*/
virtual QgsAbstractReportSection *clone() const = 0 SIP_FACTORY;
#if 0 //TODO
virtual void setContext( const QgsLayoutReportContext &context ) = 0;
#endif
QgsLayout *layout() override;
bool beginRender() override;
bool next() override;
bool endRender() override;
/**
* Returns true if the header for the section is enabled.
* \see setHeaderEnabled()
* \see header()
* \see setHeader()
*/
bool headerEnabled() const { return mHeaderEnabled; }
/**
* Sets whether the header for the section is \a enabled.
* \see headerEnabled()
* \see header()
* \see setHeader()
*/
void setHeaderEnabled( bool enabled ) { mHeaderEnabled = enabled; }
/**
* Returns the header for the section. Note that the header is only
* included if headerEnabled() is true.
* \see setHeaderEnabled()
* \see headerEnabled()
* \see setHeader()
*/
QgsLayout *header() { return mHeader.get(); }
/**
* Sets the \a header for the section. Note that the header is only
* included if headerEnabled() is true. Ownership of \a header
* is transferred to the report section.
* \see setHeaderEnabled()
* \see headerEnabled()
* \see header()
*/
void setHeader( QgsLayout *header SIP_TRANSFER ) { mHeader.reset( header ); }
/**
* Returns true if the footer for the section is enabled.
* \see setFooterEnabled()
* \see footer()
* \see setFooter()
*/
bool footerEnabled() const { return mFooterEnabled; }
/**
* Sets whether the footer for the section is \a enabled.
* \see footerEnabled()
* \see footer()
* \see setFooter()
*/
void setFooterEnabled( bool enabled ) { mFooterEnabled = enabled; }
/**
* Returns the footer for the section. Note that the footer is only
* included if footerEnabled() is true.
* \see setFooterEnabled()
* \see footerEnabled()
* \see setFooter()
*/
QgsLayout *footer() { return mFooter.get(); }
/**
* Sets the \a footer for the section. Note that the footer is only
* included if footerEnabled() is true. Ownership of \a footer
* is transferred to the report section.
* \see setFooterEnabled()
* \see footerEnabled()
* \see footer()
*/
void setFooter( QgsLayout *footer SIP_TRANSFER ) { mFooter.reset( footer ); }
/**
* Return the number of child sections for this report section. The child
* sections form the body of the report section.
* \see children()
*/
int childCount() const { return mChildren.count(); }
/**
* Return all child sections for this report section. The child
* sections form the body of the report section.
* \see childCount()
* \see child()
* \see appendChild()
* \see insertChild()
* \see removeChild()
*/
QList< QgsAbstractReportSection * > children() { return mChildren; }
/**
* Returns the child section at the specified \a index.
* \see children()
*/
QgsAbstractReportSection *child( int index );
/**
* Adds a child \a section, transferring ownership of the section to this section.
* \see children()
* \see insertChild()
*/
void appendChild( QgsAbstractReportSection *section SIP_TRANSFER );
/**
* Inserts a child \a section at the specified \a index, transferring ownership of the section to this section.
* \see children()
* \see appendChild()
*/
void insertChild( int index, QgsAbstractReportSection *section SIP_TRANSFER );
/**
* Removes a child \a section, deleting it.
* \see children()
*/
void removeChild( QgsAbstractReportSection *section );
/**
* Removes the child section at the specified \a index, deleting it.
* \see children()
*/
void removeChildAt( int index );
protected:
//! Report sub-sections
enum SubSection
{
Header, //!< Header for section
Body, //!< Body of section
Footer, //!< Footer for section
End, //!< End of section (i.e. past all available content)
};
/**
* Copies the common properties of a report section to a \a destination section.
* This method should be called from clone() implementations.
*/
void copyCommonProperties( QgsAbstractReportSection *destination ) const;
private:
SubSection mNextSection = Header;
int mNextChild = 0;
QgsLayout *mCurrentLayout = nullptr;
bool mHeaderEnabled = false;
bool mFooterEnabled = false;
std::unique_ptr< QgsLayout > mHeader;
std::unique_ptr< QgsLayout > mFooter;
QList< QgsAbstractReportSection * > mChildren;
#ifdef SIP_RUN
QgsAbstractReportSection( const QgsAbstractReportSection &other );
#endif
};
/**
* \ingroup core
* \class QgsReport
* \brief Represents a report for use with the QgsLayout engine.
*
* Reports consist of multiple sections, represented by QgsAbstractReportSection
* subclasses.
*
* \since QGIS 3.0
*/
class CORE_EXPORT QgsReport : public QgsAbstractReportSection
{
public:
//! Constructor for QgsReport.
QgsReport() = default;
QgsReport *clone() const override;
// TODO - how to handle this?
int count() override { return -1; }
bool beginRender() override;
bool next() override;
//TODO - baseFilePath should be a filename, not directory
QString filePath( const QString &baseFilePath, const QString &extension ) override;
private:
int mSectionNumber = 0;
};
#endif //QGSABSTRACTREPORTSECTION_H

View File

@ -154,6 +154,7 @@ ADD_PYTHON_TEST(PyQgsRelation test_qgsrelation.py)
ADD_PYTHON_TEST(PyQgsRelationManager test_qgsrelationmanager.py)
ADD_PYTHON_TEST(PyQgsRenderContext test_qgsrendercontext.py)
ADD_PYTHON_TEST(PyQgsRenderer test_qgsrenderer.py)
ADD_PYTHON_TEST(PyQgsReport test_qgsreport.py)
ADD_PYTHON_TEST(PyQgsRulebasedRenderer test_qgsrulebasedrenderer.py)
ADD_PYTHON_TEST(PyQgsSingleSymbolRenderer test_qgssinglesymbolrenderer.py)
ADD_PYTHON_TEST(PyQgsShapefileProvider test_provider_shapefile.py)

View File

@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
"""QGIS Unit tests for QgsReport
.. note:: This program 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; either version 2 of the License, or
(at your option) any later version.
"""
__author__ = 'Nyall Dawson'
__date__ = '29/12/2017'
__copyright__ = 'Copyright 2017, The QGIS Project'
# This will get replaced with a git SHA1 when you do a git archive
__revision__ = '$Format:%H$'
import qgis # NOQA
from qgis.core import (QgsProject,
QgsLayout,
QgsReport)
from qgis.testing import start_app, unittest
start_app()
class TestQgsReport(unittest.TestCase):
def testGettersSetters(self):
p = QgsProject()
r = QgsReport()
r.setHeaderEnabled(True)
self.assertTrue(r.headerEnabled())
header = QgsLayout(p)
r.setHeader(header)
self.assertEqual(r.header(), header)
r.setFooterEnabled(True)
self.assertTrue(r.footerEnabled())
footer = QgsLayout(p)
r.setFooter(footer)
self.assertEqual(r.footer(), footer)
def testChildren(self):
p = QgsProject()
r = QgsReport()
self.assertEqual(r.childCount(), 0)
self.assertEqual(r.children(), [])
self.assertIsNone(r.child(-1))
self.assertIsNone(r.child(1))
self.assertIsNone(r.child(0))
# try deleting non-existant children
r.removeChildAt(-1)
r.removeChildAt(0)
r.removeChildAt(100)
r.removeChild(None)
# append child
child1 = QgsReport()
r.appendChild(child1)
self.assertEqual(r.childCount(), 1)
self.assertEqual(r.children(), [child1])
self.assertEqual(r.child(0), child1)
child2 = QgsReport()
r.appendChild(child2)
self.assertEqual(r.childCount(), 2)
self.assertEqual(r.children(), [child1, child2])
self.assertEqual(r.child(1), child2)
def testInsertChild(self):
p = QgsProject()
r = QgsReport()
child1 = QgsReport()
r.insertChild(11, child1)
self.assertEqual(r.childCount(), 1)
self.assertEqual(r.children(), [child1])
child2 = QgsReport()
r.insertChild(-1, child2)
self.assertEqual(r.childCount(), 2)
self.assertEqual(r.children(), [child2, child1])
def testRemoveChild(self):
p = QgsProject()
r = QgsReport()
child1 = QgsReport()
r.appendChild(child1)
child2 = QgsReport()
r.appendChild(child2)
r.removeChildAt(-1)
r.removeChildAt(100)
r.removeChild(None)
self.assertEqual(r.childCount(), 2)
self.assertEqual(r.children(), [child1, child2])
r.removeChildAt(1)
self.assertEqual(r.childCount(), 1)
self.assertEqual(r.children(), [child1])
r.removeChild(child1)
self.assertEqual(r.childCount(), 0)
self.assertEqual(r.children(), [])
def testClone(self):
p = QgsProject()
r = QgsReport()
child1 = QgsReport()
child1.setHeaderEnabled(True)
r.appendChild(child1)
child2 = QgsReport()
child2.setFooterEnabled(True)
r.appendChild(child2)
cloned = r.clone()
self.assertEqual(cloned.childCount(), 2)
self.assertTrue(cloned.child(0).headerEnabled())
self.assertFalse(cloned.child(0).footerEnabled())
self.assertFalse(cloned.child(1).headerEnabled())
self.assertTrue(cloned.child(1).footerEnabled())
if __name__ == '__main__':
unittest.main()