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
47 changes: 47 additions & 0 deletions api/app/uploads/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ class OneShotTokenAuditItem(BaseModel):
target_email: str | None
is_used: bool
created_at: datetime
expires_at: datetime


class OneShotStatsResponse(BaseModel):
total_files: int
total_storage_bytes: int
tokens_issued: int
tokens_used: int
active_tokens: int


class FileAuditItem(BaseModel):
Expand Down Expand Up @@ -239,11 +248,49 @@ async def list_oneshot_tokens(
target_email=row.target_email,
is_used=row.is_used,
created_at=row.created_at,
expires_at=row.expires_at,
)
for row in rows
]


@router.get("/admin/stats", response_model=OneShotStatsResponse)
async def get_oneshot_stats(
db: AsyncSession = Depends(_db_dep),
_admin_user: User = require_admin(),
) -> OneShotStatsResponse:
total_files = (
await db.execute(select(func.count(FileMetadata.id)))
).scalar_one()
total_storage_bytes = (
await db.execute(select(func.coalesce(func.sum(FileMetadata.size_bytes), 0)))
).scalar_one()
tokens_issued = (
await db.execute(select(func.count(OneShotToken.id)))
).scalar_one()
tokens_used = (
await db.execute(
select(func.count(OneShotToken.id)).where(OneShotToken.is_used.is_(True))
)
).scalar_one()
active_tokens = (
await db.execute(
select(func.count(OneShotToken.id)).where(
OneShotToken.is_used.is_(False),
OneShotToken.expires_at > func.now(),
)
)
).scalar_one()

return OneShotStatsResponse(
total_files=total_files,
total_storage_bytes=total_storage_bytes,
tokens_issued=tokens_issued,
tokens_used=tokens_used,
active_tokens=active_tokens,
)


@router.get("/admin/files", response_model=list[FileAuditItem])
async def list_uploaded_files(
db: AsyncSession = Depends(_db_dep),
Expand Down
67 changes: 66 additions & 1 deletion api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,11 @@
"title": "Created At",
"type": "string"
},
"expires_at": {
"format": "date-time",
"title": "Expires At",
"type": "string"
},
"id": {
"title": "Id",
"type": "string"
Expand All @@ -238,11 +243,45 @@
"id",
"target_email",
"is_used",
"created_at"
"created_at",
"expires_at"
],
"title": "OneShotTokenAuditItem",
"type": "object"
},
"OneShotStatsResponse": {
"properties": {
"active_tokens": {
"title": "Active Tokens",
"type": "integer"
},
"tokens_issued": {
"title": "Tokens Issued",
"type": "integer"
},
"tokens_used": {
"title": "Tokens Used",
"type": "integer"
},
"total_files": {
"title": "Total Files",
"type": "integer"
},
"total_storage_bytes": {
"title": "Total Storage Bytes",
"type": "integer"
}
},
"required": [
"total_files",
"total_storage_bytes",
"tokens_issued",
"tokens_used",
"active_tokens"
],
"title": "OneShotStatsResponse",
"type": "object"
},
"OneShotUploadResponse": {
"properties": {
"file_id": {
Expand Down Expand Up @@ -763,6 +802,32 @@
]
}
},
"/api/admin/stats": {
"get": {
"operationId": "get_oneshot_stats_api_admin_stats_get",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OneShotStatsResponse"
}
}
},
"description": "Successful Response"
}
},
"security": [
{
"DeviceJWT": []
}
],
"summary": "Get Oneshot Stats",
"tags": [
"oneshot"
]
}
},
"/api/admin/files/{file_id}/download": {
"get": {
"operationId": "download_file_api_admin_files__file_id__download_get",
Expand Down
13 changes: 13 additions & 0 deletions api/tests/test_oneshot_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ async def _assert_expiry_is_set() -> None:
assert token_row["target_email"] == "recipient@example.com"
assert token_row["is_used"] is True
assert token_row["created_at"]
assert token_row["expires_at"]

files_response = client.get(
"/api/admin/files",
Expand All @@ -122,6 +123,18 @@ async def _assert_expiry_is_set() -> None:
assert file_row["size_bytes"] == len(b"classified-bytes")
assert file_row["created_at"]

stats_response = client.get(
"/api/admin/stats",
headers={"Authorization": f"Bearer {admin_jwt}"},
)
assert stats_response.status_code == 200
stats = stats_response.json()
assert stats["total_files"] >= 1
assert stats["total_storage_bytes"] >= len(b"classified-bytes")
assert stats["tokens_issued"] >= 1
assert stats["tokens_used"] >= 1
assert stats["active_tokens"] >= 0

download_response = client.get(
f"/api/admin/files/{file_id}/download",
headers={"Authorization": f"Bearer {admin_jwt}"},
Expand Down
24 changes: 21 additions & 3 deletions web/scripts/gen-openapi.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const outputTs = resolve(webDir, "src/api/openapi.ts");
// ── Step 1: Generate OpenAPI JSON from the backend ─────────────────────────
console.log(`→ Using uv project: ${uvProject}`);
console.log("→ Dumping OpenAPI schema from backend…");
let openapiDumped = false;
try {
execFileSync(
"uv",
Expand All @@ -46,15 +47,32 @@ try {
env: { ...process.env, PYTHONPATH: apiDir },
},
);
} catch {
console.error("✗ Failed to dump OpenAPI schema from backend.");
process.exit(1);
openapiDumped = true;
} catch (error) {
const isMissingUv =
typeof error === "object" &&
error !== null &&
"code" in error &&
error.code === "ENOENT";
const message = error instanceof Error ? error.message : "unknown error";
const canFallback = existsSync(openapiJson) && isMissingUv;

if (canFallback) {
console.warn(`⚠ Failed to dump OpenAPI schema from backend (${message}).`);
console.warn(`⚠ Reusing existing schema at ${openapiJson}`);
} else {
console.error("✗ Failed to dump OpenAPI schema from backend.");
process.exit(1);
}
}

if (!existsSync(openapiJson)) {
console.error(`✗ Expected OpenAPI file not found: ${openapiJson}`);
process.exit(1);
}
if (openapiDumped) {
console.log(`✓ OpenAPI schema written to ${openapiJson}`);
}

// ── Step 2: Generate TypeScript types ──────────────────────────────────────
console.log("→ Generating TypeScript types from OpenAPI schema…");
Expand Down
55 changes: 55 additions & 0 deletions web/src/api/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/stats": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Oneshot Stats */
get: operations["get_oneshot_stats_api_admin_stats_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/files/{file_id}/download": {
parameters: {
query?: never;
Expand Down Expand Up @@ -479,13 +496,31 @@ export interface components {
* Format: date-time
*/
created_at: string;
/**
* Expires At
* Format: date-time
*/
expires_at: string;
/** Id */
id: string;
/** Is Used */
is_used: boolean;
/** Target Email */
target_email: string | null;
};
/** OneShotStatsResponse */
OneShotStatsResponse: {
/** Active Tokens */
active_tokens: number;
/** Tokens Issued */
tokens_issued: number;
/** Tokens Used */
tokens_used: number;
/** Total Files */
total_files: number;
/** Total Storage Bytes */
total_storage_bytes: number;
};
/** OneShotUploadResponse */
OneShotUploadResponse: {
/** File Id */
Expand Down Expand Up @@ -784,6 +819,26 @@ export interface operations {
};
};
};
get_oneshot_stats_api_admin_stats_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OneShotStatsResponse"];
};
};
};
};
download_file_api_admin_files__file_id__download_get: {
parameters: {
query?: never;
Expand Down
5 changes: 5 additions & 0 deletions web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export type EchoResponse = components["schemas"]["EchoResponse"];
/** Item body for GET /api/admin/oneshot-tokens */
export type OneShotTokenAuditItem = components["schemas"]["OneShotTokenAuditItem"];

/** Response body for GET /api/admin/stats */
export type OneShotStatsResponse = components["schemas"]["OneShotStatsResponse"];

/** Item body for GET /api/admin/files */
export type FileAuditItem = components["schemas"]["FileAuditItem"];

Expand All @@ -51,6 +54,7 @@ type _AssertDemoEchoPost = paths["/demo/echo"]["post"];
type _AssertDemoPingGet = paths["/demo/ping"]["get"];
type _AssertDemoSseGet = paths["/demo/sse"]["get"];
type _AssertAdminOneShotTokensGet = paths["/api/admin/oneshot-tokens"]["get"];
type _AssertAdminStatsGet = paths["/api/admin/stats"]["get"];
type _AssertAdminFilesGet = paths["/api/admin/files"]["get"];
type _AssertAdminFileDownloadGet = paths["/api/admin/files/{file_id}/download"]["get"];

Expand All @@ -62,6 +66,7 @@ export type {
_AssertDemoPingGet,
_AssertDemoSseGet,
_AssertAdminOneShotTokensGet,
_AssertAdminStatsGet,
_AssertAdminFilesGet,
_AssertAdminFileDownloadGet,
};
Loading
Loading