gw_data 0.3.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.
Files changed (55) hide show
  1. gw_data/__init__.py +4 -0
  2. gw_data/config.py +19 -0
  3. gw_data/db/__init__.py +0 -0
  4. gw_data/db/models/__init__.py +37 -0
  5. gw_data/db/models/_base.py +5 -0
  6. gw_data/db/models/connectivity_edge.py +80 -0
  7. gw_data/db/models/customer.py +18 -0
  8. gw_data/db/models/g_node.py +83 -0
  9. gw_data/db/models/installation.py +52 -0
  10. gw_data/db/models/installer.py +17 -0
  11. gw_data/db/models/message.py +51 -0
  12. gw_data/db/models/position_point.py +46 -0
  13. gw_data/db/models/reading.py +55 -0
  14. gw_data/db/models/reading_channel.py +62 -0
  15. gw_data/db/models/user.py +35 -0
  16. gw_data/db/models/user_installation_role.py +38 -0
  17. gw_data/db/scripts/0_db_create.psql +4 -0
  18. gw_data/db/scripts/1_db_user_setup.psql +16 -0
  19. gw_data/db/scripts/2_db_schema_setup.sql +11 -0
  20. gw_data/db/scripts/3_db_alembic_upgrade.sh +2 -0
  21. gw_data/db/scripts/4_db_seed.py +129 -0
  22. gw_data/db/scripts/_XX_drop_all.sql +13 -0
  23. gw_data/db/scripts/seed_data/homes.csv +6 -0
  24. gw_data/db/session.py +0 -0
  25. gw_data/sema/__init__.py +15 -0
  26. gw_data/sema/base.py +184 -0
  27. gw_data/sema/codec.py +168 -0
  28. gw_data/sema/definitions/enums/base.g.node.class/000.yaml +55 -0
  29. gw_data/sema/definitions/enums/g.node.status/000.yaml +35 -0
  30. gw_data/sema/definitions/formats/left.right.dot.yaml +27 -0
  31. gw_data/sema/definitions/formats/uuid4.str.yaml +29 -0
  32. gw_data/sema/definitions/registry.yaml +77 -0
  33. gw_data/sema/definitions/types/g.node.gt/004.yaml +169 -0
  34. gw_data/sema/definitions/types/position.point.gt/000.yaml +86 -0
  35. gw_data/sema/enums/__init__.py +7 -0
  36. gw_data/sema/enums/base_g_node_class.py +29 -0
  37. gw_data/sema/enums/g_node_status.py +28 -0
  38. gw_data/sema/enums/gw_str_enum.py +97 -0
  39. gw_data/sema/enums/old_versions/__init__.py +0 -0
  40. gw_data/sema/indexes/dependency_closure.yaml +19 -0
  41. gw_data/sema/indexes/local_names.yaml +6 -0
  42. gw_data/sema/indexes/lookup.yaml +35 -0
  43. gw_data/sema/indexes/reverse_dependencies.yaml +21 -0
  44. gw_data/sema/indexes/seed_expanded.yaml +29 -0
  45. gw_data/sema/indexes/versions.yaml +31 -0
  46. gw_data/sema/property_format.py +56 -0
  47. gw_data/sema/tests/test_property_format.py +47 -0
  48. gw_data/sema/types/__init__.py +7 -0
  49. gw_data/sema/types/g_node_gt.py +106 -0
  50. gw_data/sema/types/old_versions/__init__.py +0 -0
  51. gw_data/sema/types/position_point_gt.py +31 -0
  52. gw_data-0.3.0.dist-info/METADATA +189 -0
  53. gw_data-0.3.0.dist-info/RECORD +55 -0
  54. gw_data-0.3.0.dist-info/WHEEL +4 -0
  55. gw_data-0.3.0.dist-info/entry_points.txt +2 -0
gw_data/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Gridworks Data Library"""
2
+
3
+ def main() -> None:
4
+ print("Hello from gw_data!")
gw_data/config.py ADDED
@@ -0,0 +1,19 @@
1
+ from typing import Optional
2
+ from pydantic import ConfigDict, SecretStr
3
+ from pydantic_settings import BaseSettings
4
+
5
+ DEFAULT_ENV_FILE = ".env"
6
+
7
+ class Settings(BaseSettings):
8
+ db_url: SecretStr = SecretStr(
9
+ "postgresql+psycopg://gw_admin@localhost:5432/gridworks"
10
+ )
11
+ log_level: str = "INFO"
12
+ log_dir: str = "~/.local/state/gridworks/gw_data/log"
13
+ db_echo: bool = False
14
+
15
+ model_config = ConfigDict(
16
+ env_prefix="gw_data_",
17
+ env_nested_delimiter="__",
18
+ extra="ignore",
19
+ )
gw_data/db/__init__.py ADDED
File without changes
@@ -0,0 +1,37 @@
1
+ """List of all the models"""
2
+
3
+ from gw_data.db.models.position_point import PositionPointSql
4
+ from gw_data.db.models.g_node import GNodeSql
5
+ from gw_data.db.models.connectivity_edge import ConnectivityEdgeSql
6
+
7
+ from gw_data.db.models.customer import CustomerSql
8
+ from gw_data.db.models.installer import InstallerSql
9
+ from gw_data.db.models.user import UserSql
10
+ from gw_data.db.models.installation import InstallationSql
11
+
12
+ from gw_data.db.models.reading_channel import ReadingChannelSql
13
+ from gw_data.db.models.message import MessageSql
14
+ from gw_data.db.models.reading import ReadingSql
15
+
16
+
17
+ # import position_point
18
+ # import g_node
19
+ # import connectivity_edge
20
+ # import customer
21
+ # import installer
22
+ # import user
23
+ # import data_channel
24
+ # import message
25
+ # import reading
26
+ __all__ = [
27
+ "PositionPointSql",
28
+ "GNodeSql",
29
+ "ConnectivityEdgeSql",
30
+ "CustomerSql",
31
+ "InstallerSql",
32
+ "UserSql",
33
+ "InstallationSql",
34
+ "ReadingChannelSql",
35
+ "MessageSql",
36
+ "ReadingSql"
37
+ ]
@@ -0,0 +1,5 @@
1
+ from sqlalchemy import MetaData
2
+ from sqlalchemy.orm import DeclarativeBase
3
+
4
+ class Base(DeclarativeBase):
5
+ metadata = MetaData(schema="gridworks")
@@ -0,0 +1,80 @@
1
+ """
2
+ SQLAlchemy models for the GridNodeRegistry.
3
+
4
+ Each SQL row corresponds to a serialized ASL GT snapshot.
5
+ ASL types are used for validation (via the codec) before any insert/update.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ import uuid
10
+
11
+ from datetime import datetime
12
+
13
+ from sqlalchemy import (
14
+ Uuid,
15
+ Enum,
16
+ DateTime,
17
+ ForeignKey,
18
+ UniqueConstraint,
19
+ func,
20
+ )
21
+ from sqlalchemy.orm import (
22
+ Mapped,
23
+ mapped_column
24
+ )
25
+
26
+ from gw_data.db.models._base import Base
27
+ from gw_data.sema.enums import GNodeStatus
28
+
29
+
30
+ class ConnectivityEdgeSql(Base):
31
+ __tablename__ = "connectivity_edges"
32
+
33
+ id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True)
34
+
35
+ from_g_node_id: Mapped[uuid.UUID] = mapped_column(
36
+ ForeignKey("g_nodes.id"), index=True
37
+ )
38
+ to_g_node_id: Mapped[uuid.UUID] = mapped_column(
39
+ ForeignKey("g_nodes.id"), index=True
40
+ )
41
+
42
+ status: Mapped[GNodeStatus] = mapped_column(
43
+ Enum(GNodeStatus, name="connectivity_edge_status", inherit_schema=True)
44
+ )
45
+
46
+ created_at: Mapped[datetime] = mapped_column(
47
+ DateTime(timezone=True), server_default=func.now()
48
+ )
49
+
50
+ __table_args__ = (
51
+ UniqueConstraint(
52
+ "from_g_node_id", "to_g_node_id",
53
+ name="uq_connectivity_edges_from_to"
54
+ ),
55
+ )
56
+
57
+ # # -------------------
58
+ # # ASL ↔ SQL Helpers
59
+ # # -------------------
60
+
61
+ # def to_gt(self) -> ConnectivityEdgeGt:
62
+ # return ConnectivityEdgeGt(
63
+ # id=self.id,
64
+ # from_g_node_id=self.from_g_node_id,
65
+ # to_g_node_id=self.to_g_node_id,
66
+ # from_g_node_alias=self.from_g_node_alias,
67
+ # to_g_node_alias=self.to_g_node_alias,
68
+ # status=self.status,
69
+ # )
70
+
71
+ # @staticmethod
72
+ # def from_gt(gt: ConnectivityEdgeGt) -> "ConnectivityEdgeSql":
73
+ # return ConnectivityEdgeSql(
74
+ # id=gt.id,
75
+ # from_g_node_id=gt.from_g_node_id,
76
+ # to_g_node_id=gt.to_g_node_id,
77
+ # from_g_node_alias=gt.from_g_node_alias,
78
+ # to_g_node_alias=gt.to_g_node_alias,
79
+ # status=gt.status,
80
+ # )
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+ import uuid
3
+
4
+ from sqlalchemy import Uuid
5
+ from sqlalchemy.dialects.postgresql import JSONB
6
+
7
+ from sqlalchemy.orm import (
8
+ Mapped,
9
+ mapped_column
10
+ )
11
+
12
+ from gw_data.db.models._base import Base
13
+
14
+ class CustomerSql(Base):
15
+ __tablename__ = "customers"
16
+ id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True)
17
+ primary_contact: Mapped[JSONB] = mapped_column(JSONB, nullable=False)
18
+ secondary_contact: Mapped[JSONB] = mapped_column(JSONB, nullable=False)
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Optional
5
+ import uuid
6
+
7
+ from sqlalchemy import (
8
+ func,
9
+ Uuid,
10
+ String,
11
+ Enum,
12
+ DateTime,
13
+ ForeignKey,
14
+ )
15
+ from sqlalchemy.orm import (
16
+ Mapped,
17
+ mapped_column,
18
+ relationship,
19
+ )
20
+
21
+ from gw_data.db.models._base import Base
22
+ from gw_data.db.models.position_point import PositionPointSql
23
+ from gw_data.sema.enums import BaseGNodeClass, GNodeStatus
24
+
25
+ class GNodeSql(Base):
26
+ __tablename__ = "g_nodes"
27
+
28
+ id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True)
29
+ alias: Mapped[str] = mapped_column(String, index=True, unique=True)
30
+ prev_alias: Mapped[Optional[str]] = mapped_column(String, nullable=True)
31
+
32
+ base_class: Mapped[BaseGNodeClass] = mapped_column(
33
+ Enum(BaseGNodeClass, name="base_g_node_class", inherit_schema=True),
34
+ nullable=True
35
+ )
36
+
37
+ g_node_class: Mapped[str] = mapped_column(String)
38
+
39
+ status: Mapped[GNodeStatus] = mapped_column(
40
+ Enum(GNodeStatus, name="g_node_status", inherit_schema=True)
41
+ )
42
+
43
+ position_point_id: Mapped[Optional[uuid.UUID]] = mapped_column(
44
+ ForeignKey("position_points.id"), nullable=True
45
+ )
46
+ position_point: Mapped[Optional[PositionPointSql]] = relationship()
47
+
48
+ display_name: Mapped[Optional[str]] = mapped_column(String, nullable=True)
49
+
50
+ created_at: Mapped[datetime] = mapped_column(
51
+ DateTime(timezone=True), server_default=func.now()
52
+ )
53
+
54
+ # # -------------------
55
+ # # ASL ↔ SQL Helpers
56
+ # # -------------------
57
+
58
+ # def to_gt(self) -> GNodeGt:
59
+ # """Serialize SQL row → ASL GT."""
60
+ # return GNodeGt(
61
+ # g_node_id=self.id,
62
+ # alias=self.alias,
63
+ # base_class=self.base_class,
64
+ # g_node_class=self.g_node_class,
65
+ # status=self.status,
66
+ # prev_alias=self.prev_alias,
67
+ # position_point_id=self.position_point_id,
68
+ # display_name=self.display_name,
69
+ # )
70
+
71
+ # @staticmethod
72
+ # def from_gt(gt: GNodeGt) -> "GNodeSql":
73
+ # """Create SQL model from an ASL GT instance (already validated)."""
74
+ # return GNodeSql(
75
+ # id=gt.g_node_id,
76
+ # alias=gt.alias,
77
+ # prev_alias=gt.prev_alias,
78
+ # base_class=gt.base_class,
79
+ # g_node_class=gt.g_node_class,
80
+ # status=gt.status,
81
+ # position_point_id=gt.position_point_id,
82
+ # display_name=gt.display_name,
83
+ # )
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+ import uuid
3
+
4
+ from sqlalchemy import (
5
+ String,
6
+ Uuid,
7
+ ForeignKey
8
+ )
9
+ from sqlalchemy.dialects.postgresql import JSONB
10
+
11
+ from sqlalchemy.orm import (
12
+ Mapped,
13
+ mapped_column,
14
+ relationship
15
+ )
16
+
17
+ from gw_data.db.models._base import Base
18
+ from gw_data.db.models.customer import CustomerSql
19
+ from gw_data.db.models.g_node import GNodeSql
20
+ from gw_data.db.models.installer import InstallerSql
21
+
22
+ class InstallationSql(Base):
23
+ __tablename__ = "installations"
24
+
25
+ id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True)
26
+ g_node_id: Mapped[str] = mapped_column(
27
+ ForeignKey("g_nodes.id"),
28
+ nullable=False
29
+ )
30
+ g_node: Mapped[GNodeSql] = relationship(lazy="joined")
31
+
32
+ display_name: Mapped[str] = mapped_column(String, nullable=False)
33
+ customer_id: Mapped[uuid.UUID] = mapped_column(
34
+ ForeignKey("customers.id"),
35
+ nullable=False
36
+ )
37
+ customer: Mapped[CustomerSql] = relationship()
38
+
39
+ installer_id: Mapped[uuid.UUID] = mapped_column(
40
+ ForeignKey("installers.id"),
41
+ nullable=False
42
+ )
43
+ installer: Mapped[InstallerSql] = relationship()
44
+
45
+ address: Mapped[dict] = mapped_column(JSONB, nullable=False)
46
+ alert_status: Mapped[dict] = mapped_column(JSONB, nullable=False)
47
+ hardware_layout: Mapped[dict] = mapped_column(JSONB, nullable=False)
48
+ representation_status: Mapped[dict] = mapped_column(JSONB, nullable=True)
49
+ house_parameters: Mapped[dict] = mapped_column(JSONB, nullable=True)
50
+ scada_ip_address: Mapped[str] = mapped_column(String, nullable=True)
51
+ scada_git_commit: Mapped[str] = mapped_column(String, nullable=True)
52
+
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+ import uuid
3
+
4
+ from sqlalchemy import Uuid
5
+ from sqlalchemy.dialects.postgresql import JSONB
6
+
7
+ from sqlalchemy.orm import (
8
+ Mapped,
9
+ mapped_column,
10
+ )
11
+
12
+ from gw_data.db.models._base import Base
13
+
14
+ class InstallerSql(Base):
15
+ __tablename__ = "installers"
16
+ id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True)
17
+ info: Mapped[JSONB] = mapped_column(JSONB, nullable=False)
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+ from datetime import datetime
3
+ import uuid
4
+
5
+ from sqlalchemy import (
6
+ Uuid,
7
+ String,
8
+ DateTime,
9
+ Index
10
+ )
11
+ from sqlalchemy.dialects.postgresql import JSONB
12
+
13
+ from sqlalchemy.orm import (
14
+ Mapped,
15
+ mapped_column,
16
+ )
17
+
18
+ from gw_data.db.models._base import Base
19
+
20
+ class MessageSql(Base):
21
+ __tablename__ = "messages"
22
+ id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True)
23
+ timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, primary_key=True, index=True)
24
+
25
+ # This is not a foreign key because we may receive messages from nodes that are not yet in the database
26
+ from_alias: Mapped[str] = mapped_column(String, nullable=False)
27
+
28
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
29
+ persisted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
30
+
31
+ message_type_name: Mapped[str] = mapped_column(String, nullable=False)
32
+ payload: Mapped[dict] = mapped_column(JSONB, nullable=False)
33
+
34
+ __table_args__ = (
35
+ # TimescaleDB automatically creates this one; we need to include it so Alembic doesn't get confused
36
+ Index("messages_timestamp_idx", timestamp.desc()),
37
+ Index(
38
+ "ix_from_type_message",
39
+ "from_alias",
40
+ "message_type_name",
41
+ "persisted_at"
42
+ ),
43
+ {
44
+ 'timescaledb_hypertable': {
45
+ 'time_column_name': 'timestamp',
46
+ }
47
+ }
48
+ )
49
+
50
+ def test_fn(self):
51
+ self.abc.defe = 3
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ import uuid
5
+
6
+ from sqlalchemy import (
7
+ func,
8
+ Uuid,
9
+ DateTime,
10
+ )
11
+ from sqlalchemy.orm import (
12
+ Mapped,
13
+ mapped_column
14
+ )
15
+
16
+ from gw_data.db.models._base import Base
17
+ from gw_data.sema.types import PositionPointGt
18
+
19
+ class PositionPointSql(Base):
20
+ __tablename__ = "position_points"
21
+
22
+ id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True)
23
+ latitude_micro_deg: Mapped[int] = mapped_column()
24
+ longitude_micro_deg: Mapped[int] = mapped_column()
25
+
26
+ created_at: Mapped[datetime] = mapped_column(
27
+ DateTime(timezone=True), server_default=func.now()
28
+ )
29
+
30
+ def to_gt(self) -> PositionPointGt:
31
+ """Serialize database row → ASL GT."""
32
+ return PositionPointGt(
33
+ id=str(self.id),
34
+ latitude_micro_deg=self.latitude_micro_deg,
35
+ longitude_micro_deg=self.longitude_micro_deg,
36
+ )
37
+
38
+ @staticmethod
39
+ def from_gt(gt: PositionPointGt) -> "PositionPointSql":
40
+ """Create SQL row from ASL GT after full ASL validation."""
41
+ return PositionPointSql(
42
+ id=uuid.UUID(gt.id),
43
+ latitude_micro_deg=gt.latitude_micro_deg,
44
+ longitude_micro_deg=gt.longitude_micro_deg,
45
+ )
46
+
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+ from datetime import datetime
3
+ import uuid
4
+
5
+ from sqlalchemy import (
6
+ BigInteger,
7
+ DateTime,
8
+ ForeignKey,
9
+ Index,
10
+ UniqueConstraint
11
+ )
12
+
13
+ from sqlalchemy.orm import (
14
+ Mapped,
15
+ mapped_column,
16
+ relationship,
17
+ )
18
+
19
+ from gw_data.db.models._base import Base
20
+ from gw_data.db.models.reading_channel import ReadingChannelSql
21
+
22
+ class ReadingSql(Base):
23
+ __tablename__ = "readings"
24
+ channel_id: Mapped[uuid.UUID] = mapped_column(
25
+ ForeignKey("reading_channels.id"),
26
+ nullable=False
27
+ )
28
+ channel: Mapped[ReadingChannelSql] = relationship()
29
+
30
+ # This is not a foreign key for two reasons:
31
+ # 1. TimescaleDB does not allow foreign keys between hypertables
32
+ # 2. We may conceivably have readings that do not have an associated message
33
+ # (e.g., from a server cron job or an admin action)
34
+ #
35
+ # Also note that since they are both hypertables, any queries that map between readings and messages
36
+ # should correlate based on time to improve performance.
37
+ message_id: Mapped[uuid.UUID] = mapped_column(
38
+ nullable=False,
39
+ index=True
40
+ )
41
+ timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
42
+ value: Mapped[BigInteger] = mapped_column(BigInteger, nullable=False)
43
+
44
+ __table_args__ = (
45
+ # TimescaleDB automatically creates this one; we need to include it so Alembic doesn't get confused
46
+ Index("readings_timestamp_idx", timestamp.desc()),
47
+ UniqueConstraint("channel_id", "timestamp", name="readings_channel_id_timestamp_key")
48
+ )
49
+
50
+ # This table does not need a true primary key -- but SQLAlchemy requires one.
51
+ # So we define a synthetic PK out of two columns that should always be unique.
52
+ __mapper_args__ = {
53
+ "primary_key": [channel_id, timestamp]
54
+ }
55
+
@@ -0,0 +1,62 @@
1
+ from datetime import datetime
2
+ import uuid
3
+
4
+ from sqlalchemy import (
5
+ Index,
6
+ Uuid,
7
+ Boolean,
8
+ String,
9
+ DateTime,
10
+ UniqueConstraint
11
+ )
12
+
13
+ from sqlalchemy.orm import (
14
+ Mapped,
15
+ mapped_column,
16
+ )
17
+
18
+ from gw_data.db.models._base import Base
19
+
20
+ class ReadingChannelSql(Base):
21
+ """
22
+ Reading Channel.
23
+
24
+ A reading channel is a source of data for readings, with metadata common to the readings.
25
+ """
26
+
27
+ __tablename__ = "reading_channels"
28
+ id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True)
29
+ name: Mapped[str] = mapped_column(String, nullable=False)
30
+ terminal_asset_alias: Mapped[str] = mapped_column(String, nullable=False)
31
+ display_name: Mapped[str] = mapped_column(String, nullable=False)
32
+ unit: Mapped[str] = mapped_column(String, nullable=False)
33
+ unit_type: Mapped[str] = mapped_column(String, nullable=False)
34
+ channel_type: Mapped[str] = mapped_column(String, nullable=False)
35
+ deactivated_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
36
+
37
+ __table_args__ = (
38
+ Index("terminal_asset_alias", "name"),
39
+ UniqueConstraint(
40
+ "terminal_asset_alias",
41
+ "name",
42
+ "deactivated_date",
43
+ name="unique_name_terminal_asset_deactivated_date",
44
+ postgresql_nulls_not_distinct=True
45
+ )
46
+ )
47
+
48
+ def __repr__(self):
49
+ active_status = f"deactivated {self.deactivated_date.isoformat()}" if self.self.deactivated_date is not None else "active"
50
+ return (
51
+ f"<ReadingChannelSql(name='{self.name}', "
52
+ f"unit='{self.unit}', terminal asset={self.terminal_asset_alias.split(".")[-2]}, [{active_status}])>"
53
+
54
+ )
55
+
56
+ def __str__(self):
57
+ active_status = f"deactivated {self.deactivated_date.isoformat()}" if self.self.deactivated_date is not None else "active"
58
+ return (
59
+ f"ReadingChannelSql(name:{self.name},"
60
+ f"unit: {self.unit}, terminal asset: {self.terminal_asset_alias.split(".")[-2]}, [{active_status}])"
61
+ )
62
+
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+ from datetime import datetime
3
+ import uuid
4
+
5
+ from sqlalchemy import (
6
+ String,
7
+ DateTime,
8
+ Boolean,
9
+ Uuid,
10
+ )
11
+ from sqlalchemy.sql import func
12
+
13
+ from sqlalchemy.orm import (
14
+ Mapped,
15
+ mapped_column,
16
+ relationship,
17
+ )
18
+
19
+ from gw_data.db.models._base import Base
20
+ from gw_data.db.models.user_installation_role import UserInstallationRoleSql
21
+
22
+ class UserSql(Base):
23
+ __tablename__ = "users"
24
+ id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True)
25
+ username: Mapped[str] = mapped_column(String(50), nullable=False, unique=True)
26
+ hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
27
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
28
+ last_login: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
29
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
30
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
31
+
32
+ installation_roles: Mapped[list[UserInstallationRoleSql]] = relationship(uselist=True, lazy="joined")
33
+
34
+ def __repr__(self) -> str:
35
+ return f"User(id={self.id!r}, username={self.username!r}, is_active={self.is_active!r})"
@@ -0,0 +1,38 @@
1
+ import uuid
2
+
3
+ from sqlalchemy import ForeignKey, String, UniqueConstraint
4
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
5
+
6
+ from gw_data.db.models._base import Base
7
+ from gw_data.db.models.installation import InstallationSql
8
+
9
+
10
+ class UserInstallationRoleSql(Base):
11
+ __tablename__ = "user_installation_roles"
12
+ user_id: Mapped[uuid.UUID] = mapped_column(
13
+ ForeignKey("users.id"),
14
+ nullable=False
15
+ )
16
+ role: Mapped[str] = mapped_column(String, nullable=False)
17
+ installation_id: Mapped[uuid.UUID] = mapped_column(
18
+ ForeignKey("installations.id"),
19
+ nullable=True
20
+ )
21
+
22
+ # A null installation_id associates the role to ALL installations (e.g. for admin users)
23
+ installations: Mapped[list[InstallationSql]] = relationship(
24
+ "InstallationSql",
25
+ uselist=True,
26
+ lazy="joined",
27
+ primaryjoin="or_(InstallationSql.id==UserInstallationRoleSql.installation_id, UserInstallationRoleSql.installation_id == None)"
28
+ )
29
+
30
+ __table_args__ = (
31
+ UniqueConstraint("user_id", "installation_id", name="user_installation_roles_user_installation_key"),
32
+ )
33
+
34
+ # This table does not need a true primary key -- but SQLAlchemy requires one.
35
+ # So we define a synthetic PK out of two columns that should always be unique.
36
+ __mapper_args__ = {
37
+ "primary_key": [user_id, installation_id]
38
+ }
@@ -0,0 +1,4 @@
1
+ -- In prod this will already be created.
2
+ CREATE DATABASE tsdb;
3
+ \c tsdb
4
+ CREATE EXTENSION IF NOT EXISTS timescaledb;
@@ -0,0 +1,16 @@
1
+ -- The following needs to be done by a powerful role (like postgresql or tsdbadmin), and doesn't need a specific database connection
2
+
3
+ -- Basic security config
4
+ REVOKE CONNECT ON DATABASE tsdb FROM PUBLIC;
5
+
6
+ CREATE USER gw_admin;
7
+ GRANT ALL ON DATABASE tsdb TO gw_admin;
8
+ \password gw_admin
9
+
10
+ CREATE USER gw_journalkeeper;
11
+ GRANT CONNECT ON DATABASE tsdb TO gw_journalkeeper;
12
+ \password gw_journalkeeper
13
+
14
+ CREATE USER gw_visualizer;
15
+ GRANT CONNECT ON DATABASE tsdb TO gw_visualizer;
16
+ \password gw_visualizer
@@ -0,0 +1,11 @@
1
+ -- The following should be done by gw_admin while connected to tsdb
2
+
3
+ CREATE SCHEMA gridworks;
4
+
5
+ GRANT USAGE ON SCHEMA gridworks TO gw_journalkeeper;
6
+ ALTER DEFAULT PRIVILEGES IN SCHEMA gridworks GRANT SELECT,INSERT,UPDATE,DELETE ON TABLES TO gw_journalkeeper;
7
+ ALTER DEFAULT PRIVILEGES IN SCHEMA gridworks GRANT ALL PRIVILEGES ON SEQUENCES TO gw_journalkeeper;
8
+
9
+ GRANT USAGE ON SCHEMA gridworks TO gw_visualizer;
10
+ ALTER DEFAULT PRIVILEGES IN SCHEMA gridworks GRANT SELECT ON TABLES TO gw_visualizer;
11
+ ALTER DEFAULT PRIVILEGES IN SCHEMA gridworks GRANT ALL PRIVILEGES ON SEQUENCES TO gw_visualizer;
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ .venv/bin/python3 .venv/bin/alembic upgrade head