183 lines
6.0 KiB
Python
183 lines
6.0 KiB
Python
|
|
"""Service for managing calendar resource provisioning via CalDAV."""
|
||
|
|
|
||
|
|
import json
|
||
|
|
import logging
|
||
|
|
from uuid import UUID, uuid4
|
||
|
|
|
||
|
|
from django.conf import settings
|
||
|
|
|
||
|
|
from core.services.caldav_service import CalDAVHTTPClient
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
|
||
|
|
class ResourceProvisioningError(Exception):
|
||
|
|
"""Raised when resource provisioning fails."""
|
||
|
|
|
||
|
|
|
||
|
|
class ResourceService:
|
||
|
|
"""Provisions and deletes resource principals in SabreDAV.
|
||
|
|
|
||
|
|
Resources are CalDAV principals — this service creates them by
|
||
|
|
making HTTP requests to the SabreDAV internal API. No Django model
|
||
|
|
is created; the CalDAV principal IS the resource.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self):
|
||
|
|
self._http = CalDAVHTTPClient()
|
||
|
|
|
||
|
|
def _resource_email(self, resource_id):
|
||
|
|
"""Generate a resource scheduling address."""
|
||
|
|
domain = settings.RESOURCE_EMAIL_DOMAIN
|
||
|
|
if not domain:
|
||
|
|
domain = "resource.invalid"
|
||
|
|
return f"{resource_id}@{domain}"
|
||
|
|
|
||
|
|
def create_resource(self, user, name, resource_type="ROOM"):
|
||
|
|
"""Provision a resource principal and its default calendar.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
user: The admin user creating the resource (provides auth context).
|
||
|
|
name: Display name for the resource.
|
||
|
|
resource_type: "ROOM" or "RESOURCE".
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
dict with resource info: id, email, principal_uri, calendar_uri.
|
||
|
|
|
||
|
|
Raises:
|
||
|
|
ResourceProvisioningError on failure.
|
||
|
|
"""
|
||
|
|
if resource_type not in ("ROOM", "RESOURCE"):
|
||
|
|
raise ResourceProvisioningError(
|
||
|
|
"resource_type must be 'ROOM' or 'RESOURCE'."
|
||
|
|
)
|
||
|
|
|
||
|
|
if not settings.CALDAV_INTERNAL_API_KEY:
|
||
|
|
raise ResourceProvisioningError(
|
||
|
|
"CALDAV_INTERNAL_API_KEY is not configured."
|
||
|
|
)
|
||
|
|
|
||
|
|
resource_id = str(uuid4())
|
||
|
|
email = self._resource_email(resource_id)
|
||
|
|
org_id = str(user.organization_id)
|
||
|
|
|
||
|
|
try:
|
||
|
|
response = self._http.request(
|
||
|
|
"POST",
|
||
|
|
user,
|
||
|
|
"internal-api/resources/",
|
||
|
|
data=self._json_bytes(
|
||
|
|
{
|
||
|
|
"resource_id": resource_id,
|
||
|
|
"name": name,
|
||
|
|
"email": email,
|
||
|
|
"resource_type": resource_type,
|
||
|
|
"org_id": org_id,
|
||
|
|
}
|
||
|
|
),
|
||
|
|
content_type="application/json",
|
||
|
|
extra_headers={
|
||
|
|
"X-Internal-Api-Key": settings.CALDAV_INTERNAL_API_KEY,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to create resource principal: %s", e)
|
||
|
|
raise ResourceProvisioningError(
|
||
|
|
"Failed to create resource principal."
|
||
|
|
) from e
|
||
|
|
|
||
|
|
if response.status_code == 409:
|
||
|
|
raise ResourceProvisioningError(f"Resource '{resource_id}' already exists.")
|
||
|
|
|
||
|
|
if response.status_code != 201:
|
||
|
|
logger.error(
|
||
|
|
"InternalApi create resource returned %s: %s",
|
||
|
|
response.status_code,
|
||
|
|
response.text[:500],
|
||
|
|
)
|
||
|
|
raise ResourceProvisioningError("Failed to create resource principal.")
|
||
|
|
|
||
|
|
principal_uri = f"principals/resources/{resource_id}"
|
||
|
|
calendar_uri = f"calendars/resources/{resource_id}/default/"
|
||
|
|
|
||
|
|
return {
|
||
|
|
"id": resource_id,
|
||
|
|
"email": email,
|
||
|
|
"name": name,
|
||
|
|
"resource_type": resource_type,
|
||
|
|
"principal_uri": principal_uri,
|
||
|
|
"calendar_uri": calendar_uri,
|
||
|
|
}
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _validate_resource_id(resource_id):
|
||
|
|
"""Validate that resource_id is a proper UUID.
|
||
|
|
|
||
|
|
Raises ResourceProvisioningError if the ID is not a valid UUID,
|
||
|
|
preventing path traversal via crafted IDs.
|
||
|
|
"""
|
||
|
|
try:
|
||
|
|
UUID(str(resource_id))
|
||
|
|
except (ValueError, AttributeError) as e:
|
||
|
|
raise ResourceProvisioningError(
|
||
|
|
"Invalid resource ID: must be a valid UUID."
|
||
|
|
) from e
|
||
|
|
|
||
|
|
def delete_resource(self, user, resource_id):
|
||
|
|
"""Delete a resource principal and its calendar.
|
||
|
|
|
||
|
|
Events in user calendars that reference this resource are left
|
||
|
|
as-is — the resource address becomes unresolvable.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
user: The admin user requesting deletion.
|
||
|
|
resource_id: The resource UUID.
|
||
|
|
|
||
|
|
Raises:
|
||
|
|
ResourceProvisioningError on failure.
|
||
|
|
"""
|
||
|
|
self._validate_resource_id(resource_id)
|
||
|
|
|
||
|
|
if not settings.CALDAV_INTERNAL_API_KEY:
|
||
|
|
raise ResourceProvisioningError(
|
||
|
|
"CALDAV_INTERNAL_API_KEY is not configured."
|
||
|
|
)
|
||
|
|
|
||
|
|
try:
|
||
|
|
response = self._http.request(
|
||
|
|
"DELETE",
|
||
|
|
user,
|
||
|
|
f"internal-api/resources/{resource_id}",
|
||
|
|
extra_headers={
|
||
|
|
"X-Internal-Api-Key": settings.CALDAV_INTERNAL_API_KEY,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Failed to delete resource: %s", e)
|
||
|
|
raise ResourceProvisioningError("Failed to delete resource.") from e
|
||
|
|
|
||
|
|
if response.status_code == 404:
|
||
|
|
raise ResourceProvisioningError(f"Resource '{resource_id}' not found.")
|
||
|
|
|
||
|
|
if response.status_code == 403:
|
||
|
|
try:
|
||
|
|
error_msg = response.json().get("error", "")
|
||
|
|
except ValueError:
|
||
|
|
error_msg = ""
|
||
|
|
raise ResourceProvisioningError(
|
||
|
|
error_msg or "Cannot delete a resource from a different organization."
|
||
|
|
)
|
||
|
|
|
||
|
|
if response.status_code not in (200, 204):
|
||
|
|
logger.error(
|
||
|
|
"InternalApi delete resource returned %s: %s",
|
||
|
|
response.status_code,
|
||
|
|
response.text[:500],
|
||
|
|
)
|
||
|
|
raise ResourceProvisioningError("Failed to delete resource.")
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _json_bytes(data):
|
||
|
|
"""Serialize a dict to JSON bytes."""
|
||
|
|
return json.dumps(data).encode("utf-8")
|