Adding OData Import
This commit is contained in:
1 parent
c8696e67b5
commit
291416544e
18 files changed
+547
-3
No files matched your search
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"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 ###
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
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.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(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(imported_parts.router, prefix="/imported-parts", tags=["imported-parts"])
|
||||
api_router.include_router(audit_log.router, prefix="/audit-log", tags=["audit-log"])
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from app.models.associations import role_permissions, user_roles
|
||||
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.manufacturer import Manufacturer
|
||||
from app.models.part_type import PartType
|
||||
@@ -15,6 +17,8 @@ from app.models.user import User
|
||||
|
||||
__all__ = [
|
||||
"AuditLog",
|
||||
"ImportedPart",
|
||||
"ImportedPartConfig",
|
||||
"IsoThread",
|
||||
"Manufacturer",
|
||||
"PartType",
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -26,6 +26,9 @@
|
||||
{% if user.is_superuser or "sub_part_types:read" in user.permissions %}
|
||||
<a href="/admin/sub-part-types">Sub Part Types</a>
|
||||
{% 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 %}
|
||||
<a href="/admin/audit-log">Audit Log</a>
|
||||
{% 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>
|
||||
@@ -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 %}
|
||||
@@ -6,8 +6,11 @@ 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 imported_part_config_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.services import odata as odata_service
|
||||
from app.crud import manufacturer as manufacturer_crud
|
||||
from app.crud import part_type as part_type_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)
|
||||
|
||||
|
||||
# --- 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 ---------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ dependencies = [
|
||||
"pydantic-settings>=2.3",
|
||||
"pyjwt>=2.8",
|
||||
"bcrypt>=4.1",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
+4
-1
@@ -47,13 +47,16 @@ PERMISSIONS = [
|
||||
("sub_part_types:read", "View sub part types"),
|
||||
("sub_part_types:write", "Create and update 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"),
|
||||
]
|
||||
|
||||
# Default roles -> permission names. "admin" gets everything.
|
||||
ROLES = {
|
||||
"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"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -289,6 +289,7 @@ dependencies = [
|
||||
{ name = "asyncpg" },
|
||||
{ name = "bcrypt" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "pydantic", extra = ["email"] },
|
||||
{ name = "pydantic-settings" },
|
||||
@@ -315,6 +316,7 @@ requires-dist = [
|
||||
{ name = "bcrypt", specifier = ">=4.1" },
|
||||
{ name = "black", marker = "extra == 'dev'", specifier = ">=24" },
|
||||
{ name = "fastapi", specifier = ">=0.111" },
|
||||
{ name = "httpx", specifier = ">=0.27" },
|
||||
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" },
|
||||
{ name = "jinja2", specifier = ">=3.1" },
|
||||
{ name = "pydantic", extras = ["email"], specifier = ">=2.7" },
|
||||
|
||||
Reference in new issue
Block a user