From bb496870bebe5cec1bcf920c3eb299129ae884d7 Mon Sep 17 00:00:00 2001
From: Jeremy Fee <jmfee@usgs.gov>
Date: Mon, 20 Apr 2020 19:21:49 -0600
Subject: [PATCH] Add preliminary metadata service

---
 geomagio/api/db/create.py                     |   4 +-
 .../api/db/{metadata.py => metadata_table.py} |  62 ++++++---
 .../api/db/{session.py => session_table.py}   |   0
 geomagio/api/secure/MetadataQuery.py          |  31 +++++
 geomagio/api/secure/MetadataResponse.py       |  11 ++
 geomagio/api/secure/app.py                    |   4 +-
 geomagio/api/secure/metadata.py               |  70 ++++++++++
 geomagio/metadata/Metadata.py                 |  14 +-
 geomagio/metadata/MetadataCategory.py         |   1 +
 geomagio/metadata/__init__.py                 |   5 +
 test_metadata.py                              | 123 ++++++++++++++++++
 11 files changed, 304 insertions(+), 21 deletions(-)
 rename geomagio/api/db/{metadata.py => metadata_table.py} (65%)
 rename geomagio/api/db/{session.py => session_table.py} (100%)
 create mode 100644 geomagio/api/secure/MetadataQuery.py
 create mode 100644 geomagio/api/secure/MetadataResponse.py
 create mode 100644 geomagio/api/secure/metadata.py
 create mode 100644 geomagio/metadata/__init__.py
 create mode 100644 test_metadata.py

diff --git a/geomagio/api/db/create.py b/geomagio/api/db/create.py
index b2dc79313..80fc17e87 100644
--- a/geomagio/api/db/create.py
+++ b/geomagio/api/db/create.py
@@ -3,8 +3,8 @@ import sqlalchemy
 from .common import database, sqlalchemy_metadata
 
 # register models with sqlalchemy_metadata by importing
-from .metadata import metadata
-from .session import session
+from .metadata_table import metadata
+from .session_table import session
 
 
 def create_db():
diff --git a/geomagio/api/db/metadata.py b/geomagio/api/db/metadata_table.py
similarity index 65%
rename from geomagio/api/db/metadata.py
rename to geomagio/api/db/metadata_table.py
index 79d4b9a35..9b1571a21 100644
--- a/geomagio/api/db/metadata.py
+++ b/geomagio/api/db/metadata_table.py
@@ -1,8 +1,8 @@
-import datetime
+from datetime import datetime
 import enum
 
 from obspy import UTCDateTime
-from sqlalchemy import or_, Boolean, Column, Index, Integer, String, Table, Text
+from sqlalchemy import or_, Boolean, Column, Index, Integer, JSON, String, Table, Text
 import sqlalchemy_utc
 
 from ...metadata import Metadata, MetadataCategory
@@ -23,7 +23,7 @@ metadata = Table(
     Column(
         "created_time",
         sqlalchemy_utc.UtcDateTime,
-        default=sqlalchemy_utc.now,
+        default=sqlalchemy_utc.utcnow(),
         index=True,
     ),
     # reviewer
@@ -46,7 +46,7 @@ metadata = Table(
     # whether metadata is valid (based on review)
     Column("metadata_valid", Boolean, default=True, index=True),
     # metadata json blob
-    Column("metadata", Text, nullable=True),
+    Column("metadata", JSON, nullable=True),
     # general comment
     Column("comment", Text, nullable=True),
     # review specific comment
@@ -79,34 +79,62 @@ metadata = Table(
 )
 
 
+async def create_metadata(meta: Metadata) -> Metadata:
+    query = metadata.insert()
+    values = meta.datetime_dict(exclude={"id"}, exclude_none=True)
+    query = query.values(**values)
+    metadata.id = await database.execute(query)
+    return metadata
+
+
+async def delete_metadata(id: int) -> None:
+    query = metadata.delete().where(metadata.c.id == id)
+    await database.execute(query)
+
+
 async def get_metadata(
     *,  # make all params keyword
-    network: str,
-    station: str,
+    id: int = None,
+    network: str = None,
+    station: str = None,
     channel: str = None,
     location: str = None,
     category: MetadataCategory = None,
-    starttime: UTCDateTime = None,
-    endtime: UTCDateTime = None,
+    starttime: datetime = None,
+    endtime: datetime = None,
     data_valid: bool = None,
     metadata_valid: bool = None,
 ):
-    query = (
-        metadata.select()
-        .where(metadata.c.network.like(network or "%"))
-        .where(metadata.c.station.like(station or "%"))
-        .where(metadata.c.channel.like(channel or "%"))
-        .where(metadata.c.location.like(location or "%"))
-    )
+    query = metadata.select()
+    if id is not None:
+        query = query.where(metadata.c.id == id)
+    if category:
+        query = query.where(metadata.c.category == category)
+    if network or station or channel or location:
+        query = (
+            query.where(metadata.c.network.like(network or "%"))
+            .where(metadata.c.station.like(station or "%"))
+            .where(metadata.c.channel.like(channel or "%"))
+            .where(metadata.c.location.like(location or "%"))
+        )
     if starttime:
         query = query.where(
-            or_(metadata.c.endtime == None, metadata.c.endtime > starttime.datetime)
+            or_(metadata.c.endtime == None, metadata.c.endtime > starttime)
         )
     if endtime:
         query = query.where(
-            or_(metadata.c.starttime == None, metadata.c.starttime < endtime.datetime)
+            or_(metadata.c.starttime == None, metadata.c.starttime < endtime)
         )
     if data_valid is not None:
         query = query.where(metadata.c.data_valid == data_valid)
     if metadata_valid is not None:
         query = query.where(metadata.c.metadata_valid == metadata_valid)
+    rows = await database.fetch_all(query)
+    return [Metadata(**row) for row in rows]
+
+
+async def update_metadata(meta: Metadata) -> None:
+    query = metadata.update().where(metadata.c.id == meta.id)
+    values = meta.datetime_dict(exclude={"id"})
+    query = query.values(**values)
+    await database.execute(query)
diff --git a/geomagio/api/db/session.py b/geomagio/api/db/session_table.py
similarity index 100%
rename from geomagio/api/db/session.py
rename to geomagio/api/db/session_table.py
diff --git a/geomagio/api/secure/MetadataQuery.py b/geomagio/api/secure/MetadataQuery.py
new file mode 100644
index 000000000..629ad0149
--- /dev/null
+++ b/geomagio/api/secure/MetadataQuery.py
@@ -0,0 +1,31 @@
+from datetime import timezone
+
+from obspy import UTCDateTime
+from pydantic import BaseModel, validator
+
+from ...metadata import MetadataCategory
+from ... import pydantic_utcdatetime
+
+
+class MetadataQuery(BaseModel):
+    id: int = None
+    category: MetadataCategory = None
+    starttime: UTCDateTime = None
+    endtime: UTCDateTime = None
+    network: str = None
+    station: str = None
+    channel: str = None
+    location: str = None
+    data_valid: bool = None
+    metadata_valid: bool = True
+
+    def datetime_dict(self, **kwargs):
+        values = self.dict(**kwargs)
+        for key in ["starttime", "endtime"]:
+            if key in values and values[key] is not None:
+                values[key] = values[key].datetime.replace(tzinfo=timezone.utc)
+        return values
+
+    @validator("starttime")
+    def set_default_starttime(cls, starttime: UTCDateTime = None) -> UTCDateTime:
+        return starttime or UTCDateTime() - 30 * 86400
diff --git a/geomagio/api/secure/MetadataResponse.py b/geomagio/api/secure/MetadataResponse.py
new file mode 100644
index 000000000..5c0516349
--- /dev/null
+++ b/geomagio/api/secure/MetadataResponse.py
@@ -0,0 +1,11 @@
+from typing import Dict, List
+
+from pydantic import BaseModel
+
+from ...metadata import Metadata
+from .MetadataQuery import MetadataQuery
+
+
+class MetadataResponse(BaseModel):
+    query: MetadataQuery
+    results: List[Metadata]
diff --git a/geomagio/api/secure/app.py b/geomagio/api/secure/app.py
index e6508cef7..0edaec59c 100644
--- a/geomagio/api/secure/app.py
+++ b/geomagio/api/secure/app.py
@@ -4,9 +4,10 @@ import uuid
 
 from fastapi import Depends, FastAPI, Request, Response
 
-from ..db.session import delete_session, get_session, save_session
+from ..db.session_table import delete_session, get_session, save_session
 from .encryption import get_fernet
 from .login import current_user, router as login_router, User
+from .metadata import router as metadata_router
 from .SessionMiddleware import SessionMiddleware
 
 
@@ -29,6 +30,7 @@ app.add_middleware(
 
 # include login routes to manage user
 app.include_router(login_router)
+app.include_router(metadata_router)
 
 
 @app.get("/")
diff --git a/geomagio/api/secure/metadata.py b/geomagio/api/secure/metadata.py
new file mode 100644
index 000000000..bdd998af7
--- /dev/null
+++ b/geomagio/api/secure/metadata.py
@@ -0,0 +1,70 @@
+from typing import List
+
+from fastapi import APIRouter, Body, Depends, Request, Response
+from obspy import UTCDateTime
+
+from ...metadata import Metadata, MetadataCategory
+from ..db import metadata_table
+from .login import require_user, User
+from .MetadataQuery import MetadataQuery
+from .MetadataResponse import MetadataResponse
+from ... import pydantic_utcdatetime
+
+# routes for login/logout
+router = APIRouter()
+
+
+@router.post("/metadata", response_model=Metadata)
+async def create_metadata(
+    request: Request, metadata: Metadata, user: User = Depends(require_user()),
+):
+    metadata = await metadata_table.create_metadata(metadata)
+    return Response(metadata, status_code=201, media_type="application/json")
+
+
+@router.delete("/metadata/{id}")
+async def delete_metadata(id: int, user: User = Depends(require_user())):
+    await metadata_table.delete_metadata(id)
+
+
+@router.get("/metadata", response_model=List[Metadata])
+async def get_metadata(
+    category: MetadataCategory = None,
+    starttime: UTCDateTime = None,
+    endtime: UTCDateTime = None,
+    network: str = None,
+    station: str = None,
+    channel: str = None,
+    location: str = None,
+    data_valid: bool = None,
+    metadata_valid: bool = True,
+):
+    query = MetadataQuery(
+        category=category,
+        starttime=starttime,
+        endtime=endtime,
+        network=network,
+        station=station,
+        channel=channel,
+        location=location,
+        data_valid=data_valid,
+        metadata_valid=metadata_valid,
+    )
+    metas = await metadata_table.get_metadata(**query.datetime_dict(exclude={"id"}))
+    return metas
+
+
+@router.get("/metadata/{id}", response_model=Metadata)
+async def get_metadata_by_id(id: int):
+    meta = await metadata_table.get_metadata(id=id)
+    if len(meta) != 1:
+        return Response(status_code=404)
+    else:
+        return meta[0]
+
+
+@router.put("/metadata/{id}")
+async def update_metadata(
+    id: int, metadata: Metadata = Body(...), user: User = Depends(require_user()),
+):
+    await metadata_table.update_metadata(metadata)
diff --git a/geomagio/metadata/Metadata.py b/geomagio/metadata/Metadata.py
index b1962fdd2..ce2ad5211 100644
--- a/geomagio/metadata/Metadata.py
+++ b/geomagio/metadata/Metadata.py
@@ -1,7 +1,8 @@
+from datetime import timezone
 from typing import Dict
 
 from obspy import UTCDateTime
-from pydantic import BaseModel
+from pydantic import BaseModel, validator
 
 from .. import pydantic_utcdatetime
 from .MetadataCategory import MetadataCategory
@@ -75,3 +76,14 @@ class Metadata(BaseModel):
     comment: str = None
     # review specific comment
     review_comment: str = None
+
+    def datetime_dict(self, **kwargs):
+        values = self.dict(**kwargs)
+        for key in ["created_time", "reviewed_time", "starttime", "endtime"]:
+            if key in values and values[key] is not None:
+                values[key] = values[key].datetime.replace(tzinfo=timezone.utc)
+        return values
+
+    @validator("created_time")
+    def set_default_created_time(cls, created_time: UTCDateTime = None) -> UTCDateTime:
+        return created_time or UTCDateTime()
diff --git a/geomagio/metadata/MetadataCategory.py b/geomagio/metadata/MetadataCategory.py
index d70092f84..44c5608b6 100644
--- a/geomagio/metadata/MetadataCategory.py
+++ b/geomagio/metadata/MetadataCategory.py
@@ -5,4 +5,5 @@ class MetadataCategory(str, Enum):
     ADJUSTED_MATRIX = "adjusted-matrix"
     FLAG = "flag"
     INSTRUMENT = "instrument"
+    OBSERVATORY = "observatory"
     READING = "reading"
diff --git a/geomagio/metadata/__init__.py b/geomagio/metadata/__init__.py
new file mode 100644
index 000000000..7502db024
--- /dev/null
+++ b/geomagio/metadata/__init__.py
@@ -0,0 +1,5 @@
+from .Metadata import Metadata
+from .MetadataCategory import MetadataCategory
+
+
+__all__ = ["Metadata", "MetadataCategory"]
diff --git a/test_metadata.py b/test_metadata.py
new file mode 100644
index 000000000..5d597ed8b
--- /dev/null
+++ b/test_metadata.py
@@ -0,0 +1,123 @@
+from obspy import UTCDateTime
+
+from geomagio.api.db import database, metadata_table
+from geomagio.api.ws.Observatory import OBSERVATORIES
+from geomagio.metadata import Metadata, MetadataCategory
+
+
+test_metadata = [
+    Metadata(
+        category=MetadataCategory.INSTRUMENT,
+        created_by="test_metadata.py",
+        network="NT",
+        station="BDT",
+        metadata={
+            "type": "FGE",
+            "channels": {
+                # each channel maps to a list of components to calculate nT
+                # TODO: calculate these lists based on "FGE" type
+                "U": [{"channel": "U_Volt", "offset": 0, "scale": 313.2}],
+                "V": [{"channel": "V_Volt", "offset": 0, "scale": 312.3}],
+                "W": [{"channel": "W_Volt", "offset": 0, "scale": 312.0}],
+            },
+            "electronics": {
+                "serial": "E0542",
+                # these scale values are used to convert voltage
+                "x-scale": 313.2,  # V/nT
+                "y-scale": 312.3,  # V/nT
+                "z-scale": 312.0,  # V/nT
+                "temperature-scale": 0.01,  # V/K
+            },
+            "sensor": {
+                "serial": "S0419",
+                # these constants combine with instrument setting for offset
+                "x-constant": 36958,  # nT/mA
+                "y-constant": 36849,  # nT/mA
+                "z-constant": 36811,  # nT/mA
+            },
+        },
+    ),
+    Metadata(
+        category=MetadataCategory.INSTRUMENT,
+        created_by="test_metadata.py",
+        network="NT",
+        station="NEW",
+        metadata={
+            "type": "Narod",
+            "channels": {
+                "U": [
+                    {"channel": "U_Volt", "offset": 0, "scale": 100},
+                    {"channel": "U_Bin", "offset": 0, "scale": 500},
+                ],
+                "V": [
+                    {"channel": "V_Volt", "offset": 0, "scale": 100},
+                    {"channel": "V_Bin", "offset": 0, "scale": 500},
+                ],
+                "W": [
+                    {"channel": "W_Volt", "offset": 0, "scale": 100},
+                    {"channel": "W_Bin", "offset": 0, "scale": 500},
+                ],
+            },
+        },
+    ),
+    Metadata(
+        category=MetadataCategory.INSTRUMENT,
+        created_by="test_metadata.py",
+        network="NT",
+        station="LLO",
+        metadata={
+            "type": "Narod",
+            "channels": {
+                "U": [
+                    {"channel": "U_Volt", "offset": 0, "scale": 100},
+                    {"channel": "U_Bin", "offset": 0, "scale": 500},
+                ],
+                "V": [
+                    {"channel": "V_Volt", "offset": 0, "scale": 100},
+                    {"channel": "V_Bin", "offset": 0, "scale": 500},
+                ],
+                "W": [
+                    {"channel": "W_Volt", "offset": 0, "scale": 100},
+                    {"channel": "W_Bin", "offset": 0, "scale": 500},
+                ],
+            },
+        },
+    ),
+]
+
+# add observatories
+for observatory in OBSERVATORIES:
+    network = "NT"
+    if observatory.agency == "USGS":
+        network = "NT"
+    # rest alphabetical by agency
+    elif observatory.agency == "BGS":
+        network = "GB"
+    elif observatory.agency == "GSC":
+        network = "C2"
+    elif observatory.agency == "JMA":
+        network = "JP"
+    elif observatory.agency == "SANSA":
+        network = "AF"
+    test_metadata.append(
+        Metadata(
+            category=MetadataCategory.OBSERVATORY,
+            created_by="test_metadata.py",
+            network=network,
+            station=observatory.id,
+            metadata=observatory.dict(),
+        )
+    )
+
+
+async def load_test_metadata():
+    await database.connect()
+    for meta in test_metadata:
+        await metadata_table.create_metadata(meta)
+    await database.disconnect()
+
+
+if __name__ == "__main__":
+    import asyncio
+
+    asyncio.run(load_test_metadata())
-- 
GitLab