Implement Assets API with file/asset management operations

Implementation:
- Asset data models (wikijs/models/asset.py)
  - Asset, AssetFolder models
  - AssetUpload, AssetRename, AssetMove models
  - FolderCreate model
  - File size helpers (size_mb, size_kb)
  - Field validation and normalization

- Sync AssetsEndpoint (wikijs/endpoints/assets.py)
  - list(folder_id, kind) - List assets with filtering
  - get(asset_id) - Get single asset
  - rename(asset_id, new_filename) - Rename asset
  - move(asset_id, folder_id) - Move asset between folders
  - delete(asset_id) - Delete asset
  - list_folders() - List all folders
  - create_folder(slug, name) - Create new folder
  - delete_folder(folder_id) - Delete folder
  - Note: upload/download require multipart support (future enhancement)

- Async AsyncAssetsEndpoint (wikijs/aio/endpoints/assets.py)
  - Complete async implementation
  - Identical interface to sync version
  - All asset and folder management operations

- Integration with clients
  - WikiJSClient.assets
  - AsyncWikiJSClient.assets

GraphQL operations for asset and folder management.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude
2025-10-22 20:34:50 +00:00
parent 5c0de7f70b
commit d2003a0005
8 changed files with 1209 additions and 7 deletions

View File

@@ -0,0 +1,314 @@
"""Async assets endpoint for Wiki.js API."""
import os
from typing import Dict, List, Optional
from ...exceptions import APIError, ValidationError
from ...models import Asset, AssetFolder
from .base import AsyncBaseEndpoint
class AsyncAssetsEndpoint(AsyncBaseEndpoint):
"""Async endpoint for managing Wiki.js assets."""
async def list(
self, folder_id: Optional[int] = None, kind: Optional[str] = None
) -> List[Asset]:
"""List all assets asynchronously."""
if folder_id is not None and folder_id < 0:
raise ValidationError("folder_id must be non-negative")
query = """
query ($folderId: Int, $kind: AssetKind) {
assets {
list(folderId: $folderId, kind: $kind) {
id filename ext kind mime fileSize folderId
folder { id slug name }
authorId authorName createdAt updatedAt
}
}
}
"""
variables = {}
if folder_id is not None:
variables["folderId"] = folder_id
if kind is not None:
variables["kind"] = kind.upper()
response = await self._post(
"/graphql", json_data={"query": query, "variables": variables}
)
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
assets_data = response.get("data", {}).get("assets", {}).get("list", [])
return [Asset(**self._normalize_asset_data(a)) for a in assets_data]
async def get(self, asset_id: int) -> Asset:
"""Get a specific asset by ID asynchronously."""
if not isinstance(asset_id, int) or asset_id <= 0:
raise ValidationError("asset_id must be a positive integer")
query = """
query ($id: Int!) {
assets {
single(id: $id) {
id filename ext kind mime fileSize folderId
folder { id slug name }
authorId authorName createdAt updatedAt
}
}
}
"""
response = await self._post(
"/graphql", json_data={"query": query, "variables": {"id": asset_id}}
)
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
asset_data = response.get("data", {}).get("assets", {}).get("single")
if not asset_data:
raise APIError(f"Asset with ID {asset_id} not found")
return Asset(**self._normalize_asset_data(asset_data))
async def rename(self, asset_id: int, new_filename: str) -> Asset:
"""Rename an asset asynchronously."""
if not isinstance(asset_id, int) or asset_id <= 0:
raise ValidationError("asset_id must be a positive integer")
if not new_filename or not new_filename.strip():
raise ValidationError("new_filename cannot be empty")
mutation = """
mutation ($id: Int!, $filename: String!) {
assets {
renameAsset(id: $id, filename: $filename) {
responseResult { succeeded errorCode slug message }
asset {
id filename ext kind mime fileSize folderId
authorId authorName createdAt updatedAt
}
}
}
}
"""
response = await self._post(
"/graphql",
json_data={
"query": mutation,
"variables": {"id": asset_id, "filename": new_filename.strip()},
},
)
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
result = response.get("data", {}).get("assets", {}).get("renameAsset", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to rename asset: {error_msg}")
asset_data = result.get("asset")
if not asset_data:
raise APIError("Asset renamed but no data returned")
return Asset(**self._normalize_asset_data(asset_data))
async def move(self, asset_id: int, folder_id: int) -> Asset:
"""Move an asset to a different folder asynchronously."""
if not isinstance(asset_id, int) or asset_id <= 0:
raise ValidationError("asset_id must be a positive integer")
if not isinstance(folder_id, int) or folder_id < 0:
raise ValidationError("folder_id must be non-negative")
mutation = """
mutation ($id: Int!, $folderId: Int!) {
assets {
moveAsset(id: $id, folderId: $folderId) {
responseResult { succeeded errorCode slug message }
asset {
id filename ext kind mime fileSize folderId
folder { id slug name }
authorId authorName createdAt updatedAt
}
}
}
}
"""
response = await self._post(
"/graphql",
json_data={
"query": mutation,
"variables": {"id": asset_id, "folderId": folder_id},
},
)
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
result = response.get("data", {}).get("assets", {}).get("moveAsset", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to move asset: {error_msg}")
asset_data = result.get("asset")
if not asset_data:
raise APIError("Asset moved but no data returned")
return Asset(**self._normalize_asset_data(asset_data))
async def delete(self, asset_id: int) -> bool:
"""Delete an asset asynchronously."""
if not isinstance(asset_id, int) or asset_id <= 0:
raise ValidationError("asset_id must be a positive integer")
mutation = """
mutation ($id: Int!) {
assets {
deleteAsset(id: $id) {
responseResult { succeeded errorCode slug message }
}
}
}
"""
response = await self._post(
"/graphql", json_data={"query": mutation, "variables": {"id": asset_id}}
)
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
result = response.get("data", {}).get("assets", {}).get("deleteAsset", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to delete asset: {error_msg}")
return True
async def list_folders(self) -> List[AssetFolder]:
"""List all asset folders asynchronously."""
query = """
query {
assets {
folders {
id slug name
}
}
}
"""
response = await self._post("/graphql", json_data={"query": query})
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
folders_data = response.get("data", {}).get("assets", {}).get("folders", [])
return [AssetFolder(**folder) for folder in folders_data]
async def create_folder(self, slug: str, name: Optional[str] = None) -> AssetFolder:
"""Create a new asset folder asynchronously."""
if not slug or not slug.strip():
raise ValidationError("slug cannot be empty")
slug = slug.strip().strip("/")
if not slug:
raise ValidationError("slug cannot be just slashes")
mutation = """
mutation ($slug: String!, $name: String) {
assets {
createFolder(slug: $slug, name: $name) {
responseResult { succeeded errorCode slug message }
folder { id slug name }
}
}
}
"""
variables = {"slug": slug}
if name:
variables["name"] = name
response = await self._post(
"/graphql", json_data={"query": mutation, "variables": variables}
)
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
result = response.get("data", {}).get("assets", {}).get("createFolder", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to create folder: {error_msg}")
folder_data = result.get("folder")
if not folder_data:
raise APIError("Folder created but no data returned")
return AssetFolder(**folder_data)
async def delete_folder(self, folder_id: int) -> bool:
"""Delete an asset folder asynchronously."""
if not isinstance(folder_id, int) or folder_id <= 0:
raise ValidationError("folder_id must be a positive integer")
mutation = """
mutation ($id: Int!) {
assets {
deleteFolder(id: $id) {
responseResult { succeeded errorCode slug message }
}
}
}
"""
response = await self._post(
"/graphql", json_data={"query": mutation, "variables": {"id": folder_id}}
)
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
result = response.get("data", {}).get("assets", {}).get("deleteFolder", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to delete folder: {error_msg}")
return True
def _normalize_asset_data(self, data: Dict) -> Dict:
"""Normalize asset data from API response."""
return {
"id": data.get("id"),
"filename": data.get("filename"),
"ext": data.get("ext"),
"kind": data.get("kind"),
"mime": data.get("mime"),
"file_size": data.get("fileSize"),
"folder_id": data.get("folderId"),
"folder": data.get("folder"),
"author_id": data.get("authorId"),
"author_name": data.get("authorName"),
"created_at": data.get("createdAt"),
"updated_at": data.get("updatedAt"),
}