Verified Commit c53a1a49 authored by 35V LG84's avatar 35V LG84

tep-1010: Txn Geo Location functionality

Initial functionality and machinery for Txn Geo Location feature.
Signed-off-by: 35V LG84's avatar35V LG84 <[email protected]>
parent 127f5183
/*
* Copyright 2019 E257.FI
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package fi.e257.tackler.api
import io.circe.{Decoder, Encoder}
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
import scala.util.{Failure, Success, Try}
/**
* WGS84 (EPSG:4326) based Geo Location
* This is based on simplified geo uri scheme, see TEP-1010 for details.
*
* @param lat Latitude in decimal degrees
* @param lon Longitude in decimal degrees
* @param alt optional altitude, in meters
*/
class GeoPoint protected (val lat: BigDecimal, val lon: BigDecimal, val alt: Option[BigDecimal]) {
override def toString: String = {
alt.fold(
"geo:" + GeoPoint.frmt(lat) + "," + GeoPoint.frmt(lon)
)(z =>
"geo:" + GeoPoint.frmt(lat) + "," + GeoPoint.frmt(lon) + "," + GeoPoint.frmt(z)
)
}
}
object GeoPoint {
def frmt(v: BigDecimal): String = {
s"%.${v.scale}f".format(v)
}
/**
* Create [[GeoPoint]] from given coordinates
*
* @param lat Latitude in decimal degrees
* @param lon Longitude in decimal degrees
* @param alt optional altitude, in meters
* @return Success with GeoPoint or Failure
*/
def toPoint(lat: BigDecimal, lon: BigDecimal, alt: Option[BigDecimal]): Try[GeoPoint] = {
val z = alt.getOrElse(BigDecimal(0))
if (lat < -90 || 90 < lat) {
Failure[GeoPoint](new IllegalArgumentException("Value out of specification for Latitude: " + GeoPoint.frmt(lat)))
}
else if (lon < -180 || 180 < lon) {
Failure[GeoPoint](new IllegalArgumentException("Value out of specification for Longitude: " + GeoPoint.frmt(lon)))
}
else if (z < -6378137) {
// Jules Verne: Voyage au centre de la Terre
Failure[GeoPoint](new IllegalArgumentException("Value Out of specification for Altitude: " + GeoPoint.frmt(z)))
}
else {
Success(new GeoPoint(lat, lon, alt))
}
}
implicit val decodeTxnHeader: Decoder[GeoPoint] = deriveDecoder
implicit val encodeTxnHeader: Encoder[GeoPoint] = deriveEncoder
}
......@@ -31,6 +31,7 @@ final case class TxnHeader(
code: Option[String],
description: Option[String],
uuid: Option[UUID],
location: Option[GeoPoint],
comments: Option[List[String]]
) {
......@@ -86,12 +87,14 @@ final case class TxnHeader(
val codeStr = code.map(c => " (" + c + ") ")
val uuidStr = uuid.map(u => indent + "# uuid: " + u.toString + "\n")
val locStr = location.map(loc => indent + "# location: " + loc.toString + "\n")
val commentsStr = comments.map(cs =>
cs.map(c => indent + "; " + c + "\n").mkString
)
tsFormatter(timestamp) + codeStr.getOrElse(" ") + description.fold("")("'" + _) + "\n" +
uuidStr.getOrElse("") +
locStr.getOrElse("") +
commentsStr.getOrElse("")
}
}
......
......@@ -21,13 +21,13 @@ import java.nio.file.{Files, NoSuchFileException, Path, Paths}
import java.util.Base64
import better.files._
import org.slf4j.{Logger, LoggerFactory}
import io.circe.parser.decode
import fi.e257.tackler.api.TxnFilterDefinition
import fi.e257.tackler.core.{FilesystemStorageType, GitStorageType, Settings, TacklerException, TxnException}
import fi.e257.tackler.core._
import fi.e257.tackler.model.TxnData
import fi.e257.tackler.parser.{TacklerParseException, TacklerTxns}
import fi.e257.tackler.report.Reports
import io.circe.parser.decode
import org.slf4j.{Logger, LoggerFactory}
......
......@@ -209,6 +209,26 @@ class DirsuiteCoreTest extends DirSuiteLike {
}
}
/**
* Location
*/
class DirsuiteLocationTest extends DirSuiteLike {
val basedir = Paths.get("tests")
/*
runDirSuiteTestCases(basedir, Glob("location/ex/TacklerException-*.exec")) { args: Array[String] =>
assertThrows[TacklerException] {
TacklerCli.runExceptions(args)
}
}
*/
runDirSuiteTestCases(basedir, Glob("location/ok/*.exec")) { args: Array[String] =>
assertResult(TacklerCli.SUCCESS) {
TacklerCli.runReturnValue(args)
}
}
}
/**
* Parser
......
......@@ -18,6 +18,7 @@ lexer grammar TxnLexer;
UUID_NAME: 'uuid';
LOCATION_NAME: 'location';
GEO_NAME: 'geo';
UUID_VALUE: HEX HEX HEX HEX HEX HEX HEX HEX '-' HEX HEX HEX HEX '-' HEX HEX HEX HEX '-' HEX HEX HEX HEX '-' HEX HEX HEX HEX HEX HEX HEX HEX HEX HEX HEX HEX;
......@@ -25,8 +26,6 @@ DATE: DIGIT DIGIT DIGIT DIGIT '-' DIGIT DIGIT '-' DIGIT DIGIT;
TS: DATE 'T' TIME;
TS_TZ: TS TZ;
GEO_URI: 'geo' ':' LATITUDE ',' LONGITUDE (',' NUMBER)?;
INT: DIGIT+;
NUMBER: '-'? (INT | FLOAT);
......@@ -39,10 +38,6 @@ fragment TIME: DIGIT DIGIT ':' DIGIT DIGIT ':' DIGIT DIGIT ('.' DIGIT+)?;
fragment TZ: 'Z' | (('+' | '-') DIGIT DIGIT ':' DIGIT DIGIT);
fragment LATITUDE: '-'? DIGIT DIGIT? ('.' DIGIT+)?;
fragment LONGITUDE: '-'? DIGIT DIGIT? DIGIT? ('.' DIGIT+)?;
fragment FLOAT: DIGIT+ '.' DIGIT+;
fragment
......@@ -95,6 +90,7 @@ AT: '@';
EQUAL: '=';
SPACE: ' ';
TAB: '\t';
COMMA: ',';
SEMICOLON: ';';
COLON: ':';
NL: '\r'? '\n';
......
......@@ -39,11 +39,24 @@ description: sp '\'' text;
text: ~(NL)*;
txn_meta: indent '#' sp (txn_meta_uuid | txn_meta_location) NL;
txn_meta:
txn_meta_uuid NL
| txn_meta_uuid NL txn_meta_location NL
| txn_meta_location NL
| txn_meta_location NL txn_meta_uuid NL
;
txn_meta_uuid: indent '#' sp UUID_NAME ':' sp UUID_VALUE opt_sp;
txn_meta_location: indent '#' sp LOCATION_NAME ':' sp geo_uri opt_sp;
geo_uri: GEO_NAME ':' lat ',' lon (',' alt)?;
lat: INT | NUMBER;
txn_meta_uuid: UUID_NAME ':' sp UUID_VALUE opt_sp;
lon: INT | NUMBER;
txn_meta_location: LOCATION_NAME ':' sp GEO_URI opt_sp;
alt: INT | NUMBER;
txn_comment: indent comment NL;
......@@ -72,9 +85,9 @@ opt_opening_pos: sp '{' opt_sp amount sp unit opt_sp '}';
closing_pos: sp ('@' | '=') sp amount sp unit;
account: ID (':' (ID|SUBID|INT))*;
account: ID (':' (ID | SUBID | INT))*;
amount: INT|NUMBER;
amount: INT | NUMBER;
unit: ID;
......
......@@ -22,12 +22,13 @@ import java.util.UUID
import cats.implicits._
import scala.collection.JavaConverters
import fi.e257.tackler.api.TxnHeader
import fi.e257.tackler.core.{AccountException, CfgKeys, CommodityException, Settings, TxnException}
import fi.e257.tackler.api.{GeoPoint, TxnHeader}
import fi.e257.tackler.core.{AccountException, CfgKeys, CommodityException, Settings, TacklerException, TxnException}
import fi.e257.tackler.model.{AccountTreeNode, Commodity, Posting, Posts, Transaction, Txns}
import fi.e257.tackler.parser.TxnParser._
import org.slf4j.{Logger, LoggerFactory}
import scala.util.{Failure, Success}
import scala.util.control.NonFatal
/**
......@@ -110,7 +111,7 @@ abstract class CtxHandler {
}
protected def handleAmount(amountCtx: AmountContext): BigDecimal = {
BigDecimal(Option(amountCtx.INT()).getOrElse(amountCtx.NUMBER()).getText())
BigDecimal(amountCtx.getText())
}
protected def handleValuePosition(postingCtx: PostingContext): (
......@@ -226,6 +227,26 @@ abstract class CtxHandler {
Posting(acctn, foo._1, foo._2, foo._3, foo._5, comment)
}
protected def handleMeta(metaCtx: Txn_metaContext): (Option[UUID], Option[GeoPoint]) = {
val uuid = Option(metaCtx.txn_meta_uuid()).map(muuid => {
java.util.UUID.fromString(muuid.UUID_VALUE().getText)
})
val geo = Option(metaCtx.txn_meta_location()).map(geoCtx => {
GeoPoint.toPoint(
BigDecimal(geoCtx.geo_uri().lat().getText),
BigDecimal(geoCtx.geo_uri().lon().getText()),
Option(geoCtx.geo_uri().alt()).map(a => BigDecimal(a.getText))
) match {
case Success(g) => g
case Failure(ex) => {
log.error("Invalid geo-uri:" + ex.getMessage)
throw new TacklerException("Invalid geo-uri: " + ex.getMessage)
}
}
})
(uuid, geo)
}
/**
* Handle one Transaction (txn -rule).
*
......@@ -251,11 +272,12 @@ abstract class CtxHandler {
})
val uuid: Option[UUID] = Option(txnCtx.txn_meta()).flatMap(meta => {
Option(meta.txn_meta_uuid()).map(muuid => {
java.util.UUID.fromString(muuid.UUID_VALUE().getText)
})
val meta = Option(txnCtx.txn_meta())
.fold[(Option[UUID], Option[GeoPoint])]((None, None))(metaCtx => {
handleMeta(metaCtx)
})
val uuid: Option[UUID] = meta._1
val geo: Option[GeoPoint] = meta._2
if (settings.Auditing.txnSetChecksum && uuid.isEmpty) {
val msg = "" +
......@@ -299,7 +321,7 @@ abstract class CtxHandler {
List(Posting(ate, -amount, -amount, false, posts.head.txnCommodity, comment))
})
Transaction(TxnHeader(date, code, desc, uuid, comments), posts ++ last_posting.getOrElse(Nil))
Transaction(TxnHeader(date, code, desc, uuid, geo, comments), posts ++ last_posting.getOrElse(Nil))
}
/**
......
......@@ -29,7 +29,7 @@ class TxnFilterTest extends FlatSpec {
val txnFilterFalse = TxnFilterNone()
val txnFilterTrue = TxnFilterAll()
val txn = Transaction(TxnHeader(ZonedDateTime.now(), None, None, None, None), Seq.empty)
val txn = Transaction(TxnHeader(ZonedDateTime.now(), None, None, None, None, None), Seq.empty)
behavior of "AND"
......
......@@ -16,7 +16,7 @@
*/
package fi.e257.tackler.parser
import fi.e257.tackler.core.Settings
import fi.e257.tackler.core.{Settings, TacklerException}
import org.scalatest.FunSpec
class TacklerParserLocationTest extends FunSpec {
......@@ -26,86 +26,152 @@ class TacklerParserLocationTest extends FunSpec {
describe("Geo URI tests") {
/**
* test:
*
* test: bc98cc89-d3b2-468d-9508-8d7a55924178
*/
it("pok: geo uris") {
val txnStr =
"""
|2019-04-01
| # location: geo:60.170833,24.9375
| e 1
| a
|
|2019-04-01
| # location: geo:66.5436,25.84715,160
| e 1
| a
|
|2019-04-01
| # location: geo:66.5436,25.84715,160.0
| e 1
| a
|
|2019-04-01
| # location: geo:59.90735,16.57532,-155
| e 1
| a
|
|2019-04-01
| # location: geo:59.90735,16.57532,-155.0
| e 1
| a
|
|2019-04-01
| # location: geo:0,0,0
| e 1
| a
|
|2019-04-01
| # location: geo:-90,0,0
| e 1
| a
|
|2019-04-01
| # location: geo:-90,25,0
| e 1
| a
|
|2019-04-01
| # location: geo:90,0,0
| e 1
| a
|
|2019-04-01
| # location: geo:90,25,0
| e 1
| a
|
|2019-04-01
| # location: geo:66.56,180,0
| e 1
| a
|
|2019-04-01
| # location: geo:-66.56,-180,0
| e 1
| a
|
|""".stripMargin
val txns = tt.string2Txns(txnStr)
assert(txns.txns.size === 12)
it("various valid geo uris") {
val txnStrs = List(
(
"""
|2019-04-01
| # location: geo:60.170833,24.9375
| e 1
| a
|
|""".stripMargin,
"geo:60.170833,24.9375",
),
(
"""
|2019-04-01
| # location: geo:66.5436,25.84715,160
| e 1
| a
|
|""".stripMargin,
"geo:66.5436,25.84715,160",
),
(
"""
|2019-04-01
| # location: geo:66.5436,25.84715,160.0
| e 1
| a
|
|""".stripMargin,
"geo:66.5436,25.84715,160.0",
),
(
"""
|2019-04-01
| # location: geo:59.90735,16.57532,-155
| e 1
| a
|
|""".stripMargin,
"geo:59.90735,16.57532,-155",
),
(
"""
|2019-04-01
| # location: geo:59.90735,16.57532,-155.0
| e 1
| a
|
|""".stripMargin,
"geo:59.90735,16.57532,-155.0",
),
(
"""
|2019-04-01
| # location: geo:0,0,0
| e 1
| a
|
|""".stripMargin,
"geo:0,0,0",
),
(
"""
|2019-04-01
| # location: geo:-90,0,0
| e 1
| a
|
|""".stripMargin,
"geo:-90,0,0",
),
(
"""
|2019-04-01
| # location: geo:-90,25,0
| e 1
| a
|
|""".stripMargin,
"geo:-90,25,0",
),
(
"""
|2019-04-01
| # location: geo:90,0,0
| e 1
| a
|
|""".stripMargin,
"geo:90,0,0",
),
(
"""
|2019-04-01
| # location: geo:90,25,0
| e 1
| a
|
|""".stripMargin,
"geo:90,25,0"
),
(
"""
|2019-04-01
| # location: geo:66.56,180,0
| e 1
| a
|
|""".stripMargin,
"geo:66.56,180,0",
),
(
"""
|2019-04-01
| # location: geo:-66.56,-180,0
| e 1
| a
|
|""".stripMargin,
"geo:-66.56,-180,0",
)
)
val count = txnStrs.map(okStr => {
val txnData = tt.string2Txns(okStr._1)
assert(txnData.txns.size === 1)
assert(txnData.txns.head.header.location.map(_.toString).getOrElse("this-will-not-match") === okStr._2)
1
}).foldLeft(0)(_ + _)
assert(count === 12)
}
/**
* test:
* test: c8e7cdf6-3b30-476c-84f0-f5a19812cd28
*/
it("perr: detect invalid geo uris") {
val perrStrings: List[(String, String, String)] = List(
(
"""
|2017-01-01
|2019-05-01
| # location:
| e 1
| a
......@@ -117,7 +183,7 @@ class TacklerParserLocationTest extends FunSpec {
(
// perr: no 'geo'
"""
|2017-01-01
|2019-05-01
| # location: 60.170833,24.9375
| e 1
| a
......@@ -129,65 +195,115 @@ class TacklerParserLocationTest extends FunSpec {
(
// perr: decimal ','
"""
|2017-01-01
|2019-05-01
| # location: geo:0.0,0.0,0,0
| e 1
| a
|
|""".stripMargin,
"on line: 3",
"""at input ' '"""
"""at input ' # location: geo:0.0,0.0,0,'"""
),
(
// perr: missing lat/lon
"""
|2017-01-01
|2019-05-01
| # location: geo:0
| e 1
| a
|
|""".stripMargin,
"on line: 3",
"""at input 'location'"""
"""at input ' # location: geo:0\n'"""
),
)
val count = perrStrings.map(errStr => {
val ex = intercept[TacklerParseException]({
val _ = TacklerParser.txnsText(errStr._1)
})
assert(ex.getMessage.contains(errStr._2))
assert(ex.getMessage.contains(errStr._3))
1
}).foldLeft(0)(_ + _)
assert(count === 4)
}
/**
* test: fc711c0d-2820-4f72-8b4c-1219ef578363
*/
it("detect semantically invalid geo uris") {
val perrStrings: List[(String, String)] = List(
(
// perr: latitude out of spec
// latitude out of spec 1/2
"""
|2017-01-01
| # location: geo:123,0
|2019-05-01
| # location: geo:-90.1,0
| e 1
| a
|
|""".stripMargin,
"on line: 3",
"""at input 'location'"""
"""for Latitude: -90.1"""
),
(
// perr: longitude out of spec
// latitude out of spec 2/2
"""
|2017-01-01
| # location: geo:0,1234
|2019-05-01
| # location: geo:90.1,0
| e 1
| a
|
|""".stripMargin,
"on line: 3",
"""at input ' '"""
"""for Latitude: 90.1"""
),
(
// longitude out of spec 1/2
"""
|2019-05-01
| # location: geo:0,-180.1
| e 1
| a
|
|""".stripMargin,
"""for Longitude: -180.1"""
),
(
// longitude out of spec 2/2
"""
|2019-05-01
| # location: geo:0,180.1
| e 1
| a
|
|""".stripMargin,
"""for Longitude: 180.1"""
),
(
// altitude out of spec
// Jules Verne: Voyage au centre de la Terre
"""
|2019-05-01
| # location: geo:64.8,-23.783333,-6378137.1
| e 1
| a
|
|""".stripMargin,
"""for Altitude: -6378137.1"""
),
)
val count = perrStrings.map(perrStr => {
val ex = intercept[TacklerParseException]({
val _ = TacklerParser.txnsText(perrStr._1)
val count = perrStrings.map(errStr => {
val ex = intercept[TacklerException]({
val _ = tt.string2Txns(errStr._1)
})
assert(ex.getMessage.contains(perrStr._2))
assert(ex.getMessage.contains(perrStr._3))
assert(ex.getMessage.contains(errStr._2))
1
}).foldLeft(0)(_ + _)
assert(count === 6)
assert(count === 5)
}
}
}
\ No newline at end of file
/*
* Copyright 2019 E257.FI
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software