changeset 5399:47c2ca05e8ec

Merged extented-report branch back into default.
author Sascha L. Teichmann <sascha.teichmann@intevation.de>
date Wed, 07 Jul 2021 11:44:40 +0200
parents 4a6feb5d3727 (current diff) dcc692a333c0 (diff)
children 983d6efc04e9 bb402cdfe545
files
diffstat 45 files changed, 3028 insertions(+), 150 deletions(-) [+]
line wrap: on
line diff
--- a/client/.env	Sun Jul 04 11:37:37 2021 +0200
+++ b/client/.env	Wed Jul 07 11:44:40 2021 +0200
@@ -15,4 +15,4 @@
 VUE_APP_SILENCE_TRANSLATIONWARNINGS =
 
 #Url of user manual
-VUE_APP_USER_MANUAL_URL=
+VUE_APP_USER_MANUAL_URL=
\ No newline at end of file
--- a/client/src/components/App.vue	Sun Jul 04 11:37:37 2021 +0200
+++ b/client/src/components/App.vue	Wed Jul 07 11:44:40 2021 +0200
@@ -1,5 +1,5 @@
 <template>
-  <div id="app" class="main">
+  <div id="app" class="main" style="overflow-x:scroll">
     <div v-if="isAuthenticated" class="d-flex flex-column userinterface">
       <div class="boxes d-flex p-2">
         <div class="mr-auto d-flex">
--- a/client/src/components/Sidebar.vue	Sun Jul 04 11:37:37 2021 +0200
+++ b/client/src/components/Sidebar.vue	Wed Jul 07 11:44:40 2021 +0200
@@ -148,6 +148,17 @@
     }
   },
   mounted() {
+    this.$store.dispatch("importschedule/loadAvailableReports").catch(error => {
+      let message = "Backend not reachable";
+      if (error.response) {
+        const { status, data } = error.response;
+        message = `${status}: ${data.message || data}`;
+      }
+      displayError({
+        title: this.$gettext("Backend Error"),
+        message: message
+      });
+    });
     const updateIndicators = () => {
       if (this.isWaterwayAdmin) {
         this.$store
--- a/client/src/components/identify/Identify.vue	Sun Jul 04 11:37:37 2021 +0200
+++ b/client/src/components/identify/Identify.vue	Wed Jul 07 11:44:40 2021 +0200
@@ -181,16 +181,40 @@
         </div>
       </div>
       <div
-        v-if="userManualUrl"
+        v-if="hasDownloads"
         class="border-top text-left pl-2"
         style="font-size: 90%;"
       >
         <translate>Download</translate>
-        <a
-          :href="userManualUrl ? userManualUrl : '#'"
-          :download="usermanualFilename"
-          ><translate> User Manual</translate></a
-        >
+        <div class="d-flex flex-column">
+          <font-awesome-icon
+            v-if="loadingDQL"
+            icon="spinner"
+            :spin="true"
+            fixed-width
+          />
+          <template v-if="DQLDownloadAllowed">
+            <a
+              v-for="(reportName, index) in availableReports"
+              :key="index"
+              href="#"
+              @click="downloadDataQualityReport(reportName)"
+            >
+              {{
+                reportName
+                  .split("-")
+                  .map(s => (s && s[0].toUpperCase() + s.slice(1)) || "")
+                  .join(" ")
+              }}
+            </a>
+          </template>
+          <a
+            v-if="userManualUrl"
+            :href="userManualUrl ? userManualUrl : '#'"
+            :download="usermanualFilename"
+            ><translate> User Manual</translate></a
+          >
+        </div>
       </div>
       <div class="versioninfo border-top box-body">
         <span v-translate="{ license: 'AGPL-3.0-or-later' }">
@@ -272,6 +296,9 @@
 import classifications from "@/lib/classifications";
 import { styleFactory } from "@/components/layers/styles";
 import filters from "@/lib/filters";
+import { HTTP } from "@/lib/http";
+import { format } from "date-fns";
+import { displayError } from "@/lib/errors";
 
 const {
   recencyColorCodes,
@@ -284,6 +311,7 @@
   name: "identify",
   data() {
     return {
+      loadingDQL: false,
       refGaugeStatus: "",
       gaugeStatus: "",
       gaugeCoeffs: null,
@@ -296,9 +324,18 @@
     ...mapGetters("map", ["filteredIdentifiedFeatures"]),
     ...mapState("map", ["currentMeasurement"]),
     ...mapState("gauges", ["gauges"]),
+    ...mapGetters("user", ["isWaterwayAdmin", "isSysAdmin"]),
+    ...mapState("importschedule", ["availableReports"]),
+    DQLDownloadAllowed() {
+      if (this.loadingDQL) return false;
+      return this.isWaterwayAdmin || this.isSysAdmin;
+    },
     identifiedLabel() {
       return this.$gettext("Identified Features");
     },
+    hasDownloads() {
+      return this.DQLDownloadAllowed || this.userManualUrl;
+    },
     usermanualFilename() {
       return this.$gettext("User Manual");
     },
@@ -401,6 +438,38 @@
     }
   },
   methods: {
+    downloadDataQualityReport(reportName) {
+      this.loadingDQL = true;
+      HTTP.get(`/data/report/${reportName}`, {
+        responseType: "blob",
+        headers: {
+          "X-Gemma-Auth": localStorage.getItem("token")
+        }
+      })
+        .then(response => {
+          const link = document.createElement("a");
+          const now = new Date();
+          link.href = window.URL.createObjectURL(new Blob([response.data]));
+          link.download = `DataQualityReport-${format(now, "YYYY-MM-DD")}.xlsx`;
+          document.body.appendChild(link);
+          link.click();
+          document.body.removeChild(link);
+        })
+        .catch(error => {
+          let message = "Backend not reachable";
+          if (error.response) {
+            const { status, data } = error.response;
+            message = `${status}: ${data.message || data}`;
+          }
+          displayError({
+            title: this.$gettext("Backend Error"),
+            message: message
+          });
+        })
+        .finally(() => {
+          this.loadingDQL = false;
+        });
+    },
     getGaugeStatusText(feature) {
       if (/bottleneck/.test(feature.getId())) return this.refGaugeStatusText;
       return this.gaugeStatusText;
--- a/client/src/components/importconfiguration/ImportDetails.vue	Sun Jul 04 11:37:37 2021 +0200
+++ b/client/src/components/importconfiguration/ImportDetails.vue	Wed Jul 07 11:44:40 2021 +0200
@@ -53,6 +53,14 @@
               <translate>Fairwaymarks</translate>
             </option>
           </optgroup>
+          <optgroup :label="reportslabel" v-if="isSysAdmin">
+            <option :value="$options.IMPORTTYPES.REPORT">
+              <translate>Data Quality Report</translate>
+            </option>
+            <option :value="$options.IMPORTTYPES.STATSUPDATE">
+              <translate>Update Stats</translate>
+            </option>
+          </optgroup>
         </select>
       </div>
       <ApprovedGaugeMeasurement
@@ -98,7 +106,7 @@
  * Tom Gottfried <tom.gottfried@intevation.de>
  */
 import { IMPORTTYPES } from "@/store/importschedule";
-import { mapState } from "vuex";
+import { mapState, mapGetters } from "vuex";
 export default {
   components: {
     ApprovedGaugeMeasurement: () => import("./types/ApprovedGaugeMeasurement"),
@@ -111,6 +119,7 @@
   },
   computed: {
     ...mapState("importschedule", ["currentSchedule"]),
+    ...mapGetters("user", ["isSysAdmin"]),
     isOnetime() {
       for (let kind of [
         this.$options.IMPORTTYPES.SOUNDINGRESULTS,
@@ -129,6 +138,9 @@
         this.$store.commit("importschedule/setImportType", value);
       }
     },
+    reportslabel() {
+      return this.$gettext("Reports");
+    },
     onetimeLabel() {
       return this.$gettext("Onetime Imports");
     },
--- a/client/src/components/importconfiguration/ScheduledImports.vue	Sun Jul 04 11:37:37 2021 +0200
+++ b/client/src/components/importconfiguration/ScheduledImports.vue	Wed Jul 07 11:44:40 2021 +0200
@@ -80,6 +80,12 @@
       :featureType="featureType"
       :sortBy="sortBy"
     />
+    <DQLReport
+      v-if="import_ == $options.IMPORTTYPES.REPORT"
+      @reportNameChanged="setReportName"
+      :reportName="reportName"
+      :availableReports="availableReports"
+    />
     <Faiwaydimensions
       v-if="import_ == $options.IMPORTTYPES.FAIRWAYDIMENSION"
       @urlChanged="setUrl"
@@ -116,6 +122,11 @@
       @urlChanged="setUrl"
       :url="url"
     />
+    <Statsupdate
+      v-if="import_ == $options.IMPORTTYPES.STATSUPDATE && !directImport"
+      @statsUpdateChanged="setStatsUpdate"
+      :statsUpdate="statsUpdate"
+    />
     <Waterwayarea
       v-if="import_ == $options.IMPORTTYPES.WATERWAYAREA"
       @urlChanged="setUrl"
@@ -498,9 +509,11 @@
     Bottleneck: () => import("./types/Bottleneck"),
     Distancemarksvirtual: () => import("./types/Distancemarksvirtual"),
     Distancemarksashore: () => import("./types/Distancemarksashore"),
+    DQLReport: () => import("./types/DQLReport"),
     Faiwaydimensions: () => import("./types/Fairwaydimensions"),
     Fairwaymarks: () => import("./types/Fairwaymarks"),
     Gaugemeasurement: () => import("./types/Gaugemeasurement"),
+    Statsupdate: () => import("./types/Statsupdate"),
     Waterwayarea: () => import("./types/Waterwayarea"),
     Waterwaygauges: () => import("./types/Waterwaygauges"),
     Waterwayaxis: () => import("./types/Waterwayaxis")
@@ -557,6 +570,7 @@
   },
   computed: {
     ...mapState("importschedule", [
+      "availableReports",
       "importScheduleDetailVisible",
       "currentSchedule"
     ]),
@@ -693,6 +707,12 @@
       this.uploadLabel = files[0].name;
       this.uploadFile = files[0];
     },
+    setReportName(value) {
+      this.reportName = value;
+    },
+    setStatsUpdate(value) {
+      this.statsUpdate = value;
+    },
     setUrl(value) {
       this.url = value;
     },
@@ -783,6 +803,8 @@
       this.trys = this.currentSchedule.trys;
       this.waitRetry = this.currentSchedule.waitRetry;
       this.selectedMark = this.currentSchedule.selectedMark;
+      this.statsUpdate = this.currentSchedule.statsUpdate;
+      this.reportName = this.currentSchedule.reportName;
       this.retry =
         this.currentSchedule.trys === null ||
         this.currentSchedule.trys === undefined ||
@@ -912,6 +934,14 @@
       }
       if (this.waitRetry) data["wait-retry"] = this.waitRetry;
       if (this.trys) data["trys"] = Number(this.trys);
+      if (this.import_ === this.$options.IMPORTTYPES.STATSUPDATE) {
+        if (!this.statsUpdate) return;
+        data["name"] = this.statsUpdate;
+      }
+      if (this.import_ === this.$options.IMPORTTYPES.REPORT) {
+        if (!this.reportName) return;
+        data["name"] = this.reportName;
+      }
       data["send-email"] = this.eMailNotification;
       this.triggerActive = false;
       const type =
@@ -1003,6 +1033,14 @@
       }
       if (this.waitRetry) config["wait-retry"] = this.waitRetry;
       if (this.trys) config["trys"] = Number(this.trys);
+      if (this.import_ === this.$options.IMPORTTYPES.REPORT) {
+        if (!this.reportName) return;
+        config["name"] = this.reportName;
+      }
+      if (this.import_ === this.$options.IMPORTTYPES.STATSUPDATE) {
+        if (!this.statsUpdate) return;
+        config["name"] = this.statsUpdate;
+      }
       config["send-email"] = this.eMailNotification;
       if (!this.id) {
         data["config"] = config;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/importconfiguration/types/DQLReport.vue	Wed Jul 07 11:44:40 2021 +0200
@@ -0,0 +1,84 @@
+<template>
+  <div>
+    <div class="d-flex px-2">
+      <div class="flex-column w-100">
+        <div class="flex-row text-left">
+          <small class="text-muted">
+            <translate>DQL Report</translate>
+          </small>
+        </div>
+        <div class="w-50">
+          <select
+            v-model="selectedReport"
+            class="ml-1 mr-1 form-control form-control-sm"
+          >
+            <option value="" v-if="this.availableReports.length === 0"
+              ><translate>No data selectable</translate></option
+            >
+            <option
+              v-for="(option, index) in this.availableReports"
+              :key="index"
+              :value="option"
+              >{{ option }}</option
+            >
+          </select>
+        </div>
+      </div>
+    </div>
+    <div v-if="!selectedReport" class="d-flex px-2">
+      <small
+        ><translate class="text-danger"
+          >Please select a report to update</translate
+        ></small
+      >
+    </div>
+  </div>
+</template>
+
+<script>
+/* This is Free Software under GNU Affero General Public License v >= 3.0
+ * without warranty, see README.md and license for details.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ * License-Filename: LICENSES/AGPL-3.0.txt
+ *
+ * Copyright (C) 2018 by via donau
+ *   – Österreichische Wasserstraßen-Gesellschaft mbH
+ * Software engineering by Intevation GmbH
+ *
+ * Author(s):
+ * Thomas Junk <thomas.junk@intevation.de>
+ */
+import { displayError } from "@/lib/errors";
+
+export default {
+  name: "reports",
+  props: ["reportName", "availableReports"],
+  mounted() {
+    this.$store.dispatch("importschedule/loadAvailableReports").catch(error => {
+      let message = "Backend not reachable";
+      if (error.response) {
+        const { status, data } = error.response;
+        message = `${status}: ${data.message || data}`;
+      }
+      displayError({
+        title: this.$gettext("Backend Error"),
+        message: message
+      });
+    });
+  },
+  computed: {
+    selectedReport: {
+      get() {
+        return this.reportName;
+      },
+      set(value) {
+        this.selected = value;
+        this.$emit("reportNameChanged", value);
+      }
+    }
+  }
+};
+</script>
+
+<style></style>
--- a/client/src/components/importconfiguration/types/Soundingresults.vue	Sun Jul 04 11:37:37 2021 +0200
+++ b/client/src/components/importconfiguration/types/Soundingresults.vue	Wed Jul 07 11:44:40 2021 +0200
@@ -469,15 +469,18 @@
     },
     depthReferenceOptions() {
       if (this.bottleneck) {
-        const bnProperties =this.bottleneck.properties;
-        const referenceLevels = JSON.parse(
-          bnProperties.reference_water_levels
-        )||{};
+        const bnProperties = this.bottleneck.properties;
+        const referenceLevels =
+          JSON.parse(bnProperties.reference_water_levels) || {};
         const result = Object.keys(referenceLevels);
-        const bottleneckBGorRO = bnProperties.responsible_country=="BG" || bnProperties.responsible_country=="RO";
+        const bottleneckBGorRO =
+          bnProperties.responsible_country == "BG" ||
+          bnProperties.responsible_country == "RO";
         const hasLDC = referenceLevels.hasOwnProperty("LDC");
         const hasZPG = referenceLevels.hasOwnProperty("ZPG");
-        if (((hasLDC  && !hasZPG) || (!hasLDC && !hasZPG && bottleneckBGorRO)) ) result.push("ZPG");
+        if ((hasLDC && !hasZPG) || (!hasLDC && !hasZPG && bottleneckBGorRO)) {
+          result.push("ZPG");
+        }
         return result;
       }
       return [];
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/importconfiguration/types/Statsupdate.vue	Wed Jul 07 11:44:40 2021 +0200
@@ -0,0 +1,98 @@
+<template>
+  <div>
+    <div class="d-flex px-2">
+      <div class="flex-column w-100">
+        <div class="flex-row text-left">
+          <small class="text-muted">
+            <translate>Stats Update</translate>
+          </small>
+        </div>
+        <div class="w-50">
+          <select
+            v-model="selectedStatsUpdate"
+            class="ml-1 mr-1 form-control form-control-sm"
+          >
+            <option value="" v-if="this.statsUpdates.length === 0"
+              ><translate>No data selectable</translate></option
+            >
+            <option
+              v-for="(option, index) in this.statsUpdates"
+              :key="index"
+              :value="option"
+              >{{ option }}</option
+            >
+          </select>
+        </div>
+      </div>
+    </div>
+    <div v-if="!statsUpdate" class="d-flex px-2">
+      <small
+        ><translate class="text-danger"
+          >Please select stats to update</translate
+        ></small
+      >
+    </div>
+  </div>
+</template>
+
+<script>
+/* This is Free Software under GNU Affero General Public License v >= 3.0
+ * without warranty, see README.md and license for details.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ * License-Filename: LICENSES/AGPL-3.0.txt
+ *
+ * Copyright (C) 2018 by via donau
+ *   – Österreichische Wasserstraßen-Gesellschaft mbH
+ * Software engineering by Intevation GmbH
+ *
+ * Author(s):
+ * Thomas Junk <thomas.junk@intevation.de>
+ */
+import { HTTP } from "@/lib/http";
+import { displayError } from "@/lib/errors";
+
+export default {
+  name: "statsupdate",
+  props: ["statsUpdate"],
+  data() {
+    return {
+      statsUpdates: []
+    };
+  },
+  mounted() {
+    HTTP.get("/data/stats-updates", {
+      headers: {
+        "X-Gemma-Auth": localStorage.getItem("token")
+      }
+    })
+      .then(response => {
+        this.statsUpdates = response.data["stats-updates"];
+      })
+      .catch(error => {
+        let message = "Backend not reachable";
+        if (error.response) {
+          const { status, data } = error.response;
+          message = `${status}: ${data.message || data}`;
+        }
+        displayError({
+          title: this.$gettext("Backend Error"),
+          message: message
+        });
+      });
+  },
+  computed: {
+    selectedStatsUpdate: {
+      get() {
+        return this.statsUpdate;
+      },
+      set(value) {
+        this.selected = value;
+        this.$emit("statsUpdateChanged", value);
+      }
+    }
+  }
+};
+</script>
+
+<style></style>
--- a/client/src/components/importconfiguration/types/WaterwayProfiles.vue	Sun Jul 04 11:37:37 2021 +0200
+++ b/client/src/components/importconfiguration/types/WaterwayProfiles.vue	Wed Jul 07 11:44:40 2021 +0200
@@ -159,8 +159,7 @@
       this.uploadFile = files[0];
     },
     submit() {
-      if (!this.url || !this.featureType || !this.uploadFile)
-        return;
+      if (!this.url || !this.featureType || !this.uploadFile) return;
       let formData = new FormData();
       formData.append("wp", this.uploadFile);
       formData.append("url", this.url);
--- a/client/src/components/usermanagement/Userdetail.vue	Sun Jul 04 11:37:37 2021 +0200
+++ b/client/src/components/usermanagement/Userdetail.vue	Wed Jul 07 11:44:40 2021 +0200
@@ -109,6 +109,21 @@
               :passworderrors="errors.passwordre"
             />
           </div>
+          <div class="form-group row">
+            <label for="user">
+              <translate>Recipient for DQL Report </translate>
+            </label>
+            <toggle-button
+              :value="currentUser.reports"
+              v-model="currentUser.reports"
+              class="pt-1 w-100"
+              :sync="true"
+              :speed="100"
+              v-tooltip="receivesReportLabel"
+              :width="40"
+              :height="20"
+            />
+          </div>
         </div>
         <div>
           <button
@@ -142,8 +157,10 @@
 }
 
 .userdetails {
+  min-width: 400px;
   max-height: 693px;
   margin-right: $offset;
+  overflow-y: auto;
 }
 
 form {
@@ -205,6 +222,7 @@
   components: {
     PasswordField: () => import("./Passwordfield")
   },
+  props: ["reportToggled"],
   data() {
     return {
       passwordLabel: this.$gettext("Password"),
@@ -230,6 +248,9 @@
     this.path = this.user.name;
   },
   watch: {
+    reportToggled() {
+      this.currentUser.reports = this.user.reports;
+    },
     user() {
       this.currentUser = { ...this.user };
       this.path = this.user.name;
@@ -238,6 +259,9 @@
     }
   },
   computed: {
+    receivesReportLabel() {
+      return this.$gettext("User receives Data Quality Report");
+    },
     cardHeader() {
       if (this.currentUser.isNew) return this.$gettext("Add User");
       return this.currentUser.user;
--- a/client/src/components/usermanagement/Usermanagement.vue	Sun Jul 04 11:37:37 2021 +0200
+++ b/client/src/components/usermanagement/Usermanagement.vue	Wed Jul 07 11:44:40 2021 +0200
@@ -9,8 +9,9 @@
             :columns="[
               { id: 'role', title: `${roleForColumLabel}`, class: 'col-1' },
               { id: 'user', title: `${usernameLabel}`, class: 'col-4' },
-              { id: 'country', title: `${countryLabel}`, class: 'col-2' },
-              { id: 'email', title: `${emailLabel}`, class: 'col-3' }
+              { id: 'country', title: `${countryLabel}`, class: 'col-1' },
+              { id: 'email', title: `${emailLabel}`, class: 'col-3' },
+              { id: 'reports', title: `${reportsLabel}`, class: 'col-1' }
             ]"
           />
           <UITableBody
@@ -33,7 +34,7 @@
                 {{ user.user }}
               </div>
               <div
-                class="table-cell center col-2"
+                class="table-cell center col-1"
                 @click="selectUser(user.user)"
               >
                 {{ user.country }}
@@ -41,6 +42,19 @@
               <div class="table-cell col-3" @click="selectUser(user.user)">
                 {{ user.email }}
               </div>
+              <div class="table-cell center col-1">
+                <toggle-button
+                  :value="user.reports"
+                  v-model="user.reports"
+                  class="pt-1"
+                  :sync="true"
+                  :speed="100"
+                  @change="toggleReport(user)"
+                  v-tooltip="receivesReportLabel"
+                  :width="40"
+                  :height="20"
+                />
+              </div>
               <div class="table-cell col text-right justify-content-end">
                 <button
                   @click="sendTestMail(user.user)"
@@ -85,7 +99,7 @@
           </div>
         </div>
       </div>
-      <Userdetail v-if="isUserDetailsVisible" />
+      <Userdetail :reportToggled="reportToggled" v-if="isUserDetailsVisible" />
     </div>
   </div>
 </template>
@@ -139,7 +153,8 @@
   mixins: [sortTable],
   data() {
     return {
-      sortColumn: "user" // overriding the sortTable mixin's empty default value
+      sortColumn: "user", // overriding the sortTable mixin's empty default value
+      reportToggled: false
     };
   },
   components: {
@@ -147,15 +162,18 @@
     Spacer: () => import("@/components/Spacer")
   },
   computed: {
-    ...mapGetters("usermanagement", [
-      "isUserDetailsVisible",
-      "users",
-      "currentUser"
-    ]),
+    ...mapGetters("usermanagement", ["isUserDetailsVisible", "users"]),
     ...mapState("application", ["showSidebar"]),
+    ...mapState("usermanagement", ["currentUser"]),
     usersLabel() {
       return this.$gettext("Users");
     },
+    reportsLabel() {
+      return this.$gettext("DQL Report");
+    },
+    receivesReportLabel() {
+      return this.$gettext("User receives Data Quality Report");
+    },
     sendMailLabel() {
       return this.$gettext("Send testmail");
     },
@@ -197,6 +215,37 @@
     }
   },
   methods: {
+    toggleReport(user) {
+      HTTP.patch(
+        `/users/${user.user}`,
+        {
+          reports: user.reports
+        },
+        {
+          headers: {
+            "X-Gemma-Auth": localStorage.getItem("token"),
+            "Content-type": "application/json; charset=UTF-8"
+          }
+        }
+      )
+        .then(() => {
+          if (this.currentUser && this.currentUser.user === user.user) {
+            this.reportToggled = !this.reportToggled;
+          }
+        })
+        .catch(error => {
+          let message = "Backend not reachable";
+          if (error.response) {
+            const { status, data } = error.response;
+            message = `${status}: ${data.message || data}`;
+          }
+          displayError({
+            title: this.$gettext("Backend Error"),
+            message: message
+          });
+          user.reports = !user.reports;
+        });
+    },
     sendTestMail(user) {
       HTTP.get("/testmail/" + encodeURIComponent(user), {
         headers: {
--- a/client/src/store/importschedule.js	Sun Jul 04 11:37:37 2021 +0200
+++ b/client/src/store/importschedule.js	Wed Jul 07 11:44:40 2021 +0200
@@ -31,7 +31,9 @@
   SOUNDINGRESULTS: "soundingresults",
   APPROVEDGAUGEMEASUREMENTS: "approvedgaugemeasurements",
   WATERWAYPROFILES: "waterwayprofiles",
-  FAIRWAYMARKS: "fairwaymarks"
+  FAIRWAYMARKS: "fairwaymarks",
+  REPORT: "report",
+  STATSUPDATE: "statsupdate"
 };
 
 const KINDIMPORTTYPE = {
@@ -44,7 +46,9 @@
   fd: "fairwaydimension",
   wg: "waterwaygauges",
   dmv: "distancemarksvirtual",
-  dma: "distancemarksashore"
+  dma: "distancemarksashore",
+  report: "report",
+  statsupdate: "statsupdate"
 };
 
 const IMPORTTYPEKIND = {
@@ -57,7 +61,9 @@
   fairwaydimension: "fd",
   waterwaygauges: "wg",
   distancemarksvirtual: "dmv",
-  distancemarksashore: "dma"
+  distancemarksashore: "dma",
+  report: "report",
+  statsupdate: "statsupdate"
 };
 
 const FAIRWAYMARKKINDS = {
@@ -110,7 +116,9 @@
     sourceOrganization: null,
     trys: null,
     waitRetry: null,
-    selectedMark: null
+    selectedMark: null,
+    statsUpdate: null,
+    reportName: null
   };
 };
 
@@ -123,6 +131,7 @@
 const init = () => {
   return {
     schedules: [],
+    availableReports: null,
     importScheduleDetailVisible: false,
     currentSchedule: initializeCurrentSchedule(),
     mode: MODES.LIST
@@ -134,6 +143,9 @@
   namespaced: true,
   state: init(),
   mutations: {
+    setAvailableReports: (state, value) => {
+      state.availableReports = value;
+    },
     setEditMode: state => {
       state.mode = MODES.EDIT;
     },
@@ -271,9 +283,33 @@
           sourceOrganization
         );
       }
+      if (kind === IMPORTTYPES.STATSUPDATE) {
+        const { name } = config;
+        Vue.set(state.currentSchedule, "statsUpdate", name);
+      }
+      if (kind === IMPORTTYPES.REPORT) {
+        const { name } = config;
+        Vue.set(state.currentSchedule, "reportName", name);
+      }
     }
   },
   actions: {
+    loadAvailableReports({ commit }) {
+      return new Promise((resolve, reject) => {
+        HTTP.get("/data/reports", {
+          headers: {
+            "X-Gemma-Auth": localStorage.getItem("token")
+          }
+        })
+          .then(response => {
+            commit("setAvailableReports", response.data.reports);
+            resolve(response);
+          })
+          .catch(error => {
+            reject(error);
+          });
+      });
+    },
     loadSchedule({ commit }, id) {
       return new Promise((resolve, reject) => {
         HTTP.get("/imports/config/" + id, {
--- a/example_conf.toml	Sun Jul 04 11:37:37 2021 +0200
+++ b/example_conf.toml	Wed Jul 07 11:44:40 2021 +0200
@@ -88,3 +88,4 @@
 # Schema for "Testclient imports"
 # schema-dirs = "$PATH_TO_SCHEMATA"
 # published-config ="$PATH/pub-config.json"
+# report-path = "$PATH_TO_XSLX_AND_YAML_PAIRS"
--- a/go.mod	Sun Jul 04 11:37:37 2021 +0200
+++ b/go.mod	Wed Jul 07 11:44:40 2021 +0200
@@ -3,39 +3,39 @@
 go 1.13
 
 require (
+	github.com/360EntSecGroup-Skylar/excelize/v2 v2.4.0
+	github.com/PaesslerAG/gval v1.1.0
 	github.com/cockroachdb/apd v1.1.0 // indirect
-	github.com/etcd-io/bbolt v1.3.3
 	github.com/fatih/structs v1.1.0
 	github.com/fogleman/contourmap v0.0.0-20190814184649-9f61d36c4199
+	github.com/fsnotify/fsnotify v1.4.9 // indirect
 	github.com/gofrs/uuid v3.2.0+incompatible // indirect
-	github.com/gorilla/mux v1.7.4
+	github.com/gorilla/mux v1.8.0
 	github.com/jackc/fake v0.0.0-20150926172116-812a484cc733 // indirect
 	github.com/jackc/pgx v3.6.2+incompatible
 	github.com/jonas-p/go-shp v0.1.2-0.20190401125246-9fd306ae10a6
 	github.com/lib/pq v1.2.0 // indirect
+	github.com/magiconair/properties v1.8.5 // indirect
 	github.com/mitchellh/go-homedir v1.1.0
-	github.com/pelletier/go-toml v1.6.0 // indirect
-	github.com/pkg/errors v0.9.1 // indirect
+	github.com/mitchellh/mapstructure v1.4.1 // indirect
+	github.com/pelletier/go-toml v1.9.1 // indirect
 	github.com/rs/cors v1.7.0
 	github.com/sergi/go-diff v1.0.0
 	github.com/shopspring/decimal v0.0.0-20190905144223-a36b5d85f337 // indirect
-	github.com/spf13/afero v1.2.2 // indirect
+	github.com/spf13/afero v1.6.0 // indirect
 	github.com/spf13/cast v1.3.1 // indirect
-	github.com/spf13/cobra v0.0.6
+	github.com/spf13/cobra v1.1.3
 	github.com/spf13/jwalterweatherman v1.1.0 // indirect
-	github.com/spf13/pflag v1.0.5 // indirect
-	github.com/spf13/viper v1.6.2
-	github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e
-	github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563 // indirect
-	go.etcd.io/bbolt v1.3.3 // indirect
-	golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 // indirect
-	golang.org/x/net v0.0.0-20200301022130-244492dfa37a
-	golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 // indirect
-	golang.org/x/text v0.3.3 // indirect
-	gonum.org/v1/gonum v0.7.0
+	github.com/spf13/viper v1.7.1
+	github.com/tidwall/rtree v1.2.8
+	go.etcd.io/bbolt v1.3.5
+	golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a // indirect
+	golang.org/x/net v0.0.0-20210525063256-abc453219eb5
+	golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea // indirect
+	gonum.org/v1/gonum v0.9.1
 	gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
 	gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
-	gopkg.in/ini.v1 v1.54.0 // indirect
+	gopkg.in/ini.v1 v1.62.0 // indirect
 	gopkg.in/robfig/cron.v1 v1.2.0
-	gopkg.in/yaml.v2 v2.2.8 // indirect
+	gopkg.in/yaml.v2 v2.4.0
 )
--- a/go.sum	Sun Jul 04 11:37:37 2021 +0200
+++ b/go.sum	Wed Jul 07 11:44:40 2021 +0200
@@ -1,39 +1,70 @@
 cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
+cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
+cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
+cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
+cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
+cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
+cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
+cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
+cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
+cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
+cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
+dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
+gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8=
+github.com/360EntSecGroup-Skylar/excelize/v2 v2.4.0 h1:X+2CWGf5W1tm2+W7Y/LLrAPLFSNlHATnqDudGoIzaxY=
+github.com/360EntSecGroup-Skylar/excelize/v2 v2.4.0/go.mod h1:p9lGPoVX3HYEbFRfjgrPWaaKsHe/2u4EM9DB/qoctgU=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
-github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af h1:wVe6/Ea46ZMeNkQjjBW6xcqyQA/j5e0D6GytH95g0gQ=
+github.com/PaesslerAG/gval v1.1.0 h1:k3RuxeZDO3eejD4cMPSt+74tUSvTnbGvLx0df4mdwFc=
+github.com/PaesslerAG/gval v1.1.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I=
+github.com/PaesslerAG/jsonpath v0.1.0 h1:gADYeifvlqK3R3i2cR5B4DGgxLXIPb3TRTH1mGi0jPI=
+github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8=
 github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
 github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
+github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
+github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
+github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
+github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
+github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
+github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
 github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
 github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
 github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
-github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
-github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
+github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
+github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
 github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
 github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
 github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
-github.com/etcd-io/bbolt v1.3.3 h1:gSJmxrs37LgTqR/oyJBWok6k6SvXEUerFTbltIhXkBM=
-github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw=
+github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
 github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
 github.com/fogleman/contourmap v0.0.0-20190814184649-9f61d36c4199 h1:kufr0u0RIG5ACpjFsPRbbuHa0FhMWsS3tnSFZ2hf07s=
 github.com/fogleman/contourmap v0.0.0-20190814184649-9f61d36c4199/go.mod h1:mqaaaP4j7nTF8T/hx5OCljA7BYWHmrH2uh+Q023OchE=
 github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
-github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
+github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
+github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g=
+github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3T0ecnM9pNujks=
+github.com/go-fonts/liberation v0.1.1/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY=
+github.com/go-fonts/stix v0.1.0/go.mod h1:w/c1f0ldAUlJmLBvlbkvVXLAD+tAMqobIIQpmnUIzUY=
+github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
 github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U=
 github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
 github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
@@ -45,20 +76,50 @@
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
 github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
 github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
 github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
+github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
+github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
-github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc=
-github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
-github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
+github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
+github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
+github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
 github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
 github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
+github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
+github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
+github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
+github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
+github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
+github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
+github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
+github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
+github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
+github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
+github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
+github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
+github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
 github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/jackc/fake v0.0.0-20150926172116-812a484cc733 h1:vr3AYkKovP8uR8AvSGGUK1IDqRa5lAAvEkZG1LKaCRc=
@@ -68,13 +129,17 @@
 github.com/jonas-p/go-shp v0.1.2-0.20190401125246-9fd306ae10a6 h1:h5O7ee4tlSPVjdC75eSLX7jXZiHftthuHio/GtrhaSM=
 github.com/jonas-p/go-shp v0.1.2-0.20190401125246-9fd306ae10a6/go.mod h1:MRIhyxDQ6VVp0oYeD7yPGr5RSTNScUFKCDsI5DR7PtI=
 github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
+github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
+github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
 github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
+github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
 github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
 github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
 github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
 github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -83,27 +148,44 @@
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
 github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
-github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=
-github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
-github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4=
 github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
+github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls=
+github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
+github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
+github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
+github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
+github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
+github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
 github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
 github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
-github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
+github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
+github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
+github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
+github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
 github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
+github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
+github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
 github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
 github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
-github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
+github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
 github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
-github.com/pelletier/go-toml v1.6.0 h1:aetoXYr0Tv7xRU/V4B4IZJ2QcbtMUFoNb3ORp7TzIK4=
-github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys=
-github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
+github.com/pelletier/go-toml v1.9.1 h1:a6qW1EVNZWH9WGI6CsYdD8WAylkoXBS5yv0XHlh17Tc=
+github.com/pelletier/go-toml v1.9.1/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
+github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY=
+github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
 github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
 github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
 github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
@@ -113,10 +195,18 @@
 github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
 github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
 github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
+github.com/richardlehane/mscfb v1.0.3 h1:rD8TBkYWkObWO0oLDFCbwMeZ4KoalxQy+QgniCj3nKI=
+github.com/richardlehane/mscfb v1.0.3/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
+github.com/richardlehane/msoleps v1.0.1 h1:RfrALnSNXzmXLbGct/P2b4xkFz4e8Gmj/0Vj9M9xC1o=
+github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
 github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
+github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
 github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
 github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
+github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
+github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
 github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
 github.com/shopspring/decimal v0.0.0-20190905144223-a36b5d85f337 h1:Da9XEUfFxgyDOqUfwgoTDcWzmnlOnCGi6i4iPS+8Fbw=
@@ -129,126 +219,226 @@
 github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
 github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
 github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
-github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI=
 github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
-github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc=
-github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
-github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8=
+github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY=
+github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
 github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
 github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/cobra v0.0.6 h1:breEStsVwemnKh2/s6gMvSdMEkwW0sK8vGStnlVBMCs=
-github.com/spf13/cobra v0.0.6/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
-github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk=
+github.com/spf13/cobra v1.1.3 h1:xghbfqPkxzxP3C/f3n5DdpAbdKLj4ZE4BWQI362l53M=
+github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo=
 github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
 github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
 github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
-github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
 github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU=
-github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
-github.com/spf13/viper v1.6.2 h1:7aKfF+e8/k68gda3LOjo5RxiUqddoFxVq4BKBPrxk5E=
-github.com/spf13/viper v1.6.2/go.mod h1:t3iDnF5Jlj76alVNuyFBk5oUMCvsrkbvZK0WQdfDi5k=
+github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
+github.com/spf13/viper v1.7.1 h1:pM5oEahlgWv/WnHXpgbKz7iLIxRf65tye2Ci+XFK5sk=
+github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
 github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
-github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e h1:+NL1GDIUOKxVfbp2KoJQD9cTQ6dyP2co9q4yzmT9FZo=
-github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e/go.mod h1:/h+UnNGt0IhNNJLkGikcdcJqm66zGD/uJGMRxK/9+Ao=
-github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563 h1:Otn9S136ELckZ3KKDyCkxapfufrqDqwmGjcHfAyXRrE=
-github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563/go.mod h1:mLqSmt7Dv/CNneF2wfcChfN1rvapyQr01LGKnKex0DQ=
+github.com/tidwall/cities v0.1.0 h1:CVNkmMf7NEC9Bvokf5GoSsArHCKRMTgLuubRTHnH0mE=
+github.com/tidwall/cities v0.1.0/go.mod h1:lV/HDp2gCcRcHJWqgt6Di54GiDrTZwh1aG2ZUPNbqa4=
+github.com/tidwall/geoindex v1.4.4 h1:hdwzy5qNtK75i7nus59Ibr+SwcH4F2v65bw4txrLJ9M=
+github.com/tidwall/geoindex v1.4.4/go.mod h1:rvVVNEFfkJVWGUdEfU8QaoOg/9zFX0h9ofWzA60mz1I=
+github.com/tidwall/lotsa v1.0.2 h1:dNVBH5MErdaQ/xd9s769R31/n2dXavsQ0Yf4TMEHHw8=
+github.com/tidwall/lotsa v1.0.2/go.mod h1:X6NiU+4yHA3fE3Puvpnn1XMDrFZrE9JO2/w+UMuqgR8=
+github.com/tidwall/rtree v1.2.8 h1:KzqIidAdzviaRM3BQoAHMym1zy2HYE2ta+UOPse9Pyo=
+github.com/tidwall/rtree v1.2.8/go.mod h1:S+JSsqPTI8LfWA4xHBo5eXzie8WJLVFeppAutSegl6M=
 github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
-github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
 github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
-github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
-go.etcd.io/bbolt v1.3.2 h1:Z/90sZLPOeCy2PwprqkFa25PdkusRzaj9P8zm/KNyvk=
+github.com/xuri/efp v0.0.0-20210322160811-ab561f5b45e3 h1:EpI0bqf/eX9SdZDwlMmahKM+CDBgNbsXMhsN28XrM8o=
+github.com/xuri/efp v0.0.0-20210322160811-ab561f5b45e3/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
 go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
-go.etcd.io/bbolt v1.3.3 h1:MUGmc65QhB3pIlaQ5bB4LwqSj6GIonVJXpZiaKNyaKk=
-go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
+go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
+go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
+go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
+go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
 go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
 go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
 golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
+golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 h1:xMPOj6Pz6UipU1wXLkrtqpHbR0AVFnyPEQq/wRWz9lM=
-golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20210415154028-4f45737414dc/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
+golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a h1:kr2P4QFmQr29mSLA43kwrOcgcReGTfbE9N577tCTuBc=
+golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
 golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2 h1:y102fOLFqhV41b+4GPiJoa0k/x+pJcEi2/HB1Y5T6fU=
+golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
+golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
+golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3/go.mod h1:NOZ3BPKG0ec/BKJQgnvsSFpcKLM5xXVWnvZS97DWHgE=
+golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136 h1:A1gGSx58LAGVHUUsOf7IiR0u8Xb6W51gRwfDBhkdcaw=
+golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
 golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
+golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
+golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.0.0-20200430140353-33d19683fad8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.0.0-20210216034530-4410531fe030/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb h1:fqpd0EBDzlHRCjiphRR5Zo/RSWWQlWv34418dnEixWk=
+golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
+golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
+golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
+golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
+golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
+golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/net v0.0.0-20200301022130-244492dfa37a h1:GuSPYbZzB5/dcLNCwLQLsg3obCJtX9IJhpXkvY7kzk0=
-golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210415231046-e915ea6b2b7d/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
+golang.org/x/net v0.0.0-20210525063256-abc453219eb5 h1:wjuX4b5yYQnEQHzd+CBcrcC6OVR2J1CN6mUy0oSxIPo=
+golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 h1:uYVVQ9WP/Ds2ROhcaGPeIdVq0RIXVLwsHlnvJ+cT1So=
-golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
+golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210304124612-50617c2ba197/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea h1:+WiDlPBBaO+h9vPNZi8uJ3k4BkKQB7Iow3aqwHVA5hI=
+golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
+golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
-golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo=
-gonum.org/v1/gonum v0.7.0 h1:Hdks0L0hgznZLG9nzXb8vZ0rRvqNvAcgAp84y7Mwkgw=
-gonum.org/v1/gonum v0.7.0/go.mod h1:L02bwd0sqlsvRv41G7wGWFCsVNZFv/k1xzGIxeANHGM=
+gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0=
+gonum.org/v1/gonum v0.9.1 h1:HCWmqqNoELL0RAQeKBXWtkp04mGk8koafcB4He6+uhc=
+gonum.org/v1/gonum v0.9.1/go.mod h1:TZumC3NeyVQskjXqmyWt4S3bINhy7B4eYwW69EbyX+0=
 gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0 h1:OE9mWmgKkjJyEmDAAtGMPjXu+YNeGvK9VTSHY6+Qihc=
 gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
 gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc=
+gonum.org/v1/plot v0.9.0/go.mod h1:3Pcqqmp6RHvJI72kgb8fThyUnav364FOsdDo2aGW5lY=
+google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
+google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
+google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
+google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
+google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
+google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
+google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
 gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
 gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
 gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
-gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno=
 gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/ini.v1 v1.54.0 h1:oM5ElzbIi7gwLnNbPX2M25ED1vSAK3B6dex50eS/6Fs=
-gopkg.in/ini.v1 v1.54.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU=
+gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
 gopkg.in/robfig/cron.v1 v1.2.0 h1:PSJsm0uPEND0Rumxxbo7qNb7bxQUTIWDIdpPS59/tcw=
 gopkg.in/robfig/cron.v1 v1.2.0/go.mod h1:3I22DCB+7VAStCIqyArwi2xY9a7IioCiNjrsnCqs+HE=
 gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
 gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
-gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
+rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
 rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
--- a/pkg/auth/store.go	Sun Jul 04 11:37:37 2021 +0200
+++ b/pkg/auth/store.go	Wed Jul 07 11:44:40 2021 +0200
@@ -20,7 +20,7 @@
 	"time"
 
 	"gemma.intevation.de/gemma/pkg/config"
-	bolt "github.com/etcd-io/bbolt"
+	bolt "go.etcd.io/bbolt"
 )
 
 // ErrNoSuchToken is returned if a given token does not
--- a/pkg/config/config.go	Sun Jul 04 11:37:37 2021 +0200
+++ b/pkg/config/config.go	Wed Jul 07 11:44:40 2021 +0200
@@ -122,6 +122,9 @@
 // SOAPTimeout is the timeout till a SOAP request is canceled.
 func SOAPTimeout() time.Duration { return viper.GetDuration("soap-timeout") }
 
+// ReportPath is a path where report templates are stored.
+func ReportPath() string { return viper.GetString("report-path") }
+
 var (
 	proxyKeyOnce       sync.Once
 	proxyKey           []byte
@@ -290,6 +293,8 @@
 
 	str("published-config", "", "path to a config file served to client.")
 
+	str("report-path", "", "path to a report templates.")
+
 	d("soap-timeout", 3*time.Minute, "Timeout till a SOAP request is canceled.")
 }
 
--- a/pkg/controllers/importconfig.go	Sun Jul 04 11:37:37 2021 +0200
+++ b/pkg/controllers/importconfig.go	Wed Jul 07 11:44:40 2021 +0200
@@ -30,6 +30,11 @@
 	mw "gemma.intevation.de/gemma/pkg/middleware"
 )
 
+// RolesRequierer enforces roles when storing schedules.
+type RolesRequierer interface {
+	RequiresRoles() auth.Roles
+}
+
 func runImportConfig(req *http.Request) (jr mw.JSONResult, err error) {
 
 	id, _ := strconv.ParseInt(mux.Vars(req)["id"], 10, 64)
@@ -262,12 +267,23 @@
 		return
 	}
 	config := ctor()
+
+	session, _ := auth.GetSession(req)
+
+	if r, ok := config.(RolesRequierer); ok {
+		if roles := r.RequiresRoles(); len(roles) > 0 && !session.Roles.HasAny(roles...) {
+			err = mw.JSONError{
+				Code: http.StatusUnauthorized,
+				Message: fmt.Sprintf(
+					"Not allowed to add config for kind %s", string(cfg.Kind)),
+			}
+			return
+		}
+	}
 	if err = json.Unmarshal(cfg.Config, config); err != nil {
 		return
 	}
 
-	session, _ := auth.GetSession(req)
-
 	pc := imports.PersistentConfig{
 		User:       session.User,
 		Kind:       string(cfg.Kind),
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pkg/controllers/report.go	Wed Jul 07 11:44:40 2021 +0200
@@ -0,0 +1,179 @@
+// This is Free Software under GNU Affero General Public License v >= 3.0
+// without warranty, see README.md and license for details.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// License-Filename: LICENSES/AGPL-3.0.txt
+//
+// Copyright (C) 2021 by via donau
+//   – Österreichische Wasserstraßen-Gesellschaft mbH
+// Software engineering by Intevation GmbH
+//
+// Author(s):
+//  * Sascha L. Teichmann <sascha.teichmann@intevation.de>
+
+package controllers
+
+import (
+	"database/sql"
+	"fmt"
+	"log"
+	"net/http"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+
+	"gemma.intevation.de/gemma/pkg/config"
+	"gemma.intevation.de/gemma/pkg/middleware"
+	"gemma.intevation.de/gemma/pkg/xlsx"
+
+	mw "gemma.intevation.de/gemma/pkg/middleware"
+
+	"github.com/360EntSecGroup-Skylar/excelize/v2"
+	"github.com/gorilla/mux"
+)
+
+func listReports(req *http.Request) (jr mw.JSONResult, err error) {
+	path := config.ReportPath()
+	if path == "" {
+		err = mw.JSONError{
+			Code:    http.StatusNotFound,
+			Message: http.StatusText(http.StatusNotFound),
+		}
+		return
+	}
+
+	// This would be easier with Go 1.16+.
+
+	dir, err := os.Open(path)
+	if err != nil {
+		log.Printf("error: %v\n", err)
+		err = mw.JSONError{
+			Code:    http.StatusInternalServerError,
+			Message: "Listing report templates failed.",
+		}
+		return
+	}
+	defer dir.Close()
+	files, err := dir.Readdirnames(-1)
+	if err != nil {
+		log.Printf("error: %v\n", err)
+		err = mw.JSONError{
+			Code:    http.StatusInternalServerError,
+			Message: "Listing report templates failed.",
+		}
+		return
+	}
+
+	pairs := map[string]int{}
+
+all:
+	for _, file := range files {
+		var mask int
+		switch {
+		case strings.HasSuffix(file, ".xlsx"):
+			mask = 1
+		case strings.HasSuffix(file, ".yaml"):
+			mask = 2
+		default:
+			continue all
+		}
+		basename := filepath.Base(file)
+		name := strings.TrimSuffix(basename, filepath.Ext(basename))
+		pairs[name] |= mask
+	}
+
+	var reports []string
+	for name, mask := range pairs {
+		if mask == 3 {
+			reports = append(reports, name)
+		}
+	}
+	sort.Strings(reports)
+
+	out := struct {
+		Reports []string `json:"reports"`
+	}{
+		Reports: reports,
+	}
+	jr = mw.JSONResult{Result: out}
+	return
+}
+
+func report(rw http.ResponseWriter, req *http.Request) {
+
+	path := config.ReportPath()
+	if path == "" {
+		http.NotFound(rw, req)
+		return
+	}
+
+	if stat, err := os.Stat(path); err != nil {
+		if os.IsNotExist(err) {
+			log.Printf("error: report dir '%s' does not exists.\n", path)
+			http.NotFound(rw, req)
+		} else {
+			log.Printf("error: %v\n", err)
+			http.Error(rw, "Error: "+err.Error(), http.StatusInternalServerError)
+		}
+		return
+	} else if !stat.Mode().IsDir() {
+		log.Printf("error: report dir '%s' is not a directory.\n", path)
+		http.NotFound(rw, req)
+		return
+	}
+
+	vars := mux.Vars(req)
+	name := vars["name"]
+
+	xlsxFilename := filepath.Join(path, name+".xlsx")
+	yamlFilename := filepath.Join(path, name+".yaml")
+
+	for _, check := range []string{xlsxFilename, yamlFilename} {
+		if _, err := os.Stat(check); err != nil {
+			if os.IsNotExist(err) {
+				http.NotFound(rw, req)
+			} else {
+				log.Printf("error: %v\n", err)
+				http.Error(rw, "Error: "+err.Error(), http.StatusInternalServerError)
+			}
+			return
+		}
+	}
+
+	template, err := excelize.OpenFile(xlsxFilename)
+	if err != nil {
+		log.Printf("error: %v\n", err)
+		http.Error(rw, "Error: "+err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	action, err := xlsx.ActionFromFile(yamlFilename)
+	if err != nil {
+		http.Error(rw, "Error: "+err.Error(), http.StatusInternalServerError)
+		log.Printf("error: %v\n", err)
+		return
+	}
+
+	ctx := req.Context()
+	conn := middleware.GetDBConn(req)
+
+	tx, err := conn.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
+	defer tx.Rollback()
+
+	if err := action.Execute(ctx, tx, template); err != nil {
+		log.Printf("error: %v\n", err)
+		http.Error(rw, "Error: "+err.Error(), http.StatusInternalServerError)
+		return
+	}
+	rw.Header().Set(
+		"Content-Disposition",
+		fmt.Sprintf("attachment; filename=%s.xlsx", name))
+	rw.Header().Set(
+		"Content-Type",
+		"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
+
+	if _, err := template.WriteTo(rw); err != nil {
+		log.Printf("error: %v\n", err)
+	}
+}
--- a/pkg/controllers/routes.go	Sun Jul 04 11:37:37 2021 +0200
+++ b/pkg/controllers/routes.go	Wed Jul 07 11:44:40 2021 +0200
@@ -68,6 +68,11 @@
 		Handle: updateUser,
 	})).Methods(http.MethodPut)
 
+	api.Handle("/users/{user:.+}", any(&mw.JSONHandler{
+		Input:  func(*http.Request) interface{} { return new(models.UserPatch) },
+		Handle: patchUser,
+	})).Methods(http.MethodPatch)
+
 	api.Handle("/users/{user:.+}", sysAdmin(&mw.JSONHandler{
 		Handle: deleteUser,
 	})).Methods(http.MethodDelete)
@@ -259,6 +264,12 @@
 		NoConn: true,
 	})).Methods(http.MethodPost)
 
+	api.Handle("/imports/{kind:report|statsupdate}", sysAdmin(&mw.JSONHandler{
+		Input:  importModel,
+		Handle: manualImport,
+		NoConn: true,
+	})).Methods(http.MethodPost)
+
 	// Import scheduler configuration
 	api.Handle("/imports/config/{id:[0-9]+}/run",
 		waterwayAdmin(&mw.JSONHandler{
@@ -322,6 +333,28 @@
 			NoConn: true,
 		})).Methods(http.MethodPut)
 
+	// Handler for reporting
+
+	api.Handle("/data/reports",
+		waterwayAdmin(&mw.JSONHandler{
+			Handle: listReports,
+			NoConn: true,
+		})).Methods(http.MethodGet)
+
+	api.Handle("/data/report/{name:"+models.SafePathExp+"}", waterwayAdmin(
+		mw.DBConn(http.HandlerFunc(report)))).Methods(http.MethodGet)
+
+	// Handler for update scripts
+	api.Handle("/data/stats-updates",
+		sysAdmin(&mw.JSONHandler{
+			Handle: listStatsUpdates,
+		})).Methods(http.MethodGet)
+
+	api.Handle("/data/stats-updates/{name:.+}",
+		sysAdmin(&mw.JSONHandler{
+			Handle: statsUpdates,
+		})).Methods(http.MethodGet)
+
 	// Handler to serve data to the client.
 
 	api.Handle("/data/{type:availability|fairway}/{kind:stretch|section|bottleneck}/{name:.+}", any(
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pkg/controllers/statsupdates.go	Wed Jul 07 11:44:40 2021 +0200
@@ -0,0 +1,114 @@
+// This is Free Software under GNU Affero General Public License v >= 3.0
+// without warranty, see README.md and license for details.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// License-Filename: LICENSES/AGPL-3.0.txt
+//
+// Copyright (C) 2021 by via donau
+//   – Österreichische Wasserstraßen-Gesellschaft mbH
+// Software engineering by Intevation GmbH
+//
+// Author(s):
+//  * Sascha L. Teichmann <sascha.teichmann@intevation.de>
+
+package controllers
+
+import (
+	"database/sql"
+	"log"
+	"net/http"
+
+	mw "gemma.intevation.de/gemma/pkg/middleware"
+	"github.com/gorilla/mux"
+)
+
+const (
+	listStatsUpdatesSQL   = `SELECT name FROM sys_admin.stats_updates ORDER BY name`
+	statsUpdatesScriptSQL = `SELECT script FROM sys_admin.stats_updates WHERE name = $1`
+)
+
+func listStatsUpdates(req *http.Request) (jr mw.JSONResult, err error) {
+
+	ctx := req.Context()
+	conn := mw.JSONConn(req)
+
+	rows, err2 := conn.QueryContext(ctx, listStatsUpdatesSQL)
+	if err2 != nil {
+		log.Printf("error: %v\n", err2)
+		err = mw.JSONError{
+			Code:    http.StatusInternalServerError,
+			Message: "Listing stats update failed.",
+		}
+		return
+	}
+	defer rows.Close()
+
+	names := []string{}
+
+	for rows.Next() {
+		var name string
+		if err2 := rows.Scan(&name); err2 != nil {
+			log.Printf("error: %v\n", err2)
+			err = mw.JSONError{
+				Code:    http.StatusInternalServerError,
+				Message: "Fetching stats update names failed.",
+			}
+			return
+		}
+		names = append(names, name)
+	}
+
+	if err2 := rows.Err(); err2 != nil {
+		log.Printf("error: %v\n", err2)
+		err = mw.JSONError{
+			Code:    http.StatusInternalServerError,
+			Message: "Fetching stats update names failed.",
+		}
+		return
+	}
+
+	out := struct {
+		StatsUpdates []string `json:"stats-updates"`
+	}{
+		StatsUpdates: names,
+	}
+	jr = mw.JSONResult{Result: out}
+	return
+}
+
+func statsUpdates(req *http.Request) (jr mw.JSONResult, err error) {
+	name := mux.Vars(req)["name"]
+
+	ctx := req.Context()
+	conn := mw.JSONConn(req)
+
+	var script string
+	err2 := conn.QueryRowContext(ctx, statsUpdatesScriptSQL, name).Scan(&script)
+	switch {
+	case err2 == sql.ErrNoRows:
+		err = mw.JSONError{
+			Code:    http.StatusNotFound,
+			Message: "No such update script.",
+		}
+		return
+	case err2 != nil:
+		log.Printf("error: %v\n", err2)
+		err = mw.JSONError{
+			Code:    http.StatusInternalServerError,
+			Message: "Loading update script failed.",
+		}
+		return
+	}
+
+	if _, err2 := conn.ExecContext(ctx, script); err2 != nil {
+		log.Printf("error: %v\n", err2)
+		err = mw.JSONError{
+			Code:    http.StatusInternalServerError,
+			Message: "Loading update script failed.",
+		}
+		return
+	}
+
+	jr = mw.JSONResult{Result: map[string]interface{}{}}
+	return
+}
--- a/pkg/controllers/user.go	Sun Jul 04 11:37:37 2021 +0200
+++ b/pkg/controllers/user.go	Wed Jul 07 11:44:40 2021 +0200
@@ -21,6 +21,8 @@
 	"fmt"
 	"log"
 	"net/http"
+	"strconv"
+	"strings"
 	"text/template"
 	"time"
 
@@ -37,22 +39,22 @@
 
 const (
 	createUserSQL = `INSERT INTO users.list_users
-  VALUES ($1, $2, $3, $4, NULL, $5)`
+  VALUES ($1, $2, $3, $4, NULL, $5, $6)`
 	createUserExtentSQL = `INSERT INTO users.list_users
   VALUES ($1, $2, $3, $4,
-  ST_MakeBox2D(ST_Point($5, $6), ST_Point($7, $8)), $9)`
+  ST_MakeBox2D(ST_Point($5, $6), ST_Point($7, $8)), $9, $10)`
 
 	updateUserUnprivSQL = `UPDATE users.list_users
   SET (pw, map_extent, email_address)
   = ($2, ST_MakeBox2D(ST_Point($3, $4), ST_Point($5, $6)), $7)
   WHERE username = $1`
 	updateUserSQL = `UPDATE users.list_users
-  SET (rolname, username, pw, country, map_extent, email_address)
-  = ($2, $3, $4, $5, NULL, $6)
+  SET (rolname, username, pw, country, map_extent, email_address, report_reciever)
+  = ($2, $3, $4, $5, NULL, $6, $7)
   WHERE username = $1`
 	updateUserExtentSQL = `UPDATE users.list_users
-  SET (rolname, username, pw, country, map_extent, email_address)
-  = ($2, $3, $4, $5, ST_MakeBox2D(ST_Point($6, $7), ST_Point($8, $9)), $10)
+  SET (rolname, username, pw, country, map_extent, email_address, report_reciever)
+  = ($2, $3, $4, $5, ST_MakeBox2D(ST_Point($6, $7), ST_Point($8, $9)), $10, $11)
   WHERE username = $1`
 
 	deleteUserSQL = `DELETE FROM users.list_users WHERE username = $1`
@@ -63,7 +65,8 @@
   country,
   email_address,
   ST_XMin(map_extent), ST_YMin(map_extent),
-  ST_XMax(map_extent), ST_YMax(map_extent)
+  ST_XMax(map_extent), ST_YMax(map_extent),
+  report_reciever
 FROM users.list_users`
 
 	listUserSQL = `SELECT
@@ -71,7 +74,8 @@
   country,
   email_address,
   ST_XMin(map_extent), ST_YMin(map_extent),
-  ST_XMax(map_extent), ST_YMax(map_extent)
+  ST_XMax(map_extent), ST_YMax(map_extent),
+  report_reciever
 FROM users.list_users
 WHERE username = $1`
 )
@@ -80,23 +84,30 @@
 	testSysadminNotifyMailTmpl = template.Must(
 		template.New("sysadmin").Parse(`Dear {{ .User }},
 
-this is a test email for the Gemma System Errors notification service.  You
+this is a test email from the Gemma System Errors notification service.  You
 recieved this mail, because a System Administrator triggered the test mail
 sending function at {{ .Timestamp }}.
 
-When a critical system error is detected an automated mail will be send to
-the address: {{ .Email }} with details on the error condition.`))
+When a critical system error is detected an automated mail will be sent to
+{{ .Email }} with details on the error condition.`))
 
 	testWWAdminNotifyMailTmpl = template.Must(
 		template.New("waterwayadmin").Parse(`Dear {{ .User }},
 
-this is a test email for the Gemma System Imports notification service.  You
+this is a test email from the Gemma System Mail notification service.  You
 recieved this mail, because a System Administrator triggered the test mail
 sending function at {{ .Timestamp }}.
 
-When the status of an data import managed by you changes an automated mail will
-be send to the address: {{ .Email }} with details on the new import status
-(inkluding import errors) and details on the concerned import.`))
+When the status of a data import managed by you changes an automated mail will
+be sent to {{ .Email }} with details on the new import status
+(including import errors) and details on the corresponding import.`))
+
+	testWWUserNotifyMailTmpl = template.Must(
+		template.New("waterwayuser").Parse(`Dear {{ .User }},
+
+this is a test email from the Gemma System Mail notification service.  You
+recieved this mail, because a System Administrator triggered the test mail
+sending function at {{ .Timestamp }}.`))
 )
 
 func deleteUser(req *http.Request) (jr mw.JSONResult, err error) {
@@ -181,6 +192,7 @@
 				newUser.Password,
 				newUser.Country,
 				newUser.Email,
+				newUser.Reports,
 			)
 		} else {
 			res, err = db.ExecContext(
@@ -194,6 +206,7 @@
 				newUser.Extent.X1, newUser.Extent.Y1,
 				newUser.Extent.X2, newUser.Extent.Y2,
 				newUser.Email,
+				newUser.Reports,
 			)
 		}
 	} else {
@@ -241,6 +254,137 @@
 	return
 }
 
+func patchUser(req *http.Request) (jr mw.JSONResult, err error) {
+
+	user := models.UserName(mux.Vars(req)["user"])
+	if !user.IsValid() {
+		err = mw.JSONError{
+			Code:    http.StatusBadRequest,
+			Message: "error: user invalid",
+		}
+		return
+	}
+
+	s, ok := auth.GetSession(req)
+	if !ok {
+		err = mw.JSONError{
+			Code:    http.StatusUnauthorized,
+			Message: "error: not logged in",
+		}
+		return
+	}
+
+	priv := s.Roles.Has("sys_admin")
+
+	if !priv && s.User != string(user) {
+		err = mw.JSONError{
+			Code:    http.StatusUnauthorized,
+			Message: "error: not allowed to modify someone else",
+		}
+		return
+	}
+
+	var (
+		columns   []string
+		positions []string
+		args      []interface{}
+	)
+
+	update := func(column string, value interface{}) {
+		columns = append(columns, column)
+		positions = append(positions, "$"+strconv.Itoa(len(positions)+1))
+		args = append(args, value)
+	}
+
+	updateBox := func(column string, extent *models.BoundingBox) {
+		columns = append(columns, column)
+		pos := len(positions)
+		position := fmt.Sprintf("ST_MakeBox2D(ST_Point($%d, $%d), ST_Point($%d, $%d))",
+			pos+1, pos+2, pos+3, pos+4)
+		positions = append(positions, position)
+		args = append(args, extent.X1, extent.Y1, extent.X2, extent.Y2)
+	}
+
+	patch := mw.JSONInput(req).(*models.UserPatch)
+
+	if patch.User != nil && priv {
+		update("user", *patch.User)
+	}
+	if patch.Role != nil && priv {
+		update("rolname", *patch.Role)
+	}
+	if patch.Password != nil {
+		update("pw", *patch.Password)
+	}
+	if patch.Email != nil {
+		update("email_address", *patch.Email)
+	}
+	if patch.Country != nil && priv {
+		update("country", *patch.Country)
+	}
+	if patch.Reports != nil && priv {
+		update("report_reciever", *patch.Reports)
+	}
+	if patch.Extent != nil {
+		updateBox("map_extent", patch.Extent)
+	}
+
+	var colsS, posS string
+
+	switch len(columns) {
+	case 0: // Nothing to do
+		jr = mw.JSONResult{
+			Code: http.StatusCreated,
+			Result: struct {
+				Result string `json:"result"`
+			}{"success"},
+		}
+		return
+	case 1: // No brackets if there is only one argument.
+		colsS = columns[0]
+		posS = positions[0]
+	default:
+		colsS = "(" + strings.Join(columns, ",") + ")"
+		posS = "(" + strings.Join(positions, ",") + ")"
+	}
+
+	stmt := fmt.Sprintf(
+		`UPDATE users.list_users SET %s = %s WHERE username = $%d`,
+		colsS,
+		posS,
+		len(positions)+1)
+
+	args = append(args, user)
+
+	db := mw.JSONConn(req)
+
+	var res sql.Result
+	if res, err = db.ExecContext(req.Context(), stmt, args...); err != nil {
+		return
+	}
+
+	if n, err2 := res.RowsAffected(); err2 == nil && n == 0 {
+		err = mw.JSONError{
+			Code:    http.StatusNotFound,
+			Message: fmt.Sprintf("Cannot find user %s.", user),
+		}
+		return
+	}
+
+	if patch.User != nil && *patch.User != user {
+		// Running in a go routine should not be necessary.
+		go func() { auth.Sessions.Logout(string(user)) }()
+	}
+
+	jr = mw.JSONResult{
+		Code: http.StatusCreated,
+		Result: struct {
+			Result string `json:"result"`
+		}{"success"},
+	}
+	return
+}
+
 func createUser(req *http.Request) (jr mw.JSONResult, err error) {
 
 	user := mw.JSONInput(req).(*models.User)
@@ -256,6 +400,7 @@
 			user.Password,
 			user.Country,
 			user.Email,
+			user.Reports,
 		)
 	} else {
 		_, err = db.ExecContext(
@@ -268,6 +413,7 @@
 			user.Extent.X1, user.Extent.Y1,
 			user.Extent.X2, user.Extent.Y2,
 			user.Email,
+			user.Reports,
 		)
 	}
 
@@ -307,6 +453,7 @@
 			&user.Email,
 			&user.Extent.X1, &user.Extent.Y1,
 			&user.Extent.X2, &user.Extent.Y2,
+			&user.Reports,
 		); err != nil {
 			return
 		}
@@ -343,6 +490,7 @@
 		&result.Email,
 		&result.Extent.X1, &result.Extent.Y1,
 		&result.Extent.X2, &result.Extent.Y2,
+		&result.Reports,
 	)
 
 	switch {
@@ -382,6 +530,7 @@
 		&userData.Email,
 		&userData.Extent.X1, &userData.Extent.Y1,
 		&userData.Extent.X2, &userData.Extent.Y2,
+		&userData.Reports,
 	)
 
 	switch {
@@ -408,19 +557,18 @@
 	}
 
 	var bodyTmpl *template.Template
-	if userData.Role == "sys_admin" {
+	switch userData.Role {
+	case "sys_admin":
 		subject = "Gemma: Sysadmin Notification TEST"
 		bodyTmpl = testSysadminNotifyMailTmpl
-	} else if userData.Role == "waterway_admin" {
+	case "waterway_admin":
 		subject = "Gemma: Waterway Admin Notification TEST"
 		bodyTmpl = testWWAdminNotifyMailTmpl
-	} else {
-		err = mw.JSONError{
-			Code:    http.StatusBadRequest,
-			Message: "Test mails can only be generated for admin roles.",
-		}
-		return
+	default:
+		subject = "Gemma: Waterway User Notification TEST"
+		bodyTmpl = testWWUserNotifyMailTmpl
 	}
+
 	var buf bytes.Buffer
 	if err := bodyTmpl.Execute(&buf, &tmplVars); err != nil {
 		log.Printf("error: %v\n", err)
--- a/pkg/imports/modelconvert.go	Sun Jul 04 11:37:37 2021 +0200
+++ b/pkg/imports/modelconvert.go	Wed Jul 07 11:44:40 2021 +0200
@@ -4,7 +4,7 @@
 // SPDX-License-Identifier: AGPL-3.0-or-later
 // License-Filename: LICENSES/AGPL-3.0.txt
 //
-// Copyright (C) 2018, 2020 by via donau
+// Copyright (C) 2018, 2020, 2021 by via donau
 //   – Österreichische Wasserstraßen-Gesellschaft mbH
 // Software engineering by Intevation GmbH
 //
@@ -45,6 +45,8 @@
 	DSECJobKind:        func() interface{} { return new(models.SectionDelete) },
 	DSTJobKind:         func() interface{} { return new(models.StretchDelete) },
 	DSRJobKind:         func() interface{} { return new(models.SoundingResultDelete) },
+	ReportJobKind:      func() interface{} { return FindJobCreator(ReportJobKind).Create() },
+	StatsUpdateJobKind: func() interface{} { return FindJobCreator(StatsUpdateJobKind).Create() },
 }
 
 // ImportModelForJobKind returns the constructor function to
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pkg/imports/report.go	Wed Jul 07 11:44:40 2021 +0200
@@ -0,0 +1,279 @@
+// This is Free Software under GNU Affero General Public License v >= 3.0
+// without warranty, see README.md and license for details.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// License-Filename: LICENSES/AGPL-3.0.txt
+//
+// Copyright (C) 2021 by via donau
+//   – Österreichische Wasserstraßen-Gesellschaft mbH
+// Software engineering by Intevation GmbH
+//
+// Author(s):
+//  * Sascha L. Teichmann <sascha.teichmann@intevation.de>
+
+package imports
+
+import (
+	"bytes"
+	"context"
+	"database/sql"
+	"errors"
+	"fmt"
+	"log"
+	"os"
+	"path/filepath"
+	"strings"
+	"text/template"
+	"time"
+
+	"gemma.intevation.de/gemma/pkg/auth"
+	"gemma.intevation.de/gemma/pkg/common"
+	"gemma.intevation.de/gemma/pkg/config"
+	"gemma.intevation.de/gemma/pkg/misc"
+	"gemma.intevation.de/gemma/pkg/models"
+	"gemma.intevation.de/gemma/pkg/xlsx"
+
+	"github.com/360EntSecGroup-Skylar/excelize/v2"
+)
+
+type Report struct {
+	models.QueueConfigurationType
+	Name models.SafePath `json:"name"`
+}
+
+const ReportJobKind JobKind = "report"
+
+type reportJobCreator struct{}
+
+const (
+	selectReportUsersSQL = `
+SELECT username, email_address
+FROM users.list_users
+WHERE report_reciever
+ORDER BY country, username`
+
+	selectCurrentUserSQL = `
+SELECT current_user, email_address
+FROM users.list_users
+WHERE username = current_user`
+)
+
+var reportMailTmpl = template.Must(template.New("report-mail").
+	Parse(`Dear {{ .Receiver }}
+
+this is an automatically generated report from the Gemma system.
+You got this mail because you are listed as a report receiver.
+If you received it without consent please
+contact {{ .Admin }} under {{ .AdminEmail }}.
+
+Find attached {{ .Attachment }} containing the {{ .Report }} report from {{ .When }}.
+
+Kind Regards`))
+
+func init() { RegisterJobCreator(ReportJobKind, reportJobCreator{}) }
+
+func (reportJobCreator) Description() string { return "report" }
+
+func (reportJobCreator) AutoAccept() bool { return true }
+
+func (reportJobCreator) Create() Job { return new(Report) }
+
+func (reportJobCreator) Depends() [2][]string { return [2][]string{{}, {}} }
+
+func (reportJobCreator) StageDone(context.Context, *sql.Tx, int64, Feedback) error {
+	return nil
+}
+
+// RequiresRoles enforces to be a sys_admin to run this .
+func (*Report) RequiresRoles() auth.Roles { return auth.Roles{"sys_admin"} }
+
+func (r *Report) Description() (string, error) { return string(r.Name), nil }
+
+func (*Report) CleanUp() error { return nil }
+
+func (r *Report) MarshalAttributes(attrs common.Attributes) error {
+	if err := r.QueueConfigurationType.MarshalAttributes(attrs); err != nil {
+		return err
+	}
+	attrs.Set("name", string(r.Name))
+	return nil
+}
+
+func (r *Report) UnmarshalAttributes(attrs common.Attributes) error {
+	if err := r.QueueConfigurationType.UnmarshalAttributes(attrs); err != nil {
+		return err
+	}
+	name, found := attrs.Get("name")
+	if !found {
+		return errors.New("missing 'name' attribute")
+	}
+	r.Name = models.SafePath(name)
+	if !r.Name.Valid() {
+		return fmt.Errorf("'%s' is not a safe path", name)
+	}
+	return nil
+}
+
+func (r *Report) loadTemplate() (*excelize.File, *xlsx.Action, error) {
+	path := config.ReportPath()
+	if path == "" {
+		return nil, nil, errors.New("no report dir configured")
+	}
+
+	if stat, err := os.Stat(path); err != nil {
+		if os.IsNotExist(err) {
+			return nil, nil, fmt.Errorf("report dir '%s' does not exists", path)
+		}
+		return nil, nil, err
+	} else if !stat.Mode().IsDir() {
+		return nil, nil, fmt.Errorf("report dir '%s' is not a directory", path)
+	}
+
+	xlsxFilename := filepath.Join(path, string(r.Name)+".xlsx")
+	yamlFilename := filepath.Join(path, string(r.Name)+".yaml")
+
+	for _, check := range []string{xlsxFilename, yamlFilename} {
+		if _, err := os.Stat(check); err != nil {
+			if os.IsNotExist(err) {
+				return nil, nil, fmt.Errorf("'%s' does not exists", check)
+			}
+			return nil, nil, err
+		}
+	}
+
+	template, err := excelize.OpenFile(xlsxFilename)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	action, err := xlsx.ActionFromFile(yamlFilename)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	return template, action, nil
+}
+
+func (r *Report) Do(
+	ctx context.Context,
+	importID int64,
+	conn *sql.Conn,
+	feedback Feedback,
+) (interface{}, error) {
+
+	start := time.Now()
+
+	feedback.Info("Generating report %s.", r.Name)
+
+	template, action, err := r.loadTemplate()
+	if err != nil {
+		return nil, err
+	}
+
+	tx, err := conn.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
+	if err != nil {
+		return nil, err
+	}
+	defer tx.Rollback()
+
+	// Fetch receivers
+	var users []misc.EmailReceiver
+
+	if err := func() error {
+		rows, err := tx.QueryContext(ctx, selectReportUsersSQL)
+		if err != nil {
+			return err
+		}
+		defer rows.Close()
+
+		for rows.Next() {
+			var u misc.EmailReceiver
+			if err := rows.Scan(&u.Name, &u.Address); err != nil {
+				return err
+			}
+			users = append(users, u)
+		}
+		return rows.Err()
+	}(); err != nil {
+		return nil, err
+	}
+
+	if len(users) == 0 {
+		feedback.Warn("No users found to send reports to.")
+		return nil, nil
+	}
+
+	// Fetch admin who is responsible for the report.
+	var admin misc.EmailReceiver
+	if err := tx.QueryRowContext(
+		ctx, selectCurrentUserSQL).Scan(&admin.Name, &admin.Address); err != nil {
+		log.Printf("error: Cannot find sender: %v\n")
+		return nil, fmt.Errorf("cannot find sender: %v", err)
+	}
+
+	// Generate the actual report.
+	if err := action.Execute(ctx, tx, template); err != nil {
+		log.Printf("error: %v\n", err)
+		return nil, fmt.Errorf("Generating report failed: %v", err)
+	}
+
+	var buf bytes.Buffer
+	if _, err := template.WriteTo(&buf); err != nil {
+		log.Printf("error: %v\n", err)
+		return nil, fmt.Errorf("generating report failed: %v", err)
+	}
+
+	feedback.Info("Sending report to %d receiver(s).", len(users))
+
+	now := start.UTC().Format("2006-01-02")
+
+	attached := string(r.Name) + "-" + now + ".xlsx"
+
+	body := func(u misc.EmailReceiver) (string, error) {
+		fill := struct {
+			Receiver   string
+			Attachment string
+			Report     string
+			When       string
+			Admin      string
+			AdminEmail string
+		}{
+			Receiver:   u.Name,
+			Attachment: attached,
+			Report:     string(r.Name),
+			When:       now,
+			Admin:      admin.Name,
+			AdminEmail: admin.Address,
+		}
+		var sb strings.Builder
+		if err := reportMailTmpl.Execute(&sb, &fill); err != nil {
+			return "", err
+		}
+		return sb.String(), nil
+	}
+
+	errorHandler := func(r misc.EmailReceiver, err error) error {
+		// We do not terminate the sending of the emails if
+		// sending failed. We only log it.
+		feedback.Warn("Sending report to %s failed: %v", r.Name, err)
+		return nil
+	}
+
+	if err := misc.SendMailToAll(
+		users,
+		"Report "+string(r.Name)+" from "+now,
+		body,
+		[]misc.EmailAttachment{{
+			Name:    attached,
+			Content: buf.Bytes(),
+		}},
+		errorHandler,
+	); err != nil {
+		return nil, err
+	}
+
+	feedback.Info("Generating and sending report took %v.",
+		time.Since(start))
+
+	return nil, nil
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pkg/imports/statsupdate.go	Wed Jul 07 11:44:40 2021 +0200
@@ -0,0 +1,116 @@
+// This is Free Software under GNU Affero General Public License v >= 3.0
+// without warranty, see README.md and license for details.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// License-Filename: LICENSES/AGPL-3.0.txt
+//
+// Copyright (C) 2021 by via donau
+//   – Österreichische Wasserstraßen-Gesellschaft mbH
+// Software engineering by Intevation GmbH
+//
+// Author(s):
+//  * Sascha L. Teichmann <sascha.teichmann@intevation.de>
+
+package imports
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"fmt"
+	"time"
+
+	"gemma.intevation.de/gemma/pkg/auth"
+	"gemma.intevation.de/gemma/pkg/common"
+	"gemma.intevation.de/gemma/pkg/models"
+)
+
+type StatsUpdate struct {
+	models.QueueConfigurationType
+	Name string `json:"name"`
+}
+
+const StatsUpdateJobKind JobKind = "statsupdate"
+
+type statsUpdateJobCreator struct{}
+
+func init() { RegisterJobCreator(StatsUpdateJobKind, statsUpdateJobCreator{}) }
+
+func (statsUpdateJobCreator) Description() string { return "statsupdate" }
+
+func (statsUpdateJobCreator) AutoAccept() bool { return true }
+
+func (statsUpdateJobCreator) Create() Job { return new(StatsUpdate) }
+
+func (statsUpdateJobCreator) Depends() [2][]string { return [2][]string{{}, {}} }
+
+func (statsUpdateJobCreator) StageDone(context.Context, *sql.Tx, int64, Feedback) error {
+	return nil
+}
+
+// RequiresRoles enforces to be a sys_admin to run this .
+func (*StatsUpdate) RequiresRoles() auth.Roles { return auth.Roles{"sys_admin"} }
+
+func (su *StatsUpdate) Description() (string, error) { return su.Name, nil }
+
+func (*StatsUpdate) CleanUp() error { return nil }
+
+func (su *StatsUpdate) MarshalAttributes(attrs common.Attributes) error {
+	if err := su.QueueConfigurationType.MarshalAttributes(attrs); err != nil {
+		return err
+	}
+	attrs.Set("name", su.Name)
+	return nil
+}
+
+func (su *StatsUpdate) UnmarshalAttributes(attrs common.Attributes) error {
+	if err := su.QueueConfigurationType.UnmarshalAttributes(attrs); err != nil {
+		return err
+	}
+	name, found := attrs.Get("name")
+	if !found {
+		return errors.New("missing 'name' attribute")
+	}
+	su.Name = name
+	return nil
+}
+
+const loadUpdateStatsScriptSQL = `SELECT script FROM sys_admin.stats_updates WHERE name = $1`
+
+func (su *StatsUpdate) Do(
+	ctx context.Context,
+	importID int64,
+	conn *sql.Conn,
+	feedback Feedback,
+) (interface{}, error) {
+
+	start := time.Now()
+
+	feedback.Info("Running stats update %s.", su.Name)
+
+	tx, err := conn.BeginTx(ctx, nil)
+	if err != nil {
+		return nil, err
+	}
+	defer tx.Rollback()
+
+	var script string
+	switch err := tx.QueryRowContext(ctx, loadUpdateStatsScriptSQL, su.Name).Scan(&script); {
+	case err == sql.ErrNoRows:
+		return nil, fmt.Errorf("no update script found for '%s'", su.Name)
+	case err != nil:
+		return nil, err
+	}
+
+	if _, err := tx.ExecContext(ctx, script); err != nil {
+		return nil, err
+	}
+
+	if err := tx.Commit(); err != nil {
+		return nil, err
+	}
+
+	feedback.Info("Running stats update took %v.", time.Since(start))
+
+	return nil, nil
+}
--- a/pkg/mesh/polygon.go	Sun Jul 04 11:37:37 2021 +0200
+++ b/pkg/mesh/polygon.go	Wed Jul 07 11:44:40 2021 +0200
@@ -44,7 +44,7 @@
 	IntersectionOverlaps
 )
 
-func (ls lineSegment) Rect(interface{}) ([]float64, []float64) {
+func (ls lineSegment) Rect() ([2]float64, [2]float64) {
 
 	var min, max [2]float64
 
@@ -64,14 +64,14 @@
 		max[1] = ls[1]
 	}
 
-	return min[:], max[:]
+	return min, max
 }
 
 func (p *Polygon) Indexify() {
 	indices := make([]*rtree.RTree, len(p.rings))
 
 	for i := range indices {
-		index := rtree.New(nil)
+		index := new(rtree.RTree)
 		indices[i] = index
 
 		rng := p.rings[i]
@@ -83,7 +83,8 @@
 			} else {
 				ls = []float64{rng[i], rng[i+1], rng[0], rng[1]}
 			}
-			index.Insert(ls)
+			min, max := ls.Rect()
+			index.Insert(min, max, ls)
 		}
 	}
 
@@ -217,9 +218,11 @@
 		return IntersectionOutSide
 	}
 
+	min, max := box.Rect()
+
 	for _, index := range p.indices {
 		var intersects bool
-		index.Search(box, func(item rtree.Item) bool {
+		index.Search(min, max, func(_, _ [2]float64, item interface{}) bool {
 			if item.(lineSegment).intersects(box) {
 				intersects = true
 				return false
@@ -252,9 +255,10 @@
 
 func (p *Polygon) IntersectionWithTriangle(t *Triangle) IntersectionType {
 	box := t.BBox()
+	min, max := box.Rect()
 	for _, index := range p.indices {
 		var intersects bool
-		index.Search(box, func(item rtree.Item) bool {
+		index.Search(min, max, func(_, _ [2]float64, item interface{}) bool {
 			ls := item.(lineSegment)
 			other := make(lineSegment, 4)
 			for i := range t {
--- a/pkg/mesh/vertex.go	Sun Jul 04 11:37:37 2021 +0200
+++ b/pkg/mesh/vertex.go	Wed Jul 07 11:44:40 2021 +0200
@@ -784,8 +784,8 @@
 }
 
 // Rect returns the bounding box of this box as separated coordinates.
-func (a Box2D) Rect(interface{}) ([]float64, []float64) {
-	return []float64{a.X1, a.Y1}, []float64{a.X2, a.Y2}
+func (a Box2D) Rect() ([2]float64, [2]float64) {
+	return [2]float64{a.X1, a.Y1}, [2]float64{a.X2, a.Y2}
 }
 
 // Intersects checks if two Box2Ds intersect.
--- a/pkg/misc/mail.go	Sun Jul 04 11:37:37 2021 +0200
+++ b/pkg/misc/mail.go	Wed Jul 07 11:44:40 2021 +0200
@@ -14,11 +14,23 @@
 package misc
 
 import (
+	"io"
+
 	gomail "gopkg.in/gomail.v2"
 
 	"gemma.intevation.de/gemma/pkg/config"
 )
 
+type EmailReceiver struct {
+	Name    string
+	Address string
+}
+
+type EmailAttachment struct {
+	Name    string
+	Content []byte
+}
+
 // SendMail sends an email to a given address with a given subject
 // and body.
 // The credentials to contact the SMPT server are taken from the
@@ -41,3 +53,54 @@
 
 	return d.DialAndSend(m)
 }
+
+func SendMailToAll(
+	receivers []EmailReceiver,
+	subject string,
+	body func(EmailReceiver) (string, error),
+	attachments []EmailAttachment,
+	errorHandler func(EmailReceiver, error) error,
+) error {
+
+	d := gomail.Dialer{
+		Host:      config.MailHost(),
+		Port:      int(config.MailPort()),
+		Username:  config.MailUser(),
+		Password:  config.MailPassword(),
+		LocalName: config.MailHelo(),
+		SSL:       config.MailPort() == 465,
+	}
+
+	s, err := d.Dial()
+	if err != nil {
+		return err
+	}
+	defer s.Close()
+
+	m := gomail.NewMessage()
+	for _, r := range receivers {
+		m.SetHeader("From", config.MailFrom())
+		m.SetAddressHeader("To", r.Address, r.Name)
+		m.SetHeader("Subject", subject)
+		b, err := body(r)
+		if err != nil {
+			return err
+		}
+		m.SetBody("text/plain", b)
+		for _, at := range attachments {
+			content := at.Content
+			m.Attach(at.Name, gomail.SetCopyFunc(func(w io.Writer) error {
+				_, err := w.Write(content)
+				return err
+			}))
+		}
+
+		if err := gomail.Send(s, m); err != nil {
+			if err = errorHandler(r, err); err != nil {
+				return err
+			}
+		}
+		m.Reset()
+	}
+	return nil
+}
--- a/pkg/models/common.go	Sun Jul 04 11:37:37 2021 +0200
+++ b/pkg/models/common.go	Wed Jul 07 11:44:40 2021 +0200
@@ -18,6 +18,7 @@
 	"encoding/json"
 	"errors"
 	"fmt"
+	"regexp"
 	"strings"
 	"time"
 
@@ -40,6 +41,9 @@
 	Country string
 	// UniqueCountries is a list of unique countries.
 	UniqueCountries []Country
+
+	// SafePath should only contain chars that directory traversal safe.
+	SafePath string
 )
 
 func (d Date) MarshalJSON() ([]byte, error) {
@@ -149,3 +153,25 @@
 	}
 	return b.String()
 }
+
+const SafePathExp = "[a-zA-Z0-9_-]+"
+
+var safePathRegExp = regexp.MustCompile("^" + SafePathExp + "$")
+
+func (sp SafePath) Valid() bool {
+	return safePathRegExp.MatchString(string(sp))
+}
+
+// UnmarshalJSON ensures that the given string only consist
+// of runes that are directory traversal safe.
+func (sp *SafePath) UnmarshalJSON(data []byte) error {
+	var s string
+	if err := json.Unmarshal(data, &s); err != nil {
+		return err
+	}
+	if c := SafePath(s); c.Valid() {
+		*sp = c
+		return nil
+	}
+	return fmt.Errorf("'%s' is not a safe path", s)
+}
--- a/pkg/models/user.go	Sun Jul 04 11:37:37 2021 +0200
+++ b/pkg/models/user.go	Wed Jul 07 11:44:40 2021 +0200
@@ -46,9 +46,21 @@
 		Password string       `json:"password,omitempty"`
 		Email    Email        `json:"email"`
 		Country  Country      `json:"country"`
+		Reports  bool         `json:"reports"`
 		Extent   *BoundingBox `json:"extent"`
 	}
 
+	// UserPatch is used to send only partial updates.
+	UserPatch struct {
+		User     *UserName    `json:"user,omitempty"`
+		Role     *Role        `json:"role,omitempty"`
+		Password *string      `json:"password,omitempty"`
+		Email    *Email       `json:"email,omitempty"`
+		Country  *Country     `json:"country,omitempty"`
+		Reports  *bool        `json:"reports,omitempty"`
+		Extent   *BoundingBox `json:"extent,omitempty"`
+	}
+
 	// PWResetUser is send to request a password reset for a user.
 	PWResetUser struct {
 		User string `json:"user"`
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pkg/xlsx/handlebars.go	Wed Jul 07 11:44:40 2021 +0200
@@ -0,0 +1,83 @@
+// This is Free Software under GNU Affero General Public License v >= 3.0
+// without warranty, see README.md and license for details.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// License-Filename: LICENSES/AGPL-3.0.txt
+//
+// Copyright (C) 2021 by via donau
+//   – Österreichische Wasserstraßen-Gesellschaft mbH
+// Software engineering by Intevation GmbH
+//
+// Author(s):
+//  * Sascha L. Teichmann <sascha.teichmann@intevation.de>
+
+package xlsx
+
+import "strings"
+
+func handlebars(s string, replace func(string) string) string {
+
+	var (
+		out, repl strings.Builder
+		mode      int
+	)
+
+	for _, c := range s {
+		switch mode {
+		case 0:
+			if c == '{' {
+				mode = 1
+			} else {
+				out.WriteRune(c)
+			}
+		case 1:
+			if c == '{' {
+				mode = 2
+			} else {
+				out.WriteByte('{')
+				out.WriteRune(c)
+				mode = 0
+			}
+		case 2:
+			switch c {
+			case '\\':
+				mode = 3
+			case '}':
+				mode = 4
+			default:
+				repl.WriteRune(c)
+			}
+		case 3:
+			repl.WriteRune(c)
+			mode = 2
+		case 4:
+			if c == '}' {
+				out.WriteString(replace(repl.String()))
+				repl.Reset()
+				mode = 0
+			} else {
+				repl.WriteByte('}')
+				repl.WriteRune(c)
+				mode = 2
+			}
+		}
+	}
+
+	switch mode {
+	case 1:
+		out.WriteByte('{')
+	case 2:
+		out.WriteString("{{")
+		out.WriteString(repl.String())
+	case 3:
+		out.WriteString("{{")
+		out.WriteString(repl.String())
+		out.WriteByte('\\')
+	case 4:
+		out.WriteString("{{")
+		out.WriteString(repl.String())
+		out.WriteByte('}')
+	}
+
+	return out.String()
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pkg/xlsx/sql.go	Wed Jul 07 11:44:40 2021 +0200
@@ -0,0 +1,110 @@
+// This is Free Software under GNU Affero General Public License v >= 3.0
+// without warranty, see README.md and license for details.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// License-Filename: LICENSES/AGPL-3.0.txt
+//
+// Copyright (C) 2021 by via donau
+//   – Österreichische Wasserstraßen-Gesellschaft mbH
+// Software engineering by Intevation GmbH
+//
+// Author(s):
+//  * Sascha L. Teichmann <sascha.teichmann@intevation.de>
+
+package xlsx
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"strconv"
+	"strings"
+)
+
+type sqlResult struct {
+	columns []string
+	rows    [][]interface{}
+}
+
+func (sr *sqlResult) find(column string) int {
+	for i, name := range sr.columns {
+		if name == column {
+			return i
+		}
+	}
+	return -1
+}
+
+func replaceStmt(stmt string) (string, []string) {
+
+	var names []string
+
+	add := func(name string) int {
+		for i, n := range names {
+			if n == name {
+				return i + 1
+			}
+		}
+		names = append(names, name)
+		return len(names)
+	}
+
+	replace := func(s string) string {
+		n := add(strings.TrimSpace(s))
+		return "$" + strconv.Itoa(n)
+	}
+
+	out := handlebars(stmt, replace)
+	return out, names
+}
+
+func query(
+	ctx context.Context,
+	tx *sql.Tx,
+	stmt string,
+	eval func(string) (interface{}, error),
+) (*sqlResult, error) {
+
+	nstmt, nargs := replaceStmt(stmt)
+	args := make([]interface{}, len(nargs))
+	for i, n := range nargs {
+		var err error
+		if args[i], err = eval(n); err != nil {
+			return nil, err
+		}
+	}
+
+	rs, err := tx.QueryContext(ctx, nstmt, args...)
+	if err != nil {
+		return nil, fmt.Errorf("SQL failed: '%s': %v", nstmt, err)
+	}
+	defer rs.Close()
+
+	columns, err := rs.Columns()
+	if err != nil {
+		return nil, err
+	}
+	var rows [][]interface{}
+
+	ptrs := make([]interface{}, len(columns))
+
+	for rs.Next() {
+		row := make([]interface{}, len(columns))
+		for i := range row {
+			ptrs[i] = &row[i]
+		}
+		if err := rs.Scan(ptrs...); err != nil {
+			return nil, err
+		}
+		rows = append(rows, row)
+	}
+
+	if err := rs.Err(); err != nil {
+		return nil, err
+	}
+
+	return &sqlResult{
+		columns: columns,
+		rows:    rows,
+	}, nil
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pkg/xlsx/templater.go	Wed Jul 07 11:44:40 2021 +0200
@@ -0,0 +1,672 @@
+// This is Free Software under GNU Affero General Public License v >= 3.0
+// without warranty, see README.md and license for details.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// License-Filename: LICENSES/AGPL-3.0.txt
+//
+// Copyright (C) 2021 by via donau
+//   – Österreichische Wasserstraßen-Gesellschaft mbH
+// Software engineering by Intevation GmbH
+//
+// Author(s):
+//  * Sascha L. Teichmann <sascha.teichmann@intevation.de>
+
+package xlsx
+
+import (
+	"bufio"
+	"context"
+	"database/sql"
+	"errors"
+	"fmt"
+	"io"
+	"log"
+	"os"
+	"strconv"
+	"strings"
+
+	"github.com/360EntSecGroup-Skylar/excelize/v2"
+	"gopkg.in/yaml.v2"
+
+	"github.com/PaesslerAG/gval"
+)
+
+type Action struct {
+	Type        string    `yaml:"type"`
+	Actions     []*Action `yaml:"actions"`
+	Location    []string  `yaml:"location"`
+	Source      string    `yaml:"source"`
+	Destination string    `yaml:"destination"`
+	Statement   string    `yaml:"statement"`
+	Vars        []string  `yaml:"vars"`
+	Name        string    `yaml:"name"`
+	Expr        string    `yaml:"expr"`
+}
+
+type frame struct {
+	res   *sqlResult
+	index int
+}
+
+type sheetAxis struct {
+	sheet string
+	axis  string
+}
+
+type cellValue struct {
+	value string
+	err   error
+}
+
+type executor struct {
+	ctx              context.Context
+	tx               *sql.Tx
+	template         *excelize.File
+	keep             map[string]bool
+	expressions      map[string]gval.Evaluable
+	sourceSheet      string
+	destinationSheet string
+	frames           []frame
+	// Fetching formulas out of cells is very expensive so we cache them.
+	formulaCache map[sheetAxis]cellValue
+}
+
+type area struct {
+	x1 int
+	y1 int
+	x2 int
+	y2 int
+	mc excelize.MergeCell
+}
+
+func mergeCellToArea(mc excelize.MergeCell) (area, error) {
+	x1, y1, err := excelize.CellNameToCoordinates(mc.GetStartAxis())
+	if err != nil {
+		return area{}, err
+	}
+	x2, y2, err := excelize.CellNameToCoordinates(mc.GetEndAxis())
+	if err != nil {
+		return area{}, err
+	}
+	return area{
+		x1: x1,
+		y1: y1,
+		x2: x2,
+		y2: y2,
+		mc: mc,
+	}, nil
+}
+
+func (a *area) contains(x, y int) bool {
+	return a.x1 <= x && x <= a.x2 && a.y1 <= y && y <= a.y2
+}
+
+func ActionFromFile(filename string) (*Action, error) {
+	f, err := os.Open(filename)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+	return ActionFromReader(f)
+}
+
+func ActionFromReader(r io.Reader) (*Action, error) {
+	action := new(Action)
+	err := yaml.NewDecoder(bufio.NewReader(r)).Decode(action)
+	return action, err
+}
+
+func (a *Action) Execute(
+	ctx context.Context,
+	tx *sql.Tx,
+	template *excelize.File,
+) error {
+
+	//if f, err := os.Create("cpu.prof"); err == nil {
+	//	pprof.StartCPUProfile(f)
+	//	defer pprof.StopCPUProfile()
+	//}
+
+	e := executor{
+		ctx:          ctx,
+		tx:           tx,
+		template:     template,
+		keep:         map[string]bool{},
+		expressions:  map[string]gval.Evaluable{},
+		formulaCache: map[sheetAxis]cellValue{},
+	}
+
+	oldSheets := template.GetSheetList()
+
+	if err := e.dispatch(a); err != nil {
+		return err
+	}
+
+	for _, sheet := range oldSheets {
+		if !e.keep[sheet] {
+			template.DeleteSheet(sheet)
+		}
+	}
+	return nil
+}
+
+var reused int
+
+func (e *executor) getCellFormula(sheet, axis string) (string, error) {
+	var (
+		k  = sheetAxis{sheet: sheet, axis: axis}
+		v  cellValue
+		ok bool
+	)
+	if v, ok = e.formulaCache[k]; !ok {
+		v.value, v.err = e.template.GetCellFormula(sheet, axis)
+		e.formulaCache[k] = v
+	}
+	return v.value, v.err
+}
+
+func (e *executor) setCellFormula(sheet, axis, formula string) {
+	e.formulaCache[sheetAxis{sheet: sheet, axis: axis}] = cellValue{value: formula}
+	e.template.SetCellFormula(sheet, axis, formula)
+}
+
+func (e *executor) dispatch(action *Action) error {
+	if len(action.Vars) > 0 {
+		e.pushVars(action.Vars)
+		defer e.popFrame()
+	}
+	switch action.Type {
+	case "sheet":
+		return e.sheet(action)
+	case "copy":
+		return e.copy(action)
+	case "select":
+		return e.sel(action)
+	case "assign":
+		return e.assign(action)
+	case "":
+		return e.actions(action)
+	}
+	return fmt.Errorf("unknown type '%s'", action.Type)
+}
+
+func (e *executor) pushVars(vars []string) {
+	e.frames = append(e.frames, frame{
+		res: &sqlResult{
+			columns: vars,
+			rows:    [][]interface{}{make([]interface{}, len(vars))},
+		},
+	})
+}
+
+func (e *executor) popFrame() {
+	n := len(e.frames)
+	e.frames[n-1].res = nil
+	e.frames = e.frames[:n-1]
+}
+
+func (e *executor) assign(action *Action) error {
+	if action.Name == "" {
+		return errors.New("missing name in assign")
+	}
+	if action.Expr == "" {
+		return errors.New("missing expr in assign")
+	}
+
+	for i := len(e.frames) - 1; i >= 0; i-- {
+		fr := &e.frames[i]
+		if idx := fr.res.find(action.Name); idx >= 0 {
+			f, err := e.expr(action.Expr)
+			if err != nil {
+				return err
+			}
+			value, err := f(e.ctx, e.vars())
+			if err != nil {
+				return err
+			}
+			fr.res.rows[fr.index][idx] = value
+			break
+		}
+	}
+	return e.actions(action)
+}
+
+func order(a, b int) (int, int) {
+	if a < b {
+		return a, b
+	}
+	return b, a
+}
+
+func (e *executor) copy(action *Action) error {
+	if n := len(action.Location); !(n == 1 || n == 2) {
+		return fmt.Errorf("length location = %d (expect 1 or 2)",
+			len(action.Location))
+	}
+
+	vars := e.vars()
+
+	var err error
+	expand := func(s string) string {
+		if err == nil {
+			s, err = e.expand(s, vars)
+		}
+		return s
+	}
+	split := func(s string) (int, int) {
+		var x, y int
+		if err == nil {
+			x, y, err = excelize.CellNameToCoordinates(s)
+		}
+		return x, y
+	}
+
+	var location []string
+	if len(action.Location) == 1 {
+		location = []string{action.Location[0], action.Location[0]}
+	} else {
+		location = action.Location
+	}
+
+	var destination string
+	if action.Destination == "" {
+		destination = location[0]
+	} else {
+		destination = action.Destination
+	}
+
+	var (
+		s1       = expand(location[0])
+		s2       = expand(location[1])
+		d1       = expand(destination)
+		sx1, sy1 = split(s1)
+		sx2, sy2 = split(s2)
+		dx1, dy1 = split(d1)
+	)
+	if err != nil {
+		return err
+	}
+	sx1, sx2 = order(sx1, sx2)
+	sy1, sy2 = order(sy1, sy2)
+
+	var areas []area
+
+	//log.Println("merged cells")
+	if mcs, err := e.template.GetMergeCells(e.sourceSheet); err == nil {
+		areas = make([]area, 0, len(mcs))
+		for _, mc := range mcs {
+			if a, err := mergeCellToArea(mc); err == nil {
+				areas = append(areas, a)
+			}
+		}
+	}
+
+	for y, i := sy1, 0; y <= sy2; y, i = y+1, i+1 {
+	nextX:
+		for x, j := sx1, 0; x <= sx2; x, j = x+1, j+1 {
+
+			// check if cell is part of a merged cell
+			for k := range areas {
+				area := &areas[k]
+
+				if area.contains(x, y) {
+					ofsX := x - area.x1
+					ofsY := y - area.y1
+
+					sx := dx1 + j - ofsX
+					sy := dy1 + i - ofsY
+					ex := sx + (area.x2 - area.x1)
+					ey := sy + (area.y2 - area.y1)
+
+					// Copy over attributes
+					for l := 0; l <= area.x2-area.x1; l++ {
+						for m := 0; m <= area.y2-area.y1; m++ {
+							src, err1 := excelize.CoordinatesToCellName(area.x1+l, area.y1+m)
+							dst, err2 := excelize.CoordinatesToCellName(sx+l, sy+m)
+							if err1 != nil || err2 != nil {
+								continue
+							}
+							if s, err := e.template.GetCellStyle(e.sourceSheet, src); err == nil {
+								e.template.SetCellStyle(e.destinationSheet, dst, dst, s)
+							}
+							if s, err := e.getCellFormula(e.sourceSheet, src); err == nil {
+								e.setCellFormula(e.destinationSheet, dst, s)
+							}
+						}
+					}
+
+					dst, err := excelize.CoordinatesToCellName(sx, sy)
+					if err != nil {
+						continue nextX
+					}
+
+					// Copy over expanded text
+					if v, err := e.typedExpand(area.mc.GetCellValue(), vars); err == nil {
+						e.template.SetCellValue(e.destinationSheet, dst, v)
+					}
+
+					// Finally merge the cells
+					if end, err := excelize.CoordinatesToCellName(ex, ey); err == nil {
+						e.template.MergeCell(e.destinationSheet, dst, end)
+					}
+
+					continue nextX
+				}
+			}
+
+			// Regular cell
+
+			src, err := excelize.CoordinatesToCellName(x, y)
+			if err != nil {
+				continue
+			}
+			dst, err := excelize.CoordinatesToCellName(dx1+j, dy1+i)
+			if err != nil {
+				continue
+			}
+
+			cn, err := excelize.ColumnNumberToName(x)
+			if err != nil {
+				continue
+			}
+
+			cw, err := e.template.GetColWidth(e.sourceSheet, cn)
+			if err != nil {
+				continue
+			}
+
+			rh, err := e.template.GetRowHeight(e.sourceSheet, y)
+			if err != nil {
+				continue
+			}
+
+			dc, err := excelize.ColumnNumberToName(dx1 + j)
+			if err != nil {
+				continue
+			}
+
+			if e.template.SetColWidth(e.destinationSheet, dc, dc, cw) != nil {
+				continue
+			}
+
+			if e.template.SetRowHeight(e.destinationSheet, dy1+i, rh) != nil {
+				continue
+			}
+
+			if s, err := e.template.GetCellStyle(e.sourceSheet, src); err == nil {
+				e.template.SetCellStyle(e.destinationSheet, dst, dst, s)
+			}
+			if s, err := e.getCellFormula(e.sourceSheet, src); err == nil {
+				e.setCellFormula(e.destinationSheet, dst, s)
+			}
+			if s, err := e.template.GetCellValue(e.sourceSheet, src); err == nil {
+				if v, err := e.typedExpand(s, vars); err == nil {
+					e.template.SetCellValue(e.destinationSheet, dst, v)
+				}
+			}
+		}
+	}
+
+	return nil
+}
+
+func (e *executor) sel(action *Action) error {
+	vars := e.vars()
+
+	eval := func(x string) (interface{}, error) {
+		f, err := e.expr(x)
+		if err != nil {
+			return nil, err
+		}
+		return f(e.ctx, vars)
+	}
+
+	res, err := query(e.ctx, e.tx, action.Statement, eval)
+	if err != nil {
+		return err
+	}
+
+	e.frames = append(e.frames, frame{res: res})
+	defer e.popFrame()
+
+	for i := range res.rows {
+		e.frames[len(e.frames)-1].index = i
+		if err := e.actions(action); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (e *executor) actions(action *Action) error {
+	for _, a := range action.Actions {
+		if err := e.dispatch(a); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (e *executor) sheet(action *Action) error {
+
+	vars := e.vars()
+	source, err := e.expand(action.Source, vars)
+	if err != nil {
+		return err
+	}
+
+	srcIdx := e.template.GetSheetIndex(source)
+	if srcIdx == -1 {
+		return fmt.Errorf("sheet '%s' not found", source)
+	}
+
+	destination := action.Destination
+	if destination == "" { // same as source
+		e.keep[source] = true
+		destination = source
+	} else { // new sheet
+		destination, err = e.expand(destination, vars)
+		if err != nil {
+			return err
+		}
+		dstIdx := e.template.NewSheet(destination)
+		if len(action.Actions) == 0 {
+			// Only copy if there are no explicit instructions.
+			if err := e.template.CopySheet(srcIdx, dstIdx); err != nil {
+				return err
+			}
+		}
+	}
+
+	if len(action.Actions) > 0 {
+		pSrc, pDst := e.sourceSheet, e.destinationSheet
+		defer func() {
+			e.sourceSheet, e.destinationSheet = pSrc, pDst
+		}()
+		e.sourceSheet, e.destinationSheet = source, destination
+		return e.actions(action)
+	}
+
+	// Simple filling
+
+	// "{{" only as a quick filter
+	result, err := e.template.SearchSheet(destination, "{{", true)
+	if err != nil {
+		return err
+	}
+	for _, axis := range result {
+		value, err := e.template.GetCellValue(destination, axis)
+		if err != nil {
+			return err
+		}
+		nvalue, err := e.typedExpand(value, vars)
+		if err != nil {
+			return err
+		}
+		if err := e.template.SetCellValue(destination, axis, nvalue); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func columnToNum(col interface{}) interface{} {
+	var name string
+	switch v := col.(type) {
+	case string:
+		name = v
+	default:
+		name = fmt.Sprintf("%v", col)
+	}
+	num, err := excelize.ColumnNameToNumber(name)
+	if err != nil {
+		log.Printf("error: invalid column name '%v'\n", col)
+		return 1
+	}
+	return num
+}
+
+func asInt(i interface{}) (int, error) {
+	switch v := i.(type) {
+	case int:
+		return v, nil
+	case int8:
+		return int(v), nil
+	case int16:
+		return int(v), nil
+	case int32:
+		return int(v), nil
+	case int64:
+		return int(v), nil
+	case float32:
+		return int(v), nil
+	case float64:
+		return int(v), nil
+	case string:
+		return strconv.Atoi(v)
+	default:
+		return 0, fmt.Errorf("invalid int '%v'", i)
+	}
+}
+
+func coord2cell(ix, iy interface{}) interface{} {
+	x, err := asInt(ix)
+	if err != nil {
+		log.Printf("error: invalid x value: %v\n", err)
+		return "A1"
+	}
+	y, err := asInt(iy)
+	if err != nil {
+		log.Printf("error: invalid y value: %v\n", err)
+		return "A1"
+	}
+
+	cell, err := excelize.CoordinatesToCellName(x, y)
+	if err != nil {
+		log.Printf("error: invalid cell coord (%d, %d)\n", x, y)
+		return "A1"
+	}
+	return cell
+}
+
+var templateLang = gval.Full(
+	gval.Function("column2num", columnToNum),
+	gval.Function("coord2cell", coord2cell),
+)
+
+func (e *executor) expr(x string) (gval.Evaluable, error) {
+	if f := e.expressions[x]; f != nil {
+		return f, nil
+	}
+	f, err := templateLang.NewEvaluable(x)
+	if err != nil {
+		return nil, err
+	}
+	e.expressions[x] = f
+	return f, nil
+}
+
+func (e *executor) vars() map[string]interface{} {
+	vars := map[string]interface{}{}
+	if len(e.frames) > 0 {
+		vars["row_number"] = e.frames[len(e.frames)-1].index
+	}
+	for i := len(e.frames) - 1; i >= 0; i-- {
+		fr := &e.frames[i]
+		for j, n := range fr.res.columns {
+			if _, found := vars[n]; !found {
+				vars[n] = fr.res.rows[fr.index][j]
+			}
+		}
+	}
+	return vars
+}
+
+func (e *executor) expand(
+	str string,
+	vars map[string]interface{},
+) (string, error) {
+
+	var err error
+
+	replace := func(s string) string {
+		if err != nil {
+			return ""
+		}
+		var eval gval.Evaluable
+		if eval, err = e.expr(strings.TrimSpace(s)); err != nil {
+			return ""
+		}
+		s, err = eval.EvalString(e.ctx, vars)
+		if err != nil {
+			log.Printf("error: '%s' '%s' %v\n", str, s, err)
+		}
+		return s
+	}
+
+	str = handlebars(str, replace)
+	return str, err
+}
+
+func (e *executor) typedExpand(
+	str string,
+	vars map[string]interface{},
+) (interface{}, error) {
+
+	var (
+		err      error
+		repCount int
+		last     interface{}
+	)
+
+	replace := func(s string) string {
+		if err != nil {
+			return ""
+		}
+		var eval gval.Evaluable
+		if eval, err = e.expr(strings.TrimSpace(s)); err != nil {
+			return ""
+		}
+		repCount++
+		last, err = eval(e.ctx, vars)
+		if err != nil {
+			log.Printf("error: '%s' '%s' %v\n", str, s, err)
+		}
+		return fmt.Sprintf("%v", last)
+	}
+
+	nstr := handlebars(str, replace)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if repCount == 1 &&
+		strings.HasPrefix(str, "{{") &&
+		strings.HasSuffix(str, "}}") {
+		return last, nil
+	}
+	return nstr, nil
+}
Binary file report-templates/data-quality-report.xlsx has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/report-templates/data-quality-report.yaml	Wed Jul 07 11:44:40 2021 +0200
@@ -0,0 +1,119 @@
+actions:
+# One sheet per CC
+- type: select
+  statement: >
+    SELECT cc FROM
+      (VALUES ('AT'), ('SK'), ('HU'), ('HR'), ('RS'), ('BG'), ('RO') )
+      AS t (cc);
+  actions:
+  - type: sheet
+    source: CCTmpl
+    destination: "{{ cc }}"
+    vars: [last_row, column_number, offset]
+    actions:
+    # Header:
+    - type: copy
+      location: [A1,A5]
+    # BN names
+    - type: select
+      statement: >
+        SELECT DISTINCT objnam AS bnnam
+          FROM waterway.dqr_bottleneck_stats
+          WHERE cc = {{ cc }} ORDER BY objnam;
+      actions:
+      - type: assign
+        name: last_row
+        expr: (row_number ?? 0) + 6
+      - type: copy
+        location: [A6]
+        destination: A{{ last_row }}
+    - type: copy
+      location: [A7]
+      destination: A{{ (last_row ?? 6) + 1 }}
+    # Gen Months
+    - type: select
+      statement: >
+        SELECT to_char(d, 'Month YYYY') AS month, d::date
+          FROM generate_series( '2019-10-01'::date,
+                                now() - interval '1 day',
+                                '1 month'::interval ) d;
+      actions:
+      - type: assign
+        name: column_number
+        expr: column2num("B") + (row_number ?? 0) * 2
+      - type: copy
+        location: [B4,C5]
+        destination: '{{ coord2cell(column_number, 4) }}'
+      # BN SR-Count
+      - type: select
+        statement: >
+          SELECT objnam, srcnt, fwacnt
+            FROM waterway.dqr_bottleneck_stats
+            WHERE cc = {{ cc }} AND month = {{ d }}
+            ORDER BY objnam;
+        actions:
+        - type: assign
+          name: last_row
+          expr: (row_number ?? 0) + 6
+        - type: copy
+          location: [B6,C6]
+          destination: '{{ coord2cell(column_number, last_row) }}'
+      - type: copy
+        location: [B7,C7]
+        destination: '{{ coord2cell(column_number, (last_row ?? 0) + 1) }}'
+    - type: assign
+      name: offset
+      expr: (last_row ?? 0) + 3
+    #--------------------------------------------------------------------------
+    # GAUGES
+    # Header:
+    - type: copy
+      location: [A9]
+      destination: A{{ offset }}
+    # Gauges names
+    - type: select
+      statement: >
+        SELECT DISTINCT objname AS gnam FROM waterway.dqr_gauge_stats
+          WHERE cc = {{ cc }} ORDER BY objname;
+      actions:
+      - type: assign
+        name: last_row
+        expr: (row_number ?? 0) + offset + 1
+      - type: copy
+        location: [A10]
+        destination: A{{ last_row }}
+    - type: copy
+      location: [A11]
+      destination: A{{ (last_row ?? 10) + 1 }}
+    # Gen Months
+    - type: select
+      statement: >
+        SELECT to_char(d, 'Month YYYY') AS month, d::date
+          FROM generate_series( '2019-10-01'::date,
+                                now() - interval '1 day',
+                                '1 month'::interval ) d;
+      actions:
+      - type: assign
+        name: column_number
+        expr: column2num("B") + (row_number ?? 0)
+      - type: copy
+        location: [B9]
+        destination: '{{ coord2cell(column_number, offset) }}'
+      # BN SR-Count
+      - type: select
+        statement: >
+          SELECT objname, daynodata
+            FROM waterway.dqr_gauge_stats
+            WHERE cc = {{ cc }} AND month = {{ d }}
+            ORDER BY objname;
+        actions:
+        - type: assign
+          name: last_row
+          expr: (row_number ?? 0) + offset + 1
+        - type: copy
+          location: [B10]
+          destination: '{{ coord2cell(column_number, last_row) }}'
+      - type: copy
+        location: [B11]
+        destination: '{{ coord2cell(column_number, (last_row ?? 10) + 1) }}'
+
--- a/schema/auth.sql	Sun Jul 04 11:37:37 2021 +0200
+++ b/schema/auth.sql	Wed Jul 07 11:44:40 2021 +0200
@@ -62,6 +62,7 @@
 GRANT UPDATE ON sys_admin.published_services TO sys_admin;
 GRANT INSERT, DELETE, UPDATE ON sys_admin.password_reset_requests TO sys_admin;
 GRANT DELETE ON import.imports, import.import_logs TO sys_admin;
+GRANT SELECT, INSERT, DELETE, UPDATE ON sys_admin.stats_updates TO sys_admin;
 
 --
 -- Privileges assigned directly to metamorph
--- a/schema/gemma.sql	Sun Jul 04 11:37:37 2021 +0200
+++ b/schema/gemma.sql	Wed Jul 07 11:44:40 2021 +0200
@@ -384,7 +384,8 @@
         -- keep username length compatible with role identifier
         country char(2) NOT NULL REFERENCES countries,
         map_extent box2d NOT NULL,
-        email_address varchar NOT NULL
+        email_address varchar NOT NULL,
+        report_reciever boolean NOT NULL DEFAULT false
     )
 ;
 
@@ -445,6 +446,12 @@
        UNIQUE (group_name, schema, name, ord),
        FOREIGN KEY(schema, name) REFERENCES published_services
     )
+
+    -- Table to store scripts which updates aggregated data.
+    CREATE TABLE stats_updates (
+        name   varchar PRIMARY key,
+        script TEXT    NULL
+    )
 ;
 
 
@@ -492,7 +499,8 @@
             CAST('' AS varchar) AS pw,
             p.country,
             p.map_extent,
-            p.email_address
+            p.email_address,
+            p.report_reciever
         FROM internal.user_profiles p
             JOIN pg_roles u ON p.username = u.rolname
             JOIN pg_auth_members a ON u.oid = a.member
--- a/schema/install-db.sh	Sun Jul 04 11:37:37 2021 +0200
+++ b/schema/install-db.sh	Wed Jul 07 11:44:40 2021 +0200
@@ -126,6 +126,7 @@
        -f "$BASEDIR/roles.sql" \
        -f "$BASEDIR/isrs.sql" \
        -f "$BASEDIR/gemma.sql" \
+       -f "$BASEDIR/reports.sql" \
        -f "$BASEDIR/geo_functions.sql" \
        -f "$BASEDIR/search_functions.sql" \
        -f "$BASEDIR/geonames.sql" \
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/reports.sql	Wed Jul 07 11:44:40 2021 +0200
@@ -0,0 +1,89 @@
+-- This is Free Software under GNU Affero General Public License v >= 3.0
+-- without warranty, see README.md and license for details.
+
+-- SPDX-License-Identifier: AGPL-3.0-or-later
+-- License-Filename: LICENSES/AGPL-3.0.txt
+
+-- Copyright (C) 2021 by via donau
+--   – Österreichische Wasserstraßen-Gesellschaft mbH
+-- Software engineering by Intevation GmbH
+
+-- Author(s):
+--  * Sascha Wilde <sascha.wilde@intevation.de>
+
+
+-- Materialized Views with statistical data for data quality reports
+CREATE MATERIALIZED VIEW waterway.dqr_gauge_stats AS
+WITH d AS ( SELECT ym::date, d::date
+              FROM generate_series( '2019-10-01'::date,
+                                    now() - interval '1 day',
+                                    '1 month'::interval ) ym,
+                   generate_series( ym,
+                                    ( ym + interval '1 month'
+                                         - interval '1 day'),
+                                     '1 day'::interval ) d ),
+     g AS ( SELECT d.ym, d.d AS day, (location).country_code AS cc,
+                   objname, array_agg(distinct(location)) AS locations
+            FROM waterway.gauges,d
+            GROUP BY objname,d.d,(location).country_code,d.ym ),
+     measure AS (
+       SELECT g.ym, g.cc, g.objname, g.day,
+              CASE WHEN count(measure_date) = 0
+              THEN 1 ELSE 0 END AS missing
+         FROM g
+         LEFT OUTER JOIN waterway.gauge_measurements gm
+           ON ARRAY[location] <@ g.locations
+             AND g.day <= measure_date
+             AND measure_date < (g.day + interval '1 day')
+         GROUP BY g.objname,g.day,g.ym,g.cc )
+  SELECT cc, ym AS month, objname, sum(missing) AS daynodata
+    FROM measure
+    GROUP BY cc, ym, objname;
+
+CREATE MATERIALIZED VIEW waterway.dqr_bottleneck_stats AS
+WITH d AS ( SELECT ym::date
+              FROM generate_series( '2019-10-01'::date,
+                                    now() - interval '1 day',
+                                    '1 month'::interval ) ym),
+     bn AS ( SELECT DISTINCT objnam, responsible_country AS cc
+               FROM waterway.bottlenecks ),
+     bid AS (SELECT objnam, array_agg(distinct(bottleneck_id)) AS ids
+               FROM waterway.bottlenecks GROUP BY objnam)
+  SELECT bn.cc, d.ym AS month, bid.objnam,
+         COALESCE(count(distinct(sr.date_info)),0) AS srcnt,
+         COALESCE(count(distinct(efa.measure_date)),0) AS fwacnt
+    FROM bn, bid
+    CROSS JOIN d
+    LEFT OUTER JOIN waterway.sounding_results sr
+      ON ARRAY[sr.bottleneck_id] <@ bid.ids
+         AND d.ym <= sr.date_info
+         AND sr.date_info < (d.ym + interval '1 month')
+    LEFT OUTER JOIN waterway.fairway_availability fa
+      ON ARRAY[fa.bottleneck_id] <@ bid.ids
+    LEFT OUTER JOIN waterway.effective_fairway_availability efa
+      ON fairway_availability_id = fa.id
+         AND d.ym <= efa.measure_date
+         AND efa.measure_date < (d.ym + interval '1 month')
+         AND efa.measure_type = 'Measured'
+    WHERE bid.objnam = bn.objnam
+    GROUP BY bn.cc, d.ym, bid.objnam;
+
+-- We need a wrapper procedure with owner rights for
+-- the refresh, as (from the PGSQL manual): "REFRESH MATERIALIZED VIEW
+-- completely replaces the contents of a materialized view. To execute
+-- this command you must be the owner of the materialized view.""
+
+CREATE OR REPLACE PROCEDURE sys_admin.update_dqr_stats()
+LANGUAGE plpgsql AS $$
+BEGIN
+    EXECUTE 'REFRESH MATERIALIZED VIEW waterway.dqr_bottleneck_stats';
+    EXECUTE 'REFRESH MATERIALIZED VIEW waterway.dqr_gauge_stats';
+END;
+$$ SECURITY DEFINER;
+
+GRANT EXECUTE ON PROCEDURE sys_admin.update_dqr_stats() TO sys_admin;
+
+-- Config update statement
+INSERT INTO sys_admin.stats_updates
+    VALUES ('Data quality report',
+            'CALL sys_admin.update_dqr_stats();');
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/updates/1450/01.report_reciever.sql	Wed Jul 07 11:44:40 2021 +0200
@@ -0,0 +1,101 @@
+ALTER TABLE internal.user_profiles
+  ADD COLUMN report_reciever boolean NOT NULL DEFAULT false;
+
+CREATE OR REPLACE VIEW users.list_users WITH (security_barrier) AS
+    SELECT
+        r.rolname,
+        p.username,
+        CAST('' AS varchar) AS pw,
+        p.country,
+        p.map_extent,
+        p.email_address,
+        p.report_reciever
+    FROM internal.user_profiles p
+        JOIN pg_roles u ON p.username = u.rolname
+        JOIN pg_auth_members a ON u.oid = a.member
+        JOIN pg_roles r ON a.roleid = r.oid
+    WHERE p.username = current_user
+        OR pg_has_role('waterway_admin', 'MEMBER')
+            AND p.country = (
+                SELECT country FROM internal.user_profiles
+                    WHERE username = current_user)
+            AND r.rolname <> 'sys_admin'
+        OR pg_has_role('sys_admin', 'MEMBER')
+;
+
+CREATE OR REPLACE FUNCTION internal.update_user() RETURNS trigger
+AS $$
+DECLARE
+    cur_username varchar;
+BEGIN
+    cur_username = OLD.username;
+
+    IF NEW.username <> cur_username
+    THEN
+        EXECUTE format(
+            'ALTER ROLE %I RENAME TO %I', cur_username, NEW.username);
+        cur_username = NEW.username;
+    END IF;
+
+    UPDATE internal.user_profiles p
+        SET (username, country, map_extent, email_address, report_reciever)
+        = (NEW.username, NEW.country, NEW.map_extent, NEW.email_address, NEW.report_reciever)
+        WHERE p.username = cur_username;
+
+    IF NEW.rolname <> OLD.rolname
+    THEN
+        EXECUTE format(
+            'REVOKE %I FROM %I', OLD.rolname, cur_username);
+        EXECUTE format(
+            'GRANT %I TO %I', NEW.rolname, cur_username);
+    END IF;
+
+    IF NEW.pw IS NOT NULL AND NEW.pw <> ''
+    THEN
+        EXECUTE format(
+            'ALTER ROLE %I PASSWORD %L',
+            cur_username,
+            internal.check_password(NEW.pw));
+    END IF;
+
+    -- Do not leak new password
+    NEW.pw = '';
+    RETURN NEW;
+END;
+$$
+    LANGUAGE plpgsql
+    SECURITY DEFINER;
+
+CREATE OR REPLACE FUNCTION internal.create_user() RETURNS trigger
+AS $$
+BEGIN
+    IF NEW.map_extent IS NULL
+    THEN
+        NEW.map_extent = ST_Extent(CAST(area AS geometry))
+            FROM users.stretches st
+                JOIN users.stretch_countries stc ON stc.stretch_id = st.id
+            WHERE stc.country = NEW.country;
+    END IF;
+
+    IF NEW.username IS NOT NULL
+    -- otherwise let the constraint on user_profiles speak
+    THEN
+        EXECUTE format(
+            'CREATE ROLE %I IN ROLE %I LOGIN PASSWORD %L',
+            NEW.username,
+            NEW.rolname,
+            internal.check_password(NEW.pw));
+    END IF;
+
+    INSERT INTO internal.user_profiles (
+        username, country, map_extent, email_address, report_reciever)
+        VALUES (NEW.username, NEW.country, NEW.map_extent, NEW.email_address, NEW.report_reciever);
+
+    -- Do not leak new password
+    NEW.pw = '';
+    RETURN NEW;
+END;
+$$
+    LANGUAGE plpgsql
+    SECURITY DEFINER;
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/updates/1451/01.stats_updates.sql	Wed Jul 07 11:44:40 2021 +0200
@@ -0,0 +1,6 @@
+CREATE TABLE sys_admin.stats_updates (
+  name   varchar PRIMARY key,
+  script TEXT    NULL
+);
+
+GRANT SELECT, INSERT, DELETE, UPDATE ON sys_admin.stats_updates TO sys_admin;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/updates/1452/01.report_views.sql	Wed Jul 07 11:44:40 2021 +0200
@@ -0,0 +1,58 @@
+-- Materialized Views with statistical data for data quality reports
+CREATE MATERIALIZED VIEW waterway.dqr_gauge_stats AS
+WITH d AS ( SELECT ym::date, d::date
+              FROM generate_series( '2019-10-01'::date,
+                                    now() - interval '1 day',
+                                    '1 month'::interval ) ym,
+                   generate_series( ym,
+                                    ( ym + interval '1 month'
+                                         - interval '1 day'),
+                                     '1 day'::interval ) d ),
+     g AS ( SELECT d.ym, d.d AS day, (location).country_code AS cc,
+                   objname, array_agg(distinct(location)) AS locations
+            FROM waterway.gauges,d
+            GROUP BY objname,d.d,(location).country_code,d.ym ),
+     measure AS (
+       SELECT g.ym, g.cc, g.objname, g.day,
+              CASE WHEN count(measure_date) = 0
+              THEN 1 ELSE 0 END AS missing
+         FROM g
+         LEFT OUTER JOIN waterway.gauge_measurements gm
+           ON ARRAY[location] <@ g.locations
+             AND g.day <= measure_date
+             AND measure_date < (g.day + interval '1 day')
+         GROUP BY g.objname,g.day,g.ym,g.cc )
+  SELECT cc, ym AS month, objname, sum(missing) AS daynodata
+    FROM measure
+    GROUP BY cc, ym, objname;
+
+CREATE MATERIALIZED VIEW waterway.dqr_bottleneck_stats AS
+WITH d AS ( SELECT ym::date
+              FROM generate_series( '2019-10-01'::date,
+                                    now() - interval '1 day',
+                                    '1 month'::interval ) ym),
+     bn AS ( SELECT DISTINCT objnam, responsible_country AS cc
+               FROM waterway.bottlenecks ),
+     bid AS (SELECT objnam, array_agg(distinct(bottleneck_id)) AS ids
+               FROM waterway.bottlenecks GROUP BY objnam)
+  SELECT bn.cc, d.ym AS month, bid.objnam,
+         COALESCE(count(distinct(sr.date_info)),0) AS srcnt,
+         COALESCE(count(distinct(efa.measure_date)),0) AS fwacnt
+    FROM bn, bid
+    CROSS JOIN d
+    LEFT OUTER JOIN waterway.sounding_results sr
+      ON ARRAY[sr.bottleneck_id] <@ bid.ids
+         AND d.ym <= sr.date_info
+         AND sr.date_info < (d.ym + interval '1 month')
+    LEFT OUTER JOIN waterway.fairway_availability fa
+      ON ARRAY[fa.bottleneck_id] <@ bid.ids
+    LEFT OUTER JOIN waterway.effective_fairway_availability efa
+      ON fairway_availability_id = fa.id
+         AND d.ym <= efa.measure_date
+         AND efa.measure_date < (d.ym + interval '1 month')
+         AND efa.measure_type = 'Measured'
+    WHERE bid.objnam = bn.objnam
+    GROUP BY bn.cc, d.ym, bid.objnam;
+
+-- Refresh access rights!
+GRANT SELECT on ALL tables in schema waterway TO waterway_user;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/updates/1453/01.update_dqr_stats.sql	Wed Jul 07 11:44:40 2021 +0200
@@ -0,0 +1,19 @@
+-- We need a wrapper procedure with owner rights for
+-- the refresh, as (from the PGSQL manual): "REFRESH MATERIALIZED VIEW
+-- completely replaces the contents of a materialized view. To execute
+-- this command you must be the owner of the materialized view.""
+
+CREATE OR REPLACE PROCEDURE sys_admin.update_dqr_stats()
+LANGUAGE plpgsql AS $$
+BEGIN
+    EXECUTE 'REFRESH MATERIALIZED VIEW waterway.dqr_bottleneck_stats';
+    EXECUTE 'REFRESH MATERIALIZED VIEW waterway.dqr_gauge_stats';
+END;
+$$ SECURITY DEFINER;
+
+GRANT EXECUTE ON PROCEDURE sys_admin.update_dqr_stats() TO sys_admin;
+
+-- Config update statement
+INSERT INTO sys_admin.stats_updates
+    VALUES ('Data quality report',
+            'CALL sys_admin.update_dqr_stats();');
--- a/schema/version.sql	Sun Jul 04 11:37:37 2021 +0200
+++ b/schema/version.sql	Wed Jul 07 11:44:40 2021 +0200
@@ -1,1 +1,1 @@
-INSERT INTO gemma_schema_version(version) VALUES (1441);
+INSERT INTO gemma_schema_version(version) VALUES (1453);