bmc_hub/app/modules/nextcloud/backend/service.py
Christian 56d6d45aa2 feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00

241 lines
8.5 KiB
Python

"""
Nextcloud Integration Service
Direct OCS API calls with DB cache and audit logging.
"""
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, Optional
import aiohttp
from app.core.config import settings
from app.core.crypto import decrypt_secret
from app.core.database import execute_query
logger = logging.getLogger(__name__)
class NextcloudService:
def __init__(self) -> None:
self.read_only = settings.NEXTCLOUD_READ_ONLY
self.dry_run = settings.NEXTCLOUD_DRY_RUN
self.timeout = settings.NEXTCLOUD_TIMEOUT_SECONDS
self.cache_ttl = settings.NEXTCLOUD_CACHE_TTL_SECONDS
if self.read_only:
logger.warning("🔒 Nextcloud READ_ONLY MODE ENABLED")
elif self.dry_run:
logger.warning("🏃 Nextcloud DRY_RUN MODE ENABLED")
else:
logger.warning("⚠️ Nextcloud WRITE MODE ACTIVE")
def _get_instance(self, instance_id: int, customer_id: Optional[int] = None) -> Optional[dict]:
query = "SELECT * FROM nextcloud_instances WHERE id = %s AND deleted_at IS NULL"
params = [instance_id]
if customer_id is not None:
query += " AND customer_id = %s"
params.append(customer_id)
result = execute_query(query, tuple(params))
return result[0] if result else None
def _get_auth(self, instance: dict) -> Optional[aiohttp.BasicAuth]:
password = decrypt_secret(instance["password_encrypted"])
if not password:
return None
return aiohttp.BasicAuth(instance["username"], password)
def _cache_get(self, cache_key: str) -> Optional[dict]:
query = "SELECT payload FROM nextcloud_cache WHERE cache_key = %s AND expires_at > NOW()"
result = execute_query(query, (cache_key,))
if result:
return result[0]["payload"]
return None
def _cache_set(self, cache_key: str, payload: dict) -> None:
expires_at = datetime.utcnow() + timedelta(seconds=self.cache_ttl)
query = """
INSERT INTO nextcloud_cache (cache_key, payload, expires_at)
VALUES (%s, %s, %s)
ON CONFLICT (cache_key) DO UPDATE
SET payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at
"""
execute_query(query, (cache_key, json.dumps(payload), expires_at))
def _audit(
self,
customer_id: int,
instance_id: int,
event_type: str,
request_meta: dict,
response_meta: dict,
actor_user_id: Optional[int] = None,
) -> None:
query = """
INSERT INTO nextcloud_audit_log
(customer_id, instance_id, event_type, request_meta, response_meta, actor_user_id)
VALUES (%s, %s, %s, %s, %s, %s)
"""
execute_query(
query,
(
customer_id,
instance_id,
event_type,
json.dumps(request_meta),
json.dumps(response_meta),
actor_user_id,
),
)
def _check_write_permission(self, operation: str) -> bool:
if self.read_only:
logger.error("🚫 BLOCKED: %s - READ_ONLY mode is enabled", operation)
return False
if self.dry_run:
logger.warning("🏃 DRY_RUN: %s - Operation will not be executed", operation)
return False
logger.warning("⚠️ EXECUTING WRITE OPERATION: %s", operation)
return True
async def _ocs_request(
self,
instance: dict,
endpoint: str,
method: str = "GET",
params: Optional[dict] = None,
data: Optional[dict] = None,
use_cache: bool = True,
) -> dict:
cache_key = None
if use_cache and method.upper() == "GET":
cache_key = f"nextcloud:{instance['id']}:{endpoint}:{json.dumps(params or {}, sort_keys=True)}"
cached = self._cache_get(cache_key)
if cached:
cached["cache_hit"] = True
return cached
auth = self._get_auth(instance)
if not auth:
return {"error": "credentials_invalid"}
base_url = instance["base_url"].rstrip("/")
url = f"{base_url}/{endpoint.lstrip('/')}"
headers = {"OCS-APIRequest": "true", "Accept": "application/json"}
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=self.timeout)) as session:
async with session.request(
method=method.upper(),
url=url,
headers=headers,
auth=auth,
params=params,
data=data,
) as resp:
try:
payload = await resp.json()
except Exception:
payload = {"raw": await resp.text()}
response = {
"status": resp.status,
"payload": payload,
"cache_hit": False,
}
if cache_key and resp.status == 200:
self._cache_set(cache_key, response)
return response
async def get_status(self, instance_id: int, customer_id: Optional[int] = None) -> dict:
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:
return {"status": "offline", "checked_at": datetime.utcnow().isoformat()}
response = await self._ocs_request(
instance,
"/ocs/v2.php/apps/serverinfo/api/v1/info",
method="GET",
use_cache=True,
)
return {
"status": "online" if response.get("status") == 200 else "unknown",
"checked_at": datetime.utcnow().isoformat(),
"raw": response,
}
async def list_groups(self, instance_id: int, customer_id: Optional[int] = None) -> dict:
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:
return {"groups": []}
return await self._ocs_request(
instance,
"/ocs/v1.php/cloud/groups",
method="GET",
use_cache=True,
)
async def list_public_shares(self, instance_id: int, customer_id: Optional[int] = None) -> dict:
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:
return {"payload": {"ocs": {"data": []}}}
return await self._ocs_request(
instance,
"/ocs/v1.php/apps/files_sharing/api/v1/shares",
method="GET",
params={"share_type": 3},
use_cache=True,
)
async def create_user(self, instance_id: int, customer_id: Optional[int], payload: dict) -> dict:
if not self._check_write_permission("create_nextcloud_user"):
return {"blocked": True, "read_only": self.read_only, "dry_run": self.dry_run}
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:
return {"error": "instance_unavailable"}
return await self._ocs_request(
instance,
"/ocs/v1.php/cloud/users",
method="POST",
data=payload,
use_cache=False,
)
async def reset_password(self, instance_id: int, customer_id: Optional[int], uid: str, password: str) -> dict:
if not self._check_write_permission("reset_nextcloud_password"):
return {"blocked": True, "read_only": self.read_only, "dry_run": self.dry_run}
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:
return {"error": "instance_unavailable"}
return await self._ocs_request(
instance,
f"/ocs/v1.php/cloud/users/{uid}",
method="PUT",
data={"password": password},
use_cache=False,
)
async def disable_user(self, instance_id: int, customer_id: Optional[int], uid: str) -> dict:
if not self._check_write_permission("disable_nextcloud_user"):
return {"blocked": True, "read_only": self.read_only, "dry_run": self.dry_run}
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:
return {"error": "instance_unavailable"}
return await self._ocs_request(
instance,
f"/ocs/v1.php/cloud/users/{uid}/disable",
method="PUT",
use_cache=False,
)