248 lines
9.4 KiB
Python
248 lines
9.4 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping, Sequence
|
|
from datetime import date
|
|
from typing import Any
|
|
|
|
from .config import ProjectConfig
|
|
from .nocobase_http import NocoBaseHttpError, validate_project_id
|
|
|
|
|
|
def _configured_scalar_fields(config: ProjectConfig) -> tuple[str, ...]:
|
|
return (
|
|
config.name_field,
|
|
config.customer_field,
|
|
config.inquiry_date_field,
|
|
config.progress_field,
|
|
config.applications_field,
|
|
config.part_numbers_field,
|
|
config.period_usage_field,
|
|
config.details_field,
|
|
)
|
|
|
|
|
|
PROJECT_WHITELIST = _configured_scalar_fields(ProjectConfig())
|
|
|
|
|
|
class _InvalidProjectInput(ValueError):
|
|
pass
|
|
|
|
|
|
class _InvalidProgress(ValueError):
|
|
pass
|
|
|
|
|
|
class ProjectService:
|
|
def __init__(self, config: ProjectConfig, nb_client: Any, http_client: Any) -> None:
|
|
self.config = config
|
|
self.nb_client = nb_client
|
|
self.http_client = http_client
|
|
self._whitelist = _configured_scalar_fields(config)
|
|
|
|
def create_project(
|
|
self,
|
|
*,
|
|
name: str,
|
|
customer_id: Any | None = None,
|
|
inquiry_date: str | None = None,
|
|
progress: str | None = None,
|
|
applications: str | None = None,
|
|
part_numbers: str | None = None,
|
|
period_usage: str | None = None,
|
|
details: str | None = None,
|
|
attachment_ids: Sequence[Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
try:
|
|
values = self._project_values(
|
|
name=name,
|
|
customer_id=customer_id,
|
|
inquiry_date=inquiry_date,
|
|
progress=progress,
|
|
applications=applications,
|
|
part_numbers=part_numbers,
|
|
period_usage=period_usage,
|
|
details=details,
|
|
require_name=True,
|
|
)
|
|
attachment_ids = _deduplicated_attachment_ids(attachment_ids)
|
|
except _InvalidProgress:
|
|
return _error("invalid_progress", "progress must be one of the configured project progress labels")
|
|
except _InvalidProjectInput as error:
|
|
return _error("invalid_project_input", str(error))
|
|
|
|
response = self.nb_client.create_record(self.config.resource, values, self._whitelist)
|
|
project_id = _project_id(response, self.config.id_field)
|
|
if not attachment_ids:
|
|
return {"project_id": project_id}
|
|
|
|
try:
|
|
self.http_client.add_attachment_ids(project_id, attachment_ids)
|
|
except Exception as error:
|
|
return _attachment_error(project_id, error)
|
|
return {"project_id": project_id}
|
|
|
|
def update_project(
|
|
self,
|
|
project_id: Any,
|
|
*,
|
|
name: str | None = None,
|
|
customer_id: Any | None = None,
|
|
inquiry_date: str | None = None,
|
|
progress: str | None = None,
|
|
applications: str | None = None,
|
|
part_numbers: str | None = None,
|
|
period_usage: str | None = None,
|
|
details: str | None = None,
|
|
add_attachment_ids: Sequence[Any] | None = None,
|
|
remove_attachment_ids: Sequence[Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
try:
|
|
_validate_project_id(project_id)
|
|
values = self._project_values(
|
|
name=name,
|
|
customer_id=customer_id,
|
|
inquiry_date=inquiry_date,
|
|
progress=progress,
|
|
applications=applications,
|
|
part_numbers=part_numbers,
|
|
period_usage=period_usage,
|
|
details=details,
|
|
require_name=False,
|
|
)
|
|
add_attachment_ids = _deduplicated_attachment_ids(add_attachment_ids)
|
|
remove_attachment_ids = _deduplicated_attachment_ids(remove_attachment_ids)
|
|
if _overlap(add_attachment_ids, remove_attachment_ids):
|
|
raise _InvalidProjectInput("attachment IDs cannot be added and removed in the same request")
|
|
if not values and not add_attachment_ids and not remove_attachment_ids:
|
|
raise _InvalidProjectInput("update request must include project fields or attachment IDs")
|
|
except _InvalidProgress:
|
|
return _error("invalid_progress", "progress must be one of the configured project progress labels")
|
|
except _InvalidProjectInput as error:
|
|
return _error("invalid_project_input", str(error))
|
|
|
|
if values:
|
|
self.nb_client.update_record(self.config.resource, project_id, values, self._whitelist)
|
|
|
|
try:
|
|
if add_attachment_ids:
|
|
self.http_client.add_attachment_ids(project_id, add_attachment_ids)
|
|
if remove_attachment_ids:
|
|
self.http_client.remove_attachment_ids(project_id, remove_attachment_ids)
|
|
except Exception as error:
|
|
return _attachment_error(project_id, error)
|
|
return {"project_id": project_id}
|
|
|
|
def _project_values(
|
|
self,
|
|
*,
|
|
name: str | None,
|
|
customer_id: Any | None,
|
|
inquiry_date: str | None,
|
|
progress: str | None,
|
|
applications: str | None,
|
|
part_numbers: str | None,
|
|
period_usage: str | None,
|
|
details: str | None,
|
|
require_name: bool,
|
|
) -> dict[str, Any]:
|
|
values: dict[str, Any] = {}
|
|
if name is not None:
|
|
if not isinstance(name, str) or not (normalized_name := name.strip()):
|
|
raise _InvalidProjectInput("name must not be blank")
|
|
values[self.config.name_field] = normalized_name
|
|
elif require_name:
|
|
raise _InvalidProjectInput("name is required")
|
|
|
|
if customer_id is not None:
|
|
_validate_identifier(customer_id, "customer_id")
|
|
values[self.config.customer_field] = customer_id
|
|
if inquiry_date is not None:
|
|
if not isinstance(inquiry_date, str):
|
|
raise _InvalidProjectInput("inquiry_date must be a YYYY-MM-DD date")
|
|
try:
|
|
parsed_date = date.fromisoformat(inquiry_date)
|
|
if parsed_date.isoformat() != inquiry_date:
|
|
raise ValueError("date must use YYYY-MM-DD format")
|
|
values[self.config.inquiry_date_field] = parsed_date.isoformat()
|
|
except ValueError as error:
|
|
raise _InvalidProjectInput("inquiry_date must be a YYYY-MM-DD date") from error
|
|
if progress is not None:
|
|
if not isinstance(progress, str):
|
|
raise _InvalidProjectInput("progress must be a scalar value")
|
|
if progress not in self.config.progress_values:
|
|
raise _InvalidProgress
|
|
values[self.config.progress_field] = self.config.progress_values[progress]
|
|
|
|
for value, field in (
|
|
(applications, self.config.applications_field),
|
|
(part_numbers, self.config.part_numbers_field),
|
|
(period_usage, self.config.period_usage_field),
|
|
(details, self.config.details_field),
|
|
):
|
|
if value is not None:
|
|
if not isinstance(value, str):
|
|
raise _InvalidProjectInput(f"{field} must be a scalar value")
|
|
values[field] = value
|
|
return values
|
|
|
|
|
|
def _deduplicated_attachment_ids(attachment_ids: Sequence[Any] | None) -> list[Any]:
|
|
if attachment_ids is None:
|
|
return []
|
|
if isinstance(attachment_ids, (str, bytes)) or not isinstance(attachment_ids, Sequence):
|
|
raise _InvalidProjectInput("attachment IDs must be a list")
|
|
|
|
unique_ids: list[Any] = []
|
|
for attachment_id in attachment_ids:
|
|
try:
|
|
_validate_identifier(attachment_id, "attachment IDs")
|
|
except _InvalidProjectInput as error:
|
|
raise _InvalidProjectInput("attachment IDs must be scalar values") from error
|
|
if attachment_id is None:
|
|
raise _InvalidProjectInput("attachment IDs must be scalar values")
|
|
if attachment_id not in unique_ids:
|
|
unique_ids.append(attachment_id)
|
|
return unique_ids
|
|
|
|
|
|
def _overlap(left: Sequence[Any], right: Sequence[Any]) -> bool:
|
|
return any(left_id == right_id for left_id in left for right_id in right)
|
|
|
|
|
|
def _project_id(response: Mapping[str, Any], id_field: str) -> Any:
|
|
data = response.get("data")
|
|
if not isinstance(data, Mapping) or id_field not in data:
|
|
raise ValueError(f"NocoBase create response did not contain {id_field!r}")
|
|
return data[id_field]
|
|
|
|
|
|
def _validate_identifier(value: Any, name: str) -> None:
|
|
if isinstance(value, bool) or not isinstance(value, (str, int)):
|
|
raise _InvalidProjectInput(f"{name} must be a scalar identifier")
|
|
if isinstance(value, str) and not value.strip():
|
|
raise _InvalidProjectInput(f"{name} must not be blank")
|
|
|
|
|
|
def _validate_project_id(project_id: Any) -> None:
|
|
try:
|
|
validate_project_id(project_id)
|
|
except ValueError as error:
|
|
raise _InvalidProjectInput(str(error)) from error
|
|
|
|
|
|
def _attachment_error(project_id: Any, error: Exception) -> dict[str, Any]:
|
|
if isinstance(error, NocoBaseHttpError):
|
|
if error.status_code == 404:
|
|
code = "attachment_not_found"
|
|
elif error.authentication_failed:
|
|
code = "nb_upload_unauthorized"
|
|
else:
|
|
code = "nb_upload_failed"
|
|
else:
|
|
code = "nb_upload_failed"
|
|
return {"project_id": project_id, "error": {"code": code, "message": str(error)}}
|
|
|
|
|
|
def _error(code: str, message: str) -> dict[str, Any]:
|
|
return {"error": {"code": code, "message": message}}
|