changeset 2229:6cce66a6ceb5

merged pdf-export into default
author Markus Kottlaender <markus@intevation.de>
date Wed, 13 Feb 2019 08:00:26 +0100
parents 25f73251a6ac (diff) 9b15293d028c (current diff)
children 4374d942b23d
files client/src/components/Systemconfiguration.vue
diffstat 12 files changed, 562 insertions(+), 283 deletions(-) [+]
line wrap: on
line diff
--- a/client/src/components/importschedule/Importscheduledetail.vue	Wed Feb 13 07:55:57 2019 +0100
+++ b/client/src/components/importschedule/Importscheduledetail.vue	Wed Feb 13 08:00:26 2019 +0100
@@ -72,14 +72,33 @@
               </div>
             </div>
           </div>
-
+          <div v-if="directImportAvailable" class="flex-column">
+            <div class="flex-row text-left">
+              <small class="text-muted">
+                <translate>Import via</translate>
+              </small>
+            </div>
+            <div class="flex-flex-row text-left">
+              <toggle-button
+                v-model="directImport"
+                class="mt-2"
+                :speed="100"
+                :labels="{
+                  checked: this.$options.FILE,
+                  unchecked: this.$options.URL
+                }"
+                :width="60"
+                :height="30"
+              />
+            </div>
+          </div>
           <Availablefairwaydepth
             v-if="import_ == $options.IMPORTTYPES.FAIRWAYAVAILABILITY"
             @urlChanged="setUrl"
             :url="url"
           ></Availablefairwaydepth>
           <Bottleneck
-            v-if="import_ == $options.IMPORTTYPES.BOTTLENECK"
+            v-if="import_ == $options.IMPORTTYPES.BOTTLENECK && !directImport"
             @urlChanged="setUrl"
             :url="url"
           ></Bottleneck>
@@ -153,7 +172,7 @@
             :sortBy="sortBy"
           ></Waterwayaxis>
 
-          <div class="d-flex flex-row">
+          <div v-if="!directImport" class="d-flex flex-row">
             <div class="flex-column mt-3 mr-4">
               <div class="flex-row text-left">
                 <small class="text-muted">
@@ -196,7 +215,7 @@
               </div>
             </div>
           </div>
-          <div class="flex-column w-100 mr-2">
+          <div v-if="!directImport" class="flex-column w-100 mr-2">
             <div class="flex-row text-left">
               <small class="text-muted">
                 <translate>Schedule</translate>
@@ -354,7 +373,27 @@
               </div>
             </div>
           </div>
-          <button type="submit" class="shadow-sm btn btn-info submit-button">
+          <div v-if="directImport" class="d-flex flex-row text-left">
+            <div class="mt-3 mb-3 flex-column w-100">
+              <div class="custom-file">
+                <input
+                  accept=".xml"
+                  type="file"
+                  @change="fileSelected"
+                  class="custom-file-input"
+                  id="uploadFile"
+                />
+                <label class="pointer custom-file-label" for="uploadFile">
+                  {{ uploadLabel }}
+                </label>
+              </div>
+            </div>
+          </div>
+          <button
+            v-if="!directImport"
+            type="submit"
+            class="shadow-sm btn btn-info submit-button"
+          >
             <translate>Save</translate>
           </button>
           <button
@@ -398,6 +437,7 @@
 import { mapState } from "vuex";
 import { displayInfo, displayError } from "@/lib/errors.js";
 import app from "@/main.js";
+import { HTTP } from "@/lib/http";
 
 export default {
   name: "importscheduledetail",
@@ -423,7 +463,10 @@
   },
   data() {
     return {
+      directImport: false,
       passwordVisible: false,
+      uploadLabel: this.$gettext("choose file to upload"),
+      uploadFile: null,
       ...initializeCurrentSchedule()
     };
   },
@@ -470,6 +513,14 @@
       if (this.id) return this.$gettext("Import") + " " + this.id;
       return this.$gettext("New Import");
     },
+    directImportAvailable() {
+      switch (this.import_) {
+        case this.$options.IMPORTTYPES.BOTTLENECK:
+          return true;
+        default:
+          return false;
+      }
+    },
     isCredentialsRequired() {
       switch (this.import_) {
         case this.$options.IMPORTTYPES.WATERWAYGAUGES:
@@ -519,6 +570,12 @@
     }
   },
   methods: {
+    fileSelected(e) {
+      const files = e.target.files || e.dataTransfer.files;
+      if (!files) return;
+      this.uploadLabel = files[0].name;
+      this.uploadFile = files[0];
+    },
     setUrl(value) {
       this.url = value;
     },
@@ -606,9 +663,47 @@
       this.day = null;
       this.dayOfMonth = null;
     },
+    triggerBottleneckFileUpload() {
+      if (!this.uploadFile) return;
+      let formData = new FormData();
+      formData.append("ubn", this.uploadFile);
+      HTTP.post("/imports/ubn", {
+        headers: {
+          "X-Gemma-Auth": localStorage.getItem("token"),
+          "Content-type": "text/xml; charset=UTF-8"
+        }
+      })
+        .then(response => {
+          const { id } = response.data;
+          displayInfo({
+            title: this.$gettext("File Import"),
+            message: this.$gettext("Import import: #") + id
+          });
+          this.closeDetailview();
+          this.$store.dispatch("importschedule/loadSchedules").catch(error => {
+            const { status, data } = error.response;
+            displayError({
+              title: this.gettext("Backend Error"),
+              message: `${status}: ${data.message || data}`
+            });
+          });
+        })
+        .catch(error => {
+          const { status, data } = error.response;
+          displayError({
+            title: this.$gettext("Backend Error"),
+            message: `${status}: ${data.message || data}`
+          });
+        });
+    },
     triggerManualImport() {
       if (!this.triggerActive) return;
       if (!this.import_) return;
+      if (this.directImport) {
+        if (!this.uploadFile) return;
+        this.triggerBottleneckFileUpload();
+        return;
+      }
       let data = {};
       if (this.isURLRequired) {
         if (!this.url) return;
@@ -785,6 +880,8 @@
   IMPORTTYPES: IMPORTTYPES,
   on: "on",
   off: "off",
+  FILE: app.$gettext("File"),
+  URL: app.$gettext("URL"),
   EVERY: app.$gettext("Every"),
   MINUTESPAST: app.$gettext("minutes past"),
   ON: app.$gettext("on"),
--- a/pkg/controllers/agmimports.go	Wed Feb 13 07:55:57 2019 +0100
+++ b/pkg/controllers/agmimports.go	Wed Feb 13 08:00:26 2019 +0100
@@ -14,66 +14,28 @@
 package controllers
 
 import (
-	"bufio"
-	"io"
-	"io/ioutil"
 	"log"
 	"net/http"
-	"os"
-	"path/filepath"
 	"time"
 
 	"gemma.intevation.de/gemma/pkg/auth"
 	"gemma.intevation.de/gemma/pkg/common"
-	"gemma.intevation.de/gemma/pkg/config"
 	"gemma.intevation.de/gemma/pkg/imports"
+	"gemma.intevation.de/gemma/pkg/misc"
 )
 
 const (
+	approvedGaugeMeasurementsName   = "approvedgm"
 	maxApprovedGaugeMeasurementSize = 25 * 1024 * 1024
-	approvedGaugeMeasurementsName   = "approvedgm"
 )
 
-func storeApprovedGaugeMeasurements(req *http.Request) (string, error) {
-
-	// Check for direct upload.
-	f, _, err := req.FormFile(approvedGaugeMeasurementsName)
-	if err != nil {
-		return "", err
-	}
-	defer f.Close()
-
-	dir, err := ioutil.TempDir(config.TmpDir(), approvedGaugeMeasurementsName)
-	if err != nil {
-		return "", err
-	}
-
-	o, err := os.Create(filepath.Join(dir, "agm.csv"))
-	if err != nil {
-		os.RemoveAll(dir)
-		return "", err
-	}
-
-	out := bufio.NewWriter(o)
-
-	if _, err = io.Copy(out, io.LimitReader(f, maxApprovedGaugeMeasurementSize)); err != nil {
-		o.Close()
-		os.RemoveAll(dir)
-		return "", err
-	}
-
-	if err = out.Flush(); err != nil {
-		o.Close()
-		os.RemoveAll(dir)
-		return "", err
-	}
-
-	return dir, nil
-}
-
 func importApprovedGaugeMeasurements(rw http.ResponseWriter, req *http.Request) {
 
-	dir, err := storeApprovedGaugeMeasurements(req)
+	dir, err := misc.StoreUploadedFile(
+		req,
+		approvedGaugeMeasurementsName,
+		"agm.csv",
+		maxApprovedGaugeMeasurementSize)
 	if err != nil {
 		log.Printf("error: %v\n", err)
 		http.Error(rw, "error: "+err.Error(), http.StatusInternalServerError)
--- a/pkg/controllers/routes.go	Wed Feb 13 07:55:57 2019 +0100
+++ b/pkg/controllers/routes.go	Wed Feb 13 08:00:26 2019 +0100
@@ -208,6 +208,9 @@
 	api.Handle("/imports/ubn", waterwayAdmin(
 		http.HandlerFunc(importUploadedBottleneck))).Methods(http.MethodPost)
 
+	api.Handle("/imports/ufa", waterwayAdmin(
+		http.HandlerFunc(importUploadedFairwayAvailability))).Methods(http.MethodPost)
+
 	api.Handle("/imports/{kind:st}", sysAdmin(&JSONHandler{
 		Input:  importModel,
 		Handle: manualImport,
--- a/pkg/controllers/srimports.go	Wed Feb 13 07:55:57 2019 +0100
+++ b/pkg/controllers/srimports.go	Wed Feb 13 08:00:26 2019 +0100
@@ -15,12 +15,9 @@
 
 import (
 	"archive/zip"
-	"bufio"
 	"database/sql"
 	"encoding/hex"
 	"fmt"
-	"io"
-	"io/ioutil"
 	"log"
 	"net/http"
 	"os"
@@ -40,8 +37,8 @@
 )
 
 const (
+	soundingResultName    = "soundingresult"
 	maxSoundingResultSize = 25 * 1024 * 1024
-	soundingResultName    = "soundingresult"
 )
 
 func fetchSoundingResult(req *http.Request) (string, error) {
@@ -64,44 +61,12 @@
 		return dst, nil
 	}
 
-	return storeSoundingResult(req)
-}
-
-func storeSoundingResult(req *http.Request) (string, error) {
-
-	// Check for direct upload.
-	f, _, err := req.FormFile(soundingResultName)
-	if err != nil {
-		return "", err
-	}
-	defer f.Close()
-
-	dir, err := ioutil.TempDir(config.TmpDir(), soundingResultName)
-	if err != nil {
-		return "", err
-	}
-
-	o, err := os.Create(filepath.Join(dir, "sr.zip"))
-	if err != nil {
-		os.RemoveAll(dir)
-		return "", err
-	}
-
-	out := bufio.NewWriter(o)
-
-	if _, err = io.Copy(out, io.LimitReader(f, maxSoundingResultSize)); err != nil {
-		o.Close()
-		os.RemoveAll(dir)
-		return "", err
-	}
-
-	if err = out.Flush(); err != nil {
-		o.Close()
-		os.RemoveAll(dir)
-		return "", err
-	}
-
-	return dir, nil
+	return misc.StoreUploadedFile(
+		req,
+		soundingResultName,
+		"sr.zip",
+		maxSoundingResultSize,
+	)
 }
 
 func fetchSoundingResultMetaOverrides(sr *imports.SoundingResult, req *http.Request) error {
@@ -205,7 +170,12 @@
 ) (jr JSONResult, err error) {
 
 	var dir string
-	if dir, err = storeSoundingResult(req); err != nil {
+	if dir, err = misc.StoreUploadedFile(
+		req,
+		soundingResultName,
+		"sr.zip",
+		maxSoundingResultSize,
+	); err != nil {
 		return
 	}
 
--- a/pkg/controllers/ubnimports.go	Wed Feb 13 07:55:57 2019 +0100
+++ b/pkg/controllers/ubnimports.go	Wed Feb 13 08:00:26 2019 +0100
@@ -14,19 +14,14 @@
 package controllers
 
 import (
-	"bufio"
-	"io"
-	"io/ioutil"
 	"log"
 	"net/http"
-	"os"
-	"path/filepath"
 	"time"
 
 	"gemma.intevation.de/gemma/pkg/auth"
 	"gemma.intevation.de/gemma/pkg/common"
-	"gemma.intevation.de/gemma/pkg/config"
 	"gemma.intevation.de/gemma/pkg/imports"
+	"gemma.intevation.de/gemma/pkg/misc"
 )
 
 const (
@@ -34,46 +29,13 @@
 	uploadBottleneckName      = "ubn"
 )
 
-func storeUploadedBottleneck(req *http.Request) (string, error) {
-
-	// Check for direct upload.
-	f, _, err := req.FormFile(uploadBottleneckName)
-	if err != nil {
-		return "", err
-	}
-	defer f.Close()
-
-	dir, err := ioutil.TempDir(config.TmpDir(), uploadBottleneckName)
-	if err != nil {
-		return "", err
-	}
-
-	o, err := os.Create(filepath.Join(dir, "data.xml"))
-	if err != nil {
-		os.RemoveAll(dir)
-		return "", err
-	}
-
-	out := bufio.NewWriter(o)
-
-	if _, err = io.Copy(out, io.LimitReader(f, maxUploadedBottleneckSize)); err != nil {
-		o.Close()
-		os.RemoveAll(dir)
-		return "", err
-	}
-
-	if err = out.Flush(); err != nil {
-		o.Close()
-		os.RemoveAll(dir)
-		return "", err
-	}
-
-	return dir, nil
-}
-
 func importUploadedBottleneck(rw http.ResponseWriter, req *http.Request) {
 
-	dir, err := storeUploadedBottleneck(req)
+	dir, err := misc.StoreUploadedFile(
+		req,
+		uploadBottleneckName,
+		"data.xml",
+		maxUploadedBottleneckSize)
 	if err != nil {
 		log.Printf("error: %v\n", err)
 		http.Error(rw, "error: "+err.Error(), http.StatusInternalServerError)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pkg/controllers/ufaimports.go	Wed Feb 13 08:00:26 2019 +0100
@@ -0,0 +1,81 @@
+// 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):
+//  * Sascha L. Teichmann <sascha.teichmann@intevation.de>
+
+package controllers
+
+import (
+	"log"
+	"net/http"
+	"time"
+
+	"gemma.intevation.de/gemma/pkg/auth"
+	"gemma.intevation.de/gemma/pkg/common"
+	"gemma.intevation.de/gemma/pkg/imports"
+	"gemma.intevation.de/gemma/pkg/misc"
+)
+
+const (
+	maxUploadedFairwayAvailabilitySize = 25 * 1024 * 1024
+	uploadFairwayAvailabilityName      = "ufa"
+)
+
+func importUploadedFairwayAvailability(rw http.ResponseWriter, req *http.Request) {
+
+	dir, err := misc.StoreUploadedFile(
+		req,
+		uploadFairwayAvailabilityName,
+		"data.xml",
+		maxUploadedFairwayAvailabilitySize)
+	if err != nil {
+		log.Printf("error: %v\n", err)
+		http.Error(rw, "error: "+err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	ufa := &imports.UploadedFairwayAvailability{Dir: dir}
+
+	serialized, err := common.ToJSONString(ufa)
+	if err != nil {
+		log.Printf("error: %v\n", err)
+		http.Error(rw, "error: "+err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	session, _ := auth.GetSession(req)
+
+	sendEmail := req.FormValue("email") != ""
+
+	jobID, err := imports.AddJob(
+		imports.UFAJobKind,
+		time.Time{}, // due
+		nil,         // trys
+		nil,         // wait retry
+		session.User,
+		sendEmail,
+		serialized)
+
+	if err != nil {
+		log.Printf("error: %v\n", err)
+		http.Error(rw, "error: "+err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	log.Printf("info: added import #%d to queue\n", jobID)
+
+	result := struct {
+		ID int64 `json:"id"`
+	}{
+		ID: jobID,
+	}
+	SendJSON(rw, http.StatusCreated, &result)
+}
--- a/pkg/controllers/wpimports.go	Wed Feb 13 07:55:57 2019 +0100
+++ b/pkg/controllers/wpimports.go	Wed Feb 13 08:00:26 2019 +0100
@@ -14,21 +14,16 @@
 package controllers
 
 import (
-	"bufio"
 	"fmt"
-	"io"
-	"io/ioutil"
 	"log"
 	"net/http"
-	"os"
-	"path/filepath"
 	"strconv"
 	"time"
 
 	"gemma.intevation.de/gemma/pkg/auth"
 	"gemma.intevation.de/gemma/pkg/common"
-	"gemma.intevation.de/gemma/pkg/config"
 	"gemma.intevation.de/gemma/pkg/imports"
+	"gemma.intevation.de/gemma/pkg/misc"
 )
 
 const (
@@ -36,42 +31,6 @@
 	waterwayProfilesName    = "wp"
 )
 
-func storeWaterwayProfiles(req *http.Request) (string, error) {
-
-	// Check for direct upload.
-	f, _, err := req.FormFile(waterwayProfilesName)
-	if err != nil {
-		return "", err
-	}
-	defer f.Close()
-
-	dir, err := ioutil.TempDir(config.TmpDir(), waterwayProfilesName)
-	if err != nil {
-		return "", err
-	}
-
-	o, err := os.Create(filepath.Join(dir, "wp.csv"))
-	if err != nil {
-		os.RemoveAll(dir)
-		return "", err
-	}
-
-	out := bufio.NewWriter(o)
-
-	if _, err = io.Copy(out, io.LimitReader(f, maxWaterwayProfilesSize)); err != nil {
-		o.Close()
-		os.RemoveAll(dir)
-		return "", err
-	}
-
-	if err = out.Flush(); err != nil {
-		o.Close()
-		os.RemoveAll(dir)
-		return "", err
-	}
-
-	return dir, nil
-}
 func importWaterwayProfiles(rw http.ResponseWriter, req *http.Request) {
 
 	url := req.FormValue("url")
@@ -86,7 +45,11 @@
 		return
 	}
 
-	dir, err := storeWaterwayProfiles(req)
+	dir, err := misc.StoreUploadedFile(
+		req,
+		waterwayProfilesName,
+		"wp.csv",
+		maxWaterwayProfilesSize)
 	if err != nil {
 		log.Printf("error: %v\n", err)
 		http.Error(rw, "error: "+err.Error(), http.StatusInternalServerError)
--- a/pkg/imports/bn.go	Wed Feb 13 07:55:57 2019 +0100
+++ b/pkg/imports/bn.go	Wed Feb 13 08:00:26 2019 +0100
@@ -143,23 +143,41 @@
 	conn *sql.Conn,
 	feedback Feedback,
 ) (interface{}, error) {
-	client := ifbn.NewIBottleneckService(bn.URL, bn.Insecure, nil)
+
+	fetch := func(feedback Feedback) ([]*ifbn.BottleNeckType, error) {
+		client := ifbn.NewIBottleneckService(bn.URL, bn.Insecure, nil)
+
+		req := &ifbn.Export_bn_by_isrs{}
 
-	req := &ifbn.Export_bn_by_isrs{}
+		resp, err := client.Export_bn_by_isrs(req)
+		if err != nil {
+			return nil, err
+		}
+
+		if resp.Export_bn_by_isrsResult == nil {
+			return nil, errors.New("no Bottlenecks found")
+		}
 
-	resp, err := client.Export_bn_by_isrs(req)
+		return resp.Export_bn_by_isrsResult.BottleNeckType, nil
+	}
+
+	return storeBottlenecks(ctx, fetch, importID, conn, feedback)
+}
+
+func storeBottlenecks(
+	ctx context.Context,
+	fetch func(Feedback) ([]*ifbn.BottleNeckType, error),
+	importID int64,
+	conn *sql.Conn,
+	feedback Feedback,
+) (interface{}, error) {
+	start := time.Now()
+
+	bns, err := fetch(feedback)
 	if err != nil {
-		feedback.Error("%v", err)
 		return nil, err
 	}
 
-	if resp.Export_bn_by_isrsResult == nil {
-		err := errors.New("no Bottlenecks found")
-		feedback.Error("%v", err)
-		return nil, err
-	}
-
-	bns := resp.Export_bn_by_isrsResult.BottleNeckType
 	feedback.Info("Found %d bottlenecks for import", len(bns))
 
 	tx, err := conn.BeginTx(ctx, nil)
@@ -187,8 +205,6 @@
 
 	var nids []string
 
-	start := time.Now()
-
 nextBN:
 	for _, bn := range bns {
 
--- a/pkg/imports/fa.go	Wed Feb 13 07:55:57 2019 +0100
+++ b/pkg/imports/fa.go	Wed Feb 13 08:00:26 2019 +0100
@@ -10,6 +10,7 @@
 //
 // Author(s):
 //  * Raimund Renkert <raimund.renkert@intevation.de>
+//  * Sascha L. Teichmann <sascha.teichmann@intevation.de>
 
 package imports
 
@@ -17,6 +18,8 @@
 	"context"
 	"database/sql"
 	"errors"
+	"fmt"
+	"sort"
 	"time"
 
 	"github.com/jackc/pgx/pgtype"
@@ -45,12 +48,13 @@
 const (
 	listBottlenecksSQL = `
 SELECT
-  bottleneck_id,
-  responsible_country
+  bottleneck_id
 FROM waterway.bottlenecks
 WHERE responsible_country = users.current_user_country()
   AND staging_done = true
+ORDER BY bottleneck_id
 `
+
 	latestMeasureDateSQL = `
 SELECT
 	measure_date
@@ -179,55 +183,50 @@
 // CleanUp of a fairway availablities import is a NOP.
 func (*FairwayAvailability) CleanUp() error { return nil }
 
-type bottleneckCountry struct {
-	ID                 string
-	ResponsibleCountry string
+type bottlenecks []string
+
+func (bns bottlenecks) contains(bn string) bool {
+	idx := sort.SearchStrings(bns, bn)
+	return idx < len(bns) && bns[idx] == bn
 }
 
-// Do executes the actual fairway availability import.
-func (fa *FairwayAvailability) Do(
-	ctx context.Context,
-	importID int64,
-	conn *sql.Conn,
-	feedback Feedback,
-) (interface{}, error) {
+func loadBottleneckCountries(ctx context.Context, tx *sql.Tx) (bottlenecks, error) {
 
 	// Get available bottlenecks from database for use as filter in SOAP request
-	var rows *sql.Rows
-
-	rows, err := conn.QueryContext(ctx, listBottlenecksSQL)
+	rows, err := tx.QueryContext(ctx, listBottlenecksSQL)
 	if err != nil {
 		return nil, err
 	}
 	defer rows.Close()
 
-	bottlenecks := []bottleneckCountry{}
+	var bns bottlenecks
 
 	for rows.Next() {
-		var bn bottleneckCountry
-		if err = rows.Scan(
-			&bn.ID,
-			&bn.ResponsibleCountry,
-		); err != nil {
+		var bn string
+		if err = rows.Scan(&bn); err != nil {
 			return nil, err
 		}
-		bottlenecks = append(bottlenecks, bn)
+		bns = append(bns, bn)
 	}
 	if err = rows.Err(); err != nil {
 		return nil, err
 	}
 
-	var faRows *sql.Rows
-	faRows, err = conn.QueryContext(ctx, listFairwayAvailabilitySQL)
+	return bns, nil
+}
+
+func loadFairwayAvailabilities(ctx context.Context, tx *sql.Tx) (map[uniqueFairwayAvailability]int64, error) {
+	rows, err := tx.QueryContext(ctx, listFairwayAvailabilitySQL)
 	if err != nil {
 		return nil, err
 	}
+	defer rows.Close()
 	fairwayAvailabilities := map[uniqueFairwayAvailability]int64{}
-	for faRows.Next() {
+	for rows.Next() {
 		var id int64
 		var bnId string
 		var sd time.Time
-		if err = faRows.Scan(
+		if err = rows.Scan(
 			&id,
 			&bnId,
 			&sd,
@@ -240,86 +239,93 @@
 		}
 		fairwayAvailabilities[key] = id
 	}
-	if err = faRows.Err(); err != nil {
+	if err = rows.Err(); err != nil {
 		return nil, err
 	}
+	return fairwayAvailabilities, nil
+}
 
-	var latestDate pgtype.Timestamp
-	err = conn.QueryRowContext(ctx, latestMeasureDateSQL).Scan(&latestDate)
+func latestDate(ctx context.Context, tx *sql.Tx) (pgtype.Timestamp, error) {
+	var date pgtype.Timestamp
+	err := tx.QueryRowContext(ctx, latestMeasureDateSQL).Scan(&date)
 	switch {
 	case err == sql.ErrNoRows:
-		latestDate = pgtype.Timestamp{
+		date = pgtype.Timestamp{
 			// Fill Database with data of the last 5 days. Change this to a more useful value.
 			Time: time.Now().AddDate(0, 0, -5),
 		}
 	case err != nil:
+		return pgtype.Timestamp{}, err
+	}
+	return date, nil
+}
+
+func storeFairwayAvailability(
+	ctx context.Context,
+	conn *sql.Conn,
+	feedback Feedback,
+	fetch func(context.Context, *sql.Tx, bottlenecks) ([]*ifaf.FairwayAvailability, error),
+) (interface{}, error) {
+
+	start := time.Now()
+
+	tx, err := conn.BeginTx(ctx, nil)
+	if err != nil {
+		return nil, err
+	}
+	defer tx.Rollback()
+
+	bns, err := loadBottleneckCountries(ctx, tx)
+	if err != nil {
 		return nil, err
 	}
 
-	faids, err := fa.doForFAs(ctx, bottlenecks, fairwayAvailabilities, latestDate, conn, feedback)
+	fas, err := fetch(ctx, tx, bns)
+	if err != nil {
+		return nil, err
+	}
+
+	fairwayAvailabilities, err := loadFairwayAvailabilities(ctx, tx)
 	if err != nil {
-		feedback.Error("Error processing data: %s", err)
+		return nil, err
+	}
+
+	faids, err := doForFAs(ctx, bns, fairwayAvailabilities, fas, tx, feedback)
+	if err != nil {
+		return nil, fmt.Errorf("Error processing data: %v", err)
 	}
 	if len(faids) == 0 {
 		feedback.Info("No new fairway availablity data found")
-		return nil, nil
+		return nil, UnchangedError("No new fairway availablity data found")
 	}
 	feedback.Info("Processed %d fairway availabilities", len(faids))
+
+	if err = tx.Commit(); err == nil {
+		feedback.Info(
+			"Importing fairway availabilities successfully took %s", time.Since(start))
+	} else {
+		feedback.Info(
+			"Importing fairway availabilities failed after %s", time.Since(start))
+		return nil, err
+	}
+
 	// TODO: needs to be filled more useful.
 	summary := struct {
 		FairwayAvailabilities []string `json:"fairwayAvailabilities"`
 	}{
 		FairwayAvailabilities: faids,
 	}
-	return &summary, err
+	return &summary, nil
 }
 
-func (fa *FairwayAvailability) doForFAs(
+func doForFAs(
 	ctx context.Context,
-	bottlenecks []bottleneckCountry,
+	bnIds bottlenecks,
 	fairwayAvailabilities map[uniqueFairwayAvailability]int64,
-	latestDate pgtype.Timestamp,
-	conn *sql.Conn,
+	fas []*ifaf.FairwayAvailability,
+	tx *sql.Tx,
 	feedback Feedback,
 ) ([]string, error) {
-	start := time.Now()
-
-	client := ifaf.NewFairwayAvailabilityService(fa.URL, fa.Insecure, nil)
-
-	var bnIds []string
-	for _, bn := range bottlenecks {
-		bnIds = append(bnIds, bn.ID)
-	}
-	var period ifaf.RequestedPeriod
-	period.Date_start = latestDate.Time
-	period.Date_end = time.Now()
-
-	ids := ifaf.ArrayOfString{
-		String: bnIds,
-	}
-
-	req := &ifaf.Get_bottleneck_fa{
-		Bottleneck_id: &ids,
-		Period:        &period,
-	}
-	resp, err := client.Get_bottleneck_fa(req)
-	if err != nil {
-		feedback.Error("%v", err)
-		return nil, err
-	}
-
-	if resp.Get_bottleneck_faResult == nil {
-		err := errors.New("no fairway availabilities found")
-		return nil, err
-	}
-
-	result := resp.Get_bottleneck_faResult
-
-	tx, err := conn.BeginTx(ctx, nil)
-	if err != nil {
-		return nil, err
-	}
-	defer tx.Rollback()
 
 	insertFAStmt, err := tx.PrepareContext(ctx, insertFASQL)
 	if err != nil {
@@ -344,8 +350,12 @@
 
 	var faIDs []string
 	var faID int64
-	feedback.Info("Found %d fairway availabilities", len(result.FairwayAvailability))
-	for _, faRes := range result.FairwayAvailability {
+	feedback.Info("Found %d fairway availabilities", len(fas))
+	for _, faRes := range fas {
+		if !bnIds.contains(faRes.Bottleneck_id) {
+			feedback.Warn("Bottleneck %s not found in database.", faRes.Bottleneck_id)
+			continue
+		}
 		uniqueFa := uniqueFairwayAvailability{
 			BottleneckId: faRes.Bottleneck_id,
 			Surdat:       faRes.SURDAT,
@@ -453,10 +463,51 @@
 			feedback.Info("Add %d Reference Values", rvCount)
 		}
 	}
-	feedback.Info("Storing fairway availabilities took %s", time.Since(start))
-	if err = tx.Commit(); err == nil {
-		feedback.Info("Import of fairway availabilities was successful")
+	return faIDs, nil
+}
+
+// Do executes the actual fairway availability import.
+func (fa *FairwayAvailability) Do(
+	ctx context.Context,
+	importID int64,
+	conn *sql.Conn,
+	feedback Feedback,
+) (interface{}, error) {
+
+	fetch := func(
+		ctx context.Context,
+		tx *sql.Tx, bns bottlenecks,
+	) ([]*ifaf.FairwayAvailability, error) {
+
+		latest, err := latestDate(ctx, tx)
+		if err != nil {
+			return nil, err
+		}
+
+		client := ifaf.NewFairwayAvailabilityService(fa.URL, fa.Insecure, nil)
+
+		var period ifaf.RequestedPeriod
+		period.Date_start = latest.Time
+		period.Date_end = time.Now()
+
+		ids := ifaf.ArrayOfString{String: bns}
+
+		req := &ifaf.Get_bottleneck_fa{
+			Bottleneck_id: &ids,
+			Period:        &period,
+		}
+		resp, err := client.Get_bottleneck_fa(req)
+		if err != nil {
+			return nil, err
+		}
+
+		if resp.Get_bottleneck_faResult == nil {
+			return nil, errors.New("no fairway availabilities found")
+		}
+
+		result := resp.Get_bottleneck_faResult
+		return result.FairwayAvailability, nil
 	}
 
-	return faIDs, nil
+	return storeFairwayAvailability(ctx, conn, feedback, fetch)
 }
--- a/pkg/imports/ubn.go	Wed Feb 13 07:55:57 2019 +0100
+++ b/pkg/imports/ubn.go	Wed Feb 13 08:00:26 2019 +0100
@@ -18,6 +18,10 @@
 	"database/sql"
 	"errors"
 	"os"
+	"path/filepath"
+
+	"gemma.intevation.de/gemma/pkg/soap"
+	"gemma.intevation.de/gemma/pkg/soap/ifbn"
 )
 
 type UploadedBottleneck struct {
@@ -40,10 +44,8 @@
 func (ubnJobCreator) Create() Job { return new(UploadedBottleneck) }
 
 func (ubnJobCreator) Depends() []string {
-	return []string{
-		"gauges",
-		"bottlenecks",
-	}
+	// Same as normal bottleneck import.
+	return bnJobCreator{}.Depends()
 }
 
 // StageDone moves the imported bottleneck out of the staging area.
@@ -52,8 +54,8 @@
 	tx *sql.Tx,
 	id int64,
 ) error {
-	// TODO: Implement me!
-	return nil
+	// Same as normal bottleneck import.
+	return bnJobCreator{}.StageDone(ctx, tx, id)
 }
 
 // CleanUp of a uploaded bottleneck import removes the temp dir.
@@ -68,6 +70,23 @@
 	conn *sql.Conn,
 	feedback Feedback,
 ) (interface{}, error) {
-	// TODO: Implement me!
-	return nil, errors.New("Not implemented, yet!")
+
+	fetch := func(feedback Feedback) ([]*ifbn.BottleNeckType, error) {
+		var dst ifbn.Export_bn_by_isrsResponse
+		if err := soap.ValidateFile(
+			filepath.Join(ubn.Dir, "data.xml"),
+			"IFBN.xsd",
+			&dst,
+		); err != nil {
+			return nil, err
+		}
+
+		if dst.Export_bn_by_isrsResult == nil {
+			return nil, errors.New("No bottlenecks found")
+		}
+
+		return dst.Export_bn_by_isrsResult.BottleNeckType, nil
+	}
+
+	return storeBottlenecks(ctx, fetch, importID, conn, feedback)
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pkg/imports/ufa.go	Wed Feb 13 08:00:26 2019 +0100
@@ -0,0 +1,93 @@
+// 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):
+//  * Sascha L. Teichmann <sascha.teichmann@intevation.de>
+
+package imports
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"os"
+	"path/filepath"
+
+	"gemma.intevation.de/gemma/pkg/soap"
+	"gemma.intevation.de/gemma/pkg/soap/ifaf"
+)
+
+type UploadedFairwayAvailability struct {
+	Dir string
+}
+
+const UFAJobKind JobKind = "ufa"
+
+type ufaJobCreator struct{}
+
+func init() {
+	RegisterJobCreator(UFAJobKind, ufaJobCreator{})
+}
+
+func (ufaJobCreator) Description() string {
+	return "uploaded fairway availability"
+}
+
+func (ufaJobCreator) Create() Job { return new(UploadedFairwayAvailability) }
+
+func (ufaJobCreator) Depends() []string {
+	// Same as faJobCreator
+	return faJobCreator{}.Depends()
+}
+
+func (ufaJobCreator) AutoAccept() bool { return true }
+
+func (ufaJobCreator) StageDone(context.Context, *sql.Tx, int64) error {
+	return nil
+}
+
+func (ufa *UploadedFairwayAvailability) CleanUp() error {
+	return os.RemoveAll(ufa.Dir)
+}
+
+// Do executes the actual uploaded fairway availability import.
+func (ufa *UploadedFairwayAvailability) Do(
+	ctx context.Context,
+	importID int64,
+	conn *sql.Conn,
+	feedback Feedback,
+) (interface{}, error) {
+
+	fetch := func(
+		ctx context.Context,
+		tx *sql.Tx,
+		bns bottlenecks,
+	) ([]*ifaf.FairwayAvailability, error) {
+
+		var response ifaf.Get_bottleneck_faResponse
+
+		if err := soap.ValidateFile(
+			filepath.Join(ufa.Dir, "data.xml"),
+			"IFAF.xsd",
+			&response,
+		); err != nil {
+			return nil, err
+		}
+
+		result := response.Get_bottleneck_faResult
+		if result == nil {
+			return nil, errors.New("No bottlenecks found")
+		}
+
+		return result.FairwayAvailability, nil
+	}
+
+	return storeFairwayAvailability(ctx, conn, feedback, fetch)
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pkg/misc/http.go	Wed Feb 13 08:00:26 2019 +0100
@@ -0,0 +1,62 @@
+// 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):
+//  * Sascha L. Teichmann <sascha.teichmann@intevation.de>
+
+package misc
+
+import (
+	"bufio"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"os"
+	"path/filepath"
+
+	"gemma.intevation.de/gemma/pkg/config"
+)
+
+func StoreUploadedFile(req *http.Request, field, fname string, maxSize int64) (string, error) {
+
+	// Check for direct upload.
+	f, _, err := req.FormFile(field)
+	if err != nil {
+		return "", err
+	}
+	defer f.Close()
+
+	dir, err := ioutil.TempDir(config.TmpDir(), field)
+	if err != nil {
+		return "", err
+	}
+
+	o, err := os.Create(filepath.Join(dir, fname))
+	if err != nil {
+		os.RemoveAll(dir)
+		return "", err
+	}
+
+	out := bufio.NewWriter(o)
+
+	if _, err = io.Copy(out, io.LimitReader(f, maxSize)); err != nil {
+		o.Close()
+		os.RemoveAll(dir)
+		return "", err
+	}
+
+	if err = out.Flush(); err != nil {
+		o.Close()
+		os.RemoveAll(dir)
+		return "", err
+	}
+
+	return dir, nil
+}