inasafe/inasafe

View on GitHub
safe/report/impact_report.py

Summary

Maintainability
F
5 days
Test Coverage
# coding=utf-8
"""Module to generate impact report.

Enable dynamic report generation based on report metadata.
Easily customize map report or document based report.

"""


import imp
import logging
import os
import shutil

from qgis.core import QgsRasterLayer, QgsMapSettings

from safe import messaging as m
from safe.common.exceptions import (
    KeywordNotFoundError)
from safe.defaults import (
    white_inasafe_logo_path,
    black_inasafe_logo_path,
    supporters_logo_path,
    default_north_arrow_path)
from safe.definitions.messages import disclaimer
from safe.messaging import styles
from safe.utilities.i18n import tr
from safe.utilities.keyword_io import KeywordIO
from safe.utilities.utilities import get_error_message
import collections

__copyright__ = "Copyright 2016, The InaSAFE Project"
__license__ = "GPL version 3"
__email__ = "info@inasafe.org"
__revision__ = '$Format:%H$'

SUGGESTION_STYLE = styles.GREEN_LEVEL_4_STYLE
WARNING_STYLE = styles.RED_LEVEL_4_STYLE

LOGGER = logging.getLogger('InaSAFE')


class InaSAFEReportContext():

    """A class to compile all InaSAFE related context for reporting uses.

    .. versionadded:: 4.0
    """

    def __init__(self):
        """Create InaSAFE Report context."""
        self._black_inasafe_logo = black_inasafe_logo_path()
        self._white_inasafe_logo = white_inasafe_logo_path()
        # User can change this path in preferences
        self._organisation_logo = supporters_logo_path()
        self._supporters_logo = supporters_logo_path()
        self._north_arrow = default_north_arrow_path()
        self._disclaimer = disclaimer()

    @property
    def north_arrow(self):
        """Getter to north arrow path.

        :rtype: str
        """
        return self._north_arrow

    @north_arrow.setter
    def north_arrow(self, north_arrow_path):
        """Set image that will be used as north arrow in reports.

        :param north_arrow_path: Path to the north arrow image.
        :type north_arrow_path: str
        """
        if isinstance(north_arrow_path, str) and os.path.exists(
                north_arrow_path):
            self._north_arrow = north_arrow_path
        else:
            self._north_arrow = default_north_arrow_path()

    @property
    def inasafe_logo(self):
        """Getter to safe logo path.

        .. versionchanged:: 3.2 - this property is now read only.

        :rtype: str
        """
        return self.black_inasafe_logo

    @property
    def black_inasafe_logo(self):
        """Getter to black inasafe logo path.

        :rtype: str
        """
        return self._black_inasafe_logo

    @property
    def white_inasafe_logo(self):
        """Getter for white inasafe logo path.

        :rtype: str
        """
        return self._white_inasafe_logo

    @property
    def organisation_logo(self):
        """Getter to organisation logo path.

        :rtype: str
        """
        return self._organisation_logo

    @organisation_logo.setter
    def organisation_logo(self, logo):
        """Set image that will be used as organisation logo in reports.

        :param logo: Path to the organisation logo image.
        :type logo: str
        """
        if isinstance(logo, str) and os.path.exists(logo):
            self._organisation_logo = logo
        else:
            self._organisation_logo = supporters_logo_path()

    @property
    def supporters_logo(self):
        """Getter to supporters logo path - this is a read only property.

        It always returns the InaSAFE supporters logo unlike the organisation
        logo which is customisable.

        .. versionadded:: 3.2
        :rtype: str
        """
        return self._supporters_logo

    @property
    def disclaimer(self):
        """Getter to disclaimer.

        :rtype: str
        """
        return self._disclaimer

    @disclaimer.setter
    def disclaimer(self, text):
        """Set text that will be used as disclaimer in reports.

        :param text: Disclaimer text
        :type text: str
        """
        if not isinstance(text, str):
            self._disclaimer = disclaimer()
        else:
            self._disclaimer = text


class QgsLayoutContext():

    """A class to hold the value for QgsLayout object.

    .. versionadded:: 4.0
    """

    def __init__(self, extent, map_settings, page_dpi):
        """Create QgsLayout context."""
        self._extent = extent
        self._map_settings = map_settings
        self._page_dpi = page_dpi
        self._save_as_raster = True

    @property
    def page_dpi(self):
        """The Page DPI that QgsLayout uses.

        Can be overriden by report metadata

        :rtype: float
        """
        return self._page_dpi

    @page_dpi.setter
    def page_dpi(self, value):
        """Page DPI.

        :param value: DPI value for printing
        :type value: float
        """
        self._page_dpi = value

    @property
    def extent(self):
        """The extent of the map element.

        This extent is used by map element to render the extent
        of the layer

        :rtype: QgsRectangle
        """
        return self._extent

    @extent.setter
    def extent(self, value):
        """Extent of map element.

        :param value: Extent of map element to display
        :type value: QgsRectangle
        """
        self._extent = value

    @property
    def map_settings(self):
        """QgsMapSettings instance that will be used.

        Used for QgsComposition

        :rtype: qgis.core.QgsMapSettings
        """
        return self._map_settings

    @map_settings.setter
    def map_settings(self, value):
        """QgsMapSettings instance.

        :param value: QgsMapSettings for QgsComposition
        :type value: qgis.core.QgsMapSettings
        """
        self._map_settings = value

    @property
    def save_as_raster(self):
        """Boolean that indicates the composition will be saved as Raster.

        :rtype: bool
        """
        return self._save_as_raster

    @save_as_raster.setter
    def save_as_raster(self, value):
        """Boolean that indicates the composition will be saved as Raster.

        :param value: bool value. Set true for raster.
        :type value: bool
        """
        self._save_as_raster = value


class ImpactReport():

    """A class for creating and generating report.

    .. versionadded:: 4.0
    """

    # constant for default PAGE_DPI settings
    DEFAULT_PAGE_DPI = 300
    REPORT_GENERATION_SUCCESS = 0
    REPORT_GENERATION_FAILED = 1

    class LayerException(Exception):

        """Class for Layer Exception.

        Raised if layer being used is not valid.
        """

        pass

    def __init__(
            self,
            iface,
            template_metadata,
            impact_function=None,
            hazard=None,
            exposure=None,
            impact=None,
            analysis=None,
            exposure_summary_table=None,
            aggregation_summary=None,
            extra_layers=None,
            ordered_layers=None,
            legend_layers=None,
            minimum_needs_profile=None,
            multi_exposure_impact_function=None,
            use_template_extent=False):
        """Constructor for the Composition Report class.

        :param iface: Reference to the QGIS iface object.
        :type iface: QgsAppInterface

        :param template_metadata: InaSAFE template metadata.
        :type template_metadata: ReportMetadata

        :param impact_function: Impact function instance for the report
        :type impact_function:
            safe.impact_function.impact_function.ImpactFunction

        .. versionadded:: 4.0
        """
        LOGGER.debug('InaSAFE Impact Report class initialised')
        self._iface = iface
        self._metadata = template_metadata
        self._output_folder = None
        self._impact_function = impact_function or (
            multi_exposure_impact_function)
        self._hazard = hazard or self._impact_function.hazard
        self._analysis = (analysis or self._impact_function.analysis_impacted)
        if impact_function:
            self._exposure = (
                exposure or self._impact_function.exposure)
            self._impact = (
                impact or self._impact_function.impact)
            self._exposure_summary_table = (
                exposure_summary_table
                or self._impact_function.exposure_summary_table)
            self._aggregation_summary = (
                aggregation_summary
                or self._impact_function.aggregation_summary)
        if extra_layers is None:
            extra_layers = []
        self._extra_layers = extra_layers
        self._ordered_layers = ordered_layers
        self._legend_layers = legend_layers
        self._minimum_needs = minimum_needs_profile
        self._multi_exposure_impact_function = multi_exposure_impact_function
        self._use_template_extent = use_template_extent
        self._inasafe_context = InaSAFEReportContext()

        # QgsMapSettings is added in 2.4
        if self._iface:
            map_settings = self._iface.mapCanvas().mapSettings()
        else:
            map_settings = QgsMapSettings()

        self._qgis_composition_context = QgsLayoutContext(
            None,
            map_settings,
            ImpactReport.DEFAULT_PAGE_DPI)
        self._keyword_io = KeywordIO()

    @property
    def inasafe_context(self):
        """Reference to default InaSAFE Context.

        :rtype: InaSAFEReportContext
        """
        return self._inasafe_context

    @property
    def qgis_composition_context(self):
        """Reference to default QGIS Composition Context.

        :rtype: QgsLayoutContext
        """
        return self._qgis_composition_context

    @property
    def metadata(self):
        """Getter to the template.

        :return: ReportMetadata
        :rtype: safe.report.report_metadata.ReportMetadata
        """
        return self._metadata

    @property
    def output_folder(self):
        """Output folder path for the rendering.

        :rtype: str
        """
        return self._output_folder

    @output_folder.setter
    def output_folder(self, value):
        """Output folder path for the rendering.

        :param value: output folder path
        :type value: str
        """
        self._output_folder = value
        if not os.path.exists(self._output_folder):
            os.makedirs(self._output_folder)

    @staticmethod
    def absolute_output_path(
            output_folder, components, component_key):
        """Return absolute output path of component.

        :param output_folder: The base output folder
        :type output_folder: str

        :param components: The list of components to look up
        :type components: list[ReportMetadata]

        :param component_key: The component key
        :type component_key: str

        :return: absolute output path
        :rtype: str

        .. versionadded:: 4.0
        """
        comp_keys = [c.key for c in components]

        if component_key in comp_keys:
            idx = comp_keys.index(component_key)
            output_path = components[idx].output_path
            if isinstance(output_path, str):
                return os.path.abspath(
                    os.path.join(output_folder, output_path))
            elif isinstance(output_path, list):
                output_list = []
                for path in output_path:
                    output_list.append(os.path.abspath(
                        os.path.join(output_folder, path)))
                return output_list
            elif isinstance(output_path, dict):
                output_dict = {}
                for key, path in list(output_path.items()):
                    output_dict[key] = os.path.abspath(
                        os.path.join(output_folder, path))
                return output_dict
        return None

    def component_absolute_output_path(self, component_key):
        """Return absolute output path of component.

        :param component_key: The component key
        :type component_key: str

        :return: absolute output path
        :rtype: str

        .. versionadded:: 4.0
        """
        return ImpactReport.absolute_output_path(
            self.output_folder,
            self.metadata.components,
            component_key)

    @property
    def impact_function(self):
        """Getter for impact function instance to use.

        :rtype: safe.impact_function.impact_function.ImpactFunction
        """
        return self._impact_function

    @property
    def multi_exposure_impact_function(self):
        """Getter for multi impact function instance to use.

        We define this property because we want to avoid the usage of
        impact_function property when there is multi exposure impact function
        being used.

        :rtype: MultiExposureImpactFunction
        """
        return self._multi_exposure_impact_function

    def _check_layer_count(self, layer):
        """Check for the validity of the layer.

        :param layer: QGIS layer
        :type layer: qgis.core.QgsVectorLayer
        :return:
        """
        if layer:
            if not layer.isValid():
                raise ImpactReport.LayerException('Layer is not valid')
            if isinstance(layer, QgsRasterLayer):
                # can't check feature count of raster layer
                return
            feature_count = len([f for f in layer.getFeatures()])
            if feature_count == 0:
                raise ImpactReport.LayerException(
                    'Layer contains no features')

    @property
    def hazard(self):
        """Getter to hazard layer.

        :rtype: qgis.core.QgsVectorLayer
        """
        self._check_layer_count(self._hazard)
        return self._hazard

    @hazard.setter
    def hazard(self, layer):
        """Hazard layer.

        :param layer: hazard layer
        :type layer: qgis.core.QgsVectorLayer
        """
        self._hazard = layer

    @property
    def exposure(self):
        """Getter to exposure layer.

        :rtype: qgis.core.QgsVectorLayer
        """
        self._check_layer_count(self._exposure)
        return self._exposure

    @exposure.setter
    def exposure(self, layer):
        """Exposure layer.

        :param layer: exposure layer
        :type layer: qgis.core.QgsVectorLayer
        """
        self._impact = layer

    @property
    def impact(self):
        """Getter to layer that will be used for stats, legend, reporting.

        :rtype: qgis.core.QgsVectorLayer
        """
        self._check_layer_count(self._impact)
        return self._impact

    @impact.setter
    def impact(self, layer):
        """Set the layer that will be used for stats, legend and reporting.

        :param layer: Layer that will be used for stats, legend and reporting.
        :type layer: qgis.core.QgsVectorLayer
        """
        self._impact = layer

    @property
    def analysis(self):
        """Analysis layer.

        :rtype: qgis.core.QgsVectorLayer
        """
        self._check_layer_count(self._analysis)
        return self._analysis

    @analysis.setter
    def analysis(self, layer):
        """Analysis layer.

        :param layer: Analysis layer
        :type layer: qgis.core.QgsVectorLayer
        """
        self._analysis = layer

    @property
    def exposure_summary_table(self):
        """Exposure summary table.

        :rtype: qgis.core.QgsVectorLayer
        """
        # self._check_layer_count(self._exposure_summary_table)
        return self._exposure_summary_table

    @exposure_summary_table.setter
    def exposure_summary_table(self, value):
        """Exposure summary table.

        :param value: Exposure Summary Table
        :type value: qgis.core.QgsVectorLayer
        :return:
        """
        self._exposure_summary_table = value

    @property
    def aggregation_summary(self):
        """Aggregation summary.

        :rtype: qgis.core.QgsVectorLayer
        """
        self._check_layer_count(self._aggregation_summary)
        return self._aggregation_summary

    @aggregation_summary.setter
    def aggregation_summary(self, value):
        """Aggregation summary.

        :param value: Aggregation Summary
        :type value: qgis.core.QgsVectorLayer
        """
        self._aggregation_summary = value

    @property
    def extra_layers(self):
        """Getter to extra layers.

        extra layers will be rendered alongside impact layer
        """
        return self._extra_layers

    @extra_layers.setter
    def extra_layers(self, extra_layers):
        """Set extra layers.

        extra layers will be rendered alongside impact layer

        :param extra_layers: List of QgsMapLayer
        :type extra_layers: list(QgsMapLayer)
        """
        self._extra_layers = extra_layers

    @property
    def ordered_layers(self):
        """Getter to ordered layers.

        Ordered layers will determine the layers order on map report.
        :return:
        """
        return self._ordered_layers

    @ordered_layers.setter
    def ordered_layers(self, ordered_layers):
        """Set ordered layers.

        Ordered layers will determine the layers order on map report.

        :param ordered_layers:
        :return:
        """
        self._ordered_layers = ordered_layers

    @property
    def legend_layers(self):
        """Getter to legend layers.

        Legend layers will determine the legend on map report.

        :return: List of legend layers.
        :rtype: list
        """
        return self._legend_layers

    @legend_layers.setter
    def legend_layers(self, legend_layers):
        """Set legend layers.

        Legend layers will determine the legend on map report.

        :param legend_layers: List of legend layers.
        :type legend_layers: list
        """
        self._legend_layers = legend_layers

    @property
    def use_template_extent(self):
        """Getter to the flag for using template extent.

        If True, map report will use extent defined in the template. If False,
        map report will use analysis extent.

        :return: The flag for using template extent or not.
        :rtype: bool
        """
        return self._use_template_extent

    @property
    def minimum_needs(self):
        """Minimum needs.

        :return: minimum needs used in impact report
        :rtype: safe.gui.tools.minimum_needs.needs_profile.NeedsProfile
        """
        return self._minimum_needs

    @minimum_needs.setter
    def minimum_needs(self, value):
        """Minimum needs.

        :param value: minimum needs used in impact report
        :type value: safe.gui.tools.minimum_needs.needs_profile.NeedsProfile
        """
        self._minimum_needs = value

    @property
    def map_title(self):
        """Get the map title from the layer keywords if possible.

        :returns: None on error, otherwise the title.
        :rtype: None, str
        """
        # noinspection PyBroadException
        try:
            title = self._keyword_io.read_keywords(
                self.impact, 'map_title')
            return title
        except KeywordNotFoundError:
            return None
        except Exception:  # pylint: disable=broad-except
            return None

    @property
    def map_legend_attributes(self):
        """Get the map legend attribute from the layer keywords if possible.

        :returns: None on error, otherwise the attributes (notes and units).
        :rtype: None, str
        """
        LOGGER.debug('InaSAFE Map getMapLegendAttributes called')
        legend_attribute_list = [
            'legend_notes',
            'legend_units',
            'legend_title']
        legend_attribute_dict = {}
        for legend_attribute in legend_attribute_list:
            # noinspection PyBroadException
            try:
                legend_attribute_dict[legend_attribute] = \
                    self._keyword_io.read_keywords(
                        self.impact, legend_attribute)
            except KeywordNotFoundError:
                pass
            except Exception:  # pylint: disable=broad-except
                pass
        return legend_attribute_dict

    def process_components(self):
        """Process context for each component and a given template.

        :returns: Tuple of error code and message
        :type: tuple

        .. versionadded:: 4.0
        """
        message = m.Message()
        warning_heading = m.Heading(
            tr('Report Generation issue'), **WARNING_STYLE)
        message.add(warning_heading)
        failed_extract_context = m.Heading(tr(
            'Failed to extract context'), **WARNING_STYLE)
        failed_render_context = m.Heading(tr(
            'Failed to render context'), **WARNING_STYLE)
        failed_find_extractor = m.Heading(tr(
            'Failed to load extractor method'), **WARNING_STYLE)
        failed_find_renderer = m.Heading(tr(
            'Failed to load renderer method'), **WARNING_STYLE)

        generation_error_code = self.REPORT_GENERATION_SUCCESS

        for component in self.metadata.components:
            # load extractors
            try:
                if not component.context:
                    if isinstance(
                        component.extractor, 
                        collections.abc.Callable):
                        _extractor_method = component.extractor
                    else:
                        _package_name = (
                            '%(report-key)s.extractors.%(component-key)s')
                        _package_name %= {
                            'report-key': self.metadata.key,
                            'component-key': component.key
                        }
                        # replace dash with underscores
                        _package_name = _package_name.replace('-', '_')
                        _extractor_path = os.path.join(
                            self.metadata.template_folder,
                            component.extractor
                        )
                        _module = imp.load_source(
                            _package_name, _extractor_path)
                        _extractor_method = getattr(_module, 'extractor')
                else:
                    LOGGER.info('Predefined context. Extractor not needed.')
            except Exception as e:  # pylint: disable=broad-except
                generation_error_code = self.REPORT_GENERATION_FAILED
                LOGGER.info(e)
                if not self.impact_function.use_rounding:
                    raise
                else:
                    message.add(failed_find_extractor)
                    message.add(component.info)
                    message.add(get_error_message(e))
                    continue

            # method signature:
            #  - this ImpactReport
            #  - this component
            try:
                if not component.context:
                    context = _extractor_method(self, component)
                    component.context = context
                else:
                    LOGGER.info('Using predefined context.')
            except Exception as e:  # pylint: disable=broad-except
                generation_error_code = self.REPORT_GENERATION_FAILED
                LOGGER.info(e)
                if not self.impact_function.use_rounding:
                    raise
                else:
                    message.add(failed_extract_context)
                    message.add(get_error_message(e))
                    continue

            try:
                # load processor
                if isinstance(
                    component.processor, collections.abc.Callable):
                    _renderer = component.processor
                else:
                    _package_name = '%(report-key)s.renderer.%(component-key)s'
                    _package_name %= {
                        'report-key': self.metadata.key,
                        'component-key': component.key
                    }
                    # replace dash with underscores
                    _package_name = _package_name.replace('-', '_')
                    _renderer_path = os.path.join(
                        self.metadata.template_folder,
                        component.processor
                    )
                    _module = imp.load_source(_package_name, _renderer_path)
                    _renderer = getattr(_module, 'renderer')
            except Exception as e:  # pylint: disable=broad-except
                generation_error_code = self.REPORT_GENERATION_FAILED
                LOGGER.info(e)
                if not self.impact_function.use_rounding:
                    raise
                else:
                    message.add(failed_find_renderer)
                    message.add(component.info)
                    message.add(get_error_message(e))
                    continue

            # method signature:
            #  - this ImpactReport
            #  - this component
            if component.context:
                try:
                    output = _renderer(self, component)
                    output_path = self.component_absolute_output_path(
                        component.key)
                    if isinstance(output_path, dict):
                        try:
                            dirname = os.path.dirname(output_path.get('doc'))
                        except BaseException:
                            dirname = os.path.dirname(output_path.get('map'))
                    else:
                        dirname = os.path.dirname(output_path)
                    if component.resources:
                        for resource in component.resources:
                            target_resource = os.path.basename(resource)
                            target_dir = os.path.join(
                                dirname, 'resources', target_resource)
                            # copy here
                            if os.path.exists(target_dir):
                                shutil.rmtree(target_dir)
                            shutil.copytree(resource, target_dir)
                    component.output = output
                except Exception as e:  # pylint: disable=broad-except
                    generation_error_code = self.REPORT_GENERATION_FAILED
                    LOGGER.info(e)
                    if not self.impact_function.use_rounding:
                        raise
                    else:
                        message.add(failed_render_context)
                        message.add(get_error_message(e))
                        continue

        return generation_error_code, message