Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions api/sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
# limitations under the License.
# ===============================================================================

from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.exc import IntegrityError, ProgrammingError
from sqlalchemy.orm import Session
from starlette.status import HTTP_201_CREATED
from starlette.status import HTTP_201_CREATED, HTTP_409_CONFLICT

from api.pagination import CustomPage
from core.dependencies import session_dependency
Expand All @@ -34,13 +35,39 @@
)


def database_error_handler(
payload: CreateSample | UpdateSample, error: IntegrityError | ProgrammingError
) -> None:
"""
Handle errors raised by the database when adding or updating a sample.
"""
error_message = error.orig.args[0]["M"]
if (
error_message
== 'duplicate key value violates unique constraint "sample_field_sample_id_key"'
):
detail = (
f"Sample with field_sample_id {payload.field_sample_id} already exists."
)
elif (
error_message
== 'insert or update on table "sample" violates foreign key constraint "sample_thing_id_fkey"'
):
detail = f"Thing with ID {payload.thing_id} does not exist."

raise HTTPException(status_code=HTTP_409_CONFLICT, detail=detail)


# ============= Post =============================================
@router.post("", status_code=HTTP_201_CREATED)
def add_sample(sample_data: CreateSample, session: session_dependency):
"""
Endpoint to add a sample.
"""
return adder(session, Sample, sample_data)
try:
return adder(session, Sample, sample_data)
except (IntegrityError, ProgrammingError) as e:
database_error_handler(sample_data, e)


# ============= Update =============================================
Expand All @@ -66,8 +93,10 @@ def update_sample(
This is handled by the `model_patcher` function, which excludes unset fields from
the update.
"""

return model_patcher(session, Sample, sample_id, sample_data)
try:
return model_patcher(session, Sample, sample_id, sample_data)
except (IntegrityError, ProgrammingError) as e:
database_error_handler(sample_data, e)


# ============= Get =============================================
Expand Down
20 changes: 11 additions & 9 deletions db/sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,15 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
from sqlalchemy import DateTime, String, ForeignKey, Integer, UniqueConstraint, Float
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import mapped_column, relationship, Mapped, declared_attr
from sqlalchemy import DateTime, ForeignKey, UniqueConstraint, Float
from sqlalchemy.orm import mapped_column, relationship, Mapped

# import models from classes that are defined in separate files
from db import lexicon_term
from db.base import Base, AutoBaseMixin, ReleaseMixin
from db.thing import Thing
from db.sensor import Sensor

from typing import List, Optional
from typing import Optional

import datetime

Expand All @@ -45,28 +43,32 @@ class Sample(Base, AutoBaseMixin, ReleaseMixin):
)
sensor_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("sensor.id"),
# unique=True,
comment="Foreign key for the specific equipment used.",
)

# Sample Attributes
sample_date: Mapped[datetime.datetime] = mapped_column(
DateTime, comment="Date and time of sample collection."
DateTime, nullable=False, comment="Date and time of sample collection."
)
# REFACTOR TODO: update with enum/restricted values
sample_matrix: Mapped[Optional[str]] = mapped_column(
comment="The material of the sample (e.g., 'gw', 'soil')."
)
# REFACTOR TODO: update with enum/restricted values
Comment thread
jirhiker marked this conversation as resolved.
sample_method: Mapped[Optional[str]] = mapped_column(
comment="Method used to collect the sample."
)
field_sample_id: Mapped[str] = mapped_column(
unique=True, comment="User-defined ID for field tracking."
unique=True, nullable=False, comment="User-defined ID for field tracking."
)
# REFACTOR TODO: update with enum/restricted values
sampler_name: Mapped[Optional[str]] = mapped_column(
comment="Name of the person who collected the sample."
nullable=False, comment="Name of the person who collected the sample."
)
# REFACTOR TODO: update with enum/restricted values
qc_sample: Mapped[str] = mapped_column(
default="Original",
nullable=False,
comment="Quality control sample type (e.g., 'Original', 'field dupe').",
)
sample_top: Mapped[Optional[float]] = mapped_column(
Expand Down
7 changes: 7 additions & 0 deletions schemas_v2/contact.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@
from schemas import ORMBaseModel
from schemas_v2.thing import ThingResponse

"""
REFACTOR TODO

Create common validator classes to be shared amongst create and update schemas.
Since many fields are optional in the update schemas set check_fields=False in the field_validator.
"""


# -------- CREATE ----------
class CreateEmail(BaseModel):
Expand Down
7 changes: 7 additions & 0 deletions schemas_v2/location.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@

from schemas import ORMBaseModel

"""
REFACTOR TODO

Create common validator classes to be shared amongst create and update schemas.
Since many fields are optional in the update schemas set check_fields=False in the field_validator.
"""


# -------- CREATE ----------
class CreateLocation(BaseModel):
Expand Down
164 changes: 107 additions & 57 deletions schemas_v2/sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,93 +14,143 @@
# limitations under the License.
# ===============================================================================
from datetime import datetime, timezone
from pydantic import BaseModel, field_validator
from pydantic import BaseModel, field_validator, model_validator
from typing_extensions import Self

from db.engine import get_db_session, session_ctx
from db import Thing

"""
REFACTOR TODO: can we use inheritance for commonly defined fields and then set them as optional
or not between Create, Update, and Response schemas?
"""


# -------- VALIDATE ----------
class ValidateSample(BaseModel):
"""
Validator for Sample data for Create and Update schemas.
"""

@field_validator("sample_date", check_fields=False)
def validate_sample_date(cls, sample_date: datetime | None) -> datetime | None:
"""
Validate that the sample_date is not in the future.
"""
if sample_date is not None:
if sample_date > datetime.now(tz=timezone.utc):
raise ValueError(f"Sample date {sample_date} cannot be in the future.")
return sample_date

# # REFACTOR TODO: is below ground negative or positive? the combine this with validate_sample_bottom defined below
# @field_validator("sample_bottom", check_fields=False)
# def validate_sample_bottom(cls, sample_bottom: float | None, values) -> float | None:
# """
# Validate that the sample_bottom is not less than sample_top.
# """
# sample_top = values.get('sample_top')
# if sample_bottom is not None and sample_top is not None:
# if sample_bottom > sample_top:
# raise ValueError(
# "Sample bottom cannot be greater than sample top."
# )
# return sample_bottom

# REFACTOR TODO: fields are evaluated in the order in which they are defined.
# are sample top/bottom really working as expected?

@model_validator(mode="after")
def validate_top_and_bottom(self) -> Self:
"""
Validate that sample_top and sample_bottom are both defined or both None.
"""
sample_top = getattr(self, "sample_top", None)
sample_bottom = getattr(self, "sample_bottom", None)

if (sample_top is not None and sample_bottom is None) or (
sample_top is None and sample_bottom is not None
):
raise ValueError(
"Sample top and bottom must both be defined or both must be None."
)
return self


# -------- CREATE ----------
class CreateSample(BaseModel):
class CreateSample(ValidateSample):
thing_id: int
sample_type: str
field_sample_id: str
sample_date: datetime
release_status: str
sampler_name: str # REFACTOR TODO: update with enum/restricted values
qc_sample: str = "Original"

sensor_id: int | None = None
sample_date: datetime | None = None
sample_matrix: str | None = None
sample_method: str | None = None
sampler_name: str | None = None
qc_sample: str | None = None
duplicate_sample_number: int | None = None
sample_matrix: str | None = (
Comment thread
jirhiker marked this conversation as resolved.
None # REFACTOR TODO: update with enum/restricted values
)
sample_method: str | None = (
None # REFACTOR TODO: update with enum/restricted values
)

duplicate_sample_number: int | None = 0

# REFACTOR TODO: update with numeric restrictions? Are negative values below ground and positive above?
# for example: wells below, rain above, and soil/rock could be at ground surface
sample_top: float | None = None
sample_bottom: float | None = None

release_status: str


class CreateGeochemicalSample(BaseModel):
"""
Represents a geochemical sample in the collaborative network.
# -------- UPDATE ----------
class UpdateSample(ValidateSample):
"""
Development notes:

sample_id: int
setting <type> = None makes the field optional, but if it is defined it must be of that type.
"""

thing_id: int = None # REFACTOR TODO: should users be able to change this?
sample_type: str = None
field_sample_id: str = None
sample_date: datetime = None
release_status: str = None
sampler_name: str = None # REFACTOR TODO: update with enum/restricted values
qc_sample: str = None

sensor_id: int | None = None # REFACTOR TODO: should users be able to change this?
sample_matrix: str | None = (
None # REFACTOR TODO: update with enum/restricted values
)
sample_method: str | None = (
None # REFACTOR TODO: update with enum/restricted values
)

class CreateGeothermalSample(BaseModel):
"""
Represents a geothermal sample in the collaborative network.
"""
duplicate_sample_number: int | None = None

sample_id: int
# REFACTOR TODO: update with numeric restrictions? Are negative values below ground and positive above?
# for example: wells below, rain above, and soil/rock could be at ground surface
sample_top: float | None = None
sample_bottom: float | None = None


# -------- RESPONSE ----------
class SampleResponse(BaseModel):
id: int
sample_date: datetime
sample_method: str | None = None
thing_id: int
sample_type: str
field_sample_id: str
sample_matrix: str | None = None
sampler_name: str | None = None
qc_sample: str | None = None
duplicate_sample_number: int | None = None
sample_top: float | None = None
sample_bottom: float | None = None
sensor_id: int | None = None
sample_date: datetime
release_status: str
created_at: datetime
thing_id: int
sampler_name: str
qc_sample: str

sensor_id: int | None
sample_matrix: str | None
sample_method: str | None

# -------- UPDATE ----------
class UpdateSample(BaseModel):
sample_date: datetime | None = None
sample_method: str | None = None
thing_id: int | None = None
duplicate_sample_number: int | None

@field_validator("thing_id")
def validate_thing_id_exists(cls, thing_id: int) -> int:
"""
Validate that the thing_id exists in the database.
"""
with session_ctx() as session:
thing = session.get(Thing, thing_id)
if not thing:
raise ValueError(f"Thing with ID {thing_id} does not exist.")
return thing_id

@field_validator("sample_date")
def validate_sample_date(cls, sample_date: datetime) -> datetime:
"""
Validate that the sample_date is not in the future.
"""
if sample_date:
if sample_date > datetime.now(tz=timezone.utc):
raise ValueError(f"Sample date {sample_date} cannot be in the future.")
return sample_date
sample_top: float | None
sample_bottom: float | None


# ============= EOF =============================================
1 change: 1 addition & 0 deletions services/thing_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ def transformer(records):
return paginate(query=sql, conn=session, transformer=transformer)


# REFACTOR TODO: use enums (or enum-like object) for thing_type
def add_thing(session: Session, data: BaseModel | dict, thing_type: str = None) -> Base:

if isinstance(data, BaseModel):
Expand Down
Loading
Loading