opensampl 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opensampl/__init__.py +4 -0
- opensampl/cli.py +237 -0
- opensampl/constants.py +84 -0
- opensampl/db/__init__.py +1 -0
- opensampl/db/access_orm.py +128 -0
- opensampl/db/orm.py +198 -0
- opensampl/helpers/__init__.py +1 -0
- opensampl/helpers/create_vendor.py +266 -0
- opensampl/helpers/env.py +48 -0
- opensampl/helpers/source_writer.py +62 -0
- opensampl/load_data.py +414 -0
- opensampl/server/__init__.py +21 -0
- opensampl/server/cli.py +185 -0
- opensampl/server/default.env +17 -0
- opensampl/server/docker-compose.yaml +63 -0
- opensampl/vendors/__init__.py +1 -0
- opensampl/vendors/adva.py +138 -0
- opensampl/vendors/base_probe.py +381 -0
- opensampl/vendors/constants.py +64 -0
- opensampl-0.2.0.dist-info/METADATA +222 -0
- opensampl-0.2.0.dist-info/RECORD +23 -0
- opensampl-0.2.0.dist-info/WHEEL +4 -0
- opensampl-0.2.0.dist-info/entry_points.txt +3 -0
opensampl/__init__.py
ADDED
opensampl/cli.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The openSAMPL CLI package is a click based command line interface for the openSAMPL package. It provides a way to
|
|
3
|
+
interact with the database and load data into it.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, List, Union
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
import yaml
|
|
13
|
+
from dotenv import find_dotenv, load_dotenv
|
|
14
|
+
from loguru import logger
|
|
15
|
+
|
|
16
|
+
from opensampl.constants import ENV_VARS
|
|
17
|
+
from opensampl.db.orm import Base
|
|
18
|
+
from opensampl.helpers.env import set_env
|
|
19
|
+
from opensampl.load_data import create_new_tables, write_to_table
|
|
20
|
+
from opensampl.vendors.constants import VENDOR_MAP, get_vendor_parser
|
|
21
|
+
|
|
22
|
+
BANNER=r"""
|
|
23
|
+
|
|
24
|
+
____ _ __ __ ____ _
|
|
25
|
+
___ _ __ ___ _ __ / ___| / \ | \/ | _ \| |
|
|
26
|
+
/ _ \| '_ \ / _ \ '_ \\___ \ / _ \ | |\/| | |_) | |
|
|
27
|
+
| (_) | |_) | __/ | | |___) / ___ \| | | | __/| |___
|
|
28
|
+
\___/| .__/ \___|_| |_|____/_/ \_\_| |_|_| |_____|
|
|
29
|
+
|_|
|
|
30
|
+
tools for processing clock data
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
env_file = find_dotenv()
|
|
34
|
+
load_dotenv()
|
|
35
|
+
level = ENV_VARS.LOG_LEVEL.get_value()
|
|
36
|
+
logger.configure(handlers=[{"sink": sys.stderr, "level": level.upper()}])
|
|
37
|
+
|
|
38
|
+
class CaseInsensitiveGroup(click.Group):
|
|
39
|
+
"""Defines Click group options as case-insensitive. By default, click groups are case-sensitive."""
|
|
40
|
+
|
|
41
|
+
def get_command(self, ctx, cmd_name):
|
|
42
|
+
"""Normalize command name to lower case"""
|
|
43
|
+
cmd_name = cmd_name.lower()
|
|
44
|
+
# Match against lowercased command names
|
|
45
|
+
for name, cmd in self.commands.items():
|
|
46
|
+
if name.lower() == cmd_name:
|
|
47
|
+
return cmd
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
def get_table_names():
|
|
51
|
+
"""Get all table names from the ORM in opensampl.db.orm"""
|
|
52
|
+
return [table.name for table in Base.metadata.sorted_tables]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@click.group()
|
|
56
|
+
def cli():
|
|
57
|
+
"""CLI utility for openSAMPL"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@cli.command(name="init")
|
|
61
|
+
def init():
|
|
62
|
+
"""
|
|
63
|
+
Initialize the database.
|
|
64
|
+
|
|
65
|
+
Creates all tables as defined in the opensampl.db.orm file.
|
|
66
|
+
This is not required if you are using `opensampl-server`, as that is done as part of that initialization of the db.
|
|
67
|
+
"""
|
|
68
|
+
logger.debug("Initializing database")
|
|
69
|
+
create_new_tables()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@cli.group()
|
|
73
|
+
def config():
|
|
74
|
+
"""View and manage environment variables used by openSAMPL"""
|
|
75
|
+
|
|
76
|
+
@config.command()
|
|
77
|
+
def file():
|
|
78
|
+
"""Show the path to the env file used by openSAMPL"""
|
|
79
|
+
click.echo(env_file)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@config.command()
|
|
83
|
+
@click.option("--explain", "-e", is_flag=True, help="Include descriptions of the variables")
|
|
84
|
+
@click.option("--var", "-v", help="Specify a single variable to display")
|
|
85
|
+
def show(explain: bool, var: str):
|
|
86
|
+
"""
|
|
87
|
+
Display current environment variable configurations.
|
|
88
|
+
|
|
89
|
+
Examples
|
|
90
|
+
--------
|
|
91
|
+
opensampl config show # Show all variables and their values
|
|
92
|
+
opensampl config show --explain # Show all variables with descriptions
|
|
93
|
+
opensampl config show --var BACKEND_URL # Show specific variable
|
|
94
|
+
opensampl config show -e -v BACKEND_URL # Show specific variable with description
|
|
95
|
+
|
|
96
|
+
"""
|
|
97
|
+
logger.debug(f"loaded env_file: {env_file}")
|
|
98
|
+
if var:
|
|
99
|
+
# Filter to specific variable if requested
|
|
100
|
+
vars_to_show = [v for v in ENV_VARS.all() if v.name == var]
|
|
101
|
+
if not vars_to_show:
|
|
102
|
+
click.echo(f"Error: Environment variable '{var}' not found", err=True)
|
|
103
|
+
return
|
|
104
|
+
else:
|
|
105
|
+
vars_to_show = ENV_VARS.all()
|
|
106
|
+
|
|
107
|
+
from tabulate import tabulate
|
|
108
|
+
|
|
109
|
+
# Create a list of dictionaries for the DataFrame
|
|
110
|
+
data = []
|
|
111
|
+
for env_var in vars_to_show:
|
|
112
|
+
row = {
|
|
113
|
+
"Variable": env_var.name,
|
|
114
|
+
"Value": str(env_var.get_value()),
|
|
115
|
+
}
|
|
116
|
+
if explain:
|
|
117
|
+
row.update({"Description": env_var.description})
|
|
118
|
+
data.append(row)
|
|
119
|
+
maxcolwidths = [None, None, 40] if explain else [None, None]
|
|
120
|
+
click.echo(tabulate(data, headers="keys", tablefmt="simple", maxcolwidths=maxcolwidths))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@config.command("set")
|
|
124
|
+
@click.argument("name")
|
|
125
|
+
@click.argument("value")
|
|
126
|
+
def config_set(name: str, value: str, temp: bool = None):
|
|
127
|
+
"""
|
|
128
|
+
Set the value of an environment variable.
|
|
129
|
+
|
|
130
|
+
Note that this will only work if the variable is set in the .env file, if it is a true environment variable the
|
|
131
|
+
change will not persist.
|
|
132
|
+
|
|
133
|
+
Examples
|
|
134
|
+
--------
|
|
135
|
+
opensampl config set BACKEND_URL http://localhost:8000
|
|
136
|
+
|
|
137
|
+
"""
|
|
138
|
+
set_env(name=name, value=value, temp=temp)
|
|
139
|
+
|
|
140
|
+
@cli.group(cls=CaseInsensitiveGroup)
|
|
141
|
+
def load():
|
|
142
|
+
"""Load data into database"""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
for probe_name, vendor_type in VENDOR_MAP.items():
|
|
146
|
+
load.add_command(get_vendor_parser(probe_name).get_cli_command(), name=probe_name)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def path_or_string(value):
|
|
150
|
+
"""Get content from a file or use the string directly"""
|
|
151
|
+
# Get content - either from file or use the string directly
|
|
152
|
+
content = value
|
|
153
|
+
try:
|
|
154
|
+
path = Path(value)
|
|
155
|
+
if path.exists() and path.is_file():
|
|
156
|
+
with open(path, "r") as f:
|
|
157
|
+
content = f.read()
|
|
158
|
+
except Exception:
|
|
159
|
+
# If any error occurs during path handling, treat as raw string
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
# Try parsing as YAML
|
|
163
|
+
try:
|
|
164
|
+
return yaml.safe_load(content)
|
|
165
|
+
except yaml.YAMLError as yaml_err:
|
|
166
|
+
# If YAML parsing fails, try JSON
|
|
167
|
+
try:
|
|
168
|
+
return json.loads(content)
|
|
169
|
+
except json.JSONDecodeError as json_err:
|
|
170
|
+
# If both parsing attempts fail, raise an error
|
|
171
|
+
raise click.BadParameter(
|
|
172
|
+
f"Could not parse input as YAML or JSON.\n" f"YAML error: {yaml_err}\n" f"JSON error: {json_err}"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@load.command("table")
|
|
177
|
+
@click.option(
|
|
178
|
+
"--if-exists",
|
|
179
|
+
"-i",
|
|
180
|
+
type=click.Choice(["update", "error", "replace", "ignore"]),
|
|
181
|
+
default="update",
|
|
182
|
+
help="How to handle conflicts with existing entries",
|
|
183
|
+
)
|
|
184
|
+
@click.argument("table_name", type=click.Choice(get_table_names()))
|
|
185
|
+
@click.argument("filepath", type=path_or_string)
|
|
186
|
+
def table_load(filepath: Union[Dict, List], table_name: str, if_exists: str):
|
|
187
|
+
r"""
|
|
188
|
+
Load data directly into a database table. Format can be yaml or json. Can be a list of dictionaries or a single
|
|
189
|
+
dictionary.
|
|
190
|
+
|
|
191
|
+
You do not have to specify schema, is assumed to be castdb.
|
|
192
|
+
\n\n
|
|
193
|
+
The --if-exists option controls how to handle conflicts:\n
|
|
194
|
+
- update: Only update fields that are provided and non-default (default)\n
|
|
195
|
+
- error: Raise an error if entry exists\n
|
|
196
|
+
- replace: Replace all non-primary-key fields with new values\n
|
|
197
|
+
- ignore: Skip if entry exists\n
|
|
198
|
+
|
|
199
|
+
Example:\n
|
|
200
|
+
cli.py table load locations data.json\n
|
|
201
|
+
cli.py table load probe_metadata metadata.yaml\n
|
|
202
|
+
"""
|
|
203
|
+
try:
|
|
204
|
+
if isinstance(filepath, list):
|
|
205
|
+
for row in filepath:
|
|
206
|
+
write_to_table(table=table_name, data=row, if_exists=if_exists)
|
|
207
|
+
else:
|
|
208
|
+
write_to_table(table_name, filepath, if_exists=if_exists)
|
|
209
|
+
click.echo(f"Successfully wrote data to table {table_name}")
|
|
210
|
+
except Exception as e:
|
|
211
|
+
click.echo(f"Error writing to table: {str(e)}", err=True)
|
|
212
|
+
raise click.Abort()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@cli.command(name="create")
|
|
216
|
+
@click.argument("config_path", type=click.Path(exists=True, path_type=Path))
|
|
217
|
+
@click.option(
|
|
218
|
+
"--update-db",
|
|
219
|
+
"-u",
|
|
220
|
+
is_flag=True,
|
|
221
|
+
help="Update the database with the new probe type",
|
|
222
|
+
)
|
|
223
|
+
def create_probe_command(config_path: Path, update_db: bool):
|
|
224
|
+
"""
|
|
225
|
+
** beta **
|
|
226
|
+
Create a new probe type with scaffolding, based on a config file.
|
|
227
|
+
"""
|
|
228
|
+
from opensampl.helpers.create_vendor import VendorConfig
|
|
229
|
+
|
|
230
|
+
vendor_config = VendorConfig.from_config_file(config_path)
|
|
231
|
+
vendor_config.create()
|
|
232
|
+
if update_db:
|
|
233
|
+
create_new_tables()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
cli()
|
opensampl/constants.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Constants for accessing environment configurations"""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Optional, Type
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EnvVar(BaseModel):
|
|
10
|
+
"""Defines an environment variable and its properties"""
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
description: str
|
|
14
|
+
type: Type = str
|
|
15
|
+
default: Optional[Any] = None
|
|
16
|
+
|
|
17
|
+
def get_value(self):
|
|
18
|
+
"""Get value of var in environment"""
|
|
19
|
+
default = self.resolve_default()
|
|
20
|
+
if self.type is bool:
|
|
21
|
+
return os.getenv(self.name, default).lower() == 'true'
|
|
22
|
+
elif self.type is Path:
|
|
23
|
+
return Path(os.getenv(self.name, default))
|
|
24
|
+
return os.getenv(self.name, default)
|
|
25
|
+
|
|
26
|
+
def resolve_default(self):
|
|
27
|
+
"""Resolve default value for env var based on type"""
|
|
28
|
+
if self.type is bool:
|
|
29
|
+
return self.default or 'false'
|
|
30
|
+
if self.type is Path and self.default is not None:
|
|
31
|
+
return Path(self.default).resolve()
|
|
32
|
+
return self.default
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ENV_VARS:
|
|
36
|
+
"""Variables referenced by openSAMPL"""
|
|
37
|
+
|
|
38
|
+
ROUTE_TO_BACKEND = EnvVar(
|
|
39
|
+
name="ROUTE_TO_BACKEND",
|
|
40
|
+
description=("Route all database operations through BACKEND_URL rather "
|
|
41
|
+
"than applying directly using DATABASE_URL"),
|
|
42
|
+
type=bool
|
|
43
|
+
)
|
|
44
|
+
BACKEND_URL = EnvVar(
|
|
45
|
+
name="BACKEND_URL",
|
|
46
|
+
description="URL of the backend service when routing is enabled",
|
|
47
|
+
)
|
|
48
|
+
DATABASE_URL = EnvVar(
|
|
49
|
+
name="DATABASE_URL",
|
|
50
|
+
description="URL for direct database connections",
|
|
51
|
+
)
|
|
52
|
+
ARCHIVE_PATH = EnvVar(
|
|
53
|
+
name="ARCHIVE_PATH",
|
|
54
|
+
description="Default path that files are moved to after they have been processed",
|
|
55
|
+
type=Path,
|
|
56
|
+
default="archive",
|
|
57
|
+
)
|
|
58
|
+
LOG_LEVEL = EnvVar(
|
|
59
|
+
name="LOG_LEVEL",
|
|
60
|
+
description="Log level for opensampl cli",
|
|
61
|
+
default="INFO",
|
|
62
|
+
)
|
|
63
|
+
API_KEY = EnvVar(
|
|
64
|
+
name="API_KEY",
|
|
65
|
+
description="Access key for interacting with the backend",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def __iter__(cls):
|
|
70
|
+
"""Get all EnvVar objects as iterable"""
|
|
71
|
+
yield from (value for key, value in cls.__dict__.items() if isinstance(value, EnvVar))
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def get(cls, name: str):
|
|
75
|
+
"""Get EnvVar object by name"""
|
|
76
|
+
var = getattr(cls, name, None)
|
|
77
|
+
if isinstance(var, EnvVar):
|
|
78
|
+
return var.get_value()
|
|
79
|
+
return os.getenv(name)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def all(cls) -> list[EnvVar]:
|
|
83
|
+
"""Get all EnvVar objects as list"""
|
|
84
|
+
return [value for key, value in cls.__dict__.items() if isinstance(value, EnvVar)]
|
opensampl/db/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Database files used by openSAMPL"""
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""ORM for managing access to openSAMPL database and endpoints"""
|
|
2
|
+
import secrets
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import List, Union
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
|
|
8
|
+
from sqlalchemy.orm import declarative_base, relationship
|
|
9
|
+
from sqlalchemy.orm.exc import NoResultFound
|
|
10
|
+
from sqlalchemy.schema import MetaData
|
|
11
|
+
|
|
12
|
+
Base = declarative_base(metadata=MetaData(schema="access"))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class APIAccessKey(Base):
|
|
16
|
+
"""Table for recording and managing API access keys."""
|
|
17
|
+
|
|
18
|
+
__tablename__ = "api_access_keys"
|
|
19
|
+
|
|
20
|
+
id = Column(Integer, primary_key=True, index=True)
|
|
21
|
+
key = Column(String(64), unique=True, nullable=False, index=True)
|
|
22
|
+
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
|
23
|
+
expires_at = Column(DateTime, nullable=True)
|
|
24
|
+
|
|
25
|
+
def generate_key(self):
|
|
26
|
+
"""Generate the API access key."""
|
|
27
|
+
self.key = secrets.token_urlsafe(48)
|
|
28
|
+
return self.key
|
|
29
|
+
|
|
30
|
+
def is_expired(self):
|
|
31
|
+
"""Check if API access key is expired."""
|
|
32
|
+
return self.expires_at is not None and datetime.utcnow() > self.expires_at
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Views(Base):
|
|
36
|
+
"""Table for recording and managing views."""
|
|
37
|
+
|
|
38
|
+
__tablename__ = "views"
|
|
39
|
+
view_id = Column(Text, primary_key=True, default=str(uuid.uuid4()))
|
|
40
|
+
name = Column(Text)
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def get_view_by_name(session, name):
|
|
44
|
+
"""Get view by name"""
|
|
45
|
+
try:
|
|
46
|
+
return session.query(Views).filter_by(name=name).one()
|
|
47
|
+
except NoResultFound:
|
|
48
|
+
print(f"View with name {name} not found")
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Roles(Base):
|
|
53
|
+
"""Table for recording and managing roles."""
|
|
54
|
+
|
|
55
|
+
__tablename__ = "roles"
|
|
56
|
+
role_id = Column(Text, primary_key=True, default=str(uuid.uuid4()))
|
|
57
|
+
name = Column(Text)
|
|
58
|
+
view_id = Column(Text, ForeignKey("views.view_id"))
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def get_role_by_name(session, name):
|
|
62
|
+
"""Get role by name"""
|
|
63
|
+
try:
|
|
64
|
+
return session.query(Roles).filter_by(name=name).one()
|
|
65
|
+
except NoResultFound:
|
|
66
|
+
print(f"Role with name {name} not found")
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Users(Base):
|
|
71
|
+
"""Table for recording and managing users."""
|
|
72
|
+
|
|
73
|
+
__tablename__ = "users"
|
|
74
|
+
user_id = Column(Text, primary_key=True, default=str(uuid.uuid4()))
|
|
75
|
+
email = Column(Text)
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def get_user_by_email(session, email):
|
|
79
|
+
"""Get user by email"""
|
|
80
|
+
try:
|
|
81
|
+
return session.query(Users).filter_by(email=email).one()
|
|
82
|
+
except NoResultFound:
|
|
83
|
+
print(f"User with email {email} not found")
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class UserRole(Base):
|
|
88
|
+
"""Table for recording and managing user roles."""
|
|
89
|
+
|
|
90
|
+
__tablename__ = "user_role"
|
|
91
|
+
user_id = Column(Text, ForeignKey("users.user_id"), primary_key=True)
|
|
92
|
+
role_id = Column(Text, ForeignKey("roles.role_id"), primary_key=True)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def add_user_role(emails: Union[str, List[str]], role_name: str, session):
|
|
96
|
+
"""Add user role to the database."""
|
|
97
|
+
if isinstance(emails, str):
|
|
98
|
+
emails = [emails]
|
|
99
|
+
|
|
100
|
+
role = Roles.get_role_by_name(name=role_name)
|
|
101
|
+
if role is None:
|
|
102
|
+
print(f"Role with name {role_name} not found")
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
for email in emails:
|
|
106
|
+
user = Users.get_user_by_email(email=email)
|
|
107
|
+
if user is None:
|
|
108
|
+
# Create a new user if not found
|
|
109
|
+
user = Users(email=email)
|
|
110
|
+
session.add(user)
|
|
111
|
+
session.flush() # Flush to get the generated user_id
|
|
112
|
+
print(f"New user created with email {email}")
|
|
113
|
+
|
|
114
|
+
# Check if user already has the specified role
|
|
115
|
+
if any(ur.role_id == role.role_id for ur in user.user_role):
|
|
116
|
+
print(f"User with email {email} already has role {role_name}")
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
# Create a new entry in user_role table
|
|
120
|
+
user_role = UserRole(user_id=user.user_id, role_id=role.role_id)
|
|
121
|
+
session.add(user_role)
|
|
122
|
+
print(f"User with email {email} assigned role {role_name}")
|
|
123
|
+
session.commit()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
Views.roles = relationship("Roles")
|
|
127
|
+
Roles.user_role = relationship("UserRole")
|
|
128
|
+
Users.user_role = relationship("UserRole")
|
opensampl/db/orm.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Main openSAMPL database ORM"""
|
|
2
|
+
import uuid
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from geoalchemy2 import Geometry, WKTElement
|
|
6
|
+
from geoalchemy2.shape import to_shape
|
|
7
|
+
from loguru import logger
|
|
8
|
+
from sqlalchemy import NUMERIC, TIMESTAMP, Boolean, Column, Float, ForeignKey, Integer, String, Text, UniqueConstraint
|
|
9
|
+
from sqlalchemy.event import listens_for
|
|
10
|
+
from sqlalchemy.orm import Session, declarative_base, relationship
|
|
11
|
+
from sqlalchemy.schema import MetaData
|
|
12
|
+
|
|
13
|
+
SCHEMA_NAME = "castdb"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BaseHelpers:
|
|
17
|
+
"""Mixin for Base class that adds some helper methods"""
|
|
18
|
+
|
|
19
|
+
def to_dict(self):
|
|
20
|
+
"""Convert to dictionary, including changes to make it serializable"""
|
|
21
|
+
def convert_value(value):
|
|
22
|
+
if isinstance(value, datetime):
|
|
23
|
+
return value.isoformat()
|
|
24
|
+
elif hasattr(value, "__geo_interface__"):
|
|
25
|
+
return to_shape(value).__geo_interface__
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
return {c.name: convert_value(getattr(self, c.name)) for c in self.__table__.columns}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
Base = declarative_base(cls=BaseHelpers, metadata=MetaData(schema=SCHEMA_NAME))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Locations(Base):
|
|
35
|
+
"""
|
|
36
|
+
Table for storing locations
|
|
37
|
+
|
|
38
|
+
Automatically parses lat, lon, and z into point
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
__tablename__ = "locations"
|
|
42
|
+
|
|
43
|
+
uuid = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
44
|
+
name = Column(Text, unique=True, nullable=False)
|
|
45
|
+
geom = Column(Geometry(geometry_type="GEOMETRY", srid=4326))
|
|
46
|
+
public = Column(Boolean, nullable=True)
|
|
47
|
+
|
|
48
|
+
probe_metadata = relationship("ProbeMetadata")
|
|
49
|
+
|
|
50
|
+
def __init__(self, **kwargs):
|
|
51
|
+
"""Initialize Location object, including converting lat, lon, and z into point"""
|
|
52
|
+
if "lat" in kwargs and "lon" in kwargs:
|
|
53
|
+
lat = kwargs.pop("lat")
|
|
54
|
+
lon = kwargs.pop("lon")
|
|
55
|
+
z = kwargs.pop("z", None)
|
|
56
|
+
projection = kwargs.pop("projection", 4326)
|
|
57
|
+
if z is not None:
|
|
58
|
+
point_str = f"POINT({lon} {lat} {z})"
|
|
59
|
+
else:
|
|
60
|
+
point_str = f"POINT({lon} {lat})"
|
|
61
|
+
kwargs["geom"] = WKTElement(point_str, srid=projection)
|
|
62
|
+
super().__init__(**kwargs)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TestMetadata(Base):
|
|
66
|
+
"""TestMetadata table for storing name, start and end of tests"""
|
|
67
|
+
|
|
68
|
+
__tablename__ = "test_metadata"
|
|
69
|
+
|
|
70
|
+
uuid = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
71
|
+
name = Column(Text, unique=True, nullable=False)
|
|
72
|
+
start_date = Column(TIMESTAMP)
|
|
73
|
+
end_date = Column(TIMESTAMP)
|
|
74
|
+
|
|
75
|
+
probe_metadata = relationship("ProbeMetadata")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ProbeMetadata(Base):
|
|
79
|
+
"""
|
|
80
|
+
Stores the basic information about clock probes.
|
|
81
|
+
|
|
82
|
+
A unique probe is identified by its ip address and probe_id.
|
|
83
|
+
|
|
84
|
+
Can be associated with an existing location or test by its name by providing location_name/test_name
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
__tablename__ = "probe_metadata"
|
|
88
|
+
|
|
89
|
+
uuid = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
90
|
+
probe_id = Column(Text)
|
|
91
|
+
ip_address = Column(Text)
|
|
92
|
+
vendor = Column(Text)
|
|
93
|
+
model = Column(Text)
|
|
94
|
+
name = Column(Text, unique=True)
|
|
95
|
+
public = Column(Boolean, nullable=True)
|
|
96
|
+
location_uuid = Column(String(36), ForeignKey("locations.uuid"))
|
|
97
|
+
test_uuid = Column(String(36), ForeignKey("test_metadata.uuid"))
|
|
98
|
+
|
|
99
|
+
__table_args__ = (UniqueConstraint("probe_id", "ip_address", name="uq_probe_metadata_ipaddress_probeid"),)
|
|
100
|
+
|
|
101
|
+
probe_data = relationship("ProbeData")
|
|
102
|
+
adva_metadata = relationship("AdvaMetadata", back_populates="probe", uselist=False)
|
|
103
|
+
|
|
104
|
+
def __init__(self, **kwargs):
|
|
105
|
+
"""Initialize Probe Metadata object, dealing with converting location name into uuid"""
|
|
106
|
+
location_name = kwargs.pop("location_name", None)
|
|
107
|
+
test_name = kwargs.pop("test_name", None)
|
|
108
|
+
super().__init__(**kwargs)
|
|
109
|
+
|
|
110
|
+
if location_name:
|
|
111
|
+
self._location_name = location_name # Store it temporarily until we have a session
|
|
112
|
+
if test_name:
|
|
113
|
+
self._test_name = test_name
|
|
114
|
+
|
|
115
|
+
def resolve_references(self, session=None):
|
|
116
|
+
"""
|
|
117
|
+
Resolve the references to location and/or test entries when given just the name, and
|
|
118
|
+
provides the correct foreign uuid key.
|
|
119
|
+
|
|
120
|
+
:param session: sqlalchemy session, used to query for the location/test
|
|
121
|
+
"""
|
|
122
|
+
# If no session, attempt to figure it out from the object
|
|
123
|
+
if not session:
|
|
124
|
+
session = Session.object_session(self)
|
|
125
|
+
if not session:
|
|
126
|
+
logger.warning(
|
|
127
|
+
"No session provided and one could not be found. Not resolving references for probe metadata."
|
|
128
|
+
)
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
if hasattr(self, "_location_name"):
|
|
132
|
+
location = session.query(Locations).filter_by(name=self._location_name).first()
|
|
133
|
+
if not location:
|
|
134
|
+
logger.warning(
|
|
135
|
+
f"Could not find location with name {self._location_name}, leaving location reference null."
|
|
136
|
+
)
|
|
137
|
+
self.location_uuid = location.uuid if location else None
|
|
138
|
+
delattr(self, "_location_name") # Clean up after resolving
|
|
139
|
+
|
|
140
|
+
if hasattr(self, "_test_name"):
|
|
141
|
+
test_meta = session.query(TestMetadata).filter_by(name=self._test_name).first()
|
|
142
|
+
if not test_meta:
|
|
143
|
+
logger.warning(
|
|
144
|
+
f"Could not find test metadata with name {self._test_name}, leaving test reference null."
|
|
145
|
+
)
|
|
146
|
+
self.test_uuid = test_meta.uuid if test_meta else None
|
|
147
|
+
delattr(self, "_test_name")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class ProbeData(Base):
|
|
151
|
+
"""
|
|
152
|
+
Table for storing actual time data from the probes.
|
|
153
|
+
|
|
154
|
+
Each entry has a reference to the probe's uuid, timestamp for the measurement, and value for the measurement.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
__tablename__ = "probe_data"
|
|
158
|
+
|
|
159
|
+
time = Column(TIMESTAMP, primary_key=True)
|
|
160
|
+
probe_uuid = Column(String(36), ForeignKey("probe_metadata.uuid"), primary_key=True)
|
|
161
|
+
value = Column(NUMERIC)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class AdvaMetadata(Base):
|
|
165
|
+
"""
|
|
166
|
+
ADVA Clock Probe specific metadata
|
|
167
|
+
|
|
168
|
+
This is metadata that is specifically provided by ADVA probes in their text file exports.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
__tablename__ = "adva_metadata"
|
|
172
|
+
|
|
173
|
+
probe_uuid = Column(String, ForeignKey("probe_metadata.uuid"), primary_key=True)
|
|
174
|
+
type = Column(Text)
|
|
175
|
+
start = Column(TIMESTAMP)
|
|
176
|
+
frequency = Column(Integer)
|
|
177
|
+
timemultiplier = Column(Integer)
|
|
178
|
+
multiplier = Column(Integer)
|
|
179
|
+
title = Column(Text)
|
|
180
|
+
adva_probe = Column(Text)
|
|
181
|
+
adva_reference = Column(Text)
|
|
182
|
+
adva_reference_expected_ql = Column(Text)
|
|
183
|
+
adva_source = Column(Text)
|
|
184
|
+
adva_direction = Column(Text)
|
|
185
|
+
adva_version = Column(Float)
|
|
186
|
+
adva_status = Column(Text)
|
|
187
|
+
adva_mtie_mask = Column(Text)
|
|
188
|
+
adva_mask_margin = Column(Integer)
|
|
189
|
+
|
|
190
|
+
probe = relationship("ProbeMetadata", back_populates="adva_metadata")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@listens_for(ProbeMetadata, "before_insert")
|
|
194
|
+
def resolve_uuid(mapper, connection, target):
|
|
195
|
+
"""Resolve the location_uuid and test_uuid entries for a probe before the object is inserted into the database."""
|
|
196
|
+
session = Session.object_session(target)
|
|
197
|
+
if session:
|
|
198
|
+
target.resolve_references(session)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Helper functions for the openSAMPL package"""
|