204000aabc
Resolve 3 merge conflicts and renumber the automation_hooks migration to follow main's migration chain (036_add_document_translation_fields). Conflicts resolved: - app/api/__init__.py: add automation_router alongside main's new routers - app/utils/settings_service.py: add automation_hooks_enabled alongside compliance_enabled - tests/conftest.py: add AutomationHook alongside AuditLog/ComplianceTemplate imports Migration renumbered: - 027_add_automation_hooks → 037_add_automation_hooks - down_revision: 026_add_scheduled_jobs → 036_add_document_translation_fields Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
178 lines
6.2 KiB
Python
178 lines
6.2 KiB
Python
#!/usr/bin/env python3
|
||
|
||
"""Upload files to Apple iCloud Drive via the pyicloud library.
|
||
|
||
This module uses the ``pyicloud`` library to authenticate with Apple's iCloud
|
||
service and upload files to iCloud Drive. Because Apple does not offer a public
|
||
REST API for iCloud Drive, this integration relies on the *unofficial*
|
||
reverse-engineered protocol implemented by ``pyicloud``.
|
||
|
||
Requirements
|
||
~~~~~~~~~~~~
|
||
* An Apple ID with iCloud Drive enabled.
|
||
* An **app-specific password** generated at https://appleid.apple.com (required
|
||
when two-factor authentication is active – which is the default for all modern
|
||
Apple IDs).
|
||
* The ``pyicloud`` Python package (``pip install pyicloud``).
|
||
|
||
Configuration
|
||
~~~~~~~~~~~~~
|
||
Set the following environment variables (or ``app/config.py`` fields):
|
||
|
||
* ``ICLOUD_USERNAME`` – Apple ID email address.
|
||
* ``ICLOUD_PASSWORD`` – App-specific password.
|
||
* ``ICLOUD_FOLDER`` – Target folder path inside iCloud Drive, using ``/`` as
|
||
the separator (e.g. ``Documents/Uploads``). The folder is created
|
||
automatically if it does not exist.
|
||
* ``ICLOUD_COOKIE_DIRECTORY`` – (Optional) Directory for persisting session
|
||
cookies so that re-authentication is avoided between task runs. Defaults to
|
||
``~/.pyicloud``.
|
||
"""
|
||
|
||
import logging
|
||
import os
|
||
|
||
from app.celery_app import celery
|
||
from app.config import settings
|
||
from app.tasks.retry_config import UploadTaskWithRetry
|
||
from app.utils import log_task_progress
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _get_icloud_api(
|
||
username: str,
|
||
password: str,
|
||
cookie_directory: str | None = None,
|
||
):
|
||
"""Return an authenticated ``PyiCloudService`` instance.
|
||
|
||
Args:
|
||
username: Apple ID email address.
|
||
password: App-specific password.
|
||
cookie_directory: Optional directory for session cookies.
|
||
|
||
Returns:
|
||
An authenticated ``PyiCloudService`` instance.
|
||
|
||
Raises:
|
||
ImportError: If ``pyicloud`` is not installed.
|
||
ValueError: If authentication fails or 2FA is required interactively.
|
||
"""
|
||
from pyicloud import PyiCloudService # noqa: S404 – unofficial third-party iCloud client
|
||
|
||
kwargs: dict = {}
|
||
if cookie_directory:
|
||
kwargs["cookie_directory"] = cookie_directory
|
||
|
||
api = PyiCloudService(username, password, **kwargs)
|
||
|
||
# If 2SA/2FA is required the user must use an app-specific password instead.
|
||
if api.requires_2sa or api.requires_2fa:
|
||
raise ValueError(
|
||
"iCloud account requires two-factor authentication. "
|
||
"Please generate an app-specific password at https://appleid.apple.com "
|
||
"and use it as ICLOUD_PASSWORD."
|
||
)
|
||
|
||
return api
|
||
|
||
|
||
def _navigate_to_folder(drive_root, folder_path: str):
|
||
"""Navigate into (or create) the folder hierarchy described by *folder_path*.
|
||
|
||
Args:
|
||
drive_root: The iCloud Drive root node (``api.drive``).
|
||
folder_path: ``/``-separated path such as ``Documents/Uploads``.
|
||
|
||
Returns:
|
||
The drive node representing the target folder.
|
||
"""
|
||
node = drive_root
|
||
if not folder_path:
|
||
return node
|
||
|
||
parts = [p for p in folder_path.strip("/").split("/") if p]
|
||
for part in parts:
|
||
children = {child.name: child for child in node.dir()}
|
||
if part in children:
|
||
node = children[part]
|
||
else:
|
||
# Create the missing folder
|
||
node = node.mkdir(part)
|
||
return node
|
||
|
||
|
||
@celery.task(base=UploadTaskWithRetry, bind=True)
|
||
def upload_to_icloud(self, file_path: str, file_id: int = None, folder_override: str = None):
|
||
"""Upload a file to Apple iCloud Drive.
|
||
|
||
Args:
|
||
file_path: Local path to the file to upload.
|
||
file_id: Optional ``FileRecord.id`` for progress logging.
|
||
folder_override: If provided, overrides the default ``ICLOUD_FOLDER``
|
||
setting for this upload.
|
||
"""
|
||
task_id = self.request.id
|
||
logger.info(f"[{task_id}] Starting iCloud Drive upload: {file_path}")
|
||
log_task_progress(
|
||
task_id,
|
||
"upload_to_icloud",
|
||
"in_progress",
|
||
f"Uploading to iCloud Drive: {os.path.basename(file_path)}",
|
||
file_id=file_id,
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Validate inputs
|
||
# ------------------------------------------------------------------
|
||
if not os.path.exists(file_path):
|
||
error_msg = f"File not found: {file_path}"
|
||
logger.error(f"[{task_id}] {error_msg}")
|
||
log_task_progress(task_id, "upload_to_icloud", "failure", error_msg, file_id=file_id)
|
||
raise FileNotFoundError(error_msg)
|
||
|
||
if not settings.icloud_username or not settings.icloud_password:
|
||
error_msg = "iCloud credentials are not configured (ICLOUD_USERNAME / ICLOUD_PASSWORD)"
|
||
logger.error(f"[{task_id}] {error_msg}")
|
||
log_task_progress(task_id, "upload_to_icloud", "failure", error_msg, file_id=file_id)
|
||
raise ValueError(error_msg)
|
||
|
||
filename = os.path.basename(file_path)
|
||
target_folder = folder_override if folder_override is not None else (settings.icloud_folder or "")
|
||
|
||
# ------------------------------------------------------------------
|
||
# Authenticate & upload
|
||
# ------------------------------------------------------------------
|
||
try:
|
||
api = _get_icloud_api(
|
||
settings.icloud_username,
|
||
settings.icloud_password,
|
||
settings.icloud_cookie_directory,
|
||
)
|
||
|
||
folder_node = _navigate_to_folder(api.drive, target_folder)
|
||
|
||
with open(file_path, "rb") as fh:
|
||
folder_node.upload(fh)
|
||
|
||
logger.info(f"[{task_id}] Successfully uploaded {filename} to iCloud Drive folder '{target_folder}'")
|
||
log_task_progress(
|
||
task_id,
|
||
"upload_to_icloud",
|
||
"success",
|
||
f"Uploaded to iCloud Drive: {filename}",
|
||
file_id=file_id,
|
||
)
|
||
return {
|
||
"status": "Completed",
|
||
"file": file_path,
|
||
"icloud_folder": target_folder or "/",
|
||
}
|
||
|
||
except Exception as e:
|
||
error_msg = f"Error uploading {filename} to iCloud Drive: {e}"
|
||
logger.error(f"[{task_id}] {error_msg}")
|
||
log_task_progress(task_id, "upload_to_icloud", "failure", error_msg, file_id=file_id)
|
||
raise RuntimeError(error_msg) from e
|