Adding OData Import

This commit is contained in:
Ian Roddis committed 2026-06-25 15:52:21 -03:00
1 parent c8696e67b5
commit 291416544e
18 files changed
+547 -3

No files matched your search

+2 -1
View File
@@ -1,7 +1,8 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(uv run *)" "Bash(uv run *)",
"Bash(uv sync *)"
] ]
} }
} }
@@ -0,0 +1,29 @@
"""add_imported_parts_tables
Revision ID: 7be5ef599d3f
Revises: b60d0aa3ceda
Create Date: 2026-06-25 15:29:20.685050
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7be5ef599d3f'
down_revision: Union[str, None] = 'b60d0aa3ceda'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
+120
View File
@@ -0,0 +1,120 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import imported_part as imported_part_crud
from app.crud import imported_part_config as config_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.imported_part import (
ImportedPartConfigRead,
ImportedPartConfigUpdate,
ImportedPartRead,
SyncResult,
)
from app.services import odata as odata_service
router = APIRouter()
@router.get("", response_model=list[ImportedPartRead])
async def list_imported_parts(
skip: int = 0,
limit: int = 500,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("imported_parts:read")),
):
return await imported_part_crud.list_all(db, skip=skip, limit=limit)
@router.get("/config", response_model=ImportedPartConfigRead)
async def get_config(
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("imported_parts:write")),
):
config = await config_crud.get(db)
if config is None:
return ImportedPartConfigRead(odata_url=None, username=None, has_password=False)
return ImportedPartConfigRead(
odata_url=config.odata_url,
username=config.username,
has_password=bool(config.password),
)
@router.patch("/config", response_model=ImportedPartConfigRead)
async def update_config(
payload: ImportedPartConfigUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("imported_parts:write")),
):
current = await config_crud.get(db)
# Keep existing password if not provided in the update
new_password = payload.password if payload.password is not None else (current.password if current else None)
config = await config_crud.upsert(
db,
odata_url=payload.odata_url,
username=payload.username,
password=new_password,
)
await audit_crud.log(
db, actor=actor, action="update", resource_type="ImportedPartConfig",
resource_id=1,
description="Updated ImportedPart OData configuration",
)
return ImportedPartConfigRead(
odata_url=config.odata_url,
username=config.username,
has_password=bool(config.password),
)
@router.post("/refresh", response_model=SyncResult)
async def refresh_imported_parts(
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("imported_parts:write")),
):
config = await config_crud.get(db)
if not config or not config.odata_url:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="OData URL is not configured.",
)
records = await odata_service.fetch(config.odata_url, config.username, config.password)
created, updated = await imported_part_crud.upsert_bulk(db, records)
await audit_crud.log(
db, actor=actor, action="update", resource_type="ImportedPart",
resource_id=None,
description=f"OData sync: {created} created, {updated} updated ({len(records)} total records)",
)
return SyncResult(created=created, updated=updated, total=len(records))
@router.get("/{inventory_id}", response_model=ImportedPartRead)
async def get_imported_part(
inventory_id: str,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("imported_parts:read")),
):
part = await imported_part_crud.get(db, inventory_id)
if part is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Imported part not found")
return part
@router.delete("/{inventory_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_imported_part(
inventory_id: str,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("imported_parts:delete")),
):
part = await imported_part_crud.get(db, inventory_id)
if part is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Imported part not found")
await imported_part_crud.delete(db, part)
await audit_crud.log(
db, actor=actor, action="delete", resource_type="ImportedPart",
resource_id=None,
description=audit_crud.describe_delete("ImportedPart", 0, inventory_id),
)
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api import audit_log, auth, iso_threads, manufacturers, part_types, roles, scope_models, sub_part_types, unc_threads, users from app.api import audit_log, auth, imported_parts, iso_threads, manufacturers, part_types, roles, scope_models, sub_part_types, unc_threads, users
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
@@ -12,4 +12,5 @@ api_router.include_router(manufacturers.router, prefix="/manufacturers", tags=["
api_router.include_router(scope_models.router, prefix="/scope-models", tags=["scope-models"]) api_router.include_router(scope_models.router, prefix="/scope-models", tags=["scope-models"])
api_router.include_router(part_types.router, prefix="/part-types", tags=["part-types"]) api_router.include_router(part_types.router, prefix="/part-types", tags=["part-types"])
api_router.include_router(sub_part_types.router, prefix="/sub-part-types", tags=["sub-part-types"]) api_router.include_router(sub_part_types.router, prefix="/sub-part-types", tags=["sub-part-types"])
api_router.include_router(imported_parts.router, prefix="/imported-parts", tags=["imported-parts"])
api_router.include_router(audit_log.router, prefix="/audit-log", tags=["audit-log"]) api_router.include_router(audit_log.router, prefix="/audit-log", tags=["audit-log"])
+74
View File
@@ -0,0 +1,74 @@
from sqlalchemy import insert, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.imported_part import ImportedPart
_FIELDS = frozenset({
"InventoryID", "Description", "TBXColour",
"ProductMaxThreadDepthmm", "ProductMaxThreadDepthin",
"ProductODmm", "ProductODin", "TBXThreadAttribute",
})
async def get(db: AsyncSession, inventory_id: str) -> ImportedPart | None:
return await db.get(ImportedPart, inventory_id)
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 500
) -> list[ImportedPart]:
result = await db.execute(
select(ImportedPart).order_by(ImportedPart.InventoryID).offset(skip).limit(limit)
)
return list(result.scalars().all())
async def count(db: AsyncSession) -> int:
from sqlalchemy import func
result = await db.execute(select(func.count()).select_from(ImportedPart))
return result.scalar_one()
async def delete(db: AsyncSession, part: ImportedPart) -> None:
await db.delete(part)
await db.commit()
async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]:
"""Insert-or-update records keyed on InventoryID. Returns (created, updated)."""
if not records:
return 0, 0
clean = [
{k: str(v) if v is not None else None for k, v in r.items() if k in _FIELDS}
for r in records
if r.get("InventoryID")
]
if not clean:
return 0, 0
incoming_ids = {r["InventoryID"] for r in clean}
result = await db.execute(
select(ImportedPart.InventoryID).where(ImportedPart.InventoryID.in_(incoming_ids))
)
existing_ids = {row[0] for row in result.all()}
to_insert = [r for r in clean if r["InventoryID"] not in existing_ids]
to_update = [r for r in clean if r["InventoryID"] in existing_ids]
if to_insert:
await db.execute(insert(ImportedPart), to_insert)
for record in to_update:
inv_id = record["InventoryID"]
vals = {k: v for k, v in record.items() if k != "InventoryID"}
if vals:
await db.execute(
update(ImportedPart)
.where(ImportedPart.InventoryID == inv_id)
.values(vals)
)
await db.commit()
return len(to_insert), len(to_update)
+34
View File
@@ -0,0 +1,34 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.imported_part_config import ImportedPartConfig
_SINGLETON_ID = 1
async def get(db: AsyncSession) -> ImportedPartConfig | None:
return await db.get(ImportedPartConfig, _SINGLETON_ID)
async def upsert(
db: AsyncSession,
*,
odata_url: str | None,
username: str | None,
password: str | None,
) -> ImportedPartConfig:
config = await db.get(ImportedPartConfig, _SINGLETON_ID)
if config is None:
config = ImportedPartConfig(
id=_SINGLETON_ID,
odata_url=odata_url,
username=username,
password=password,
)
db.add(config)
else:
config.odata_url = odata_url
config.username = username
config.password = password
await db.commit()
await db.refresh(config)
return config
+4
View File
@@ -2,6 +2,8 @@
from app.models.associations import role_permissions, user_roles from app.models.associations import role_permissions, user_roles
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.imported_part import ImportedPart
from app.models.imported_part_config import ImportedPartConfig
from app.models.iso_thread import IsoThread from app.models.iso_thread import IsoThread
from app.models.manufacturer import Manufacturer from app.models.manufacturer import Manufacturer
from app.models.part_type import PartType from app.models.part_type import PartType
@@ -15,6 +17,8 @@ from app.models.user import User
__all__ = [ __all__ = [
"AuditLog", "AuditLog",
"ImportedPart",
"ImportedPartConfig",
"IsoThread", "IsoThread",
"Manufacturer", "Manufacturer",
"PartType", "PartType",
+17
View File
@@ -0,0 +1,17 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class ImportedPart(Base):
__tablename__ = "imported_parts"
InventoryID: Mapped[str] = mapped_column(String, primary_key=True)
Description: Mapped[str | None] = mapped_column(nullable=True, default=None)
TBXColour: Mapped[str | None] = mapped_column(nullable=True, default=None)
ProductMaxThreadDepthmm: Mapped[str | None] = mapped_column(nullable=True, default=None)
ProductMaxThreadDepthin: Mapped[str | None] = mapped_column(nullable=True, default=None)
ProductODmm: Mapped[str | None] = mapped_column(nullable=True, default=None)
ProductODin: Mapped[str | None] = mapped_column(nullable=True, default=None)
TBXThreadAttribute: Mapped[str | None] = mapped_column(nullable=True, default=None)
+15
View File
@@ -0,0 +1,15 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class ImportedPartConfig(Base):
"""Singleton (id=1) storing the OData source URL and credentials."""
__tablename__ = "imported_part_config"
id: Mapped[int] = mapped_column(primary_key=True, default=1)
odata_url: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
username: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
password: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
+34
View File
@@ -0,0 +1,34 @@
from pydantic import BaseModel, ConfigDict
class ImportedPartRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
InventoryID: str
Description: str | None
TBXColour: str | None
ProductMaxThreadDepthmm: str | None
ProductMaxThreadDepthin: str | None
ProductODmm: str | None
ProductODin: str | None
TBXThreadAttribute: str | None
class ImportedPartConfigRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
odata_url: str | None
username: str | None
has_password: bool
class ImportedPartConfigUpdate(BaseModel):
odata_url: str | None = None
username: str | None = None
password: str | None = None
class SyncResult(BaseModel):
created: int
updated: int
total: int
+24
View File
@@ -0,0 +1,24 @@
"""Fetch records from an OData v4 JSON endpoint, following pagination."""
import httpx
async def fetch(
url: str,
username: str | None = None,
password: str | None = None,
) -> list[dict]:
auth = (username, password) if username and password else None
records: list[dict] = []
next_url: str | None = url
headers= {"Accept": "application/json" }
async with httpx.AsyncClient(timeout=30.0) as client:
while next_url:
response = await client.get(next_url, auth=auth, headers=headers)
response.raise_for_status()
body = response.json()
records.extend(body.get("value", []))
next_url = body.get("@odata.nextLink")
return records
+3
View File
@@ -26,6 +26,9 @@
{% if user.is_superuser or "sub_part_types:read" in user.permissions %} {% if user.is_superuser or "sub_part_types:read" in user.permissions %}
<a href="/admin/sub-part-types">Sub Part Types</a> <a href="/admin/sub-part-types">Sub Part Types</a>
{% endif %} {% endif %}
{% if user.is_superuser or "imported_parts:read" in user.permissions %}
<a href="/admin/imported-parts">Imported Parts</a>
{% endif %}
{% if user.is_superuser or "audit_log:read" in user.permissions %} {% if user.is_superuser or "audit_log:read" in user.permissions %}
<a href="/admin/audit-log">Audit Log</a> <a href="/admin/audit-log">Audit Log</a>
{% endif %} {% endif %}
@@ -0,0 +1,44 @@
<div id="imported-parts-section">
{% if sync_result %}
<p class="muted">{{ sync_result }}</p>
{% endif %}
{% if sync_error %}
<p style="color:var(--color-danger,#c00)">{{ sync_error }}</p>
{% endif %}
<div class="card" style="overflow-x:auto">
<p class="muted" style="margin-bottom:.5rem">
Showing {{ parts|length }} record{{ '' if parts|length == 1 else 's' }}{% if total and total > parts|length %} of {{ total }} total{% endif %}.
</p>
<table class="table">
<thead>
<tr>
<th>InventoryID</th>
<th>Description</th>
<th>TBX Colour</th>
<th>Max Thread Depth (mm)</th>
<th>Max Thread Depth (in)</th>
<th>OD (mm)</th>
<th>OD (in)</th>
<th>Thread Attribute</th>
</tr>
</thead>
<tbody>
{% for p in parts %}
<tr>
<td>{{ p.InventoryID }}</td>
<td>{{ p.Description or '—' }}</td>
<td>{{ p.TBXColour or '—' }}</td>
<td>{{ p.ProductMaxThreadDepthmm or '—' }}</td>
<td>{{ p.ProductMaxThreadDepthin or '—' }}</td>
<td>{{ p.ProductODmm or '—' }}</td>
<td>{{ p.ProductODin or '—' }}</td>
<td>{{ p.TBXThreadAttribute or '—' }}</td>
</tr>
{% else %}
<tr><td colspan="8" class="muted">No imported parts. Use Refresh to sync from OData.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
+41
View File
@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Imported Parts · {{ app_name }}{% endblock %}
{% block body %}
{% include "_nav.html" %}
<main class="container">
<h1>Imported Parts</h1>
<div class="card">
<h2>OData Configuration</h2>
<form hx-post="/admin/imported-parts/config"
hx-target="#config-status"
hx-swap="innerHTML"
class="form-grid">
<label>OData URL
<input type="url" name="odata_url" value="{{ config.odata_url or '' }}" placeholder="https://…">
</label>
<label>Username
<input type="text" name="username" value="{{ config.username or '' }}" autocomplete="off">
</label>
<label>Password
<input type="password" name="password" placeholder="{{ '(set — leave blank to keep)' if config.has_password else '(none set)' }}" autocomplete="new-password">
</label>
<button type="submit" class="btn btn-primary">Save Configuration</button>
</form>
<div id="config-status" class="muted" style="margin-top:.5rem"></div>
</div>
<div style="margin-bottom:1rem">
<button class="btn btn-primary"
hx-post="/admin/imported-parts/refresh"
hx-target="#imported-parts-section"
hx-swap="outerHTML"
hx-indicator="#refresh-indicator">
Refresh from OData
</button>
<span id="refresh-indicator" class="htmx-indicator muted"> Loading…</span>
</div>
{% include "admin/_imported_parts_table.html" %}
</main>
{% endblock %}
+97
View File
@@ -6,8 +6,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import imported_part as imported_part_crud
from app.crud import imported_part_config as imported_part_config_crud
from app.crud import iso_thread as iso_thread_crud from app.crud import iso_thread as iso_thread_crud
from app.crud import sub_part_type as sub_part_type_crud from app.crud import sub_part_type as sub_part_type_crud
from app.services import odata as odata_service
from app.crud import manufacturer as manufacturer_crud from app.crud import manufacturer as manufacturer_crud
from app.crud import part_type as part_type_crud from app.crud import part_type as part_type_crud
from app.crud import role as role_crud from app.crud import role as role_crud
@@ -1007,6 +1010,100 @@ async def delete_sub_part_type(
return Response(status_code=status.HTTP_200_OK) return Response(status_code=status.HTTP_200_OK)
# --- Imported Parts ----------------------------------------------------------
@router.get("/imported-parts", response_class=HTMLResponse)
async def imported_parts_page(
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(web_require("imported_parts:read")),
):
config = await imported_part_config_crud.get(db)
config_view = {
"odata_url": config.odata_url if config else None,
"username": config.username if config else None,
"has_password": bool(config and config.password),
}
parts = await imported_part_crud.list_all(db)
total = await imported_part_crud.count(db)
return templates.TemplateResponse(
request,
"admin/imported_parts.html",
{"user": user, "config": config_view, "parts": parts, "total": total},
)
@router.post("/imported-parts/config", response_class=HTMLResponse)
async def save_imported_part_config(
request: Request,
odata_url: str = Form(""),
username: str = Form(""),
password: str = Form(""),
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("imported_parts:write")),
):
current = await imported_part_config_crud.get(db)
# Keep existing password when field submitted blank
kept_password = password.strip() or (current.password if current else None)
await imported_part_config_crud.upsert(
db,
odata_url=odata_url.strip() or None,
username=username.strip() or None,
password=kept_password,
)
await audit_crud.log(
db, actor=actor, action="update", resource_type="ImportedPartConfig",
resource_id=1,
description="Updated ImportedPart OData configuration",
)
return HTMLResponse("Configuration saved.", status_code=200)
@router.post("/imported-parts/refresh", response_class=HTMLResponse)
async def refresh_imported_parts(
request: Request,
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("imported_parts:write")),
):
config = await imported_part_config_crud.get(db)
if not config or not config.odata_url:
parts = await imported_part_crud.list_all(db)
total = await imported_part_crud.count(db)
return templates.TemplateResponse(
request,
"admin/_imported_parts_table.html",
{"parts": parts, "total": total, "sync_error": "OData URL is not configured."},
)
try:
records = await odata_service.fetch(config.odata_url, config.username, config.password)
created, updated = await imported_part_crud.upsert_bulk(db, records)
await audit_crud.log(
db, actor=actor, action="update", resource_type="ImportedPart",
resource_id=None,
description=f"OData sync: {created} created, {updated} updated ({len(records)} total records)",
)
parts = await imported_part_crud.list_all(db)
total = await imported_part_crud.count(db)
return templates.TemplateResponse(
request,
"admin/_imported_parts_table.html",
{
"parts": parts,
"total": total,
"sync_result": f"Sync complete: {created} created, {updated} updated.",
},
)
except Exception as exc:
parts = await imported_part_crud.list_all(db)
total = await imported_part_crud.count(db)
return templates.TemplateResponse(
request,
"admin/_imported_parts_table.html",
{"parts": parts, "total": total, "sync_error": f"Sync failed: {exc}"},
)
# --- Audit Log --------------------------------------------------------------- # --- Audit Log ---------------------------------------------------------------
+1
View File
@@ -17,6 +17,7 @@ dependencies = [
"pydantic-settings>=2.3", "pydantic-settings>=2.3",
"pyjwt>=2.8", "pyjwt>=2.8",
"bcrypt>=4.1", "bcrypt>=4.1",
"httpx>=0.27",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+4 -1
View File
@@ -47,13 +47,16 @@ PERMISSIONS = [
("sub_part_types:read", "View sub part types"), ("sub_part_types:read", "View sub part types"),
("sub_part_types:write", "Create and update sub part types"), ("sub_part_types:write", "Create and update sub part types"),
("sub_part_types:delete", "Delete sub part types"), ("sub_part_types:delete", "Delete sub part types"),
("imported_parts:read", "View imported parts"),
("imported_parts:write", "Sync and configure imported parts"),
("imported_parts:delete", "Delete imported parts"),
("audit_log:read", "View audit log"), ("audit_log:read", "View audit log"),
] ]
# Default roles -> permission names. "admin" gets everything. # Default roles -> permission names. "admin" gets everything.
ROLES = { ROLES = {
"admin": [name for name, _ in PERMISSIONS], # includes audit_log:read "admin": [name for name, _ in PERMISSIONS], # includes audit_log:read
"user": ["users:read", "iso_threads:read", "unc_threads:read", "manufacturers:read", "scope_models:read", "part_types:read", "sub_part_types:read"], "user": ["users:read", "iso_threads:read", "unc_threads:read", "manufacturers:read", "scope_models:read", "part_types:read", "sub_part_types:read", "imported_parts:read"],
} }
Generated
+2
View File
@@ -289,6 +289,7 @@ dependencies = [
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "bcrypt" }, { name = "bcrypt" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" },
{ name = "jinja2" }, { name = "jinja2" },
{ name = "pydantic", extra = ["email"] }, { name = "pydantic", extra = ["email"] },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
@@ -315,6 +316,7 @@ requires-dist = [
{ name = "bcrypt", specifier = ">=4.1" }, { name = "bcrypt", specifier = ">=4.1" },
{ name = "black", marker = "extra == 'dev'", specifier = ">=24" }, { name = "black", marker = "extra == 'dev'", specifier = ">=24" },
{ name = "fastapi", specifier = ">=0.111" }, { name = "fastapi", specifier = ">=0.111" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" },
{ name = "jinja2", specifier = ">=3.1" }, { name = "jinja2", specifier = ">=3.1" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.7" }, { name = "pydantic", extras = ["email"], specifier = ">=2.7" },