Skip to content
Open
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
2 changes: 2 additions & 0 deletions bloom_lims/api/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from .search import router as search_router
from .object_creation import router as object_creation_router
from .worksets import router as worksets_router
from .tracking import router as tracking_router


# Main v1 router
Expand All @@ -53,6 +54,7 @@
router.include_router(search_router)
router.include_router(object_creation_router)
router.include_router(worksets_router)
router.include_router(tracking_router)


@router.get("/")
Expand Down
16 changes: 16 additions & 0 deletions bloom_lims/api/v1/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,22 @@ async def require_api_auth(user: APIUser = Depends(get_api_user)) -> APIUser:
return user


async def require_admin(user: APIUser = Depends(get_api_user)) -> APIUser:
"""
Dependency that requires admin role.
Use this in endpoints that need admin privileges.

Raises:
HTTPException 403 if user is not admin
"""
if user.role not in ("admin", "service"):
raise HTTPException(
status_code=403,
detail="Admin privileges required for this operation.",
)
return user


async def optional_api_auth(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
Expand Down
25 changes: 20 additions & 5 deletions bloom_lims/api/v1/objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
DatabaseError,
create_error_response,
)
from .dependencies import require_api_auth, APIUser
from .dependencies import require_api_auth, require_admin, APIUser


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -179,13 +179,20 @@ async def create_object(data: ObjectCreateSchema, user: APIUser = Depends(requir
async def update_object(
euid: str,
data: ObjectUpdateSchema,
user: APIUser = Depends(require_api_auth),
user: APIUser = Depends(require_admin),
):
"""
Update an existing object.
Update an existing object. Requires admin privileges.

Supports partial updates - only provided fields are updated.
json_addl fields are merged, not replaced.

Editable fields:
- name: Object name
- status: Object status (bstatus)
- created_dt: Creation datetime
- is_deleted: Soft delete flag
- json_addl: Additional JSON data (merged with existing)
"""
try:
from bloom_lims.db import BLOOMdb3
Expand All @@ -202,11 +209,19 @@ async def update_object(
# Update name if provided
if data.name is not None:
obj.name = data.name
# Also update in json_addl.properties.name for consistency
if obj.json_addl and "properties" in obj.json_addl:
obj.json_addl["properties"]["name"] = data.name
flag_modified(obj, "json_addl")

# Update status if provided
if data.status is not None:
obj.bstatus = data.status

# Update created_dt if provided
if data.created_dt is not None:
obj.created_dt = data.created_dt

# Merge json_addl if provided
if data.json_addl is not None:
existing = obj.json_addl or {}
Expand Down Expand Up @@ -241,10 +256,10 @@ async def update_object(
async def delete_object(
euid: str,
hard_delete: bool = Query(False, description="Permanently delete (vs soft delete)"),
user: APIUser = Depends(require_api_auth),
user: APIUser = Depends(require_admin),
):
"""
Delete an object.
Delete an object. Requires admin privileges.

By default performs a soft delete (sets is_deleted=True).
Use hard_delete=True for permanent deletion (use with caution).
Expand Down
154 changes: 154 additions & 0 deletions bloom_lims/api/v1/tracking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""
Carrier Tracking API endpoints.

Provides tracking integration for FedEx, UPS, USPS and other carriers.
"""

import logging
import os
from pathlib import Path
from typing import Optional

import yaml
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel

from .dependencies import require_api_auth, APIUser

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/tracking", tags=["Tracking"])

# Carrier options
CARRIER_OPTIONS = ["FedEx", "UPS", "USPS", "Other"]


class TrackingRequest(BaseModel):
"""Request to track a package."""
tracking_number: str
carrier: str = "FedEx"


class TrackingResponse(BaseModel):
"""Tracking response data."""
tracking_number: str
carrier: str
status: Optional[str] = None
transit_time_hours: Optional[float] = None
origin_state: Optional[str] = None
ship_date: Optional[str] = None
delivery_date: Optional[str] = None
raw_data: Optional[dict] = None
error: Optional[str] = None


def _load_fedex_credentials():
"""Load FedEx credentials from config file or environment."""
# Try config file first
config_path = Path.home() / ".config" / "daylily-carrier-tracking" / "fedex_prod.yaml"
if config_path.exists():
try:
with open(config_path, "r") as f:
return yaml.safe_load(f)
except Exception as e:
logger.warning(f"Failed to load FedEx config from {config_path}: {e}")

# Fall back to environment variables
api_key = os.environ.get("FEDEX_API_KEY")
secret = os.environ.get("FEDEX_SECRET")
if api_key and secret:
return {"api_key": api_key, "secret": secret}

return None


def _get_fedex_tracker():
"""Initialize FedEx tracker if available."""
try:
import daylily_carrier_tracking as FTD
return FTD.FedexTracker()
except Exception as e:
logger.warning(f"FedEx tracker not available: {e}")
return None


@router.get("/carriers")
async def list_carriers():
"""List available carriers."""
return {"carriers": CARRIER_OPTIONS}


@router.get("/track/{tracking_number}")
async def track_package_get(
tracking_number: str,
carrier: str = "FedEx",
user: APIUser = Depends(require_api_auth),
):
"""Track a package by tracking number (GET)."""
return await _track_package(tracking_number, carrier)


@router.post("/track")
async def track_package_post(
request: TrackingRequest,
user: APIUser = Depends(require_api_auth),
):
"""Track a package by tracking number (POST)."""
return await _track_package(request.tracking_number, request.carrier)


async def _track_package(tracking_number: str, carrier: str) -> TrackingResponse:
"""Internal function to track a package."""
tracking_number = tracking_number.strip()

if not tracking_number:
raise HTTPException(status_code=400, detail="Tracking number is required")

if carrier not in CARRIER_OPTIONS:
raise HTTPException(status_code=400, detail=f"Invalid carrier. Must be one of: {CARRIER_OPTIONS}")

if carrier == "FedEx":
tracker = _get_fedex_tracker()
if not tracker:
return TrackingResponse(
tracking_number=tracking_number,
carrier=carrier,
error="FedEx tracking not configured. Install daylily-carrier-tracking and set credentials."
)

try:
result = tracker.get_fedex_ops_meta_ds(tracking_number)
if result and len(result) > 0:
data = result[0]
transit_sec = data.get("Transit_Time_sec")
return TrackingResponse(
tracking_number=tracking_number,
carrier=carrier,
status=data.get("status", "Unknown"),
transit_time_hours=round(transit_sec / 3600, 1) if transit_sec else None,
origin_state=data.get("origin_state"),
ship_date=data.get("ship_date"),
delivery_date=data.get("delivery_date"),
raw_data=data,
)
else:
return TrackingResponse(
tracking_number=tracking_number,
carrier=carrier,
error="No tracking data found"
)
except Exception as e:
logger.error(f"FedEx tracking error for {tracking_number}: {e}")
return TrackingResponse(
tracking_number=tracking_number,
carrier=carrier,
error=str(e)
)

# Other carriers not yet implemented
return TrackingResponse(
tracking_number=tracking_number,
carrier=carrier,
error=f"{carrier} tracking not yet implemented"
)

47 changes: 32 additions & 15 deletions bloom_lims/domain/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,9 +406,15 @@ def _create_child_instance(self, layout_str, layout_ds):
if version == "*":
version = "1.0"

template = self.query_template_by_component_v2(
templates = self.query_template_by_component_v2(
category, type_name, subtype, version
)[0]
)
if not templates:
raise Exception(
f"Template not found: {category}/{type_name}/{subtype}/{version}. "
"Please ensure the database is seeded with templates."
)
template = templates[0]

new_instance = self.create_instance(template.euid)
_update_recursive(new_instance.json_addl, defaults_ds)
Expand Down Expand Up @@ -949,28 +955,39 @@ def fetch_graph_data_by_node_depth(self, start_euid, depth):
def create_instance_by_template_components(
self, category, type, subtype, version
):
return self.create_instances(
self.query_template_by_component_v2(category, type, subtype, version)[
0
].euid
)
templates = self.query_template_by_component_v2(category, type, subtype, version)
if not templates:
raise Exception(
f"Template not found: {category}/{type}/{subtype}/{version}. "
"Please ensure the database is seeded with templates (run: bloom init)."
)
return self.create_instances(templates[0].euid)

# Is this too special casey? Belong lower?
def create_container_with_content(self, cx_quad_tup, mx_quad_tup):
"""ie CX=container, MX=content (material)
("content", "control", "giab-HG002", "1.0"),
("container", "tube", "tube-generic-10ml", "1.0")
"""
container = self.create_instance(
self.query_template_by_component_v2(
cx_quad_tup[0], cx_quad_tup[1], cx_quad_tup[2], cx_quad_tup[3]
)[0].euid
cx_templates = self.query_template_by_component_v2(
cx_quad_tup[0], cx_quad_tup[1], cx_quad_tup[2], cx_quad_tup[3]
)
content = self.create_instance(
self.query_template_by_component_v2(
mx_quad_tup[0], mx_quad_tup[1], mx_quad_tup[2], mx_quad_tup[3]
)[0].euid
if not cx_templates:
raise Exception(
f"Container template not found: {'/'.join(cx_quad_tup)}. "
"Please ensure the database is seeded with templates."
)
container = self.create_instance(cx_templates[0].euid)

mx_templates = self.query_template_by_component_v2(
mx_quad_tup[0], mx_quad_tup[1], mx_quad_tup[2], mx_quad_tup[3]
)
if not mx_templates:
raise Exception(
f"Content template not found: {'/'.join(mx_quad_tup)}. "
"Please ensure the database is seeded with templates."
)
content = self.create_instance(mx_templates[0].euid)

container.json_addl["properties"]["name"] = content.json_addl["properties"][
"name"
Expand Down
16 changes: 12 additions & 4 deletions bloom_lims/domain/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,16 @@ def create_file(
file_properties = {"properties": file_metadata}
import_or_remote = file_metadata.get('import_or_remote', 'import')

# Query for file template with proper error handling
file_templates = self.query_template_by_component_v2("file", "file", "generic", "1.0")
if not file_templates:
raise Exception(
"File template not found: file/file/generic/1.0. "
"Please ensure the database is seeded with file templates (run: bloom init)."
)

new_file = self.create_instance(
self.query_template_by_component_v2("file", "file", "generic", "1.0")[0].euid,
file_templates[0].euid,
file_properties,
)
self.session.commit()
Expand Down Expand Up @@ -215,7 +223,7 @@ def create_file(
raise Exception(e)
else:
logging.warning(f"No data provided for file creation or import skipped: {file_data, url}")
new_file.bstatus = f"no file data provided or {import_or_remote} is not 'import'"
new_file.bstatus = "awaiting file data"
self.session.commit()

if create_locked:
Expand Down Expand Up @@ -297,8 +305,8 @@ def create_file_old(
raise Exception(e)
else:
logging.warning(f"No data provided for file creation, or import skipped ({import_or_remote}): {file_data, url}")
new_file.bstatus = f"no file data provided or {import_or_remote} is not 'import'"
self.session.commit()
new_file.bstatus = "awaiting file data"
self.session.commit()

if create_locked:
self.lock_file(new_file.euid)
Expand Down
12 changes: 10 additions & 2 deletions bloom_lims/domain/workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,11 @@ def do_action_link_tubes_auto(self, wfs_euid, action_ds):
results = self.query_template_by_component_v2(
category, type_name, subtype, version
)

if not results:
raise Exception(
f"Template not found: {category}/{type_name}/{subtype}/{version}. "
"Please ensure the database is seeded with templates."
)
gdna_template = results[0]

cx_category = "container"
Expand All @@ -361,7 +365,11 @@ def do_action_link_tubes_auto(self, wfs_euid, action_ds):
cx_results = self.query_template_by_component_v2(
cx_category, cx_type, cx_subtype, cx_version
)

if not cx_results:
raise Exception(
f"Template not found: {cx_category}/{cx_type}/{cx_subtype}/{cx_version}. "
"Please ensure the database is seeded with templates."
)
cx_tube_template = cx_results[0]

parent_wf = wfs.child_of_lineages[0].parent_instance
Expand Down
Loading
Loading