#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# -------------------------------------------------------------------------------
# This file is part of Mentat system (https://mentat.cesnet.cz/).
#
# Copyright (C) since 2011 CESNET, z.s.p.o (http://www.ces.net/)
# Use of this source is governed by the MIT license, see LICENSE file.
# -------------------------------------------------------------------------------


"""
This pluggable module provides access to periodical event reports.
"""

__author__ = "Jan Mach <jan.mach@cesnet.cz>"
__credits__ = "Pavel Kácha <pavel.kacha@cesnet.cz>, Andrea Kropáčová <andrea.kropacova@cesnet.cz>"

import datetime
from itertools import chain
import os.path
import re

import flask
from flask.helpers import locked_cached_property
from flask_babel import force_locale, format_datetime, gettext, lazy_gettext
import flask_login
import flask_principal
from jinja2.loaders import ChoiceLoader, FileSystemLoader
import markupsafe
import pytz
from sqlalchemy import or_

import pyzenkit.utils

from hawat import charts
import hawat.acl
from hawat.base import HawatBlueprint
from hawat.blueprints.event_classes import get_event_class
from hawat.blueprints.reports.forms import (
    EventReportSearchForm,
    FeedbackForm,
    ReportingDashboardForm
)
import hawat.const
import hawat.events
import hawat.menu
from hawat.utils import URLParamsBuilder
from hawat.view import BaseSearchView, FileIdView, ItemDeleteView, ItemShowView, RenderableView
from hawat.view.mixin import AJAXMixin, HTMLMixin, SQLAlchemyMixin
import mentat.const
from mentat.const import tr_
from mentat.datatype.sqldb import (
    EventReportModel,
    GroupModel,
    ItemChangeLogModel,
    UserModel,
    _asoc_groups_reports
)
import mentat.stats.idea

BLUEPRINT_NAME = 'reports'
"""Name of the blueprint as module global constant."""

BABEL_RFC3339_FORMAT = "yyyy-MM-ddTHH:mm:ssZZZZZ"
BABEL_NON_RFC3339_FORMAT = "yyyy-MM-dd HH:mm:ss ZZZZZ"

MAX_IPs_IN_URL = 20


def format_datetime_search(dt, round_up=False):
    """
    Static method that takes datetime object or a string in isoformat
    datetime in utc and returns a string representing the initial
    date formatted for form searching.
    """
    if not isinstance(dt, datetime.datetime):
        dt = datetime.datetime.fromisoformat(dt)
    dt = dt.replace(microsecond=0)
    if round_up:
        dt = dt + datetime.timedelta(seconds=1)
    return dt.isoformat() + "Z"


def get_detector_choices():
    """
    Return select choices for detectors.
    """
    return [(det, det) for det in hawat.events.get_event_detectors()]


def get_event_class_choices():
    """
    Return select choices for event classes (both source and target).
    """
    return [(cls, cls) for cls in hawat.events.get_event_classes() + hawat.events.get_target_classes()]


def get_categories_choices():
    """
    Return select choices for categories.
    """
    return [(cat, cat) for cat in hawat.events.get_event_categories()]


DASHBOARD_CHART_SECTIONS = [
    charts.ChartSection(
        mentat.stats.idea.ST_SKEY_CNT_REPORTS,
        lazy_gettext('reports'),
        lazy_gettext('Report counts'),
        lazy_gettext(
            'This view shows total numbers of event reports generated by the Mentat system per '
            'day. Numbers of <em>summary</em> and <em>extra</em> reports are shown separatelly.'
        ),
        charts.DataComplexity.SINGLE,
        lazy_gettext('Report category'),
        data_keys=[
            charts.DataKey(mentat.stats.idea.ST_SKEY_CNT_REPS_S, lazy_gettext('summaries')),
            charts.DataKey(mentat.stats.idea.ST_SKEY_CNT_REPS_E, lazy_gettext('extras')),
            charts.DataKey(mentat.stats.idea.ST_SKEY_CNT_REPS_T, lazy_gettext('targets'))
        ]
    ),
    charts.ChartSection(
        mentat.stats.idea.ST_SKEY_EMAILS,
        lazy_gettext('e-mails'),
        lazy_gettext('E-mail counts'),
        lazy_gettext(
            'This view shows total numbers of report e-mails generated by the Mentat system per '
            'day.'
        ),
        charts.DataComplexity.SINGLE,
        lazy_gettext('E-mail')
    ),
    charts.ChartSection(
        'cnt_events_reported',
        lazy_gettext('events reported'),
        lazy_gettext('Reported event counts'),
        lazy_gettext(
            'This view shows total numbers of events that have been reported within the summary '
            'event reports. Numbers of <em>new</em> and <em>relapsing</em> events are shown '
            'separatelly. <em>New</em> events are those with unique combination of source and '
            'classification, <em>relapsing</em> events are those with repeating combination of '
            'source and classification.',
        ),
        charts.DataComplexity.SINGLE,
        lazy_gettext('Reported event category'),
        data_keys=[
            charts.DataKey(mentat.stats.idea.ST_SKEY_CNT_EVTS_N, lazy_gettext('new')),
            charts.DataKey(mentat.stats.idea.ST_SKEY_CNT_EVTS_R, lazy_gettext('relapsed'))
        ]
    ),
    charts.ChartSection(
        mentat.stats.idea.ST_SKEY_CNT_EVTS_A,
        lazy_gettext('events processed'),
        lazy_gettext('Processed event counts'),
        lazy_gettext(
            'This view shows total numbers of events that have been processed while generating '
            'event reports. Numbers of <em>reported</em>, <em>filtered</em> and '
            '<em>thresholded</em> events are shown separatelly. The <em>reported</em> events are '
            'those that have been actually reported within the summary reports. The '
            '<em>filtered</em> events are those that have been filtered out by configured '
            'reporting filters and never made it to the report. And finally the '
            '<em>thresholded</em> events are those that have been postponed from reporting by the '
            'event thresholding mechanism.'
        ),
        charts.DataComplexity.SINGLE,
        lazy_gettext('Processed event category'),
        data_keys=[
            charts.DataKey(mentat.stats.idea.ST_SKEY_CNT_EVENTS, lazy_gettext('reported')),
            charts.DataKey(mentat.stats.idea.ST_SKEY_CNT_EVTS_F, lazy_gettext('filtered')),
            charts.DataKey(mentat.stats.idea.ST_SKEY_CNT_EVTS_T, lazy_gettext('thresholded'))
        ]
    )
] + [charts.COMMON_CHART_SECTIONS_MAP[key] for key in (
    mentat.stats.idea.ST_SKEY_ABUSES,
    mentat.stats.idea.ST_SKEY_ASNS,
    mentat.stats.idea.ST_SKEY_CATEGORIES,
    mentat.stats.idea.ST_SKEY_CATEGSETS,
    mentat.stats.idea.ST_SKEY_COUNTRIES,
    mentat.stats.idea.ST_SKEY_DETECTORS,
    mentat.stats.idea.ST_SKEY_DETECTORSWS,
    mentat.stats.idea.ST_SKEY_SOURCES,
    mentat.stats.idea.ST_SKEY_TARGETS,
    mentat.stats.idea.ST_SKEY_CLASSES,
    mentat.stats.idea.ST_SKEY_SEVERITIES
)]


def get_event_class_from_whole_class(whole_class):
    """
    Whole class is {event_class}/{subclass} or {event_class}, if there
    is no subclass. This function extracts the event_class.
    """
    return whole_class if '/' not in whole_class else whole_class.split('/')[0]


def build_related_search_params(item):
    """
    Build dictionary containing parameters for searching all related report events.
    The search is considered to be exact if statistics are available and the amount
    of source IP addresses is not too big, so they can be used in event search.
    Function returns the result dictionary and if the search is exact (boolean).
    """
    exact = False
    is_target = item.type == mentat.const.REPORT_TYPE_TARGET
    related_events_search_params = {
        f'{"target_" if is_target else ""}severities': item.severity,
        'categories': 'Test',
        f'{"target_" if is_target else ""}groups': [group.name for group in item.groups],
        'submit': gettext('Search')
    }
    if not item.flag_testdata:
        related_events_search_params.update({
            'not_categories': 'True'
        })

    if item.structured_data:
        detect_times = []
        for report_type in ['regular', 'relapsed']:
            type_data = item.structured_data[report_type]
            for event_class_data in type_data.values():
                if is_target:
                    detect_times.append(datetime.datetime.fromisoformat(event_class_data['first_time']))
                    detect_times.append(datetime.datetime.fromisoformat(event_class_data['last_time']))
                else:
                    for ip_data in event_class_data.values():
                        detect_times.append(datetime.datetime.fromisoformat(ip_data['first_time']))
                        detect_times.append(datetime.datetime.fromisoformat(ip_data['last_time']))
        related_events_search_params.update({
            'dt_from': format_datetime_search(min(detect_times)),
            'dt_to': format_datetime_search(max(detect_times), round_up=True),
        })
    else:
        related_events_search_params.update({
            # 'Z' is added, so it is in UTC ISO format (%Y-%m-%dT%H:%M:%SZ).
            'st_from': item.dt_from.isoformat() + 'Z',
            'st_to': item.dt_to.isoformat() + 'Z',
        })

    if item.statistics and all(field in item.statistics for field in ['sources', 'classes']):
        related_events_search_params.update({
            f'{"target_" if is_target else ""}classes': list(item.statistics['classes'].keys()),
        })
        if len(item.statistics['sources'].keys()) <= MAX_IPs_IN_URL:
            related_events_search_params.update({
                'source_addrs': ",".join(list(item.statistics['sources'].keys())),
            })
            exact = True

    return related_events_search_params, exact


def section_event_search_url(data, item, section_name, ip):
    """
    Returns the URL for searching events related to the section of the report and
    also a boolean indicating if the search is exact (True) or not (False).
    The search is considered to be exact if the count of source IP addresses
    is not too big, so they can be used in event search.
    """
    exact = False
    params = {
        "dt_from": format_datetime_search(data['first_time']),
        "dt_to": format_datetime_search(data['last_time'], round_up=True),
        "not_categories": not item.flag_testdata,
        "categories": 'Test',
        "submit": True
    }
    if item.type == mentat.const.REPORT_TYPE_TARGET:
        params.update({
            "target_classes": get_event_class_from_whole_class(section_name),
            "target_groups": item.groups,
            "target_severities": item.severity
        })
        # Gather all source IP addresses and use them to narrow
        # down the search, but only if there are not too many
        # of them, as it could make the URL too long.
        source_ips = set()
        for detector in data["detector_data"].values():
            source_ips.update(detector["Source"]["ips"])
        if len(source_ips) <= MAX_IPs_IN_URL:
            params.update({
                "source_addrs": ",".join(source_ips)
            })
            exact = True
    else:
        params.update({
            "source_addrs": ip,
            "classes": get_event_class_from_whole_class(section_name),
            "groups": item.groups,
            "severities": item.severity
        })
        exact = True
    return flask.url_for('events.search', **params), exact


def add_search_events_entry(action_menu):
    """
    Adds the "search related events" menu entry to the action menu. This is to
    avoid code duplication, because this menu is also needed in the JSONView.

    The legend checks the 'exact' return value of build_related_search_params
    and writes warning that the search might be too broad if the search
    is not exact (e.g. because there are too many source IP addresses).
    """

    def get_legend_text(report):
        """
        Generates legend text based on whether the search might be too broad.
        """
        legend_text = lazy_gettext('Search for all events related to report "%(label)s".',
                                   label=markupsafe.escape(report.label))
        _, exact = build_related_search_params(report)
        if not exact:
            legend_text += lazy_gettext(' This search might find some events that are not included in the report.')
        return legend_text

    def get_search_url(report):
        """
        Builds the search URL based on the related search parameters for the given report.
        """
        search_params, _ = build_related_search_params(report)
        return flask.url_for('events.search', **search_params)

    action_menu.add_entry(
        'endpoint',
        'search',
        endpoint='events.search',
        title=lazy_gettext('Search events'),
        legend=lambda **x: get_legend_text(x['item']),
        url=lambda **x: get_search_url(x['item']),
        position=20
    )


def add_download_entries(action_menu):
    """
    Adds all entries for downloading report data to the action menu.
    This is to avoid code duplication, because it is needed in 2 views.
    """
    action_menu.add_entry(
        'submenu',
        'more',
        align_right=True,
        title=lazy_gettext("Download"),
        legend=lazy_gettext('More actions'),
        position=40
    )
    action_menu.add_entry(
        'endpoint',
        'more.downloadjson',
        endpoint='reports.data',
        title=lazy_gettext('Download data in JSON format'),
        url=lambda **x: flask.url_for('reports.data', fileid=x['item'].label, filetype='json'),
        icon='action-download',
        hidelegend=True
    )
    action_menu.add_entry(
        'endpoint',
        'more.downloadjsonzip',
        endpoint='reports.data',
        title=lazy_gettext('Download compressed data in JSON format'),
        url=lambda **x: flask.url_for('reports.data', fileid=x['item'].label, filetype='jsonzip'),
        icon='action-download-zip',
        hidelegend=True
    )


def adjust_query_for_groups(query, groups):
    """
    Adjust given SQLAlchemy query for current user. In case user specified set of
    groups, perform query filtering. In case no groups were selected, restrict
    non-administrators only to groups they are member of.
    """

    # Adjust query to filter only selected groups.
    if groups:
        # Naive approach.
        # query = query.filter(model.group_id.in_([grp.id for grp in groups]))
        # "Joined" approach.
        return query.join(_asoc_groups_reports).join(GroupModel).filter(GroupModel.id.in_([grp.id for grp in groups]))

    # For regular users restrict query only to groups they are a member or a manager of.
    if not hawat.acl.PERMISSION_POWER.can():
        return query.join(_asoc_groups_reports).join(GroupModel).filter(
            or_(GroupModel.members.any(UserModel.id == flask_login.current_user.id),
                GroupModel.managers.any(UserModel.id == flask_login.current_user.id))
        )

    return query


class SearchView(HTMLMixin, SQLAlchemyMixin, BaseSearchView):  # pylint: disable=locally-disabled,too-many-ancestors
    """
    View responsible for searching IDEA event report database and presenting result.
    """
    methods = ['GET']

    authentication = True

    @classmethod
    def get_view_icon(cls):
        return 'module-{}'.format(cls.module_name)

    @classmethod
    def get_view_title(cls, **kwargs):
        return lazy_gettext('Search event reports')

    @classmethod
    def get_menu_title(cls, **kwargs):
        return lazy_gettext('Reports')

    @property
    def dbmodel(self):
        return EventReportModel

    @staticmethod
    def get_search_form(request_args):
        return EventReportSearchForm(request_args,
                                     meta={'csrf': False},
                                     choices_detectors=get_detector_choices(),
                                     choices_categories=get_categories_choices(),
                                     choices_classes=get_event_class_choices())

    @staticmethod
    def build_query(query, model, form_args):
        # Adjust query based on group selection.
        query = adjust_query_for_groups(query, form_args.get('groups', None))
        # Adjust query based on text search string.
        if 'label' in form_args and form_args['label']:
            query = query.filter(model.label.like('%{}%'.format(form_args['label'])))
        # Adjust query based on lower time boudary selection.
        if 'dt_from' in form_args and form_args['dt_from']:
            query = query.filter(model.createtime >= form_args['dt_from'])
        # Adjust query based on upper time boudary selection.
        if 'dt_to' in form_args and form_args['dt_to']:
            query = query.filter(model.createtime <= form_args['dt_to'])

        # Adjust query based on report severity selection.
        if 'types' in form_args and form_args['severities']:
            query = query.filter(model.severity.in_(form_args['severities']))
        # Adjust query based on report type selection.
        if 'severities' in form_args and form_args['types']:
            query = query.filter(model.type.in_(form_args['types']))

        # Adjust query based on categories selection.
        if 'categories' in form_args and form_args['categories']:
            query = query.filter(or_(*[model.statistics['categories'].has_key(category) for category in form_args['categories']]))
        # Adjust query based on event classes selection.
        if 'classes' in form_args and form_args['classes']:
            query = query.filter(or_(*[model.statistics['classes'].has_key(cls) for cls in form_args['classes']]))
        # Adjust query based on detector selection.
        if 'detectors' in form_args and form_args['detectors']:
            query = query.filter(or_(*[model.statistics['detectors'].has_key(detector) for detector in form_args['detectors']]))
        # Adjust query based on source ip addresses selection.
        if 'source_ips' in form_args and form_args['source_ips']:
            query = query.filter(or_(*[model.statistics['sources'].has_key(ip) for ip in form_args['source_ips']]))
        # Adjust query based on target ip addresses selection.
        if 'target_ips' in form_args and form_args['target_ips']:
            query = query.filter(or_(*[model.statistics['targets'].has_key(ip) for ip in form_args['target_ips']]))

        # Return the result sorted by creation time in descending order and by label.
        return query.order_by(model.createtime.desc()).order_by(model.label.desc())

    def do_after_search(self, items):
        if items:
            self.response_context.update(
                max_evcount_rep=max(x.evcount_rep for x in items)
            )


class ShowView(HTMLMixin, SQLAlchemyMixin, ItemShowView):
    """
    Detailed report view.
    """
    methods = ['GET']

    authentication = True

    @classmethod
    def get_menu_title(cls, **kwargs):
        return lazy_gettext('Show report')

    @classmethod
    def get_menu_legend(cls, **kwargs):
        return lazy_gettext(
            'View details of event report &quot;%(item)s&quot;',
            item=markupsafe.escape(str(kwargs['item']))
        )

    @classmethod
    def get_view_title(cls, **kwargs):
        return lazy_gettext('Show report')

    @property
    def dbmodel(self):
        return EventReportModel

    @classmethod
    def authorize_item_action(cls, **kwargs):
        for group in kwargs['item'].groups:
            permission_mm = flask_principal.Permission(
                hawat.acl.MembershipNeed(group.id),
                hawat.acl.ManagementNeed(group.id)
            )
            if permission_mm.can():
                return permission_mm.can()
        return hawat.acl.PERMISSION_POWER.can()

    @classmethod
    def get_breadcrumbs_menu(cls):  # pylint: disable=locally-disabled,unused-argument
        action_menu = hawat.menu.Menu()
        action_menu.add_entry(
            'endpoint',
            'home',
            endpoint=flask.current_app.config['ENDPOINT_HOME']
        )
        action_menu.add_entry(
            'endpoint',
            'search',
            endpoint='{}.search'.format(cls.module_name)
        )
        action_menu.add_entry(
            'endpoint',
            'show',
            endpoint='{}.show'.format(cls.module_name),
        )
        return action_menu

    @classmethod
    def action_menu_add_json(cls, action_menu):
        action_menu.add_entry(
            'endpoint',
            'json',
            endpoint='reports.json',
            url=lambda **x: flask.url_for('reports.json', item_id=x['item'].id),
            position=10
        )

    @classmethod
    def get_action_menu(cls):
        action_menu = hawat.menu.Menu()
        cls.action_menu_add_json(action_menu)
        add_search_events_entry(action_menu)
        action_menu.add_entry(
            'endpoint',
            'delete',
            endpoint='reports.delete',
            position=30
        )
        add_download_entries(action_menu)
        return action_menu

    @staticmethod
    def format_datetime(val, tzone, utc=False, rfc_complaint=False):
        """
        Static method that take string with isoformat datetime in utc and return
        string with datetime formatted according to 'rfc_complaint' parameter
        in tz timezone, or in utc, if 'utc' parameter is True.
        """
        time = datetime.datetime.fromisoformat(val).replace(tzinfo=pytz.utc)
        if not utc:
            time = time.astimezone(tzone)
        return format_datetime(
            time,
            BABEL_RFC3339_FORMAT if rfc_complaint else BABEL_NON_RFC3339_FORMAT,
            rebase=False
        )

    @staticmethod
    def format_datetime_wz(val, format_str, tzone):
        """
        Static method that take string with isoformat datetime in utc and return
        string with BABEL_RFC3339_FORMAT formatted datetime in tz timezone
        """
        return format_datetime(
            datetime.datetime.fromisoformat(val).replace(tzinfo=pytz.utc).astimezone(tzone),
            format_str,
            rebase=False
        )

    @staticmethod
    def escape_id(ident):
        """
        Escape id for use in bootstrap
        """
        return re.sub(r"[^A-Za-z0-9-_]", (lambda x: r'\{:X}_'.format(ord(x.group()))), ident)

    def do_before_response(self, **kwargs):
        if 'item' in self.response_context and self.response_context['item']:
            self.response_context.update(
                statistics=self.response_context['item'].statistics,
                template_vars=flask.current_app.mconfig[mentat.const.CKEY_CORE_REPORTER][
                    mentat.const.CKEY_CORE_REPORTER_TEMPLATEVARS],
                form=FeedbackForm(),
                format_datetime=ShowView.format_datetime,
                format_datetime_wz=ShowView.format_datetime_wz,
                tz=pytz.timezone(self.response_context['item'].structured_data["timezone"]) if self.response_context[
                    'item'].structured_data else None,
                format_datetime_search=format_datetime_search,
                tz_utc=pytz.utc,
                escape_id=ShowView.escape_id,
                get_event_class=get_event_class,
                get_event_class_from_whole_class=get_event_class_from_whole_class,
                section_event_search_url=section_event_search_url,
                REPORT_FIELDS={
                    "MAIN_LIST": (mentat.const.REPORT_FIELDS_MAIN_LIST_VIEW, "Main"),
                    "MAIN_NUMBER": (mentat.const.REPORT_FIELDS_MAIN_NUMBER, "Main"),
                    "SOURCE_LIST": (mentat.const.REPORT_FIELDS_SOURCE_LIST, "Source"),
                    "SOURCE_NUMBER": (mentat.const.REPORT_FIELDS_SOURCE_NUMBER_VIEW, "Source"),
                    "TARGET_LIST": (mentat.const.REPORT_FIELDS_TARGET_LIST_VIEW, "Target"),
                    "TARGET_NUMBER": (mentat.const.REPORT_FIELDS_TARGET_NUMBER_VIEW, "Target")
                }
            )


class UnauthShowView(ShowView):  # pylint: disable=locally-disabled,too-many-ancestors
    """
    Unauthorized access to report detail view.
    """
    methods = ['GET']

    authentication = False

    @classmethod
    def get_view_name(cls):
        return 'unauth'

    @classmethod
    def get_view_template(cls):
        return '{}/show.html'.format(cls.module_name)

    @classmethod
    def authorize_item_action(cls, **kwargs):
        return True

    @property
    def search_by(self):
        return self.dbmodel.label

    @classmethod
    def action_menu_add_json(cls, action_menu):
        action_menu.add_entry(
            'endpoint',
            'unauth_json',
            endpoint='reports.unauth_json',
            url=lambda **x: flask.url_for('reports.unauth_json', item_id=x['item'].label),
            position=10
        )


class JSONShowView(HTMLMixin, SQLAlchemyMixin, ItemShowView):  # pylint: disable=locally-disabled,abstract-method
    """
    Presenting the report data as the original JSON.
    Report is fetched based on ID.
    """
    authentication = True

    @classmethod
    def get_view_name(cls):
        return 'json'

    @classmethod
    def get_view_title(cls, **kwargs):
        return lazy_gettext('Show report data in JSON')

    @classmethod
    def get_menu_title(cls, **kwargs):
        return lazy_gettext('Show data in JSON')

    @classmethod
    def get_menu_legend(cls, **kwargs):
        return lazy_gettext(
            'View data from report &quot;%(item)s&quot; in JSON',
            item=flask.escape(kwargs['item'].label)
        )

    @classmethod
    def authorize_item_action(cls, **kwargs):
        for group in kwargs['item'].groups:
            permission_mm = flask_principal.Permission(
                hawat.acl.MembershipNeed(group.id),
                hawat.acl.ManagementNeed(group.id)
            )
            if permission_mm.can():
                return permission_mm.can()
        return hawat.acl.PERMISSION_POWER.can()

    @classmethod
    def action_menu_add_show(cls, action_menu):
        action_menu.add_entry(
            'endpoint',
            'show',
            endpoint='reports.show',
            position=10
        )

    @classmethod
    def get_action_menu(cls):  # pylint: disable=locally-disabled,unused-argument
        action_menu = hawat.menu.Menu()
        cls.action_menu_add_show(action_menu)
        add_search_events_entry(action_menu)
        add_download_entries(action_menu)
        return action_menu

    @classmethod
    def get_directory_path(cls, fileid):
        return mentat.const.construct_report_dirpath(
            pyzenkit.utils.get_resource_path_fr(flask.current_app.mconfig[mentat.const.CKEY_CORE_REPORTER]
                                                [mentat.const.CKEY_CORE_REPORTER_REPORTSDIR]),
            fileid,
            True
        )

    def fetch_report(self, item_identifier):
        return self.dbsession.query(EventReportModel) \
            .filter(EventReportModel.id == item_identifier) \
            .one_or_none()

    def fetch(self, item_id):
        def _get_json_data(basedirpath, filename):
            try:
                with open(os.path.join(basedirpath, filename), "r", encoding="utf-8") as f:
                    return f.read()
            except FileNotFoundError:
                return None

        report = self.fetch_report(item_id)
        if report is None:
            flask.abort(404)

        basedirpath = self.get_directory_path(report.label)
        filename = 'security-report-{}.json'.format(report.label)
        if not basedirpath:
            flask.abort(400)

        self.logger.info(
            "Serving file '{}' from directory '{}'.".format(
                filename,
                basedirpath
            )
        )
        json_data = _get_json_data(basedirpath, filename)
        if not json_data:
            flask.abort(404)
        self.response_context.update(json_data=json_data)

        return report


class UnauthJSONShowView(JSONShowView):  # pylint: disable=locally-disabled,too-many-ancestors
    """
    Unauthorized access to view report data in JSON.
    Report is fetched based on label.
    """
    methods = ['GET']

    authentication = False

    @classmethod
    def get_view_name(cls):
        return 'unauth_json'

    @classmethod
    def get_view_template(cls):
        return '{}/json.html'.format(cls.module_name)

    @classmethod
    def authorize_item_action(cls, **kwargs):
        return True

    @classmethod
    def action_menu_add_show(cls, action_menu):
        action_menu.add_entry(
            'endpoint',
            'unauth',
            endpoint='reports.unauth',
            url=lambda **x: flask.url_for('reports.unauth', item_id=x['item'].label)
        )

    def fetch_report(self, item_identifier):
        return self.dbsession.query(EventReportModel) \
            .filter(EventReportModel.label == item_identifier) \
            .one_or_none()


class DataView(FileIdView):
    """
    View responsible for providing access to report data.
    """
    methods = ['GET']

    authentication = False

    @classmethod
    def get_view_name(cls):
        return 'data'

    @classmethod
    def get_view_title(cls, **kwargs):
        return lazy_gettext('Event report data')

    @classmethod
    def get_directory_path(cls, fileid, filetype):
        return mentat.const.construct_report_dirpath(
            pyzenkit.utils.get_resource_path_fr(flask.current_app.mconfig[mentat.const.CKEY_CORE_REPORTER]
                                                [mentat.const.CKEY_CORE_REPORTER_REPORTSDIR]),
            fileid,
            True
        )

    def get_filename(self, fileid, filetype):
        fileext = ''
        if filetype == 'json':
            fileext = 'json'
        elif filetype == 'jsonzip':
            fileext = 'json.zip'
        else:
            flask.abort(400, "Requested invalid data file type '{}'".format(filetype))
        return 'security-report-{}.{}'.format(fileid, fileext)


class AbstractDashboardView(SQLAlchemyMixin, BaseSearchView):  # pylint: disable=locally-disabled,too-many-ancestors
    """
    Base class responsible for presenting reporting dashboard.
    """
    authentication = True

    always_include_charts = False

    @classmethod
    def get_view_icon(cls):
        return 'module-{}'.format(cls.module_name)

    @classmethod
    def get_menu_title(cls, **kwargs):
        return lazy_gettext('Reporting')

    @classmethod
    def get_view_title(cls, **kwargs):
        return lazy_gettext('Event reporting dashboards')

    @classmethod
    def get_view_template(cls):
        return '{}/dashboard.html'.format(cls.module_name)

    @property
    def dbmodel(self):
        return EventReportModel

    @staticmethod
    def get_search_form(request_args):
        return ReportingDashboardForm(request_args, meta={'csrf': False})

    @staticmethod
    def build_query(query, model, form_args):
        # Adjust query based on group selection.
        query = adjust_query_for_groups(query, form_args.get('groups', None))
        # Adjust query based on lower time boudary selection.
        if 'dt_from' in form_args and form_args['dt_from']:
            query = query.filter(model.createtime >= form_args['dt_from'])
        # Adjust query based on upper time boudary selection.
        if 'dt_to' in form_args and form_args['dt_to']:
            query = query.filter(model.createtime <= form_args['dt_to'])

        # Return the result sorted by label.
        return query.order_by(model.label)

    def _add_chart_section_data(self, stats, timeline_cfg):
        for i, chsection in enumerate(self.response_context['chart_sections']):
            if chsection.key in (
                mentat.stats.idea.ST_SKEY_CNT_REPORTS,
                'cnt_events_reported',
                mentat.stats.idea.ST_SKEY_CNT_EVTS_A,
            ):
                data_format = charts.InputDataFormat.WIDE_SIMPLE
            else:
                data_format = charts.InputDataFormat.WIDE_COMPLEX

            timeline_chart_data = charts.TimelineChartData(
                stats[mentat.stats.idea.ST_SKEY_TIMELINE],
                chsection,
                timeline_cfg,
                data_format,
                add_rest=True,
                x_axis_label_override=lazy_gettext('time')
            )
            chsection = chsection.add_data(timeline_chart_data)

            if chsection.data_complexity != charts.DataComplexity.NONE:
                secondary_chart_data = charts.SecondaryChartData(
                    stats,
                    chsection,
                    data_format,
                    add_rest=True,
                    sort=True
                )
                chsection = chsection.add_data(secondary_chart_data)

            self.response_context['chart_sections'][i] = chsection

    def do_after_search(self, items):
        self.logger.debug(
            "Calculating event reporting dashboard overview from %d records.",
            len(items)
        )
        if items:
            dt_from = self.response_context['form_data'].get('dt_from', None)
            if dt_from:
                dt_from = dt_from.replace(tzinfo=None)
            dt_to = self.response_context['form_data'].get('dt_to', None)
            if dt_to:
                dt_to = dt_to.replace(tzinfo=None)

            if not dt_from:
                dt_from = self.dbcolumn_min(self.dbmodel.createtime)
            if not dt_to:
                dt_to = datetime.datetime.utcnow()

            stats = mentat.stats.idea.aggregate_stats_reports(items, dt_from, dt_to)

            # Remove to: and cc: prefixes from emails.
            # replace the dicts under 'emails' in stats and buckets with modified dicts without 'to:' and 'cc:'
            for stats_or_bucket in chain((stats,), (b[1] for b in stats.get('timeline', []))):
                if 'emails' in stats_or_bucket:
                    d = {}
                    for k, v in stats_or_bucket['emails'].items():
                        key = k[3:] if k.startswith('to:') or k.startswith('cc:') else k
                        if key not in d:
                            d[key] = 0
                        d[key] += v
                    stats_or_bucket['emails'] = d

            if self.always_include_charts:
                timeline_cfg = stats[mentat.stats.idea.ST_SKEY_TLCFG]
                self.response_context.update(
                    chart_sections=DASHBOARD_CHART_SECTIONS.copy()
                )
                self._add_chart_section_data(stats, timeline_cfg)

            self.response_context.update(
                statistics=stats
            )

    def do_before_response(self, **kwargs):
        self.response_context.update(
            quicksearch_list=self.get_quicksearch_by_time()
        )


class DashboardView(HTMLMixin, AbstractDashboardView):  # pylint: disable=locally-disabled,too-many-ancestors
    """
    View responsible for presenting reporting dashboard in the form of HTML page.
    """
    methods = ['GET']

    always_include_charts = True

    @classmethod
    def get_view_name(cls):
        return 'dashboard'


class APIDashboardView(AJAXMixin, AbstractDashboardView):  # pylint: disable=locally-disabled,too-many-ancestors
    """
    View responsible for presenting reporting dashboard in the form of JSON document.
    """
    methods = ['GET', 'POST']

    @classmethod
    def get_view_name(cls):
        return 'apidashboard'

    def process_response_context(self):
        super().process_response_context()
        # Prevent certain response context keys to appear in final response.
        for key in ('items', 'quicksearch_list'):
            try:
                del self.response_context[key]
            except KeyError:
                pass
        return self.response_context


class DeleteView(HTMLMixin, SQLAlchemyMixin, ItemDeleteView):  # pylint: disable=locally-disabled,too-many-ancestors
    """
    View for deleting existing user accounts.
    """
    methods = ['GET', 'POST']

    authentication = True

    authorization = [hawat.acl.PERMISSION_ADMIN]

    @classmethod
    def get_menu_legend(cls, **kwargs):
        return lazy_gettext(
            'Delete event report &quot;%(item)s&quot;',
            item=markupsafe.escape(str(kwargs['item']))
        )

    def get_url_next(self):
        return flask.url_for(
            '{}.{}'.format(self.module_name, 'search')
        )

    @property
    def dbmodel(self):
        return EventReportModel

    @property
    def dbchlogmodel(self):
        return ItemChangeLogModel

    @staticmethod
    def get_message_success(**kwargs):
        return gettext(
            'Event report <strong>%(item_id)s</strong> was successfully and permanently deleted.',
            item_id=markupsafe.escape(str(kwargs['item']))
        )

    @staticmethod
    def get_message_failure(**kwargs):
        return gettext(
            'Unable to delete event report <strong>%(item_id)s</strong>.',
            item_id=markupsafe.escape(str(kwargs['item']))
        )


class FeedbackView(AJAXMixin, RenderableView):
    """
    View for sending feedback for reports.
    """
    methods = ['POST']

    authentication = True

    @classmethod
    def get_view_name(cls):
        return 'feedback'

    @classmethod
    def get_view_title(cls, **kwargs):
        return lazy_gettext('Report feedback')

    def dispatch_request(self, item_id):  # pylint: disable=locally-disabled,arguments-differ
        """
        Mandatory interface required by the :py:func:`flask.views.View.dispatch_request`.
        Will be called by the *Flask* framework to service the request.

        Feedback for report with label *item_id*.
        More specific part like section and ip can be send in POST data.
        """
        form = FeedbackForm(flask.request.form)
        if form.validate():
            mail_locale = flask.current_app.config['BABEL_DEFAULT_LOCALE']
            link = \
            flask.current_app.mconfig[mentat.const.CKEY_CORE_REPORTER][mentat.const.CKEY_CORE_REPORTER_TEMPLATEVARS][
                "report_access_url"] + \
            item_id + "/unauth" + "#" + form.section.data
            if form.ip.data and form.ip.data != "None":
                feedback_for = item_id + " (" + form.section.data + ", ip: " + form.ip.data + ")"
            else:  # target reports do not have ip included
                feedback_for = item_id + " (" + form.section.data + ")"

            with force_locale(mail_locale):
                email_headers = {
                    'subject': gettext(
                        "[Mentat] Feedback for report - %(item_id)s",
                        item_id=item_id
                    ),
                    'to': flask.current_app.config['HAWAT_REPORT_FEEDBACK_MAILS'],
                    'reply_to': flask_login.current_user.email
                }
                email_body = flask.render_template(
                    'reports/email_report_feedback.txt',
                    account=flask_login.current_user,
                    text=form.text.data,
                    feedback_for=feedback_for,
                    link=link
                )
                flask.current_app.mailer.send(email_headers, email_body)
            self.response_context["message"] = gettext('Thank you. Your feedback was sent to administrators.')
        else:
            self.response_context.update(
                form_errors=[(field_name, err) for field_name, error_messages in form.errors.items() for err in
                             error_messages]
            )
            self.response_context["message"] = "<br />".join(
                [": ".join(err) for err in self.response_context["form_errors"]])
        return self.generate_response()


# -------------------------------------------------------------------------------


class ReportsBlueprint(HawatBlueprint):
    """Pluggable module - periodical event reports (*reports*)."""

    @classmethod
    def get_module_title(cls):
        return lazy_gettext('Event reports')

    def register_app(self, app):
        app.menu_main.add_entry(
            'view',
            'dashboards.reporting',
            position=20,
            view=DashboardView
        )
        app.menu_main.add_entry(
            'view',
            BLUEPRINT_NAME,
            position=120,
            view=SearchView,
            resptitle=True
        )

        # Register context actions provided by this module.
        app.set_csag(
            hawat.const.CSAG_ABUSE,
            tr_('Search for group <strong>%(name)s</strong> in report database'),
            SearchView,
            URLParamsBuilder({'submit': tr_('Search')}).add_rule('groups', True)
                                                       .add_kwrule('dt_from', False, True)
                                                       .add_kwrule('dt_to', False, True)
        )

        app.set_csag(
            hawat.const.CSAG_ABUSE,
            tr_('Search for group <strong>%(name)s</strong> in reporting dashboards'),
            DashboardView,
            URLParamsBuilder({'submit': tr_('Search')}).add_rule('groups', True)
                                                       .add_kwrule('dt_from', False, True)
                                                       .add_kwrule('dt_to', False, True)
        )

        app.set_csag(
            hawat.const.CSAG_DETECTOR,
            tr_('Search for reports with detector <strong>%(name)s</strong>'),
            SearchView,
            URLParamsBuilder({'submit': tr_('Search')}).add_rule('detectors', True)
                                                       .add_kwrule('dt_from', False, True)
                                                       .add_kwrule('dt_to', False, True)
        )

        app.set_csag(
            hawat.const.CSAG_CATEGORY,
            tr_('Search for reports with category <strong>%(name)s</strong>'),
            SearchView,
            URLParamsBuilder({'submit': tr_('Search')}).add_rule('category', True)
                                                       .add_kwrule('dt_from', False, True)
                                                       .add_kwrule('dt_to', False, True)
        )

        app.set_csag(
            hawat.const.CSAG_CLASS,
            tr_('Search for reports with event class <strong>%(name)s</strong>'),
            SearchView,
            URLParamsBuilder({'submit': tr_('Search')}).add_rule('classes', True)
                                                       .add_kwrule('dt_from', False, True)
                                                       .add_kwrule('dt_to', False, True)
        )

        app.set_csag(
            hawat.const.CSAG_ADDRESS,
            tr_('Search for reports with IP address <strong>%(name)s</strong> as a source'),
            SearchView,
            URLParamsBuilder({'submit': tr_('Search')}).add_rule('source_ips', True)
                                                       .add_kwrule('dt_from', False, True)
                                                       .add_kwrule('dt_to', False, True)
        )

        app.set_csag(
            hawat.const.CSAG_ADDRESS,
            tr_('Search for reports with IP address <strong>%(name)s</strong> as a target'),
            SearchView,
            URLParamsBuilder({'submit': tr_('Search')}).add_rule('target_ips', True)
                                                       .add_kwrule('dt_from', False, True)
                                                       .add_kwrule('dt_to', False, True)
        )

    @locked_cached_property
    def jinja_loader(self):
        """The Jinja loader for this package bound object.

        .. versionadded:: 0.5
        """
        return ChoiceLoader([FileSystemLoader(os.path.join(self.root_path, self.template_folder)),
                             FileSystemLoader(pyzenkit.utils.get_resource_path_fr(
                                 flask.current_app.mconfig[mentat.const.CKEY_CORE_REPORTER][
                                     mentat.const.CKEY_CORE_REPORTER_TEMPLATESDIR]))])


# -------------------------------------------------------------------------------


def get_blueprint():
    """
    Mandatory interface for :py:mod:`hawat.Hawat` and factory function. This function
    must return a valid instance of :py:class:`hawat.app.HawatBlueprint` or
    :py:class:`flask.Blueprint`.
    """

    hbp = ReportsBlueprint(
        BLUEPRINT_NAME,
        __name__,
        template_folder = 'templates'
    )

    hbp.register_view_class(SearchView,         '/{}/search'.format(BLUEPRINT_NAME))
    hbp.register_view_class(ShowView,           '/{}/<int:item_id>/show'.format(BLUEPRINT_NAME))
    hbp.register_view_class(UnauthShowView,     '/{}/<item_id>/unauth'.format(BLUEPRINT_NAME))
    hbp.register_view_class(JSONShowView,       '/{}/<int:item_id>/json'.format(BLUEPRINT_NAME))
    hbp.register_view_class(UnauthJSONShowView, '/{}/<item_id>/unauth_json'.format(BLUEPRINT_NAME))
    hbp.register_view_class(DataView,           '/{}/data/<fileid>/<filetype>'.format(BLUEPRINT_NAME))
    hbp.register_view_class(DashboardView,      '/{}/dashboard'.format(BLUEPRINT_NAME))
    hbp.register_view_class(DeleteView,         '/{}/<int:item_id>/delete'.format(BLUEPRINT_NAME))
    hbp.register_view_class(FeedbackView,       '/{}/<item_id>/feedback'.format(BLUEPRINT_NAME))
    hbp.register_view_class(APIDashboardView,   '/api/{}/dashboard'.format(BLUEPRINT_NAME))

    return hbp
