diff --git a/documents/conf.py b/documents/conf.py
index 28b41f2..bf7bc5a 100644
--- a/documents/conf.py
+++ b/documents/conf.py
@@ -193,6 +193,7 @@ apidoc_separate_modules = True
autodoc_pydantic_model_show_field_summary = True
autodoc_pydantic_model_show_validator_summary = True
autodoc_pydantic_field_doc_policy = "both"
+autodoc_pydantic_field_docutils_summary = True
set_type_checking_flag = True
diff --git a/pyproject.toml b/pyproject.toml
index 18cc7c4..4ed7a27 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -38,6 +38,11 @@ dependencies = [
"email-validator>=2.3.0",
"sphinxcontrib-apidoc>=0.6.0",
"autodoc-pydantic>=2.2.0",
+ "sqlmodel>=0.0.31",
+ "fastapi>=0.128.0",
+ "uvicorn>=0.38.0",
+ "python-dotenv>=1.2.1",
+ "psycopg2>=2.9.11",
]
classifiers = [
"Programming Language :: Python :: 3",
diff --git a/src/nrsk/__init__.py b/src/nrsk/__init__.py
index e69de29..d99be3a 100644
--- a/src/nrsk/__init__.py
+++ b/src/nrsk/__init__.py
@@ -0,0 +1,7 @@
+"""NRSK root."""
+
+from pathlib import Path
+
+PACKAGE_ROOT = Path(__file__).resolve().parent
+PROJECT_ROOT = PACKAGE_ROOT.parent.parent
+DOCS_ROOT = PACKAGE_ROOT.parent.parent / "documents"
diff --git a/src/nrsk/db.py b/src/nrsk/db.py
new file mode 100644
index 0000000..b6e44ea
--- /dev/null
+++ b/src/nrsk/db.py
@@ -0,0 +1,18 @@
+"""Database management code."""
+
+import os
+
+from dotenv import load_dotenv
+from sqlmodel import Session, SQLModel, create_engine, select
+
+load_dotenv()
+
+POSTGRES_USER = os.getenv("POSTGRES_USER")
+POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD")
+POSTGRES_PATH = os.getenv("POSTGRES_PATH")
+
+
+def get_engine():
+ return create_engine(
+ f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_PATH}",
+ )
diff --git a/src/nrsk/documents/intake.py b/src/nrsk/documents/intake.py
new file mode 100644
index 0000000..06c8d8a
--- /dev/null
+++ b/src/nrsk/documents/intake.py
@@ -0,0 +1,97 @@
+"""Document data intake."""
+
+import os
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI, Request
+from fastapi.responses import HTMLResponse
+from sqlmodel import Session, SQLModel, select
+
+from nrsk.db import get_engine
+
+# import others to create DB?
+from nrsk.models import Document, User
+
+engine = get_engine()
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ # --- Startup Logic ---
+
+ yield # The app runs while it's "yielding"
+
+ # --- Shutdown Logic ---
+ print("Shutting down safely")
+
+
+app = FastAPI(lifespan=lifespan)
+
+
+@app.get("/schema")
+def get_schema():
+ # This generates the JSON Schema from your SQLModel/Pydantic model
+ return Document.model_json_schema(mode="serialization")
+
+
+@app.post("/submit")
+def submit_data(data: Document):
+ with Session(engine) as session:
+ breakpoint()
+ data = Document.model_validate(data)
+ session.add(data)
+ session.commit()
+ return {"status": "success", "id": data.id}
+
+
+@app.get("/documents/")
+def read_documents(skip: int = 0, limit: int = 10):
+ with Session(engine) as session:
+ statement = select(Document).offset(skip).limit(limit)
+ results = session.exec(statement).all()
+ return results
+
+
+@app.get("/", response_class=HTMLResponse)
+def get_form():
+ return """
+
+
+
+ QA Entry Form
+
+
+
+
+ Submit QA Revision
+
+
+
+
+
+
+ """
diff --git a/src/nrsk/documents/seed_doc_db.py b/src/nrsk/documents/seed_doc_db.py
new file mode 100644
index 0000000..014919a
--- /dev/null
+++ b/src/nrsk/documents/seed_doc_db.py
@@ -0,0 +1,23 @@
+"""Seed DB for documents, e.g. with doc types"""
+
+from sqlmodel import Session
+
+from nrsk import DOCS_ROOT
+from nrsk.db import get_engine
+from nrsk.documents.validate import validate_doc_types
+
+
+def seed_doc_types():
+ engine = get_engine()
+ doc_types = validate_doc_types(DOCS_ROOT / "_data" / "doc-types.yaml")
+
+ with Session(engine) as session:
+ for dtype in doc_types:
+ session.add(dtype)
+
+ session.commit()
+
+
+if __name__ == "__main__":
+ seed_doc_types()
+ print("seeded doc types")
diff --git a/src/nrsk/documents/validate.py b/src/nrsk/documents/validate.py
index 40f1a50..438e129 100644
--- a/src/nrsk/documents/validate.py
+++ b/src/nrsk/documents/validate.py
@@ -11,9 +11,13 @@ import yaml
from nrsk.models import InformationTypes
-def validate_doc_types(app):
+def sphinx_validate_doc_types(app) -> dict:
"""Ensure doc type data is valid."""
fpath = pathlib.Path(app.srcdir) / "_data" / "doc-types.yaml"
+ return validate_doc_types(fpath)
+
+
+def validate_doc_types(fpath: str) -> dict:
with open(fpath) as f:
data = yaml.safe_load(f)
- data = InformationTypes.validate_python(data)
+ return InformationTypes.validate_python(data)
diff --git a/src/nrsk/models.py b/src/nrsk/models.py
index 1404800..e0fb9db 100644
--- a/src/nrsk/models.py
+++ b/src/nrsk/models.py
@@ -37,20 +37,22 @@ This is the official Data Dictionary discussed in :ref:`the Information
Management Plan `.
"""
-from __future__ import annotations # allow lookahead annotation
-
import re
-import uuid
from datetime import datetime, timedelta
from enum import StrEnum
-from typing import Annotated, Any
+from typing import Annotated, Any, Optional
+from uuid import UUID, uuid4
+
+# _PK_TYPE = UUID
+# moving away from UUID at least temporarily b/c SQLite doesn't
+# really support it, which makes adding new data via DBeaver harder
+_PK_TYPE = int
from pydantic import (
AnyUrl,
BaseModel,
ConfigDict,
EmailStr,
- Field,
PositiveInt,
TypeAdapter,
ValidationError,
@@ -58,23 +60,45 @@ from pydantic import (
field_validator,
model_validator,
)
+from sqlalchemy import text
+from sqlmodel import JSON, Column, Field, Relationship, SQLModel
ALL_CAPS = re.compile("^[A-Z]$")
-UUID_PK = Annotated[
- uuid.UUID,
- Field(
- default_factory=uuid.uuid4,
+
+
+class NRSKModel(SQLModel):
+ id: _PK_TYPE = Field(
+ # default_factory=uuid4,
description="The unique ID of this object. Used as a primary key in the database.",
- examples=["3fa85f64-5717-4562-b3fc-2c963f66afa6"],
- frozen=True,
- ),
-]
+ primary_key=True,
+ # schema_extra={
+ # "examples": ["3fa85f64-5717-4562-b3fc-2c963f66afa6"],
+ # },
+ )
-class User(BaseModel):
+class DocumentUserLink(NRSKModel, table=True):
+ """Linkages between users and documents."""
+
+ position: int = Field(default=0)
+ """Integer indicating order of people"""
+
+ role_note: str = Field(
+ default="",
+ )
+ """Extra information about role such as 'lead' or 'section 2.4'"""
+
+ document_id: _PK_TYPE | None = Field(
+ foreign_key="document.id", primary_key=True, default=None
+ )
+ user_id: _PK_TYPE | None = Field(
+ foreign_key="user.id", primary_key=True, default=None
+ )
+
+
+class User(NRSKModel, table=True):
"""A person involved in the Project."""
- uuid: UUID_PK
given_name: str
family_name: str
preferred_name: str | None = None
@@ -85,16 +109,19 @@ class User(BaseModel):
organization: str | None
title: str | None
+ contributed: list["Document"] = Relationship(
+ back_populates="contributors", link_model=DocumentUserLink
+ )
-class OpenItem(BaseModel):
- uuid: UUID_PK
+
+class OpenItem(NRSKModel):
name: str
status: str
created_on: datetime
closed_on: datetime | None = None
-class SSC(BaseModel):
+class SSC(NRSKModel):
"""
A Structure, System, or Component in the plant.
@@ -106,24 +133,25 @@ class SSC(BaseModel):
contents in terms of systems/components/equipment/parts
"""
- uuid: UUID_PK
name: str
pbs_code: str | None = Field(
description="An integer sequence that determines the 'system number' and also the ordering in printouts",
- examples=["1.2.3", "20.5.11"],
+ schema_extra={
+ "examples": ["1.2.3", "20.5.11"],
+ },
default="",
)
"""PBS code is tied closely to the structure of the PBS, obviously. If 1.2
is a category level, that's ok, but that doesn't imply that the second level
of PBS 2 is also a category level; it may be systems.
Since this can change in major PBS reorganizations, it should not be used
- for cross-referencing (use UUID).
+ for cross-referencing (use ID).
"""
abbrev: str = Field(
description="A human-friendly abbreviation uniquely defining the system"
)
- parent: SSC | None = None
+ parent: Optional["SSC"] = None
functions: list[str | None] = Field(
description="Functions of this system", default=None
)
@@ -156,25 +184,25 @@ class SystemsList(BaseModel):
systems: list[SSC]
-class ParamDef(BaseModel):
+class ParamDef(NRSKModel):
"""A parameter class defining an aspect of plant design."""
- uuid: UUID_PK
name: str = Field(
- description="Name of parameter class", examples=["Nominal gross power"]
- )
- description: str = Field(
- description="Detailed description of what parameters of this type represent"
+ schema_extra={"examples": ["Nominal gross power"]},
)
+ """Name of the parameter class."""
+ description: str
+ """Detailed description of what parameters of this type represent"""
+
valid_units: list[str | None] = Field(
- description="List of units allowed", examples=["MW", "W", "shp"], default=None
+ schema_extra={"examples": ["MW", "W", "shp"]}, default=None
)
+ """List of units allowed"""
-class ParamVal(BaseModel):
+class ParamVal(NRSKModel):
"""A particular value of a Parameter, assigned to a particular SSC."""
- uuid: UUID_PK
ssc: SSC
pdef: ParamDef
value: str
@@ -185,21 +213,23 @@ class ParamVal(BaseModel):
source: str = Field(description="Where this version of the value came from")
-class ITSystem(BaseModel):
+class ITSystem(NRSKModel):
"""An IT system used by the project."""
- uuid: UUID_PK
name: str
vendor: str
version: str | None = None
use_cases: list[str] = Field(
- description="One or more use cases this system is used for.",
- examples=[
- [
- "Document management",
- ]
- ],
+ schema_extra={
+ "examples": [
+ [
+ "Document management",
+ ]
+ ],
+ }
)
+ """One or more use cases this system is used for."""
+
physical_location: str = Field(description="Where the system is physically located")
url: AnyUrl | None = Field(description="Full URL to the system", default=None)
custodian: User | None = Field(
@@ -210,27 +240,36 @@ class ITSystem(BaseModel):
quality_related: bool
-class InformationType(BaseModel):
+class InformationType(NRSKModel, table=True):
"""A type/kind/class of Information, Document, or Record."""
model_config = ConfigDict(extra="forbid")
name: str
abbrev: str
- examples: list[str] | None = None
+ examples: list[str] | None = Field(
+ default=None,
+ sa_column=Column(JSON),
+ )
description: str = ""
retention: str | None = ""
record: bool = True
use_cases: str = ""
notes: str = ""
- parent: InformationType | None = None
+ parent_id: _PK_TYPE | None = Field(default=None, foreign_key="informationtype.id")
+ # Add these two relationships for easier DB parsing in code
+ parent: Optional["InformationType"] = Relationship(
+ back_populates="subtypes",
+ sa_relationship_kwargs={"remote_side": "InformationType.id"},
+ )
+ subtypes: list["InformationType"] = Relationship(back_populates="parent")
InformationTypes = TypeAdapter(list[InformationType])
"""A list of document types."""
-class Document(BaseModel):
+class Document(NRSKModel, table=True):
"""
Data dictionary entry for Documents and Records.
@@ -392,34 +431,96 @@ class Document(BaseModel):
LIFETIME = "LIFETIME"
"""Lifetime of the plant."""
- uuid: UUID_PK
- number: str = Field(
- description="The identification number meeting the document numbering rules",
- )
+ # use_attribute_docstrings allows us to just use docstrings and get
+ # the same info in both the JSON Schema and also the Sphinx render
+ model_config = ConfigDict(use_attribute_docstrings=True)
+
+ number: str
+ """The identification number meeting the document numbering rules"""
+
title: str = Field(
- description="Descriptive title explaining the contents",
- examples=["CNSG Development and Status 1966-1977"],
+ schema_extra={
+ "examples": ["CNSG Development and Status 1966-1977"],
+ },
)
+ """Descriptive title explaining the contents"""
+
revision: str = Field(
- description="Revision code",
- examples=["0", "1", "1a", "A"],
+ schema_extra={
+ "examples": ["0", "1", "1a", "A"],
+ },
)
- originating_organization: str
- originator_number: str | None = Field(
- description="The originating organization's document number (if originated externally).",
+ """Revision code"""
+
+ originating_organization_id: _PK_TYPE | None = Field(
+ foreign_key="organization.id",
+ description="The organization that owns or issued this document",
default=None,
)
- originator_revision: str | None = Field(
- description="The originating organization's revision code (if originated externally).",
- default=None,
+ # This allows you to do `my_document.orginating_organization` in Python
+ originating_organization: "Organization" = Relationship()
+
+ originator_number: str | None = None
+ """The originating organization's document number (if originated externally)."""
+
+ originator_revision: str | None = None
+ """The originating organization's revision code (if originated externally)."""
+
+ type_id: _PK_TYPE = Field(
+ foreign_key="informationtype.id",
+ description="The ID of the InformationType",
)
- type: str
- revision_authors: list[str] | None
- revision_reviewers: list[str] | None
- revision_approvers: list[str] | None
- revision_comment: str = Field(
- description="Explanation of what changed in this revision", default=""
+ # type: "InformationType" = Relationship()
+
+ contributors: list[User] = Relationship(
+ back_populates="contributed",
+ link_model=DocumentUserLink,
+ sa_relationship_kwargs={
+ "order_by": "DocumentUserLink.position",
+ "lazy": "selectin",
+ },
)
+ """Holds all relationships with users but does not show up in JSON Schema"""
+
+ @computed_field
+ @property
+ def authors(self) -> list[User]:
+ """List of author info for the UI."""
+ return [{"id": a.id, "name": a.name} for a in self.contributors]
+
+ @computed_field
+ @property
+ def reviewers(self) -> list[User]:
+ """List of reviewer info for the UI."""
+ return [
+ {"id": a.id, "name": a.name}
+ for a in self.contributors
+ if a.role == "reviewer"
+ ]
+
+ # revision_reviewers: list[RevisionReviewerLink] = Relationship(
+ # back_populates="reviewed",
+ # link_model=RevisionReviewerLink,
+ # sa_relationship_kwargs={
+ # "order_by": "RevisionReviewerLink.position",
+ # "cascade": "all, delete-orphan",
+ # },
+ # )
+ # """The reviewer(s), if any."""
+
+ # revision_approvers: list[RevisionApproverLink] = Relationship(
+ # back_populates="approved",
+ # link_model=RevisionApproverLink,
+ # sa_relationship_kwargs={
+ # "order_by": "RevisionApproverLink.position",
+ # "cascade": "all, delete-orphan",
+ # },
+ # )
+ # """The approver(s), if any."""
+
+ revision_comment: str | None = None
+ """Explanation of what changed in this revision"""
+
status: STATUS = STATUS.RESERVED
usage: USAGE = USAGE.FOR_INFORMATION
retention_plan: RETENTION = RETENTION.LIFETIME
@@ -433,14 +534,18 @@ class Document(BaseModel):
# filenames may be empty at first, i.e. for RESERVED docs
filenames: list[str] = Field(
description="Filenames of files attached to this Document. Main file should be the first.",
- default=[],
+ default_factory=list,
+ sa_column=Column(JSON, nullable=False, server_default=text("'[]'")),
)
file_notes: list[str] = Field(
description="Short description of each file represented in filenames.",
- default=[],
+ default_factory=list,
+ sa_column=Column(JSON, nullable=False, server_default=text("'[]'")),
)
checksums: list[str] = Field(
- description="SHA-256 checksum of each file for data integrity", default=[]
+ description="SHA-256 checksum of each file for data integrity",
+ default_factory=list,
+ sa_column=Column(JSON, nullable=False, server_default=text("'[]'")),
)
"""Checksums are used to verify long-term data integrity against tampering
and data degradation. While BLAKE3 checksums are faster, SHA-256 is more standard
@@ -455,11 +560,6 @@ class Document(BaseModel):
description="Additional information about the Document/Record", default=""
)
- @field_validator("type", mode="after")
- @classmethod
- def type_must_be_valid(cls, v: str) -> str:
- assert v in ["CALC", "PROC"], f"{v} must be within the list of doctypes"
-
@computed_field
@property
def status_category(self) -> str:
@@ -479,7 +579,32 @@ class Document(BaseModel):
return self
-class PredecessorTask(BaseModel):
+class Organization(NRSKModel, table=True):
+ """An organization of people: companies, departments, governments, etc."""
+
+ name: str = Field(index=True)
+ """Organization Name"""
+
+ abbreviation: str | None = Field(default=None, index=True)
+ website: str | None = None
+ is_active: bool = Field(default=True)
+
+ # allow it to be hierarchical to capture full org trees and refer to
+ # divisions
+ parent_id: _PK_TYPE | None = Field(
+ default=None,
+ foreign_key="organization.id",
+ )
+ """The parent organization this org reports to"""
+
+ parent: Optional["Organization"] = Relationship(
+ back_populates="child_orgs",
+ sa_relationship_kwargs={"remote_side": "Organization.id"},
+ )
+ child_orgs: list["Organization"] = Relationship(back_populates="parent")
+
+
+class PredecessorTask(NRSKModel):
"""Link to a predecessor task."""
class PRED_TYPE(StrEnum): # noqa: N801
@@ -494,7 +619,6 @@ class PredecessorTask(BaseModel):
SF = "SF"
"""Start-to-finish: predecessor starts before successor finishes (uncommon, maybe shift change)"""
- uuid: UUID_PK
id: str
"""ID of the predecessor task."""
type: PRED_TYPE = PRED_TYPE.FS
@@ -505,10 +629,9 @@ class PredecessorTask(BaseModel):
)
-class ScheduledTask(BaseModel):
+class ScheduledTask(NRSKModel):
"""Scheduled task, e.g. in P6."""
- uuid: UUID_PK
name: str
id: str | None = None
is_milestone: bool = False