IbisAttributeListActionBean.java

/*
 * Copyright (C) 2015 B3Partners B.V.
 *
 * 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 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package nl.b3p.viewer.stripes;

import static nl.b3p.viewer.ibis.util.DateUtils.addMonth;
import static nl.b3p.viewer.ibis.util.DateUtils.differenceInMonths;

import java.io.StringReader;
import java.math.BigDecimal;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.util.TreeMap;
import java.util.TreeSet;
import javax.persistence.EntityManager;
import net.sourceforge.stripes.action.ActionBean;
import net.sourceforge.stripes.action.ActionBeanContext;
import net.sourceforge.stripes.action.After;
import net.sourceforge.stripes.action.Before;
import net.sourceforge.stripes.action.DefaultHandler;
import net.sourceforge.stripes.action.Resolution;
import net.sourceforge.stripes.action.StreamingResolution;
import net.sourceforge.stripes.action.StrictBinding;
import net.sourceforge.stripes.action.UrlBinding;
import net.sourceforge.stripes.controller.LifecycleStage;
import net.sourceforge.stripes.validation.DateTypeConverter;
import net.sourceforge.stripes.validation.EnumeratedTypeConverter;
import net.sourceforge.stripes.validation.Validate;
import nl.b3p.viewer.config.app.Application;
import nl.b3p.viewer.config.app.ApplicationLayer;
import nl.b3p.viewer.config.security.Authorizations;
import nl.b3p.viewer.config.services.AttributeDescriptor;
import nl.b3p.viewer.config.services.FeatureTypeRelation;
import nl.b3p.viewer.config.services.Layer;
import nl.b3p.viewer.config.services.SimpleFeatureType;
import nl.b3p.viewer.ibis.util.IbisConstants;
import nl.b3p.viewer.util.FeatureToJson;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.geotools.data.DataUtilities;
import org.geotools.data.Query;
import org.geotools.data.simple.SimpleFeatureCollection;
import org.geotools.data.simple.SimpleFeatureIterator;
import org.geotools.feature.simple.SimpleFeatureBuilder;
import org.geotools.data.simple.SimpleFeatureSource;
import org.geotools.feature.simple.SimpleFeatureTypeBuilder;
import org.geotools.filter.text.ecql.ECQL;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.opengis.feature.Property;
import org.opengis.feature.simple.SimpleFeature;
import org.opengis.filter.Filter;
import org.stripesstuff.stripersist.Stripersist;

/**
 * Attribute list backend for voor IBIS component IbisReport.
 *
 * @author Mark Prins
 */
@UrlBinding("/action/ibisattributes")
@StrictBinding
public class IbisAttributeListActionBean implements ActionBean, IbisConstants {

    private static final Log log = LogFactory.getLog(IbisAttributeListActionBean.class);
    private static final String JSON_METADATA = "metaData";
    private ActionBeanContext context;

    /**
     * Base64 form fData to echo back.
     */
    @Validate
    private String data;
    /**
     * filename to echo back.
     */
    @Validate
    private String filename;
    /**
     * mimetype to echo back.
     */
    @Validate
    private String mimetype;

    @Validate
    private Application application;

    @Validate
    private ApplicationLayer appLayer;

    @Validate(converter = DateTypeConverter.class)
    private Date fromDate;
    @Validate(converter = DateTypeConverter.class)
    private Date toDate;
    @Validate
    private String regio;
    @Validate
    private String gemeente;
    @Validate
    private String terrein;
    @Validate
    private List<String> attrNames;
    /**
     * report reportType
     */
    @Validate(converter = EnumeratedTypeConverter.class)
    private ReportType reportType;
    /**
     * report type
     */
    @Validate(converter = EnumeratedTypeConverter.class)
    private QueryArea aggregationLevel;
    @Validate(converter = EnumeratedTypeConverter.class)
    private AggregationLevelDate aggregationLevelDate;

    private Layer layer = null;
    private boolean unauthorized;
    private String gebiedsNaamQuery;
    private QueryArea areaType;

    enum QueryArea {

        REGIO, GEMEENTE, TERREIN
    }

    enum ReportType {

        INDIVIDUAL, AGGREGATED, ISSUE;
    }

    enum AggregationLevelDate {

        NONE, MONTH
    }

    /**
     * Field in the datamodel (base uitgifte view). {@value }
     */
    private static final String TERREINID_FIELDNAME = "id";
    /**
     * Field in the datamodel (base uitgifte view). {@value }
     */
    private static final String GEMEENTE_FIELDNAME = "naam";
    /**
     * Field in the datamodel (base uitgifte view). {@value }
     */
    private static final String REGIO_FIELDNAME = "vvr_naam";
    /**
     * Field in the datamodel (base uitgifte view). {@value }
     */
    private static final String TERREIN_FIELDNAME = "a_plannaam";
    /**
     * Field in the datamodel. {@value }
     */
    private static final String STATUS_FIELDNAME = "status";
    /**
     * Field in the datamodel (related view). {@value }
     */
    private static final String KAVELID_RELATED_FIELDNAME = "kavelid";
    /**
     * Field in the datamodel (related view). {@value }
     */
    private static final String GEMEENTE_RELATED_FIELDNAME = "gemeentenaam";
    /**
     * Field in the datamodel (related view). {@value }
     */
    private static final String REGIO_RELATED_FIELDNAME = "regionaam";
    /**
     * Field in the datamodel (related view). {@value }
     */
    private static final String TERREIN_RELATED_FIELDNAME = "terreinnaam";

    /**
     * Field in the datamodel (related view). {@value }
     */
    private static final String TERREINID_RELATED_FIELDNAME = "terreinid";

    /**
     * Field in the datamodel (related view). {@value }
     */
    private static final String UITGIFTEDATUM_RELATED_FIELDNAME = "datumuitgifte";
    /**
     * Field in the datamodel (related view). {@value }
     */
    private static final String OPPERVLAKTE_GEOM_RELATED_FIELDNAME = "opp_geometrie";

    /**
     * aggregate locality field name. {@value}
     */
    private static final String GEBIED_FIELDNAME = "gebied";

    /**
     * name of the related feature type. {@code null}.
     *
     * @todo assuming there is only one relate
     */
    private static final String RELATED_FT_NAME = null;

    @After(stages = LifecycleStage.BindingAndValidation)
    public void loadLayer() {
        this.layer = appLayer.getService().getSingleLayer(appLayer.getLayerName(), Stripersist.getEntityManager());
    }

    @Before(stages = LifecycleStage.EventHandling)
    public void checkAuthorization() {
        EntityManager em = Stripersist.getEntityManager();
        if (application == null
                || appLayer == null
                || !Authorizations.isAppLayerReadAuthorized(application, appLayer, context.getRequest(), em)) {
            unauthorized = true;
        }
    }

    /**
     * Echo back the received base64 encoded form data. A fallback for IE and
     * browsers that don't support client side downloads.
     *
     * @return excel download of the posted fData (posted data is not validated
     * for 'excel-ness')
     * @throws Exception if data is null or something goes wrong during IO
     */
    public Resolution download() throws Exception {
        if (data == null) {
            throw new IllegalArgumentException("Data cannot be null.");
        } else if (unauthorized) {
            throw new IllegalStateException("Not authorized.");
        }
        if (mimetype == null) {
            mimetype = "application/vnd.ms-excel";
        }
        if (filename == null) {
            filename = "ibisrapportage.xls";
        }
        log.debug("returning excel:" + data);
        return new StreamingResolution(mimetype, new StringReader(data)).setFilename(filename).setAttachment(false);
    }

    @DefaultHandler
    public Resolution query() throws Exception {
        JSONObject json = new JSONObject();
        json.put("success", Boolean.FALSE);
        // initial metadata
        JSONObject metadata = new JSONObject()
                .put("root", "data").put("totalProperty", "total")
                .put("successProperty", "success")
                .put("messageProperty", "message")
                .put("idProperty", "rownum");
        json.put(JSON_METADATA, metadata);

        String error = null;
        if (appLayer == null) {
            error = "Invalid parameters.";
        } else if (unauthorized) {
            error = "Not authorized.";
        } else if (reportType == null) {
            error = "Report type is required.";
        } else {
            try {
                // test either regio / gemeente / terrein must not be null
                if (terrein != null) {
                    areaType = QueryArea.TERREIN;
                    gebiedsNaamQuery = TERREIN_FIELDNAME + "='" + terrein + "'";;
                } else if (gemeente != null) {
                    areaType = QueryArea.GEMEENTE;
                    gebiedsNaamQuery = GEMEENTE_FIELDNAME + "='" + gemeente + "'";
                } else if (regio != null) {
                    areaType = QueryArea.REGIO;
                    gebiedsNaamQuery = REGIO_FIELDNAME + "='" + regio + "'";
                } else {
                    throw new IllegalArgumentException("Geen gebied opgegeven voor rapport.");
                }

                switch (reportType) {
                    case ISSUE:
                        reportIssued(json);
                        break;
                    case INDIVIDUAL:
                        reportIndividualData(json);
                        break;
                    case AGGREGATED:
                        reportAggregateData(json);
                        break;
                }

                json.put("message", "OK");
                json.put("success", Boolean.TRUE);

            } catch (Exception e) {
                log.error("Error generating report data.", e);
                error = e.getLocalizedMessage();
            }
        }

        if (error != null) {
            json.put("success", Boolean.FALSE);
            json.put("message", error);
        }

        log.debug("returning json:" + json);
        return new StreamingResolution("application/json", new StringReader(json.toString()));
    }

    /**
     * Uitgifte report.
     *
     * @param json that get the data added
     * @throws Exception if any
     */
    private void reportIssued(JSONObject json) throws Exception {
        if (fromDate == null || toDate == null) {
            throw new IllegalArgumentException("Datum vanaf en datum tot zijn verplicht voor uitgifte.");
        }
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ");
        sdf.setTimeZone(TimeZone.getDefault());

        SimpleFeatureType ft = layer.getFeatureType();
        SimpleFeatureType relFt = this.getRelatedSFT(ft, RELATED_FT_NAME);
        SimpleFeatureSource fs = (SimpleFeatureSource) ft.openGeoToolsFeatureSource();
        SimpleFeatureSource foreignFs = (SimpleFeatureSource) relFt.openGeoToolsFeatureSource();

        Filter filter = ECQL.toFilter(this.gebiedsNaamQuery);
        List<String> tPropnames = Arrays.asList(
                TERREINID_FIELDNAME,
                TERREIN_FIELDNAME,
                GEMEENTE_FIELDNAME,
                REGIO_FIELDNAME);
        Query q = new Query(fs.getName().toString());
        q.setPropertyNames(tPropnames);
        q.setFilter(filter);
        q.setHandle("uitgifte-rapport");
        q.setMaxFeatures(FeatureToJson.MAX_FEATURES);

        try {
            // store terreinen in mem and get a list of the id's
            SimpleFeatureCollection inMem = DataUtilities.collection(fs.getFeatures(q).features());
            StringBuilder in = new StringBuilder();
            SimpleFeatureIterator inMemFeats = inMem.features();
            Set<String> terreinNames = new TreeSet<>(
                    new Comparator<String>() {
                        @Override
                        public int compare(String a, String b) {
                            return a.compareTo(b);
                        }
                    }
            );
            Set<String> regioNames = new TreeSet<>(new Comparator<String>() {
                @Override
                public int compare(String a, String b) {
                    return a.compareTo(b);
                }
            });
            Set<String> gemeenteNames = new TreeSet<>(new Comparator<String>() {
                @Override
                public int compare(String a, String b) {
                    return a.compareTo(b);
                }
            });

            while (inMemFeats.hasNext()) {
                SimpleFeature f = inMemFeats.next();
                in.append(f.getAttribute(TERREINID_FIELDNAME)).append(",");
                terreinNames.add((String) f.getAttribute(TERREIN_FIELDNAME));
                regioNames.add((String) f.getAttribute(REGIO_FIELDNAME));
                gemeenteNames.add((String) f.getAttribute(GEMEENTE_FIELDNAME));
            }
            inMemFeats.close();

            // get related features (terreinen)
            Query foreignQ = new Query(foreignFs.getName().toString());
            foreignQ.setHandle("uitgifte-rapport-related");
            List<String> propnames = Arrays.asList(
                    KAVELID_RELATED_FIELDNAME,
                    OPPERVLAKTE_GEOM_RELATED_FIELDNAME,
                    UITGIFTEDATUM_RELATED_FIELDNAME,
                    TERREIN_RELATED_FIELDNAME,
                    REGIO_RELATED_FIELDNAME,
                    GEMEENTE_RELATED_FIELDNAME);
            foreignQ.setPropertyNames(propnames);
            String query = STATUS_FIELDNAME + "= 'uitgegeven' AND "
                    + TERREINID_RELATED_FIELDNAME + " IN (" + in.substring(0, in.length() - 1) + ") AND "
                    + UITGIFTEDATUM_RELATED_FIELDNAME + " DURING " + sdf.format(fromDate) + "/" + sdf.format(toDate);
            log.debug("uitgifte query: " + query);
            foreignQ.setFilter(ECQL.toFilter(query));

            // kavels for selected terrein id's
            SimpleFeatureCollection sfc = DataUtilities.collection(foreignFs.getFeatures(foreignQ).features());

            // create new aggregate featuretype
            org.opengis.feature.simple.SimpleFeatureType type = DataUtilities.createType(
                    "AGGREGATE",
                    "id:String,*geom:MultiPolygon:28992,maand:String,oppervlakte:Double,gebied:String");

            // create flamingo attribute descriptors for AGGREGATE
            List<AttributeDescriptor> relFeatureTypeAttributes = new ArrayList<>();
            AttributeDescriptor maand = new AttributeDescriptor();
            maand.setName("maand");
            maand.setAlias("maand");
            maand.setType(AttributeDescriptor.TYPE_DATE);
            maand.setId(1L);
            relFeatureTypeAttributes.add(maand);

            AttributeDescriptor opp = new AttributeDescriptor();
            opp.setName("oppervlakte");
            opp.setAlias("oppervlakte");
            opp.setType(AttributeDescriptor.TYPE_DOUBLE);
            opp.setId(2L);
            relFeatureTypeAttributes.add(opp);

            AttributeDescriptor plan = new AttributeDescriptor();
            plan.setName(GEBIED_FIELDNAME);
            plan.setAlias("gebiedsnaam");
            plan.setType(AttributeDescriptor.TYPE_STRING);
            plan.setId(3L);
            relFeatureTypeAttributes.add(plan);

            switch (aggregationLevel) {
                case REGIO:
                    switch (aggregationLevelDate) {
                        case MONTH:
                            sfc = aggregateUitgifteByMonthAndArea(sfc, type, "oppervlakte",
                                    regioNames, REGIO_RELATED_FIELDNAME);
                            break;
                        case NONE:
                            sfc = aggregateUitgifteByArea(sfc, type, "oppervlakte",
                                    regioNames, REGIO_RELATED_FIELDNAME);
                            break;
                    }
                    break;
                case GEMEENTE:
                    switch (aggregationLevelDate) {
                        case MONTH:
                            sfc = aggregateUitgifteByMonthAndArea(sfc, type, "oppervlakte",
                                    gemeenteNames, GEMEENTE_RELATED_FIELDNAME);
                            break;
                        case NONE:
                            sfc = aggregateUitgifteByArea(sfc, type, "oppervlakte",
                                    gemeenteNames, GEMEENTE_RELATED_FIELDNAME);
                            break;
                    }
                    break;
                case TERREIN:
                    switch (aggregationLevelDate) {
                        case MONTH:
                            sfc = aggregateUitgifteByMonthAndArea(sfc, type, "oppervlakte",
                                    terreinNames, TERREIN_RELATED_FIELDNAME);
                            break;
                        case NONE:
                            sfc = aggregateUitgifteByArea(sfc, type, "oppervlakte",
                                    terreinNames, TERREIN_RELATED_FIELDNAME);
                            break;
                    }
                    break;
            }
            switch (aggregationLevelDate) {
                case MONTH:
                    propnames = Arrays.asList("maand", "oppervlakte", GEBIED_FIELDNAME);
                    break;
                case NONE:
                    propnames = Arrays.asList("oppervlakte", GEBIED_FIELDNAME);
            }
            featuresToJson(sfc, json, relFeatureTypeAttributes, propnames);
        } finally {
            foreignFs.getDataStore().dispose();
            fs.getDataStore().dispose();
        }
    }

    /**
     * aggregate features by date and area into new collection with named
     * features.
     *
     * @param sfc source of simple features to aggregate
     * @param type aggregate feature type
     * @param featNames names of the new features
     * @param gebiedFieldName field name of the aggregation bucket (eg. gemeente
     * or regio)
     * @return aggregated features
     */
    private SimpleFeatureCollection aggregateUitgifteByMonthAndArea(SimpleFeatureCollection sfc,
            org.opengis.feature.simple.SimpleFeatureType type, final String sfTypeAreaName,
            Set<String> featNames, final String gebiedFieldName) {

        final int months = differenceInMonths(fromDate, toDate);
        final SimpleDateFormat YYYYMM = new SimpleDateFormat("YYYY.MM");
        Map<String, SimpleFeature> newfeats = new TreeMap<>();

        // create a feature for each month for each 'gebiedFieldName' with 0 area and null geom
        for (String fName : featNames) {
            Date newDate = fromDate;
            for (int m = 0; m < months; m++) {
                String key = fName + YYYYMM.format(newDate);
                SimpleFeature month = DataUtilities.
                        createFeature(type, key + "|null|" + YYYYMM.format(newDate) + "|0d|" + fName);
                newfeats.put(key, month);
                newDate = addMonth(newDate);
            }
        }

        // for each month add up opp_geometrie
        SimpleFeatureIterator items = sfc.features();
        while (items.hasNext()) {
            SimpleFeature f = items.next();
            Date d = (Date) f.getAttribute(UITGIFTEDATUM_RELATED_FIELDNAME);
            SimpleFeature newFeat = newfeats.get(f.getAttribute(gebiedFieldName) + YYYYMM.format(d));
            newFeat.setAttribute(sfTypeAreaName,
                    ((Double) newFeat.getAttribute(sfTypeAreaName))
                    + ((BigDecimal) f.getAttribute(OPPERVLAKTE_GEOM_RELATED_FIELDNAME)).doubleValue());

        }
        items.close();

        ArrayList<SimpleFeature> feats = new ArrayList<>(newfeats.values());
        return DataUtilities.collection(feats);
    }

    /**
     * aggregate features area into new collection with named features.
     *
     * @param sfc source of simple features to aggregate
     * @param type aggregate featuretype
     * @param featNames names of the new features
     * @param gebiedFieldName field name of the aggregation bucket (eg. gemeente
     * or regio)
     * @return aggregated features
     */
    private SimpleFeatureCollection aggregateUitgifteByArea(SimpleFeatureCollection sfc,
            org.opengis.feature.simple.SimpleFeatureType type, String sfTypeAreaName,
            Set<String> featNames, final String gebiedFieldName) {

        // create a feature for each 'gebiedFieldName' with 0 area and null date and null geom
        Map<String, SimpleFeature> newfeats = new TreeMap<>();
        for (String fName : featNames) {
            SimpleFeature newfeat = DataUtilities.
                    createFeature(type, fName + "|null|null|0d|" + fName);
            newfeats.put(fName, newfeat);
        }

        // for each regio add up opp_geometrie
        SimpleFeatureIterator items = sfc.features();
        while (items.hasNext()) {
            SimpleFeature f = items.next();
            SimpleFeature newFeat = newfeats.get((String) f.getAttribute(gebiedFieldName));
            newFeat.setAttribute(sfTypeAreaName,
                    ((Double) newFeat.getAttribute(sfTypeAreaName))
                    + ((BigDecimal) f.getAttribute(OPPERVLAKTE_GEOM_RELATED_FIELDNAME)).doubleValue());

        }
        items.close();
        ArrayList<SimpleFeature> feats = new ArrayList<SimpleFeature>(newfeats.values());
        Collections.reverse(feats);
        return DataUtilities.collection(feats);
    }

    /**
     * Convert a SimpleFeatureCollection to JSON with metadata.
     *
     * @param sfc collections of features
     * @param json output/appendend to json structure
     * @param featureTypeAttributes flamingo attribute descriptors for the
     * features
     * @param outputPropNames fieldnames to put in output
     * @throws JSONException is any
     */
    private void featuresToJson(SimpleFeatureCollection sfc, JSONObject json,
            List<AttributeDescriptor> featureTypeAttributes, List<String> outputPropNames) throws JSONException {

        // metadata for fData fields
        JSONArray fields = new JSONArray();
        // columns for grid
        JSONArray columns = new JSONArray();
        // fData payload
        JSONArray datas = new JSONArray();

        SimpleFeatureIterator sfIter = sfc.features();

        boolean getMetadataFromFirstFeature = true;
        while (sfIter.hasNext()) {
            SimpleFeature feature = sfIter.next();
            JSONObject fData = new JSONObject();

            for (AttributeDescriptor attr : featureTypeAttributes) {
                String name = attr.getName();
                if (getMetadataFromFirstFeature) {
                    if (outputPropNames.contains(name)) {
                        // only load metadata into json this for first feature
                        JSONObject field = new JSONObject().put("name", name).put("type", attr.getExtJSType());
                        if (reportType == ReportType.ISSUE && attr.getType().equals(AttributeDescriptor.TYPE_DATE)) {
                            field.put("dateFormat", "Y-m");
                        }
                        fields.put(field);
                        columns.put(new JSONObject().put("text", (attr.getAlias() != null ? attr.getAlias() : name)).put("dataIndex", name));
                    }
                }
                fData.put(attr.getName(), feature.getAttribute(attr.getName()));
            }
            datas.put(fData);
            getMetadataFromFirstFeature = false;
        }

        json.getJSONObject(JSON_METADATA).put("fields", fields);
        json.getJSONObject(JSON_METADATA).put("columns", columns);
        json.put("data", datas);
        json.put("total", datas.length());

        sfIter.close();
    }

    /**
     * look up the named related featuretype.
     *
     * @param ft main/parent feature type
     * @param typeNameToGet the name of the related feature type or null
     * @return the named feature type or the first feature type in case
     * {@code typeNameToGet} is {
     * @null} or {
     * @null} when the/a related feature type does not exist
     */
    private SimpleFeatureType getRelatedSFT(SimpleFeatureType ft, String typeNameToGet) {
        SimpleFeatureType relFt = null;

        for (FeatureTypeRelation rel : ft.getRelations()) {
            if (rel.getType().equals(FeatureTypeRelation.RELATE)) {
                relFt = rel.getForeignFeatureType();
                if (relFt.getTypeName().equals(typeNameToGet) || typeNameToGet == null) {
                    break;
                }
            }
        }
        return relFt;
    }

    private void reportIndividualData(JSONObject json) throws Exception {
        List<String> tPropnames = new ArrayList(attrNames);

        SimpleFeatureType ft = layer.getFeatureType();
        List<AttributeDescriptor> featureTypeAttributes = ft.getAttributes();
        SimpleFeatureSource fs = (SimpleFeatureSource) ft.openGeoToolsFeatureSource();

        List<String> foreignAttrNames = new ArrayList<String>();
        // find out which attribute names -if any- are from related features
        for (String a : attrNames) {
            if (fs.getSchema().getDescriptor(a) == null) {
                // if not from fs it must be foreign
                foreignAttrNames.add(a);
                tPropnames.remove(a);
            }
        }
        foreignAttrNames.add(TERREINID_RELATED_FIELDNAME);
        tPropnames.add(TERREINID_FIELDNAME);
        Filter filter = ECQL.toFilter(this.gebiedsNaamQuery);
        Query q = new Query(fs.getName().toString());
        q.setPropertyNames(tPropnames);
        q.setFilter(filter);
        q.setHandle("individueel-rapport");
        q.setMaxFeatures(FeatureToJson.MAX_FEATURES);

        SimpleFeatureCollection mainFSinMem;
        try {
            mainFSinMem = DataUtilities.collection(fs.getFeatures(q).features());
        } finally {
            fs.getDataStore().dispose();
        }
        if (!foreignAttrNames.isEmpty()) {
            SimpleFeatureType relFt = this.getRelatedSFT(ft, RELATED_FT_NAME);
            SimpleFeatureSource foreignFs = (SimpleFeatureSource) relFt.openGeoToolsFeatureSource();
            // compose IN query criteria and store parent features in a map so we can easily get them later
            StringBuilder in = new StringBuilder();
            HashMap<Integer, SimpleFeature> parentFeatures = new HashMap<>();
            SimpleFeatureIterator inMemFeats = mainFSinMem.features();
            while (inMemFeats.hasNext()) {
                SimpleFeature f = inMemFeats.next();
                in.append(f.getAttribute(TERREINID_FIELDNAME)).append(",");
                parentFeatures.put((Integer) f.getAttribute(TERREINID_FIELDNAME), f);
            }
            inMemFeats.close();

            // get related features (children)
            Query foreignQ = new Query(foreignFs.getName().toString());
            foreignQ.setHandle("individueel-rapport-related");
            foreignQ.setPropertyNames(foreignAttrNames);
            String query = TERREINID_RELATED_FIELDNAME + " IN (" + in.substring(0, in.length() - 1) + ")";
            log.debug("individueel-rapport-related: " + query);
            foreignQ.setFilter(ECQL.toFilter(query));
            try {
                // kavels/children for selected terrein id's
                SimpleFeatureCollection relatedFC = foreignFs.getFeatures(foreignQ);
                // create a new aggregate feature type 'COMPOSITE' that has attributes of both parent and child types
                SimpleFeatureTypeBuilder tb = new SimpleFeatureTypeBuilder();
                tb.setName("COMPOSITE");
                for (String prop : attrNames) {
                    org.opengis.feature.type.AttributeDescriptor attrDescName = fs.getSchema().getDescriptor(prop);
                    if (attrDescName == null) {
                        // if not from the parent it must be from the child
                        attrDescName = foreignFs.getSchema().getDescriptor(prop);
                    }
                    tb.add(attrDescName);
                }
                tb.add(TERREINID_RELATED_FIELDNAME, Integer.class);
                org.opengis.feature.simple.SimpleFeatureType type = tb.buildFeatureType();
                //  merge main & related child flamingo attribute descriptors for COMPOSITE,
                featureTypeAttributes.addAll(relFt.getAttributes());

                SimpleFeatureBuilder sfBuilder = new SimpleFeatureBuilder(type);
                SimpleFeatureIterator sfcIter = relatedFC.features();
                ArrayList<SimpleFeature> newfeats = new ArrayList<>();
                while (sfcIter.hasNext()) {
                    // create as many new features as children
                    SimpleFeature f = sfcIter.next();
                    SimpleFeature n = SimpleFeatureBuilder.retype(f, sfBuilder);
                    // copy main data to related children(n)
                    SimpleFeature p = parentFeatures.get((Integer) f.getAttribute(TERREINID_RELATED_FIELDNAME));
                    for (Property a : p.getProperties()) {
                        if (attrNames.contains(a.getName().toString())) {
                            n.setAttribute(a.getName(), a.getValue());
                        }
                    }
                    newfeats.add(n);
                }
                sfcIter.close();
                mainFSinMem = DataUtilities.collection(newfeats);
            } finally {
                foreignFs.getDataStore().dispose();
            }
        }
        featuresToJson(mainFSinMem, json, featureTypeAttributes, attrNames);
    }

    private void reportAggregateData(JSONObject json) throws Exception {
        SimpleFeatureType ft = layer.getFeatureType();
        List<AttributeDescriptor> featureTypeAttributes = ft.getAttributes();
        SimpleFeatureSource fs = (SimpleFeatureSource) ft.openGeoToolsFeatureSource();

        List<String> tPropnames = new ArrayList(attrNames);
        List<String> foreignAttrNames = new ArrayList<>();
        // find out which attribute names -if any- are from related features
        for (String a : attrNames) {
            if (fs.getSchema().getDescriptor(a) == null) {
                // if not from fs it must be foreign
                foreignAttrNames.add(a);
                tPropnames.remove(a);
            }
        }
        foreignAttrNames.add(TERREINID_RELATED_FIELDNAME);
        tPropnames.add(TERREINID_FIELDNAME);
        tPropnames.add(GEMEENTE_FIELDNAME);
        tPropnames.add(REGIO_FIELDNAME);
        tPropnames.add(TERREIN_FIELDNAME);

        Filter filter = ECQL.toFilter(this.gebiedsNaamQuery);
        Query q = new Query(fs.getName().toString());
        q.setPropertyNames(tPropnames);
        q.setFilter(filter);
        q.setHandle("aggregatie-rapport");
        q.setMaxFeatures(FeatureToJson.MAX_FEATURES);
        log.debug("aggregatie query:" + q);

        try {
            // create the new aggregate featuretype
            org.opengis.feature.simple.SimpleFeatureType type;
            SimpleFeatureTypeBuilder tb = new SimpleFeatureTypeBuilder();
            tb.setName("AGGREGATE");
            for (String prop : tPropnames) {
                org.opengis.feature.type.AttributeDescriptor attrDescName = fs.getSchema().getDescriptor(prop);
                if (attrDescName != null) {
                    tb.add(attrDescName);
                }
            }
            tb.add(GEBIED_FIELDNAME, String.class);
            // update flamingo attribute descriptors for AGGREGATE
            AttributeDescriptor g = new AttributeDescriptor();
            g.setName(GEBIED_FIELDNAME);
            g.setAlias("gebiedsnaam");
            g.setType(AttributeDescriptor.TYPE_STRING);
            featureTypeAttributes.add(0, g);
            // get parent features
            SimpleFeatureCollection sfc = DataUtilities.collection(fs.getFeatures(q).features());

            if (!foreignAttrNames.isEmpty()) {
                SimpleFeatureType relFt = this.getRelatedSFT(ft, RELATED_FT_NAME);
                SimpleFeatureSource foreignFs = (SimpleFeatureSource) relFt.openGeoToolsFeatureSource();

                // add related attributes to type
                for (String prop : foreignAttrNames) {
                    org.opengis.feature.type.AttributeDescriptor attrDescName = foreignFs.getSchema().getDescriptor(prop);
                    if (attrDescName != null) {
                        tb.add(attrDescName);
                    }
                }
                tb.add(TERREINID_RELATED_FIELDNAME, Integer.class);
                type = tb.buildFeatureType();

                //  merge main & related child flamingo attribute descriptors for AGGREGATE,
                featureTypeAttributes.addAll(relFt.getAttributes());

                // compose IN query criteria and store parent features in a map so we can easily get them later
                StringBuilder in = new StringBuilder();
                HashMap<Integer, SimpleFeature> parentFeatures = new HashMap<>();
                SimpleFeatureIterator inMemFeats = sfc.features();
                while (inMemFeats.hasNext()) {
                    SimpleFeature f = inMemFeats.next();
                    in.append(f.getAttribute(TERREINID_FIELDNAME)).append(",");
                    parentFeatures.put((Integer) f.getAttribute(TERREINID_FIELDNAME), f);
                }
                inMemFeats.close();

                // get related features (children)
                Query foreignQ = new Query(foreignFs.getName().toString());
                foreignQ.setHandle("aggregatie-rapport-related");
                foreignQ.setPropertyNames(foreignAttrNames);
                String query = TERREINID_RELATED_FIELDNAME + " IN (" + in.substring(0, in.length() - 1) + ")";
                log.debug("aggregatie-rapport-related: " + query);
                foreignQ.setFilter(ECQL.toFilter(query));

                try {
                    // kavels/children for selected terrein id's
                    SimpleFeatureCollection relatedFC = foreignFs.getFeatures(foreignQ);
                    SimpleFeatureBuilder sfBuilder = new SimpleFeatureBuilder(type);
                    SimpleFeatureIterator sfcIter = relatedFC.features();
                    ArrayList<SimpleFeature> newfeats = new ArrayList<>();

                    boolean firsttimeForP = true;
                    Set<SimpleFeature> firsttimeForPSet = new HashSet<>();
                    while (sfcIter.hasNext()) {
                        // create as many new features as related/children
                        SimpleFeature f = sfcIter.next();
                        SimpleFeature n = SimpleFeatureBuilder.retype(f, sfBuilder);

                        // copy main data to related children
                        //   but payload field only once to prevent aggregating those values later
                        SimpleFeature p = parentFeatures.get((Integer) f.getAttribute(TERREINID_RELATED_FIELDNAME));
                        for (Property a : p.getProperties()) {
                            if (firsttimeForPSet.contains(p)) {
                                if (!attrNames.contains(a.getName().toString())) {
                                    n.setAttribute(a.getName(), a.getValue());
                                }
                            } else {
                                if (tPropnames.contains(a.getName().toString())) {
                                    n.setAttribute(a.getName(), a.getValue());
                                }
                            }
                        }
                        newfeats.add(n);
                        firsttimeForPSet.add(p);
                    }
                    sfcIter.close();
                    sfc = DataUtilities.collection(newfeats);
                } finally {
                    foreignFs.getDataStore().dispose();
                }
            } else {
                type = tb.buildFeatureType();
            }

            // aggregation
            Set<String> regions = new HashSet<>();
            switch (aggregationLevel) {
                case REGIO:
                    // max number for regio is 1
                    regions.add(regio);
                    sfc = aggregateFields(sfc, type, regions, attrNames, REGIO_FIELDNAME);
                    break;
                case GEMEENTE:
                    if (this.areaType == QueryArea.TERREIN) {
                        regions.add(gemeente);
                    } else {
                        // create set of all gemeente from query result
                        SimpleFeatureIterator iter = sfc.features();
                        try {
                            while (iter.hasNext()) {
                                SimpleFeature f = iter.next();
                                regions.add((String) f.getAttribute(GEMEENTE_FIELDNAME));
                            }
                        } finally {
                            iter.close();
                        }
                    }
                    sfc = aggregateFields(sfc, type, regions, attrNames, GEMEENTE_FIELDNAME);
                    break;
                case TERREIN:
                    if (this.areaType == QueryArea.TERREIN) {
                        regions.add(terrein);
                    } else {
                        // create set of all terrein from query result
                        SimpleFeatureIterator iter = sfc.features();
                        try {
                            while (iter.hasNext()) {
                                SimpleFeature f = iter.next();
                                regions.add((String) f.getAttribute(TERREIN_FIELDNAME));
                            }
                        } finally {
                            iter.close();
                        }
                    }
                    sfc = aggregateFields(sfc, type, regions, attrNames, TERREIN_FIELDNAME);
                    break;
            }

            attrNames.add(GEBIED_FIELDNAME);
            featuresToJson(sfc, json, featureTypeAttributes, attrNames);
        } finally {
            fs.getDataStore().dispose();
        }

    }

    /**
     * Aggregate the values of the given feature collection into new features of
     * {@code type} that are named.
     *
     * @param sfc source of simple features to aggregate
     * @param type aggregate feature type
     * @param featNames names of the new features
     * @param gebiedFieldName field name of the aggregation bucket (eg. gemeente
     * or regio)
     * @param aggregateFieldNames
     * @param gebiedFieldName
     * @return aggregated features
     *
     */
    private SimpleFeatureCollection aggregateFields(
            SimpleFeatureCollection sfc,
            org.opengis.feature.simple.SimpleFeatureType type,
            Set<String> featNames,
            List<String> aggregateFieldNames,
            String gebiedFieldName) {

        // create a feature for each (regio|gemeente|terrein) name with 0 value
        Map<String, SimpleFeature> newfeats = new TreeMap<>();
        for (String fName : featNames) {
            SimpleFeature newfeat = DataUtilities.template(type);
            for (String aggrName : aggregateFieldNames) {
                newfeat.setAttribute(aggrName, 0);
            }
            newfeat.setAttribute(GEBIED_FIELDNAME, fName);
            newfeats.put(fName, newfeat);
        }

        // for each (regio|gemeente|terrein) name in relatedFC get the attribute to aggregate
        //  and add up in new named feature
        SimpleFeatureIterator items = sfc.features();
        while (items.hasNext()) {
            SimpleFeature f = items.next();
            SimpleFeature newFeat = newfeats.get((String) f.getAttribute(gebiedFieldName));

            for (String aggrFieldName : aggregateFieldNames) {
                if (f.getAttribute(aggrFieldName) != null) {
                    // add up if not null
                    newFeat.setAttribute(aggrFieldName,
                            ((Number) newFeat.getAttribute(aggrFieldName)).doubleValue()
                            + ((Number) f.getAttribute(aggrFieldName)).doubleValue()
                    );
                }
            }
        }
        items.close();
        ArrayList<SimpleFeature> feats = new ArrayList<>(newfeats.values());
        return DataUtilities.collection(feats);
    }

    //<editor-fold defaultstate="collapsed" desc="getters en setters">
    public String getData() {
        return data;
    }

    public void setData(String data) {
        this.data = data;
    }

    public String getFilename() {
        return filename;
    }

    public void setFilename(String filename) {
        this.filename = filename;
    }

    public String getMimetype() {
        return mimetype;
    }

    public void setMimetype(String mimetype) {
        this.mimetype = mimetype;
    }

    @Override
    public void setContext(ActionBeanContext context) {
        this.context = context;
    }

    @Override
    public ActionBeanContext getContext() {
        return context;
    }

    public Application getApplication() {
        return application;
    }

    public void setApplication(Application application) {
        this.application = application;
    }

    public ApplicationLayer getAppLayer() {
        return appLayer;
    }

    public void setAppLayer(ApplicationLayer appLayer) {
        this.appLayer = appLayer;
    }

    public String getRegio() {
        return regio;
    }

    public void setRegio(String regio) {
        this.regio = regio;
    }

    public String getGemeente() {
        return gemeente;
    }

    public void setGemeente(String gemeente) {
        this.gemeente = gemeente;
    }

    public String getTerrein() {
        return terrein;
    }

    public void setTerrein(String terrein) {
        this.terrein = terrein;
    }

    public ReportType getReportType() {
        return reportType;
    }

    public void setReportType(ReportType reportType) {
        this.reportType = reportType;
    }

    public QueryArea getAggregationLevel() {
        return aggregationLevel;
    }

    public void setAggregationLevel(QueryArea aggregationLevel) {
        this.aggregationLevel = aggregationLevel;
    }

    public AggregationLevelDate getAggregationLevelDate() {
        return aggregationLevelDate;
    }

    public void setAggregationLevelDate(AggregationLevelDate aggregationLevelDate) {
        this.aggregationLevelDate = aggregationLevelDate;
    }

    public Date getFromDate() {
        return fromDate;
    }

    public void setFromDate(Date fromDate) {
        this.fromDate = fromDate;
    }

    public Date getToDate() {
        return toDate;
    }

    public void setToDate(Date toDate) {
        this.toDate = toDate;
    }

    public List<String> getAttrNames() {
        return attrNames;
    }

    public void setAttrNames(List<String> attrNames) {
        this.attrNames = attrNames;
    }

    //</editor-fold>
}