This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Service layer for secretary-api."""
|
||||
@@ -0,0 +1,376 @@
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.dialects.mysql import insert as mysql_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import SsoPrincipal
|
||||
from app.db.models import (
|
||||
SupportRoleAssignment,
|
||||
SupportUser,
|
||||
UserWorkspaceAccess,
|
||||
Workspace,
|
||||
)
|
||||
|
||||
|
||||
class AccessService:
|
||||
ROLE_PRIORITY = {
|
||||
"SYSTEM_ADMIN": 5,
|
||||
"SUPER_ADMIN": 4,
|
||||
"PROJECT_MANAGER": 3,
|
||||
"FEEDBACK_PROVIDER": 2,
|
||||
"END_USER": 1,
|
||||
}
|
||||
ROLE_PERMISSIONS = {
|
||||
"SYSTEM_ADMIN": (True, True, True, True),
|
||||
"SUPER_ADMIN": (True, True, True, True),
|
||||
"PROJECT_MANAGER": (True, True, True, True),
|
||||
"FEEDBACK_PROVIDER": (True, True, False, False),
|
||||
"END_USER": (True, True, False, False),
|
||||
}
|
||||
GLOBAL_ADMIN_ROLES = {"SYSTEM_ADMIN", "SUPER_ADMIN"}
|
||||
MANAGER_ROLES = {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
|
||||
SUPPORTED_WORKSPACE_ROLES = {
|
||||
"PROJECT_MANAGER",
|
||||
"END_USER",
|
||||
"FEEDBACK_PROVIDER",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def default_user_workspace_code(db: Session) -> str | None:
|
||||
"""Return the first active ABC project workspace.
|
||||
|
||||
The old Q&A_Platform seed workspace must not determine the first SSO
|
||||
redirect after ABC projects are synchronized.
|
||||
"""
|
||||
return db.execute(
|
||||
select(Workspace.workspace_code)
|
||||
.where(
|
||||
Workspace.is_active.is_(True),
|
||||
Workspace.workspace_type == "SOFTWARE_APP",
|
||||
)
|
||||
.order_by(Workspace.id.asc()),
|
||||
).scalar()
|
||||
|
||||
@staticmethod
|
||||
def _upsert_support_user(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
email: str | None = None,
|
||||
name: str | None = None,
|
||||
department: str | None = None,
|
||||
phone_number: str | None = None,
|
||||
tenant_ids: list[str] | None = None,
|
||||
) -> SupportUser:
|
||||
"""Create or refresh an SSO identity without concurrent insert races.
|
||||
|
||||
The access endpoint is called several times in parallel during the
|
||||
first SSO redirect. A select-then-insert sequence can deadlock on the
|
||||
unique (sso_subject, tenant_id) index, so use MySQL's atomic upsert.
|
||||
Empty profile fields never erase values already synchronized.
|
||||
"""
|
||||
table = SupportUser.__table__
|
||||
statement = mysql_insert(table).values(
|
||||
sso_subject=user_id,
|
||||
tenant_id=tenant_id,
|
||||
email=email,
|
||||
name=name,
|
||||
department=department,
|
||||
phone_number=phone_number,
|
||||
tenant_ids=tenant_ids or None,
|
||||
)
|
||||
statement = statement.on_duplicate_key_update(
|
||||
email=func.coalesce(statement.inserted.email, table.c.email),
|
||||
name=func.coalesce(statement.inserted.name, table.c.name),
|
||||
department=func.coalesce(
|
||||
statement.inserted.department,
|
||||
table.c.department,
|
||||
),
|
||||
phone_number=func.coalesce(
|
||||
statement.inserted.phone_number,
|
||||
table.c.phone_number,
|
||||
),
|
||||
tenant_ids=func.coalesce(
|
||||
statement.inserted.tenant_ids,
|
||||
table.c.tenant_ids,
|
||||
),
|
||||
)
|
||||
db.execute(statement)
|
||||
return db.execute(
|
||||
select(SupportUser).where(
|
||||
SupportUser.sso_subject == user_id,
|
||||
SupportUser.tenant_id == tenant_id,
|
||||
),
|
||||
).scalar_one()
|
||||
|
||||
@staticmethod
|
||||
def ensure_support_user(db: Session, principal: SsoPrincipal) -> SupportUser:
|
||||
return AccessService._upsert_support_user(
|
||||
db,
|
||||
user_id=principal.user_id,
|
||||
tenant_id=principal.tenant_id,
|
||||
email=principal.email,
|
||||
name=principal.name,
|
||||
department=principal.department,
|
||||
phone_number=principal.phone_number,
|
||||
tenant_ids=principal.tenant_ids,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def ensure_support_user_identity(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
email: str | None = None,
|
||||
name: str | None = None,
|
||||
department: str | None = None,
|
||||
phone_number: str | None = None,
|
||||
tenant_ids: list[str] | None = None,
|
||||
) -> SupportUser:
|
||||
return AccessService._upsert_support_user(
|
||||
db,
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
email=email,
|
||||
name=name,
|
||||
department=department,
|
||||
phone_number=phone_number,
|
||||
tenant_ids=tenant_ids,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _role_codes(
|
||||
db: Session,
|
||||
user: SupportUser,
|
||||
workspace_id: int | None = None,
|
||||
*,
|
||||
global_only: bool = False,
|
||||
) -> list[str]:
|
||||
conditions = [SupportRoleAssignment.support_user_id == user.id]
|
||||
if global_only:
|
||||
# Global admin roles are global by role code. Older seed data may
|
||||
# still contain a workspace_id on these assignments.
|
||||
conditions.append(
|
||||
SupportRoleAssignment.role_code.in_(AccessService.GLOBAL_ADMIN_ROLES),
|
||||
)
|
||||
elif workspace_id is not None:
|
||||
# Only global administrator roles apply to every workspace. A
|
||||
# project-scoped role with a NULL workspace is not a shortcut to
|
||||
# global access.
|
||||
conditions.append(
|
||||
or_(
|
||||
SupportRoleAssignment.workspace_id == workspace_id,
|
||||
and_(
|
||||
SupportRoleAssignment.workspace_id.is_(None),
|
||||
SupportRoleAssignment.role_code.in_(AccessService.GLOBAL_ADMIN_ROLES),
|
||||
),
|
||||
),
|
||||
)
|
||||
return list(
|
||||
db.execute(
|
||||
select(SupportRoleAssignment.role_code).where(*conditions),
|
||||
).scalars().all(),
|
||||
)
|
||||
|
||||
def is_system_admin(self, db: Session, principal: SsoPrincipal) -> bool:
|
||||
"""Check only Secretary's internal global role assignments.
|
||||
|
||||
The ABC `users.type` claim is intentionally not consulted here. ABC's
|
||||
users/roles/members tables remain responsible for ABC's own console API.
|
||||
"""
|
||||
user = self.ensure_support_user(db, principal)
|
||||
return bool(
|
||||
set(self._role_codes(db, user, global_only=True))
|
||||
& AccessService.GLOBAL_ADMIN_ROLES,
|
||||
)
|
||||
|
||||
def _workspace_role(
|
||||
self,
|
||||
db: Session,
|
||||
user: SupportUser,
|
||||
workspace_id: int,
|
||||
) -> str | None:
|
||||
roles = self._role_codes(db, user, workspace_id)
|
||||
if not roles:
|
||||
return None
|
||||
return max(roles, key=lambda role: self.ROLE_PRIORITY.get(role, 0))
|
||||
|
||||
def _apply_role_permissions(
|
||||
self,
|
||||
access: UserWorkspaceAccess,
|
||||
role: str,
|
||||
) -> UserWorkspaceAccess:
|
||||
can_read, can_write, can_manage, can_approve = self.ROLE_PERMISSIONS.get(
|
||||
role,
|
||||
(False, False, False, False),
|
||||
)
|
||||
access.workspace_role = role
|
||||
access.can_read = can_read
|
||||
access.can_write = can_write
|
||||
access.can_manage = can_manage
|
||||
access.can_approve = can_approve
|
||||
return access
|
||||
|
||||
@staticmethod
|
||||
def _synthetic_access(
|
||||
principal: SsoPrincipal,
|
||||
workspace: Workspace,
|
||||
role: str,
|
||||
) -> UserWorkspaceAccess:
|
||||
access = UserWorkspaceAccess(
|
||||
user_id=principal.user_id,
|
||||
tenant_id=principal.tenant_id,
|
||||
workspace_id=workspace.id,
|
||||
workspace=workspace,
|
||||
)
|
||||
return AccessService()._apply_role_permissions(access, role)
|
||||
|
||||
def find_workspace_access(
|
||||
self,
|
||||
db: Session,
|
||||
principal: SsoPrincipal,
|
||||
workspace_code: str,
|
||||
default_workspace_code: str | None = None,
|
||||
) -> UserWorkspaceAccess | None:
|
||||
user = self.ensure_support_user(db, principal)
|
||||
workspace = db.execute(
|
||||
select(Workspace).where(
|
||||
Workspace.workspace_code == workspace_code,
|
||||
Workspace.is_active.is_(True),
|
||||
),
|
||||
).scalar_one_or_none()
|
||||
if workspace is None or user.status != "ACTIVE":
|
||||
return None
|
||||
|
||||
global_roles = set(self._role_codes(db, user, global_only=True))
|
||||
if global_roles & AccessService.GLOBAL_ADMIN_ROLES:
|
||||
return self._synthetic_access(principal, workspace, "SYSTEM_ADMIN")
|
||||
|
||||
role = self._workspace_role(db, user, workspace.id)
|
||||
if role is None:
|
||||
# A first-time user can submit feedback in the default workspace
|
||||
# without creating an END_USER role assignment. Role assignments
|
||||
# are reserved for explicit workspace administration.
|
||||
if workspace.workspace_code != (
|
||||
default_workspace_code or self.default_user_workspace_code(db)
|
||||
):
|
||||
return None
|
||||
role = "END_USER"
|
||||
|
||||
access = db.execute(
|
||||
select(UserWorkspaceAccess).where(
|
||||
UserWorkspaceAccess.user_id == principal.user_id,
|
||||
UserWorkspaceAccess.tenant_id == principal.tenant_id,
|
||||
UserWorkspaceAccess.workspace_id == workspace.id,
|
||||
),
|
||||
).scalar_one_or_none()
|
||||
if access is None:
|
||||
access = UserWorkspaceAccess(
|
||||
user_id=principal.user_id,
|
||||
tenant_id=principal.tenant_id,
|
||||
workspace_id=workspace.id,
|
||||
workspace=workspace,
|
||||
)
|
||||
else:
|
||||
access.workspace = workspace
|
||||
return self._apply_role_permissions(access, role)
|
||||
|
||||
def assert_workspace_access(
|
||||
self,
|
||||
db: Session,
|
||||
principal: SsoPrincipal,
|
||||
workspace_code: str,
|
||||
*,
|
||||
write: bool = False,
|
||||
) -> UserWorkspaceAccess:
|
||||
access = self.find_workspace_access(db, principal, workspace_code)
|
||||
if access is None:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="이 workspace에 대한 권한이 없습니다.")
|
||||
|
||||
if write and not access.can_write:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="이 workspace에 작성 권한이 없습니다.")
|
||||
if not write and not access.can_read:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="이 workspace를 조회할 권한이 없습니다.")
|
||||
return access
|
||||
|
||||
def list_accessible_workspaces(
|
||||
self,
|
||||
db: Session,
|
||||
principal: SsoPrincipal,
|
||||
default_workspace_code: str | None = None,
|
||||
) -> list[tuple[Workspace, UserWorkspaceAccess]]:
|
||||
default_workspace_code = (
|
||||
default_workspace_code or self.default_user_workspace_code(db)
|
||||
)
|
||||
workspaces = db.execute(
|
||||
select(Workspace)
|
||||
.where(Workspace.is_active.is_(True))
|
||||
.order_by(Workspace.id.asc()),
|
||||
).scalars().all()
|
||||
entries: list[tuple[Workspace, UserWorkspaceAccess]] = []
|
||||
for workspace in workspaces:
|
||||
access = self.find_workspace_access(
|
||||
db,
|
||||
principal,
|
||||
workspace.workspace_code,
|
||||
default_workspace_code=default_workspace_code,
|
||||
)
|
||||
if access is not None and (access.can_read or access.can_manage):
|
||||
entries.append((workspace, access))
|
||||
return entries
|
||||
|
||||
def ensure_default_support_access(
|
||||
self,
|
||||
db: Session,
|
||||
principal: SsoPrincipal,
|
||||
default_workspace_code: str | None = None,
|
||||
) -> list[tuple[Workspace, UserWorkspaceAccess]]:
|
||||
user = self.ensure_support_user(db, principal)
|
||||
if self.is_system_admin(db, principal):
|
||||
return self.list_accessible_workspaces(
|
||||
db,
|
||||
principal,
|
||||
default_workspace_code=default_workspace_code,
|
||||
)
|
||||
|
||||
default_workspace_code = (
|
||||
default_workspace_code or self.default_user_workspace_code(db)
|
||||
)
|
||||
if default_workspace_code is None:
|
||||
return []
|
||||
|
||||
workspace = db.execute(
|
||||
select(Workspace).where(
|
||||
Workspace.workspace_code == default_workspace_code,
|
||||
Workspace.is_active.is_(True),
|
||||
),
|
||||
).scalar_one_or_none()
|
||||
if workspace is None:
|
||||
return []
|
||||
|
||||
role = self._workspace_role(db, user, workspace.id) or "END_USER"
|
||||
|
||||
access = db.execute(
|
||||
select(UserWorkspaceAccess).where(
|
||||
UserWorkspaceAccess.user_id == principal.user_id,
|
||||
UserWorkspaceAccess.tenant_id == principal.tenant_id,
|
||||
UserWorkspaceAccess.workspace_id == workspace.id,
|
||||
),
|
||||
).scalar_one_or_none()
|
||||
if access is None:
|
||||
access = UserWorkspaceAccess(
|
||||
user_id=principal.user_id,
|
||||
tenant_id=principal.tenant_id,
|
||||
workspace_id=workspace.id,
|
||||
)
|
||||
db.add(access)
|
||||
access.workspace = workspace
|
||||
self._apply_role_permissions(access, role)
|
||||
db.commit()
|
||||
db.refresh(access)
|
||||
access.workspace = workspace
|
||||
return [(workspace, access)]
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class StorageService:
|
||||
"""Small S3-compatible adapter used for R2-backed support attachments."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._client: Any | None = None
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return settings.is_r2_enabled
|
||||
|
||||
def _get_client(self) -> Any:
|
||||
if not self.enabled or not settings.has_r2_config:
|
||||
raise RuntimeError("R2 storage is not fully configured.")
|
||||
|
||||
if self._client is None:
|
||||
try:
|
||||
import boto3
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"R2 storage requires the boto3 dependency."
|
||||
) from exc
|
||||
|
||||
self._client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.r2_endpoint,
|
||||
aws_access_key_id=settings.r2_access_key_id,
|
||||
aws_secret_access_key=settings.r2_secret_access_key,
|
||||
region_name=settings.r2_region,
|
||||
)
|
||||
return self._client
|
||||
|
||||
def put_bytes(self, key: str, body: bytes, content_type: str | None) -> None:
|
||||
self._get_client().put_object(
|
||||
Bucket=settings.r2_bucket,
|
||||
Key=key,
|
||||
Body=body,
|
||||
ContentType=content_type or "application/octet-stream",
|
||||
)
|
||||
|
||||
def get_bytes(self, key: str, bucket: str | None = None) -> bytes:
|
||||
response = self._get_client().get_object(
|
||||
Bucket=bucket or settings.r2_bucket,
|
||||
Key=key,
|
||||
)
|
||||
return response["Body"].read()
|
||||
|
||||
def delete(self, key: str, bucket: str | None = None) -> None:
|
||||
self._get_client().delete_object(
|
||||
Bucket=bucket or settings.r2_bucket,
|
||||
Key=key,
|
||||
)
|
||||
|
||||
|
||||
storage_service = StorageService()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,318 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from urllib import error, parse, request
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.dialects.mysql import insert as mysql_insert
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.models import Workspace, WorkspaceChannelMapping
|
||||
|
||||
|
||||
class WorkspaceMappingService:
|
||||
def _fetch_remote_mappings(
|
||||
self,
|
||||
workspace_codes: list[str],
|
||||
) -> dict[str, dict[str, object]]:
|
||||
if not workspace_codes or not settings.master_api_key:
|
||||
return {}
|
||||
|
||||
query = parse.urlencode({"workspaceCodes": ",".join(workspace_codes)})
|
||||
endpoint = (
|
||||
f"{settings.abc_api_base_url.rstrip('/')}/api/internal/support/workspace-mappings"
|
||||
f"?{query}"
|
||||
)
|
||||
req = request.Request(
|
||||
endpoint,
|
||||
headers={"x-api-key": settings.master_api_key},
|
||||
method="GET",
|
||||
)
|
||||
|
||||
try:
|
||||
with request.urlopen(req, timeout=5) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except (error.HTTPError, error.URLError, TimeoutError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
raw_items = payload.get("items") if isinstance(payload, dict) else None
|
||||
if not isinstance(raw_items, list):
|
||||
return {}
|
||||
|
||||
mappings: dict[str, dict[str, object]] = {}
|
||||
for item in raw_items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
workspace_code = item.get("workspace_code")
|
||||
project_id = item.get("project_id")
|
||||
channel_id = item.get("channel_id")
|
||||
if (
|
||||
not isinstance(workspace_code, str)
|
||||
or not isinstance(project_id, int)
|
||||
or not isinstance(channel_id, int)
|
||||
or project_id <= 0
|
||||
or channel_id <= 0
|
||||
):
|
||||
continue
|
||||
mappings[workspace_code] = item
|
||||
return mappings
|
||||
|
||||
def _fetch_remote_projects(self) -> list[dict[str, object]] | None:
|
||||
if not settings.master_api_key:
|
||||
return None
|
||||
|
||||
endpoint = (
|
||||
f"{settings.abc_api_base_url.rstrip('/')}/api/internal/support/projects"
|
||||
)
|
||||
req = request.Request(
|
||||
endpoint,
|
||||
headers={"x-api-key": settings.master_api_key},
|
||||
method="GET",
|
||||
)
|
||||
|
||||
try:
|
||||
with request.urlopen(req, timeout=5) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except (error.HTTPError, error.URLError, TimeoutError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
raw_items = payload.get("items") if isinstance(payload, dict) else None
|
||||
if not isinstance(raw_items, list):
|
||||
return None
|
||||
|
||||
projects: list[dict[str, object]] = []
|
||||
for item in raw_items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
project_id = item.get("project_id")
|
||||
project_name = item.get("project_name")
|
||||
channel_id = item.get("channel_id")
|
||||
channel_name = item.get("channel_name")
|
||||
if (
|
||||
not isinstance(project_id, int)
|
||||
or project_id <= 0
|
||||
or not isinstance(project_name, str)
|
||||
or not project_name.strip()
|
||||
or len(project_name) > 50
|
||||
):
|
||||
continue
|
||||
projects.append(
|
||||
{
|
||||
"project_id": project_id,
|
||||
"project_name": project_name.strip(),
|
||||
"channel_id": channel_id if isinstance(channel_id, int) else None,
|
||||
"channel_name": channel_name if isinstance(channel_name, str) else None,
|
||||
},
|
||||
)
|
||||
return projects
|
||||
|
||||
def sync_project_workspaces(self, db: Session) -> list[str] | None:
|
||||
"""Mirror ABC projects as selectable Secretary workspaces.
|
||||
|
||||
Project creation remains owned by ABC. The management/access APIs call
|
||||
this lightweight synchronization before returning workspace options, so
|
||||
newly created projects become assignable without hard-coded seed data.
|
||||
|
||||
``None`` means the ABC project list could not be fetched. An empty
|
||||
list is a successful response with no projects.
|
||||
"""
|
||||
projects = self._fetch_remote_projects()
|
||||
if projects is None:
|
||||
return None
|
||||
|
||||
project_codes = list(
|
||||
dict.fromkeys(str(project["project_name"]) for project in projects),
|
||||
)
|
||||
|
||||
# Keep the Secretary workspace catalog aligned with ABC. Older seed
|
||||
# rows such as Q&A_Platform must not become the default SSO target
|
||||
# after the corresponding ABC project no longer exists.
|
||||
stale_query = db.query(Workspace).filter(
|
||||
Workspace.workspace_type == "SOFTWARE_APP",
|
||||
Workspace.is_active.is_(True),
|
||||
)
|
||||
if project_codes:
|
||||
stale_query = stale_query.filter(
|
||||
~Workspace.workspace_code.in_(project_codes),
|
||||
)
|
||||
stale_count = stale_query.update(
|
||||
{Workspace.is_active: False},
|
||||
synchronize_session=False,
|
||||
)
|
||||
if not projects:
|
||||
db.commit()
|
||||
return project_codes
|
||||
|
||||
changed = 0
|
||||
for project in projects:
|
||||
project_name = str(project["project_name"])
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO software_apps
|
||||
(app_code, app_name, description, is_active)
|
||||
VALUES (:app_code, :app_name, :description, 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
app_name = VALUES(app_name),
|
||||
description = VALUES(description),
|
||||
is_active = 1
|
||||
""",
|
||||
),
|
||||
{
|
||||
"app_code": project_name,
|
||||
"app_name": project_name,
|
||||
"description": f"{project_name} Q&A",
|
||||
},
|
||||
)
|
||||
software_app_id = db.execute(
|
||||
text("SELECT id FROM software_apps WHERE app_code = :app_code"),
|
||||
{"app_code": project_name},
|
||||
).scalar_one()
|
||||
|
||||
statement = mysql_insert(Workspace.__table__).values(
|
||||
workspace_type="SOFTWARE_APP",
|
||||
software_app_id=int(software_app_id),
|
||||
service_type_id=None,
|
||||
workspace_code=project_name,
|
||||
workspace_name=project_name,
|
||||
is_active=True,
|
||||
)
|
||||
statement = statement.on_duplicate_key_update(
|
||||
software_app_id=statement.inserted.software_app_id,
|
||||
workspace_name=statement.inserted.workspace_name,
|
||||
is_active=True,
|
||||
)
|
||||
db.execute(statement)
|
||||
workspace = db.execute(
|
||||
select(Workspace).where(Workspace.workspace_code == project_name),
|
||||
).scalar_one()
|
||||
changed += 1
|
||||
|
||||
channel_id = project["channel_id"]
|
||||
if not isinstance(channel_id, int) or channel_id <= 0:
|
||||
continue
|
||||
|
||||
existing = db.execute(
|
||||
select(WorkspaceChannelMapping)
|
||||
.where(
|
||||
WorkspaceChannelMapping.workspace_id == workspace.id,
|
||||
WorkspaceChannelMapping.is_active.is_(True),
|
||||
)
|
||||
.order_by(WorkspaceChannelMapping.id.asc()),
|
||||
).scalars().first()
|
||||
if existing is not None:
|
||||
existing.abc_project_id = int(project["project_id"])
|
||||
existing.abc_channel_id = str(channel_id)
|
||||
existing.abc_channel_key = str(project.get("channel_name") or "")
|
||||
continue
|
||||
|
||||
mapping_statement = mysql_insert(WorkspaceChannelMapping.__table__).values(
|
||||
workspace_id=workspace.id,
|
||||
abc_project_id=int(project["project_id"]),
|
||||
abc_channel_id=str(channel_id),
|
||||
abc_channel_key=str(project.get("channel_name") or ""),
|
||||
is_active=True,
|
||||
)
|
||||
mapping_statement = mapping_statement.on_duplicate_key_update(
|
||||
abc_project_id=mapping_statement.inserted.abc_project_id,
|
||||
abc_channel_key=mapping_statement.inserted.abc_channel_key,
|
||||
is_active=True,
|
||||
)
|
||||
db.execute(mapping_statement)
|
||||
|
||||
if changed or stale_count:
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# Another access request may have synchronized the same
|
||||
# project concurrently. Its committed row is authoritative.
|
||||
db.rollback()
|
||||
|
||||
return project_codes
|
||||
|
||||
def sync_missing_mappings(
|
||||
self,
|
||||
db: Session,
|
||||
workspace_codes: list[str] | None = None,
|
||||
) -> int:
|
||||
statement = select(Workspace).where(Workspace.is_active.is_(True))
|
||||
if workspace_codes:
|
||||
statement = statement.where(Workspace.workspace_code.in_(workspace_codes))
|
||||
workspaces = db.execute(statement.order_by(Workspace.id.asc())).scalars().all()
|
||||
if not workspaces:
|
||||
return 0
|
||||
|
||||
missing_workspaces: list[Workspace] = []
|
||||
for workspace in workspaces:
|
||||
mappings = db.execute(
|
||||
select(WorkspaceChannelMapping)
|
||||
.where(
|
||||
WorkspaceChannelMapping.workspace_id == workspace.id,
|
||||
WorkspaceChannelMapping.is_active.is_(True),
|
||||
)
|
||||
.order_by(WorkspaceChannelMapping.id.asc()),
|
||||
).scalars().all()
|
||||
has_valid_mapping = any(
|
||||
mapping.abc_project_id is not None
|
||||
and int(mapping.abc_project_id) > 0
|
||||
and str(mapping.abc_channel_id).isdigit()
|
||||
and int(mapping.abc_channel_id) > 0
|
||||
for mapping in mappings
|
||||
)
|
||||
if not has_valid_mapping:
|
||||
missing_workspaces.append(workspace)
|
||||
|
||||
if not missing_workspaces:
|
||||
return 0
|
||||
|
||||
remote_mappings = self._fetch_remote_mappings(
|
||||
[workspace.workspace_code for workspace in missing_workspaces],
|
||||
)
|
||||
changed = 0
|
||||
|
||||
for workspace in missing_workspaces:
|
||||
remote = remote_mappings.get(workspace.workspace_code)
|
||||
if remote is None:
|
||||
continue
|
||||
|
||||
project_id = int(remote["project_id"])
|
||||
channel_id = str(remote["channel_id"])
|
||||
existing = db.execute(
|
||||
select(WorkspaceChannelMapping)
|
||||
.where(
|
||||
WorkspaceChannelMapping.workspace_id == workspace.id,
|
||||
WorkspaceChannelMapping.is_active.is_(True),
|
||||
)
|
||||
.order_by(WorkspaceChannelMapping.id.asc()),
|
||||
).scalars().first()
|
||||
|
||||
if existing is None:
|
||||
existing = WorkspaceChannelMapping(
|
||||
workspace_id=workspace.id,
|
||||
abc_project_id=project_id,
|
||||
abc_channel_id=channel_id,
|
||||
abc_channel_key=str(remote.get("channel_name") or ""),
|
||||
is_active=True,
|
||||
)
|
||||
db.add(existing)
|
||||
else:
|
||||
existing.abc_project_id = project_id
|
||||
existing.abc_channel_id = channel_id
|
||||
existing.abc_channel_key = str(remote.get("channel_name") or "")
|
||||
existing.is_active = True
|
||||
changed += 1
|
||||
|
||||
if changed:
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# Another request may have synchronized the same workspace
|
||||
# between our read and insert. Keep the request successful.
|
||||
db.rollback()
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
workspace_mapping_service = WorkspaceMappingService()
|
||||
Reference in New Issue
Block a user